Strip reasoning_content for non-thinking providers

NousResearch/hermes-agent · cluster 11089 · agent

Data as of

Nothing open on GitHub. Fix merged in #50480. Check it covers your case.

The short answer

0open issues
0open PRs
12items grouped
Known?
Yes. 12 related items grouped here, 5 issues already closed.
Fix event
A fix was merged to main in PR #50480. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

A bug where reasoning_content fields in conversation history cause HTTP 400 errors on strict providers like Cerebras and Groq, along with multiple proposed fixes to strip those fields during cross-provider fallback or multi-turn replay.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #50480 fix(agent): strip stale reasoning_content when falling back to a strict provider

    merged to main

    files: agent/agent_runtime_helpers.py, tests/run_agent/test_deepseek_reasoning_content_echo.py, tests/run_agent/test_run_agent.py

    Show the diff
    • agent/agent_runtime_helpers.py
      @@ -2202,25 +2202,36 @@ def copy_reasoning_content_for_api(agent, source_msg: dict, api_msg: dict) -> No
           if source_msg.get("role") != "assistant":
               return
       
      -    # 1. Explicit reasoning_content already set — preserve it verbatim
      -    # (includes DeepSeek/Kimi's own space-placeholder written at creation
      -    # time, and any valid reasoning content from the same provider).
      +    needs_thinking_pad = agent._needs_thinking_reasoning_pad()
      +
      +    # 1. Explicit reasoning_content already set.
      +    #
      +    # When the active provider enforces the thinking-mode echo-back
      +    # (DeepSeek / Kimi / MiMo), preserve it verbatim — that includes their
      +    # own space-placeholder written at creation time and any valid reasoning
      +    # from the same provider. Sessions persisted BEFORE #17341 have
      +    # empty-string placeholders pinned at creation time; DeepSeek V4 Pro
      +    # rejects those with HTTP 400, so upgrade "" → " " on replay.
           #
      -    # Exception: sessions persisted BEFORE #17341 have empty-string
      -    # placeholders pinned at creation time. DeepSeek V4 Pro rejects
      -    # those with HTTP 400. When the active provider enforces the
      -    # thinking-mode echo, upgrade "" → " " on replay so stale history
      -    # doesn't 400 the user on the next turn.
      +    # When the active provider does NOT enforce echo-back, strip the field
      +    # entirely. Strict OpenAI-compatible providers (Mistral, Cerebras, Groq,
      +    # SambaNova, …) reject ANY reasoning_content key in input messages with
      +    # HTTP 400/422 ("Extra inputs are not permitted"), even an empty string
      +    # or a single-space pad. This is the cross-provider fallback case: a
      +    # reasoning primary (DeepSeek/Kimi/MiMo) pads history with " ", then a
      +    # fallback to a strict provider replays that pad and 422s. Stripping
      +    # here covers the rebuild path; reapply_reasoning_echo_for_provider()
      +    # covers the already-built api_messages path. Refs #45655.
           existing = source_msg.get("reasoning_content")
           if isinstance(existing, str):
      -        if existing == "" and agent._needs_thinking_reasoning_pad():
      +        if not needs_thinking_pad:
      +            api_msg.pop("reasoning_content", None)
      +        elif existing == "":
                   api_msg["reasoning_content"] = " "
               else:
                   api_msg["reasoning_content"] = existing
               return
       
      -    needs_thinking_pad = agent._needs_thinking_reasoning_pad()
      -
           # 2. Cross-provider poisoned history (#15748): on DeepSeek/Kimi,
           # if the source turn has tool_calls AND a 'reasoning' field but no
           # 'reasoning_content' key, the 'reasoning' text was written by a
      @@ -2246,9 +2257,13 @@ def copy_reasoning_content_for_api(agent, source_msg: dict, api_msg: dict) -> No
           # for providers that use the internal 'reasoning' key.
           # This must happen before the unconditional empty-string fallback so
           # genuine reasoning content is not overwritten (#15812 regression in
      -    # PR #15478).
      +    # PR #15478). Only promote for providers that enforce echo-back —
      +    # strict providers reject the field (refs #45655).
           if isinstance(normalized_reasoning, str) and normalized_reasoning:
      -        api_msg["reasoning_content"] = normalized_reasoning
      +        if needs_thinking_pad:
      +            api_msg["reasoning_content"] = normalized_reasoning
      +        else:
      +            api_msg.pop("reasoning_content", None)
               return
       
           # 4. DeepSeek / Kimi thinking mode: all assistant messages need
      @@ -2269,34 +2284,53 @@ def copy_reasoning_content_for_api(agent, source_msg: dict, api_msg: dict) -> No
       
       
       def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int:
      -    """Re-pad assistant turns with reasoning_content for the active provider.
      +    """Re-pad (or strip) assistant turns' reasoning_content for the active provider.
       
           ``api_messages`` is built once, before the retry loop, while the *primary*
      -    provider is active.  If a mid-conversation fallback then switches to a
      -    require-side provider (DeepSeek / Kimi / MiMo thinking mode), assistant
      -    turns that were built when the prior provider did NOT need the echo-back go
      -    out without ``reasoning_content`` and the new provider rejects them with
      -    HTTP 400 ("The reasoning_content in the thinking mode must be passed back").
      -
      -    Calling this immediately before building the request kwargs re-applies the
      -    pad against the *current* provider.  It is idempotent and a no-op unless
      -    ``_needs_thinking_reasoning_pad()`` is True for the active provider, so it
      -    is safe to call every iteration and covers every fallback path.
      -
      -    Returns the number of assistant turns that gained reasoning_content.
      +    provider is active.  A mid-conversation fallback can then switch providers,
      +    so the reasoning fields baked into ``api_messages`` are shaped for the
      +    *prior* provider and must be reconciled against the *current* one:
      +
      +    * Switching TO a require-side provider (DeepSeek / Kimi / MiMo thinking
      +      mode): assistant turns built when the prior provider did NOT need the
      +      echo-back go out without ``reasoning_content`` and the new provider
      +      rejects them with HTTP 400 ("The reasoning_content in the thinking mode
      +      must be passed back").  Re-apply the pad.
      +
      +    * Switching TO a strict provider that rejects the field (Mistral,
      +      Cerebras, Groq, SambaNova, …): assistant turns built under a reasoning
      +      primary carry a ``reasoning_content`` pad (often a single space ``" "``),
      +      and the strict provider rejects it with HTTP 400/422 ("Extra inputs are
      +      not permitted").  Strip the field.  This is the exact cross-provider
      +      fallback bug from #45655 — a DeepSeek primary pads history with ``" "``,
      +      the request falls back to Mistral, and Mistral 422s on the stale pad.
      +
      +    Calling this immediately before building the request kwargs reconciles the
      +    fields against the *current* provider.  It is idempotent and safe to call
      +    every iteration; it covers every fallback path.
      +
      +    Returns the number of assistant turns whose reasoning_content was added or
      +    removed.
           """
      -    if not agent._needs_thinking_reasoning_pad():
      -        return 0
      -    padded = 0
      +    needs_pad = agent._needs_thinking_reasoning_pad()
      +    changed = 0
           for api_msg in api_messages:
               if api_msg.get("role") != "assistant":
                   continue
      -        if api_msg.get("reasoning_content"):
      -            continue
      -        copy_reasoning_content_for_api(agent, api_msg, api_msg)
      -        if api_msg.get("reasoning_content"):
      -            padded += 1
      -    return padded
      +        if needs_pad:
      +            if api_msg.get("reasoning_content"):
      +                continue
      +            copy_reasoning_content_for_api(agent, api_msg, api_msg)
      +            if api_msg.get("reasoning_content"):
      +                changed += 1
      +        else:
      +            # Strict provider — strip any stale reasoning_content pad left
      +            # over from a reasoning primary so the fallback request doesn't
      +            # 400/422 on it.
      +            if "reasoning_content" in api_msg:
      +                api_msg.pop("reasoning_content", None)
      +                changed += 1
      +    return changed
       
       
       def _iter_pool_sockets(client: Any):
    • tests/run_agent/test_deepseek_reasoning_content_echo.py
      @@ -160,10 +160,11 @@ def test_deepseek_stale_empty_placeholder_upgraded_to_space(self) -> None:
               agent._copy_reasoning_content_for_api(source, api_msg)
               assert api_msg["reasoning_content"] == " "
       
      -    def test_non_thinking_provider_preserves_empty_reasoning_content_verbatim(self) -> None:
      -        """The stale-placeholder upgrade ONLY fires when the active provider
      -        enforces thinking-mode echo. On non-thinking providers, an empty
      -        reasoning_content must still round-trip verbatim.
      +    def test_non_thinking_provider_strips_empty_reasoning_content(self) -> None:
      +        """Strict OpenAI-compatible providers (Mistral, Cerebras, …) reject ANY
      +        reasoning_content key in input messages — even an empty string — with
      +        HTTP 400/422. On a non-thinking provider the field must be stripped,
      +        not round-tripped. Refs #45655.
               """
               agent = _make_agent(
                   provider="openrouter",
      @@ -177,7 +178,7 @@ def test_non_thinking_provider_preserves_empty_reasoning_content_verbatim(self)
               }
               api_msg: dict = {}
               agent._copy_reasoning_content_for_api(source, api_msg)
      -        assert api_msg["reasoning_content"] == ""
      +        assert "reasoning_content" not in api_msg
       
           def test_deepseek_reasoning_field_promoted(self) -> None:
               """When only 'reasoning' is set, it gets promoted to reasoning_content."""
      @@ -532,7 +533,12 @@ def test_switch_to_deepseek_pads_bare_turns(self) -> None:
               assert msgs[2]["reasoning_content"] == "summary from codex"
               assert msgs[4]["reasoning_content"] == " "
       
      -    def test_noop_under_non_require_provider(self) -> None:
      +    def test_strips_stale_pad_under_strict_provider(self) -> None:
      +        """Switching TO a strict provider (Codex/Mistral/Cerebras) must STRIP
      +        stale reasoning_content baked in under a reasoning primary, otherwise
      +        the fallback request 400/422s ("Extra inputs are not permitted").
      +        Refs #45655 — DeepSeek primary → Mistral fallback 422 on the " " pad.
      +        """
               from agent.agent_runtime_helpers import reapply_reasoning_echo_for_provider
       
               agent = _make_agent(
      @@ -541,9 +547,11 @@ def test_noop_under_non_require_provider(self) -> None:
                   base_url="https://chatgpt.com/backend-api/codex",
               )
               msgs = self._codex_built_history()
      -        padded = reapply_reasoning_echo_for_provider(agent, msgs)
      -        assert padded == 0
      -        # the bare turn stays bare — Codex doesn't want reasoning_content
      +        changed = reapply_reasoning_echo_for_provider(agent, msgs)
      +        # msgs[2] carried "summary from codex" — must be stripped for the
      +        # strict provider; the bare turn (msgs[4]) stays bare.
      +        assert changed == 1
      +        assert "reasoning_content" not in msgs[2]
               assert "reasoning_content" not in msgs[4]
       
           def test_idempotent(self) -> None:
      @@ -563,3 +571,79 @@ def test_non_assistant_messages_untouched(self) -> None:
               assert "reasoning_content" not in msgs[0]  # system
               assert "reasoning_content" not in msgs[1]  # user
               assert "reasoning_content" not in msgs[3]  # tool
      +
      +
      +class TestReasoningPrimaryToStrictFallback:
      +    """Regression: reasoning primary → strict fallback must not 422.
      +
      +    User report (HTTP 422): a DeepSeek V4 Pro primary pads tool-call turns
      +    with ``reasoning_content=" "``; a mid-session fallback to Mistral
      +    (mistral-small) replays those pads and Mistral rejects them with::
      +
      +        body.messages.2.assistant.reasoning_content: Extra inputs are not
      +        permitted  (input: ' ')
      +
      +    api_messages is built once under the primary, so the stale pad survives
      +    into the fallback request. reapply_reasoning_echo_for_provider() must
      +    strip it when the active provider doesn't enforce echo-back. Refs #45655.
      +    """
      +
      +    @staticmethod
      +    def _deepseek_built_history() -> list[dict]:
      +        """Multi-turn history as built under a DeepSeek primary — tool-call
      +        turns padded with " " at indices 2 and 6 (matching the report)."""
      +        return [
      +            {"role": "system", "content": "sys"},
      +            {"role": "user", "content": "u1"},
      +            {"role": "assistant", "reasoning_content": " ",
      +             "tool_calls": [{"id": "a", "function": {"name": "terminal"}}]},
      +            {"role": "tool", "tool_call_id": "a", "content": "ok"},
      +            {"role": "assistant", "content": "done"},
      +            {"role": "user", "content": "u2"},
      +            {"role": "assistant", "reasoning_content": " ",
      +             "tool_calls": [{"id": "b", "function": {"name": "terminal"}}]},
      +            {"role": "tool", "tool_call_id": "b", "content": "ok"},
      +        ]
      +
      +    def test_mistral_fallback_strips_space_pad(self) -> None:
      +        from agent.agent_runtime_helpers import reapply_reasoning_echo_for_provider
      +
      +        mistral = _make_agent(
      +            provider="mistral",
      +            model="mistral-small-latest",
      +            base_url="https://api.mistral.ai/v1",
      +        )
      +        msgs = self._deepseek_built_history()
      +        changed = reapply_reasoning_echo_for_provider(mistral, msgs)
      +        assert changed == 2  # both padded tool-call turns
      +        leaks = [i for i, m in enumerate(msgs) if "reasoning_content" in m]
      +        assert leaks == []
      +
      +    def test_roundtrip_back_to_deepseek_repads(self) -> None:
      +        """Strict fallback strips, then switching back to DeepSeek re-pads —
      +        no regression on the #15748 echo-back requirement."""
      +        from agent.agent_runtime_helpers import reapply_reasoning_echo_for_provider
      +
      +        msgs = self._deepseek_built_history()
      +        mistral = _make_agent(
      +            provider="mistral", model="mistral-small-latest",
      +            base_url="https://api.mistral.ai/v1",
      +        )
      +        reapply_reasoning_echo_for_provider(mistral, msgs)
      +        deepseek = _make_agent(provider="deepseek", model="deepseek-v4-pro")
      +        reapply_reasoning_echo_for_provider(deepseek, msgs)
      +        assert msgs[2]["reasoning_content"] == " "
      +        assert msgs[6]["reasoning_content"] == " "
      +
      +    def test_copy_strips_space_pad_for_mistral(self) -> None:
      +        """copy_reasoning_content_for_api strips the " " pad on the rebuild
      +        path too (covers fresh api_messages built under the strict provider)."""
      +        mistral = _make_agent(
      +            provider="mistral", model="mistral-small-latest",
      +            base_url="https://api.mistral.ai/v1",
      +        )
      +        source = {"role": "assistant", "reasoning_content": " ",
      +                  "tool_calls": [{"id": "a"}]}
      +        api_msg: dict = {"role": "assistant", "tool_calls": [{"id": "a"}]}
      +        mistral._copy_reasoning_content_for_api(source, api_msg)
      +        assert "reasoning_content" not in api_msg
    • tests/run_agent/test_run_agent.py
      @@ -6413,6 +6413,13 @@ def test_kimi_tool_replay_includes_space_reasoning_content(self, agent):
       
           def test_explicit_reasoning_content_beats_normalized_reasoning_on_replay(self, agent):
               self._setup_agent(agent)
      +        # Precedence (explicit reasoning_content wins over the 'reasoning'
      +        # field) only matters on a provider that echoes reasoning_content
      +        # back — strict providers strip the field entirely. Pin a
      +        # reasoning provider so the precedence is observable.
      +        agent.base_url = "https://api.kimi.com/coding/v1"
      +        agent._base_url_lower = agent.base_url.lower()
      +        agent.provider = "kimi-coding"
               prior_assistant = {
                   "role": "assistant",
                   "content": "",
      @@ -6445,6 +6452,45 @@ def test_explicit_reasoning_content_beats_normalized_reasoning_on_replay(self, a
               replayed_assistant = next(msg for msg in sent_messages if msg.get("role") == "assistant")
               assert replayed_assistant["reasoning_content"] == "provider-native scratchpad"
       
      +    def test_strict_provider_strips_reasoning_content_on_replay(self, agent):
      +        """On a strict provider (Mistral et al.) reasoning_content from a
      +        prior reasoning primary must be stripped on replay — otherwise the
      +        request 400/422s ('Extra inputs are not permitted'). Refs #45655."""
      +        self._setup_agent(agent)
      +        agent.base_url = "https://api.mistral.ai/v1"
      +        agent._base_url_lower = agent.base_url.lower()
      +        agent.provider = "mistral"
      +        prior_assistant = {
      +            "role": "assistant",
      +            "content": "",
      +            "tool_calls": [
      +                {
      +                    "id": "c1",
      +                    "type": "function",
      +                    "function": {"name": "web_search", "arguments": "{\"q\":\"test\"}"},
      +                }
      +            ],
      +            "reasoning_content": " ",  # space-pad from a reasoning primary
      +        }
      +        tool_result = {"role": "tool", "tool_call_id": "c1", "content": "ok"}
      +        final_resp = _mock_response(content="done", finish_reason="stop")
      +        agent.client.chat.completions.create.return_value = final_resp
      +
      +        with (
      +            patch.object(agent, "_persist_session"),
      +            patch.object(agent, "_save_trajectory"),
      +            patch.object(agent, "_cleanup_task_resources"),
      +        ):
      +            result = agent.run_conversation(
      +                "next step",
      +                conversation_history=[prior_assistant, tool_result],
      +            )
      +
      +        assert result["completed"] is True
      +        sent_messages = agent.client.chat.completions.create.call_args.kwargs["messages"]
      +        replayed_assistant = next(msg for msg in sent_messages if msg.get("role") == "assistant")
      +        assert "reasoning_content" not in replayed_assistant
      +
       
       # ---------------------------------------------------------------------------
       # Bugfix: _vprint force=True on error messages during TTS

6 PRs closed without merging

From the close comments: done elsewhere 2 · superseded by another PR 2 · stale or abandoned 1 · won't fix / by design 1

  • #11139 fix(run_agent): strip reasoning_content for Groq to prevent HTTP 400 (#11089) · stale or abandoned
  • #34760 fix(agent): strip reasoning_content echo for providers that reject it (Cerebras) · done elsewhere → 2b3a4f0, #50480
  • #35563 fix(agent): strip reasoning_content for non-thinking providers (#35543) · won't fix / by design → #24435, #34182, #28238, #26555
  • #45364 fix: strip reasoning_content for providers that reject it · done elsewhere → 2b3a4f0
  • #45417 fix(agent): strip reasoning_content on fallback to non-thinking providers (#45332) · superseded by another PR → #45364, #45332
Show 1 more
  • #45661 fix: strip reasoning_content for providers that don't support echo-back (#45655) · superseded by another PR → #50480, #45655
Closed issues · 5
  • Closed#11089[Bug]: Trying to use Groq API for inference, but getting error
  • Closed#34716[Bug] Cerebras models (gpt-oss-120b, zai-glm-4.7) fail on second turn with reasoning_content unsupported error
  • Closed#35543cross-provider poisoned history: reasoning_content from thinking-mode sessions causes HTTP 400 on Cerebras and other strict providers
  • Closed#45332fix: Strip `reasoning_content` from messages on cross-provider fallback to non-thinking providers
  • Closed#45655Cerebras API rejects reasoning_content on multi-turn replay (HTTP 400)
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.16: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#50480
Closed issues
5
Attempts
6 PRs closed without merging
Fix link checked
22 Jun 2026

For your agent

The same facts, typed.