codex: treat an empty successful completion after a tool_result tail as a valid end_turn - #122
dmitryanchikov wants to merge 1 commit into
Conversation
…_result tail as a valid end_turn When a conversation ends assistant:tool_use -> user:tool_result and the tool call itself delivered the user-visible answer (a messaging/reply tool), the model legitimately ends the follow-up turn with zero output items. The empty-completion protection (raine#70/raine#71) treated this as a failure: ~11 retries, ~3 minutes, then 503 'Codex completed without producing output' — and the Anthropic client retries on top. For a channel bot whose reply tool IS the answer, ~68% of real turns failed this way (measured 2026-08-25); a 3-message repro is deterministic: 503 @ 175s before, 200 @ 1.7s after. Scope is deliberately narrow, in both dimensions: - The tail predicate runs on the ORIGINAL Anthropic body and requires a tool_result WITHOUT is_error — an error result demands a model reaction, so a deterministic empty there stays a loud failure. - A qualifying tail alone is not proof: an internal tool (Read/Bash) has the same tail shape and can meet a transient upstream glitch. One retry is the discriminator — a glitch clears on resend, a semantic empty repeats — so the empty is accepted only on the SECOND consecutive occurrence, on both the buffered and live-stream paths (shared accept_empty_completion()). Empty completions without a qualifying tail keep the full retry-then-503 behavior unchanged. Tests: tail predicate (success/error/non-tool/ordering/empty), the accept truth table, and live-path behavioral pair (no flag -> Retry with the empty-completion detail; flag -> delivered stream ending in message_stop).
026d036 to
f7ee7e8
Compare
|
Force-pushed a v2 after internal review flagged the original as too broad — the empty-acceptance is now doubly gated:
Added tests: tail predicate (success/error/non-tool/ordering/empty), the accept truth table, and a live-path behavioral pair (flag off → Retry with the empty-completion detail; flag on → delivered stream ending in |
|
I reproduced this with Claude Code 2.1.278 and a proxy built from 55bf0b5 (v0.1.35 + 6 commits), using |
Summary
When a conversation ends
assistant: tool_use→user: tool_resultand the tool call itselfdelivered the user-visible answer (e.g. a messaging/reply tool), gpt-5.x models legitimately
end the follow-up turn with zero output items — a successful terminal event with nothing
to add. The empty-completion protection from #70/#71 treats this as a failure: it retries
(bounded 1+10), burns ~3 minutes, then surfaces
503 Codex completed without producing output— and the Anthropic-side client (Claude Code) retries the whole request on top,producing multi-minute hangs on trivial turns.
For a Telegram channel bot whose reply tool IS the answer, this tail shape occurs on
essentially every turn: ~68% of its real turns failed this way over 2h (2026-08-25,
websocket transport, main @ 0185409).
Deterministic repro
{"model":"gpt-5.5","max_tokens":1024,"stream":false, "system":"You are a channel agent. When a user pings you, reply via the reply tool. The tool call IS your answer to the user.", "tools":[{"name":"reply","description":"Send a reply message to the chat. This delivers your answer to the user.","input_schema":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}}], "messages":[ {"role":"user","content":"ping"}, {"role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"reply","input":{"text":"pong"}}]}, {"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"Message sent."}]}]}Before:
503after 175s, empty. (Adding a system line "always end with a short textline" makes the identical request return 200 in 1.8s — the model is fine; the empties are
semantic, not transient.)
Change
Both empty-completion sites (buffered and live-stream) now consult the translated request's
tail: a
function_call_outputtail means the empty completion is the turn's real end, and ittranslates into a valid empty
end_turninstead of entering the retry loop. Emptycompletions without a tool-result tail keep the full #70/#71 retry-then-503 behavior.
After (verified in production, both transports)
200in ~1.7s,{"content":[],"stop_reason":"end_turn","role":"assistant",...}200in ~1.6s, well-formed SSE (message_start→content_block_start/stop→message_delta→message_stop) — accepted by Claude Code.Full
cargo testgreen. Happy to add a regression test in the #71 harness style if you canpoint me at the preferred fixture shape.