fix(session): retry an empty completion regardless of finish reason - #49061
Yedigaryan wants to merge 2 commits into
Conversation
Extends this branch's guard (finish === "unknown" && !generated) to also cover finish === "stop", per bekandev's analysis and repro on anomalyco#37735: production data across 4 models shows empty completions overwhelmingly finish "stop", not "unknown", so the narrower guard here never fires for the more common case. Also guards on !ctx.needsCompaction, since compaction deliberately interrupts the stream and would otherwise false-positive as an empty turn, and parameterizes the error message with the actual finish reason for diagnosis. generated-tracking, EmptyResponseError, and the SessionRetry.policy hookup are unchanged from this branch — the retry still rides the existing RETRY_MAX_RETRIES schedule. Adds two tests: an empty `stop` completion retries and recovers, and a reasoning-only `stop` completion (reasoning counts as generated output, per anomalyco#37372) is correctly left alone. Fixes the pre-existing "publish retry status updates" test, which used llm.text("") as inert filler after a simulated 503 — an empty completion is now itself retryable, so that filler added a second retry and masked the 503 the test measures. Changed to real text, per bekandev's own note that this is the only llm.text("") in the suite. Verified against dev-based bun test test/session/ + test/cli/run/run-process.test.ts (385 pass, 0 fail), bun run typecheck (clean), and oxlint/prettier on touched files (0 new warnings vs. this branch unpatched). Closes anomalyco#37735 Refs anomalyco#45315, anomalyco#37372, anomalyco#41466 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Thanks for your contribution! This PR doesn't have a linked issue. All PRs must reference an existing issue. Please:
See CONTRIBUTING.md for details. |
|
The following comment was made by an LLM, it may be inaccurate: Based on my search results, I found potentially related PRs:
However, none of these appear to be duplicates of PR #49061. PR #49061 is a specific extension of the retry logic to handle the No duplicate PRs found |
holny
left a comment
There was a problem hiding this comment.
Ran the processor suite locally at a6274a2 (on top of the retry-empty-response base): 18/18 pass including the two new cases, typecheck clean. The reasoning-only guard is a nice subtle test — that one would have bitten.
One thing I'd push back on before this merges, though:
The empty-response retry is unbounded. In Effect.retry(SessionRetry.policy(...)) (processor.ts:681), policy treats EmptyResponseError as always-retryable (retry.ts:189) and nothing caps the attempts — backoff climbs to the 30s ceiling and cycles forever, and every attempt is a real billable llm.stream call. With finish === "unknown" that was mostly pathological. Widening to finish === "stop" makes it reachable by much more mundane behavior: a model that deterministically replies empty-with-stop (small local models do this, some providers also emit stop on refusals) would loop indefinitely while the session sits in retry status. Suggest bounding just the empty case — e.g. intersecting the schedule with a small recurs(n) for EmptyResponseError, or tracking attempts in ctx and surfacing a terminal error after N — plus a test that pushes empty-stop responses repeatedly and asserts the calls stop at N.
Two smaller notes:
- The new
!ctx.needsCompactionguard is the right call (don't burn a retry against a context that's about to compact), but no test exercises empty+stop with compaction pending — same harness could cover it. - Scope note for reviewers: the base stack (#40531) predates the current v2 layout —
packages/opencode/src/session/processor.tsandretry.tsdon't exist on v2 anymore — so this lands only as part of the stack, and the gate will need re-deriving when the stack rebases.
The change to the 503 status test (empty → "after") with the comment explaining why is much appreciated, by the way.
|
The linked issue is #37735, and the PR body says GitHub doesn't populate Happy to retarget straight to |
Widening the guard from finish === "unknown" to also include "stop" makes it reachable by ordinary, deterministic model behavior (small local models, some providers on refusal), not just rare gateway hiccups. Unlike a rate limit or a 5xx, an empty completion carries no signal that a later attempt will differ, and policy() had no bound for EmptyResponseError at all — delay climbs to the 30s ceiling and retries forever, each attempt a real billable llm.stream call. Adds EMPTY_RESPONSE_MAX_RETRIES = 5 and caps the schedule to it, same mechanism as retryable()'s Cause.done(meta.attempt) exhaustion path, so an empty completion that never recovers now surfaces a terminal error instead of looping. Two tests: - repeated empty `stop` completions exhaust the bound and surface handle.message.error, with llm.calls capped at EMPTY_RESPONSE_MAX_RETRIES + 1 - an empty `stop` completion whose usage alone crosses the context limit sets ctx.needsCompaction before the empty-response guard runs; compaction must win and this must not also count as an empty-response attempt Verified: bun test test/session/ + test/cli/run/run-process.test.ts (387 pass, 0 fail), bun run typecheck (clean), oxlint/prettier on touched files (0 new warnings vs. this branch unpatched, verified via git stash A/B). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Good catch, and correct — verified against the actual code before fixing anything. Fixed in 467fd03:
On your scope note: agreed, and worth being explicit for reviewers — this only makes sense stacked on #40531 as-is, since PR description updated to match. |
|
Read the cap — that's the shape I was after. Routing the exhausted case through the same Agreed on the stack framing being the honest one — re-deriving against whatever the v2 equivalent looks like is a rebase-time problem, not something this PR can pre-solve. Nothing else from me. |
Issue for this PR
Closes #37735
Refs #45315
Type of change
What does this PR do?
Builds on this branch (#40531) by widening its guard so it also covers
finish: "stop", not just"unknown", and bounds the empty-response retry so it can't loop forever.This branch already tracks whether a stream generated anything (
text-delta/reasoning-deltawith non-zero length,tool-input-start/tool-call) and retries viaSessionRetry.EmptyResponseErrorwhenfinish === "unknown" && !generated. Per @bekandev's repro and production data on #37735 (4 models, all finishingstop), that guard never fires for the more common case: a provider that opens the assistant role and closes onstopwithout ever emitting a content delta. OpenCode currently records that as a normal successful completion — no error, no retry, the turn is just silently empty.This PR:
(finish === "unknown" || finish === "stop") && !generated!ctx.needsCompaction, since compaction deliberately interrupts the stream mid-turn and would otherwise false-positive as an empty responsepolicy()had no cap onEmptyResponseErrorat all — it retried unconditionally, backoff climbing to the 30s ceiling and cycling forever, each attempt a real billable request. That was reachable but narrow when scoped tounknownonly; widening tostopmakes it reachable by ordinary, deterministic behavior (some small local models and some providers on refusal always return empty-stop). AddedEMPTY_RESPONSE_MAX_RETRIES = 5and capped the schedule to it, so an empty completion that never recovers now surfaces a terminal error instead of looping — same exhaustion mechanismretryable()already uses viaCause.done(meta.attempt).Nothing else changes —
generatedtracking and theEmptyResponseError/SessionRetry.policyhookup are this branch's existing code.Note on #37372, which a bot comment on #37735 flagged as a possible duplicate: it isn't. That issue is filed against the separate
v2branch's session runner (packages/core/src/session/runner/llm.ts), a different implementation from this repo'sdev/packages/opencode/src/session/processor.ts, which is what this PR touches. This change doesn't address or close it.How did you verify your code works?
bun test test/session/+test/cli/run/run-process.test.ts— 387 pass, 0 failbun run typecheck— cleanoxlint/prettier --checkon touched files — 0 new warnings vs. this branch unpatched (verified viagit stashA/B)stopcompletion retries and recovers; astopcompletion with real reasoning content is correctly left alone; repeated emptystopcompletions exhaustEMPTY_RESPONSE_MAX_RETRIESand surface a terminal error (llm.callscapped atEMPTY_RESPONSE_MAX_RETRIES + 1); an emptystopcompletion whose usage alone crosses the context limit setsneedsCompactionbefore the guard runs, and does not also count as an empty-response attemptllm.text("")as inert filler after a simulated 503, which now retries too under the widened guard and masks the 503 being measured — changed to real text (this is the onlyllm.text("")in the suite, per @bekandev's note on Empty stop completions are recorded as successful responses #37735)Checklist