Skip to content

[Bug]: DeepSeek reasoning models crash with HTTP 400 on multi-turn sessions — reasoning_content dropped during session reload #17825

@bugyfuture

Description

@bugyfuture

Bug Description

When using DeepSeek reasoning models (V4-Pro, R1, Reasoner) as the primary model, multi-turn conversations crash with HTTP 400 on the second turn and beyond:

Non-retryable client error: Error code: 400 - {'error': {'message':
'The reasoning_content in the thinking mode must be passed back to the API.'}}

Steps to Reproduce

  1. Configure DeepSeek V4 Pro as the primary model with base_url: https://api.deepseek.com/v1 (the correct endpoint — /anthropic has its own separate issue)
    2. Start a conversation — turn 1 succeeds ✅
    3. Send a second message in the same session — HTTP 400 ❌

Expected Behavior

This causes a chain failure:

1. Turn 1: DeepSeek returns reasoning_content → saved to DB ✅
2. Turn 2: session reloaded from DB → reasoning_content stripped ❌
3. Turn 2 runs: new response has reasoning_content, but prior assistant messages don't → HTTP 400

The _copy_reasoning_content_for_api() gap-fix in run_agent.py can't help here — it operates on api_messages, not the base history that gets built from DB reload.

Actual Behavior

On the first message in a session, everything works normally — DeepSeek responds with reasoning and a final answer. On the second message (same session), the agent crashes with a non-retryable HTTP 400:

Non-retryable client error: Error code: 400 - {'error': {'message':
'The reasoning_content in the thinking mode must be passed back to the API.',
'type': 'invalid_request_error', 'param': None, 'code': 'invalid_request_error'}}


The crash happens inside the gateway's agent loop. From ~/.hermes/logs/errors.log:


ERROR    gateway.run:_run_agent:9847 — Non-retryable client error (HTTP 400):
{'error': {'message': 'The reasoning_content in the thinking mode must be
passed back to the API.', 'type': 'invalid_request_error', 'param': None,
'code': 'invalid_request_error'}}


The session is dead at this point — every subsequent message in that session produces the same 400 because prior assistant turns are missing reasoning_content and DeepSeek refuses to process a history where some assistant messages have it and others don't.

Affected Component

CLI (interactive chat)

Messaging Platform (if gateway-related)

No response

Debug Report

Environment


    Repository:  https://github.com/NousResearch/hermes-agent.git
    Upstream HEAD: 627abbb1e (chore(release): map davidvv in AUTHOR_MAP)
    Local changes: gateway/run.py (2-line fix), run_agent.py (gap-filler)
    Provider:      DeepSeek (custom, base_url: https://api.deepseek.com/v1)
    Model:         deepseek-v4-pro


    Error Output

    From ~/.hermes/logs/errors.log — multiple sessions, same crash pattern:


    2026-04-25 10:23:30,754 ERROR [20260425_064607_5cbb93af] root:
      Non-retryable client error: Error code: 400 -
      {'error': {'message': 'The reasoning_content in the thinking mode
       must be passed back to the API.', 'type': 'invalid_request_error',
       'param': None, 'code': 'invalid_request_error'}}

    2026-04-25 10:27:22,828 ERROR [20260425_064607_5cbb93af] root:
      (same session, retry — same 400)

    2026-04-25 10:30:09,120 ERROR [20260425_102739_d592f4f1] root:
      (new session, second turn — same 400)

    2026-04-27 08:05:38,043 WARNING [20260427_080149_e689a3] root:
      Failed to get summary response: Error code: 400 -
      'The reasoning_content in the thinking mode must be passed back to the API.'

    2026-04-28 15:08:09,337 WARNING [20260428_142857_d1ae44] root:
      Failed to get summary response: Error code: 400 -
      'The reasoning_content in the thinking mode must be passed back to the API.'


    Error hits both the main agent loop AND the session summarizer — because both go through _run_agent → agent_history rebuild.

    The Broken Pipeline (Step by Step)


    TURN 1:
      DeepSeek API → returns reasoning_content: "Let me think..."
      run_agent.py → stores in msg["reasoning_content"]
      session.py    → saves to SQLite ✅ (line 1151 — reasoning_content IS in schema)
      session.py    → writes to JSONL transcript ✅

    TURN 2:
      session.py    → reads history from SQLite ✅ (line 1183 — reasoning_content IS loaded)
      run.py:9940   → rebuilds agent_history from loaded messages
                      ❌ DROPS reasoning_content — not in the preserved-fields tuple
      agent_history → [{role:"user",...}, {role:"assistant", content:"...", reasoning:...}]
                      ^ reasoning_content is ABSENT
      run_agent.py  → _copy_reasoning_content_for_api() gap-filler fires
                      BUT: it copies from msg["reasoning_content"] (which is missing)
                      → nothing to copy, gap remains
      DeepSeek API  → sees: prior assistant turn WITHOUT reasoning_content
                      current turn WITH reasoning_content
                      → HTTP 400: "reasoning_content must be passed back"

    ALL SUBSEQUENT TURNS: same 400 — session is permanently broken


    What Each File Does (and Doesn't Do)

    | File | Saves reasoning_content? | Loads reasoning_content? | Rebuilds history with it? |
    |---|---|---|---|
    | gateway/session.py | ✅ line 1151 | ✅ line 1183 | N/A |
    | gateway/run.py | ❌ line 9940 | N/A | ❌ dropped |
    | run_agent.py | ✅ stores in msg dict | ✅ gap-filler at 7259 | ✅ but operates on already-broken agent_history |

    The problem is in gateway/run.py alone. Session I/O is fine. The gap-filler in run_agent.py is correct but helpless — it receives history that was already stripped during the DB→agent_history rebuild.

    The Fix (Unified Diff)

    diff
    --- a/gateway/run.py
    +++ b/gateway/run.py
    @@ -9937,10 +9937,11 @@
                             if role == "assistant":
    -                            for _rkey in ("reasoning", "reasoning_details",
    +                            for _rkey in ("reasoning", "reasoning_content",
    +                                          "reasoning_details",
                                               "codex_reasoning_items"):
                                     _rval = msg.get(_rkey)
    -                                if _rval:
    +                                if _rval is not None:
                                         entry[_rkey] = _rval


    Change 1: Add "reasoning_content" — this is DeepSeek's native reasoning field, stored correctly by session.py but dropped during history rebuild.

    Change 2: if _rval: → if _rval is not None: — the gap-filler in run_agent.py sets reasoning_content to "" on tool-call turns that lack it. if _rval evaluates "" as falsy and drops it again, causing the same 400 on the next turn. is not None preserves empty strings through the DB round-trip.

    Reproducibility

    100% reproducible. Any multi-turn DeepSeek session on unpatched main will crash on turn 2. The first message always succeeds (no prior history to violate), then the session is dead. Affects all DeepSeek reasoning models (V4-Pro, R1, Reasoner) via both /v1 and /anthropic endpoints.

Operating System

Ubuntu 24.04

Python Version

3.11

Hermes Version

No response

Additional Logs / Traceback (optional)

Root Cause Analysis (optional)

In gateway/run.py (~line 9939), when the gateway reloads session history from the database (_run_agent, building agent_history), it preserves reasoning fields on assistant messages — but reasoning_content is missing from the list:

python
Current upstream code (BROKEN):
if role == "assistant":
    for _rkey in ("reasoning",              # ← preserved
                  "reasoning_details",       # ← preserved
                  "codex_reasoning_items"):  # ← preserved
        _rval = msg.get(_rkey)
        if _rval:                            # ← falsy guard skips ""
            entry[_rkey] = _rval
reasoning_content is SILENTLY DROPPED


This causes a chain failure:

1. Turn 1: DeepSeek returns reasoning_content → saved to DB ✅
2. Turn 2: session reloaded from DB → reasoning_content stripped ❌
3. Turn 2 runs: new response has reasoning_content, but prior assistant messages don't → HTTP 400

The _copy_reasoning_content_for_api() gap-fix in run_agent.py can't help here — it operates on api_messages, not the base history that gets built from DB reload.

Proposed Fix (optional)

Two changes in gateway/run.py:

python
FIXED:
if role == "assistant":
    for _rkey in ("reasoning", "reasoning_content",   # ← ADDED
                  "reasoning_details",
                  "codex_reasoning_items"):
        _rval = msg.get(_rkey)
        if _rval is not None:                         # ← was: if _rval:
            entry[_rkey] = _rval


1. Add "reasoning_content" to the preserved fields tuple — this is DeepSeek's native reasoning field
2. Change if _rval: to if _rval is not None: — the gap-filler in run_agent.py sets reasoning_content to "" (empty string) on turns that lack it, and if _rval evaluates "" as falsy, dropping it again

Why is not None matters

DeepSeek requires reasoning_content to be present on every assistant message when the model is in thinking mode — even on tool-call turns where the field is empty string. The gap-fixer in run_agent.py fills missing ones with "", but without the is not None guard, empty strings are lost in the DB round-trip, causing the same 400.

Tested

Verified live against api.deepseek.com/v1:

| Scenario | Result |
|---|---|
| reasoning_content present on all turns | OK |
| reasoning_content missing on prior turns | HTTP 400 |
| Tool calls + reasoning_content | OK |
| Tool calls without reasoning_content | HTTP 400 |

Related

There's also a gap-filler in run_agent.py (_copy_reasoning_content_for_api()) that fills missing reasoning_content with "" on outgoing API messages. That fix is necessary but insufficient — it can't compensate for the DB reload stripping the field entirely from history.

Environment

- Hermes Agent version: up to current main
- Provider: DeepSeek (any reasoning model: V4-Pro, R1, Reasoner)
- Config: base_url: https://api.deepseek.com/v1, provider: custom

Are you willing to submit a PR for this?

  • I'd like to fix this myself and submit a PR

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Low — cosmetic, nice to havecomp/agentCore agent loop, run_agent.py, prompt buildercomp/gatewayGateway runner, session dispatch, deliveryduplicateThis issue or pull request already existsprovider/deepseekDeepSeek APItype/bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions