Repository navigation
[Feature]: Automatic turn-level retry (10 attempts) for retryable failures — today a transient 529 requires a manual /retry #501
Description
Product or interface
Desktop app (the same shared local-runtime-v2 turn runtime backs CLI - interactive TUI, so this is a runtime-level request rather than a UI-only one).
Use case and problem
While a run is in flight it sometimes fails for reasons that are clearly transient — provider overload (529), rate limiting, upstream/network errors, a dropped connection before the first token. In those cases the turn terminates immediately, the session lands in error, and the only recovery is a manual user action: pressing the retry affordance (/retry in the TUI, catalog.ts:246, gated on context.canRetry).
That manual step is the problem. It costs the user one interaction per transient failure, and in unattended or long-running work (background tasks, goal loops, batch sessions) there is nobody there to press it, so a single 529 ends the turn even though the next request would very likely have succeeded.
Today the retry machinery is below the turn level and does not cover this case:
withLLMRetry(packages/agent-core/src/pi-turn-runner/llm-retry.ts) retries a single logical LLM call — one provider request that failed before output was committed. Once output commits (or a tool call has already run), a later stream failure stays on that attempt by design (preflightAttemptreturnscommitted;discardAttemptat:304throws away uncommitted events).PiTurnRunner.runTurndrives pi's tool-calling loop exactly once. There is no outer loop that re-runs a turn.LocalAgentTurnRunner.runAttempts(packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts:173) is a whole-turn loop, but it only iterates for output-safety regeneration (OUTPUT_SAFETY_MAX_REGENERATIONS = 3,:80) and rethrows every attempt failure (:200-210).
The retryability verdict is in fact already computed and then thrown away: isProductionRuntimeErrorRetryable (runtime-error-retry-policy.ts:10, covering LLM_RATE_LIMITED, LLM_UPSTREAM_ERROR, LLM_TPM_RATE_LIMITED, LLM_CLUSTER_OVERLOADED) is wired into the commit pipeline (production-composition.ts:508) and lands on LocalRuntimeTerminalError.retryable (turn-commit-pipeline.ts:63,73) — which has no reader anywhere in the tree. The information needed to make this decision is already produced at the exact moment it is needed, and nothing consumes it.
Raising DEFAULT_LLM_RETRY_POLICY.maxRetries alone would not address this. It is a per-provider-request budget, and the companion maxRetryElapsedMs: 120_000 cap would cut a 10-retry budget short anyway (with baseDelayMs: 1000 / maxDelayMs: 30000, ten backoffs already exceed 120s).
Desired behavior
When a turn terminates with a retryable error classification, retry the turn automatically instead of surfacing a terminal error — up to 10 automatic retries (11 total attempts) by default.
Acceptance criteria:
- A turn that fails with a retryable classification (timeout, network, rate limited, TPM rate limited, overloaded/529) is re-run automatically without user action.
- The budget is 10 retries by default, and is configurable (users who hit Usage-limit retry loop after rapid verifier/session fan-out #108-style loops must be able to lower it or disable it).
- Only retryable classifications retry. These must stay terminal immediately: usage limit / quota exhaustion, credits exhausted, authentication failure, bad request, not found, content filter, and any user cancellation or abort. The shared classifier already encodes this —
toLLMRetryDecisioninpackages/shared/src/llm-error-classifier.ts:222retries only timeout/network/rate_limited/tpm_rate_limited/overloaded plus HTTP 529 — and a turn-level policy should reuse it rather than re-derive a second list. - Backoff between attempts is bounded and jittered, with a total elapsed-time budget so a wedged provider cannot hold a turn open indefinitely.
- Retry is abortable: cancelling during backoff stops immediately and does not start another attempt.
- Attempts are bounded by what has already been shown to the user. An automatic retry must not silently duplicate output the user has already seen, so a failure that arrives after content has been released must not be blindly re-run.
- Each attempt is observable: attempt ordinal, error classification, delay, and final give-up reason are emitted, and a metric records retries per turn.
- When the budget is exhausted, the original terminal error is surfaced unchanged — no new error text, no degraded message.
Alternatives and additional context
- The opposite failure mode already exists. Usage-limit retry loop after rapid verifier/session fan-out #108 reports ~80
Token Plan usage limit reachederrors from a retry loop. That is why criterion 3 matters: quota exhaustion must never enter this path, and the classifier already treatsUSAGE_LIMIT_EXCEEDEDas non-retryable. - [Bug]: Session remains permanently stuck after temporary provider failure during /goal #425 ("Session remains permanently stuck after temporary provider failure") was a different root cause — history re-sent every image until
400 Too many images in request: 31 > 30made every retry fail — fixed by capping images. It is a useful precedent that retry affordances can themselves become the failure point. - Why not just document
/retry? It does not help unattended runs, and it cannot help when the failure repeats deterministically at the start of a turn. - Scope note for maintainers:
gateTerminalOnInputReview(local-agent-turn-runner.ts:507) already suppresses terminal events behind an async predicate, so holding a retryable terminalSESSION_STATUS/FAILEDwhile the retry budget remains is a natural extension rather than a new mechanism. The open design questions are (a) whether to retry in place inrunAttemptsversus re-dispatching a new turn the way/retrydoes, and (b) how attempt history is retracted, sincerecallAttemptcurrently throws unless the display-retraction hooks are present.
No private project content, logs or account data is included.
Activity
Hylouis233 commentedon Oct 16, 2026
Reference implementation
I put together a working implementation of the behaviour requested above so the proposal is concrete rather than hypothetical. It is on a fork branch:
Hylouis233:feat/auto-retry-failed-turnmain: main...Hylouis233:feat/auto-retry-failed-turn (1 commit, 6 files, +~430 lines including tests)A pull request could not be opened from here: this repository does not accept pull requests from non-collaborator accounts (the API returns
404onPOST /pulls), which matchesCONTRIBUTING.md. So this is posted on the issue instead. If a maintainer wants it, the branch is ready to pull; if you would rather implement it yourselves, the design notes below should still save the exploration.Where it hooks in
gateTerminalOnInputReviewalready suppresses terminal events behind an async predicate. I extended it to hold a retryableSESSION_STATUS/FAILEDinstead of forwarding it, then re-run the whole tool loop fromrunAttempts. Releasing the original event when the budget ends means the user still sees the real error — no synthesized "gave up" message, and no duplicate text, because the intermediate failures never reach the client.Decisions worth reviewing
toLLMRetryDecisionfrom@mavis/shared/llm-error-classifierstays the single source of truth;isProductionRuntimeErrorRetryableadds four MiniMax business codes (50111 / 50113 / 50150 / 50151). Usage limit (42212), credits (50110) and auth (50112) are excluded, which is what keeps this from reproducing Usage-limit retry loop after rapid verifier/session fan-out #108./retryhandle the rest.maxRetries: 0restores current behaviour exactly.Verification
pnpm typecheckandpnpm check:sourcepass. 24 new tests cover the policy (classification, backoff, budget, abort, disabled) and the runner end-to-end (turn re-run three times with failures hidden, original error surfaced after exhaustion, usage limit and post-output failures not retried, cancelled backoff still delivers the terminal event).The full
pnpm test:capabilitiesrun had 4 failures on this branch and 3 on a cleanmaincheckout; all are pre-existing flakes or environment issues (Homebrewlibnodedylib, two filesystem-watcher timing tests, and one test that passes 3/3 in isolation). Details are in the branch's commit message and I can post the full attribution if useful.NOT RUN: full
pnpm verify, the Windows profile, and any live-provider or desktop acceptance.fix(turn-system): release a held turn failure unconditionally
fix(turn-system): stop retrying rejected requests and streamed turns
Hylouis233 commentedon Oct 16, 2026
Implementation diff (source only)
Correcting my previous comment: the branch has moved on. It now has three commits, and two blocking defects that an independent audit caught in my first version have been fixed. Details and the audit findings below the diff.
Hylouis233:feat/auto-retry-failed-turn@ba68e38turn-auto-retry.test.tsandlocal-agent-turn-runner-auto-retry.test.ts; the diff below is the implementation plus the generated inventory entry.What the audit changed
An independent reviewer ran the implementation against the acceptance criteria in this issue and measured two blocking defects. Both were reproduced before fixing, and both now have regression tests that were confirmed to fail against the previous behaviour.
1. A rejected request would have been resent ten times. My first version fell back to
isProductionRuntimeErrorRetryableafter the shared classifier had already refused. That allowlist containsLLM_UPSTREAM_ERROR(50113), which is the catch-all bucketmapUpstreamHttpErrorassigns to any status ≥ 400 except 402/401/403/429/529. So an HTTP 400 or 404 — both on the "must stay terminal" list in this issue — became eleven attempts. It also contradicted the "reuse the classifier rather than re-derive a list" requirement. Measured before the fix:{bad400: 11, notFound: 11, auth: 1, quota: 1, credits: 1}.Replaced with a table of only the codes that name a specific transient condition (rate limited, TPM rate limited, cluster overloaded). This also fixes a reporting lie: the fallback labelled every code
overloadedin the logs.2. Output could be streamed to the user twice. The "do not duplicate visible output" guard read the safety writer's approved-partial record. With
streamUnreviewedOutputthe writer forwards events through a fast path that never populates it, so the guard reported "nothing shown" while the answer was streaming.turn-execution-policy.tsturns that on for every BYOK turn whentuiProductPolicyis set, so this was the production default. Measured before the fix:calls: 3, assistant chunks["THE ANSWER TEXT","THE ANSWER TEXT"].The gate now records that any non-terminal event reached the client, which covers both writer modes. It is deliberately conservative: any streamed event, including tool results, blocks the retry, because the retry re-runs the whole tool loop.
Two smaller fixes came out of the same pass: a held failure is now released from a
finallyblock (it was dropped if an attempt threw while one was still held), and a retry is counted and logged only once it actually starts, so a cancelled backoff no longer reports a retry that never ran.Verification
pnpm typecheckandpnpm check:sourcepass. 30 tests across the two new files pass, including the two regression tests.Full
pnpm test:capabilities: 3 failures, all pre-existing. The same three fail on a cleanmaincheckout —update-runtime.test.ts(Homebrewlibnodedylib),skill-directory-symlinks.test.tsandtui/theme/runtime.test.ts(both filesystem-watcher timing, they flip between runs).local-native-tools.test.tsalso flakes under parallel load but passes 3/3 in isolation. None import the turn runner.NOT RUN: full
pnpm verify, the Windows profile, theperf:fullprofile, and any live-provider or desktop acceptance.Known limitation worth deciding on
The auto-retry path does not call
recallAttempt, so a failed attempt's already-committed assistant messages and tool calls stay in canonical history while the re-run appends a second pass. The output guard blocks retries once anything is streamed, which covers the visible case, but a turn that failed before streaming anything can still leave committed tool side effects in history. Whether that needs retracting is a design question I would rather the maintainers answer than guess at.