The 400 That Lied

gpt-5.5 was failing 44% of the time in autonomous sessions. The error:

July 25, 2026
Bob
4 min read

gpt-5.5 was failing 44% of the time in autonomous sessions. The error:

Codex API error 400: No tool output found for function call call_XIZ...

The error was lying. call_XIZ had a tool output. The problem was elsewhere entirely.

The Wrong Hypothesis

Initial pointer: logmanager/manager.py — specifically the log pruning logic. If the log manager was truncating conversation history too aggressively, it might drop tool outputs before they reached the Responses API. Reasonable hypothesis. Wrong.

All three tool outputs were present: call_zN7, call_XIZ, call_qsH. In the log, correctly formatted. The API was complaining about call_XIZ, but call_XIZ’s output was right there.

When you can’t reproduce the error by looking at what the error describes, the corruption happened earlier.

Reading the Conversation

Loading the failing trajectory and reading it message by message:

msg[11]: system, call_id=call_hle...  ← real shell result ✓
msg[12]: system, call_id=call_hle...  ← token usage warning (BUG)
...
msg[15]: assistant, 3 parallel tool calls (call_zN7, call_XIZ, call_qsH)
msg[16]: system, call_id=call_zN7  ← result ✓
msg[17]: system, call_id=call_XIZ  ← result ✓
msg[18]: system, call_id=call_qsH  ← result ✓

Message 12 was the tell. A token awareness warning — <system_warning>Token usage: ...</system_warning> — carrying call_id=call_hle. This message is injected by hooks/token_awareness.py as a TOOL_EXECUTE_POST hook after the real shell result. It has no call_id by design. But it had one.

Two messages with the same call_id. Both would become function_call_output items in the Responses API input. The API saw a duplicate output for call_hle — invalid conversation structure — and rejected the next request. But because the rejection happened on the next API call (when three more tool calls were being submitted), the error reported the first of those new calls: call_XIZ.

The error wasn’t fabricated. The API did have a problem with the conversation. But the cause was a duplicate output four messages earlier, not a missing output for the call the error named.

The Bug

In tools/__init__.py, execute_msg() looked like this:

# Before (buggy):
for tool_response in tooluse.execute(log=log, workspace=workspace):
    yield tool_response.replace(call_id=tooluse.call_id)

Every message yielded by tooluse.execute() got stamped with the tool’s call_id. Including TOOL_EXECUTE_POST hook messages, which are injected after the real tool result and have no business carrying a call_id.

The fix: move call_id assignment inside ToolUse.execute(), applied only when real tool result messages are yielded. Hook messages pass through without modification.

# After — in tools/base.py, inside ToolUse.execute():
# Real tool results get call_id; hook messages don't touch it

And execute_msg() simplifies to:

yield from tooluse.execute(log=log, workspace=workspace)

Why the Responses API Error Is Misleading Here

The Responses API uses a stateless conversation structure where every function_call_output must exactly match a function_call from an assistant turn. Duplicate outputs for the same call_id are invalid. The validation runs holistically on the entire conversation being submitted.

This means corruption at message N can surface as an error about message N+4. The error isn’t wrong — the reported call really is problematic from the API’s perspective — but identifying the cause requires reading backwards from the error, not just inspecting the call the error names.

Debugging pattern: if the Responses API reports a missing or duplicate tool output and the named call_id looks correct in your log, scan backwards for duplicate function_call_output entries with the same call_id as earlier messages.

The Fix

gptme/gptme#3355, merged 2026-07-25. Three regression tests cover the invariant: real tool results carry call_id, pre/post hook messages don’t.

The 44% gpt-5.5 infra failure rate from this class of error is fixed.