Skip to content

fix(models): pair Codex invalid tool calls with their tool results - #5509

Merged
WillemJiang merged 2 commits into
bytedance:mainfrom
BlueX888:fix/prep-codex-invalid-tool-call-orphan-output
Sep 17, 2026
Merged

WillemJiang merged 2 commits into
bytedance:mainfrom
BlueX888:fix/prep-codex-invalid-tool-call-orphan-output

Conversation

@BlueX888

Copy link
Copy Markdown
Contributor

Why

A Codex model that emits a function_call whose arguments are not valid JSON does not fail the turn: CodexChatModel._parse_response parks the call on AIMessage.invalid_tool_calls (keeping its call_id and name), and DanglingToolCallMiddleware — always in the agent middleware chain at backend/packages/harness/deerflow/agents/factory.py:251 — answers it with a placeholder ToolMessage carrying the same call_id, so the model sees a recoverable tool error.

The serializer in CodexChatModel._convert_messages (backend/packages/harness/deerflow/models/openai_codex_provider.py:157) then dropped that call. It emitted input items for msg.tool_calls only, so the placeholder reached POST /backend-api/codex/responses as a function_call_output whose call_id had no matching function_call item in the same request. Responses requires the pair, so a malformed call — the exact case the middleware exists to recover from — ended in a provider error instead of a retry.

Related context: #2890 reported this failure class on the OpenAI-compatible path, where the repair landed in the middleware. The Codex Responses path serializes its own request and was not covered by that fix.

What changed

  • Codex requests now replay invalid_tool_calls as function_call input items next to the valid ones, so the placeholder function_call_output that answers them stays paired.
  • The valid-call path, the parse side (invalid_tool_calls population), and the middleware are unchanged. Chat Completions providers already serialize invalid_tool_calls this way through LangChain's converter; this brings the Responses serializer in line with them.

Surface area

  • Frontend UI — page / component / setting / interaction under frontend/
  • Backend API — endpoint / SSE event / request-response shape under backend/app
  • Agents / LangGraph — agent node, graph wiring, langgraph.json, or prompt change
  • Sandbox — docker/ or sandboxed execution
  • Skills — change under skills/
  • Dependencies — new/upgraded entry in backend/pyproject.toml or frontend/package.json (say what it buys us)
  • Default behavior change — changes existing behavior without the user opting in (default model, default setting, data shape)
  • Docs / tests / CI only — no runtime behavior change

No box matches: the change is the Codex Responses request serializer in backend/packages/harness/deerflow/models/openai_codex_provider.py, a harness model provider rather than backend/app, and no default is changed. The bug-fix verification below is the scope signal for review.

Bug fix verification

  • Test path that reproduces the bug: backend/tests/test_codex_provider.py::test_convert_messages_keeps_placeholder_result_paired_with_invalid_tool_call
  • Did it go red on main and green on this branch? yes

The test drives the real chain rather than a hand-built message: _parse_response on a Codex function_call frame with truncated JSON, then DanglingToolCallMiddleware._build_patched_messages, then _convert_messages. On main the placeholder is dropped at the serialization boundary:

>       assert output_ids <= call_ids
E       AssertionError: assert {'call_bad'} <= set()
E
E         Extra items in the left set:
E         'call_bad'

tests/test_codex_provider.py:247: AssertionError
------------------------------ Captured log call -------------------------------
WARNING  deerflow.agents.middlewares.dangling_tool_call_middleware:dangling_tool_call_middleware.py:498 DanglingToolCallMiddleware: 0 orphan(s) dropped, 1 placeholder(s) injected
=========================== short test summary info ============================
FAILED tests/test_codex_provider.py::test_convert_messages_keeps_placeholder_result_paired_with_invalid_tool_call
============================== 1 failed in 1.03s ===============================

Validation

cd backend && uv run --locked pytest tests/test_codex_provider.py -q
22 passed in 0.78s

cd backend && uv run --locked pytest tests/test_dangling_tool_call_middleware.py -q
64 passed in 0.63s

cd backend && uv run --locked pytest -m "not live" --ignore=tests/blocking_io tests/ -q
16961 passed, 186 skipped, 3 deselected, 32 warnings in 363.26s (0:06:03)

cd backend && uv run --locked ruff check .
All checks passed!

cd backend && uv run --locked ruff format --check .
1499 files already formatted

AI assistance

Tool(s) used: Claude Code

How you used it: Claude Code traced the failure from the middleware placeholder back to the Responses serializer, wrote the regression test and the serializer change, and ran the red/green cycle plus the full backend suite. I reviewed every line of the diff and the test's assertions before committing.

  • I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

`_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.
@github-actions github-actions Bot added area:backend Gateway / runtime / core backend under backend/ risk:medium Medium risk: regular code changes size/S PR changes 20-100 lines labels Sep 17, 2026

@willem-bd willem-bd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head a108750. The core fix is correct and verified end-to-end: DanglingToolCallMiddleware is always in the agent chain and gives every invalid_tool_call a (possibly synthetic) call_id, a normalized name, and "{}" arguments plus a paired placeholder ToolMessage, so emitting function_call items for invalid_tool_calls exactly repairs the orphan function_call_output that the Responses endpoint rejects. The new test fails against main and passes at head (86/86 across test_codex_provider.py + test_dangling_tool_call_middleware.py), and ruff format/check are clean. One suggestion below about nullable InvalidToolCall fields on paths that reach _convert_messages without the middleware.

"type": "function_call",
"name": tc["name"],
"arguments": json.dumps(tc["args"]) if isinstance(tc["args"], dict) else tc["args"],
"call_id": tc["id"],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InvalidToolCall fields are nullable — langchain's parser and this provider's own _parse_tool_call_arguments (via .get()) can produce name: None, id: None, or args: None. This loop then emits {"type": "function_call", "name": null, ..., "call_id": null}, which the Responses endpoint rejects as schema-invalid — turning a case the old serializer silently dropped into a full-request 400. In the agent path DanglingToolCallMiddleware relabels/normalizes these before the request, but _convert_messages is also reachable without the middleware (direct model.invoke callers). Consider skipping invalid calls whose name/id are not non-empty strings (defaulting args to "{}"); that cannot orphan a placeholder, since the middleware mints synthetic ids for exactly those calls before serialization.

@BlueX888 BlueX888 Sep 17, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and reproduced — that path does emit the nulls. 22a8b250 drops a call that is missing either field.

Driving _convert_messages without the middleware, for a malformed function_call item on each of those shapes:

no call_id  -> {"type":"function_call","name":"write_file","arguments":"{\"a\":","call_id":null}
no name     -> {"type":"function_call","name":null,"arguments":"{\"a\":","call_id":"call_x"}
neither     -> {"type":"function_call","name":null,"arguments":"{\"a\":","call_id":null}

_parse_tool_call_arguments builds these with output_item.get("name") and .get("call_id"), so both really are None rather than absent. The same three inputs through DanglingToolCallMiddleware come out as unknown_tool / deerflow_synthetic_tool_call_1_invalid_0 with arguments: {}, and pair with their placeholder — which is what makes the skip safe: a call the middleware has not repaired is one it did not mint an id for, so no placeholder exists to orphan.

Arguments also fall back to "{}" when they are neither an object nor a string, since args is nullable on the same type. Two tests cover both directions: the three shapes are dropped without the middleware, and with it they are still serialized and still paired with their function_call_output. 88 passed across test_codex_provider.py and test_dangling_tool_call_middleware.py.

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.
@github-actions github-actions Bot added size/M PR changes 100-300 lines and removed size/S PR changes 20-100 lines labels Sep 17, 2026
@WillemJiang WillemJiang added this to the 2.1.0 milestone Sep 17, 2026
@WillemJiang
WillemJiang merged commit 51bd002 into bytedance:main Sep 17, 2026
19 checks passed
BlueX888 added a commit to BlueX888/BlueX888 that referenced this pull request Sep 17, 2026
- NOTES 补 bytedance/deer-flow#5509、agno-agi/agno#9948 两条中文说明(此前 cron 已把标题行写进贡献区但没有说明)
- 缺陷表:deer-flow #5473 → 社区 PR #5494 已修复;openai-agents-python #5041 → 维护者未接受并关闭(需真实 provider wire 证据)
- deer-flow star 数 82k → 83k
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:backend Gateway / runtime / core backend under backend/ risk:medium Medium risk: regular code changes size/M PR changes 100-300 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants