Tool-call/output pairing is enforced only when history is rebuilt, not at the send boundary; injected standalone outputs are orphans by construction
Nobody has claimed this yet.
- 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:21ensure_call_outputs_present()synthesizes an output for any dangling call (the placeholder text isaborted).- It has exactly one call site:
codex-rs/core/src/context_manager/history.rs:798, insidenormalize_history(). So it applies to the history-snapshot path only. codex-rs/core/src/client.rs:849build_responses_request()performs no pairing/structure validation on the assembledinput.- The first sampling request of a turn does not go through the snapshot path at all:
codex-rs/core/src/session/turn.rs:1585usesinitial_input.take(), i.e. the caller-provided input, and only later iterations callclone_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:
- Apply the presence invariant at the send boundary (or normalize
initial_inputthe same way the snapshot path is normalized), so no request can leave with a dangling call regardless of which path built it. - 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. - 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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