{"repo":"NousResearch/hermes-agent","public_id":"13848","display_label":"reasoning_content handling bugs and fixes across providers","summary":"This cluster concerns bugs and proposed fixes around preserving, sanitizing, and correctly replaying reasoning_content (thinking blocks) in multi-turn sessions for DeepSeek and Kimi models. Issues span HTTP 400 errors from missing or malformed reasoning_content on tool-call and assistant turns, with fixes targeting extraction, persistence, and provider-specific sanitization.","subsystem":"agent","counts":{"members":61,"open_issues":4,"open_issues_distinct":4,"open_prs":1,"closed_issues":22,"merged_prs":8,"attempt_prs":27},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":14018},"resolution":{"state":"partially_resolved","by":"pr-14018,pr-15407,pr-15478,pr-16500,pr-17510,pr-18045,pr-22798,pr-26648"},"freshness":{"state":"verified","last_verified_at":"2026-05-16T00:03:27+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.039978142076502715},"fix_edges":[{"issue_number":13848,"pr_number":14018,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":14933,"pr_number":16500,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":14938,"pr_number":16500,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":15213,"pr_number":15478,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":15250,"pr_number":15407,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":15353,"pr_number":15407,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":15353,"pr_number":16500,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":15700,"pr_number":26648,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":15748,"pr_number":16500,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":15812,"pr_number":15478,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":16748,"pr_number":17510,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":17212,"pr_number":26648,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":17400,"pr_number":18045,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":17825,"pr_number":26648,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":21944,"pr_number":22798,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":21946,"pr_number":22798,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":13848,"title":"[Bug]: 400 error for kimi-for-coding","url":null,"state":"closed"},{"kind":"issue","number":14933,"title":"[BUG] DeepSeek V4 thinking mode fails with reasoning_content error","url":null,"state":"closed"},{"kind":"issue","number":14938,"title":"DeepSeek V4 Flash Toolcall Fails with reasoning_content Error","url":null,"state":"closed"},{"kind":"issue","number":15213,"title":"[Bug]: HTTP 400 \"reasoning_content must be passed back\" with deepseek-v4-pro in cron/auxiliary path (thinking mode works in main loop, breaks elsewhere)","url":null,"state":"closed"},{"kind":"issue","number":15250,"title":"[Bug]: DeepSeek V4 Flash Discord session poisoned when tool-call assistant lacks reasoning_content","url":null,"state":"closed"},{"kind":"issue","number":15353,"title":"DeepSeek V4 thinking mode: missing reasoning_content on tool-call messages causes 400","url":null,"state":"closed"},{"kind":"issue","number":15679,"title":"[Bug]: DeepSeek reasoning models not supported (reasoning_content missing)","url":null,"state":"closed"},{"kind":"issue","number":15717,"title":"[Bug]: DeepSeek API 400 error: \"reasoning_content\" in thinking mode must be passed back to the API","url":null,"state":"closed"},{"kind":"issue","number":15741,"title":"[Bug]: cron path still emits HTTP 400 reasoning_content error on deepseek-v4-flash after #15213 closure","url":null,"state":"closed"},{"kind":"issue","number":15748,"title":"[Bug] _copy_reasoning_content_for_api: cross-provider reasoning promotion leaks stale content to DeepSeek/Kimi","url":null,"state":"closed"},{"kind":"issue","number":16021,"title":"[Bug report] DeepSeek V4: reasoning_content missing on non-tool-call turns causes HTTP 400","url":null,"state":"closed"},{"kind":"issue","number":16135,"title":"[BUG] DeepSeek v4-pro reasoning mode breaks with \"reasoning_content must be passed back\" error","url":null,"state":"closed"},{"kind":"issue","number":16137,"title":"[Bug]: DeepSeek thinking mode requires reasoning_content in API replay","url":null,"state":"closed"},{"kind":"issue","number":16388,"title":"DeepSeek tool-calling turns missing reasoning_content cause HTTP 400 — Kimi-only compensation should generalise","url":null,"state":"closed"},{"kind":"issue","number":16748,"title":"DeepSeek /anthropic (V4 thinking): stripped thinking blocks cause HTTP 400 on replay","url":null,"state":"closed"},{"kind":"issue","number":17212,"title":"DeepSeek direct API 400 \"reasoning_content must be passed back\" on multi-turn tool calls","url":null,"state":"closed"},{"kind":"issue","number":17400,"title":"reasoning_content dropped in multi-turn tool calls with DeepSeek v4 (causes HTTP 400)","url":null,"state":"closed"},{"kind":"issue","number":17825,"title":"[Bug]: DeepSeek reasoning models crash with HTTP 400 on multi-turn sessions — reasoning_content dropped during session reload","url":null,"state":"closed"},{"kind":"issue","number":17992,"title":"DeepSeek V4 /anthropic: signed thinking blocks stripped, causing HTTP 400","url":null,"state":"open"},{"kind":"issue","number":21944,"title":"Bug: DeepSeek V4 Pro thinking content not persisted in session (HTTP 400 on multi-turn)","url":null,"state":"closed"},{"kind":"issue","number":21946,"title":"Bug: DeepSeek V4 Pro thinking content not persisted in session (HTTP 400 on multi-turn)","url":null,"state":"closed"},{"kind":"issue","number":22313,"title":"bug: DeepSeek Anthropic-compatible API — HTTP 400: thinking blocks stripped from message history","url":null,"state":"closed"},{"kind":"issue","number":24443,"title":"MiMo reasoning models may fail in Hermes because reasoning_content is not preserved in chat history","url":null,"state":"open"},{"kind":"issue","number":29607,"title":"Third-party relay (PackyAPI) + DeepSeek: thinking blocks stripped causing HTTP 400","url":null,"state":"open"},{"kind":"issue","number":34519,"title":"[Bug]: Fallback provider activation reuses stale api_messages — reasoning_content not injected for DeepSeek/Kimi thinking mode","url":null,"state":"closed"},{"kind":"issue","number":58117,"title":"Thinking blocks cause blank text responses in CLI mode + infinite heartbeat loops","url":null,"state":"open"},{"kind":"pr","number":13897,"title":"fix(anthropic_adapter): preserve reasoning_content for Kimi /coding tool-call messages","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14018","number":14018}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":14018,"title":"fix(kimi): preserve reasoning_content in Anthropic adapter for /coding endpoint (salvage #13897)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","scripts/release.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -1083,6 +1083,31 @@ def convert_messages_to_anthropic(\n                     \"name\": fn.get(\"name\", \"\"),\n                     \"input\": parsed_args,\n                 })\n+            # Kimi's /coding endpoint (Anthropic protocol) requires assistant\n+            # tool-call messages to carry reasoning_content when thinking is\n+            # enabled server-side.  Preserve it as a thinking block so Kimi\n+            # can validate the message history.  See hermes-agent#13848.\n+            #\n+            # Accept empty string \"\" — _copy_reasoning_content_for_api()\n+            # injects \"\" as a tier-3 fallback for Kimi tool-call messages\n+            # that had no reasoning.  Kimi requires the field to exist, even\n+            # if empty.\n+            #\n+            # Prepend (not append): Anthropic protocol requires thinking\n+            # blocks before text and tool_use blocks.\n+            #\n+            # Guard: only add when reasoning_details didn't already contribute\n+            # thinking blocks.  On native Anthropic, reasoning_details produces\n+            # signed thinking blocks — adding another unsigned one from\n+            # reasoning_content would create a duplicate (same text) that gets\n+            # downgraded to a spurious text block on the last assistant message.\n+            reasoning_content = m.get(\"reasoning_content\")\n+            _already_has_thinking = any(\n+                isinstance(b, dict) and b.get(\"type\") in (\"thinking\", \"redacted_thinking\")\n+                for b in blocks\n+            )\n+            if isinstance(reasoning_content, str) and not _already_has_thinking:\n+                blocks.insert(0, {\"type\": \"thinking\", \"thinking\": reasoning_content})\n             # Anthropic rejects empty assistant content\n             effective = blocks or content\n             if not effective or effective == \"\":","@@ -1238,6 +1263,7 @@ def convert_messages_to_anthropic(\n     #    cache markers can interfere with signature validation.\n     _THINKING_TYPES = frozenset((\"thinking\", \"redacted_thinking\"))\n     _is_third_party = _is_third_party_anthropic_endpoint(base_url)\n+    _is_kimi = _is_kimi_coding_endpoint(base_url)\n \n     last_assistant_idx = None\n     for i in range(len(result) - 1, -1, -1):","@@ -1249,7 +1275,25 @@ def convert_messages_to_anthropic(\n         if m.get(\"role\") != \"assistant\" or not isinstance(m.get(\"content\"), list):\n             continue\n \n-        if _is_third_party or idx != last_assistant_idx:\n+        if _is_kimi:\n+            # Kimi's /coding endpoint enables thinking server-side and\n+            # requires unsigned thinking blocks on replayed assistant\n+            # tool-call messages.  Strip signed Anthropic blocks (Kimi\n+            # can't validate signatures) but preserve the unsigned ones\n+            # we synthesised from reasoning_content above.\n+            new_content = []\n+            for b in m[\"content\"]:\n+                if not isinstance(b, dict) or b.get(\"type\") not in _THINKING_TYPES:\n+                    new_content.append(b)\n+                    continue\n+                if b.get(\"signature\") or b.get(\"data\"):\n+                    # Anthropic-signed block — Kimi can't validate, strip\n+                    continue\n+                # Unsigned thinking (synthesised from reasoning_content) —\n+                # keep it: Kimi needs it for message-history validation.\n+                new_content.append(b)\n+            m[\"content\"] = new_content or [{\"type\": \"text\", \"text\": \"(empty)\"}]\n+        elif _is_third_party or idx != last_assistant_idx:\n             # Third-party endpoint: strip ALL thinking blocks from every\n             # assistant message — signatures are Anthropic-proprietary.\n             # Direct Anthropic: strip from non-latest assistant messages only."]},{"file":"scripts/release.py","hunks":["@@ -95,6 +95,8 @@\n     \"[redacted:email]\": \"sgaofen\",\n     \"[redacted:email]\": \"nocoo\",\n     \"[redacted:email]\": \"n-WN\",\n+    \"[redacted:email]\": \"hiddenpuppy\",\n+    \"[redacted:email]\": \"HiddenPuppy\",\n     \"[redacted:email]\": \"keyuyuan\",\n     \"[redacted:email]\": \"Only-Code-A\",\n     \"[redacted:email]\": \"TroyMitchell911\",\n"]}]},{"kind":"pr","number":14941,"title":"fix: set empty reasoning_content for DeepSeek when tool_calls present","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"93a2d6b307674fc9975538cd7a2f903068211c95","number":0},{"kind":"commit","value":"d58b305ad","number":0},{"kind":"commit","value":"d58b305adfdb5697c0ff268696040b0505d40892","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":14973,"title":"fix: extract DeepSeek reasoning_content from model_extra","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"93a2d6b30","number":0},{"kind":"commit","value":"d58b305ad","number":0},{"kind":"commit","value":"ad0ac8947","number":0},{"kind":"commit","value":"5ae608152","number":0},{"kind":"commit","value":"ad0ac8947","number":0},{"kind":"commit","value":"9daa0620a","number":0},{"kind":"commit","value":"63bf7a29b","number":0},{"kind":"pr","value":"#16500","number":16500}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":15354,"title":"fix: add DeepSeek reasoning_content echo for tool-call messages (fixes #15353)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"93a2d6b30","number":0},{"kind":"pr","value":"#15407","number":15407},{"kind":"pr","value":"#15250","number":15250},{"kind":"pr","value":"#15353","number":15353}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":15407,"title":"fix: DeepSeek V4 thinking mode reasoning_content echo on tool-call messages","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","scripts/release.py","tests/run_agent/test_deepseek_reasoning_content_echo.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -7625,6 +7625,12 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic\n             raw_reasoning_content = getattr(assistant_message, \"reasoning_content\", None)\n             if raw_reasoning_content is not None:\n                 msg[\"reasoning_content\"] = _sanitize_surrogates(raw_reasoning_content)\n+            elif msg.get(\"tool_calls\") and self._needs_deepseek_tool_reasoning():\n+                # DeepSeek thinking mode requires reasoning_content on every\n+                # assistant tool-call message. Without it, replaying the\n+                # persisted message causes HTTP 400. Include empty string\n+                # as a defensive compatibility fallback (refs #15250).\n+                msg[\"reasoning_content\"] = \"\"\n \n         if hasattr(assistant_message, 'reasoning_details') and assistant_message.reasoning_details:\n             # Pass reasoning_details back unmodified so providers (OpenRouter,","@@ -7700,6 +7706,35 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic\n \n         return msg\n \n+    def _needs_kimi_tool_reasoning(self) -> bool:\n+        \"\"\"Return True when the current provider is Kimi / Moonshot thinking mode.\n+\n+        Kimi ``/coding`` and Moonshot thinking mode both require\n+        ``reasoning_content`` on every assistant tool-call message; omitting\n+        it causes the next replay to fail with HTTP 400.\n+        \"\"\"\n+        return (\n+            self.provider in {\"kimi-coding\", \"kimi-coding-cn\"}\n+            or base_url_host_matches(self.base_url, \"api.kimi.com\")\n+            or base_url_host_matches(self.base_url, \"moonshot.ai\")\n+            or base_url_host_matches(self.base_url, \"moonshot.cn\")\n+        )\n+\n+    def _needs_deepseek_tool_reasoning(self) -> bool:\n+        \"\"\"Return True when the current provider is DeepSeek thinking mode.\n+\n+        DeepSeek V4 thinking mode requires ``reasoning_content`` on every\n+        assistant tool-call turn; omitting it causes HTTP 400 when the\n+        message is replayed in a subsequent API request (#15250).\n+        \"\"\"\n+        provider = (self.provider or \"\").lower()\n+        model = (self.model or \"\").lower()\n+        return (\n+            provider == \"deepseek\"\n+            or \"deepseek\" in model\n+            or base_url_host_matches(self.base_url, \"api.deepseek.com\")\n+        )\n+\n     def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> None:\n         \"\"\"Copy provider-facing reasoning fields onto an API replay message.\"\"\"\n         if source_msg.get(\"role\") != \"assistant\":","@@ -7715,13 +7750,14 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No\n             api_msg[\"reasoning_content\"] = normalized_reasoning\n             return\n \n-        kimi_requires_reasoning = (\n-            self.provider in {\"kimi-coding\", \"kimi-coding-cn\"}\n-            or base_url_host_matches(self.base_url, \"api.kimi.com\")\n-            or base_url_host_matches(self.base_url, \"moonshot.ai\")\n-            or base_url_host_matches(self.base_url, \"moonshot.cn\")\n-        )\n-        if kimi_requires_reasoning and source_msg.get(\"tool_calls\"):\n+        # Providers that require an echoed reasoning_content on every\n+        # assistant tool-call turn. Detection logic lives in the per-provider\n+        # helpers so both the creation path (_build_assistant_message) and\n+        # this replay path stay in sync.\n+        if source_msg.get(\"tool_calls\") and (\n+            self._needs_kimi_tool_reasoning()\n+            or self._needs_deepseek_tool_reasoning()\n+        ):\n             api_msg[\"reasoning_content\"] = \"\"\n \n     @staticmethod","@@ -9095,6 +9131,7 @@ def _handle_max_iterations(self, messages: list, api_call_count: int) -> str:\n             api_messages = []\n             for msg in messages:\n                 api_msg = msg.copy()\n+                self._copy_reasoning_content_for_api(msg, api_msg)\n                 for internal_field in (\"reasoning\", \"finish_reason\", \"_thinking_prefill\"):\n                     api_msg.pop(internal_field, None)\n                 if _needs_sanitize:"]},{"file":"scripts/release.py","hunks":["@@ -499,6 +499,7 @@\n     \"[redacted:email]\": \"zhangxicen\",\n     \"[redacted:email]\": \"teknium1\",\n     \"[redacted:email]\": \"teknium1\",\n+    \"[redacted:email]\": \"chen1749144759\",\n }\n \n "]},{"file":"tests/run_agent/test_deepseek_reasoning_content_echo.py","hunks":["@@ -0,0 +1,213 @@\n+\"\"\"Regression test: DeepSeek V4 thinking mode reasoning_content echo.\n+\n+DeepSeek V4-flash / V4-pro thinking mode requires ``reasoning_content`` on\n+every assistant message that carries ``tool_calls``. When a persisted\n+session replays an assistant tool-call turn that was recorded without the\n+field, DeepSeek rejects the next request with HTTP 400::\n+\n+    The reasoning_content in the thinking mode must be passed back to the API.\n+\n+Fix covers three paths:\n+\n+1. ``_build_assistant_message`` — new tool-call messages without raw\n+   reasoning_content get ``\"\"`` pinned at creation time so nothing gets\n+   persisted poisoned.\n+2. ``_copy_reasoning_content_for_api`` — already-poisoned history replays\n+   with ``reasoning_content=\"\"`` injected defensively.\n+3. Detection covers three signals: ``provider == \"deepseek\"``,\n+   ``\"deepseek\" in model``, and ``api.deepseek.com`` host match. The third\n+   catches custom-provider setups pointing at DeepSeek.\n+\n+Refs #15250 / #15353.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import pytest\n+\n+from run_agent import AIAgent\n+\n+\n+def _make_agent(provider: str = \"\", model: str = \"\", base_url: str = \"\") -> AIAgent:\n+    agent = object.__new__(AIAgent)\n+    agent.provider = provider\n+    agent.model = model\n+    agent.base_url = base_url\n+    return agent\n+\n+\n+class TestNeedsDeepSeekToolReasoning:\n+    \"\"\"_needs_deepseek_tool_reasoning() recognises all three detection signals.\"\"\"\n+\n+    def test_provider_deepseek(self) -> None:\n+        agent = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-flash\")\n+        assert agent._needs_deepseek_tool_reasoning() is True\n+\n+    def test_model_substring(self) -> None:\n+        # Custom provider pointing at DeepSeek with provider='custom'\n+        agent = _make_agent(provider=\"custom\", model=\"deepseek-v4-pro\")\n+        assert agent._needs_deepseek_tool_reasoning() is True\n+\n+    def test_base_url_host(self) -> None:\n+        agent = _make_agent(\n+            provider=\"custom\",\n+            model=\"some-aliased-name\",\n+            base_url=\"https://api.deepseek.com/v1\",\n+        )\n+        assert agent._needs_deepseek_tool_reasoning() is True\n+\n+    def test_provider_case_insensitive(self) -> None:\n+        agent = _make_agent(provider=\"DeepSeek\", model=\"\")\n+        assert agent._needs_deepseek_tool_reasoning() is True\n+\n+    def test_non_deepseek_provider(self) -> None:\n+        agent = _make_agent(\n+            provider=\"openrouter\",\n+            model=\"anthropic/claude-sonnet-4.6\",\n+            base_url=\"https://openrouter.ai/api/v1\",\n+        )\n+        assert agent._needs_deepseek_tool_reasoning() is False\n+\n+    def test_empty_everything(self) -> None:\n+        agent = _make_agent()\n+        assert agent._needs_deepseek_tool_reasoning() is False\n+\n+\n+class TestCopyReasoningContentForApi:\n+    \"\"\"_copy_reasoning_content_for_api pads reasoning_content for DeepSeek tool-calls.\"\"\"\n+\n+    def test_deepseek_tool_call_poisoned_history_gets_empty_string(self) -> None:\n+        \"\"\"Already-poisoned history (no reasoning_content, no reasoning) gets ''.\"\"\"\n+        agent = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-flash\")\n+        source = {\n+            \"role\": \"assistant\",\n+            \"content\": \"\",\n+            \"tool_calls\": [{\"id\": \"c1\", \"function\": {\"name\": \"terminal\"}}],\n+        }\n+        api_msg: dict = {}\n+        agent._copy_reasoning_content_for_api(source, api_msg)\n+        assert api_msg.get(\"reasoning_content\") == \"\"\n+\n+    def test_deepseek_assistant_no_tool_call_left_alone(self) -> None:\n+        \"\"\"Plain assistant turns without tool_calls don't get padded.\"\"\"\n+        agent = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-flash\")\n+        source = {\"role\": \"assistant\", \"content\": \"hello\"}\n+        api_msg: dict = {}\n+        agent._copy_reasoning_content_for_api(source, api_msg)\n+        assert \"reasoning_content\" not in api_msg\n+\n+    def test_deepseek_explicit_reasoning_content_preserved(self) -> None:\n+        \"\"\"When reasoning_content is already set, it's copied verbatim.\"\"\"\n+        agent = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-flash\")\n+        source = {\n+            \"role\": \"assistant\",\n+            \"reasoning_content\": \"<think>real chain of thought</think>\",\n+            \"tool_calls\": [{\"id\": \"c1\", \"function\": {\"name\": \"terminal\"}}],\n+        }\n+        api_msg: dict = {}\n+        agent._copy_reasoning_content_for_api(source, api_msg)\n+        assert api_msg[\"reasoning_content\"] == \"<think>real chain of thought</think>\"\n+\n+    def test_deepseek_reasoning_field_promoted(self) -> None:\n+        \"\"\"When only 'reasoning' is set, it gets promoted to reasoning_content.\"\"\"\n+        agent = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-flash\")\n+        source = {\n+            \"role\": \"assistant\",\n+            \"reasoning\": \"thought trace\",\n+            \"tool_calls\": [{\"id\": \"c1\", \"function\": {\"name\": \"terminal\"}}],\n+        }\n+        api_msg: dict = {}\n+        agent._copy_reasoning_content_for_api(source, api_msg)\n+        assert api_msg[\"reasoning_content\"] == \"thought trace\"\n+\n+    def test_kimi_path_still_works(self) -> None:\n+        \"\"\"Existing Kimi detection still pads reasoning_content.\"\"\"\n+        agent = _make_agent(provider=\"kimi-coding\", model=\"kimi-k2.5\")\n+        source = {\n+            \"role\": \"assistant\",\n+            \"content\": \"\",\n+            \"tool_calls\": [{\"id\": \"c1\", \"function\": {\"name\": \"terminal\"}}],\n+        }\n+        api_msg: dict = {}\n+        agent._copy_reasoning_content_for_api(source, api_msg)\n+        assert api_msg.get(\"reasoning_content\") == \"\"\n+\n+    def test_kimi_moonshot_base_url(self) -> None:\n+        agent = _make_agent(\n+            provider=\"custom\", model=\"kimi-k2\", base_url=\"https://api.moonshot.ai/v1\"\n+        )\n+        source = {\n+            \"role\": \"assistant\",\n+            \"content\": \"\",\n+            \"tool_calls\": [{\"id\": \"c1\", \"function\": {\"name\": \"terminal\"}}],\n+        }\n+        api_msg: dict = {}\n+        agent._copy_reasoning_content_for_api(source, api_msg)\n+        assert api_msg.get(\"reasoning_content\") == \"\"\n+\n+    def test_non_thinking_provider_not_padded(self) -> None:\n+        \"\"\"Providers that don't require the echo are untouched.\"\"\"\n+        agent = _make_agent(\n+            provider=\"openrouter\",\n+            model=\"anthropic/claude-sonnet-4.6\",\n+            base_url=\"https://openrouter.ai/api/v1\",\n+        )\n+        source = {\n+            \"role\": \"assistant\",\n+            \"content\": \"\",\n+            \"tool_calls\": [{\"id\": \"c1\", \"function\": {\"name\": \"terminal\"}}],\n+        }\n+        api_msg: dict = {}\n+        agent._copy_reasoning_content_for_api(source, api_msg)\n+        assert \"reasoning_content\" not in api_msg\n+\n+    def test_deepseek_custom_base_url(self) -> None:\n+        \"\"\"Custom provider pointing at api.deepseek.com is detected via host.\"\"\"\n+        agent = _make_agent(\n+            provider=\"custom\",\n+            model=\"whatever\",\n+            base_url=\"https://api.deepseek.com/v1\",\n+        )\n+        source = {\n+            \"role\": \"assistant\",\n+            \"content\": \"\",\n+            \"tool_calls\": [{\"id\": \"c1\", \"function\": {\"name\": \"terminal\"}}],\n+        }\n+        api_msg: dict = {}\n+        agent._copy_reasoning_content_for_api(source, api_msg)\n+        assert api_msg.get(\"reasoning_content\") == \"\"\n+\n+    def test_non_assistant_role_ignored(self) -> None:\n+        \"\"\"User/tool messages are left alone.\"\"\"\n+        agent = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-flash\")\n+        source = {\"role\": \"user\", \"content\": \"hi\"}\n+        api_msg: dict = {}\n+        agent._copy_reasoning_content_for_api(source, api_msg)\n+        assert \"reasoning_content\" not in api_msg\n+\n+\n+class TestNeedsKimiToolReasoning:\n+    \"\"\"The extracted _needs_kimi_tool_reasoning() helper keeps Kimi behavior intact.\"\"\"\n+\n+    @pytest.mark.parametrize(\n+        \"provider,base_url\",\n+        [\n+            (\"kimi-coding\", \"\"),\n+            (\"kimi-coding-cn\", \"\"),\n+            (\"custom\", \"https://api.kimi.com/v1\"),\n+            (\"custom\", \"https://api.moonshot.ai/v1\"),\n+            (\"custom\", \"https://api.moonshot.cn/v1\"),\n+        ],\n+    )\n+    def test_kimi_signals(self, provider: str, base_url: str) -> None:\n+        agent = _make_agent(provider=provider, model=\"kimi-k2\", base_url=base_url)\n+        assert agent._needs_kimi_tool_reasoning() is True\n+\n+    def test_non_kimi_provider(self) -> None:\n+        agent = _make_agent(\n+            provider=\"openrouter\",\n+            model=\"moonshotai/kimi-k2\",\n+            base_url=\"https://openrouter.ai/api/v1\",\n+        )\n+        # model name contains 'moonshot' but host is openrouter — should be False\n+        assert agent._needs_kimi_tool_reasoning() is False\n"]}]},{"kind":"pr","number":15446,"title":"fix(agent): comprehensive DeepSeek V4 support — context windows, thinking mode, reasoning replay","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"93a2d6b30","number":0},{"kind":"commit","value":"d58b305ad","number":0},{"kind":"commit","value":"ad0ac8947","number":0},{"kind":"commit","value":"5ae608152","number":0},{"kind":"commit","value":"ad0ac8947","number":0},{"kind":"commit","value":"9daa0620a","number":0},{"kind":"commit","value":"63bf7a29b","number":0},{"kind":"pr","value":"#16500","number":16500}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":15478,"title":"fix: DeepSeek/Kimi thinking mode requires reasoning_content on ALL assistant messages","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/run_agent/test_deepseek_reasoning_content_echo.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -7785,7 +7785,17 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No\n             api_msg[\"reasoning_content\"] = normalized_reasoning\n             return\n \n-        # 4. reasoning_content was present but not a string (e.g. None after\n+        # 4. DeepSeek / Kimi thinking mode: all assistant messages need\n+        # reasoning_content. Inject \"\" to satisfy the provider's requirement\n+        # when no explicit reasoning content is present.\n+        if (\n+            self._needs_kimi_tool_reasoning()\n+            or self._needs_deepseek_tool_reasoning()\n+        ):\n+            api_msg[\"reasoning_content\"] = \"\"\n+            return\n+\n+        # 5. reasoning_content was present but not a string (e.g. None after\n         # context compaction).  Don't pass null to the API.\n         api_msg.pop(\"reasoning_content\", None)\n "]},{"file":"tests/run_agent/test_deepseek_reasoning_content_echo.py","hunks":["@@ -88,13 +88,13 @@ def test_deepseek_tool_call_poisoned_history_gets_empty_string(self) -> None:\n         agent._copy_reasoning_content_for_api(source, api_msg)\n         assert api_msg.get(\"reasoning_content\") == \"\"\n \n-    def test_deepseek_assistant_no_tool_call_left_alone(self) -> None:\n-        \"\"\"Plain assistant turns without tool_calls don't get padded.\"\"\"\n+    def test_deepseek_assistant_no_tool_call_gets_padded(self) -> None:\n+        \"\"\"DeepSeek thinking mode pads ALL assistant turns, even without tool_calls.\"\"\"\n         agent = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-flash\")\n         source = {\"role\": \"assistant\", \"content\": \"hello\"}\n         api_msg: dict = {}\n         agent._copy_reasoning_content_for_api(source, api_msg)\n-        assert \"reasoning_content\" not in api_msg\n+        assert api_msg.get(\"reasoning_content\") == \"\"\n \n     def test_deepseek_explicit_reasoning_content_preserved(self) -> None:\n         \"\"\"When reasoning_content is already set, it's copied verbatim.\"\"\"\n"]}]},{"kind":"pr","number":15688,"title":"fix(auxiliary): sanitize reasoning_content for DeepSeek/Kimi tool-call replay","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":15760,"title":"Fix DeepSeek reasoning_content propagation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"93a2d6b30","number":0},{"kind":"commit","value":"d58b305ad","number":0},{"kind":"commit","value":"ad0ac8947","number":0},{"kind":"commit","value":"5ae608152","number":0},{"kind":"commit","value":"ad0ac8947","number":0},{"kind":"commit","value":"9daa0620a","number":0},{"kind":"commit","value":"63bf7a29b","number":0},{"kind":"pr","value":"#16500","number":16500}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":15792,"title":"fix(deepseek): pad reasoning_content on plain assistant turns in thinking mode (#15741)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"5ae608152","number":0},{"kind":"commit","value":"ad0ac8947","number":0},{"kind":"commit","value":"63bf7a29b","number":0}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":15804,"title":"fix: extract reasoning_content from model_extra for DeepSeek/Moonshot","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"93a2d6b30","number":0},{"kind":"commit","value":"d58b305ad","number":0},{"kind":"commit","value":"ad0ac8947","number":0},{"kind":"commit","value":"5ae608152","number":0},{"kind":"commit","value":"ad0ac8947","number":0},{"kind":"commit","value":"9daa0620a","number":0},{"kind":"commit","value":"63bf7a29b","number":0},{"kind":"pr","value":"#16500","number":16500}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":16500,"title":"fix(agent): block cross-provider reasoning leak to DeepSeek/Kimi (#15748)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/run_agent/test_deepseek_reasoning_content_echo.py"]},{"kind":"pr","number":16614,"title":"fix(transport): emit deepseek-v4 thinking.type and reasoning_effort on non-OpenRouter routes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14958","number":14958},{"kind":"pr","value":"#15577","number":15577},{"kind":"pr","value":"#16448","number":16448},{"kind":"pr","value":"#15251","number":15251}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":16781,"title":"fix: preserve DeepSeek thinking blocks on Anthropic replay (#16748)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"fd5479a4f","number":0},{"kind":"pr","value":"#17510","number":17510}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":17223,"title":"fix(anthropic_adapter): preserve unsigned thinking blocks for DeepSeek /anthropic (#16748)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a7e7921db","number":0},{"kind":"pr","value":"#16748","number":16748}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":17225,"title":"fix(agent): send thinking control to DeepSeek direct API to prevent 400 on tool-call replay","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":17489,"title":"fix(agent): pad reasoning_content on DeepSeek/Kimi tool-call turns","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"76edc40ab","number":0},{"kind":"pr","value":"#18045","number":18045},{"kind":"pr","value":"#16855","number":16855}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":17510,"title":"fix(anthropic): preserve DeepSeek /anthropic thinking blocks on replay (#16748)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","tests/agent/test_deepseek_anthropic_thinking.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -420,6 +420,33 @@ def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) ->\n     return False\n \n \n+def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool:\n+    \"\"\"Return True for DeepSeek's Anthropic-compatible endpoint.\n+\n+    DeepSeek's ``/anthropic`` route speaks the Anthropic Messages protocol\n+    but, when thinking mode is enabled, requires the ``thinking`` blocks\n+    from prior assistant turns to round-trip on subsequent requests — the\n+    generic third-party path strips them and triggers HTTP 400::\n+\n+        The content[].thinking in the thinking mode must be passed back\n+        to the API.\n+\n+    Per DeepSeek's published compatibility matrix the blocks are unsigned\n+    (no Anthropic-proprietary signature, no ``redacted_thinking`` support),\n+    so this endpoint is handled with the same strip-signed / keep-unsigned\n+    policy used for Kimi's ``/coding`` endpoint.  The match is pinned to\n+    the ``/anthropic`` path so the OpenAI-compatible ``api.deepseek.com``\n+    base URL (which never reaches this adapter) is not misclassified.\n+    See hermes-agent#16748.\n+    \"\"\"\n+    if not base_url_host_matches(base_url or \"\", \"api.deepseek.com\"):\n+        return False\n+    normalized = _normalize_base_url_text(base_url)\n+    if not normalized:\n+        return False\n+    return \"/anthropic\" in normalized.rstrip(\"/\").lower()\n+\n+\n def _requires_bearer_auth(base_url: str | None) -> bool:\n     \"\"\"Return True for Anthropic-compatible providers that require Bearer auth.\n ","@@ -1569,7 +1596,16 @@ def convert_messages_to_anthropic(\n     #    cache markers can interfere with signature validation.\n     _THINKING_TYPES = frozenset((\"thinking\", \"redacted_thinking\"))\n     _is_third_party = _is_third_party_anthropic_endpoint(base_url)\n-    _is_kimi = _is_kimi_family_endpoint(base_url, model)\n+    # Kimi /coding and DeepSeek /anthropic share a contract: both speak the\n+    # Anthropic Messages protocol upstream but require that thinking blocks\n+    # synthesised from reasoning_content round-trip on subsequent turns when\n+    # thinking is enabled.  Signed Anthropic blocks still have to be stripped\n+    # (neither endpoint can validate Anthropic's signatures); unsigned blocks\n+    # are preserved.  See hermes-agent#13848 (Kimi) and #16748 (DeepSeek).\n+    _preserve_unsigned_thinking = (\n+        _is_kimi_family_endpoint(base_url, model)\n+        or _is_deepseek_anthropic_endpoint(base_url)\n+    )\n \n     last_assistant_idx = None\n     for i in range(len(result) - 1, -1, -1):","@@ -1581,22 +1617,22 @@ def convert_messages_to_anthropic(\n         if m.get(\"role\") != \"assistant\" or not isinstance(m.get(\"content\"), list):\n             continue\n \n-        if _is_kimi:\n-            # Kimi's /coding endpoint enables thinking server-side and\n-            # requires unsigned thinking blocks on replayed assistant\n-            # tool-call messages.  Strip signed Anthropic blocks (Kimi\n-            # can't validate signatures) but preserve the unsigned ones\n-            # we synthesised from reasoning_content above.\n+        if _preserve_unsigned_thinking:\n+            # Kimi's /coding and DeepSeek's /anthropic endpoints both enable\n+            # thinking server-side and require unsigned thinking blocks on\n+            # replayed assistant tool-call messages.  Strip signed Anthropic\n+            # blocks (neither upstream can validate Anthropic signatures) but\n+            # preserve the unsigned ones we synthesised from reasoning_content.\n             new_content = []\n             for b in m[\"content\"]:\n                 if not isinstance(b, dict) or b.get(\"type\") not in _THINKING_TYPES:\n                     new_content.append(b)\n                     continue\n                 if b.get(\"signature\") or b.get(\"data\"):\n-                    # Anthropic-signed block — Kimi can't validate, strip\n+                    # Anthropic-signed block — upstream can't validate, strip\n                     continue\n                 # Unsigned thinking (synthesised from reasoning_content) —\n-                # keep it: Kimi needs it for message-history validation.\n+                # keep it: the upstream needs it for message-history validation.\n                 new_content.append(b)\n             m[\"content\"] = new_content or [{\"type\": \"text\", \"text\": \"(empty)\"}]\n         elif _is_third_party or idx != last_assistant_idx:"]},{"file":"tests/agent/test_deepseek_anthropic_thinking.py","hunks":["@@ -0,0 +1,242 @@\n+\"\"\"Regression guard: preserve thinking blocks on DeepSeek's /anthropic endpoint.\n+\n+DeepSeek's ``api.deepseek.com/anthropic`` route speaks the Anthropic Messages\n+protocol but, when thinking mode is enabled, requires ``thinking`` blocks from\n+prior assistant turns to round-trip on subsequent requests.  The generic\n+third-party path strips them (signatures are Anthropic-proprietary and other\n+proxies cannot validate them), so without a DeepSeek-specific carve-out the\n+next tool-call turn fails with HTTP 400::\n+\n+    The content[].thinking in the thinking mode must be passed back to the\n+    API.\n+\n+DeepSeek's compatibility matrix lists ``thinking`` as supported but\n+``redacted_thinking`` and ``cache_control`` on thinking blocks as not\n+supported.  Handling is the same as Kimi's ``/coding`` endpoint: strip\n+Anthropic-signed blocks (DeepSeek can't validate them) but preserve unsigned\n+blocks that Hermes synthesises from ``reasoning_content``.\n+\n+See hermes-agent#16748.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import pytest\n+\n+\n+class TestDeepSeekAnthropicPreservesThinking:\n+    \"\"\"convert_messages_to_anthropic must replay DeepSeek thinking blocks.\"\"\"\n+\n+    @pytest.mark.parametrize(\n+        \"base_url\",\n+        [\n+            \"https://api.deepseek.com/anthropic\",\n+            \"https://api.deepseek.com/anthropic/\",\n+            \"https://api.deepseek.com/anthropic/v1\",\n+            \"https://API.DeepSeek.com/anthropic\",\n+        ],\n+    )\n+    def test_unsigned_thinking_block_survives_replay(self, base_url: str) -> None:\n+        \"\"\"Unsigned thinking (synthesised from reasoning_content) must be preserved.\"\"\"\n+        from agent.anthropic_adapter import convert_messages_to_anthropic\n+\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"hi\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"reasoning_content\": \"planning the tool call\",\n+                \"tool_calls\": [\n+                    {\n+                        \"id\": \"call_1\",\n+                        \"type\": \"function\",\n+                        \"function\": {\"name\": \"skill_view\", \"arguments\": \"{}\"},\n+                    }\n+                ],\n+            },\n+            {\"role\": \"tool\", \"tool_call_id\": \"call_1\", \"content\": \"ok\"},\n+        ]\n+        _system, converted = convert_messages_to_anthropic(\n+            messages, base_url=base_url\n+        )\n+\n+        assistant_msg = next(m for m in converted if m[\"role\"] == \"assistant\")\n+        thinking_blocks = [\n+            b for b in assistant_msg[\"content\"]\n+            if isinstance(b, dict) and b.get(\"type\") == \"thinking\"\n+        ]\n+        assert len(thinking_blocks) == 1, (\n+            f\"DeepSeek /anthropic ({base_url}) must preserve unsigned thinking \"\n+            \"blocks synthesised from reasoning_content — upstream rejects \"\n+            \"replayed tool-call messages without them.\"\n+        )\n+        assert thinking_blocks[0][\"thinking\"] == \"planning the tool call\"\n+        # Synthesised block — never has a signature\n+        assert \"signature\" not in thinking_blocks[0]\n+\n+    def test_unsigned_thinking_preserved_on_non_latest_assistant_turn(self) -> None:\n+        \"\"\"DeepSeek validates history across every prior assistant turn, not just last.\"\"\"\n+        from agent.anthropic_adapter import convert_messages_to_anthropic\n+\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"q1\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"reasoning_content\": \"r1\",\n+                \"tool_calls\": [\n+                    {\n+                        \"id\": \"call_1\",\n+                        \"type\": \"function\",\n+                        \"function\": {\"name\": \"f\", \"arguments\": \"{}\"},\n+                    }\n+                ],\n+            },\n+            {\"role\": \"tool\", \"tool_call_id\": \"call_1\", \"content\": \"ok\"},\n+            {\"role\": \"user\", \"content\": \"q2\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"reasoning_content\": \"r2\",\n+                \"tool_calls\": [\n+                    {\n+                        \"id\": \"call_2\",\n+                        \"type\": \"function\",\n+                        \"function\": {\"name\": \"f\", \"arguments\": \"{}\"},\n+                    }\n+                ],\n+            },\n+            {\"role\": \"tool\", \"tool_call_id\": \"call_2\", \"content\": \"ok\"},\n+        ]\n+        _system, converted = convert_messages_to_anthropic(\n+            messages, base_url=\"https://api.deepseek.com/anthropic\"\n+        )\n+\n+        assistants = [m for m in converted if m[\"role\"] == \"assistant\"]\n+        assert len(assistants) == 2\n+        for assistant, expected in zip(assistants, (\"r1\", \"r2\")):\n+            thinking = [\n+                b for b in assistant[\"content\"]\n+                if isinstance(b, dict) and b.get(\"type\") == \"thinking\"\n+            ]\n+            assert len(thinking) == 1\n+            assert thinking[0][\"thinking\"] == expected\n+\n+    def test_signed_anthropic_thinking_block_is_stripped(self) -> None:\n+        \"\"\"Anthropic-signed blocks (that leaked through) must still be stripped.\n+\n+        DeepSeek issues its own signatures and cannot validate Anthropic's —\n+        the strip-signed / keep-unsigned split matches the Kimi policy.\n+        \"\"\"\n+        from agent.anthropic_adapter import convert_messages_to_anthropic\n+\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"hi\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": [\n+                    {\n+                        \"type\": \"thinking\",\n+                        \"thinking\": \"anthropic-signed payload\",\n+                        \"signature\": \"anthropic-sig-xyz\",\n+                    },\n+                    {\"type\": \"text\", \"text\": \"hello\"},\n+                ],\n+            },\n+            {\"role\": \"user\", \"content\": \"again\"},\n+        ]\n+        _system, converted = convert_messages_to_anthropic(\n+            messages, base_url=\"https://api.deepseek.com/anthropic\"\n+        )\n+\n+        assistant_msg = next(m for m in converted if m[\"role\"] == \"assistant\")\n+        thinking_blocks = [\n+            b for b in assistant_msg[\"content\"]\n+            if isinstance(b, dict) and b.get(\"type\") == \"thinking\"\n+        ]\n+        assert thinking_blocks == [], (\n+            \"Signed Anthropic thinking blocks must be stripped on DeepSeek — \"\n+            \"DeepSeek cannot validate Anthropic-proprietary signatures.\"\n+        )\n+\n+    def test_cache_control_stripped_from_thinking_block(self) -> None:\n+        \"\"\"cache_control must still be stripped even when the block is preserved.\n+\n+        DeepSeek's compatibility matrix lists cache_control on thinking blocks\n+        as ignored — cache markers interfere with signature validation on\n+        upstreams that do check them, so Hermes strips them everywhere.\n+        \"\"\"\n+        from agent.anthropic_adapter import convert_messages_to_anthropic\n+\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"hi\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"reasoning_content\": \"r1\",\n+                \"tool_calls\": [\n+                    {\n+                        \"id\": \"call_1\",\n+                        \"type\": \"function\",\n+                        \"function\": {\"name\": \"f\", \"arguments\": \"{}\"},\n+                    }\n+                ],\n+            },\n+            {\"role\": \"tool\", \"tool_call_id\": \"call_1\", \"content\": \"ok\"},\n+        ]\n+        # Inject cache_control on the synthesised thinking block after-the-fact\n+        # by running conversion once, mutating, then re-running would be\n+        # indirect.  Instead check the simpler invariant: no thinking block in\n+        # the converted output carries cache_control.\n+        _system, converted = convert_messages_to_anthropic(\n+            messages, base_url=\"https://api.deepseek.com/anthropic\"\n+        )\n+        for m in converted:\n+            if not isinstance(m.get(\"content\"), list):\n+                continue\n+            for b in m[\"content\"]:\n+                if isinstance(b, dict) and b.get(\"type\") in (\"thinking\", \"redacted_thinking\"):\n+                    assert \"cache_control\" not in b\n+\n+    def test_openai_compat_deepseek_base_is_not_matched(self) -> None:\n+        \"\"\"The OpenAI-compatible ``api.deepseek.com`` base must NOT trigger the\n+        DeepSeek /anthropic branch — it never reaches this adapter, but the\n+        detector should still fail closed so an accidental misuse doesn't\n+        quietly send signed Anthropic blocks to an OpenAI endpoint.\n+        \"\"\"\n+        from agent.anthropic_adapter import _is_deepseek_anthropic_endpoint\n+\n+        assert _is_deepseek_anthropic_endpoint(\"https://api.deepseek.com\") is False\n+        assert _is_deepseek_anthropic_endpoint(\"https://api.deepseek.com/v1\") is False\n+        assert _is_deepseek_anthropic_endpoint(\"https://api.deepseek.com/anthropic\") is True\n+        assert _is_deepseek_anthropic_endpoint(\"https://api.deepseek.com/anthropic/v1\") is True\n+\n+    def test_non_deepseek_third_party_still_strips_all_thinking(self) -> None:\n+        \"\"\"MiniMax and other third-party Anthropic endpoints must keep the\n+        generic strip-all behaviour (they reject unsigned blocks outright).\n+        \"\"\"\n+        from agent.anthropic_adapter import convert_messages_to_anthropic\n+\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"hi\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"reasoning_content\": \"r1\",\n+                \"tool_calls\": [\n+                    {\n+                        \"id\": \"call_1\",\n+                        \"type\": \"function\",\n+                        \"function\": {\"name\": \"f\", \"arguments\": \"{}\"},\n+                    }\n+                ],\n+            },\n+            {\"role\": \"tool\", \"tool_call_id\": \"call_1\", \"content\": \"ok\"},\n+        ]\n+        _system, converted = convert_messages_to_anthropic(\n+            messages, base_url=\"https://api.minimax.io/anthropic\"\n+        )\n+        assistant_msg = next(m for m in converted if m[\"role\"] == \"assistant\")\n+        thinking_blocks = [\n+            b for b in assistant_msg[\"content\"]\n+            if isinstance(b, dict) and b.get(\"type\") == \"thinking\"\n+        ]\n+        assert thinking_blocks == [], (\n+            \"Non-DeepSeek third-party endpoints must keep the generic \"\n+            \"strip-all-thinking behaviour — unsigned blocks get rejected.\"\n+        )\n"]}]},{"kind":"pr","number":18045,"title":"fix(agent): preserve reasoning_content replay on DeepSeek v4 + Kimi/Moonshot thinking","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/transports/chat_completions.py","run_agent.py","scripts/release.py","tests/agent/transports/test_chat_completions.py","tests/run_agent/test_deepseek_reasoning_content_echo.py","tests/run_agent/test_deepseek_v4_thinking_live.py"],"fix_hunks":[{"file":"agent/transports/chat_completions.py","hunks":["@@ -477,9 +477,13 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:\n         # so keep them apart in provider_data rather than merging.\n         reasoning = getattr(msg, \"reasoning\", None)\n         reasoning_content = getattr(msg, \"reasoning_content\", None)\n+        if reasoning_content is None and hasattr(msg, \"model_extra\"):\n+            model_extra = getattr(msg, \"model_extra\", None) or {}\n+            if isinstance(model_extra, dict) and \"reasoning_content\" in model_extra:\n+                reasoning_content = model_extra[\"reasoning_content\"]\n \n         provider_data: Dict[str, Any] = {}\n-        if reasoning_content:\n+        if reasoning_content is not None:\n             provider_data[\"reasoning_content\"] = reasoning_content\n         rd = getattr(msg, \"reasoning_details\", None)\n         if rd:"]},{"file":"run_agent.py","hunks":["@@ -8501,6 +8501,7 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic\n         Handles reasoning extraction, reasoning_details, and optional tool_calls\n         so both the tool-call path and the final-response path share one builder.\n         \"\"\"\n+        assistant_tool_calls = getattr(assistant_message, \"tool_calls\", None)\n         reasoning_text = self._extract_reasoning(assistant_message)\n         _from_structured = bool(reasoning_text)\n ","@@ -8560,16 +8561,22 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic\n             \"finish_reason\": finish_reason,\n         }\n \n-        if hasattr(assistant_message, \"reasoning_content\"):\n-            raw_reasoning_content = getattr(assistant_message, \"reasoning_content\", None)\n-            if raw_reasoning_content is not None:\n-                msg[\"reasoning_content\"] = _sanitize_surrogates(raw_reasoning_content)\n-            elif msg.get(\"tool_calls\") and self._needs_deepseek_tool_reasoning():\n-                # DeepSeek thinking mode requires reasoning_content on every\n-                # assistant tool-call message. Without it, replaying the\n-                # persisted message causes HTTP 400. Include empty string\n-                # as a defensive compatibility fallback (refs #15250).\n-                msg[\"reasoning_content\"] = \"\"\n+        raw_reasoning_content = getattr(assistant_message, \"reasoning_content\", None)\n+        if raw_reasoning_content is None and hasattr(assistant_message, \"model_extra\"):\n+            model_extra = getattr(assistant_message, \"model_extra\", None) or {}\n+            if isinstance(model_extra, dict) and \"reasoning_content\" in model_extra:\n+                raw_reasoning_content = model_extra[\"reasoning_content\"]\n+        if raw_reasoning_content is not None:\n+            msg[\"reasoning_content\"] = _sanitize_surrogates(raw_reasoning_content)\n+        elif assistant_tool_calls and self._needs_thinking_reasoning_pad():\n+            # DeepSeek v4 thinking mode and Kimi / Moonshot thinking mode\n+            # both require reasoning_content on every assistant tool-call\n+            # message. Without it, replaying the persisted message causes\n+            # HTTP 400 (\"The reasoning_content in the thinking mode must\n+            # be passed back to the API\"). Include streamed reasoning\n+            # text when captured; otherwise pad with empty string.\n+            # Refs #15250, #17400.\n+            msg[\"reasoning_content\"] = reasoning_text or \"\"\n \n         # Additive fallback (refs #16844, #16884). Streaming-only providers\n         # (glm, MiniMax, gpt-5.x via aigw, Anthropic via openai-compat shims)","@@ -8626,9 +8633,9 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic\n         if codex_message_items:\n             msg[\"codex_message_items\"] = codex_message_items\n \n-        if assistant_message.tool_calls:\n+        if assistant_tool_calls:\n             tool_calls = []\n-            for tool_call in assistant_message.tool_calls:\n+            for tool_call in assistant_tool_calls:\n                 raw_id = getattr(tool_call, \"id\", None)\n                 call_id = getattr(tool_call, \"call_id\", None)\n                 if not isinstance(call_id, str) or not call_id.strip():","@@ -8677,6 +8684,18 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic\n \n         return msg\n \n+    def _needs_thinking_reasoning_pad(self) -> bool:\n+        \"\"\"Return True when the active provider enforces reasoning_content echo-back.\n+\n+        DeepSeek v4 thinking and Kimi / Moonshot thinking both reject replays\n+        of assistant tool-call messages that omit ``reasoning_content`` (refs\n+        #15250, #17400).\n+        \"\"\"\n+        return (\n+            self._needs_deepseek_tool_reasoning()\n+            or self._needs_kimi_tool_reasoning()\n+        )\n+\n     def _needs_kimi_tool_reasoning(self) -> bool:\n         \"\"\"Return True when the current provider is Kimi / Moonshot thinking mode.\n ","@@ -8719,20 +8738,17 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No\n             api_msg[\"reasoning_content\"] = existing\n             return\n \n-        needs_thinking_pad = (\n-            self._needs_kimi_tool_reasoning()\n-            or self._needs_deepseek_tool_reasoning()\n-        )\n+        needs_thinking_pad = self._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\n         # prior provider (e.g. MiniMax) — DeepSeek's own _build_assistant_message\n-        # always pins reasoning_content=\"\" at creation time for tool-call turns,\n-        # so the shape (reasoning set, reasoning_content absent, tool_calls\n-        # present) is unreachable from same-provider DeepSeek history. Inject\n-        # \"\" to satisfy the API without leaking another provider's chain of\n-        # thought to DeepSeek/Kimi.\n+        # pins reasoning_content at creation time for tool-call turns, so the\n+        # shape (reasoning set, reasoning_content absent, tool_calls present)\n+        # is unreachable from same-provider DeepSeek history after this fix.\n+        # Inject \"\" to satisfy the API without leaking another provider's\n+        # chain of thought to DeepSeek/Kimi.\n         normalized_reasoning = source_msg.get(\"reasoning\")\n         if (\n             needs_thinking_pad","@@ -8745,9 +8761,9 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No\n \n         # 3. Healthy session: promote 'reasoning' field to 'reasoning_content'\n         # for providers that use the internal 'reasoning' key.\n-        # This must happen BEFORE the DeepSeek/Kimi tool-call check so that\n-        # genuine reasoning content is not overwritten by the empty-string\n-        # fallback (#15812 regression in PR #15478).\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         if isinstance(normalized_reasoning, str) and normalized_reasoning:\n             api_msg[\"reasoning_content\"] = normalized_reasoning\n             return"]},{"file":"scripts/release.py","hunks":["@@ -53,6 +53,9 @@\n     \"sr@samirusani\": \"samrusani\",\n     \"[redacted:email]\": \"angel12\",\n     \"[redacted:email]\": \"charles-brooks\",\n+    # DeepSeek v4 + Kimi thinking-mode reasoning_content salvage (April 2026)\n+    \"[redacted:email]\": \"lsdsjy\",\n+    \"[redacted:email]\": \"season179\",\n     \"[redacted:email]\": \"heathley\",\n     \"[redacted:email]\": \"dandaka\",\n     \"[redacted:email]\": \"cyclingwithelephants\","]},{"file":"tests/agent/transports/test_chat_completions.py","hunks":["@@ -620,6 +620,41 @@ def test_reasoning_content_preserved_separately(self, transport):\n         assert nr.reasoning == \"summary text\"\n         assert nr.provider_data == {\"reasoning_content\": \"detailed scratchpad\"}\n \n+    def test_empty_reasoning_content_preserved(self, transport):\n+        \"\"\"DeepSeek can require an explicit empty reasoning_content replay field.\"\"\"\n+        r = SimpleNamespace(\n+            choices=[SimpleNamespace(\n+                message=SimpleNamespace(\n+                    content=None,\n+                    tool_calls=None,\n+                    reasoning=None,\n+                    reasoning_content=\"\",\n+                ),\n+                finish_reason=\"stop\",\n+            )],\n+            usage=None,\n+        )\n+        nr = transport.normalize_response(r)\n+        assert nr.provider_data == {\"reasoning_content\": \"\"}\n+        assert nr.reasoning_content == \"\"\n+\n+    def test_reasoning_content_preserved_from_model_extra(self, transport):\n+        \"\"\"OpenAI SDK can expose provider-specific DeepSeek fields via model_extra.\"\"\"\n+        r = SimpleNamespace(\n+            choices=[SimpleNamespace(\n+                message=SimpleNamespace(\n+                    content=None,\n+                    tool_calls=None,\n+                    reasoning=None,\n+                    model_extra={\"reasoning_content\": \"model-extra scratchpad\"},\n+                ),\n+                finish_reason=\"stop\",\n+            )],\n+            usage=None,\n+        )\n+        nr = transport.normalize_response(r)\n+        assert nr.provider_data == {\"reasoning_content\": \"model-extra scratchpad\"}\n+\n \n class TestChatCompletionsCacheStats:\n "]},{"file":"tests/run_agent/test_deepseek_reasoning_content_echo.py","hunks":["@@ -23,6 +23,8 @@\n \n from __future__ import annotations\n \n+from types import SimpleNamespace\n+\n import pytest\n \n from run_agent import AIAgent","@@ -33,9 +35,36 @@ def _make_agent(provider: str = \"\", model: str = \"\", base_url: str = \"\") -> AIAg\n     agent.provider = provider\n     agent.model = model\n     agent.base_url = base_url\n+    agent.verbose_logging = False\n+    agent.reasoning_callback = None\n+    agent.stream_delta_callback = None\n+    agent._stream_callback = None\n     return agent\n \n \n+_ATTR_ABSENT = object()\n+_EXPECT_NOT_PRESENT = object()\n+\n+\n+def _sdk_tool_call(call_id: str = \"c1\", name: str = \"terminal\", arguments: str = \"{}\"):\n+    \"\"\"Minimal SDK-shaped tool_call object that satisfies the builder's iteration.\"\"\"\n+    return SimpleNamespace(\n+        id=call_id,\n+        call_id=call_id,\n+        type=\"function\",\n+        function=SimpleNamespace(name=name, arguments=arguments),\n+        extra_content=None,\n+    )\n+\n+\n+def _build_sdk_message(reasoning_content=_ATTR_ABSENT, **extra):\n+    \"\"\"SDK-shaped assistant message; ``reasoning_content`` defaults to absent.\"\"\"\n+    kwargs = {\"content\": \"\", **extra}\n+    if reasoning_content is not _ATTR_ABSENT:\n+        kwargs[\"reasoning_content\"] = reasoning_content\n+    return SimpleNamespace(**kwargs)\n+\n+\n class TestNeedsDeepSeekToolReasoning:\n     \"\"\"_needs_deepseek_tool_reasoning() recognises all three detection signals.\"\"\"\n ","@@ -109,16 +138,7 @@ def test_deepseek_explicit_reasoning_content_preserved(self) -> None:\n         assert api_msg[\"reasoning_content\"] == \"<think>real chain of thought</think>\"\n \n     def test_deepseek_reasoning_field_promoted(self) -> None:\n-        \"\"\"When only 'reasoning' is set (no tool_calls), it gets promoted to reasoning_content.\n-\n-        On DeepSeek/Kimi, tool-call turns with 'reasoning' but no\n-        'reasoning_content' are treated as cross-provider poisoned history\n-        (#15748) and padded with \"\" instead of promoted. Same-provider\n-        DeepSeek tool-call turns always have reasoning_content pinned at\n-        creation time by _build_assistant_message, so the (reasoning-set,\n-        reasoning_content-absent, tool_calls-present) shape is unreachable\n-        from same-provider history.\n-        \"\"\"\n+        \"\"\"When only 'reasoning' is set, it gets promoted to reasoning_content.\"\"\"\n         agent = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-flash\")\n         source = {\n             \"role\": \"assistant\",","@@ -135,8 +155,8 @@ def test_deepseek_poisoned_cross_provider_history_padded(self) -> None:\n \n         If the source turn has tool_calls AND a 'reasoning' field but NO\n         'reasoning_content' key, it's from a prior provider (the DeepSeek\n-        build path always pins reasoning_content=\"\" at creation). Inject\n-        \"\" instead of forwarding the prior provider's chain of thought.\n+        build path pins reasoning_content at creation). Inject \"\" instead\n+        of forwarding the prior provider's chain of thought.\n         \"\"\"\n         agent = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-flash\")\n         source = {","@@ -228,6 +248,172 @@ def test_non_assistant_role_ignored(self) -> None:\n         assert \"reasoning_content\" not in api_msg\n \n \n+class TestBuildAssistantMessageDeepSeekReasoningContent:\n+    \"\"\"_build_assistant_message pins replay-safe DeepSeek tool-call state.\"\"\"\n+\n+    def test_deepseek_tool_call_reasoning_is_backfilled_into_reasoning_content(self) -> None:\n+        agent = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-flash\")\n+        assistant_message = SimpleNamespace(\n+            content=None,\n+            reasoning=\"DeepSeek tool-call reasoning\",\n+            reasoning_content=None,\n+            reasoning_details=None,\n+            codex_reasoning_items=None,\n+            codex_message_items=None,\n+            tool_calls=[\n+                SimpleNamespace(\n+                    id=\"call_1\",\n+                    call_id=None,\n+                    response_item_id=None,\n+                    type=\"function\",\n+                    function=SimpleNamespace(name=\"terminal\", arguments=\"{}\"),\n+                )\n+            ],\n+        )\n+\n+        msg = agent._build_assistant_message(assistant_message, \"tool_calls\")\n+\n+        assert msg[\"reasoning_content\"] == \"DeepSeek tool-call reasoning\"\n+        assert msg[\"tool_calls\"][0][\"id\"] == \"call_1\"\n+\n+    def test_deepseek_model_extra_reasoning_content_is_preserved(self) -> None:\n+        \"\"\"OpenAI SDK stores unknown provider fields in model_extra.\"\"\"\n+        agent = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-flash\")\n+        assistant_message = SimpleNamespace(\n+            content=None,\n+            reasoning=None,\n+            reasoning_content=None,\n+            model_extra={\"reasoning_content\": \"DeepSeek model_extra reasoning\"},\n+            reasoning_details=None,\n+            codex_reasoning_items=None,\n+            codex_message_items=None,\n+            tool_calls=[\n+                SimpleNamespace(\n+                    id=\"call_1\",\n+                    call_id=None,\n+                    response_item_id=None,\n+                    type=\"function\",\n+                    function=SimpleNamespace(name=\"terminal\", arguments=\"{}\"),\n+                )\n+            ],\n+        )\n+\n+        msg = agent._build_assistant_message(assistant_message, \"tool_calls\")\n+\n+        assert msg[\"reasoning_content\"] == \"DeepSeek model_extra reasoning\"\n+\n+    def test_deepseek_tool_call_without_raw_reasoning_content_gets_empty_string(self) -> None:\n+        agent = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-flash\")\n+        assistant_message = SimpleNamespace(\n+            content=None,\n+            reasoning=None,\n+            reasoning_content=None,\n+            reasoning_details=None,\n+            codex_reasoning_items=None,\n+            codex_message_items=None,\n+            tool_calls=[\n+                SimpleNamespace(\n+                    id=\"call_1\",\n+                    call_id=None,\n+                    response_item_id=None,\n+                    type=\"function\",\n+                    function=SimpleNamespace(name=\"terminal\", arguments=\"{}\"),\n+                )\n+            ],\n+        )\n+\n+        msg = agent._build_assistant_message(assistant_message, \"tool_calls\")\n+\n+        assert msg[\"reasoning_content\"] == \"\"\n+        assert msg[\"tool_calls\"][0][\"id\"] == \"call_1\"\n+\n+\n+class TestBuildAssistantMessagePadsStrictProviders:\n+    \"\"\"Regression for #17400: _build_assistant_message must pin reasoning_content\n+    on tool-call turns when the active provider enforces echo-back, regardless\n+    of whether the SDK exposed reasoning_content as None, omitted it entirely,\n+    or returned an empty thinking block.\n+\n+    Prior to the fix, the pad branch was guarded by ``msg.get(\"tool_calls\")``,\n+    which was always falsy because tool_calls were assigned later in the same\n+    method. Persisted history accumulated assistant tool-call turns with no\n+    reasoning_content; the next replay 400'd on DeepSeek/Kimi.\n+    \"\"\"\n+\n+    @pytest.mark.parametrize(\n+        \"provider,model,base_url,sdk_reasoning_content,expected\",\n+        [\n+            pytest.param(\n+                \"deepseek\", \"deepseek-v4-pro\", \"\",\n+                None, \"\",\n+                id=\"deepseek-attr-none\",\n+            ),\n+            pytest.param(\n+                \"deepseek\", \"deepseek-v4-pro\", \"\",\n+                _ATTR_ABSENT, \"\",\n+                id=\"deepseek-attr-absent\",\n+            ),\n+            pytest.param(\n+                \"kimi-coding\", \"kimi-k2.6\", \"\",\n+                None, \"\",\n+                id=\"kimi-attr-none\",\n+            ),\n+            pytest.param(\n+                \"custom\", \"kimi-k2\", \"https://api.moonshot.ai/v1\",\n+                _ATTR_ABSENT, \"\",\n+                id=\"moonshot-base-url\",\n+            ),\n+            pytest.param(\n+                \"openrouter\", \"anthropic/claude-sonnet-4.6\", \"https://openrouter.ai/api/v1\",\n+                _ATTR_ABSENT, _EXPECT_NOT_PRESENT,\n+                id=\"openrouter-no-pad\",\n+            ),\n+        ],\n+    )\n+    def test_tool_call_reasoning_content_pad(\n+        self, provider, model, base_url, sdk_reasoning_content, expected,\n+    ) -> None:\n+        agent = _make_agent(provider=provider, model=model, base_url=base_url)\n+        msg_in = _build_sdk_message(\n+            reasoning_content=sdk_reasoning_content,\n+            tool_calls=[_sdk_tool_call()],\n+        )\n+        msg = agent._build_assistant_message(msg_in, finish_reason=\"tool_calls\")\n+        if expected is _EXPECT_NOT_PRESENT:\n+            assert \"reasoning_content\" not in msg\n+        else:\n+            assert msg[\"reasoning_content\"] == expected\n+\n+    def test_tool_call_preserves_real_reasoning_content(self) -> None:\n+        agent = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-pro\")\n+        msg_in = _build_sdk_message(\n+            reasoning_content=\"actual chain of thought\",\n+            tool_calls=[_sdk_tool_call()],\n+        )\n+        msg = agent._build_assistant_message(msg_in, finish_reason=\"tool_calls\")\n+        assert msg[\"reasoning_content\"] == \"actual chain of thought\"\n+\n+    def test_text_only_turn_not_padded_by_tool_call_branch(self) -> None:\n+        \"\"\"Plain-text turns rely on _copy_reasoning_content_for_api at replay\n+        time, not on this builder's tool-call pad.\"\"\"\n+        agent = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-pro\")\n+        msg_in = SimpleNamespace(content=\"hello\", tool_calls=None)\n+        msg = agent._build_assistant_message(msg_in, finish_reason=\"stop\")\n+        assert \"tool_calls\" not in msg\n+        assert \"reasoning_content\" not in msg\n+\n+    def test_streamed_reasoning_text_promoted_over_pad(self) -> None:\n+        \"\"\"When ``.reasoning`` carries streamed thinking, it must be promoted\n+        to reasoning_content rather than overwritten with the empty pad.\"\"\"\n+        agent = _make_agent(provider=\"deepseek\", model=\"deepseek-v4-pro\")\n+        msg_in = _build_sdk_message(\n+            reasoning=\"streamed thoughts\",\n+            tool_calls=[_sdk_tool_call()],\n+        )\n+        msg = agent._build_assistant_message(msg_in, finish_reason=\"tool_calls\")\n+        assert msg[\"reasoning_content\"] == \"streamed thoughts\"\n+\n+\n class TestNeedsKimiToolReasoning:\n     \"\"\"The extracted _needs_kimi_tool_reasoning() helper keeps Kimi behavior intact.\"\"\"\n "]},{"file":"tests/run_agent/test_deepseek_v4_thinking_live.py","hunks":["@@ -0,0 +1,245 @@\n+\"\"\"Live DeepSeek V4 thinking-mode tool-call replay smoke test.\n+\n+Opt-in only:\n+    HERMES_LIVE_TESTS=1 pytest tests/run_agent/test_deepseek_v4_thinking_live.py -q\n+\n+Requires DEEPSEEK_API_KEY in the process environment. The key is captured at\n+module import time because tests/conftest.py intentionally removes credential\n+environment variables before each test body runs.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+import os\n+import sys\n+from typing import Any\n+\n+import pytest\n+\n+\n+LIVE = os.environ.get(\"HERMES_LIVE_TESTS\") == \"1\"\n+DEEPSEEK_KEY = os.environ.get(\"DEEPSEEK_API_KEY\", \"\")\n+LIVE_MODELS = (\"deepseek-v4-flash\", \"deepseek-v4-pro\")\n+LIVE_BASE_URL = \"https://api.deepseek.com\"\n+\n+pytestmark = [\n+    pytest.mark.skipif(not LIVE, reason=\"live-only: set HERMES_LIVE_TESTS=1\"),\n+    pytest.mark.skipif(not DEEPSEEK_KEY, reason=\"DEEPSEEK_API_KEY not configured\"),\n+]\n+\n+TOOL_NAME = \"lookup_ticket_status\"\n+TOOLS = [\n+    {\n+        \"type\": \"function\",\n+        \"function\": {\n+            \"name\": TOOL_NAME,\n+            \"description\": \"Return the status for a test ticket id.\",\n+            \"parameters\": {\n+                \"type\": \"object\",\n+                \"properties\": {\n+                    \"ticket_id\": {\n+                        \"type\": \"string\",\n+                        \"description\": \"The ticket id to look up.\",\n+                    },\n+                },\n+                \"required\": [\"ticket_id\"],\n+                \"additionalProperties\": False,\n+            },\n+        },\n+    }\n+]\n+\n+\n+def _thinking_kwargs() -> dict:\n+    return {\n+        \"reasoning_effort\": \"high\",\n+        \"extra_body\": {\"thinking\": {\"type\": \"enabled\"}},\n+    }\n+\n+\n+def _jsonable(value: Any) -> Any:\n+    if hasattr(value, \"model_dump\"):\n+        return value.model_dump(mode=\"json\")\n+    if isinstance(value, dict):\n+        return {k: _jsonable(v) for k, v in value.items()}\n+    if isinstance(value, list):\n+        return [_jsonable(v) for v in value]\n+    return value\n+\n+\n+def _print_trace(label: str, value: Any) -> None:\n+    sys.__stdout__.write(f\"\\n--- {label} ---\\n\")\n+    sys.__stdout__.write(\n+        json.dumps(_jsonable(value), ensure_ascii=False, indent=2, sort_keys=True)\n+    )\n+    sys.__stdout__.write(\"\\n\")\n+    sys.__stdout__.flush()\n+\n+\n+def _message_snapshot(message) -> dict:\n+    return {\n+        \"content\": getattr(message, \"content\", None),\n+        \"reasoning\": getattr(message, \"reasoning\", None),\n+        \"reasoning_content\": _raw_reasoning_content(message),\n+        \"model_extra\": getattr(message, \"model_extra\", None),\n+        \"tool_calls\": _jsonable(getattr(message, \"tool_calls\", None)),\n+    }\n+\n+\n+def _make_live_client():\n+    from openai import OpenAI\n+\n+    return OpenAI(api_key=[redacted:secret-assignment] base_url=LIVE_BASE_URL)\n+\n+\n+def _make_agent_for_message_building(model: str):\n+    from run_agent import AIAgent\n+\n+    agent = object.__new__(AIAgent)\n+    agent.provider = \"deepseek\"\n+    agent.model = model\n+    agent.base_url = LIVE_BASE_URL\n+    agent.verbose_logging = False\n+    agent.reasoning_callback = None\n+    agent.stream_delta_callback = None\n+    agent._stream_callback = None\n+    return agent\n+\n+\n+def _raw_reasoning_content(message):\n+    direct = getattr(message, \"reasoning_content\", None)\n+    if direct is not None:\n+        return direct\n+    model_extra = getattr(message, \"model_extra\", None) or {}\n+    if isinstance(model_extra, dict) and \"reasoning_content\" in model_extra:\n+        return model_extra[\"reasoning_content\"]\n+    return None\n+\n+\n[redacted:email](\"live_model\", LIVE_MODELS)\n+def test_deepseek_v4_thinking_tool_call_replay_round_trip(live_model: str):\n+    \"\"\"Hit DeepSeek twice and replay the assistant tool-call turn.\n+\n+    The first request forces a tool call with thinking enabled. The second\n+    request replays that assistant message with content, reasoning_content,\n+    and tool_calls, then appends the tool result. DeepSeek accepting the\n+    second request is the live guardrail for the V4 thinking replay contract.\n+    \"\"\"\n+\n+    client = _make_live_client()\n+    agent = _make_agent_for_message_building(live_model)\n+\n+    first_request = {\n+        \"model\": live_model,\n+        \"messages\": [\n+            {\n+                \"role\": \"user\",\n+                \"content\": (\n+                    \"You must use the provided lookup_ticket_status tool \"\n+                    \"exactly once with ticket_id 'DS-4242'. Do not answer \"\n+                    \"directly.\"\n+                ),\n+            }\n+        ],\n+        \"tools\": TOOLS,\n+        \"max_tokens\": 1024,\n+        \"timeout\": 90,\n+        **_thinking_kwargs(),\n+    }\n+    _print_trace(f\"{live_model} first request\", first_request)\n+    first = client.chat.completions.create(**first_request)\n+    _print_trace(f\"{live_model} first raw response\", first)\n+\n+    first_choice = first.choices[0]\n+    first_message = first_choice.message\n+    _print_trace(\n+        f\"{live_model} first assistant message\",\n+        {\n+            \"finish_reason\": first_choice.finish_reason,\n+            **_message_snapshot(first_message),\n+        },\n+    )\n+    assert first_message.tool_calls, \"DeepSeek did not return a tool call\"\n+    first_tool_call = first_message.tool_calls[0]\n+    assert first_tool_call.function.name == TOOL_NAME\n+    assert isinstance(json.loads(first_tool_call.function.arguments or \"{}\"), dict)\n+\n+    raw_reasoning_content = _raw_reasoning_content(first_message)\n+    assert raw_reasoning_content is not None, (\n+        \"DeepSeek did not return reasoning_content; the thinking payload may \"\n+        \"not have been honored\"\n+    )\n+\n+    stored_assistant = agent._build_assistant_message(\n+        first_message,\n+        first_choice.finish_reason or \"tool_calls\",\n+    )\n+    _print_trace(f\"{live_model} stored assistant message\", stored_assistant)\n+    assert stored_assistant[\"reasoning_content\"] == raw_reasoning_content\n+\n+    replay_assistant = {\n+        \"role\": \"assistant\",\n+        \"content\": stored_assistant.get(\"content\") or \"\",\n+        \"tool_calls\": stored_assistant[\"tool_calls\"],\n+    }\n+    agent._copy_reasoning_content_for_api(stored_assistant, replay_assistant)\n+    _print_trace(f\"{live_model} replay assistant message\", replay_assistant)\n+\n+    tool_call_id = stored_assistant[\"tool_calls\"][0][\"id\"]\n+    messages = [\n+        {\n+            \"role\": \"user\",\n+            \"content\": (\n+                \"You must use the provided lookup_ticket_status tool \"\n+                \"exactly once with ticket_id 'DS-4242'. Do not answer \"\n+                \"directly.\"\n+            ),\n+        },\n+        replay_assistant,\n+        {\n+            \"role\": \"tool\",\n+            \"tool_call_id\": tool_call_id,\n+            \"content\": json.dumps(\n+                {\"ticket_id\": \"DS-4242\", \"status\": \"green\", \"source\": \"live-test\"},\n+                separators=(\",\", \":\"),\n+            ),\n+        },\n+    ]\n+\n+    from agent.transports.chat_completions import ChatCompletionsTransport\n+\n+    api_messages = ChatCompletionsTransport().convert_messages(messages)\n+    _print_trace(\n+        f\"{live_model} second request messages after transport conversion\",\n+        api_messages,\n+    )\n+    assert api_messages[1][\"reasoning_content\"] == raw_reasoning_content\n+    assert \"call_id\" not in api_messages[1][\"tool_calls\"][0]\n+    assert \"response_item_id\" not in api_messages[1][\"tool_calls\"][0]\n+\n+    second_request = {\n+        \"model\": live_model,\n+        \"messages\": api_messages,\n+        \"max_tokens\": 1024,\n+        \"timeout\": 90,\n+        **_thinking_kwargs(),\n+    }\n+    _print_trace(f\"{live_model} second request\", second_request)\n+    second = client.chat.completions.create(**second_request)\n+    _print_trace(f\"{live_model} second raw response\", second)\n+    _print_trace(\n+        f\"{live_model} second assistant message\",\n+        {\n+            \"finish_reason\": second.choices[0].finish_reason,\n+            **_message_snapshot(second.choices[0].message),\n+        },\n+    )\n+\n+    second_message = second.choices[0].message\n+    final_content = second_message.content or \"\"\n+    final_reasoning = _raw_reasoning_content(second_message) or \"\"\n+    assert second.choices[0].finish_reason == \"stop\"\n+    assert final_content.strip() or final_reasoning.strip(), (\n+        \"DeepSeek returned neither visible content nor reasoning_content\"\n+    )\n"]}]},{"kind":"pr","number":18218,"title":"fix: preserve DeepSeek V4 signed thinking blocks on /anthropic endpoint","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":21033,"title":"fix(agent): rebuild api_messages after provider fallback to apply reasoning requirements","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b5495db70117b45320066f4e2768e66382062fce","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":21668,"title":"fix(deepseek): send explicit thinking:disabled when reasoning is off","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"cd9470f41638bd515db096cd934c463205790110","number":0},{"kind":"commit","value":"068c24f8a4203e86de32b0d84ccaf047e8cd6ef7","number":0},{"kind":"pr","value":"#17225","number":17225}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":21986,"title":"Fix DeepSeek thinking content array replay","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#21989","number":21989}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":21989,"title":"Fix DeepSeek thinking content array replay","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":22643,"title":"fix(agent): extract thinking from content-list blocks for DeepSeek V4 Pro","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22798","number":22798},{"kind":"pr","value":"#21946","number":21946}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":22798,"title":"fix(agent): extract thinking from content-list blocks for DeepSeek V4 Pro (salvage #22643)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/run_agent/test_run_agent.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -3529,6 +3529,19 @@ def _extract_reasoning(self, assistant_message) -> Optional[str]:\n         # instead of returning structured reasoning fields.  Only fall back\n         # to inline extraction when no structured reasoning was found.\n         content = getattr(assistant_message, \"content\", None)\n+        if not reasoning_parts and isinstance(content, list):\n+            # DeepSeek V4 Pro (and compatible providers) return content as a\n+            # list of typed blocks, e.g.:\n+            #   [{\"type\": \"thinking\", \"thinking\": \"...\"}, {\"type\": \"output\", ...}]\n+            # Without this branch the thinking text is silently dropped and the\n+            # next turn fails with HTTP 400 (\"thinking must be passed back\").\n+            # Refs #21944.\n+            for block in content:\n+                if isinstance(block, dict) and block.get(\"type\") == \"thinking\":\n+                    thinking_text = block.get(\"thinking\") or block.get(\"text\") or \"\"\n+                    thinking_text = thinking_text.strip()\n+                    if thinking_text and thinking_text not in reasoning_parts:\n+                        reasoning_parts.append(thinking_text)\n         if not reasoning_parts and isinstance(content, str) and content:\n             inline_patterns = (\n                 r\"<think>(.*?)</think>\","]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -517,6 +517,42 @@ def test_inline_reasoning_blocks_fallback(self, agent, content, expected):\n         msg = _mock_assistant_msg(content=content)\n         assert agent._extract_reasoning(msg) == expected\n \n+    def test_content_list_thinking_blocks_extracted(self, agent):\n+        \"\"\"DeepSeek V4 Pro returns content as a typed-block list (issue #21944).\n+\n+        Without this branch thinking text is silently dropped → HTTP 400 on\n+        the next turn (\"thinking must be passed back to the API\").\n+        \"\"\"\n+        msg = _mock_assistant_msg(\n+            content=[\n+                {\"type\": \"thinking\", \"thinking\": \"deep analysis here\"},\n+                {\"type\": \"output\", \"text\": \"final answer\"},\n+            ]\n+        )\n+        result = agent._extract_reasoning(msg)\n+        assert result == \"deep analysis here\"\n+\n+    def test_content_list_non_thinking_blocks_ignored(self, agent):\n+        \"\"\"Non-thinking blocks in a content list must not be treated as reasoning.\"\"\"\n+        msg = _mock_assistant_msg(\n+            content=[\n+                {\"type\": \"text\", \"text\": \"just a regular response\"},\n+            ]\n+        )\n+        assert agent._extract_reasoning(msg) is None\n+\n+    def test_content_list_thinking_prefers_structured_field(self, agent):\n+        \"\"\"Structured ``reasoning`` field wins over content-list thinking blocks.\"\"\"\n+        msg = _mock_assistant_msg(\n+            reasoning=\"from structured field\",\n+            content=[\n+                {\"type\": \"thinking\", \"thinking\": \"from content list\"},\n+            ],\n+        )\n+        result = agent._extract_reasoning(msg)\n+        # structured field was found first → content-list branch skipped\n+        assert result == \"from structured field\"\n+\n \n class TestCleanSessionContent:\n     def test_none_passthrough(self):\n"]}]},{"kind":"pr","number":24603,"title":"fix(agent): preserve reasoning_content for Xiaomi MiMo thinking mode (#24443)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24465","number":24465},{"kind":"pr","value":"#24465","number":24465},{"kind":"pr","value":"#24603","number":24603},{"kind":"pr","value":"#24605","number":24605},{"kind":"pr","value":"#24465","number":24465},{"kind":"pr","value":"#24443","number":24443}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":24605,"title":"fix(run_agent): enroll Xiaomi MiMo in reasoning_content echo-back enforcement","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24662","number":24662}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":24638,"title":"fix(agent): add MiMo to reasoning_content echo-back providers in _needs_thinking_reasoning_pad","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24662","number":24662}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":24662,"title":"fix(agent): add MiMo to reasoning_content echo-back providers in _needs_thinking_reasoning_pad","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24465","number":24465},{"kind":"pr","value":"#24443","number":24443}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":24887,"title":"feat: add Xiaomi MiMo Anthropic-compatible endpoint support for thinking mode","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24465","number":24465},{"kind":"pr","value":"#24603","number":24603},{"kind":"pr","value":"#24784","number":24784},{"kind":"pr","value":"#24810","number":24810},{"kind":"pr","value":"#24884","number":24884},{"kind":"pr","value":"#24443","number":24443}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":26648,"title":"fix(deepseek): wire thinking-mode via DeepSeekProfile (closes #15700, #17212, #17825)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/model-providers/deepseek/__init__.py","scripts/release.py","tests/plugins/model_providers/test_deepseek_profile.py"]},{"kind":"pr","number":43947,"title":"Fix/deepsfix(deepseek): omitting tool_choice auto/any and preserving signed thinking blocks for /anthropic endpointeek anthropic compat","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":58148,"title":"fix: surface reasoning/thinking content as response instead of '(empty)'","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50397","number":50397}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}