diff --git a/src/plyngent/agent/responses_bridge.py b/src/plyngent/agent/responses_bridge.py index 7c130a3..15c0022 100644 --- a/src/plyngent/agent/responses_bridge.py +++ b/src/plyngent/agent/responses_bridge.py @@ -83,6 +83,49 @@ def _assistant_to_input_items( return items +def _item_call_id( + item: dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput, +) -> str | None: + """Return the call id for a ``function_call`` input item, else ``None``.""" + if isinstance(item, ResponseFunctionToolCallOutput): + return item.call_id + if isinstance(item, dict) and item.get("type") == "function_call": + call_id = item.get("call_id") + return call_id if isinstance(call_id, str) else None + return None + + +def _interleave_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``. + + 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). + """ + outputs_by_call: dict[str, list[ResponseFunctionToolCallOutput]] = {} + for item in items: + if isinstance(item, ResponseFunctionToolCallOutput): + outputs_by_call.setdefault(item.call_id, []).append(item) + + out: list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput] = [] + for item in items: + if isinstance(item, ResponseFunctionToolCallOutput): + continue # re-added right after their call below + out.append(item) + 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)) + # Defensive: outputs whose call was not seen keep stream order at the end. + for pending in outputs_by_call.values(): + out.extend(pending) + return out + + def chat_messages_to_responses_input( messages: Sequence[AnyChatMessage], ) -> tuple[str | None, list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput]]: @@ -112,7 +155,7 @@ def chat_messages_to_responses_input( ) instructions = "\n\n".join(instructions_parts) if instructions_parts else None - return instructions, items + return instructions, _interleave_tool_outputs(items) def response_to_assistant_message(response: Response) -> AssistantChatMessage: diff --git a/tests/test_agent/test_responses_bridge.py b/tests/test_agent/test_responses_bridge.py index eb0a069..104e1ab 100644 --- a/tests/test_agent/test_responses_bridge.py +++ b/tests/test_agent/test_responses_bridge.py @@ -8,7 +8,7 @@ from plyngent.agent.responses_bridge import ( responses_status_to_finish_reason, tool_items_to_response_tools, ) -from plyngent.lmproto.openai.model import Response +from plyngent.lmproto.openai.model import Response, ResponseFunctionToolCallOutput from plyngent.lmproto.openai_compatible.model import ( AssistantChatMessage, AssistantFunctionTool, @@ -57,6 +57,51 @@ def test_chat_messages_to_input_and_instructions() -> None: assert len(items) == 3 # user, function_call, function_call_output +def test_parallel_tool_outputs_interleaved_after_calls() -> None: + """Regression: DeepSeek Responses rejects call, call, output, output batches. + + Each ``function_call_output`` must directly follow its ``function_call`` so + strict/adjacency-matching servers (DeepSeek) accept parallel tool rounds. + """ + messages = [ + UserChatMessage(content="run both"), + AssistantChatMessage( + content=None, + tool_calls=[ + AssistantFunctionToolCall( + id="call_01_LPBuUG3KTaMDIwUoLwTE9127", + function=AssistantFunctionTool(name="read_file", arguments='{"path":"a"}'), + ), + AssistantFunctionToolCall( + id="call_02_ABCD", + function=AssistantFunctionTool(name="grep_files", arguments='{"pattern":"todo"}'), + ), + ], + ), + ToolChatMessage(content="file body", tool_call_id="call_01_LPBuUG3KTaMDIwUoLwTE9127"), + 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] + assert types == [ + "message", + "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] + call_b = items[3] + out_b = items[4] + assert isinstance(call_a, dict) and isinstance(call_b, dict) + assert isinstance(out_a, ResponseFunctionToolCallOutput) + assert isinstance(out_b, ResponseFunctionToolCallOutput) + assert out_a.call_id == call_a["call_id"] + assert out_b.call_id == call_b["call_id"] + + def test_response_to_assistant_with_tools() -> None: raw = { "id": "resp_1",