diff --git a/run_agent.py b/run_agent.py index 7c6c62cc9a37a..b77125396ee57 100644 --- a/run_agent.py +++ b/run_agent.py @@ -9990,16 +9990,16 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic 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 a single space — - # DeepSeek V4 Pro tightened validation and rejects empty - # string ("The reasoning content in the thinking mode must - # be passed back to the API"). A space satisfies non-empty - # checks everywhere without leaking fabricated reasoning. + # DeepSeek v4, Kimi / Moonshot, and Xiaomi MiMo thinking modes + # 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 a single space — DeepSeek V4 Pro tightened + # validation and rejects empty string ("The reasoning content in + # the thinking mode must be passed back to the API"). A space + # satisfies non-empty checks everywhere without leaking fabricated + # reasoning. # Refs #15250, #17400, #17341. msg["reasoning_content"] = reasoning_text or " " @@ -10112,13 +10112,24 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic 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). + DeepSeek v4, Kimi / Moonshot, and Xiaomi MiMo thinking modes 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() + or self._needs_xiaomi_tool_reasoning() + ) + + def _needs_xiaomi_tool_reasoning(self) -> bool: + """Return True when the current provider is Xiaomi MiMo thinking mode.""" + provider = (self.provider or "").lower() + model = (self.model or "").lower() + return ( + provider in {"xiaomi", "mimo", "xiaomi-mimo"} + or model.startswith("xiaomi/") + or base_url_host_matches(self.base_url, "xiaomimimo.com") ) def _needs_kimi_tool_reasoning(self) -> bool: @@ -10156,8 +10167,9 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No return # 1. Explicit reasoning_content already set — preserve it verbatim - # (includes DeepSeek/Kimi's own space-placeholder written at creation - # time, and any valid reasoning content from the same provider). + # (includes strict providers' own space-placeholder written at + # creation time, and any valid reasoning content from the same + # provider). # # Exception: sessions persisted BEFORE #17341 have empty-string # placeholders pinned at creation time. DeepSeek V4 Pro rejects @@ -10174,7 +10186,7 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No needs_thinking_pad = self._needs_thinking_reasoning_pad() - # 2. Cross-provider poisoned history (#15748): on DeepSeek/Kimi, + # 2. Cross-provider poisoned history (#15748): on strict providers, # 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 @@ -10182,7 +10194,7 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No # shape (reasoning set, reasoning_content absent, tool_calls present) # is unreachable from same-provider DeepSeek history after this fix. # Inject a single space to satisfy the API without leaking another - # provider's chain of thought to DeepSeek/Kimi. Space (not "") + # provider's chain of thought to the current provider. Space (not "") # because DeepSeek V4 Pro rejects empty-string reasoning_content # in thinking mode (refs #17341). normalized_reasoning = source_msg.get("reasoning") @@ -10204,7 +10216,7 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No api_msg["reasoning_content"] = normalized_reasoning return - # 4. DeepSeek / Kimi thinking mode: all assistant messages need + # 4. Strict thinking modes: all assistant messages need # reasoning_content. Inject a single space to satisfy the provider's # requirement when no explicit reasoning content is present. Covers # both tool-call turns (already-poisoned history with no reasoning diff --git a/tests/run_agent/test_deepseek_reasoning_content_echo.py b/tests/run_agent/test_deepseek_reasoning_content_echo.py index 0efdb2c5a185e..22abc71f6735b 100644 --- a/tests/run_agent/test_deepseek_reasoning_content_echo.py +++ b/tests/run_agent/test_deepseek_reasoning_content_echo.py @@ -249,6 +249,32 @@ def test_kimi_moonshot_base_url(self) -> None: agent._copy_reasoning_content_for_api(source, api_msg) assert api_msg.get("reasoning_content") == " " + def test_xiaomi_tool_call_poisoned_history_gets_space_placeholder(self) -> None: + agent = _make_agent(provider="xiaomi", model="mimo-v2.5-pro") + 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_xiaomi_token_plan_base_url_gets_space_placeholder(self) -> None: + agent = _make_agent( + provider="custom", + model="mimo-v2.5-pro", + base_url="https://token-plan-cn.xiaomimimo.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_thinking_provider_not_padded(self) -> None: """Providers that don't require the echo are untouched.""" agent = _make_agent( @@ -405,6 +431,16 @@ class TestBuildAssistantMessagePadsStrictProviders: _ATTR_ABSENT, " ", id="moonshot-base-url", ), + pytest.param( + "xiaomi", "mimo-v2.5-pro", "", + None, " ", + id="xiaomi-provider", + ), + pytest.param( + "custom", "mimo-v2.5-pro", "https://token-plan-cn.xiaomimimo.com/v1", + _ATTR_ABSENT, " ", + id="xiaomi-token-plan-base-url", + ), pytest.param( "openrouter", "anthropic/claude-sonnet-4.6", "https://openrouter.ai/api/v1", _ATTR_ABSENT, _EXPECT_NOT_PRESENT, @@ -481,3 +517,30 @@ def test_non_kimi_provider(self) -> None: ) # model name contains 'moonshot' but host is openrouter — should be False assert agent._needs_kimi_tool_reasoning() is False + + +class TestNeedsXiaomiToolReasoning: + """Xiaomi MiMo thinking mode enforces the same reasoning_content echo-back.""" + + @pytest.mark.parametrize( + "provider,model,base_url", + [ + ("xiaomi", "mimo-v2.5-pro", ""), + ("mimo", "mimo-v2.5-pro", ""), + ("custom", "xiaomi/mimo-v2.5-pro", ""), + ("custom", "mimo-v2.5-pro", "https://api.xiaomimimo.com/v1"), + ("custom", "mimo-v2.5-pro", "https://token-plan-cn.xiaomimimo.com/v1"), + ("custom", "mimo-v2.5-pro", "https://token-plan-cn.xiaomimimo.com/anthropic"), + ], + ) + def test_xiaomi_signals(self, provider: str, model: str, base_url: str) -> None: + agent = _make_agent(provider=provider, model=model, base_url=base_url) + assert agent._needs_xiaomi_tool_reasoning() is True + + def test_non_xiaomi_provider(self) -> None: + agent = _make_agent( + provider="openrouter", + model="mimo-v2.5-pro", + base_url="https://openrouter.ai/api/v1", + ) + assert agent._needs_xiaomi_tool_reasoning() is False