openai / openai/codex

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

Open
#46,193 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

app bug custom-model tool-calls
Dominant language
Rust
Stars
125k
Forks
19.4k
PR merge metrics
PR metrics pending

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reading codex-rs/core/src/context_manager/normalize.rs, history.rs, client.rs, and session/turn.rs to trace the two request-building paths and the injected output case. Run strict_upstream.py with the documented command to reproduce the listed verdicts. Done requires an agreed representation for standalone outputs and a send path that prevents invalid call/output structures, but the issue leaves those design choices open.

Written by the indexing model from the issue text.

Assessment

Tech stack
python, rust
Domain
api, backend
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
28/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.