From 51bd002df928c8855b5db89fe77c148468dd5021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=93=88=E5=9F=BA=E7=B1=B3?= <140241684+BlueX888@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:10:18 +0800 Subject: [PATCH] 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. --- .../deerflow/models/openai_codex_provider.py | 37 +++++--- backend/tests/test_codex_provider.py | 87 +++++++++++++++++++ 2 files changed, 114 insertions(+), 10 deletions(-) diff --git a/backend/packages/harness/deerflow/models/openai_codex_provider.py b/backend/packages/harness/deerflow/models/openai_codex_provider.py index 95cf12b09..856484b42 100644 --- a/backend/packages/harness/deerflow/models/openai_codex_provider.py +++ b/backend/packages/harness/deerflow/models/openai_codex_provider.py @@ -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( { diff --git a/backend/tests/test_codex_provider.py b/backend/tests/test_codex_provider.py index 1b9136b85..6735c7132 100644 --- a/backend/tests/test_codex_provider.py +++ b/backend/tests/test_codex_provider.py @@ -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 # ---------------------------------------------------------------------------