mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
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:
@@ -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()) == ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user