core/agent: keep a rolled-back turn's reasoning with its calls

A failed turn keeps the calls that already ran as a bare pairing carrier, and
clipping the chain-of-thought off that carrier sent the call back to the API
with no reasoning at all — thinking mode refuses a call whose reasoning never
came back. The carrier keeps the reasoning and drops only the text.
This commit is contained in:
2026-10-06 23:23:12 +08:00
parent 19e247d739
commit 932b4f0a7f
2 changed files with 14 additions and 6 deletions
+6 -3
View File
@@ -256,8 +256,11 @@ def _executed_tool_pair(
"""Assistant call record plus the results of the calls that produced one. """Assistant call record plus the results of the calls that produced one.
Pairs by call id (first result per id), so the history keeps the API's Pairs by call id (first result per id), so the history keeps the API's
one-result-per-call shape even when a model repeats an id. Text and one-result-per-call shape even when a model repeats an id. Text goes with
reasoning go with the rest of the unfinished turn. the rest of the unfinished turn; the reasoning stays, because thinking mode
refuses a tool call whose reasoning never came back (see
``responses_bridge`` / the DeepSeek chat client, which can only send an
empty part once the real chain-of-thought is gone).
""" """
by_id: dict[str, ToolChatMessage] = {} by_id: dict[str, ToolChatMessage] = {}
for result in results: for result in results:
@@ -273,7 +276,7 @@ def _executed_tool_pair(
paired.append(by_id[call.id]) paired.append(by_id[call.id])
if not executed: if not executed:
return [] return []
record = msgspec.structs.replace(assistant, content=UNSET, reasoning_content=UNSET, tool_calls=executed) record = msgspec.structs.replace(assistant, content=UNSET, tool_calls=executed)
return [record, *paired] return [record, *paired]
+8 -3
View File
@@ -28,7 +28,11 @@ def _call(call_id: str, name: str = "some_tool") -> AssistantFunctionToolCall:
def test_rollback_tail_keeps_the_executed_call_of_a_cut_short_batch() -> None: def test_rollback_tail_keeps_the_executed_call_of_a_cut_short_batch() -> None:
messages: list[AnyChatMessage] = [ messages: list[AnyChatMessage] = [
UserChatMessage(content="do both"), UserChatMessage(content="do both"),
AssistantChatMessage(content="first, then the other", tool_calls=[_call("call_a"), _call("call_b")]), AssistantChatMessage(
content="first, then the other",
reasoning_content="step by step",
tool_calls=[_call("call_a"), _call("call_b")],
),
ToolChatMessage(tool_call_id="call_a", content="a ran"), ToolChatMessage(tool_call_id="call_a", content="a ran"),
] ]
@@ -37,9 +41,10 @@ def test_rollback_tail_keeps_the_executed_call_of_a_cut_short_batch() -> None:
assert len(tail) == 2 assert len(tail) == 2
record, result = tail record, result = tail
assert isinstance(record, AssistantChatMessage) assert isinstance(record, AssistantChatMessage)
# The assistant row survives only as the carrier of the pairing. # The assistant row survives as the carrier of the pairing, with the
# reasoning thinking mode demands on a call.
assert record.content is UNSET assert record.content is UNSET
assert record.reasoning_content is UNSET assert record.reasoning_content == "step by step"
calls = record.tool_calls calls = record.tool_calls
assert calls is not UNSET assert calls is not UNSET
assert [call.id for call in calls] == ["call_a"] assert [call.id for call in calls] == ["call_a"]