Skip to content

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
raine:mainfrom
dmitryanchikov:fix/codex-tool-tail-empty-completion
Open

codex: treat an empty successful completion after a tool_result tail as a valid end_turn#122
dmitryanchikov wants to merge 1 commit into
raine:mainfrom
dmitryanchikov:fix/codex-tool-tail-empty-completion

Conversation

@dmitryanchikov

Copy link
Copy Markdown

Summary

When a conversation ends assistant: tool_useuser: tool_result and the tool call itself
delivered 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: 503 after 175s, empty. (Adding a system line "always end with a short text
line" 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_output tail means the empty completion is the turn's real end, and it
translates into a valid empty end_turn instead of entering the retry loop. Empty
completions without a tool-result tail keep the full #70/#71 retry-then-503 behavior.

After (verified in production, both transports)

  • non-streaming: 200 in ~1.7s, {"content":[],"stop_reason":"end_turn","role":"assistant",...}
  • streaming: 200 in ~1.6s, well-formed SSE (message_startcontent_block_start/stop
    message_deltamessage_stop) — accepted by Claude Code.

Full cargo test green. Happy to add a regression test in the #71 harness style if you can
point me at the preferred fixture shape.

…_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
dmitryanchikov force-pushed the fix/codex-tool-tail-empty-completion branch from 026d036 to f7ee7e8 Compare August 26, 2026 04:57
@dmitryanchikov

Copy link
Copy Markdown
Author

Force-pushed a v2 after internal review flagged the original as too broad — the empty-acceptance is now doubly gated:

  1. Predicate narrowed: 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.
  2. One retry as the glitch discriminator: a qualifying tail alone isn't proof (an internal tool result has the same shape and can meet a transient upstream glitch, which is what fix(codex): retry terminal-only live completions #70/fix(codex): retry empty buffered completions #71 exist for). The first empty still retries once; only the second consecutive empty is accepted as a valid empty end_turn. Both transports share one accept_empty_completion().

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 message_stop). Verified in production: the repro returns 200 in ~4.5s (one discriminator roundtrip included), and an is_error-tail control produced an actual model reaction (thinking + a retry tool call) rather than a silent empty.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant