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:
哈基米 2026-09-17 21:10:18 +08:00 committed by GitHub
parent 3322d93935
commit 51bd002df9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 114 additions and 10 deletions

View File

@ -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(
{

View File

@ -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
# ---------------------------------------------------------------------------