fix(agent): block cross-provider reasoning leak to DeepSeek/Kimi (#15748) (#16500)

On provider switches mid-session (e.g. MiniMax -> DeepSeek), the source
assistant turn carries a 'reasoning' field written by the prior provider
but no 'reasoning_content' key. _copy_reasoning_content_for_api would
promote that foreign 'reasoning' to 'reasoning_content' on the outbound
DeepSeek request, leaking a cross-provider chain of thought and in
practice causing HTTP 400.

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 — it can only come from a prior provider.
Pad with '' in that case instead of promoting.

Healthy same-provider 'reasoning' promotion (no tool_calls, or on
providers that do not require the empty-string pin) is unchanged.
This commit is contained in:
Teknium
2026-04-27 04:06:23 -07:00
committed by GitHub
parent 65f648ee84
commit ee1a07f9e9
2 changed files with 73 additions and 25 deletions

View File

@@ -7890,39 +7890,45 @@ class AIAgent:
api_msg["reasoning_content"] = existing api_msg["reasoning_content"] = existing
return 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. # for providers that use the internal 'reasoning' key.
# This must happen BEFORE the DeepSeek/Kimi tool-call check so that # This must happen BEFORE the DeepSeek/Kimi tool-call check so that
# genuine reasoning content is not overwritten by the empty-string # genuine reasoning content is not overwritten by the empty-string
# fallback (#15812 regression in PR #15478). # fallback (#15812 regression in PR #15478).
normalized_reasoning = source_msg.get("reasoning")
if isinstance(normalized_reasoning, str) and normalized_reasoning: if isinstance(normalized_reasoning, str) and normalized_reasoning:
api_msg["reasoning_content"] = normalized_reasoning api_msg["reasoning_content"] = normalized_reasoning
return 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 # 4. DeepSeek / Kimi thinking mode: all assistant messages need
# reasoning_content. Inject "" to satisfy the provider's requirement # reasoning_content. Inject "" to satisfy the provider's requirement
# when no explicit reasoning content is present. # when no explicit reasoning content is present. Covers both
if ( # tool-call turns (already-poisoned history with no reasoning at all)
self._needs_kimi_tool_reasoning() # and plain text turns.
or self._needs_deepseek_tool_reasoning() if needs_thinking_pad:
):
api_msg["reasoning_content"] = "" api_msg["reasoning_content"] = ""
return return

View File

@@ -109,17 +109,59 @@ class TestCopyReasoningContentForApi:
assert api_msg["reasoning_content"] == "<think>real chain of thought</think>" assert api_msg["reasoning_content"] == "<think>real chain of thought</think>"
def test_deepseek_reasoning_field_promoted(self) -> None: 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") agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
source = { source = {
"role": "assistant", "role": "assistant",
"content": "",
"reasoning": "thought trace", "reasoning": "thought trace",
"tool_calls": [{"id": "c1", "function": {"name": "terminal"}}],
} }
api_msg: dict = {} api_msg: dict = {}
agent._copy_reasoning_content_for_api(source, api_msg) agent._copy_reasoning_content_for_api(source, api_msg)
assert api_msg["reasoning_content"] == "thought trace" 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: def test_kimi_path_still_works(self) -> None:
"""Existing Kimi detection still pads reasoning_content.""" """Existing Kimi detection still pads reasoning_content."""
agent = _make_agent(provider="kimi-coding", model="kimi-k2.5") agent = _make_agent(provider="kimi-coding", model="kimi-k2.5")