fix(agent): extend thinking-mode reasoning_content pad to Kimi/Moonshot
Builds on #16855 (@lsdsjy) which fixed DeepSeek v4 reasoning_content replay via model_extra fallback + capturing tool_calls at method entry. Kimi / Moonshot thinking mode enforces the same echo-back contract and hits the same 400 when a tool-call turn is persisted without reasoning_content. - _build_assistant_message: pad branch now uses _needs_thinking_reasoning_pad() (DeepSeek OR Kimi) instead of _needs_deepseek_tool_reasoning() alone. - Extract _needs_thinking_reasoning_pad() and reuse it in _copy_reasoning_content_for_api so both sites share one predicate. - tests/run_agent/test_deepseek_reasoning_content_echo.py: add TestBuildAssistantMessagePadsStrictProviders parametrized over DeepSeek (attr=None, attr-absent), Kimi (attr=None), Moonshot (via base_url), and an OpenRouter negative control that must NOT pad. Proven to fail 2/5 cases on Kimi/Moonshot without this change. - scripts/release.py: add AUTHOR_MAP entries for lsdsjy and season179. Refs #17400. Co-authored-by: season179 <season.saw@gmail.com>
This commit is contained in:
parent
b9b9ee3e6c
commit
76edc40ab0
30
run_agent.py
30
run_agent.py
@ -8568,11 +8568,14 @@ class AIAgent:
|
||||
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_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
|
||||
# only when no structured reasoning text was captured.
|
||||
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
|
||||
@ -8681,6 +8684,18 @@ class AIAgent:
|
||||
|
||||
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.
|
||||
|
||||
@ -8723,10 +8738,7 @@ class AIAgent:
|
||||
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
|
||||
|
||||
@ -53,6 +53,9 @@ AUTHOR_MAP = {
|
||||
"sr@samirusani": "samrusani",
|
||||
"angelclaw@AngelMacBook.local": "angel12",
|
||||
"charles@cryptoassetrecovery.com": "charles-brooks",
|
||||
# DeepSeek v4 + Kimi thinking-mode reasoning_content salvage (April 2026)
|
||||
"luwinyang@deepseek.com": "lsdsjy",
|
||||
"season.saw@gmail.com": "season179",
|
||||
"heathley@Heathley-MacBook-Air.local": "heathley",
|
||||
"vlad19@gmail.com": "dandaka",
|
||||
"adamrummer@gmail.com": "cyclingwithelephants",
|
||||
|
||||
@ -42,6 +42,29 @@ def _make_agent(provider: str = "", model: str = "", base_url: str = "") -> AIAg
|
||||
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."""
|
||||
|
||||
@ -305,6 +328,92 @@ class TestBuildAssistantMessageDeepSeekReasoningContent:
|
||||
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."""
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user