core/agent: return each round's reasoning in the Responses input

A request carrying `tools` must return every round's chain-of-thought — "必须完整
回传 `reasoning_content` … 即使该轮模型未实际进行工具调用" — and the API merges a
`reasoning` item into the assistant message its round forms: a missing item and
an empty `reasoning_text` are both answered with "The `reasoning_text` in the
thinking mode must be passed back to the API". Each round now goes back as its
reasoning, its text, then its calls, with a blank line standing in when the
round kept none (a `rollback_tail` carrier, a forged todo call, the seed a
compacted session starts with) — never words of the bridge's own for a round
that may genuinely have reasoned nothing. A round's calls stay contiguous ahead
of their outputs: an interleaved batch reads as a second round without
reasoning and is rejected the same way, and an output recorded after another
item moves up beside its own call.

The reasoning is read back from the item's `content` parts, the only part the
API documents for `reasoning` items (`summary` and `encrypted_content` are not
supported).
This commit is contained in:
2026-10-06 23:23:18 +08:00
parent 932b4f0a7f
commit c06fb57e72
2 changed files with 357 additions and 39 deletions
+77 -27
View File
@@ -41,6 +41,15 @@ if TYPE_CHECKING:
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
from plyngent.typedef import Unset
# A round that kept no chain-of-thought still goes back with a ``reasoning``
# item, holding a blank line: thinking mode pairs every round of the turn being
# continued with its reasoning and rejects both a missing item and an empty
# ``reasoning_text`` — "The ``reasoning_text`` in the thinking mode must be
# passed back to the API" — while any non-empty text is accepted. A blank part
# says nothing the round did not say; a line of prose here would put the
# bridge's words in the model's own mouth.
_BLANK_REASONING = " "
def tool_items_to_response_tools(
tools: Sequence[AnyToolItem] | None,
@@ -64,11 +73,44 @@ def tool_items_to_response_tools(
return result
def _reasoning_item(text: str) -> dict[str, Any]:
"""A ``reasoning`` input item holding *text* as its chain-of-thought."""
return {"type": "reasoning", "content": [{"type": "reasoning_text", "text": text}]}
def _assistant_to_input_items(
message: AssistantChatMessage,
) -> list[dict[str, Any] | ResponseEasyInputMessage]:
"""Map one assistant message to Responses ``input`` items.
A round's items come back in the order the model produced them: the
reasoning, then the text, then the calls. The ``reasoning`` item always
holds text — the API merges it into the assistant message its round forms,
and the turn being continued must return every round's chain-of-thought
("必须完整回传 ``reasoning_content`` … 即使该轮模型未实际进行工具调用") — and
a round that kept none sends a blank line, because an empty
``reasoning_text`` and a missing item are both answered with a 400.
History holds rounds with no chain-of-thought at all — a ``rollback_tail``
carrier, a forged ``todo_list`` call, the seed a compacted session starts
with, rounds recorded before the reasoning was read back.
The part is ``reasoning_text``, the only content part the API documents for
``reasoning`` items (``summary`` and ``encrypted_content`` are unsupported).
"""
items: list[dict[str, Any] | ResponseEasyInputMessage] = []
if message.tool_calls is not UNSET and message.tool_calls:
raw_reasoning = message.reasoning_content
reasoning = raw_reasoning if isinstance(raw_reasoning, str) else ""
tool_calls = message.tool_calls
calls = (
[call for call in tool_calls if isinstance(call, AssistantFunctionToolCall)] if tool_calls is not UNSET else []
)
content = message.content
text = content if isinstance(content, str) and content else None
if calls or text is not None or reasoning:
items.append(_reasoning_item(reasoning or _BLANK_REASONING))
if text is not None:
items.append(ResponseEasyInputMessage(role="assistant", content=text))
items.extend(
{
"type": "function_call",
@@ -76,11 +118,8 @@ def _assistant_to_input_items(
"name": call.function.name,
"arguments": call.function.arguments,
}
for call in message.tool_calls
if isinstance(call, AssistantFunctionToolCall)
for call in calls
)
if isinstance(message.content, str) and message.content:
items.append(ResponseEasyInputMessage(role="assistant", content=message.content))
return items
@@ -96,17 +135,20 @@ def _item_call_id(
return None
def _interleave_tool_outputs(
def _group_tool_outputs(
items: list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput],
) -> list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput]:
"""Place each ``function_call_output`` immediately after its ``function_call``.
"""Place a round's ``function_call_output`` items after all of its calls.
OpenAI matches outputs to calls by ``call_id`` anywhere in ``input``, but
DeepSeek's Responses implementation expects each ``function_call`` to be
directly followed by its ``function_call_output`` — a parallel batch sent
as ``call, call, output, output`` is rejected with "No tool output found
for tool call …". Interleaving is a no-op for single-call rounds and stays
valid for OpenAI (outputs still follow their calls).
The API reads a round as the assistant message its ``reasoning`` and
``function_call`` items merge into, so a round's calls must stay contiguous:
sent as ``call, output, call, output``, the second call becomes a round of
its own without reasoning and the request is answered with "The
``reasoning_text`` in the thinking mode must be passed back to the API".
Grouping also keeps each output next to its own call when another item (a
developer notice) was recorded between them — an output that trails an
unrelated assistant message is rejected with "No tool output found for tool
call …".
"""
outputs_by_call: dict[str, list[ResponseFunctionToolCallOutput]] = {}
for item in items:
@@ -114,16 +156,22 @@ def _interleave_tool_outputs(
outputs_by_call.setdefault(item.call_id, []).append(item)
out: list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput] = []
pending: list[str] = []
for item in items:
if isinstance(item, ResponseFunctionToolCallOutput):
continue # re-added right after their call below
out.append(item)
continue # re-added after the round's last call below
call_id = _item_call_id(item)
if call_id is not None and call_id in outputs_by_call:
out.extend(outputs_by_call.pop(call_id))
if call_id is None:
out.extend(output for pending_id in pending for output in outputs_by_call.pop(pending_id, []))
pending.clear()
out.append(item)
if call_id is not None:
pending.append(call_id)
for pending_id in pending:
out.extend(outputs_by_call.pop(pending_id, []))
# Defensive: outputs whose call was not seen keep stream order at the end.
for pending in outputs_by_call.values():
out.extend(pending)
for pending_outputs in outputs_by_call.values():
out.extend(pending_outputs)
return out
@@ -156,7 +204,7 @@ def chat_messages_to_responses_input(
)
instructions = "\n\n".join(instructions_parts) if instructions_parts else None
return instructions, _interleave_tool_outputs(items)
return instructions, _group_tool_outputs(items)
def response_to_assistant_message(response: Response) -> AssistantChatMessage:
@@ -189,7 +237,7 @@ def _reasoning_text_blocks(raw: object) -> list[str]:
if not isinstance(block_obj, dict):
continue
block_map = cast("dict[str, object]", block_obj)
if block_map.get("type") in {"summary_text", "output_text", "reasoning_content"}:
if block_map.get("type") in {"summary_text", "output_text", "reasoning_text", "reasoning_content"}:
text = block_map.get("text")
if isinstance(text, str) and text:
parts.append(text)
@@ -197,12 +245,14 @@ def _reasoning_text_blocks(raw: object) -> list[str]:
def reasoning_summary_text(response: Response) -> str:
"""Concatenate reasoning text from the response.
"""Concatenate the chain-of-thought text of a completed response.
OpenAI puts ``reasoning`` items in ``output`` with a ``summary`` block list.
DeepSeek (Responses convention) additionally returns the full chain-of-thought
in the top-level ``response.reasoning`` (``content``/``summary`` block lists);
prefer it when present so the two sources are not duplicated.
A reasoning item carries it in ``content`` — ``reasoning_text`` parts, with
``summary`` left empty (DeepSeek's documented shape; OpenAI streams a
summary instead). DeepSeek (Responses convention) may also return the full
chain-of-thought in the top-level ``response.reasoning`` (``content`` /
``summary`` block lists); prefer it when present so the two sources are not
duplicated.
"""
reasoning = response.reasoning
if isinstance(reasoning, dict):
@@ -213,7 +263,7 @@ def reasoning_summary_text(response: Response) -> str:
for raw in response.output:
if raw.get("type") != "reasoning":
continue
parts.extend(_reasoning_text_blocks(raw.get("summary")))
parts.extend(_reasoning_text_blocks(raw.get("content")) + _reasoning_text_blocks(raw.get("summary")))
return "".join(parts)
+280 -12
View File
@@ -13,12 +13,13 @@ from plyngent.agent.responses_bridge import (
responses_status_to_finish_reason,
tool_items_to_response_tools,
)
from plyngent.lmproto.openai.model import Response, ResponseFunctionToolCallOutput
from plyngent.lmproto.openai.model import Response, ResponseEasyInputMessage, ResponseFunctionToolCallOutput
from plyngent.lmproto.openai_compatible.model import (
AssistantChatMessage,
AssistantFunctionTool,
AssistantFunctionToolCall,
ChatCompletionsParam,
DeveloperChatMessage,
SystemChatMessage,
ToolChatMessage,
ToolFunction,
@@ -27,6 +28,18 @@ from plyngent.lmproto.openai_compatible.model import (
)
def _input_item_types(
items: list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput],
) -> list[str]:
"""Item ``type`` tags, in stream order."""
types: list[str] = []
for item in items:
tag = item.get("type") if isinstance(item, dict) else item.__struct_config__.tag
assert isinstance(tag, str)
types.append(tag)
return types
def test_tool_items_to_response_tools() -> None:
items = [
ToolFunctionItem(
@@ -59,14 +72,18 @@ def test_chat_messages_to_input_and_instructions() -> None:
]
instructions, items = chat_messages_to_responses_input(messages)
assert instructions == "You are helpful."
assert len(items) == 3 # user, function_call, function_call_output
# user message, then the call turn's reasoning + call, then its output.
assert _input_item_types(items) == ["message", "reasoning", "function_call", "function_call_output"]
def test_parallel_tool_outputs_interleaved_after_calls() -> None:
"""Regression: DeepSeek Responses rejects call, call, output, output batches.
def test_parallel_tool_outputs_group_after_the_rounds_calls() -> None:
"""Regression: DeepSeek Responses rejected an interleaved batch.
Each ``function_call_output`` must directly follow its ``function_call`` so
strict/adjacency-matching servers (DeepSeek) accept parallel tool rounds.
A round is the assistant message its ``reasoning`` and ``function_call``
items merge into, so its calls must stay contiguous: sent as
``call, output, call, output`` the second call reads as a round of its own
without reasoning, and the request comes back 400 ("The ``reasoning_text`` in
the thinking mode must be passed back to the API").
"""
messages = [
UserChatMessage(content="run both"),
@@ -87,19 +104,20 @@ def test_parallel_tool_outputs_interleaved_after_calls() -> None:
ToolChatMessage(content="grep hit", tool_call_id="call_02_ABCD"),
]
_, items = chat_messages_to_responses_input(messages)
types = [item.get("type") if isinstance(item, dict) else item.__struct_config__.tag for item in items]
types = _input_item_types(items)
assert types == [
"message",
"reasoning",
"function_call",
"function_call",
"function_call_output",
"function_call",
"function_call_output",
]
# Each output is paired with its own call, in stream order.
call_a = items[1]
out_a = items[2]
# Each output follows the round's calls, paired with its own call.
call_a = items[2]
call_b = items[3]
out_b = items[4]
out_a = items[4]
out_b = items[5]
assert isinstance(call_a, dict) and isinstance(call_b, dict)
assert isinstance(out_a, ResponseFunctionToolCallOutput)
assert isinstance(out_b, ResponseFunctionToolCallOutput)
@@ -107,6 +125,221 @@ def test_parallel_tool_outputs_interleaved_after_calls() -> None:
assert out_b.call_id == call_b["call_id"]
def test_tool_output_after_another_item_still_follows_its_call() -> None:
"""A notice recorded between a call and its output does not split the pair.
The output is moved up beside the calls of its round; left where it was it
would trail the notice's assistant message and be answered with "No tool
output found for tool call …".
"""
messages = [
AssistantChatMessage(
content=None,
reasoning_content="Read the file.",
tool_calls=[
AssistantFunctionToolCall(
id="call_1",
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
)
],
),
DeveloperChatMessage(content="reminder: keep going"),
ToolChatMessage(content="file body", tool_call_id="call_1"),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == [
"reasoning",
"function_call",
"function_call_output",
"message",
]
assert items[2] == ResponseFunctionToolCallOutput(call_id="call_1", output="file body")
def test_tool_call_turn_carries_its_reasoning() -> None:
"""Regression: DeepSeek rejected the tool-call round with a 422.
The reasoning item leads, directly ahead of the call it produced — DeepSeek
merges it into that call's assistant message. Its ``content`` is a list of
``reasoning_text`` parts, the only part the API reference documents for a
``reasoning`` input item (a bare string is a deserialization error).
"""
messages = [
UserChatMessage(content="read it"),
AssistantChatMessage(
content=None,
reasoning_content="Open the file first.",
tool_calls=[
AssistantFunctionToolCall(
id="call_1",
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
)
],
),
ToolChatMessage(content="file body", tool_call_id="call_1"),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == ["message", "reasoning", "function_call", "function_call_output"]
assert items[1] == {
"type": "reasoning",
"content": [{"type": "reasoning_text", "text": "Open the file first."}],
}
def test_parallel_call_turn_keeps_one_reasoning_item() -> None:
"""A batch keeps the round's single reasoning item, ahead of its calls."""
messages = [
AssistantChatMessage(
content=None,
reasoning_content="Both are independent.",
tool_calls=[
AssistantFunctionToolCall(
id="call_a",
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
),
AssistantFunctionToolCall(
id="call_b",
function=AssistantFunctionTool(name="regex_files", arguments='{"pattern":"todo"}'),
),
],
),
ToolChatMessage(content="file body", tool_call_id="call_a"),
ToolChatMessage(content="grep hit", tool_call_id="call_b"),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == [
"reasoning",
"function_call",
"function_call",
"function_call_output",
"function_call_output",
]
assert items[0] == {
"type": "reasoning",
"content": [{"type": "reasoning_text", "text": "Both are independent."}],
}
def test_round_with_text_and_a_call_puts_the_text_before_the_call() -> None:
"""A call round ends on its tool output, never on a trailing text message.
A request that ended with the round's text *after* its own output was
answered with a 400 ("the `reasoning_text` … must be passed back"): the
text has to stay with its reasoning, ahead of the calls.
"""
messages = [
UserChatMessage(content="read it"),
AssistantChatMessage(
content="Reading the file now.",
reasoning_content="Open the file first.",
tool_calls=[
AssistantFunctionToolCall(
id="call_1",
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
)
],
),
ToolChatMessage(content="file body", tool_call_id="call_1"),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == ["message", "reasoning", "message", "function_call", "function_call_output"]
def test_answer_round_without_reasoning_sends_a_blank_part() -> None:
"""A full part is required, not just the item.
An empty ``reasoning_text`` is answered with the same 400 as a missing item,
so a round that kept no chain-of-thought — the seed a compacted session
starts with, a round recorded before the reasoning was read back — sends a
blank line rather than words of the bridge's own. The rule is per round: an
answer that called nothing is checked too.
"""
messages = [
UserChatMessage(content="hi"),
AssistantChatMessage(content="hello"),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == ["message", "reasoning", "message"]
assert items[1] == {
"type": "reasoning",
"content": [{"type": "reasoning_text", "text": " "}],
}
def test_answer_turn_with_reasoning_carries_it() -> None:
"""A round that called nothing still hands its reasoning back.
Thinking Mode → Tool Calls: a request carrying ``tools`` must return the
reasoning of every round, "even if the round did not actually call a tool";
a missing one is a 400.
"""
messages = [
UserChatMessage(content="hi"),
AssistantChatMessage(content="hello", reasoning_content="They greet me, so I greet back."),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == ["message", "reasoning", "message"]
assert items[1] == {
"type": "reasoning",
"content": [{"type": "reasoning_text", "text": "They greet me, so I greet back."}],
}
def test_forged_call_round_sends_a_blank_part() -> None:
"""A synthetic nag assistant carries ``reasoning_content=""``.
The round still needs reasoning text, so a blank line goes back with it
rather than an empty part (which the API rejects just like a missing item).
"""
messages = [
AssistantChatMessage(
content=None,
reasoning_content="",
tool_calls=[
AssistantFunctionToolCall(
id="call_nag_1",
function=AssistantFunctionTool(name="todo_list", arguments="{}"),
)
],
),
ToolChatMessage(content="stack", tool_call_id="call_nag_1"),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == ["reasoning", "function_call", "function_call_output"]
assert items[0] == {
"type": "reasoning",
"content": [{"type": "reasoning_text", "text": " "}],
}
def test_rolled_back_call_round_sends_a_blank_part() -> None:
"""A kept call that recorded no reasoning still carries reasoning text.
History from before the chain-of-thought was read back, and a carrier
``rollback_tail`` wrote without one, hold a call with no reasoning at all;
the round must return text, so a blank line goes back with it (both a
missing item and an empty part are a 400).
"""
messages = [
AssistantChatMessage(
content=None,
tool_calls=[
AssistantFunctionToolCall(
id="call_kept",
function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'),
)
],
),
ToolChatMessage(content="file body", tool_call_id="call_kept"),
]
_, items = chat_messages_to_responses_input(messages)
assert _input_item_types(items) == ["reasoning", "function_call", "function_call_output"]
assert items[0] == {
"type": "reasoning",
"content": [{"type": "reasoning_text", "text": " "}],
}
def test_response_to_assistant_with_tools() -> None:
raw = {
"id": "resp_1",
@@ -194,6 +427,41 @@ def test_reasoning_summary_text_falls_back_to_output_items() -> None:
assert reasoning_summary_text(response) == "openai-style reasoning"
def test_reasoning_summary_text_reads_output_item_content() -> None:
"""DeepSeek's reasoning item carries the chain-of-thought in ``content``.
The parts are ``reasoning_text`` and ``summary`` comes back empty (see the
API reference sample), so reading ``summary`` alone finds nothing — and a
tool-call turn then goes back without the reasoning thinking mode wants.
"""
body = {
"id": "resp_c",
"object": "response",
"created_at": 1,
"model": "deepseek-flash",
"status": "completed",
"output": [
{
"type": "reasoning",
"id": "rs_1",
"status": "completed",
"content": [{"type": "reasoning_text", "text": "The user greets me."}],
"summary": [],
},
{
"type": "function_call",
"call_id": "call_1",
"name": "read_file",
"arguments": '{"path":"a"}',
"status": "completed",
},
],
}
response = msgspec.convert(body, Response)
assert reasoning_summary_text(response) == "The user greets me."
assert response_to_assistant_message(response).reasoning_content == "The user greets me."
def test_reasoning_summary_text_empty_when_absent() -> None:
assert reasoning_summary_text(_reasoning_response()) == ""