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