diff --git a/run_agent.py b/run_agent.py index d6af4a1b57..7272b5a57c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7890,39 +7890,45 @@ class AIAgent: api_msg["reasoning_content"] = existing return - # 2. Healthy session: promote 'reasoning' field to 'reasoning_content' + needs_thinking_pad = ( + self._needs_kimi_tool_reasoning() + or self._needs_deepseek_tool_reasoning() + ) + + # 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. + normalized_reasoning = source_msg.get("reasoning") + if ( + needs_thinking_pad + and source_msg.get("tool_calls") + and isinstance(normalized_reasoning, str) + and normalized_reasoning + ): + api_msg["reasoning_content"] = "" + return + + # 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). - normalized_reasoning = source_msg.get("reasoning") if isinstance(normalized_reasoning, str) and normalized_reasoning: api_msg["reasoning_content"] = normalized_reasoning return - # 3. DeepSeek / Kimi thinking mode: tool-call turns that lack - # reasoning_content are "poisoned history" — a prior provider (MiniMax, - # etc.) left them empty. DeepSeek returns HTTP 400 if reasoning_content - # is absent on replay; inject "" to satisfy the provider's requirement - # without forwarding any cross-provider reasoning content. - needs_empty_reasoning = ( - source_msg.get("tool_calls") - and ( - self._needs_kimi_tool_reasoning() - or self._needs_deepseek_tool_reasoning() - ) - ) - if needs_empty_reasoning: - api_msg["reasoning_content"] = "" - return - # 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() - ): + # when no explicit reasoning content is present. Covers both + # tool-call turns (already-poisoned history with no reasoning at all) + # and plain text turns. + if needs_thinking_pad: api_msg["reasoning_content"] = "" return diff --git a/tests/run_agent/test_deepseek_reasoning_content_echo.py b/tests/run_agent/test_deepseek_reasoning_content_echo.py index eb31d1760e..a3f1cf8bb1 100644 --- a/tests/run_agent/test_deepseek_reasoning_content_echo.py +++ b/tests/run_agent/test_deepseek_reasoning_content_echo.py @@ -109,17 +109,59 @@ class TestCopyReasoningContentForApi: assert api_msg["reasoning_content"] == "real chain of thought" def test_deepseek_reasoning_field_promoted(self) -> None: - """When only 'reasoning' is set, it gets promoted to reasoning_content.""" + """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. + """ agent = _make_agent(provider="deepseek", model="deepseek-v4-flash") source = { "role": "assistant", + "content": "", "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_deepseek_poisoned_cross_provider_history_padded(self) -> None: + """Cross-provider tool-call turn (#15748): MiniMax reasoning leaks + to DeepSeek/Kimi request. + + 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. + """ + agent = _make_agent(provider="deepseek", model="deepseek-v4-flash") + source = { + "role": "assistant", + "content": "", + "reasoning": "MiniMax chain of thought from a prior turn", + "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}], + } + api_msg: dict = {} + agent._copy_reasoning_content_for_api(source, api_msg) + assert api_msg["reasoning_content"] == "" + + def test_kimi_poisoned_cross_provider_history_padded(self) -> None: + """Kimi path of #15748 — same rule as DeepSeek.""" + agent = _make_agent(provider="kimi-coding", model="kimi-k2.5") + source = { + "role": "assistant", + "content": "", + "reasoning": "DeepSeek chain of thought from a prior turn", + "tool_calls": [{"id": "c1", "function": {"name": "terminal"}}], + } + api_msg: dict = {} + agent._copy_reasoning_content_for_api(source, api_msg) + assert api_msg["reasoning_content"] == "" + 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")