{"repo":"NousResearch/hermes-agent","public_id":"11089","display_label":"Strip reasoning_content for non-thinking providers","summary":"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.","subsystem":"agent","counts":{"members":12,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":5,"merged_prs":1,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50480},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-22T01:05:08+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1593939393939394},"fix_edges":[{"issue_number":34716,"pr_number":50480,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":45655,"pr_number":50480,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":11089,"title":"[Bug]: Trying to use Groq API for inference, but getting error","url":null,"state":"closed"},{"kind":"issue","number":34716,"title":"[Bug] Cerebras models (gpt-oss-120b, zai-glm-4.7) fail on second turn with reasoning_content unsupported error","url":null,"state":"closed"},{"kind":"issue","number":35543,"title":"cross-provider poisoned history: reasoning_content from thinking-mode sessions causes HTTP 400 on Cerebras and other strict providers","url":null,"state":"closed"},{"kind":"issue","number":45332,"title":"fix: Strip `reasoning_content` from messages on cross-provider fallback to non-thinking providers","url":null,"state":"closed"},{"kind":"issue","number":45655,"title":"Cerebras API rejects reasoning_content on multi-turn replay (HTTP 400)","url":null,"state":"closed"},{"kind":"pr","number":11139,"title":"fix(run_agent): strip reasoning_content for Groq to prevent HTTP 400 (#11089)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":34760,"title":"fix(agent): strip reasoning_content echo for providers that reject it (Cerebras)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"2b3a4f0af80f2952760fdeedb9f26f4eac7faff3","number":0},{"kind":"pr","value":"#50480","number":50480}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":35563,"title":"fix(agent): strip reasoning_content for non-thinking providers (#35543)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24435","number":24435},{"kind":"pr","value":"#34182","number":34182},{"kind":"pr","value":"#28238","number":28238},{"kind":"pr","value":"#26555","number":26555},{"kind":"pr","value":"#34182","number":34182},{"kind":"pr","value":"#28238","number":28238}],"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":45364,"title":"fix: strip reasoning_content for providers that reject it","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"2b3a4f0af80f2952760fdeedb9f26f4eac7faff3","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":45417,"title":"fix(agent): strip reasoning_content on fallback to non-thinking providers (#45332)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45364","number":45364},{"kind":"pr","value":"#45332","number":45332}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":45661,"title":"fix: strip reasoning_content for providers that don't support echo-back (#45655)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50480","number":50480},{"kind":"pr","value":"#45655","number":45655},{"kind":"pr","value":"#50480","number":50480}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":50480,"title":"fix(agent): strip stale reasoning_content when falling back to a strict provider","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","tests/run_agent/test_deepseek_reasoning_content_echo.py","tests/run_agent/test_run_agent.py"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -2202,25 +2202,36 @@ def copy_reasoning_content_for_api(agent, source_msg: dict, api_msg: dict) -> No\n     if source_msg.get(\"role\") != \"assistant\":\n         return\n \n-    # 1. Explicit reasoning_content already set — preserve it verbatim\n-    # (includes DeepSeek/Kimi's own space-placeholder written at creation\n-    # time, and any valid reasoning content from the same provider).\n+    needs_thinking_pad = agent._needs_thinking_reasoning_pad()\n+\n+    # 1. Explicit reasoning_content already set.\n+    #\n+    # When the active provider enforces the thinking-mode echo-back\n+    # (DeepSeek / Kimi / MiMo), preserve it verbatim — that includes their\n+    # own space-placeholder written at creation time and any valid reasoning\n+    # from the same provider. Sessions persisted BEFORE #17341 have\n+    # empty-string placeholders pinned at creation time; DeepSeek V4 Pro\n+    # rejects those with HTTP 400, so upgrade \"\" → \" \" on replay.\n     #\n-    # Exception: sessions persisted BEFORE #17341 have empty-string\n-    # placeholders pinned at creation time. DeepSeek V4 Pro rejects\n-    # those with HTTP 400. When the active provider enforces the\n-    # thinking-mode echo, upgrade \"\" → \" \" on replay so stale history\n-    # doesn't 400 the user on the next turn.\n+    # When the active provider does NOT enforce echo-back, strip the field\n+    # entirely. Strict OpenAI-compatible providers (Mistral, Cerebras, Groq,\n+    # SambaNova, …) reject ANY reasoning_content key in input messages with\n+    # HTTP 400/422 (\"Extra inputs are not permitted\"), even an empty string\n+    # or a single-space pad. This is the cross-provider fallback case: a\n+    # reasoning primary (DeepSeek/Kimi/MiMo) pads history with \" \", then a\n+    # fallback to a strict provider replays that pad and 422s. Stripping\n+    # here covers the rebuild path; reapply_reasoning_echo_for_provider()\n+    # covers the already-built api_messages path. Refs #45655.\n     existing = source_msg.get(\"reasoning_content\")\n     if isinstance(existing, str):\n-        if existing == \"\" and agent._needs_thinking_reasoning_pad():\n+        if not needs_thinking_pad:\n+            api_msg.pop(\"reasoning_content\", None)\n+        elif existing == \"\":\n             api_msg[\"reasoning_content\"] = \" \"\n         else:\n             api_msg[\"reasoning_content\"] = existing\n         return\n \n-    needs_thinking_pad = agent._needs_thinking_reasoning_pad()\n-\n     # 2. Cross-provider poisoned history (#15748): on DeepSeek/Kimi,\n     # if the source turn has tool_calls AND a 'reasoning' field but no\n     # '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\n     # for providers that use the internal 'reasoning' key.\n     # This must happen before the unconditional empty-string fallback so\n     # genuine reasoning content is not overwritten (#15812 regression in\n-    # PR #15478).\n+    # PR #15478). Only promote for providers that enforce echo-back —\n+    # strict providers reject the field (refs #45655).\n     if isinstance(normalized_reasoning, str) and normalized_reasoning:\n-        api_msg[\"reasoning_content\"] = normalized_reasoning\n+        if needs_thinking_pad:\n+            api_msg[\"reasoning_content\"] = normalized_reasoning\n+        else:\n+            api_msg.pop(\"reasoning_content\", None)\n         return\n \n     # 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\n \n \n def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int:\n-    \"\"\"Re-pad assistant turns with reasoning_content for the active provider.\n+    \"\"\"Re-pad (or strip) assistant turns' reasoning_content for the active provider.\n \n     ``api_messages`` is built once, before the retry loop, while the *primary*\n-    provider is active.  If a mid-conversation fallback then switches to a\n-    require-side provider (DeepSeek / Kimi / MiMo thinking mode), assistant\n-    turns that were built when the prior provider did NOT need the echo-back go\n-    out without ``reasoning_content`` and the new provider rejects them with\n-    HTTP 400 (\"The reasoning_content in the thinking mode must be passed back\").\n-\n-    Calling this immediately before building the request kwargs re-applies the\n-    pad against the *current* provider.  It is idempotent and a no-op unless\n-    ``_needs_thinking_reasoning_pad()`` is True for the active provider, so it\n-    is safe to call every iteration and covers every fallback path.\n-\n-    Returns the number of assistant turns that gained reasoning_content.\n+    provider is active.  A mid-conversation fallback can then switch providers,\n+    so the reasoning fields baked into ``api_messages`` are shaped for the\n+    *prior* provider and must be reconciled against the *current* one:\n+\n+    * Switching TO a require-side provider (DeepSeek / Kimi / MiMo thinking\n+      mode): assistant turns built when the prior provider did NOT need the\n+      echo-back go out without ``reasoning_content`` and the new provider\n+      rejects them with HTTP 400 (\"The reasoning_content in the thinking mode\n+      must be passed back\").  Re-apply the pad.\n+\n+    * Switching TO a strict provider that rejects the field (Mistral,\n+      Cerebras, Groq, SambaNova, …): assistant turns built under a reasoning\n+      primary carry a ``reasoning_content`` pad (often a single space ``\" \"``),\n+      and the strict provider rejects it with HTTP 400/422 (\"Extra inputs are\n+      not permitted\").  Strip the field.  This is the exact cross-provider\n+      fallback bug from #45655 — a DeepSeek primary pads history with ``\" \"``,\n+      the request falls back to Mistral, and Mistral 422s on the stale pad.\n+\n+    Calling this immediately before building the request kwargs reconciles the\n+    fields against the *current* provider.  It is idempotent and safe to call\n+    every iteration; it covers every fallback path.\n+\n+    Returns the number of assistant turns whose reasoning_content was added or\n+    removed.\n     \"\"\"\n-    if not agent._needs_thinking_reasoning_pad():\n-        return 0\n-    padded = 0\n+    needs_pad = agent._needs_thinking_reasoning_pad()\n+    changed = 0\n     for api_msg in api_messages:\n         if api_msg.get(\"role\") != \"assistant\":\n             continue\n-        if api_msg.get(\"reasoning_content\"):\n-            continue\n-        copy_reasoning_content_for_api(agent, api_msg, api_msg)\n-        if api_msg.get(\"reasoning_content\"):\n-            padded += 1\n-    return padded\n+        if needs_pad:\n+            if api_msg.get(\"reasoning_content\"):\n+                continue\n+            copy_reasoning_content_for_api(agent, api_msg, api_msg)\n+            if api_msg.get(\"reasoning_content\"):\n+                changed += 1\n+        else:\n+            # Strict provider — strip any stale reasoning_content pad left\n+            # over from a reasoning primary so the fallback request doesn't\n+            # 400/422 on it.\n+            if \"reasoning_content\" in api_msg:\n+                api_msg.pop(\"reasoning_content\", None)\n+                changed += 1\n+    return changed\n \n \n def _iter_pool_sockets(client: Any):"]},{"file":"tests/run_agent/test_deepseek_reasoning_content_echo.py","hunks":["@@ -160,10 +160,11 @@ def test_deepseek_stale_empty_placeholder_upgraded_to_space(self) -> None:\n         agent._copy_reasoning_content_for_api(source, api_msg)\n         assert api_msg[\"reasoning_content\"] == \" \"\n \n-    def test_non_thinking_provider_preserves_empty_reasoning_content_verbatim(self) -> None:\n-        \"\"\"The stale-placeholder upgrade ONLY fires when the active provider\n-        enforces thinking-mode echo. On non-thinking providers, an empty\n-        reasoning_content must still round-trip verbatim.\n+    def test_non_thinking_provider_strips_empty_reasoning_content(self) -> None:\n+        \"\"\"Strict OpenAI-compatible providers (Mistral, Cerebras, …) reject ANY\n+        reasoning_content key in input messages — even an empty string — with\n+        HTTP 400/422. On a non-thinking provider the field must be stripped,\n+        not round-tripped. Refs #45655.\n         \"\"\"\n         agent = _make_agent(\n             provider=\"openrouter\",","@@ -177,7 +178,7 @@ def test_non_thinking_provider_preserves_empty_reasoning_content_verbatim(self)\n         }\n         api_msg: dict = {}\n         agent._copy_reasoning_content_for_api(source, api_msg)\n-        assert api_msg[\"reasoning_content\"] == \"\"\n+        assert \"reasoning_content\" not in api_msg\n \n     def test_deepseek_reasoning_field_promoted(self) -> None:\n         \"\"\"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:\n         assert msgs[2][\"reasoning_content\"] == \"summary from codex\"\n         assert msgs[4][\"reasoning_content\"] == \" \"\n \n-    def test_noop_under_non_require_provider(self) -> None:\n+    def test_strips_stale_pad_under_strict_provider(self) -> None:\n+        \"\"\"Switching TO a strict provider (Codex/Mistral/Cerebras) must STRIP\n+        stale reasoning_content baked in under a reasoning primary, otherwise\n+        the fallback request 400/422s (\"Extra inputs are not permitted\").\n+        Refs #45655 — DeepSeek primary → Mistral fallback 422 on the \" \" pad.\n+        \"\"\"\n         from agent.agent_runtime_helpers import reapply_reasoning_echo_for_provider\n \n         agent = _make_agent(","@@ -541,9 +547,11 @@ def test_noop_under_non_require_provider(self) -> None:\n             base_url=\"https://chatgpt.com/backend-api/codex\",\n         )\n         msgs = self._codex_built_history()\n-        padded = reapply_reasoning_echo_for_provider(agent, msgs)\n-        assert padded == 0\n-        # the bare turn stays bare — Codex doesn't want reasoning_content\n+        changed = reapply_reasoning_echo_for_provider(agent, msgs)\n+        # msgs[2] carried \"summary from codex\" — must be stripped for the\n+        # strict provider; the bare turn (msgs[4]) stays bare.\n+        assert changed == 1\n+        assert \"reasoning_content\" not in msgs[2]\n         assert \"reasoning_content\" not in msgs[4]\n \n     def test_idempotent(self) -> None:","@@ -563,3 +571,79 @@ def test_non_assistant_messages_untouched(self) -> None:\n         assert \"reasoning_content\" not in msgs[0]  # system\n         assert \"reasoning_content\" not in msgs[1]  # user\n         assert \"reasoning_content\" not in msgs[3]  # tool\n+\n+\n+class TestReasoningPrimaryToStrictFallback:\n+    \"\"\"Regression: reasoning primary → strict fallback must not 422.\n+\n+    User report (HTTP 422): a DeepSeek V4 Pro primary pads tool-call turns\n+    with ``reasoning_content=\" \"``; a mid-session fallback to Mistral\n+    (mistral-small) replays those pads and Mistral rejects them with::\n+\n+        body.messages.2.assistant.reasoning_content: Extra inputs are not\n+        permitted  (input: ' ')\n+\n+    api_messages is built once under the primary, so the stale pad survives\n+    into the fallback request. reapply_reasoning_echo_for_provider() must\n+    strip it when the active provider doesn't enforce echo-back. Refs #45655.\n+    \"\"\"\n+\n+    @staticmethod\n+    def _deepseek_built_history() -> list[dict]:\n+        \"\"\"Multi-turn history as built under a DeepSeek primary — tool-call\n+        turns padded with \" \" at indices 2 and 6 (matching the report).\"\"\"\n+        return [\n+            {\"role\": \"system\", \"content\": \"sys\"},\n+            {\"role\": \"user\", \"content\": \"u1\"},\n+            {\"role\": \"assistant\", \"reasoning_content\": \" \",\n+             \"tool_calls\": [{\"id\": \"a\", \"function\": {\"name\": \"terminal\"}}]},\n+            {\"role\": \"tool\", \"tool_call_id\": \"a\", \"content\": \"ok\"},\n+            {\"role\": \"assistant\", \"content\": \"done\"},\n+            {\"role\": \"user\", \"content\": \"u2\"},\n+            {\"role\": \"assistant\", \"reasoning_content\": \" \",\n+             \"tool_calls\": [{\"id\": \"b\", \"function\": {\"name\": \"terminal\"}}]},\n+            {\"role\": \"tool\", \"tool_call_id\": \"b\", \"content\": \"ok\"},\n+        ]\n+\n+    def test_mistral_fallback_strips_space_pad(self) -> None:\n+        from agent.agent_runtime_helpers import reapply_reasoning_echo_for_provider\n+\n+        mistral = _make_agent(\n+            provider=\"mistral\",\n+            model=\"mistral-small-latest\",\n+            base_url=\"https://api.mistral.ai/v1\",\n+        )\n+        msgs = self._deepseek_built_history()\n+        changed = reapply_reasoning_echo_for_provider(mistral, msgs)\n+        assert changed == 2  # both padded tool-call turns\n+        leaks = [i for i, m in enumerate(msgs) if \"reasoning_content\" in m]\n+        assert leaks == []\n+\n+    def test_roundtrip_back_to_deepseek_repads(self) -> None:\n+        \"\"\"Strict fallback strips, then switching back to DeepSeek re-pads —\n+        no regression on the #15748 echo-back requirement.\"\"\"\n+        from agent.agent_runtime_helpers import reapply_reasoning_echo_for_provider\n+\n+        msgs = self._deepseek_built_history()\n+        mistral = _make_agent(\n+            provider=\"mistral\", model=\"mistral-small-latest\",\n+            base_url=\"https://api.mistral.ai/v1\",\n+        )\n+        reapply_reasoning_echo_for_provider(mistral, msgs)\n+        deepseek = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-pro\")\n+        reapply_reasoning_echo_for_provider(deepseek, msgs)\n+        assert msgs[2][\"reasoning_content\"] == \" \"\n+        assert msgs[6][\"reasoning_content\"] == \" \"\n+\n+    def test_copy_strips_space_pad_for_mistral(self) -> None:\n+        \"\"\"copy_reasoning_content_for_api strips the \" \" pad on the rebuild\n+        path too (covers fresh api_messages built under the strict provider).\"\"\"\n+        mistral = _make_agent(\n+            provider=\"mistral\", model=\"mistral-small-latest\",\n+            base_url=\"https://api.mistral.ai/v1\",\n+        )\n+        source = {\"role\": \"assistant\", \"reasoning_content\": \" \",\n+                  \"tool_calls\": [{\"id\": \"a\"}]}\n+        api_msg: dict = {\"role\": \"assistant\", \"tool_calls\": [{\"id\": \"a\"}]}\n+        mistral._copy_reasoning_content_for_api(source, api_msg)\n+        assert \"reasoning_content\" not in api_msg"]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -6413,6 +6413,13 @@ def test_kimi_tool_replay_includes_space_reasoning_content(self, agent):\n \n     def test_explicit_reasoning_content_beats_normalized_reasoning_on_replay(self, agent):\n         self._setup_agent(agent)\n+        # Precedence (explicit reasoning_content wins over the 'reasoning'\n+        # field) only matters on a provider that echoes reasoning_content\n+        # back — strict providers strip the field entirely. Pin a\n+        # reasoning provider so the precedence is observable.\n+        agent.base_url = \"https://api.kimi.com/coding/v1\"\n+        agent._base_url_lower = agent.base_url.lower()\n+        agent.provider = \"kimi-coding\"\n         prior_assistant = {\n             \"role\": \"assistant\",\n             \"content\": \"\",","@@ -6445,6 +6452,45 @@ def test_explicit_reasoning_content_beats_normalized_reasoning_on_replay(self, a\n         replayed_assistant = next(msg for msg in sent_messages if msg.get(\"role\") == \"assistant\")\n         assert replayed_assistant[\"reasoning_content\"] == \"provider-native scratchpad\"\n \n+    def test_strict_provider_strips_reasoning_content_on_replay(self, agent):\n+        \"\"\"On a strict provider (Mistral et al.) reasoning_content from a\n+        prior reasoning primary must be stripped on replay — otherwise the\n+        request 400/422s ('Extra inputs are not permitted'). Refs #45655.\"\"\"\n+        self._setup_agent(agent)\n+        agent.base_url = \"https://api.mistral.ai/v1\"\n+        agent._base_url_lower = agent.base_url.lower()\n+        agent.provider = \"mistral\"\n+        prior_assistant = {\n+            \"role\": \"assistant\",\n+            \"content\": \"\",\n+            \"tool_calls\": [\n+                {\n+                    \"id\": \"c1\",\n+                    \"type\": \"function\",\n+                    \"function\": {\"name\": \"web_search\", \"arguments\": \"{\\\"q\\\":\\\"test\\\"}\"},\n+                }\n+            ],\n+            \"reasoning_content\": \" \",  # space-pad from a reasoning primary\n+        }\n+        tool_result = {\"role\": \"tool\", \"tool_call_id\": \"c1\", \"content\": \"ok\"}\n+        final_resp = _mock_response(content=\"done\", finish_reason=\"stop\")\n+        agent.client.chat.completions.create.return_value = final_resp\n+\n+        with (\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+        ):\n+            result = agent.run_conversation(\n+                \"next step\",\n+                conversation_history=[prior_assistant, tool_result],\n+            )\n+\n+        assert result[\"completed\"] is True\n+        sent_messages = agent.client.chat.completions.create.call_args.kwargs[\"messages\"]\n+        replayed_assistant = next(msg for msg in sent_messages if msg.get(\"role\") == \"assistant\")\n+        assert \"reasoning_content\" not in replayed_assistant\n+\n \n # ---------------------------------------------------------------------------\n # Bugfix: _vprint force=True on error messages during TTS\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}