mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
core/agent: interleave responses tool outputs after their calls
This commit is contained in:
@@ -83,6 +83,49 @@ def _assistant_to_input_items(
|
|||||||
return 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(
|
def chat_messages_to_responses_input(
|
||||||
messages: Sequence[AnyChatMessage],
|
messages: Sequence[AnyChatMessage],
|
||||||
) -> tuple[str | None, list[dict[str, Any] | ResponseEasyInputMessage | ResponseFunctionToolCallOutput]]:
|
) -> 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
|
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:
|
def response_to_assistant_message(response: Response) -> AssistantChatMessage:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from plyngent.agent.responses_bridge import (
|
|||||||
responses_status_to_finish_reason,
|
responses_status_to_finish_reason,
|
||||||
tool_items_to_response_tools,
|
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 (
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
AssistantChatMessage,
|
AssistantChatMessage,
|
||||||
AssistantFunctionTool,
|
AssistantFunctionTool,
|
||||||
@@ -57,6 +57,51 @@ def test_chat_messages_to_input_and_instructions() -> None:
|
|||||||
assert len(items) == 3 # user, function_call, function_call_output
|
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:
|
def test_response_to_assistant_with_tools() -> None:
|
||||||
raw = {
|
raw = {
|
||||||
"id": "resp_1",
|
"id": "resp_1",
|
||||||
|
|||||||
Reference in New Issue
Block a user