diff --git a/src/plyngent/agent/responses_bridge.py b/src/plyngent/agent/responses_bridge.py index bd6b316..a76460e 100644 --- a/src/plyngent/agent/responses_bridge.py +++ b/src/plyngent/agent/responses_bridge.py @@ -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) diff --git a/tests/test_agent/test_responses_bridge.py b/tests/test_agent/test_responses_bridge.py index 1c8f6fc..7ba7203 100644 --- a/tests/test_agent/test_responses_bridge.py +++ b/tests/test_agent/test_responses_bridge.py @@ -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()) == ""