Status: implemented
English | 中文
The per-provider request retry policy extends this foundation with exact-provider configuration and an explicit unbounded mode. This note continues to own structured failure facts, the closed-step recovery boundary, normal mode's transient defaults, visible single attempts, and durable retry status. Terminal LLM stream failures supersedes its thrown-error identity and stream-sidecar mechanism.
Provider adapters can fail by throwing during dispatch or iteration or by ending with finish { kind: 'error' | 'aborted' }. The final adapter boundary normalizes thrown values to that terminal finish protocol before dsh-agent-loop receives them; middleware and result-processing defects remain thrown. The loop offers a terminal model-request failure to agent/request-error. An unhandled failure is terminal; a handling listener repairs policy-owned state, returns { kind: 'retry' }, and stops waterfall delegation. The retry-action decision owns this return contract.
That boundary is already safe for another request attempt. Raw assistant/chunk events carry the failed turn and step, message derivation ignores them unless a successful assistant/message cites them, tool calls are dispatched only after a successful terminal finish and assembly, and a retry opens a new numbered turn from the durable log. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate.
The prior boundary left three narrower gaps.
agent/request-error listener would multiply attempts and omit intermediate failures from the session log.The default policy provides bounded recovery from transient failures of the same explicit provider/model request. Provider or model failover, response splicing, and semantic output repair are different problems and have no current consumer.
@deepseek-ai/dsh-llm exports one JSON-serializable LlmFailure payload:
type ProviderRequestId = Branded<'ProviderRequestId'>
interface LlmFailure {
message: string
code: string
status?: number
providerRetryAfterMs?: number
requestId?: ProviderRequestId
}
code remains the provider-neutral machine-routing taxonomy established by HarnessError; the new fields are observations from the provider boundary. ProviderRequestId is owned and constructed by dsh-llm, then serializes as its provider-issued string. The payload deliberately has no retryable, failover, partialOutput, provider, model, phase, or route id fields. Retryability belongs to policy, provider/model are already in the durable request header, and partial output is derived from the failed step's assistant/chunk events.
LlmError carries failure: LlmFailure and preserves failure.code === error.code. FinishReasonMap.error and FinishReasonMap.aborted carry the same payload instead of parallel failure shapes. The final adapter boundary detaches those facts from adapter-thrown values and emits the appropriate terminal finish; unknown SDK exceptions receive an UNKNOWN payload. Exact thrown-object identity does not cross the LLM stream seam.
The agent loop passes the terminal finish's LlmFailure to agent/request-error and uses the same payload when recording an unrecovered turn/end.reason.
Adapters extract structured facts before falling back to message inspection. They validate HTTP status, parse Retry-After seconds or dates into a positive finite millisecond delay, brand the provider request id when exposed, and distinguish their own timeout from the caller's abort. Provider-specific codes and messages may refine a mapping, but no recovery listener parses them.
The shared transient-code set is intentionally small: adapter mappings for RATE_LIMIT and SERVER, explicit TIMEOUT and TRANSPORT codes for remote failures, and EMPTY_RESPONSE for a completed provider response with no content blocks. Both adapters classify the last case as an error finish; see empty model responses are retryable. Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum.
@deepseek-ai/dsh-llm-retry is a function plugin that listens to agent/request-error. It introduces no service or new loop branch; the agent-loop package changes only the data carried through its existing failed-step recovery control flow.
The agent/request-error waterfall carries the current LlmFailure, an immutable list of prior failures that authorized retry turns in the consecutive recovery sequence, and the serving registration's immutable retry policy. The loop transports but does not interpret that policy, owns the consecutive failure history, and clears it after a successful model request. Normal dsh-llm-retry policy counts durable retry records scheduled by the same exact-provider policy, while dsh-compaction-basic keeps its own context-overflow budget. Alternating transient and context-overflow failures therefore consume their owning finite budgets independently; the maximum request count is one plus the sum of the loaded finite budgets.
The provider-policy decision owns the current configuration shape. Provider adapters register their nested retryPolicy; omission uses normal defaults: two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the five transient codes above. The count and delay bounds match the conservative edge of the inspected implementations: OpenCode uses two request retries with 500 ms/10 s bounds, Pi separates three agent-level retries from provider retries and defaults provider retries to zero, and Codex uses finite request/stream budgets plus a five-minute idle timeout. Ten percent follows Codex's bounded jitter.
For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid providerRetryAfterMs replaces exponential backoff only when it does not exceed maxDelayMs; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in [1 - jitterRatio, 1 + jitterRatio] and clamps the final value to maxDelayMs; provider delay is not jittered.
The plugin owns a lifetime AbortController and tracks every active recovery callback, including delegated waterfall work and backoff. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; abort wins over a late delegated retry decision, and a captured callback can neither retry nor enter the rest of its waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener.
Before sleeping, dsh-llm-retry appends one non-surface llm/retry session event containing the turn, failed step, provider, policy mode, complete resolved-policy key, provider-policy retry number, mode-specific finite maximum when present, scheduled delay, and LlmFailure. The key sorts the code set and separates retry histories when a provider route is replaced by a behaviorally different same-mode policy. The plugin owns the SessionEventMap augmentation and exports the payload through its browser-safe ./types subpath; dsh-session remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on turn/end. The event ships with production renderers and replay/snapshot coverage, because its purpose is operational state rather than trace collection.
The listener calls next() for a non-transient code, an exhausted policy budget, or an over-cap provider delay. This preserves composition with context-overflow recovery and later policy plugins. For an owned failure it records and awaits the delay, then returns { kind: 'retry' } without delegating. Turn cancellation and plugin disposal end the wait without returning a retry; the loop's cancellation/disposal checks remain authoritative.
The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, ACP, and headless example compositions use the same provider-routed policy. The shipped Web composition also loads it, so browser and command-line requests use the same provider defaults. Library consumers retain explicit plugin composition: omitting the plugin leaves request failures terminal.
Adapters perform one provider request per stream() call. The pi-ai adapter removes public maxRetries and maxRetryDelayMs profile fields and disables library retries; the hand-written adapter keeps its current single-attempt behavior. This prevents an SDK budget from multiplying the agent budget and ensures every transient retry is represented by a closed failed step plus llm/retry.
ctx.llm.stream() remains the raw one-attempt waterfall. Direct callers such as compaction summarization receive the structured failure but do not gain automatic retry, because they have no agent step boundary or general durable place to separate attempts. A future direct-call consumer may justify a buffering helper that retries only before emitting a chunk; this decision adds no such helper.
Each adapter exposes a validated streamIdleTimeoutMs configuration field with the five-minute prior-art default cited above. The interval is capped at Node's maximum timer delay so it cannot be clamped to one millisecond. It covers each outstanding iterator next() from demand to adapter-recognized provider activity; time a consumer spends between next() calls is not provider idle time. DeepSeek SSE comments count as transport activity but never become StreamChunk values or session-log events.
@deepseek-ai/dsh-timeout exposes a rearmable idle-watchdog primitive. One stable local AbortController is fused with the caller signal and passed to the transport for the whole adapter call; each outstanding next() arms the watchdog, resolution disarms it, and the next demand rearms it. Out-of-band transport activity calls pulse() to rearm an outstanding demand without yielding a value. Timeout aborts that stable controller with a capability-owned TimeoutReason, and finally clears the timer. The adapter classifies its watchdog as TIMEOUT and an earlier upstream abort as ABORTED. The existing one-shot deadline() is not presented as a sliding timer.
Boundary tests prove termination at both actual transports. The hand-written adapter aborts its fetch/reader, and the pi-ai adapter maps the stable signal through the SDK and proves the SDK closes the response. A timer that merely rejects a consumer promise while leaving the request running does not satisfy the contract.
A failed attempt may leave assistant/chunk events in its closed step, but it never appends assistant/message and never dispatches a tool. A retry closes the failed turn, opens the next numbered turn, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when llm/retry identifies the failed step or turn/end records failure. Web validates the complete retry payload contract, clears the failed partial at llm/retry, projects consecutive retry-turn events into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from subsequent turn facts. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no assistant node. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows.
If recovery is exhausted, the final failure is stored once on turn/end.reason with the structured facts. Web derives one turn-error node at that sequence position and renders its display-safe message and optional code inline; AUTH projections replace provider copy that may echo credential fragments with API key is invalid, while the raw diagnostic remains in the session log. The same fold runs for live events and history replay. If transient recovery continues, llm/retry is the durable home for that attempt's failure and delay, so its failed turn does not also gain a terminal error row. No standalone final-error event or response-id vocabulary is added.
llm/stream into a response lifecycle or adding convenience generation APIs without a production consumer.llm/stream or the provider SDK — rejected because a raw stream has no durable attempt boundary after emitting chunks, hidden SDK retries multiply budgets, and neither path can record each failed attempt consistently.dsh-llm — rejected because the agent log already separates raw chunks, successful messages, and numbered attempts. A second state machine would duplicate ownership without enabling the bounded same-route retry.retryable or failover on LlmFailure — rejected because adapters report facts while deployment policy decides action. The same 429 may be retried in an interactive bundle and rejected in a cost-capped batch.always entries while retaining bounded normal mode as the default.LlmFailure is the single serializable payload for adapter throws, error finishes, and aborted finishes; normalization preserves stable code, status, retry delay, branded provider request id, and caller-abort versus adapter-timeout classification where available.agent/request-error carries current failure facts, immutable prior-retried failure facts, and the serving registration's immutable retry policy; a success clears the history, and alternating transient/context-overflow integration tests prove the two policies consume only their own finite budgets.ctx.llm captures it with the route; normal mode delegates ineligible paths and makes at most maxRetries + 1 provider requests when no other policy applies.Retry-After, exhausted budgets, deterministic timer/random hooks, and abort during backoff.turn/end.reason, and composition with dsh-compaction-basic context-overflow recovery.llm/retry event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Client tests cover complete wire validation, clock-independent countdown, cancellation versus completed retry labels, and trajectory attribution; keyless UI snapshots cover Web scheduling and success, a real Web composition test covers partial transport failure through recovery, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted.next() is outstanding, disarmed during consumer think time and in finally, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it.ctx.llm.stream() callers remain single-attempt and receive the same structured failure facts.llm/retry, and adapter-level single-attempt behavior prevents hidden SDK retries from multiplying policy decisions. A retry can still duplicate provider billing even when no chunk arrived; normal mode limits that risk, while explicit always mode accepts it until cancellation or success.