This document describes the standard API-error retry path in AgentSession.
It explicitly excludes context-overflow recovery via auto-compaction. Overflow is handled by compaction logic and is documented separately in compaction.md.
Implementation files
../src/session/agent-session.ts../src/config/settings-schema.ts../src/modes/controllers/event-controller.ts../src/modes/controllers/input-controller.ts../src/modes/rpc/rpc-mode.ts../src/modes/rpc/rpc-client.ts../src/modes/rpc/rpc-types.ts
Scope boundary vs compaction
Retry and compaction are checked from the same agent_end path, but they are intentionally separated:
agent_endinspects the last assistant message.#isRetryableError(...)runs first.- If retry is initiated, compaction checks are skipped for that turn.
- Context-overflow errors are hard-excluded from retry classification (
isContextOverflow(...)short-circuits retry). - Overflow therefore falls through to
#checkCompaction(...)instead of standard retry.
So: overload/rate/server/network-style failures use this retry policy; context-window overflow uses compaction recovery.
Retry classification
#isRetryableError(...) requires all of the following:
- assistant
stopReason === "error" errorMessageexists- message is not context overflow
- one of:
- the stop is a classifier refusal (
stopDetails.typeis"refusal"or"sensitive") - the error is a stale OpenAI Responses replay failure (
Item with id 'β¦' not found, or an invalid/expired/not-foundprevious_response) errorMessagematches transient transport/envelope patterns orisUsageLimitError(...)
- the stop is a classifier refusal (
The stale-replay and transient/usage-limit branches additionally require that the stream was not interrupted after already emitting observable output. #streamInterruptedAfterObservableOutput(...) treats a STREAM_INTERRUPTED_AFTER_CONTENT stop detail β or any tool call, non-empty text, thinking, or redacted-thinking block β as non-retryable, so a partially produced turn is not silently replayed. Classifier refusals are checked first and bypass this exclusion.
Current retryable inputs are regex/string-classified:
- transient transport/envelope failures, including Anthropic stream-envelope failures before
message_start - overloaded/provider-returned-error wording
- rate limit / usage limit / too many requests
- HTTP-like server classes: 429, 500, 502, 503, 504
- service unavailable / server/internal error
- provider-suggested retry wording, including OpenAI
retry your requestfailures - network/connection/socket failures, refused/closed connections, upstream connect/reset-before-headers, socket hang up, timeout/timed out, fetch failed, terminated, retry delay wording, and unexpected socket close messages
Transport classification is regex text matching, not typed provider error codes; classifier refusals are the exception, detected from the typed stopDetails field.
Beyond #isRetryableError(...), a narrower trigger feeds the same retry engine: #isRetryableReasonlessAbort(...) routes a content-less aborted stop carrying the generic abort sentinel (GENERIC_ABORT_SENTINEL) β only when no user, dispose, or streaming-edit-guard abort is in progress β into #handleRetryableError(message, { allowModelFallback: false }), i.e. retried without model fallback.
Retry lifecycle and state transitions
Session state used by retry:
#retryAttempt: number(0means idle)#retryPromise: Promise<void> | undefined(tracks in-progress retry lifecycle)#retryResolve: (() => void) | undefined(resolves#retryPromise)#retryAbortController: AbortController | undefined(cancels backoff sleep)
Flow (#handleRetryableError):
- Read
retrysettings group. - If
retry.enabled === false, stop immediately (false, no retry started). - Increment
#retryAttempt. - Create
#retryPromiseonce (first attempt in a chain). - If attempt exceeded
retry.maxRetries, emit final failure event and stop. - Compute capped jittered local delay:
min(retry.baseDelayMs * 2^(attempt-1), 8000ms) * (75β100% jitter). Stale OpenAI Responses replay errors skip the backoff entirely (delay0) after resetting the cached provider session. - For usage-limit errors, parse retry hints and call auth storage (
markUsageLimitReached(...)); if credential switching succeeds β including spending a banked Codex reset via the opt-in auto-redeem β force delay to0. Otherwise wait for whichever comes first β the providerβs retry-after/backoff hint, or the earliest moment a temporarily blocked sibling credential frees up (retryAtMs+ 1s buffer) so the next attempt can pick it up. - If no credential switch occurred and
retry.modelFallbackis enabled, suppress the current model selector for cooldown and try configured retry model fallback chains, forcing delay to0on model switch. Classifier refusals skip the cooldown and only proceed when a fallback model was actually applied (pinned); with no fallback, the chain ends without anauto_retry_start. - If the final delay exceeds
retry.maxDelayMsand no credential/model switch happened, emit final failure and do not sleep. - Emit
auto_retry_start. - Remove the trailing assistant error message from agent runtime state (kept in persisted session history).
- Sleep with abort support.
- Schedule
agent.continue()through the post-prompt task scheduler (delayMs: 1) for the same prompt generation.
What resets retry counters
#retryAttempt resets to 0 in these cases:
- first successful non-error, non-aborted assistant message after retries started (emits
auto_retry_end { success: true }) - retry cancellation during backoff sleep
- max retries exceeded path
- max delay exceeded path
- classifier refusal with no fallback model applied (chain ends silently, no retry started)
#retryPromise resolves/clears when retry chain ends (success, cancellation, max-exceeded, max-delay failure, or classifier-refusal stop), via #resolveRetry().
Backoff and max-attempt semantics
Settings:
retry.enabled(defaulttrue)retry.maxRetries(default10)retry.baseDelayMs(default500)retry.maxDelayMs(default300000, 5 minutes;<= 0disables the fail-fast cap)
Attempt numbering:
- attempt counter is incremented before max-check
- start events use current attempt (1-based)
- max-exceeded end event reports
attempt: this.#retryAttempt - 1(last attempted retry count)
Backoff sequence with default settings, before jitter:
- attempt 1: 500 ms
- attempt 2: 1000 ms
- attempt 3: 2000 ms
- attempt 4: 4000 ms
- attempt 5+: 8000 ms
The actual local sleep is 75β100% of the nominal value, matching Anthropic-style retry jitter so concurrent sessions do not retry in lockstep.
Delay override inputs can come from parsed retry headers (retry-after-ms, retry-after, x-ratelimit-reset-ms, x-ratelimit-reset) or usage-limit backoff. Credential/model fallback switches set delay to 0; otherwise parsed hints can extend the capped local delay. If the computed delay is greater than retry.maxDelayMs and no switch succeeded, retry ends immediately with a final error instead of sleeping.
Abort mechanics
Explicit retry abort
abortRetry():
- aborts
#retryAbortController(if present) - resolves retry promise (
#resolveRetry()) so awaiters are unblocked
If abort hits while sleeping, catch path emits:
auto_retry_end { success: false, finalError: "Retry cancelled" }- resets attempt/controller
Global operation abort interaction
abort() calls abortRetry() before aborting the active agent stream. This guarantees retry backoff is cancelled when user issues a general abort.
TUI interaction
On auto_retry_start, EventController (#handleAutoRetryStart):
- stops the working loader and clears the status container
- renders a
retryLoaderwith text:Retrying (attempt/maxAttempts) in Ns⦠(esc to cancel)
Esc cancellation dispatches on live session state rather than a swapped handler: the input controller checks viewSession.isRetrying and calls viewSession.abortRetry() (alongside its compaction/handoff abort checks).
On auto_retry_end (#handleAutoRetryEnd), it stops and clears the retryLoader and status container.
Streaming and prompt completion behavior
prompt() ultimately waits on #waitForPostPromptRecovery() after agent.prompt(...) returns; that loop awaits the retry lifecycle promise alongside TTSR resume and deferred post-prompt tasks.
Effect:
- a prompt call does not fully resolve until any started retry chain finishes (success/failure/cancel)
- retry lifecycle is part of one logical prompt execution boundary
This prevents callers from treating a retrying turn as complete too early.
Controls: settings and RPC
Configuration knobs
Defined in settings schema under retry group:
retry.enabledretry.maxRetriesretry.baseDelayMsretry.maxDelayMsretry.modelFallback(defaulttrue; gates retry model-fallback switching)retry.fallbackChainsretry.fallbackRevertPolicy("cooldown-expiry"by default;"never"disables automatic restoration)
Programmatic toggles in session:
setAutoRetryEnabled(enabled)writesretry.enabledautoRetryEnabledreadsretry.enabledisRetryingreports whether retry lifecycle promise is active
RPC controls
RPC command surface:
set_auto_retryβsession.setAutoRetryEnabled(command.enabled)abort_retryβsession.abortRetry()
Client helpers:
RpcClient.setAutoRetry(enabled)RpcClient.abortRetry()
Both commands return success responses; retry progress/failure details come from streamed session events, not command response payloads.
Event emission and failure surfacing
Session-level retry events:
auto_retry_start { attempt, maxAttempts, delayMs, errorMessage }auto_retry_end { success, attempt, finalError? }retry_fallback_applied { from, to, role }retry_fallback_succeeded { model, role }
Propagation:
- emitted through
AgentSession.subscribe(...) - forwarded to extension runner as extension events
- in RPC mode, forwarded directly as JSON event objects (
session.subscribe(event => output(event))) - in TUI, consumed by
EventControllerfor loader/error UI
Final failure surfacing:
- On max-exceeded, max-delay failure, or cancellation,
auto_retry_end.success === false - TUI shows:
Retry failed after N attempts: <finalError> - Extensions/hooks receive
auto_retry_endwith same fields - RPC consumers receive same event object on stdout stream
Permanent stop conditions
Retry stops and will not auto-continue when any of these occur:
retry.enabledis false- error is not retry-classified
- error is context overflow (delegated to compaction path)
- max retries exceeded
- provider-requested delay exceeds
retry.maxDelayMsand no credential/model switch is available - user cancels retry (
abort_retryorEscduring retry loader) - global abort (
abort) cancels retry first
A new retry chain can still start later on a future retryable error after counters reset.
Operational caveats
- Classification is regex text matching; provider-specific structured errors are not used here.
- Retry strips the failing assistant error from runtime context before re-continue, but session history still keeps that error entry.
RpcSessionStatecurrently exposesautoCompactionEnabledbut not anautoRetryEnabledfield; RPC callers must track their own toggle state or query settings through other APIs.- Model fallback changes append temporary
model_changeentries and may later restore the primary model when its cooldown expires, depending onretry.fallbackRevertPolicy.