# NousResearch/hermes-agent · Strip reasoning_content for non-thinking providers

## Open now

- 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.

## TL;DR

- 12 member(s): 0 open issue(s), 5 closed issue(s), 0 open PR(s), 1 merged PR(s), 6 PR(s) closed without merging.
- A fix was merged to main in PR #50480. Verify it addresses your case.
- **Recommended:** read PR #50480 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Closed / duplicate issues

- #11089: \[Bug\]: Trying to use Groq API for inference, but getting error
- #34716: \[Bug\] Cerebras models (gpt-oss-120b, zai-glm-4.7) fail on second turn with reasoning_content unsupported error
- #35543: cross-provider poisoned history: reasoning_content from thinking-mode sessions causes HTTP 400 on Cerebras and other strict providers
- #45332: fix: Strip \`reasoning_content\` from messages on cross-provider fallback to non-thinking providers
- #45655: Cerebras API rejects reasoning_content on multi-turn replay (HTTP 400)

## Prior & competing PRs

- **#45417: fix(agent): strip reasoning_content on fallback to non-thinking providers (#45332)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#45364`, `#45332`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#50480: fix(agent): strip stale reasoning_content when falling back to a strict provider**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/agent_runtime_helpers.py`, `tests/run_agent/test_deepseek_reasoning_content_echo.py`, `tests/run_agent/test_run_agent.py`
  - fix hunks:
  - `agent/agent_runtime_helpers.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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",
    ```

    ```diff
    @@ -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."""
    ```

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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": "",
    ```

    ```diff
    @@ -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
    ```
- **#11139: fix(run_agent): strip reasoning_content for Groq to prevent HTTP 400 (#11089)**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#34760: fix(agent): strip reasoning_content echo for providers that reject it (Cerebras)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `2b3a4f0af80f2952760fdeedb9f26f4eac7faff3`, `#50480`
- **#35563: fix(agent): strip reasoning_content for non-thinking providers (#35543)**. closed without merge
  - maintainer direction: category `wontfix_bydesign`; implication `wontfix`; refs `#24435`, `#34182`, `#28238`, `#26555`, `#34182`, `#28238`
- **#45364: fix: strip reasoning_content for providers that reject it**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `2b3a4f0af80f2952760fdeedb9f26f4eac7faff3`
- **#45661: fix: strip reasoning_content for providers that don't support echo-back (#45655)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#50480`, `#45655`, `#50480`

## Fix status

- Merged to main in PR #50480 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-22`.
- Subsystem: `agent`
