Skip to content

Tool-call/output pairing is enforced only when history is rebuilt, not at the send boundary; injected standalone outputs are orphans by construction #46193

Open

Description

@JunkaiWang-TheoPhy

Summary

The "every tool call has an output" invariant is maintained only when history is rebuilt and is never checked at the point a request is sent, so a function_call with no matching function_call_output can reach a strict Responses upstream. That is the failure that permanently wedges a thread: the upstream rejects the whole turn with No tool output found for tool call X, and the rejected turn stays in the loaded history, so every later turn reproduces it until the thread is unloaded.

I am filing this as a location report rather than another reproduction of the symptom — the symptom is already tracked in #44604, #31053, #36827, #31579. What may be new here is where the guard is missing, plus a runnable checker for the strict-upstream rules, which is what took me the longest to establish.

Where the invariant is (and is not) enforced

  • codex-rs/core/src/context_manager/normalize.rs:21 ensure_call_outputs_present() synthesizes an output for any dangling call (the placeholder text is aborted).
  • It has exactly one call site: codex-rs/core/src/context_manager/history.rs:798, inside normalize_history(). So it applies to the history-snapshot path only.
  • codex-rs/core/src/client.rs:849 build_responses_request() performs no pairing/structure validation on the assembled input.
  • The first sampling request of a turn does not go through the snapshot path at all: codex-rs/core/src/session/turn.rs:1585 uses initial_input.take(), i.e. the caller-provided input, and only later iterations call clone_history().for_prompt(...).

So there is no send-time barrier. Whether the wire payload is well-formed depends on which path produced it, and a normal reply (not an error) is a well-formed provider response, which is why the failure surfaces as a wedged thread rather than a retry.

The injected-output case is a separate, deliberate orphan

Automations and cross-thread delegation submit SubmittedTurnInput::ResponseItem(FunctionCallOutput { call_id: None, .. }) (codex-rs/core/src/session/turn.rs:739). That shape is intentional — it is treated as external context (#39782, #39791) — but by construction it is an output with no call. On a strict upstream, neither form is acceptable:

input[i] sent to https://api.deepseek.com/v1/responses result
function_call_output with name/namespace/output, no call_id 400 input: missing field 'call_id'
same item with a synthesized call_id, no matching function_call 400 No tool call found for tool output with call_id …
synthesized function_call + the original output, one shared call_id 200
the output rewritten as a developer message 200

Populating call_id alone therefore converts one 400 into another; the pair has to exist in the same request. This is the same conclusion as #42088 and #45450, and I have added the table there as well.

A detail that cost me a lot of time

While mapping strict-upstream behaviour I found the provider's error text is not a reliable classifier, which may matter for triage of these reports:

transcript sent verdict message
single call: call → output → message(hook) 200
single call: call → message(hook) → output 400 No tool output found for tool call A.
batch: callA → callB → outA → outB 200
batch split by its own outputs: callA → outA → callB → outB 400 The 'reasoning_text' in the thinking mode must be passed back to the API
any transcript with an assistant/tool item but no reasoning item 400 The 'reasoning_text' …
reasoning replayed as summary with empty content 400 The 'reasoning_text' …

The third and fourth rows are the same underlying ordering problem reported with two different messages; the "reasoning" wording sends you after the wrong subsystem (I spent a cycle on it). Note also that batch-integrity is only decidable when a reasoning item is present — A → outA → B → outB is locally legal for every pair, so a checker cannot reject it on item order alone.

Runnable checker

To make this independent of any local proxy, I wrote a small strict-upstream stub that implements exactly the rules in the table above and returns the same messages. It reproduces all nine verdicts I measured against the real endpoint:

python3 strict_upstream.py --port 8123 --log /tmp/strict.jsonl --mode batch
# then point a CODEX_HOME provider at http://127.0.0.1:8123/v1 and start a turn

It logs every request with a compact shape summary and the verdict, so a failing case can be pasted directly into a report. Attached: strict_upstream.py — single file, no dependencies beyond the standard library.

Possible directions (for discussion, not a PR)

I understand external code PRs are not accepted, so this is only meant to frame the problem:

  1. Apply the presence invariant at the send boundary (or normalize initial_input the same way the snapshot path is normalized), so no request can leave with a dangling call regardless of which path built it.
  2. Decide the intended wire representation for injected standalone outputs for providers that verify pairing — synthesize the matching function_call, or lower the item to a message — rather than leaving it to each client/proxy to guess.
  3. Treat the "dangling call" 400 as recoverable (repair the loaded history and retry once) instead of ending the turn, since ending the turn is what makes the thread permanently unusable.

Environment

macOS arm64, Codex Desktop / app-server 0.154.0-alpha.6.2, upstream https://api.deepseek.com via a local provider gateway; code locations quoted against origin/main as of e269f21.

Activity

  1. added
    bugSomething isn't working
    appIssues related to the Codex desktop app
    tool-callsIssues related to tool calling
    custom-modelIssues related to custom model providers (including local models)
    on Sep 17, 2026
  2. JunkaiWang-TheoPhy commented on Sep 17, 2026

    @JunkaiWang-TheoPhy
    Author

    Adding the run that triggered this today, because it comes with a roll-out comparison that the summary above does not have.

    19:08:02  exec_command {"cmd": "sleep 60; tail -8 …"}        tool starts (long-running)
    19:08:37  Turn error: No tool output found for tool call call_dGjPbyB7dUN2jlrPS7ZwxiNp.
    

    The persisted roll-out for that thread contains 79 function_call items and 79 outputs, and no unpaired call — while the call_id named in the error appears neither as a call nor as an output in it. Every other call in that thread uses the ordinary call_00_… form; only the failing one uses call_dGjPby…. So the rejected call was present in the request that went out and absent from disk, which is consistent with the send-boundary gap described here rather than with "the output arrived late".

    Two supporting measurements from a local build of a validating proxy, checked against the real endpoint, that may save time for anyone reproducing:

    • A transcript whose batch is split by its own outputs (callA → outA → callB → outB) is rejected with The 'reasoning_text' in the thinking mode must be passed back to the API. The cause is ordering, not reasoning; reordering to callA → callB → outA → outB returns 200.
    • On the injected-output side, populating call_id on a standalone function_call_output is not sufficient on its own: with no matching function_call the same request fails differently (No tool call found for tool output with call_id …). Only synthesizing the pair (or rewriting the item as a message) yields 200.

    I filed the same observation twice from two parallel sessions (#46195, now closed) — sorry for the duplicate; this issue is the right place for it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    appIssues related to the Codex desktop appbugSomething isn't workingcustom-modelIssues related to custom model providers (including local models)tool-callsIssues related to tool calling

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions