Skip to content
Merged
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
25 changes: 25 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -8100,6 +8100,31 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
# as a defensive compatibility fallback (refs #15250).
msg["reasoning_content"] = ""

# Additive fallback (refs #16844, #16884). Streaming-only providers
# (glm, MiniMax, gpt-5.x via aigw, Anthropic via openai-compat shims)
# accumulate reasoning through ``delta.reasoning_content`` chunks
# but never land it on the message object as a top-level attribute,
# so neither branch above fires and the chain-of-thought is stored
# only under the internal ``reasoning`` key. When the user later
# replays that history through a DeepSeek-v4 / Kimi thinking model,
# the missing ``reasoning_content`` causes HTTP 400 ("The
# reasoning_content in the thinking mode must be passed back to the
# API.").
#
# Promote the already-sanitized streamed ``reasoning_text`` to
# ``reasoning_content`` at write time, but ONLY when no prior branch
# already set it AND we actually captured reasoning text. This
# preserves every existing behavior:
# - SDK-exposed ``reasoning_content`` (OpenAI/Moonshot/DeepSeek SDK)
# still wins.
# - DeepSeek tool-call ""-pad (#15250) still fires.
# - Non-thinking turns with no reasoning leave the field absent,
# so ``_copy_reasoning_content_for_api``'s cross-provider leak
# guard (#15748) and ``reasoning``→``reasoning_content``
# promotion tiers still apply at replay time.
if "reasoning_content" not in msg and reasoning_text:
msg["reasoning_content"] = reasoning_text

if hasattr(assistant_message, 'reasoning_details') and assistant_message.reasoning_details:
# Pass reasoning_details back unmodified so providers (OpenRouter,
# Anthropic, OpenAI) can maintain reasoning continuity across turns.
Expand Down
56 changes: 56 additions & 0 deletions tests/run_agent/test_run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1397,6 +1397,62 @@ def test_empty_content(self, agent):
result = agent._build_assistant_message(msg, "stop")
assert result["content"] == ""

def test_streaming_only_reasoning_promoted_to_reasoning_content(self, agent):
"""Refs #16844 / #16884. Streaming-only providers (glm, MiniMax,
gpt-5.x via aigw, Anthropic via openai-compat shims) accumulate
reasoning through delta chunks but never expose
``reasoning_content`` as a top-level attribute on the finalized
message — only ``reasoning`` (or the internal accumulator).

Without write-side promotion, the persisted message stores the
chain-of-thought under the internal ``reasoning`` key and omits
``reasoning_content``. When the user later replays that history
through a DeepSeek-v4 / Kimi thinking model, the missing field
causes HTTP 400 ("The reasoning_content in the thinking mode
must be passed back to the API.").

Fix: when ``reasoning_content`` wasn't written by an earlier
branch AND we captured reasoning text from streaming deltas,
promote it to ``reasoning_content`` at write time.
"""
# SDK-style object that exposes ``reasoning`` but NOT
# ``reasoning_content`` — the streaming-only provider shape.
msg = _mock_assistant_msg(content="answer", reasoning="hidden thinking")
assert not hasattr(msg, "reasoning_content")

result = agent._build_assistant_message(msg, "stop")

assert result["reasoning"] == "hidden thinking"
assert result["reasoning_content"] == "hidden thinking"

def test_sdk_reasoning_content_still_wins_over_fallback(self, agent):
"""Additive fallback must not override SDK-supplied reasoning_content.

When both ``reasoning`` and ``reasoning_content`` are present, the
SDK's own ``reasoning_content`` is authoritative (may carry
structured data the accumulator doesn't have).
"""
msg = _mock_assistant_msg(
content="answer",
reasoning="summary only",
reasoning_content="structured provider scratchpad",
)
result = agent._build_assistant_message(msg, "stop")
assert result["reasoning_content"] == "structured provider scratchpad"

def test_no_reasoning_text_leaves_field_absent(self, agent):
"""Non-thinking turns with no reasoning leave reasoning_content absent.

This preserves ``_copy_reasoning_content_for_api``'s downstream
tiers at replay time — cross-provider leak guard (#15748),
promote-from-``reasoning``, and DeepSeek/Kimi ""-pad — which
would all be bypassed if we eagerly wrote ``reasoning_content=""``
on every assistant turn regardless of provider.
"""
msg = _mock_assistant_msg(content="plain answer")
result = agent._build_assistant_message(msg, "stop")
assert "reasoning_content" not in result

def test_tool_call_extra_content_preserved(self, agent):
"""Gemini thinking models attach extra_content with thought_signature
to tool calls. This must be preserved so subsequent API calls include it."""
Expand Down
Loading