codex: treat an empty successful completion after a tool_result tail as a valid end_turn - #122
Open
dmitryanchikov wants to merge 1 commit into
Open
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).
dmitryanchikov
force-pushed
the
fix/codex-tool-tail-empty-completion
branch
from
August 26, 2026 04:57
026d036 to
f7ee7e8
Compare
Author
|
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.