mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 11:06:18 +00:00
fix(models): pair Codex invalid tool calls with their tool results (#5509)
* fix(models): pair Codex invalid tool calls with their tool results
`_parse_response` parks a function_call whose `arguments` are not valid JSON
on `AIMessage.invalid_tool_calls`, keeping its id and name. `_convert_messages`
serialized only `msg.tool_calls`, so the placeholder ToolMessage that
`DanglingToolCallMiddleware` injects to answer that call was emitted as a
`function_call_output` whose call_id had no matching `function_call` item in
the same request. Responses requires that pairing, turning a recoverable
malformed call into a hard provider error.
Emit `invalid_tool_calls` alongside `tool_calls` as `function_call` items.
* fix(models): drop invalid tool calls that lack a name or call_id
InvalidToolCall fields are nullable, and serializing every invalid call as a
function_call item sends name: null and call_id: null for one that is missing
them, which the Responses schema does not accept. For a caller that reaches
_convert_messages without DanglingToolCallMiddleware, that turned a call the
old serializer dropped into a rejected request.
A call missing either field is now skipped, and its arguments fall back to
"{}" when they are neither an object nor a string. Skipping cannot orphan the
placeholder ToolMessage that this branch pairs the call with: the middleware
mints a synthetic id and a fallback name for exactly these calls before
serialization, so a call still missing them here has no placeholder.
This commit is contained in:
parent
3322d93935
commit
51bd002df9
@ -154,16 +154,33 @@ class CodexChatModel(BaseChatModel):
|
||||
if msg.content:
|
||||
content = self._normalize_content(msg.content)
|
||||
input_items.append({"role": "assistant", "content": content})
|
||||
if msg.tool_calls:
|
||||
for tc in msg.tool_calls:
|
||||
input_items.append(
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": tc["name"],
|
||||
"arguments": json.dumps(tc["args"]) if isinstance(tc["args"], dict) else tc["args"],
|
||||
"call_id": tc["id"],
|
||||
}
|
||||
)
|
||||
# Malformed calls are parked on ``invalid_tool_calls``, but
|
||||
# DanglingToolCallMiddleware answers them with a placeholder ToolMessage;
|
||||
# Responses rejects that function_call_output unless its function_call
|
||||
# item is in the request too.
|
||||
#
|
||||
# A function_call item needs both a name and a call_id, and every
|
||||
# InvalidToolCall field is nullable, so a call missing either is dropped
|
||||
# rather than serialized as a schema-invalid item. Dropping one cannot
|
||||
# orphan a placeholder ToolMessage: the middleware mints a synthetic id
|
||||
# and a fallback name for exactly these calls before serialization, so a
|
||||
# call still missing them here has no placeholder to pair with.
|
||||
for tc in [*msg.tool_calls, *(msg.invalid_tool_calls or [])]:
|
||||
name = tc.get("name")
|
||||
call_id = tc.get("id")
|
||||
if not (isinstance(name, str) and name):
|
||||
continue
|
||||
if not (isinstance(call_id, str) and call_id):
|
||||
continue
|
||||
args = tc.get("args")
|
||||
input_items.append(
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": name,
|
||||
"arguments": json.dumps(args) if isinstance(args, dict) else (args or "{}"),
|
||||
"call_id": call_id,
|
||||
}
|
||||
)
|
||||
elif isinstance(msg, ToolMessage):
|
||||
input_items.append(
|
||||
{
|
||||
|
||||
@ -211,6 +211,93 @@ def test_convert_messages_tool_message():
|
||||
assert items[0]["output"] == "result data"
|
||||
|
||||
|
||||
def test_convert_messages_keeps_placeholder_result_paired_with_invalid_tool_call():
|
||||
"""A malformed call stays on invalid_tool_calls but is answered by a placeholder
|
||||
ToolMessage, so it must still serialize as a function_call item.
|
||||
|
||||
Responses rejects a function_call_output whose call_id has no matching
|
||||
function_call item, so dropping the invalid call turns the placeholder the
|
||||
middleware injected for recovery into the provider error it exists to prevent.
|
||||
"""
|
||||
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
|
||||
|
||||
model = _make_model()
|
||||
response = {
|
||||
"output": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": "write_file",
|
||||
"arguments": '{"path": "report.md", "content": "unterminated',
|
||||
"call_id": "call_bad",
|
||||
}
|
||||
],
|
||||
"usage": {},
|
||||
}
|
||||
ai_msg = model._parse_response(response).generations[0].message
|
||||
assert [tc["id"] for tc in ai_msg.invalid_tool_calls] == ["call_bad"]
|
||||
|
||||
patched = DanglingToolCallMiddleware()._build_patched_messages([HumanMessage(content="write it"), ai_msg])
|
||||
assert isinstance(patched[-1], ToolMessage)
|
||||
assert patched[-1].tool_call_id == "call_bad"
|
||||
|
||||
_, items = model._convert_messages(patched)
|
||||
call_ids = {item["call_id"] for item in items if item.get("type") == "function_call"}
|
||||
output_ids = {item["call_id"] for item in items if item.get("type") == "function_call_output"}
|
||||
assert output_ids == {"call_bad"}
|
||||
assert output_ids <= call_ids
|
||||
|
||||
|
||||
def test_convert_messages_drops_invalid_calls_missing_a_name_or_call_id():
|
||||
"""A call the middleware has not repaired must not serialize as null fields.
|
||||
|
||||
InvalidToolCall fields are nullable, and a ``function_call`` item carrying a
|
||||
null ``name`` or ``call_id`` is schema-invalid, so serializing one turns a
|
||||
case the old serializer dropped into a rejected request. The middleware
|
||||
repairs exactly these calls (see the test below), so dropping them here
|
||||
cannot leave a placeholder ToolMessage without its call.
|
||||
"""
|
||||
model = _make_model()
|
||||
|
||||
for output_item in (
|
||||
{"type": "function_call", "name": "write_file", "arguments": '{"a":'},
|
||||
{"type": "function_call", "call_id": "call_x", "arguments": '{"a":'},
|
||||
{"type": "function_call", "arguments": '{"a":'},
|
||||
):
|
||||
ai_msg = model._parse_response({"output": [output_item], "usage": {}}).generations[0].message
|
||||
assert ai_msg.invalid_tool_calls, output_item
|
||||
|
||||
_, items = model._convert_messages([HumanMessage(content="hi"), ai_msg])
|
||||
assert [i for i in items if i.get("type") == "function_call"] == []
|
||||
|
||||
|
||||
def test_convert_messages_serializes_invalid_calls_the_middleware_repaired():
|
||||
"""A repaired invalid call is still sent, and still paired with its result."""
|
||||
from deerflow.agents.middlewares.dangling_tool_call_middleware import (
|
||||
DanglingToolCallMiddleware,
|
||||
)
|
||||
|
||||
model = _make_model()
|
||||
|
||||
for output_item in (
|
||||
{"type": "function_call", "name": "write_file", "arguments": '{"a":'},
|
||||
{"type": "function_call", "call_id": "call_x", "arguments": '{"a":'},
|
||||
{"type": "function_call", "arguments": '{"a":'},
|
||||
):
|
||||
ai_msg = model._parse_response({"output": [output_item], "usage": {}}).generations[0].message
|
||||
patched = DanglingToolCallMiddleware()._build_patched_messages([HumanMessage(content="hi"), ai_msg])
|
||||
|
||||
_, items = model._convert_messages(patched)
|
||||
call_ids = {i["call_id"] for i in items if i.get("type") == "function_call"}
|
||||
output_ids = {i["call_id"] for i in items if i.get("type") == "function_call_output"}
|
||||
assert output_ids == call_ids
|
||||
assert call_ids
|
||||
assert all(cid for cid in call_ids)
|
||||
for item in items:
|
||||
if item.get("type") == "function_call":
|
||||
assert item["name"]
|
||||
assert item["arguments"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_sse_data_line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user