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
16 changes: 11 additions & 5 deletions environments/agent_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,13 @@ def _tc_to_dict(tc):

# Preserve reasoning_content for multi-turn chat template handling
# (e.g., Kimi-K2's template renders <think> blocks differently
# for history vs. the latest turn based on this field)
if reasoning:
msg_dict["reasoning_content"] = reasoning
# for history vs. the latest turn based on this field).
# Always set reasoning_content -- DeepSeek and DeepSeek-compatible
# APIs (Ark Coding Plan, etc.) require reasoning_content on EVERY
# assistant message when thinking mode is enabled, even if the
# message had no reasoning. Inconsistency (some messages with,
# some without) triggers a 400 error.
msg_dict["reasoning_content"] = reasoning or ""

messages.append(msg_dict)

Expand Down Expand Up @@ -492,8 +496,10 @@ def _tc_to_dict(tc):
"role": "assistant",
"content": assistant_msg.content or "",
}
if reasoning:
msg_dict["reasoning_content"] = reasoning
# Always set reasoning_content for multi-turn consistency
# (same rationale as tool-call path above -- DeepSeek requires
# reasoning_content on every assistant message in thinking mode)
msg_dict["reasoning_content"] = reasoning or ""
messages.append(msg_dict)

turn_elapsed = _time.monotonic() - turn_start
Expand Down
29 changes: 18 additions & 11 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7639,11 +7639,12 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
raw_reasoning_content = getattr(assistant_message, "reasoning_content", None)
if raw_reasoning_content is not None:
msg["reasoning_content"] = _sanitize_surrogates(raw_reasoning_content)
elif msg.get("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
# as a defensive compatibility fallback (refs #15250).
elif self._needs_deepseek_tool_reasoning() or self._needs_kimi_tool_reasoning():
# DeepSeek / Kimi thinking mode requires reasoning_content on EVERY
# assistant message (not just tool-call ones). An inconsistent
# mix (some messages with, some without) triggers HTTP 400 from
# the API. Include empty string as a defensive compatibility
# fallback (refs #15250).
msg["reasoning_content"] = ""

if hasattr(assistant_message, 'reasoning_details') and assistant_message.reasoning_details:
Expand Down Expand Up @@ -7738,15 +7739,20 @@ def _needs_deepseek_tool_reasoning(self) -> bool:
"""Return True when the current provider is DeepSeek thinking mode.

DeepSeek V4 thinking mode requires ``reasoning_content`` on every
assistant tool-call turn; omitting it causes HTTP 400 when the
message is replayed in a subsequent API request (#15250).
assistant turn; omitting it causes HTTP 400 when the message is
replayed in a subsequent API request (#15250).

Detection covers: provider='deepseek', model name containing
'deepseek', api.deepseek.com host, and Ark Coding Plan endpoint
(ark.cn-beijing.volces.com) which follows DeepSeek API conventions.
"""
provider = (self.provider or "").lower()
model = (self.model or "").lower()
return (
provider == "deepseek"
or "deepseek" in model
or base_url_host_matches(self.base_url, "api.deepseek.com")
or base_url_host_matches(self.base_url, "ark.cn-beijing.volces.com")
)

def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> None:
Expand All @@ -7765,10 +7771,11 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No
return

# Providers that require an echoed reasoning_content on every
# assistant tool-call turn. Detection logic lives in the per-provider
# helpers so both the creation path (_build_assistant_message) and
# this replay path stay in sync.
if source_msg.get("tool_calls") and (
# assistant turn (not just tool-call messages — DeepSeek validates
# the entire conversation history for consistency). Detection logic
# lives in the per-provider helpers so both the creation path
# (_build_assistant_message) and this replay path stay in sync.
if (
self._needs_kimi_tool_reasoning()
or self._needs_deepseek_tool_reasoning()
):
Expand Down
17 changes: 14 additions & 3 deletions tests/run_agent/test_deepseek_reasoning_content_echo.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ def test_base_url_host(self) -> None:
)
assert agent._needs_deepseek_tool_reasoning() is True

def test_ark_coding_plan_endpoint(self) -> None:
"""Ark Coding Plan endpoint (ark.cn-beijing.volces.com) follows
DeepSeek API conventions and requires reasoning_content echo."""
agent = _make_agent(
provider="custom",
model="glm-5.1",
base_url="https://ark.cn-beijing.volces.com/api/coding/v3",
)
assert agent._needs_deepseek_tool_reasoning() is True

def test_provider_case_insensitive(self) -> None:
agent = _make_agent(provider="DeepSeek", model="")
assert agent._needs_deepseek_tool_reasoning() is True
Expand Down Expand Up @@ -88,13 +98,14 @@ def test_deepseek_tool_call_poisoned_history_gets_empty_string(self) -> None:
agent._copy_reasoning_content_for_api(source, api_msg)
assert api_msg.get("reasoning_content") == ""

def test_deepseek_assistant_no_tool_call_left_alone(self) -> None:
"""Plain assistant turns without tool_calls don't get padded."""
def test_deepseek_assistant_no_tool_call_now_padded(self) -> None:
"""Plain assistant turns without tool_calls ALSO get padded for DeepSeek
because the API validates the whole conversation history for consistency."""
agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
source = {"role": "assistant", "content": "hello"}
api_msg: dict = {}
agent._copy_reasoning_content_for_api(source, api_msg)
assert "reasoning_content" not in api_msg
assert api_msg.get("reasoning_content") == ""

def test_deepseek_explicit_reasoning_content_preserved(self) -> None:
"""When reasoning_content is already set, it's copied verbatim."""
Expand Down