Skip to content

Retry an empty model response instead of ending the turn (#185820) - #70

Merged
mayoalexander merged 1 commit into
mainfrom
fix/retry-empty-finalization
Sep 17, 2026
Merged

mayoalexander merged 1 commit into
mainfrom
fix/retry-empty-finalization

Conversation

@mayoalexander

Copy link
Copy Markdown

A user hit iris-ai (iris) returned no output (finish reason: unknown). Every upstream attempt failed, typed continue, and the same request succeeded first try. The loop should have done that.

processor.ts built the error with isRetryable: false, so SessionRetry — which already has backoff, Retry-After handling and an abort-aware sleep — never ran. The comment justified it: a stream "only lets a stream finish empty once EVERY provider has failed".

Measured the same hour, that premise did not hold: models:smoke --model=iris/iris-ai passed sync+stream+tools+turn2, the spare OpenCode Go licence had 80% headroom, and only the primary was capped (weekly 100%, resets 09-21). See #185818.

The change

  • Retry an empty finalization, bounded (max 2), using the existing backoff, never when aborted.
  • Safe by definition of the branch: zero output parts, zero output tokens — nothing partial to duplicate.
  • Reset finish before retrying, else a retry that answers without a finish-step inherits the discarded attempt’s unknown.
  • Exhausted retries still surface the error, now naming the attempt count.
  • Decision extracted to retry.ts as pure predicates.

Tests — 11 new, mutation-verified

Unit (retry.test.ts) plus a new integration test that mocks LLM.stream and drives the real processor: empty→answer retries and keeps the answer; always-empty stops at the cap with the error; a retry without a finish-step does not inherit the stale finish.

Mutations each caught: never retry (old behaviour) · no increment · forget the finish reset · retry on partial output · retry when aborted · unbounded · treat stop as empty.

The finish-reset mutation survived my first version of the tests — the case it protects was not covered until I added it.

test/session: 52 pass (41 on base), 0 fail. Typecheck clean. CI note: this repo’s Blacksmith runners do not execute tests (#185791), so local is the gate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JwiqN8M84qimu1TjZGSsdQ

…n (#185820)

A user hit "iris-ai (iris) returned no output (finish reason: unknown). Every
upstream attempt failed", typed "continue", and the SAME request succeeded on the
first try. The loop should have done that.

processor.ts built the error with isRetryable: false, so SessionRetry — which
already has backoff, Retry-After handling and a sleep that respects abort — never
ran. The comment justified it:

  "since the failover work (#178556), [the proxy] only lets a stream finish empty
   once EVERY provider has failed. So by the time a user sees this, try another
   model is genuinely the right next step."

MEASURED THE SAME HOUR, that premise did not hold: `models:smoke --model=iris/iris-ai`
passed sync+stream+tools+turn2 on fl-iris-api, the spare OpenCode Go licence had 80%
headroom, and only the primary was capped (weekly 100%, resets 09-21). So an empty
finish happens while a retry would succeed — which "continue" proved by hand.

Retrying is safe because of what defines this branch: zero output parts and zero
output tokens, so there is nothing partial to duplicate. It is bounded
(EMPTY_FINALIZATION_MAX_RETRIES = 2), uses the existing backoff, and never retries
an aborted turn. `finish` is reset before going round again, else a stream that
answers without a finish-step would keep the discarded attempt's "unknown". When
the retries are exhausted the error still surfaces, now saying how many attempts
were made.

The decision lives in retry.ts as two pure predicates so it is testable without
driving a session.

TESTS — 11 new, all mutation-verified.
  retry.test.ts (unit): the shape, the cap, aborted turns, and that ANY output
  disqualifies it (the property the safety of this rests on).
  empty-finalization.test.ts (integration, new): mocks LLM.stream and drives the
  real processor — empty-then-answer retries and keeps the answer; always-empty
  stops after the cap with the error and attempt count; a retry that answers
  without a finish-step does not inherit the discarded attempt's finish.

  Mutations, each caught: never retry (the old behaviour) 2 fail · no attempt
  increment 1 fail · forget the finish reset 1 fail · retry on partial output
  1 fail · retry an aborted turn 1 fail · unbounded 2 fail · treat a normal
  "stop" as empty 1 fail.

  The finish-reset mutation SURVIVED the first version of these tests. The case it
  protects — a retry that answers without a finish-step — was not covered until it
  was added.

test/session: 52 pass (41 on base), 0 fail. opencode typecheck clean.

NOTE: this is the CLI lineage (main). iris/1.18.23 does not carry this code path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwiqN8M84qimu1TjZGSsdQ
@mayoalexander
mayoalexander merged commit 87ba23a into main Sep 17, 2026
1 of 3 checks passed
@mayoalexander
mayoalexander deleted the fix/retry-empty-finalization branch September 17, 2026 19:17
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