Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 30 additions & 18 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 " "

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -10174,15 +10186,15 @@ 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
# 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 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")
Expand All @@ -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
Expand Down
63 changes: 63 additions & 0 deletions tests/run_agent/test_deepseek_reasoning_content_echo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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