diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 28de5eb97c..1e0ef18f9c 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -2,6 +2,8 @@ Status: implemented +The [per-provider request retry policy](../feature/2026-07-24-provider-retry-policies.md) 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. + ## Problem `dsh-llm` can report provider failures either by throwing during adapter dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary tags thrown failures so `dsh-agent-loop` can distinguish them from middleware and result-processing defects, and the loop normalizes both delivery forms into `agent/request-error` after closing the failed step. The default decision is `fail`; `dsh-compact-basic` is the only shipped recovery listener, and it retries a canonical context-window overflow only after compaction proves that the durable surface shrank. @@ -14,7 +16,7 @@ The prior boundary left three narrower gaps. - Retry ownership differs by adapter. The hand-written DeepSeek adapter makes one attempt, while pi-ai profiles can enable opaque library retries. Combining hidden transport retries with an `agent/request-error` listener would multiply attempts and omit intermediate failures from the session log. - A recovered failure has no durable status fact. The failed step and chunks remain reconstructable, but an observer cannot tell whether the agent is deliberately backing off, for how long, or why. A long silent wait looks like a stalled loop. -The goal is 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. +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. ## Decision @@ -48,31 +50,19 @@ The initial shared transient-code set is intentionally small: the adapters' exis `@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` seam carries the current `LlmFailure` and an immutable list of prior failures that led to another request attempt in this consecutive recovery sequence. `dsh-llm-retry` counts only prior failures whose codes are in its configured transient set, while `dsh-compact-basic` counts only prior context-overflow failures. A successful model request clears the history. Alternating transient and context-overflow failures therefore consume their owning policy budgets independently; the maximum request count is one plus the sum of the finite budgets of the loaded recovery policies. +The `agent/request-error` seam carries the current `LlmFailure` and an immutable list of prior failures that led to another request attempt in this consecutive recovery sequence. Normal `dsh-llm-retry` policy counts retry records scheduled by the same exact-provider policy, while `dsh-compact-basic` counts prior context-overflow failures. A successful model request clears the history. Alternating transient and context-overflow failures therefore consume their owning finite budgets independently. -The plugin resolves and validates this deployment configuration at load: - -```ts ignore-check -interface Config { - maxTransientRetries?: number - initialDelayMs?: number - maxDelayMs?: number - jitterRatio?: number - retryableCodes?: string[] -} -``` - -The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the four 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](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets. +The [provider-policy decision](../feature/2026-07-24-provider-retry-policies.md) 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 four 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](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). 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 backoff callback. Each wait fuses the waterfall's turn signal with that lifetime signal. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; a captured callback whose lifetime signal aborts returns `fail` and can neither retry nor enter the rest of its captured 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, one-based transient retry number, configured maximum, scheduled delay, and `LlmFailure`. The plugin owns the `SessionEventMap` augmentation; `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 only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection. +Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, provider, policy mode, provider-policy retry number, mode-specific finite maximum when present, scheduled delay, and `LlmFailure`. The plugin owns the `SessionEventMap` augmentation; `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 only with a production renderer 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. It returns `{ action: 'retry' }` only after the delay completes under both signals; turn cancellation and plugin disposal return `fail`, after which the loop's cancellation/disposal checks remain authoritative. -The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same bounded policy. Library consumers retain explicit plugin composition: omitting the plugin leaves `agent/request-error` at its current fail default. +The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same provider-routed policy. Library consumers retain explicit plugin composition: omitting the plugin leaves `agent/request-error` at its current fail default. ### Make one layer own visible attempts @@ -99,7 +89,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - Automatic provider or model failover. Requests already select one explicit provider and model, and the provider registry deliberately has one adapter owner per provider. - Retrying or continuing after a successful terminal finish, or splicing chunks from two attempts into one assistant message. - Repairing malformed tool arguments, refusals, content filters, or other semantic model output. -- Unbounded retries, unattended retry-until-cancelled behavior, circuit breakers, shared provider health, or cross-agent retry budgets. +- Circuit breakers, shared provider health, or cross-agent retry budgets. - Changing `llm/stream` into a response lifecycle or adding convenience generation APIs without a production consumer. ## Alternatives considered @@ -108,7 +98,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - **Add response start, interrupted, discarded, failed, and committed events to `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. - **Add logical routes, capability matrices, and failover selection** — rejected because current requests already name provider and model explicitly, one adapter owns each provider, and no current consumer requires automatic fallback or can prove semantic compatibility. - **Put `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. -- **Retry forever while the caller remains active** — rejected because it gives one request unbounded cost and latency. Visible status makes bounded waiting understandable; it does not make an unlimited budget safe. +- **Retry forever while the caller remains active** — the [per-provider policy](../feature/2026-07-24-provider-retry-policies.md) supersedes this rejection for explicit `always` entries while retaining bounded normal mode as the default. - **Log retry status only through the process logger** — rejected because process logs do not reconstruct session behavior and cannot drive replayed UI state. - **Keep only flat codes** — rejected because retry delay and provider request id are structured provider facts, and HTTP status is necessary for diagnosis when different wire failures share one stable code. @@ -119,7 +109,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - DeepSeek and pi-ai adapter tests cover representative 400, 401/403, 429, 5xx, connection, malformed/truncated stream, timeout, abort, retry-after seconds/date, request-id, and unknown-SDK-error paths without recovery policy parsing message text. - Pi-ai pins the SDK option to zero retries and performs one observed wire attempt for a retryable provider response; separate tests make removing either boundary fail. - `agent/request-error` carries current failure facts plus immutable prior-retried failure facts; a success clears that history, and alternating transient/context-overflow integration tests prove the two policies consume only their own finite budgets. -- `dsh-llm-retry` validates every config field at Loader startup, delegates all ineligible paths with `next()`, and makes at most `maxTransientRetries + 1` provider requests when no other policy applies. +- Each provider adapter validates its nested retry policy at Loader startup, and `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. - HMR-during-backoff tests prove disposal unregisters the listener, aborts and awaits its captured callbacks, emits no retry decision after disposal, and leaves no timer or promise alive. - Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff. - Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new step, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery. @@ -130,12 +120,12 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` ## Consequences -- Every transient recovery attempt is visible as a closed step plus `llm/retry`, and the bounded policy prevents hidden SDK retries from multiplying cost. A retry can still duplicate provider billing even when no chunk arrived; the finite attempt budget limits but cannot remove that risk. +- Every retry attempt is visible as a closed step plus `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. - Provider SDKs may hide status or retry headers. Those adapters retain the stable facts they expose and otherwise use a coarse code rather than letting recovery policy parse fragile text. - Durable retry events expand the session protocol and UI state machine. Shipping the event and its consumer together prevents an unused telemetry vocabulary, but later schema changes still require persistence and replay work. - Clearing a failed step's live chunks can visibly retract output. That is preferable to presenting discarded text or partial tool JSON as committed history, and snapshots pin the transition. - Adapter-local idle enforcement stops stalled transports without counting consumer think time. Contract tests at each transport boundary guard against SDK drift. -- Multiple recovery plugins add their finite budgets. Their classifiers remain disjoint here; an overlapping classifier would be registration-order policy and must be documented and tested by the plugins that introduce it. +- Multiple normal recovery plugins add their finite budgets. Always mode delegates first and then supplies an unbounded fallback; overlapping classifiers remain registration-order policy and must be documented and tested by the plugins that introduce them. ## Related diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml new file mode 100644 index 0000000000..ee221716ce --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-provider-retry-policies.md: 0ff304715aa5e1bfaf296e3f53e1da28d8b4042d +2026-07-24-provider-retry-policies.zh.md: 3b1d8f12a5fb37424e8b964f7bbb2be278263056 diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md new file mode 100644 index 0000000000..0ff304715a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md @@ -0,0 +1,65 @@ +# Agent Note: Per-provider request retry policies + +Status: implemented + +English | [中文](2026-07-24-provider-retry-policies.zh.md) + +## Problem + +One process may route model requests to providers with different reliability and cost constraints. A single transient classifier and finite retry budget cannot express a deployment that wants bounded recovery for most providers but requires one provider to keep retrying every model-request failure until the request succeeds or the caller cancels it. + +Provider policy must follow the request that actually failed, including a route selected by `agent/request`, rather than the agent's initial options. Unbounded policy also cannot store JavaScript `Infinity` in the durable session event, and neither provider error text nor discarded partial output may enter the next model request. + +## Decision + +Each concrete adapter accepts an optional `retryPolicy` inside its provider configuration. The adapter validates and resolves the policy, and `ctx.llm` captures it when that exact provider route registers. `@deepseek-ai/dsh-llm-retry` reads the registered policy for the provider whose step failed. A provider without `retryPolicy` uses the normal defaults. + +```yaml +providers: + - provider: deepseek + retryPolicy: + mode: normal + maxRetries: 2 + retryableCodes: [RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT] + backoff: + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 + - provider: internal + retryPolicy: + mode: always + backoff: + initialDelayMs: 1000 + maxDelayMs: 30000 + jitterRatio: 0.2 +``` + +The listener selects the policy from the durable `request/header` in force when the failed step closed, excluding later recovery mutations. Normal mode retains the bounded transient behavior: it retries configured codes up to `maxRetries`, counts retries scheduled by the same provider policy in the current consecutive failure sequence, and otherwise delegates. + +Always mode asks downstream recovery first so a specialized policy such as context-overflow compaction can make progress. A downstream retry wins. A downstream failure decision or thrown recovery error falls back to an unbounded retry of the same provider request; the thrown error is logged. Success, turn cancellation, and plugin disposal are the only termination paths. + +Both modes use exponential local delays from `initialDelayMs` to `maxDelayMs`. `jitterRatio` multiplies each target by a uniform sample in `[1 - jitterRatio, 1 + jitterRatio]`, then applies the cap. A positive provider `Retry-After` within the cap remains exact and unjittered. An over-cap provider delay makes normal mode delegate; always mode retains its guarantee by using the configured local backoff. + +Each scheduled retry appends a non-surface `llm/retry` event with the failed provider, policy mode, provider-policy retry number, delay, and failure facts. Normal events carry finite `maxRetries`; always events omit it, and UIs render the limit as `∞`. The event and failed `assistant/chunk` records do not contribute surface messages, so the next request contains the same derived context as the failed request unless another recovery policy deliberately changes the surface. + +## Alternatives considered + +**One global `always` switch** — rejected because it cannot isolate the unbounded cost and latency risk to the provider that needs it and can silently apply after runtime rerouting. + +**A separate exact-provider list on `dsh-llm-retry`** — rejected because it duplicates provider route names outside their owning adapter configuration and lets provider registration drift from recovery policy. + +**A very large finite retry count** — rejected because it eventually violates the requested keep-retrying contract and serializes an arbitrary operational limit as if it were meaningful. + +**Provider-SDK retries** — rejected because hidden attempts multiply agent-level budgets, cannot use the closed-step durability boundary, and may splice or discard streamed output without a reconstructable retry record. + +**Put the error into model context** — rejected because a transport or provider diagnostic is operational state, not conversation content. It can expose sensitive provider details and changes the retried request instead of repeating the failed request. + +## Verification + +Adapter tests validate nested policies at provider load and prove registration captures configured and default policies. Unit and real-Loader composition tests select policies from the failed request's provider, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation interrupts stalled downstream recovery, and prove cancellation and disposal stop active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind its provider to the request header and its retry number to the active provider policy; ACP and TUI tests render finite and infinite limits. + +## Consequences + +Normal mode remains a finite default, while an explicit always policy can spend unbounded requests and time on permanent authentication, quota, invalid-request, protocol, or context failures. Operators must pair always mode with a cancellable caller and provider-specific cost controls. Retry state stays observable and durable without becoming model-visible, and exact-provider selection keeps one provider's exceptional policy from changing another provider's recovery behavior. + +This decision extends the closed-step recovery, single visible adapter attempt, structured failure, and durable status design in [bounded recovery for transient LLM request failures](../architecture/2026-06-21-bounded-llm-request-recovery.md). diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md new file mode 100644 index 0000000000..3b1d8f12a5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md @@ -0,0 +1,65 @@ +# Agent Note: 逐提供方请求重试策略 + +Status: implemented + +[English](2026-07-24-provider-retry-policies.md) | 中文 + +## 问题 + +同一进程可能把模型请求路由到可靠性和成本约束各不相同的提供方。单一的瞬态错误分类器与有限重试预算无法表达这种部署需求:大多数提供方只需有界恢复,但其中一个提供方必须持续重试每次模型请求失败,直到请求成功或调用方取消。 + +提供方策略必须跟随实际失败的请求,包括 `agent/request` 选择的路由,而不能跟随 agent(智能体)的初始选项。无界策略也不能把 JavaScript `Infinity` 存入持久会话事件;提供方错误文本与丢弃的部分输出都不得进入下一次模型请求。 + +## 决策 + +每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`。适配器负责校验并解析策略,`ctx.llm` 则在该精确提供方路由注册时捕获策略。`@deepseek-ai/dsh-llm-retry` 读取失败步骤对应提供方的已注册策略。未配置 `retryPolicy` 的提供方使用 normal 默认值。 + +```yaml +providers: + - provider: deepseek + retryPolicy: + mode: normal + maxRetries: 2 + retryableCodes: [RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT] + backoff: + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 + - provider: internal + retryPolicy: + mode: always + backoff: + initialDelayMs: 1000 + maxDelayMs: 30000 + jitterRatio: 0.2 +``` + +监听器根据失败步骤关闭时生效的持久 `request/header` 选择策略,后续恢复产生的改动不参与选择。normal 模式保留有界瞬态错误处理行为:它重试配置的错误代码,次数不超过 `maxRetries`;在当前连续失败序列中,同一提供方策略安排的重试都计入次数;其他情况委托后续处理。 + +always 模式先请求下游恢复,使上下文溢出压缩(compaction)之类的专用策略有机会取得进展。下游若决定重试,则以该决定为准。下游若决定失败或恢复过程抛出错误,则回退为无界重试同一提供方请求;抛出的错误会写入日志。成功、轮次取消和插件 dispose(资源释放)是仅有的终止路径。 + +两种模式的本地延迟都按指数增长,从 `initialDelayMs` 增至 `maxDelayMs`。`jitterRatio` 用 `[1 - jitterRatio, 1 + jitterRatio]` 区间内的均匀随机样本乘以每次目标值,再应用上限。提供方给出的正数 `Retry-After` 若未超过上限,则保持精确且不加抖动。若提供方延迟超过上限,normal 模式会委托后续处理;always 模式则改用配置的本地退避,以维持无限重试保证。 + +每次安排重试都会追加一条不进入表层的 `llm/retry` 事件,其中包含失败的提供方、策略模式、提供方策略内的重试编号、延迟和失败事实。normal 事件包含有限的 `maxRetries`;always 事件省略该字段,UI 将上限渲染为 `∞`。该事件与失败的 `assistant/chunk` 记录都不会生成表层消息,因此除非其他恢复策略有意改变表层,否则下一次请求包含的派生上下文与失败请求相同。 + +## 曾考虑的替代方案 + +**单一全局 `always` 开关**:不予采纳,因为它无法把无界成本与延迟风险限制在确有需要的提供方,还可能在运行时重新路由后悄然生效。 + +**在 `dsh-llm-retry` 上维护单独的精确提供方列表**:不予采纳,因为它会在所属适配器配置之外重复提供方路由名称,并让提供方注册与恢复策略发生偏差。 + +**设置很大的有限重试次数**:不予采纳,因为它最终仍会违反持续重试的契约,并把任意选取的运维上限序列化成看似有意义的数值。 + +**使用提供方 SDK 重试**:不予采纳,因为隐藏尝试会叠加 agent 层预算,无法利用已关闭 step 的持久性边界,还可能在没有可重建重试记录的情况下拼接或丢弃流式输出。 + +**把错误放入模型上下文**:不予采纳,因为传输或提供方诊断信息属于运维状态,而非对话内容。它可能暴露敏感的提供方细节,并会改变重试请求,无法重复原本失败的请求。 + +## 验证 + +适配器测试会在提供方加载时校验嵌套策略,并证明注册流程会捕获已配置策略和默认策略。单元测试与真实 Loader 组合测试根据失败请求的提供方选择策略、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消会中断停滞的下游恢复,并证明取消与 dispose 会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将事件中的提供方绑定到请求头,并将重试编号绑定到活跃的提供方策略;ACP 与 TUI 测试会分别渲染有限和无限上限。 + +## 后果 + +normal 模式仍是有限的默认策略;显式的 always 策略可能在永久性的身份验证、配额、无效请求、协议或上下文错误上耗费无限次请求和无限时间。运维方必须为 always 模式配备可取消的调用方和针对提供方的成本控制。重试状态保持可观察且持久,但不会对模型可见;精确提供方选择也能避免某个提供方的例外策略改变其他提供方的恢复行为。 + +本决策扩展了[瞬态 LLM(大语言模型)请求失败的有界恢复](../architecture/2026-06-21-bounded-llm-request-recovery.md)中确定的已关闭 step 恢复、单次可见适配器尝试、结构化失败与持久状态设计。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 466a96dd30..afa1d20efd 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: 5a0ff63413a0c2a59d042f935d341dd39234f669 -architecture.zh.md: e1fb143982ad968fe6be2a5f6154722a602a297b +architecture.md: 3347e529f7e2c7b4c376d01132851d883c90182e +architecture.zh.md: f3d3bf8b19bace2124b89c3b6ee7e4587445b9fe diff --git a/docs/architecture.md b/docs/architecture.md index 5a0ff63413..3347e529f7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,7 +119,7 @@ Each step assembles ordered prompt sections, tool schemas, and variables; unknow Tool-time context—including async `inject()` and post-tool `additionalContexts`—settles after results. Steering drains before `agent/post-step`, which sees durable output, results, context, and steering. Leftovers queue. Terminal `agent/turn-stop` remains authoritative through close/flush; later steering is discarded while queued prompts remain. -Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)). +Pruning precedes summaries; overflow retries require durable progress. Adapters register nested `retryPolicy`; normal bounds transient failures, while always retries until success or cancellation ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). ### Failure Boundaries diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index e1fb143982..f3d3bf8b19 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -119,7 +119,7 @@ forever: 工具执行阶段的上下文,包括异步 `inject()` 和工具执行后的 `additionalContexts`,会在结果产生后稳定。steering(中途引导)会在 `agent/post-step` 前排空;该事件会观察持久输出、结果、上下文和 steering。余留内容进入队列。终止型 `agent/turn-stop` 在关闭和刷写期间始终具有最终决定权;后续 steering 会被丢弃,排队提示词仍予保留。 -裁剪先于摘要;溢出重试必须取得持久进展。有界的瞬态重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。 +裁剪先于摘要;溢出重试必须取得持久进展。适配器会注册嵌套的 `retryPolicy`;normal 限制瞬态错误重试次数,always 则持续重试,直至成功或取消([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。 ### 失败边界 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49a0a1510f..2036a34307 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -76,8 +76,6 @@ export interface Config { toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ goals?: agentCore.GoalConfig | false - /** Bounded transient model-request retry policy forwarded through agent-core. */ - llmRetry?: NonNullable } ``` @@ -127,8 +125,9 @@ Source: [`packages/core/agent-loop/src/index.ts:360`](../packages/core/agent-loo * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, `llmRetry` to the bounded request-recovery policy, - * and `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool + * plugins this bundle owns. Provider adapters own their `retryPolicy`; this + * bundle always mounts its executor. * `goals` opts into and configures the persisted goal domain plus its model tool * and same-session driver; `invariants` configures global and package-filtered * relational checks. Owner schemas supply defaults for optional input; @@ -164,8 +163,6 @@ export interface Config { invariants?: InvariantConfig /** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */ goals?: GoalConfig | false - /** Bounded transient model-request retry policy. */ - llmRetry?: llmRetry.Config } /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ @@ -189,9 +186,9 @@ export interface GoalConfig { } ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`llmRetry`](../packages/llm/llm-retry/src/index.ts) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) +Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:87`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:88`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -265,8 +262,6 @@ export interface Config { toolBash?: NonNullable /** Generic background-task control-tool config forwarded through agent-spine-demo. */ toolTasks?: NonNullable - /** Bounded transient model-request retry policy forwarded through agent-spine-demo. */ - llmRetry?: NonNullable /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] } @@ -538,6 +533,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Provider-owned model-request retry policy; omission uses normal defaults. */ + retryPolicy?: RetryPolicyConfig } /** One optional model entry advertised by the hand-written adapter. */ @@ -553,7 +550,9 @@ export interface DeepSeekCatalogModel { } ``` -Source: [`packages/llm/llm-deepseek/src/index.ts:34`](../packages/llm/llm-deepseek/src/index.ts) +Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) + +Source: [`packages/llm/llm-deepseek/src/index.ts:35`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -590,12 +589,14 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number + /** Provider-owned model-request retry policy; omission uses normal defaults. */ + retryPolicy?: RetryPolicyConfig } ``` -Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `ThinkingLevel` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) +Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `ThinkingLevel` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:48`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:54`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` @@ -645,25 +646,14 @@ Source: [`packages/support/llm-replay/src/index.ts:387`](../packages/support/llm ## `@deepseek-ai/dsh-llm-retry` -Requires: `agents` +Requires: `agents` · `llm` ```ts config-catalog -/** Deployment-owned limits and classification for transient request recovery. */ -export interface Config { - /** Maximum transient retries after the first request (default 2). */ - maxTransientRetries?: number - /** Initial local exponential-backoff delay in milliseconds (default 500). */ - initialDelayMs?: number - /** Maximum accepted or locally scheduled delay in milliseconds (default 10000). */ - maxDelayMs?: number - /** Symmetric random multiplier range around one (default 0.1). */ - jitterRatio?: number - /** Stable failure codes eligible for this policy. */ - retryableCodes?: string[] -} +/** This policy executor has no config; providers own `retryPolicy`. */ +export type Config = Readonly> ``` -Source: [`packages/llm/llm-retry/src/index.ts:39`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:43`](../packages/llm/llm-retry/src/index.ts) ## `@deepseek-ai/dsh-lsp-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6becfd9434..f301ff6528 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -547,7 +547,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:52`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:55`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5858799e7c..99c89b7ff3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -653,6 +653,13 @@ registerAdapter(providers: string[], adapter: LlmAdapter): () => void */ listProviders(): LlmProviderInfo[] +/** + * Resolve the retry policy captured when one provider route was registered. + * @param provider - registered provider route to inspect. + * @returns the provider-owned policy, with normal defaults already resolved. + */ +providerRetryPolicy(provider: string): ResolvedRetryPolicy + /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -686,9 +693,9 @@ async resolveModelContext( provider: string, model: string, ): Promise ``` -Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelContext](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelContext](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:159`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:171`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 257ce90cda..820454495a 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -66,6 +66,10 @@ Every adapter MUST obey these, and every consumer may rely on them: This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (hand-rolled fetch/SSE) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request. +## `ResolvedRetryPolicy` + +Provider configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. `LlmService.providerRetryPolicy(provider)` returns the captured value and supplies normal defaults when the adapter omits one. The [generated config catalog](../config-catalog.md) owns the optional input shapes. + ## `AppIdentity` — app attribution The static public application identity every adapter sends to providers ([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts)). `attributionHeaders(identity?)` maps it to the standard `User-Agent` header only; OpenRouter-specific app attribution headers are intentionally not supported by this contract. The default `APP_IDENTITY` sources its version from the package manifest; every field is a public product fact - no secrets, paths, session ids, or per-user identifiers, and nothing per-request may influence the values. Rationale: [Mandatory `User-Agent` attribution](../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). @@ -154,7 +158,7 @@ declare class BlockAssembler { ## The seam -`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. The separate `resolveModelContext()` query exposes correctness-sensitive capacity for an exact route without making catalog membership authoritative; absence means unknown metadata, not invalid routing. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. The separate `resolveModelContext()` query exposes correctness-sensitive capacity for an exact route without making catalog membership authoritative; absence means unknown metadata, not invalid routing. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). ```ts public-api /** @@ -170,6 +174,12 @@ declare abstract class LlmAdapter { * @returns detached display metadata whose id must equal `provider`. */ providerInfo(provider: string): LlmProviderInfo; + /** + * Return the provider-owned retry policy captured with this route. + * @param _provider - a route passed to `registerAdapter()` for this instance. + * @returns a resolved policy, or `undefined` to use the normal defaults. + */ + providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined; /** * List models this adapter can currently advertise for one owned provider. * The result is advisory: an adapter may accept unlisted model ids, and diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 179ad0dcee..9db87fd0a3 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:55`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 8c1d815bc0..0dce9a770c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -299,14 +299,24 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- #### `llm/retry` — log-only ```ts persistence-catalog -/** Durable, non-surface record of one transient retry scheduled after a closed failed step. */ +/** Durable, non-surface record of one provider-routed retry scheduled after a closed failed step. */ 'llm/retry': { turn: number step: number + provider: string + mode: 'normal' retry: number maxRetries: number delayMs: number failure: LlmFailure +} | { + turn: number + step: number + provider: string + mode: 'always' + retry: number + delayMs: number + failure: LlmFailure } ``` diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index f9f61e4096..86b0fdc6fb 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' -import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -68,6 +68,11 @@ class StepwiseToolAdapter extends LlmAdapter { class OverflowRecoveryAdapter extends LlmAdapter { readonly conversationRequests: GenerateOptions[] = [] readonly summaryRequests: GenerateOptions[] = [] + private readonly retryPolicy = resolveRetryPolicy({ + mode: 'normal', + maxRetries: 1, + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }, 'compaction test provider retryPolicy') constructor( private readonly delivery: 'thrown' | 'in-band', @@ -80,6 +85,10 @@ class OverflowRecoveryAdapter extends LlmAdapter { return Promise.resolve({ contextWindow: 128 }) } + override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { + return this.retryPolicy + } + override async * stream(options: GenerateOptions): AsyncIterable { // The cache-reusing summarizer replays the conversation prefix and marks // its call only by the compaction instruction in the trailing user message. @@ -340,12 +349,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () const adapter = new OverflowRecoveryAdapter('thrown', true) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) - await ctx.plugin(LlmRetry, { - maxTransientRetries: 1, - initialDelayMs: 1, - maxDelayMs: 1, - jitterRatio: 0, - }) + await ctx.plugin(LlmRetry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 6939ec9927..d25338d99b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -336,6 +336,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'listProviders(): LlmProviderInfo[]', jsDoc: '/**\n * Describe provider routes with a registered adapter.\n * @returns detached provider metadata in registration order.\n */', }, + { + signature: 'providerRetryPolicy(provider: string): ResolvedRetryPolicy', + jsDoc: '/**\n * Resolve the retry policy captured when one provider route was registered.\n * @param provider - registered provider route to inspect.\n * @returns the provider-owned policy, with normal defaults already resolved.\n */', + }, { signature: 'async listModels(provider: string): Promise', jsDoc: '/**\n * Discover models advertised by one registered provider. Catalog membership\n * is advisory and never changes routing or request validation.\n * @param provider - registered provider route to inspect.\n * @returns detached model metadata in adapter-preferred order.\n */', @@ -1693,6 +1697,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ReasoningBlock', declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}', }, + { + name: 'ResolvedAlwaysRetryPolicy', + declaration: 'export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'always\';\n}', + }, + { + name: 'ResolvedNormalRetryPolicy', + declaration: 'export interface ResolvedNormalRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'normal\';\n readonly maxRetries: number;\n readonly retryableCodes: readonly string[];\n}', + }, + { + name: 'ResolvedRetryBackoff', + declaration: 'export interface ResolvedRetryBackoff {\n readonly initialDelayMs: number;\n readonly maxDelayMs: number;\n readonly jitterRatio: number;\n}', + }, + { + name: 'ResolvedRetryPolicy', + declaration: 'export type ResolvedRetryPolicy = ResolvedNormalRetryPolicy | ResolvedAlwaysRetryPolicy;', + }, { name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 79fbbd7824..3314dfd7c1 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -69,7 +69,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy: - Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → definition-owned `finalizeContent` → `tools/result` pipeline; exact event signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md) - Compaction: pressure on `agent/post-step`; canonical context overflow on `agent/request-error` -- Transient model recovery: `dsh-llm-retry` on `agent/request-error`, with finite code-specific budgets and non-surface `llm/retry` status events +- Model-request recovery: `dsh-llm-retry` on `agent/request-error`, with exact-provider normal or unbounded policies and non-surface `llm/retry` status events - Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation - Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection. - Persistence: `session/event` + `session/flush` diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 93d89d6557..ef46d95f98 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -42,7 +42,6 @@ The app owns this cluster through one ordered Cordis effect. Teardown drains the | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | | `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer | -| `llmRetry` | owner defaults | bounded transient model-request retry policy routed through `dsh-agent-spine-demo` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory and the parent of the derived `session-query.db` index | | `packChunks` | `false` | write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`) | | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 1a05a14e3e..3772e2ce1c 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -76,8 +76,6 @@ export interface Config { toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ goals?: agentCore.GoalConfig | false - /** Bounded transient model-request retry policy forwarded through agent-core. */ - llmRetry?: NonNullable } // Each front door owns a complete, directly readable config schema; extracting @@ -104,7 +102,6 @@ export const Config: z = z.object({ toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), goals: z.union([z.const(false), agentCore.GoalConfigSchema]), - llmRetry: agentCore.LlmRetryConfigSchema, }) /* jscpd:ignore-end */ diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 57d5922535..af8b0af0f2 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -21,7 +21,7 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-goal optional persisted same-session goal domain @deepseek-ai/dsh-tool-goal optional model-facing goal controls @deepseek-ai/dsh-goal-session optional same-session goal-round driver -@deepseek-ai/dsh-llm-retry bounded transient request retry policy +@deepseek-ai/dsh-llm-retry provider-routed request retry policy @deepseek-ai/dsh-tasks generic background-task registry @deepseek-ai/dsh-invariants configurable invariant registry service @deepseek-ai/dsh-session/invariant @@ -53,11 +53,11 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants?, llmRetry? } +// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. @@ -65,7 +65,7 @@ For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/ A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. App packages make stdout-safe ACP wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling. -The bounded retry policy may repeat a transiently failed request in a new numbered step. Retry status and failed partial chunks stay outside model history, each provider attempt can still incur billing, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. +The retry policy may repeat a failed request in a new numbered step. Retry status, provider errors, and failed partial chunks stay outside model history; each provider attempt can still incur billing, always mode has no attempt limit, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. ## Model Experience diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index bf69e27787..11a8a890c9 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-spine-demo", - "description": "The default executor-less/UI-less agent spine with fallback session titles, bounded retry, and optional persisted goals", + "description": "The default executor-less/UI-less agent spine with fallback session titles, provider-routed retry, and optional persisted goals", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index c43ee2ab8d..d2a65844ea 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -74,8 +74,9 @@ export interface GoalConfig { * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, `llmRetry` to the bounded request-recovery policy, - * and `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool + * plugins this bundle owns. Provider adapters own their `retryPolicy`; this + * bundle always mounts its executor. * `goals` opts into and configures the persisted goal domain plus its model tool * and same-session driver; `invariants` configures global and package-filtered * relational checks. Owner schemas supply defaults for optional input; @@ -111,8 +112,6 @@ export interface Config { invariants?: InvariantConfig /** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */ goals?: GoalConfig | false - /** Bounded transient model-request retry policy. */ - llmRetry?: llmRetry.Config } /** The skill config schema exported for app packages that forward `skills`. */ @@ -139,9 +138,6 @@ export const GoalConfigSchema: z = z.object({ tool: toolGoal.Config, }) -/** The bounded LLM retry schema exported for app packages that forward `llmRetry`. */ -export const LlmRetryConfigSchema: z = llmRetry.Config - /** Intersect the owners' schemas so validation + defaulting stay identical. */ export const Config = z.intersect([ AgentLoop.Config, @@ -156,8 +152,7 @@ export const Config = z.intersect([ toolTasks: z.union([z.const(false), ToolTasksConfigSchema]), invariants: InvariantService.Config, goals: z.union([z.const(false), GoalConfigSchema]), - llmRetry: LlmRetryConfigSchema, - }) as unknown as z>, + }) as unknown as z>, ]) as unknown as z /** @@ -179,7 +174,6 @@ export function pickSpineConfig(config: Omit): Omit { this.requests += 1 @@ -219,15 +237,7 @@ describe('dsh-agent-spine-demo bundle', () => { it('loads and configures bounded request recovery for every bundled front door', async () => { const adapter = new TransientOnceAdapter() - const ctx = await mount({ - workspaceContext: false, - llmRetry: { - maxTransientRetries: 1, - initialDelayMs: 1, - maxDelayMs: 1, - jitterRatio: 0, - }, - }) + const ctx = await mount({ workspaceContext: false }) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ sessionId: SessionId('bundled-retry-session'), @@ -242,7 +252,7 @@ describe('dsh-agent-spine-demo bundle', () => { const retryEvents = handle.agent.session.events.filter(event => event.type === 'llm/retry') expect(retryEvents).toHaveLength(1) expect(retryEvents[0]?.data.retry).toBe(1) - expect(retryEvents[0]?.data.maxRetries).toBe(1) + expect(retryEvents[0]?.data).toMatchObject({ provider: 'mock', mode: 'normal', maxRetries: 1 }) expect(handle.agent.session.events.find(event => event.type === 'session/title')?.data.title).toBe('recover') expect(messageText(handle.agent.session.deriveMessages().at(-1))).toBe('recovered by bundled policy') await handle.dispose() @@ -523,7 +533,6 @@ describe('dsh-agent-spine-demo bundle', () => { toolBash: { enableRunInBackground: false }, toolTasks: false as const, invariants: { enabled: false }, - llmRetry: { maxTransientRetries: 1, jitterRatio: 0 }, } expect(agentCore.pickSpineConfig(appConfig)).toEqual({ @@ -537,7 +546,6 @@ describe('dsh-agent-spine-demo bundle', () => { toolBash: appConfig.toolBash, toolTasks: appConfig.toolTasks, invariants: appConfig.invariants, - llmRetry: appConfig.llmRetry, }) expect(agentCore.pickSpineConfig({ workspaceContext: false })).toEqual({ workspaceContext: false }) }) diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index 8a931cc147..a0b049ee68 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -19,7 +19,6 @@ The package mounts no console logger, interactive UI, user-interaction service, | `skills` | owner defaults | skill registry, local provider, and model-facing skill tool | | `toolBash` | owner defaults | model-facing bash config, including this producer's background opt-in | | `toolTasks` | owner defaults | generic `task_output` wait bounds | -| `llmRetry` | owner defaults | bounded transient model-request retry policy | | `persistenceRoot` | `./.sessions` | JSONL session root | | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | | `workspaceContext` | required | workspace-instruction byte budget, or `false` to disable loading | diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts index f7d543c3fe..bfec7c9a4f 100644 --- a/packages/examples/cli-demo/src/index.ts +++ b/packages/examples/cli-demo/src/index.ts @@ -50,8 +50,6 @@ export interface Config { toolBash?: NonNullable /** Generic background-task control-tool config forwarded through agent-spine-demo. */ toolTasks?: NonNullable - /** Bounded transient model-request retry policy forwarded through agent-spine-demo. */ - llmRetry?: NonNullable /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] } @@ -74,7 +72,6 @@ export const Config: z = z.object({ tools: ToolRegistry.Config, toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), - llmRetry: agentCore.LlmRetryConfigSchema, workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) /* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index 65eafff1cc..f467033128 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -3,7 +3,15 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk, type TokenUsage } from '@deepseek-ai/dsh-llm' +import { + CallId, + LlmAdapter, + resolveRetryPolicy, + type GenerateOptions, + type ResolvedRetryPolicy, + type StreamChunk, + type TokenUsage, +} from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import { afterEach, describe, expect, it } from 'vitest' import * as cliDemo from '../src/index.ts' @@ -20,11 +28,19 @@ type ScriptEntry = readonly StreamChunk[] | 'hang' class ScriptedAdapter extends LlmAdapter { readonly requests: GenerateOptions[] = [] private cursor = 0 + private readonly retryPolicy = resolveRetryPolicy({ + mode: 'normal', + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }, 'cli test provider retryPolicy') constructor(private readonly script: readonly ScriptEntry[]) { super() } + override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { + return this.retryPolicy + } + async * stream(options: GenerateOptions): AsyncIterable { this.requests.push(options) const entry = this.script[this.cursor++] @@ -107,7 +123,6 @@ async function harness(script: readonly ScriptEntry[]): Promise { persistenceRoot: root, skills: { local: { dshHome: join(skillHome, '.dsh'), agentsHome: join(skillHome, '.agents') } }, workspaceContext: false, - llmRetry: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, }) await new Promise(resolve => setTimeout(resolve, 80)) ctx.llm.registerAdapter(['mock'], new ScriptedAdapter(script)) diff --git a/packages/llm/README.md b/packages/llm/README.md index 0c937c17dc..6585de4b9a 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -6,8 +6,8 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a |---|---|---| | `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` | | `token-meter/` | Replay-aware request and surface token measurement | `ctx.tokenMeter` | -| `llm-retry/` | Bounded transient request retry policy | (listens to `agent/request-error`) | +| `llm-retry/` | Exact-provider normal or unbounded request retry policy | (listens to `agent/request-error`) | | `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | Multi-provider adapter via `@earendil-works/pi-ai` | (registers on `ctx.llm`) | -The interface lives at `llm/llm/`; adapters, retry policy, and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. The route-owning adapter optionally resolves exact provider/model context capacity; the token meter remains model-agnostic. A new provider adapter registers one or more provider routes on `ctx.llm` without touching the interface or consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the two shipping implementations, the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership, and the [routed model context Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) for capacity and compaction-policy ownership. +The interface lives at `llm/llm/`; adapters, retry policy, and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. The route-owning adapter supplies retry policy and optionally resolves exact provider/model context capacity; the retry executor and token meter remain provider-agnostic. A new provider adapter registers one or more provider routes on `ctx.llm` without touching the consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the two shipping implementations, the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership, and the [routed model context Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) for capacity and compaction-policy ownership. diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 5a9a1bfdbc..177312c1ef 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -17,6 +17,12 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire thinking: enabled # optional; provider default is enabled reasoningEffort: high # optional; high | max — omitted ⇒ not sent streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default + retryPolicy: # optional; omission uses bounded normal defaults + mode: always # normal | always + backoff: + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash @@ -26,7 +32,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire contextWindow: 64000 ``` -The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. +The plugin registers the single provider route `deepseek` together with its resolved `retryPolicy`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelContext('deepseek', model)` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists it returns `undefined` without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. @@ -34,7 +40,7 @@ The plugin registers the single provider route `deepseek`. A request selects it `thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral. A request with `GenerateOptions.purpose: 'session-title'` forces thinking disabled and omits `reasoning_effort`, reserving its bounded output for visible title text without changing conversation or compaction defaults. -`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy. +`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. ## App attribution diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 64faa3b725..4a270fecd1 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -5,12 +5,14 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelContext, LlmModelInfo, LlmProviderInfo, + ResolvedRetryPolicy, + RetryPolicyConfig, StreamChunk, } from '@deepseek-ai/dsh-llm' import { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' @@ -46,6 +48,8 @@ export interface DeepSeekAdapterOptions { models?: readonly DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number + /** Provider-owned model-request retry policy; omission uses normal defaults. */ + retryPolicy?: RetryPolicyConfig } /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -95,6 +99,7 @@ export function httpErrorCode(status: number, error?: WireError['error']): strin */ export class DeepSeekAdapter extends LlmAdapter { private readonly streamIdleTimeoutMs: number + private readonly retryPolicy: ResolvedRetryPolicy constructor(private readonly options: DeepSeekAdapterOptions) { super() @@ -110,12 +115,17 @@ export class DeepSeekAdapter extends LlmAdapter { `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } + this.retryPolicy = resolveRetryPolicy(options.retryPolicy, 'llm-deepseek: retryPolicy') } override providerInfo(provider: string): LlmProviderInfo { return { id: provider, name: 'DeepSeek' } } + override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { + return this.retryPolicy + } + override listModels(provider: string): Promise { return Promise.resolve((this.options.models ?? []).map(model => ({ provider, diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 66828fc954..8172dd5420 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -7,7 +7,8 @@ import type { Context } from 'cordis' import z from 'schemastery' -import type {} from '@deepseek-ai/dsh-llm' +import { RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts' import type { DeepSeekCatalogModel } from './adapter.ts' @@ -46,6 +47,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Provider-owned model-request retry policy; omission uses normal defaults. */ + retryPolicy?: RetryPolicyConfig } const catalogModel: z = z.object({ @@ -63,6 +66,7 @@ export const Config: z = z.object({ defaultContextWindow: z.number().step(1).min(1), models: z.array(catalogModel).default(DEFAULT_MODELS), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), + retryPolicy: RetryPolicySchema, }) /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ @@ -111,5 +115,6 @@ export function apply(ctx: Context, config: Config): void { : { defaultContextWindow: config.defaultContextWindow }, models: resolveModels(config.models), streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS, + ...config.retryPolicy === undefined ? {} : { retryPolicy: config.retryPolicy }, })) } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 145017ea3d..6203c7db67 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -522,6 +522,26 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([]) }) + it('registers retryPolicy from the provider config', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + retryPolicy: { + mode: 'always', + backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, + }, + }) + + expect(ctx.llm.providerRetryPolicy('deepseek')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + }) + it('owns the deepseek provider and advertises the default models', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -731,4 +751,16 @@ describe('plugin registration and config', () => { streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, })).rejects.toThrow(/streamIdleTimeoutMs/) }) + + it('rejects invalid nested retryPolicy before registering the provider', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + + await expect(ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + retryPolicy: { mode: 'normal', maxRetries: -1 }, + })).rejects.toThrow(/retryPolicy/) + expect(ctx.llm.listProviders()).toEqual([]) + }) }) diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 8a6736f112..31d890c0f1 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -17,6 +17,13 @@ Configure credentials and deployment-specific transport settings per provider. O apiKey: !!js process.env.OPENAI_API_KEY baseURL: https://proxy.example.com:8443 reasoning: high + retryPolicy: + mode: normal + maxRetries: 3 + backoff: + initialDelayMs: 500 + maxDelayMs: 10000 + jitterRatio: 0.1 - provider: anthropic apiKey: !!js process.env.ANTHROPIC_API_KEY streamIdleTimeoutMs: 300000 @@ -30,7 +37,7 @@ Each provider name must exist in pi-ai's installed catalog and may appear only o The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelContext(provider, model)` performs the same exact descriptor lookup and returns its context window, keeping capacity metadata on the route-owning adapter rather than a consuming plugin. -Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, and `streamIdleTimeoutMs`. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index ed0fb9fae4..59f8fce354 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -13,7 +13,7 @@ import type { SimpleStreamOptions, } from '@earendil-works/pi-ai' import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelContext, LlmModelInfo, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import { resolveProfiles } from './config.ts' import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' @@ -30,7 +30,10 @@ export interface PiAiAdapterOptions { * Resolve a catalog model dynamically and apply only the configured endpoint * override, preserving the catalog's API/capability/compatibility metadata. */ -function resolveModel(profile: PiAiProviderProfile, modelId: string): Model { +function resolveModel( + profile: Omit, + modelId: string, +): Model { const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model | undefined if (model === undefined) { throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL') @@ -39,7 +42,7 @@ function resolveModel(profile: PiAiProviderProfile, modelId: string): Model } /** Copy profile stream knobs into pi-ai's common option vocabulary. */ -function profileOptions(profile: PiAiProviderProfile): SimpleStreamOptions { +function profileOptions(profile: Omit): SimpleStreamOptions { return { ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, ...profile.reasoning === undefined ? {} : { reasoning: profile.reasoning }, @@ -75,6 +78,10 @@ export class PiAiAdapter extends LlmAdapter { this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile])) } + override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { + return this.profiles.get(provider)?.retryPolicy + } + override listModels(provider: string): Promise { const profile = this.profiles.get(provider) if (profile === undefined) { diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 199463aaf6..5c6916c38e 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -8,6 +8,8 @@ import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import type { CacheRetention, ThinkingBudgets, ThinkingLevel, Transport } from '@earendil-works/pi-ai' import z from 'schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 @@ -36,12 +38,16 @@ export interface PiAiProviderProfile { websocketConnectTimeoutMs?: number /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs?: number + /** Provider-owned model-request retry policy; omission uses normal defaults. */ + retryPolicy?: RetryPolicyConfig } /** Validated profile with every adapter-owned default resolved. */ -export interface ResolvedPiAiProviderProfile extends PiAiProviderProfile { +export interface ResolvedPiAiProviderProfile extends Omit { /** Positive finite provider-idle interval after defaulting. */ streamIdleTimeoutMs: number + /** Immutable retry policy captured with this provider route. */ + retryPolicy: ResolvedRetryPolicy } /** Plugin configuration: the non-empty provider profiles this instance owns. */ @@ -69,6 +75,7 @@ const profile = z.object({ timeoutMs: z.natural(), websocketConnectTimeoutMs: z.natural(), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), + retryPolicy: RetryPolicySchema, }) /** Runtime schema for {@link Config}. */ @@ -115,6 +122,10 @@ export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): Resol return { ...source, streamIdleTimeoutMs, + retryPolicy: resolveRetryPolicy( + source.retryPolicy, + `llm-pi-ai: provider "${source.provider}" retryPolicy`, + ), ...source.headers === undefined ? {} : { headers: { ...source.headers } }, ...source.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...source.thinkingBudgets } }, } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index ab08f21b81..da104cb22d 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -10,6 +10,9 @@ * providers: * - provider: openai * apiKey: !!js process.env.OPENAI_API_KEY + * retryPolicy: + * mode: normal + * maxRetries: 2 * - provider: anthropic * apiKey: !!js process.env.ANTHROPIC_API_KEY * - provider: openrouter @@ -36,6 +39,6 @@ export const inject = ['llm'] /** Register one generic pi-ai adapter for all configured provider routes. */ export function apply(ctx: Context, config: Config): void { const profiles = resolveProfiles(config.providers) - const adapter = new PiAiAdapter({ profiles }) + const adapter = new PiAiAdapter({ profiles: config.providers }) ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter) } diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index f37b07f624..7b059e2f78 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -303,12 +303,31 @@ describe('provider profile lifecycle', () => { const ctx = new Context() await ctx.plugin(LlmService) const fiber = await ctx.plugin(LlmPiAi, { - providers: [{ provider: 'openai' }, { provider: 'anthropic' }], + providers: [ + { + provider: 'openai', + retryPolicy: { + mode: 'always', + backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, + }, + }, + { provider: 'anthropic' }, + ], }) expect(ctx.llm.listProviders()).toEqual([ { id: 'openai', name: 'openai' }, { id: 'anthropic', name: 'anthropic' }, ]) + expect(ctx.llm.providerRetryPolicy('openai')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + expect(ctx.llm.providerRetryPolicy('anthropic')).toMatchObject({ + mode: 'normal', + maxRetries: 2, + }) await fiber.dispose() expect(ctx.llm.listProviders()).toEqual([]) }) @@ -373,6 +392,23 @@ describe('provider profile lifecycle', () => { } }) + it('rejects invalid nested retryPolicy at the provider-profile boundary', async () => { + expect(() => resolveProfiles([{ + provider: 'openai', + retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } }, + }])).toThrow(/retryPolicy\.backoff\.jitterRatio/) + + const ctx = new Context() + await ctx.plugin(LlmService) + await expect(ctx.plugin(LlmPiAi, { + providers: [{ + provider: 'openai', + retryPolicy: { mode: 'normal', maxRetries: -1 }, + }], + })).rejects.toThrow(/retryPolicy/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + it('constructs the adapter directly and rejects routes it does not own', async () => { const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] }) await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index 699e7e3dad..711666b8a0 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -1,41 +1,50 @@ # `@deepseek-ai/dsh-llm-retry` -Function plugin that retries selected transient model-request failures on the agent loop's closed-step recovery seam. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered step. +Function plugin that applies exact-provider retry policy on the agent loop's closed-step recovery seam. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered step. -The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. Delay bounds must fit Node's supported timer range. A valid `providerRetryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead. +Each provider adapter owns an optional nested `retryPolicy`, captured when its route registers on `ctx.llm`. Omission uses normal mode: two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`. A normal policy can change its finite budget, eligible codes, and backoff. Always mode asks downstream recovery first, then retries every model-request failure without an attempt limit; success, cancellation, or plugin disposal stops it. -Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward. +Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction. -The separately published `./invariant` companion checks that every retry record names the current open turn and its latest closed step, has a unique step record and increasing retry number, and carries a positive bounded retry budget and non-negative bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. +Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, failure, and scheduled delay. Normal events include the finite maximum; always events omit it, and UIs render `∞`. Cancellation and plugin disposal abort the wait; disposal drains active backoffs, and a callback captured before disposal fails closed. + +The separately published `./invariant` companion checks that every retry record names the current open turn and latest closed step, matches the failed request's durable provider, has a unique step record and correct provider-policy retry number, and carries a valid mode-specific budget and bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. ```yaml -- name: '@deepseek-ai/dsh-llm-retry' +- name: '@deepseek-ai/dsh-llm-deepseek' config: - maxTransientRetries: 2 - initialDelayMs: 500 - maxDelayMs: 10000 - jitterRatio: 0.1 - retryableCodes: [RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT] + apiKey: !!js process.env.DEEPSEEK_API_KEY + retryPolicy: + mode: always + backoff: + initialDelayMs: 1000 + maxDelayMs: 30000 + jitterRatio: 0.2 + +- name: '@deepseek-ai/dsh-llm-retry' ``` +The executor has no policy config. Multi-provider adapters such as `dsh-llm-pi-ai` place `retryPolicy` inside each provider profile, avoiding a second provider-name list. + ## Model Experience -### Transient request recovery +### Model-request recovery #### What the model sees -No retry event, delay, or failure prose is model-visible. After a retry, the next numbered step reconstructs the same explicit provider/model request from durable session history; failed chunks never enter derived messages. +No retry event, delay, provider error, or failed partial output is model-visible. The next numbered step reconstructs the same explicit provider/model request from durable surface history unless a downstream recovery policy deliberately changes that surface. #### Token effect -Each retry is a new provider request and may repeat input-token billing. The finite budget caps attempts; `llm/retry` itself contributes no tokens. +Each retry is a new provider request and may repeat input-token billing. Normal mode has a finite budget; always mode can consume unbounded requests until success or cancellation. `llm/retry` itself contributes no tokens. #### KV Cache effect -The reconstructed request preserves the prior prefix and is eligible for provider cache reuse under that provider's rules. The non-surface status event does not change cache identity. +The reconstructed request preserves the prior prefix and is eligible for provider cache reuse under that provider's rules. The non-surface retry event does not change cache identity. ## Known Limitations and Deferred Work - **Agent steps are the only retry boundary** — direct `ctx.llm.stream()` consumers remain single-attempt because a raw stream cannot separate already-emitted chunks durably. -- **Finite plugin budgets add** — this policy counts only configured transient codes; context-overflow compaction counts only its own code. A future policy with overlapping codes must document and test registration-order behavior. +- **Always mode retries permanent failures** — authentication, quota, invalid-request, protocol, and unrecoverable context errors continue until success, cancellation, or disposal; deployments own provider-specific cost and latency controls. +- **Recovery policies compose by waterfall order** — always mode accepts a downstream retry before applying its fallback. A later policy that never settles also prevents the fallback from running. - **`llm/retry` records scheduling, not completion** — later step and turn events establish success, exhaustion, or cancellation. diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 6d6c27636c..9ee3b5f896 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-llm-retry", - "description": "Bounded transient LLM request retry policy for the DeepSeek Harness", + "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/llm/llm-retry/src/history.ts b/packages/llm/llm-retry/src/history.ts new file mode 100644 index 0000000000..2b86a77e03 --- /dev/null +++ b/packages/llm/llm-retry/src/history.ts @@ -0,0 +1,38 @@ +/** Durable request-route lookup for one closed model step. @module @deepseek-ai/dsh-llm-retry/history */ + +import type { SessionEvent } from '@deepseek-ai/dsh-session' + +/** + * Find the provider in force when one step closed, excluding later recovery mutations. + * A preceding retry is also a route marker because every provider change + * requires a newer full request-header snapshot. + * @param events - session events containing the closed step. + * @param turn - turn that owns the failed step. + * @param step - failed step whose provider is required. + * @returns the provider from the request header in force at that step boundary. + */ +export function providerForClosedStep( + events: readonly SessionEvent[], + turn: number, + step: number, +): string | undefined { + const stepEndIndex = events.findLastIndex(event => + event.type === 'step/end' + && event.data.turn === turn + && event.data.step === step, + ) + if (stepEndIndex < 0) return undefined + for (let index = stepEndIndex; index >= 0; index -= 1) { + // The loop bounds prove this indexed read exists. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const event = events[index]! + if (event.type === 'request/header') return event.data.header.config.provider + if (event.type === 'llm/retry' + && event.data.turn === turn + && event.data.step < step) { + return event.data.provider + } + if (event.type === 'turn/start' || event.type === 'turn/end') return undefined + } + return undefined +} diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 4edf22d6f2..04f1b0996a 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -1,5 +1,5 @@ /** - * Bounded transient model-request retry policy on the agent loop's closed-step + * Provider-routed model-request retry policy on the agent loop's closed-step * recovery seam. Each scheduled retry is durable before its cancellable wait. * * @module @deepseek-ai/dsh-llm-retry @@ -8,103 +8,50 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' -import type { LlmFailure } from '@deepseek-ai/dsh-llm' -import type {} from '@deepseek-ai/dsh-session' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { providerForClosedStep } from './history.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { - /** Durable, non-surface record of one transient retry scheduled after a closed failed step. */ + /** Durable, non-surface record of one provider-routed retry scheduled after a closed failed step. */ 'llm/retry': { turn: number step: number + provider: string + mode: 'normal' retry: number maxRetries: number delayMs: number failure: LlmFailure + } | { + turn: number + step: number + provider: string + mode: 'always' + retry: number + delayMs: number + failure: LlmFailure } } } export const name = 'llm-retry' -export const inject = ['agents'] +export const inject = ['agents', 'llm'] -const DEFAULT_MAX_TRANSIENT_RETRIES = 2 -const DEFAULT_INITIAL_DELAY_MS = 500 -const DEFAULT_MAX_DELAY_MS = 10_000 -const DEFAULT_JITTER_RATIO = 0.1 -const DEFAULT_RETRYABLE_CODES = Object.freeze(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']) - -/** Deployment-owned limits and classification for transient request recovery. */ -export interface Config { - /** Maximum transient retries after the first request (default 2). */ - maxTransientRetries?: number - /** Initial local exponential-backoff delay in milliseconds (default 500). */ - initialDelayMs?: number - /** Maximum accepted or locally scheduled delay in milliseconds (default 10000). */ - maxDelayMs?: number - /** Symmetric random multiplier range around one (default 0.1). */ - jitterRatio?: number - /** Stable failure codes eligible for this policy. */ - retryableCodes?: string[] -} +/** This policy executor has no config; providers own `retryPolicy`. */ +export type Config = Readonly> /** Runtime schema for {@link Config}. */ -export const Config: z = z.object({ - maxTransientRetries: z.number().step(1).min(0).default(DEFAULT_MAX_TRANSIENT_RETRIES), - initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS), - maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS), - jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO), - retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]), -}) +export const Config: z = z.object({}) -interface ResolvedConfig { - readonly maxTransientRetries: number - readonly initialDelayMs: number - readonly maxDelayMs: number - readonly jitterRatio: number - readonly retryableCodes: ReadonlySet -} - -function resolveConfig(config: Config): ResolvedConfig { - const maxTransientRetries = config.maxTransientRetries ?? DEFAULT_MAX_TRANSIENT_RETRIES - const initialDelayMs = config.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS - const maxDelayMs = config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS - const jitterRatio = config.jitterRatio ?? DEFAULT_JITTER_RATIO - const codes = config.retryableCodes ?? [...DEFAULT_RETRYABLE_CODES] - - if (!Number.isInteger(maxTransientRetries) || maxTransientRetries < 0) { - throw new Error('llm-retry: maxTransientRetries must be a non-negative integer') +function validateConfig(config: Config): void { + const [key] = Object.keys(config) + if (key === undefined) return + if (key === 'retryPolicy') { + throw new Error('llm-retry: retryPolicy belongs under each provider configuration') } - if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) { - throw new Error(`llm-retry: initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) - } - if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) { - throw new Error(`llm-retry: maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) - } - if (initialDelayMs > maxDelayMs) { - throw new Error('llm-retry: initialDelayMs must be less than or equal to maxDelayMs') - } - if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) { - throw new Error('llm-retry: jitterRatio must be between 0 and 1') - } - if (codes.length === 0) { - throw new Error('llm-retry: retryableCodes must not be empty') - } - if (codes.some(code => code.length === 0)) { - throw new Error('llm-retry: retryableCodes must contain only non-empty strings') - } - if (new Set(codes).size !== codes.length) { - throw new Error('llm-retry: retryableCodes must not contain duplicates') - } - - return Object.freeze({ - maxTransientRetries, - initialDelayMs, - maxDelayMs, - jitterRatio, - retryableCodes: new Set(codes), - }) + throw new Error(`llm-retry: unknown key "${key}"`) } /** Non-serializable seams used to make timing policy deterministic in tests. */ @@ -113,7 +60,38 @@ export interface RetryInternals { random?: () => number } -function localDelay(config: ResolvedConfig, retry: number, random: () => number): number { +type DownstreamOutcome = + | { readonly type: 'decision'; readonly decision: RequestErrorDecision } + | { readonly type: 'error'; readonly error: unknown } + | { readonly type: 'aborted' } + +function downstreamUntilAbort( + next: () => Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) return Promise.resolve({ type: 'aborted' }) + return new Promise((resolve) => { + const finish = (outcome: DownstreamOutcome): void => { + signal.removeEventListener('abort', onAbort) + resolve(outcome) + } + const onAbort = (): void => { finish({ type: 'aborted' }) } + signal.addEventListener('abort', onAbort, { once: true }) + let downstream: Promise + try { + downstream = next() + } catch (error: unknown) { + finish({ type: 'error', error }) + return + } + void downstream.then( + (decision) => { finish({ type: 'decision', decision }) }, + (error: unknown) => { finish({ type: 'error', error }) }, + ) + }) +} + +function localDelay(config: ResolvedRetryPolicy, retry: number, random: () => number): number { const exponent = Math.min(retry - 1, 1024) const exponential = Math.min(config.initialDelayMs * 2 ** exponent, config.maxDelayMs) const jitter = 1 - config.jitterRatio + 2 * config.jitterRatio * random() @@ -136,13 +114,13 @@ function cancellableDelay(delayMs: number, signal: AbortSignal): Promise>() @@ -152,25 +130,40 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna turn: number, step: number, failure: LlmFailure, + provider: string, + policy: ResolvedRetryPolicy, retry: number, delayMs: number, signal: AbortSignal, ): Promise { const fusedSignal = AbortSignal.any([signal, lifetime.signal]) if (fusedSignal.aborted) return { action: 'fail' } - agent.session.append('llm/retry', { - turn, - step, - retry, - maxRetries: resolved.maxTransientRetries, - delayMs, - failure, - }) + const eventData = policy.mode === 'normal' + ? { + turn, + step, + provider, + mode: policy.mode, + retry, + maxRetries: policy.maxRetries, + delayMs, + failure, + } + : { + turn, + step, + provider, + mode: policy.mode, + retry, + delayMs, + failure, + } + agent.session.append('llm/retry', eventData) if (!await cancellableDelay(delayMs, fusedSignal)) return { action: 'fail' } return { action: 'retry' } } - const disposeListener = ctx.on('agent/request-error', ( + const disposeListener = ctx.on('agent/request-error', async ( agent: Agent, turn: number, step: number, @@ -184,22 +177,61 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna // removed. Lifetime cancellation must prevent that stale callback from // entering a downstream policy after disposal. if (lifetime.signal.aborted) return Promise.resolve({ action: 'fail' }) - if (!resolved.retryableCodes.has(failure.code)) return next() - const priorTransientFailures = priorFailures.filter(item => resolved.retryableCodes.has(item.code)).length - if (priorTransientFailures >= resolved.maxTransientRetries) return next() + // Bind policy to the header in force when this step closed. Downstream + // recovery may append later state before an always fallback runs. + const provider = providerForClosedStep(agent.session.events, turn, step) + /* v8 ignore next 3 -- agent-loop closes only steps whose request header was recorded */ + if (provider === undefined) { + throw new Error(`llm-retry: no request provider for closed turn ${turn}/step ${step}`) + } + const policy = ctx.llm.providerRetryPolicy(provider) - const retry = priorTransientFailures + 1 + if (policy.mode === 'always') { + const downstream = await downstreamUntilAbort( + next, + AbortSignal.any([signal, lifetime.signal]), + ) + if (downstream.type === 'aborted') return { action: 'fail' } + if (downstream.type === 'error') { + ctx.logger.warn( + `llm-retry: provider "${provider}" always policy ignored a downstream recovery failure: %o`, + downstream.error, + ) + } + if (downstream.type === 'decision' && downstream.decision.action === 'retry') { + return downstream.decision + } + } else if (!policy.retryableCodes.includes(failure.code)) { + return next() + } + + const firstPriorStep = step - priorFailures.length + const priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> => + event.type === 'llm/retry' + && event.data.turn === turn + && event.data.step >= firstPriorStep + && event.data.step < step + && event.data.provider === provider + && event.data.mode === policy.mode, + ) + const previousRetry = priorPolicyRetry?.data.retry ?? 0 + if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next() + const retry = previousRetry + 1 let delayMs: number if (failure.providerRetryAfterMs !== undefined && Number.isFinite(failure.providerRetryAfterMs) && failure.providerRetryAfterMs > 0) { - if (failure.providerRetryAfterMs > resolved.maxDelayMs) return next() - delayMs = failure.providerRetryAfterMs + if (failure.providerRetryAfterMs > policy.maxDelayMs) { + if (policy.mode === 'normal') return next() + delayMs = localDelay(policy, retry, random) + } else { + delayMs = failure.providerRetryAfterMs + } } else { - delayMs = localDelay(resolved, retry, random) + delayMs = localDelay(policy, retry, random) } - const tracked = backoff(agent, turn, step, failure, retry, delayMs, signal) + const tracked = backoff(agent, turn, step, failure, provider, policy, retry, delayMs, signal) .finally(() => active.delete(tracked)) active.add(tracked) return tracked diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index 784f459606..0cabbd6365 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -4,6 +4,7 @@ import type { Context } from 'cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { providerForClosedStep } from './history.ts' import type {} from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-retry' @@ -19,34 +20,46 @@ function validateRetry( event: SessionEvent<'llm/retry'>, fail: InvariantFailure, ): void { - const { turn, step, retry, maxRetries, delayMs } = event.data + const { turn, step, provider, mode, retry, delayMs } = event.data if (!Number.isSafeInteger(retry) || retry < 1) { fail('llm/retry retry must be a positive safe integer') } - if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) { - fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`) + if (typeof provider !== 'string' || provider.length === 0) { + fail('llm/retry provider must be non-empty string') } - if (!(delayMs >= 0 && delayMs <= MAX_TIMER_DELAY_MS)) { - fail(`llm/retry delayMs must be within 0..${MAX_TIMER_DELAY_MS}`) - } - - const currentTurnEvents: SessionEvent[] = [] - let openTurn: number | undefined - for (const prior of history.slice().reverse()) { - if (prior.type === 'turn/end') fail('llm/retry must be appended inside an open turn') - if (prior.type === 'turn/start') { - openTurn = prior.data.turn + switch (mode) { + case 'normal': { + const { maxRetries } = event.data + if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) { + fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`) + } break } - currentTurnEvents.push(prior) + case 'always': + if ('maxRetries' in event.data) fail('llm/retry always mode must omit maxRetries') + break + default: + fail(`llm/retry mode must be normal or always, got ${String(mode)}`) } - if (openTurn === undefined) fail('llm/retry must be appended inside an open turn') + if (typeof delayMs !== 'number' || !Number.isFinite(delayMs) + || delayMs < 0 || delayMs > MAX_TIMER_DELAY_MS) { + fail(`llm/retry delayMs must be a finite number within 0..${MAX_TIMER_DELAY_MS}`) + } + + const turnStartIndex = history.findLastIndex(prior => + prior.type === 'turn/start' || prior.type === 'turn/end') + const turnBoundary = history[turnStartIndex] + if (turnBoundary?.type !== 'turn/start') { + fail('llm/retry must be appended inside an open turn') + } + const openTurn = turnBoundary.data.turn if (turn !== openTurn) { fail(`llm/retry names turn ${turn}, but the open turn is ${openTurn}`) } + const currentTurnEvents = history.slice(turnStartIndex + 1) let closedStep: number | undefined - for (const prior of currentTurnEvents) { + for (const prior of currentTurnEvents.slice().reverse()) { if (prior.type === 'step/start') { fail(`llm/retry must follow step/end, but step ${prior.data.step} is still open`) } @@ -58,15 +71,26 @@ function validateRetry( if (closedStep === undefined || step !== closedStep) { fail(`llm/retry names step ${step}, but the latest closed step is ${String(closedStep)}`) } + const routedProvider = providerForClosedStep(history, turn, step) + if (routedProvider !== provider) { + fail(`llm/retry provider ${provider} does not match the failed request provider ${String(routedProvider)}`) + } const priorRetries = currentTurnEvents .filter((prior): prior is SessionEvent<'llm/retry'> => prior.type === 'llm/retry') if (priorRetries.some(prior => prior.data.step === step)) { fail(`llm/retry duplicates the retry record for turn ${turn}/step ${step}`) } - const priorRetry = priorRetries[0] - if (priorRetry !== undefined && retry <= priorRetry.data.retry) { - fail(`llm/retry retry ${retry} must increase after retry ${priorRetry.data.retry}`) + const lastSuccessIndex = currentTurnEvents.findLastIndex(prior => prior.type === 'assistant/message') + const priorPolicyRetry = currentTurnEvents.findLast((prior, index): prior is SessionEvent<'llm/retry'> => ( + index > lastSuccessIndex + && prior.type === 'llm/retry' + && prior.data.provider === provider + && prior.data.mode === mode + )) + const expectedRetry = (priorPolicyRetry?.data.retry ?? 0) + 1 + if (retry !== expectedRetry) { + fail(`llm/retry retry ${retry} must equal provider policy retry ${expectedRetry}`) } } diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 7f9bc6b061..c9c5d2c564 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -4,6 +4,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import InvariantService from '@deepseek-ai/dsh-invariants' import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' +import { providerForClosedStep } from '../src/history.ts' async function setup(): Promise { const ctx = new Context() @@ -17,33 +18,116 @@ function closeStep(ctx: Context, id: string, turn = 1, step = 1) { const session = ctx.sessions.create(SessionId(id)) session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn, step }) + session.append('request/header', { + header: { config: { provider: 'mock', model: 'mock' } }, + reason: 'initial', + }) session.append('step/end', { turn, step }) return session } const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 } +const normal = { provider: 'mock', mode: 'normal' as const } describe('llm-retry invariants', () => { + it('has no provider without the requested closed step', () => { + expect(providerForClosedStep([], 1, 1)).toBeUndefined() + expect(providerForClosedStep([{ + type: 'step/end', + data: { turn: 1, step: 1 }, + }] as never, 1, 1)).toBeUndefined() + }) + + it('does not inherit a provider across a turn boundary', () => { + expect(providerForClosedStep([ + { type: 'turn/start', data: { turn: 1 } }, + { + type: 'request/header', + data: { header: { config: { provider: 'prior' } } }, + }, + { type: 'turn/end', data: { turn: 1 } }, + { type: 'turn/start', data: { turn: 2 } }, + { type: 'step/end', data: { turn: 2, step: 1 } }, + ] as never, 2, 1)).toBeUndefined() + }) + it('accepts increasing retry records for successive closed steps and ignores unrelated events', async () => { const ctx = await setup() const session = closeStep(ctx, 'retry-invariant-valid') expect(() => { session.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 500, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 500, failure, }) session.append('step/start', { turn: 1, step: 2 }) session.append('step/end', { turn: 1, step: 2 }) session.append('llm/retry', { - turn: 1, step: 2, retry: 2, maxRetries: 2, delayMs: 1_000, failure, + turn: 1, step: 2, ...normal, retry: 2, maxRetries: 2, delayMs: 1_000, failure, }) const zeroDelay = closeStep(ctx, 'retry-invariant-zero-delay') zeroDelay.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 1, delayMs: 0, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 1, delayMs: 0, failure, }) }).not.toThrow() expect(() => { ctx.emit('tools/change') }).not.toThrow() }) + it('accepts unbounded always records without serializing an infinite maximum', async () => { + const ctx = await setup() + const session = closeStep(ctx, 'retry-invariant-always') + expect(() => { + session.append('llm/retry', { + turn: 1, + step: 1, + provider: 'mock', + mode: 'always', + retry: 1, + delayMs: 500, + failure, + }) + }).not.toThrow() + expect(() => { + session.append('llm/retry', { + turn: 1, + step: 1, + provider: 'mock', + mode: 'always', + retry: 1, + maxRetries: 2, + delayMs: 500, + failure, + } as never) + }).toThrow(/always mode must omit maxRetries/) + }) + + it('rejects empty providers and unknown modes from hostile durable input', async () => { + const ctx = await setup() + const emptyProvider = closeStep(ctx, 'retry-invariant-empty-provider') + expect(() => { + emptyProvider.append('llm/retry', { + turn: 1, + step: 1, + provider: '', + mode: 'always', + retry: 1, + delayMs: 1, + failure, + }) + }).toThrow(/provider must be non-empty/) + + const unknownMode = closeStep(ctx, 'retry-invariant-unknown-mode') + expect(() => { + unknownMode.append('llm/retry', { + turn: 1, + step: 1, + provider: 'mock', + mode: 'sometimes', + retry: 1, + delayMs: 1, + failure, + } as never) + }).toThrow(/mode must be normal or always/) + }) + it.each([ [{ retry: 0, maxRetries: 2, delayMs: 1 }, /positive safe integer/], [{ retry: 1.5, maxRetries: 2, delayMs: 1 }, /positive safe integer/], @@ -56,7 +140,7 @@ describe('llm-retry invariants', () => { const ctx = await setup() const session = closeStep(ctx, `retry-invariant-bounds-${data.retry}-${data.maxRetries}-${data.delayMs}`) expect(() => { - session.append('llm/retry', { turn: 1, step: 1, ...data, failure }) + session.append('llm/retry', { turn: 1, step: 1, ...normal, ...data, failure }) }).toThrow(message) }) @@ -65,14 +149,14 @@ describe('llm-retry invariants', () => { const absent = ctx.sessions.create(SessionId('retry-invariant-no-turn')) expect(() => { absent.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, }) }).toThrow(/inside an open turn/) const wrongTurn = closeStep(ctx, 'retry-invariant-wrong-turn') expect(() => { wrongTurn.append('llm/retry', { - turn: 2, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + turn: 2, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, }) }).toThrow(/open turn is 1/) @@ -81,7 +165,7 @@ describe('llm-retry invariants', () => { openStep.append('step/start', { turn: 1, step: 1 }) expect(() => { openStep.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, }) }).toThrow(/step 1 is still open/) @@ -89,14 +173,14 @@ describe('llm-retry invariants', () => { noStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) expect(() => { noStep.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, }) }).toThrow(/latest closed step is undefined/) const wrongStep = closeStep(ctx, 'retry-invariant-wrong-step') expect(() => { wrongStep.append('llm/retry', { - turn: 1, step: 2, retry: 1, maxRetries: 2, delayMs: 1, failure, + turn: 1, step: 2, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, }) }).toThrow(/latest closed step is 1/) @@ -104,34 +188,97 @@ describe('llm-retry invariants', () => { closedTurn.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) expect(() => { closedTurn.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, }) }).toThrow(/inside an open turn/) }) + it('binds the policy provider to the failed step rather than a later header', async () => { + const ctx = await setup() + const session = closeStep(ctx, 'retry-invariant-provider') + session.append('request/header', { + header: { config: { provider: 'other', model: 'mock' } }, + reason: 'change', + }) + expect(() => { + session.append('llm/retry', { + turn: 1, + step: 1, + provider: 'mock', + mode: 'always', + retry: 1, + delayMs: 1, + failure, + }) + }).not.toThrow() + + const mismatch = closeStep(ctx, 'retry-invariant-provider-mismatch') + expect(() => { + mismatch.append('llm/retry', { + turn: 1, + step: 1, + provider: 'other', + mode: 'always', + retry: 1, + delayMs: 1, + failure, + }) + }).toThrow(/does not match the failed request provider mock/) + }) + + it('rejects a current-turn retry without a current-turn provider route', async () => { + const ctx = await setup() + const session = closeStep(ctx, 'retry-invariant-prior-route') + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 2, step: 1 }) + session.append('step/end', { turn: 2, step: 1 }) + expect(() => { + session.append('llm/retry', { + turn: 2, + step: 1, + provider: 'mock', + mode: 'always', + retry: 1, + delayMs: 1, + failure, + }) + }).toThrow(/does not match the failed request provider undefined/) + }) + + it('rejects non-numeric durable delays', async () => { + const ctx = await setup() + const session = closeStep(ctx, 'retry-invariant-delay-type') + expect(() => { + session.append('llm/retry', { + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: '1', failure, + } as never) + }).toThrow(/delayMs must be a finite number/) + }) + it('rejects duplicate and non-increasing retry records', async () => { const ctx = await setup() const duplicate = closeStep(ctx, 'retry-invariant-duplicate') duplicate.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 3, delayMs: 1, failure, }) expect(() => { duplicate.append('llm/retry', { - turn: 1, step: 1, retry: 2, maxRetries: 3, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 2, maxRetries: 3, delayMs: 1, failure, }) }).toThrow(/duplicates the retry record/) const nonIncreasing = closeStep(ctx, 'retry-invariant-non-increasing') nonIncreasing.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 3, delayMs: 1, failure, }) nonIncreasing.append('step/start', { turn: 1, step: 2 }) nonIncreasing.append('step/end', { turn: 1, step: 2 }) expect(() => { nonIncreasing.append('llm/retry', { - turn: 1, step: 2, retry: 1, maxRetries: 3, delayMs: 1, failure, + turn: 1, step: 2, ...normal, retry: 1, maxRetries: 3, delayMs: 1, failure, }) - }).toThrow(/must increase/) + }).toThrow(/must equal provider policy retry 2/) }) it('validates existing histories on late registration', async () => { @@ -140,7 +287,7 @@ describe('llm-retry invariants', () => { const session = ctx.sessions.create(SessionId('retry-invariant-late')) session.append('step/end', { turn: 1, step: 1 }) session.append('llm/retry', { - turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + turn: 1, step: 1, ...normal, retry: 1, maxRetries: 2, delayMs: 1, failure, }) await ctx.plugin(InvariantService) await expect(ctx.plugin(RetryInvariant)).rejects.toThrow(/inside an open turn/) diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 1aafc91d92..3ecc5985fe 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -9,8 +9,8 @@ import Include from '@cordisjs/plugin-include' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import LlmService, { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -21,6 +21,16 @@ let context: Context | undefined class TransientOnceAdapter extends LlmAdapter { requests = 0 + private readonly retryPolicy = resolveRetryPolicy({ + mode: 'normal', + maxRetries: 1, + retryableCodes: ['RATE_LIMIT', 'SERVER'], + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }, 'loader test provider retryPolicy') + + override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { + return this.retryPolicy + } async * stream(_options: GenerateOptions): AsyncIterable { this.requests += 1 @@ -87,7 +97,7 @@ describe('real Loader composition', () => { // Real-Loader composition resolves workspace packages through tsx at test // time; first resolution after the host/client program split is slow enough // to trip the default 5s budget on cold caches. - it('loads the flat policy and records recovery through the shipping loop', { timeout: 60_000 }, async () => { + it('loads provider-supplied policy and records recovery through the shipping loop', { timeout: 60_000 }, async () => { const loaded = await loadYaml([ "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-session'", @@ -95,12 +105,6 @@ describe('real Loader composition', () => { "- name: '@deepseek-ai/dsh-tools'", "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-llm-retry'", - ' config:', - ' maxTransientRetries: 1', - ' initialDelayMs: 1', - ' maxDelayMs: 1', - ' jitterRatio: 0', - ' retryableCodes: [RATE_LIMIT, SERVER]', "- name: '@deepseek-ai/dsh-agent-loop'", ]) diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 1668c36d73..41c70b257d 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -34,12 +34,17 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) const session = ctx.sessions.create(SessionId(`retry-${kind}`)) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) + session.append('request/header', { + header: { config: { provider: 'mock', model: 'mock' } }, + reason: 'initial', + }) session.append('step/end', { turn: 1, step: 1 }) const event = session.append('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'always', retry: 1, - maxRetries: 2, delayMs: 750, failure: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }, }) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 4dc1c06bd6..238385baf5 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -1,8 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Fiber } from 'cordis' -import LlmService, { CallId, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { + AlwaysRetryPolicyConfig, + BackoffConfig, + GenerateOptions, + NormalRetryPolicyConfig, + ResolvedRetryPolicy, + RetryPolicyConfig, + StreamChunk, +} from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -10,13 +18,13 @@ import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import * as retry from '../src/index.ts' type ScriptEntry = Error | Iterable | AsyncIterable class ScriptedAdapter extends LlmAdapter { readonly requests: GenerateOptions[] = [] + private retryPolicies: Readonly> = {} constructor(private readonly entries: ScriptEntry[]) { super() @@ -29,6 +37,21 @@ class ScriptedAdapter extends LlmAdapter { if (entry instanceof Error) throw entry yield* entry } + + configureRetryPolicies( + policies: Readonly>, + ): void { + this.retryPolicies = Object.fromEntries(Object.entries(policies).map(([provider, policy]) => [ + provider, + policy === undefined + ? undefined + : resolveRetryPolicy(policy, `retry test provider "${provider}" retryPolicy`), + ])) + } + + override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { + return this.retryPolicies[provider] + } } async function* partialToolFailure(error: Error): AsyncGenerator { @@ -52,8 +75,8 @@ function textResponse(text: string): StreamChunk[] { } async function harness( - adapter: LlmAdapter, - config: retry.Config = {}, + adapter: ScriptedAdapter, + policies: Readonly> = { mock: normalConfig() }, beforeRetry?: (ctx: Context) => void, internals: retry.RetryInternals = {}, ): Promise<{ ctx: Context; retryFiber: Fiber }> { @@ -64,20 +87,44 @@ async function harness( await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) beforeRetry?.(ctx) - const resolvedConfig = Object.assign({ - maxTransientRetries: 2, - initialDelayMs: 500, - maxDelayMs: 10_000, - jitterRatio: 0, - }, config) + adapter.configureRetryPolicies(policies) const retryFiber = await ctx.plugin(Object.assign((inner: Context) => { - retry.apply(inner, resolvedConfig, internals) + retry.apply(inner, {}, internals) }, { inject: retry.inject })) await ctx.plugin(AgentLoop, { agents: [] }) - ctx.llm.registerAdapter(['mock'], adapter) + ctx.llm.registerAdapter(['mock', 'other'], adapter) return { ctx, retryFiber } } +function normalConfig( + overrides: Partial> = {}, +): NormalRetryPolicyConfig { + const { backoff, ...policy } = overrides + return { + mode: 'normal', + maxRetries: 2, + ...policy, + backoff: { + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0, + ...backoff, + }, + } +} + +function alwaysConfig(backoff: BackoffConfig = {}): AlwaysRetryPolicyConfig { + return { + mode: 'always', + backoff: { + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0, + ...backoff, + }, + } +} + function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { @@ -108,14 +155,14 @@ afterEach(async () => { context = undefined }) -describe('bounded transient retry policy', () => { +describe('provider-routed retry policy', () => { it('records the scheduled delay before opening a fresh request attempt', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ new LlmError('busy', 'RATE_LIMIT', { status: 429 }), textResponse('done'), ]) - ;({ ctx: context } = await harness(adapter)) + ;({ ctx: context } = await harness(adapter, {}, undefined, { random: () => 0.5 })) const agent = context.agentLoop.create(SessionId('retry-success'), { provider: 'mock', model: 'mock', @@ -135,6 +182,8 @@ describe('bounded transient retry policy', () => { expect(event.data).toEqual({ turn: 1, step: 1, + provider: 'mock', + mode: 'normal', retry: 1, maxRetries: 2, delayMs: 500, @@ -207,7 +256,9 @@ describe('bounded transient retry policy', () => { new LlmError('busy two', 'SERVER'), new LlmError('busy three', 'SERVER'), ]) - ;({ ctx: context } = await harness(adapter, { jitterRatio: 0.1 }, undefined, { + ;({ ctx: context } = await harness(adapter, { mock: normalConfig({ + backoff: { jitterRatio: 0.1 }, + }) }, undefined, { random: () => samples.shift() ?? 0.5, })) const agent = context.agentLoop.create(SessionId('retry-exhausted'), { provider: 'mock', model: 'mock' }) @@ -238,11 +289,9 @@ describe('bounded transient retry policy', () => { new LlmError('busy', 'SERVER'), textResponse('done'), ]) - ;({ ctx: context } = await harness(adapter, { - initialDelayMs: 1, - maxDelayMs: 1, - jitterRatio: 1, - }, undefined, { random: () => 0 })) + ;({ ctx: context } = await harness(adapter, { mock: normalConfig({ + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 1 }, + }) }, undefined, { random: () => 0 })) const agent = context.agentLoop.create(SessionId('retry-zero-delay'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) @@ -261,7 +310,9 @@ describe('bounded transient retry policy', () => { new LlmError('wait', 'RATE_LIMIT', { providerRetryAfterMs: 2_000 }), textResponse('done'), ]) - ;({ ctx: context } = await harness(accepted, { jitterRatio: 1 })) + ;({ ctx: context } = await harness(accepted, { mock: normalConfig({ + backoff: { jitterRatio: 1 }, + }) })) const acceptedAgent = context.agentLoop.create(SessionId('retry-after-accepted'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, acceptedAgent, 1) acceptedAgent.send([{ type: 'text', text: 'go' }]) @@ -284,6 +335,32 @@ describe('bounded transient retry policy', () => { expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) }) + it('uses local jittered backoff when always mode receives an over-cap Retry-After', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('wait too long', 'AUTH', { providerRetryAfterMs: 10 }), + textResponse('done'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig({ + initialDelayMs: 2, + maxDelayMs: 4, + jitterRatio: 0.5, + }) }, undefined, { random: () => 1 })) + const agent = context.agentLoop.create(SessionId('retry-always-over-cap'), { + provider: 'mock', + model: 'mock', + }) + const scheduled = waitForRetry(context, agent, 1) + + agent.send([{ type: 'text', text: 'go' }]) + expect((await scheduled).data.delayMs).toBe(3) + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(3) + await idle + + expect(adapter.requests).toHaveLength(2) + }) + it('delegates non-transient failures without scheduling a timer', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) @@ -297,13 +374,203 @@ describe('bounded transient retry policy', () => { expect(vi.getTimerCount()).toBe(0) }) + it('selects policy by the failed request provider', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('mock auth failed', 'AUTH'), + new LlmError('other auth failed', 'AUTH'), + textResponse('other recovered'), + ]) + ;({ ctx: context } = await harness(adapter, { + other: alwaysConfig({ initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }), + })) + + const normalAgent = context.agentLoop.create(SessionId('retry-provider-normal'), { + provider: 'mock', + model: 'mock', + }) + const normalIdle = waitForIdle(context, normalAgent) + normalAgent.send([{ type: 'text', text: 'normal' }]) + await normalIdle + expect(normalAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + + const alwaysAgent = context.agentLoop.create(SessionId('retry-provider-always'), { + provider: 'other', + model: 'mock', + }) + const scheduled = waitForRetry(context, alwaysAgent, 1) + alwaysAgent.send([{ type: 'text', text: 'always' }]) + expect((await scheduled).data).toMatchObject({ + provider: 'other', + mode: 'always', + retry: 1, + delayMs: 1, + }) + const alwaysIdle = waitForIdle(context, alwaysAgent) + await vi.advanceTimersByTimeAsync(1) + await alwaysIdle + + expect(adapter.requests.map(request => request.provider)).toEqual(['mock', 'other', 'other']) + }) + + it('selects an always policy from the provider chosen by agent/request', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('rerouted auth failed', 'AUTH'), + textResponse('rerouted recovery'), + ]) + ;({ ctx: context } = await harness(adapter, { + other: alwaysConfig({ initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }), + }, (ctx) => { + ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ + ...config, + provider: 'other', + })) + })) + const agent = context.agentLoop.create(SessionId('retry-provider-rerouted'), { + provider: 'mock', + model: 'mock', + }) + const scheduled = waitForRetry(context, agent, 1) + + agent.send([{ type: 'text', text: 'reroute' }]) + expect((await scheduled).data).toMatchObject({ provider: 'other', mode: 'always' }) + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(1) + await idle + + expect(adapter.requests.map(request => request.provider)).toEqual(['other', 'other']) + }) + + it('keeps always mode unbounded while preserving cancellable jittered backoff', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('auth one', 'AUTH'), + new LlmError('auth two', 'AUTH'), + new LlmError('auth three', 'AUTH'), + new LlmError('auth four', 'AUTH'), + textResponse('eventually recovered'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig({ + initialDelayMs: 1, + maxDelayMs: 4, + jitterRatio: 0.1, + }) }, undefined, { random: () => 1 })) + const agent = context.agentLoop.create(SessionId('retry-always-unbounded'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + + agent.send([{ type: 'text', text: 'keep trying' }]) + await vi.runAllTimersAsync() + await idle + + const events = agent.session.events.filter(event => event.type === 'llm/retry') + expect(adapter.requests).toHaveLength(5) + expect(events.map(event => ({ + provider: event.data.provider, + mode: event.data.mode, + retry: event.data.retry, + delayMs: event.data.delayMs, + hasMax: 'maxRetries' in event.data, + }))).toEqual([ + { provider: 'mock', mode: 'always', retry: 1, delayMs: 1.1, hasMax: false }, + { provider: 'mock', mode: 'always', retry: 2, delayMs: 2.2, hasMax: false }, + { provider: 'mock', mode: 'always', retry: 3, delayMs: 4, hasMax: false }, + { provider: 'mock', mode: 'always', retry: 4, delayMs: 4, hasMax: false }, + ]) + }) + + it('keeps failed error text and partial output out of every retried model context', async () => { + vi.useFakeTimers() + const diagnostic = 'private provider diagnostic must not enter context' + const adapter = new ScriptedAdapter([ + partialToolFailure(new LlmError(diagnostic, 'AUTH')), + textResponse('recovered without leaked context'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig({ + initialDelayMs: 1, + maxDelayMs: 1, + }) })) + const agent = context.agentLoop.create(SessionId('retry-always-context-isolation'), { + provider: 'mock', + model: 'mock', + }) + const scheduled = waitForRetry(context, agent, 1) + + agent.send([{ type: 'text', text: 'safe input' }]) + await scheduled + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(1) + await idle + + expect(adapter.requests).toHaveLength(2) + expect(adapter.requests[1]?.messages).toEqual(adapter.requests[0]?.messages) + const retriedContext = JSON.stringify(adapter.requests[1]?.messages) + expect(retriedContext).not.toContain(diagnostic) + expect(retriedContext).not.toContain('discarded partial output') + expect(agent.session.events.some(event => + event.type === 'llm/retry' && event.data.failure.message === diagnostic, + )).toBe(true) + }) + + it('lets downstream specialized recovery run before always fallback', async () => { + const adapter = new ScriptedAdapter([ + new LlmError('requires specialized recovery', 'AUTH'), + textResponse('specialized recovery won'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig() })) + context.on('agent/request-error', async () => ({ action: 'retry' })) + const agent = context.agentLoop.create(SessionId('retry-always-composition'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + + agent.send([{ type: 'text', text: 'recover' }]) + await idle + + expect(adapter.requests).toHaveLength(2) + expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + }) + + it.each([ + ['synchronously', () => { throw new Error('downstream recovery failed') }], + ['asynchronously', async () => { throw new Error('downstream recovery failed') }], + ])('falls back to always retry when downstream recovery throws %s', async (_kind, failDownstream) => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('requires fallback', 'AUTH'), + textResponse('always recovered'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig({ + initialDelayMs: 1, + maxDelayMs: 1, + }) })) + context.on('agent/request-error', failDownstream) + const agent = context.agentLoop.create(SessionId('retry-always-downstream-error'), { + provider: 'mock', + model: 'mock', + }) + const scheduled = waitForRetry(context, agent, 1) + + agent.send([{ type: 'text', text: 'recover' }]) + await scheduled + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(1) + await idle + + expect(adapter.requests).toHaveLength(2) + }) + it('aborts and drains a captured backoff before plugin disposal completes', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ new LlmError('temporary', 'TRANSPORT'), textResponse('must not run'), ]) - const mounted = await harness(adapter) + const mounted = await harness(adapter, { mock: alwaysConfig() }) context = mounted.ctx const agent = context.agentLoop.create(SessionId('retry-hmr'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) @@ -322,7 +589,7 @@ describe('bounded transient retry policy', () => { it('does not make plugin disposal wait for a delegated recovery policy', async () => { const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) - const mounted = await harness(adapter) + const mounted = await harness(adapter, { mock: alwaysConfig() }) context = mounted.ctx const downstream = Promise.withResolvers() const entered = Promise.withResolvers() @@ -353,6 +620,61 @@ describe('bounded transient retry policy', () => { expect(adapter.requests).toHaveLength(1) }) + it('lets turn cancellation interrupt a delegated recovery policy', async () => { + const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) + const mounted = await harness(adapter, { mock: alwaysConfig() }) + context = mounted.ctx + const downstream = Promise.withResolvers() + const entered = Promise.withResolvers() + context.on('agent/request-error', () => { + entered.resolve(undefined) + return downstream.promise + }) + const agent = context.agentLoop.create(SessionId('retry-delegated-cancel'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + agent.send([{ type: 'text', text: 'go' }]) + await entered.promise + + agent.cancel({ kind: 'user' }) + await idle + downstream.resolve({ action: 'fail' }) + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'aborted' } }, + }) + }) + + it('handles synchronous cancellation while entering delegated recovery', async () => { + const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) + const mounted = await harness(adapter, { mock: alwaysConfig() }) + context = mounted.ctx + const downstream = Promise.withResolvers() + context.on('agent/request-error', (agent) => { + agent.cancel({ kind: 'user' }) + return downstream.promise + }) + const agent = context.agentLoop.create(SessionId('retry-delegated-sync-cancel'), { + provider: 'mock', + model: 'mock', + }) + const idle = waitForIdle(context, agent) + + agent.send([{ type: 'text', text: 'go' }]) + await idle + downstream.resolve({ action: 'fail' }) + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'aborted' } }, + }) + }) + it('fails a captured callback after disposal without entering downstream policy', async () => { const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')]) const captured = Promise.withResolvers() @@ -391,10 +713,10 @@ describe('bounded transient retry policy', () => { it('lets turn cancellation win during backoff without opening another step', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ - new LlmError('temporary', 'TIMEOUT'), + new LlmError('permanent', 'AUTH'), textResponse('must not run'), ]) - ;({ ctx: context } = await harness(adapter)) + ;({ ctx: context } = await harness(adapter, { mock: alwaysConfig() })) const agent = context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' }) const scheduled = waitForRetry(context, agent, 1) agent.send([{ type: 'text', text: 'go' }]) @@ -411,13 +733,16 @@ describe('bounded transient retry policy', () => { expect(vi.getTimerCount()).toBe(0) }) - it('lets an earlier recovery listener cancel before retry policy runs', async () => { + it.each([ + ['normal', normalConfig()], + ['always', alwaysConfig()], + ])('lets an earlier recovery listener cancel before %s retry policy runs', async (_mode, policy) => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ new LlmError('temporary', 'SERVER'), textResponse('must not run'), ]) - ;({ ctx: context } = await harness(adapter, {}, (ctx) => { + ;({ ctx: context } = await harness(adapter, { mock: policy }, (ctx) => { ctx.on('agent/request-error', async (agent, _turn, _step, _error, _failure, _history, _signal, next) => { agent.cancel({ kind: 'user' }) return next() @@ -458,19 +783,15 @@ describe('bounded transient retry policy', () => { expect(vi.getTimerCount()).toBe(0) }) - it.each([ - [{ maxTransientRetries: -1 }, /maxTransientRetries/], - [{ maxTransientRetries: 1.5 }, /maxTransientRetries/], - [{ initialDelayMs: 0 }, /initialDelayMs/], - [{ maxDelayMs: Number.POSITIVE_INFINITY }, /maxDelayMs/], - [{ initialDelayMs: MAX_TIMER_DELAY_MS + 1 }, /initialDelayMs/], - [{ maxDelayMs: MAX_TIMER_DELAY_MS + 1 }, /maxDelayMs/], - [{ initialDelayMs: 20, maxDelayMs: 10 }, /less than or equal/], - [{ jitterRatio: 1.1 }, /jitterRatio/], - [{ retryableCodes: [] }, /must not be empty/], - [{ retryableCodes: ['SERVER', 'SERVER'] }, /duplicates/], - [{ retryableCodes: [''] }, /non-empty strings/], - ] as const)('fails direct composition for invalid config %#', (config, message) => { - expect(() => { retry.apply(new Context(), config as retry.Config) }).toThrow(message) + it('rejects retry policy configured on the executor instead of a provider', () => { + expect(() => { + retry.apply(new Context(), { retryPolicy: { mode: 'always' } }) + }).toThrow(/retryPolicy belongs under each provider/) + }) + + it('rejects unknown executor config', () => { + expect(() => { + retry.apply(new Context(), { retryPolciy: {} }) + }).toThrow(/unknown key "retryPolciy"/) }) }) diff --git a/packages/llm/llm-retry/tsdown.config.ts b/packages/llm/llm-retry/tsdown.config.ts new file mode 100644 index 0000000000..ab8dc26ee8 --- /dev/null +++ b/packages/llm/llm-retry/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown' + +/** Build the package root and invariant companion as independent bundles. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, + { + entry: ['lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +]) diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index beac6d5e8e..74dafa3e48 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -10,13 +10,14 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. - `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order. +- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. - `ctx.llm.resolveModelContext(provider: string, model: string): Promise` Resolve authoritative context capacity for one exact route from its owning adapter. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. `LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. -Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. +Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. Context capacity is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelContext()` asks the adapter that owns the exact provider/model route; an adapter can describe an unlisted dynamic model, and `undefined` means only that capacity is unavailable. Invalid returned capacity fails with `INVALID_MODEL_CONTEXT`. @@ -28,7 +29,7 @@ Context capacity is a separate correctness query, not a catalog decoration or gl ### Extension points -- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, and `resolveModelContext()` when exact capacity is known; the defaults use the route id as its name, advertise no models, and return no capacity. +- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerRetryPolicy()` to supply provider-owned recovery configuration, `providerInfo()` and asynchronous `listModels()` to expose selector metadata, and `resolveModelContext()` when exact capacity is known; the defaults use bounded normal retry policy, use the route id as its name, advertise no models, and return no capacity. - Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead. ### Content-block vocabulary (`types.ts`) @@ -69,7 +70,7 @@ Pass-through; the registry preserves the assembled request prefix, while the sel ## Known Limitations and Deferred Work -- **No default retry/caching/rate-limit policy ships in this service** — `llm/stream` remains a single-attempt call-wrapper seam; the agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure. `@deepseek-ai/dsh-llm-retry` is an optional policy plugin loaded by the shared example spine. +- **No retry execution, caching, or rate limiting ships in this service** — provider registration stores retry policy, but `llm/stream` remains a single-attempt call-wrapper seam. The agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure; `@deepseek-ai/dsh-llm-retry` is the optional executor loaded by the shared example spine. - **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.md)). - **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)). - **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw. diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index cc13bc183e..d5b0298e50 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -38,11 +38,16 @@ "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "dependencies": { + "schemastery": "^3.18.0" + }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 6765833e8c..773b9a5ca1 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -16,6 +16,8 @@ import type { Message, StreamChunk, } from './types.ts' +import { resolveRetryPolicy } from './retry-policy.ts' +import type { ResolvedRetryPolicy } from './retry-policy.ts' import type { ProviderRequestId } from './brand.ts' import { deepFreeze } from './call-config.ts' import { HarnessError } from './error.ts' @@ -27,6 +29,7 @@ export * from './brand.ts' export * from './never.ts' export * from './error.ts' export * from './types.ts' +export * from './retry-policy.ts' export { BlockAssembler } from './assembler.ts' export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts' export type { LlmCallConfig } from './call-config.ts' @@ -119,6 +122,15 @@ export abstract class LlmAdapter { return { id: provider, name: provider } } + /** + * Return the provider-owned retry policy captured with this route. + * @param _provider - a route passed to `registerAdapter()` for this instance. + * @returns a resolved policy, or `undefined` to use the normal defaults. + */ + providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined { + return undefined + } + /** * List models this adapter can currently advertise for one owned provider. * The result is advisory: an adapter may accept unlisted model ids, and @@ -157,7 +169,11 @@ export abstract class LlmAdapter { * surface, interceptable via the `llm/stream` waterfall. */ export class LlmService extends Service { - private adapters = new Map() + private adapters = new Map() constructor(ctx: Context) { super(ctx, 'llm') @@ -175,7 +191,11 @@ export class LlmService extends Service { const dispose = this.ctx.effect(function* (this: LlmService) { if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER') const unique = new Set() - const registrations: { adapter: LlmAdapter; provider: LlmProviderInfo }[] = [] + const registrations: { + adapter: LlmAdapter + provider: LlmProviderInfo + retryPolicy: ResolvedRetryPolicy + }[] = [] for (const provider of providers) { if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER') if (unique.has(provider) || this.adapters.has(provider)) { @@ -186,7 +206,13 @@ export class LlmService extends Service { throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER') } unique.add(provider) - registrations.push({ adapter, provider: { id: info.id, name: info.name } }) + const retryPolicy = adapter.providerRetryPolicy(provider) + ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`) + registrations.push({ + adapter, + provider: { id: info.id, name: info.name }, + retryPolicy, + }) } for (const registration of registrations) this.adapters.set(registration.provider.id, registration) yield () => { @@ -206,6 +232,15 @@ export class LlmService extends Service { return [...this.adapters.values()].map(({ provider }) => ({ ...provider })) } + /** + * Resolve the retry policy captured when one provider route was registered. + * @param provider - registered provider route to inspect. + * @returns the provider-owned policy, with normal defaults already resolved. + */ + providerRetryPolicy(provider: string): ResolvedRetryPolicy { + return this.registration(provider).retryPolicy + } + /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -262,7 +297,11 @@ export class LlmService extends Service { return { contextWindow: context.contextWindow } } - private registration(provider: string): { adapter: LlmAdapter; provider: LlmProviderInfo } { + private registration(provider: string): { + adapter: LlmAdapter + provider: LlmProviderInfo + retryPolicy: ResolvedRetryPolicy + } { const registration = this.adapters.get(provider) if (!registration) throw new LlmError(`no adapter registered for provider "${provider}"`, 'NO_ADAPTER') return registration diff --git a/packages/llm/llm/src/retry-policy.ts b/packages/llm/llm/src/retry-policy.ts new file mode 100644 index 0000000000..a1932d50a2 --- /dev/null +++ b/packages/llm/llm/src/retry-policy.ts @@ -0,0 +1,184 @@ +/** + * Provider-owned request-retry policy configuration and resolution. + * + * Adapters expose one resolved policy per registered provider route; the + * optional dsh-llm-retry plugin executes it on the agent's failed-step seam. + * + * @module @deepseek-ai/dsh-llm/retry-policy + */ + +import z from 'schemastery' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' + +const DEFAULT_MAX_RETRIES = 2 +const DEFAULT_INITIAL_DELAY_MS = 500 +const DEFAULT_MAX_DELAY_MS = 10_000 +const DEFAULT_JITTER_RATIO = 0.1 +const DEFAULT_RETRYABLE_CODES = Object.freeze(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']) + +/** Bounded exponential backoff with symmetric jitter around each local delay. */ +export interface BackoffConfig { + /** Initial local exponential-backoff delay in milliseconds (default 500). */ + initialDelayMs?: number + /** Maximum locally scheduled or accepted provider delay in milliseconds (default 10000). */ + maxDelayMs?: number + /** Symmetric random multiplier range around one (default 0.1). */ + jitterRatio?: number +} + +/** Current bounded transient retry behavior for one provider route. */ +export interface NormalRetryPolicyConfig { + /** Retry only configured transient failure codes. */ + mode: 'normal' + /** Maximum eligible retries after the first request (default 2). */ + maxRetries?: number + /** Stable failure codes eligible for this policy. */ + retryableCodes?: string[] + /** Local exponential-backoff and jitter configuration. */ + backoff?: BackoffConfig +} + +/** Unbounded retry behavior for every model-request failure on one provider route. */ +export interface AlwaysRetryPolicyConfig { + /** Retry every model-request failure until success, cancellation, or disposal. */ + mode: 'always' + /** Local exponential-backoff and jitter configuration. */ + backoff?: BackoffConfig +} + +/** Provider-owned model-request retry policy configuration. */ +export type RetryPolicyConfig = NormalRetryPolicyConfig | AlwaysRetryPolicyConfig + +/** Fully resolved backoff shared by both retry modes. */ +export interface ResolvedRetryBackoff { + readonly initialDelayMs: number + readonly maxDelayMs: number + readonly jitterRatio: number +} + +/** Fully resolved bounded transient retry policy. */ +export interface ResolvedNormalRetryPolicy extends ResolvedRetryBackoff { + readonly mode: 'normal' + readonly maxRetries: number + readonly retryableCodes: readonly string[] +} + +/** Fully resolved unbounded retry policy. */ +export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff { + readonly mode: 'always' +} + +/** Immutable provider policy captured when its adapter route is registered. */ +export type ResolvedRetryPolicy = ResolvedNormalRetryPolicy | ResolvedAlwaysRetryPolicy + +const backoffSchema: z = z.object({ + initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS), + maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS), + jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO), +}) + +const normalPolicySchema: z = z.object({ + mode: z.const('normal').required(), + maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES), + retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]), + backoff: backoffSchema, +}) + +const alwaysPolicySchema: z = z.object({ + mode: z.const('always').required(), + backoff: backoffSchema, +}) + +/** Cordis schema embedded by each concrete provider configuration. */ +export const RetryPolicySchema: z = z.union([ + normalPolicySchema, + alwaysPolicySchema, +]) + +const NORMAL_POLICY_KEYS: ReadonlySet = new Set([ + 'mode', 'maxRetries', 'retryableCodes', 'backoff', +]) +const ALWAYS_POLICY_KEYS: ReadonlySet = new Set(['mode', 'backoff']) +const BACKOFF_KEYS: ReadonlySet = new Set(['initialDelayMs', 'maxDelayMs', 'jitterRatio']) + +function validateKeys(value: object, allowed: ReadonlySet, path: string): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new Error(`${path}: unknown key "${key}"`) + } +} + +function resolveBackoff(config: BackoffConfig | undefined, path: string): ResolvedRetryBackoff { + if (config !== undefined) validateKeys(config, BACKOFF_KEYS, path) + const initialDelayMs = config?.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS + const maxDelayMs = config?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS + const jitterRatio = config?.jitterRatio ?? DEFAULT_JITTER_RATIO + + if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) { + throw new Error(`${path}.initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) { + throw new Error(`${path}.maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (initialDelayMs > maxDelayMs) { + throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`) + } + if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) { + throw new Error(`${path}.jitterRatio must be between 0 and 1`) + } + + return Object.freeze({ initialDelayMs, maxDelayMs, jitterRatio }) +} + +/** + * Validate, default, and detach one provider-owned retry policy. + * @param config - optional provider configuration; omission selects normal defaults. + * @param path - diagnostic path naming the provider config that owns the value. + * @returns an immutable policy safe to capture in provider registration state. + */ +export function resolveRetryPolicy( + config: RetryPolicyConfig | undefined, + path: string, +): ResolvedRetryPolicy { + if (config === undefined) { + return Object.freeze({ + mode: 'normal', + maxRetries: DEFAULT_MAX_RETRIES, + retryableCodes: DEFAULT_RETRYABLE_CODES, + ...resolveBackoff(undefined, `${path}.backoff`), + }) + } + + switch (config.mode) { + case 'normal': { + validateKeys(config, NORMAL_POLICY_KEYS, path) + const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES + const retryableCodes = config.retryableCodes ?? [...DEFAULT_RETRYABLE_CODES] + if (!Number.isSafeInteger(maxRetries) || maxRetries < 0) { + throw new Error(`${path}.maxRetries must be a non-negative safe integer`) + } + if (retryableCodes.length === 0) { + throw new Error(`${path}.retryableCodes must not be empty`) + } + if (retryableCodes.some(code => code.length === 0)) { + throw new Error(`${path}.retryableCodes must contain only non-empty strings`) + } + if (new Set(retryableCodes).size !== retryableCodes.length) { + throw new Error(`${path}.retryableCodes must not contain duplicates`) + } + return Object.freeze({ + mode: 'normal', + maxRetries, + retryableCodes: Object.freeze([...retryableCodes]), + ...resolveBackoff(config.backoff, `${path}.backoff`), + }) + } + case 'always': + validateKeys(config, ALWAYS_POLICY_KEYS, path) + return Object.freeze({ + mode: 'always', + ...resolveBackoff(config.backoff, `${path}.backoff`), + }) + default: + throw new Error(`${path}.mode must be "normal" or "always"`) + } +} diff --git a/packages/llm/llm/tests/retry-policy.spec.ts b/packages/llm/llm/tests/retry-policy.spec.ts new file mode 100644 index 0000000000..d262533787 --- /dev/null +++ b/packages/llm/llm/tests/retry-policy.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { + resolveRetryPolicy, + RetryPolicySchema, +} from '@deepseek-ai/dsh-llm' +import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' + +describe('provider retry policy', () => { + it('resolves immutable normal defaults', () => { + const policy = resolveRetryPolicy(undefined, 'provider.retryPolicy') + + expect(policy).toEqual({ + mode: 'normal', + maxRetries: 2, + retryableCodes: ['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'], + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0.1, + }) + expect(Object.isFrozen(policy)).toBe(true) + if (policy.mode !== 'normal') throw new Error('expected normal policy') + expect(Object.isFrozen(policy.retryableCodes)).toBe(true) + }) + + it('resolves and detaches a configured normal policy', () => { + const retryableCodes = ['BUSY'] + const config: RetryPolicyConfig = { + mode: 'normal', + maxRetries: 4, + retryableCodes, + backoff: { + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0, + }, + } + + const policy = resolveRetryPolicy(config, 'provider.retryPolicy') + retryableCodes.push('LATE') + + expect(policy).toEqual({ + mode: 'normal', + maxRetries: 4, + retryableCodes: ['BUSY'], + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0, + }) + }) + + it('resolves always mode with default backoff', () => { + expect(resolveRetryPolicy({ mode: 'always' }, 'provider.retryPolicy')).toEqual({ + mode: 'always', + initialDelayMs: 500, + maxDelayMs: 10_000, + jitterRatio: 0.1, + }) + expect(RetryPolicySchema).toBeDefined() + }) + + it.each([ + [{ mode: 'normal', maxRetries: -1 }, /maxRetries/], + [{ mode: 'normal', maxRetries: 1.5 }, /maxRetries/], + [{ mode: 'normal', maxRetries: Number.MAX_SAFE_INTEGER + 1 }, /maxRetries/], + [{ mode: 'always', backoff: { initialDelayMs: 0 } }, /initialDelayMs/], + [{ mode: 'normal', backoff: { maxDelayMs: Number.POSITIVE_INFINITY } }, /maxDelayMs/], + [{ mode: 'normal', backoff: { initialDelayMs: MAX_TIMER_DELAY_MS + 1 } }, /initialDelayMs/], + [{ mode: 'always', backoff: { maxDelayMs: MAX_TIMER_DELAY_MS + 1 } }, /maxDelayMs/], + [{ mode: 'normal', backoff: { initialDelayMs: 20, maxDelayMs: 10 } }, /less than or equal/], + [{ mode: 'always', backoff: { jitterRatio: 1.1 } }, /jitterRatio/], + [{ mode: 'normal', retryableCodes: [] }, /must not be empty/], + [{ mode: 'normal', retryableCodes: ['SERVER', 'SERVER'] }, /duplicates/], + [{ mode: 'normal', retryableCodes: [''] }, /non-empty strings/], + [{ mode: 'normal', maxRetires: 1 }, /unknown key "maxRetires"/], + [{ mode: 'always', maxRetries: 1 }, /unknown key "maxRetries"/], + [{ mode: 'always', backoff: { initialDelay: 1 } }, /unknown key "initialDelay"/], + [{ mode: 'sometimes' }, /mode must be "normal" or "always"/], + ] as const)('rejects invalid policy %#', (config, message) => { + expect(() => { + resolveRetryPolicy(config as unknown as RetryPolicyConfig, 'provider.retryPolicy') + }).toThrow(message) + }) +}) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 90be1ffcb0..52c51852ba 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -11,6 +11,7 @@ import LlmService, { LlmError, llmFailureOf, ProviderRequestId, + resolveRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' import type { LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' @@ -159,6 +160,27 @@ describe('LlmService', () => { expect(chunks).toEqual(SCRIPT) }) + it('captures provider-owned retry policy at registration and defaults omission', async () => { + const configured = resolveRetryPolicy({ mode: 'always' }, 'test retryPolicy') + const adapter = new class extends ScriptedAdapter { + override providerRetryPolicy(provider: string) { + return provider === 'configured' ? configured : undefined + } + }(SCRIPT) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['configured', 'defaulted'], adapter) + + expect(ctx.llm.providerRetryPolicy('configured')).toBe(configured) + expect(ctx.llm.providerRetryPolicy('defaulted')).toMatchObject({ + mode: 'normal', + maxRetries: 2, + }) + expect(() => ctx.llm.providerRetryPolicy('missing')).toThrow( + expect.objectContaining({ code: 'NO_ADAPTER' }), + ) + }) + it('throws NO_ADAPTER for unregistered providers', async () => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json index 5bc7a9fcf5..fa4adda095 100644 --- a/packages/llm/llm/tsconfig.json +++ b/packages/llm/llm/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../util/timeout" } ] } diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 8a731a1bf6..d1e78a0e35 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1366,8 +1366,9 @@ export function streamSessionEventUpdate( return } case 'llm/retry': { + const retryLimit = event.data.mode === 'always' ? '∞' : String(event.data.maxRetries) const text = '\n\n[Previous model attempt discarded; retrying ' - + `${event.data.retry}/${event.data.maxRetries} in ${event.data.delayMs}ms: ` + + `${event.data.retry}/${retryLimit} in ${event.data.delayMs}ms: ` + `${event.data.failure.message}]\n\n` notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } }) return diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 2585968b7e..08993f7ce1 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -103,6 +103,8 @@ describe('streamSessionEventUpdate', () => { expect(updatesFor(evt('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'normal', retry: 1, maxRetries: 2, delayMs: 500, @@ -114,6 +116,21 @@ describe('streamSessionEventUpdate', () => { text: '\n\n[Previous model attempt discarded; retrying 1/2 in 500ms: backend busy]\n\n', }, }]) + expect(updatesFor(evt('llm/retry', { + turn: 1, + step: 2, + provider: 'mock', + mode: 'always', + retry: 7, + delayMs: 1_000, + failure: { message: 'still unavailable', code: 'AUTH' }, + }))).toEqual([{ + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: '\n\n[Previous model attempt discarded; retrying 7/∞ in 1000ms: still unavailable]\n\n', + }, + }]) expect(updatesFor(evt('turn/end', { turn: 1, reason: { kind: 'error', step: 2, failure: { message: 'still busy', code: 'SERVER' } }, diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 2ade1937e3..bfe199e13b 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -2298,8 +2298,9 @@ export function createTuiChat( } case 'llm/retry': { clearStreaming() + const retryLimit = event.data.mode === 'always' ? '∞' : String(event.data.maxRetries) appendNotice( - `Retrying model request (${event.data.retry}/${event.data.maxRetries}) in ${event.data.delayMs}ms: ${event.data.failure.message}`, + `Retrying model request (${event.data.retry}/${retryLimit}) in ${event.data.delayMs}ms: ${event.data.failure.message}`, 'warning', ) break diff --git a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt index accef4fffc..39862c8260 100644 --- a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt @@ -21,7 +21,7 @@ buffer 7| "▌ " style 0-0 fg=bright-blue 8| -9| " Retrying model request (1/2) in 1000ms: temporary transport failure " +9| " Retrying model request (1/∞) in 1000ms: temporary transport failure " style 1-67 fg=yellow 10| 11| " Turn cancelled. " diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 16d6794002..1aec66d631 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -269,6 +269,8 @@ describe('TUI terminal-state snapshots', () => { harness.session.append('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'normal', retry: 1, maxRetries: 2, delayMs: 500, @@ -297,8 +299,9 @@ describe('TUI terminal-state snapshots', () => { harness.session.append('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'always', retry: 1, - maxRetries: 2, delayMs: 1_000, failure: { message: 'temporary transport failure', code: 'TRANSPORT' }, }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 73be9abe76..6af58fe97e 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1290,6 +1290,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'normal', retry: 1, maxRetries: 2, delayMs: 500, @@ -1322,6 +1324,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('llm/retry', { turn: 1, step: 1, + provider: 'mock', + mode: 'normal', retry: 1, maxRetries: 2, delayMs: 500, @@ -1330,6 +1334,8 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session.append('llm/retry', { turn: 1, step: 2, + provider: 'mock', + mode: 'normal', retry: 2, maxRetries: 2, delayMs: 1_000, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edd75466c5..7b19523d33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2217,6 +2217,10 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/llm/llm: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -2224,6 +2228,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index d87fba3d08..3394801d3d 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -41,6 +41,7 @@ export const LINK_MAP: Record = { LlmFailure: 'llm-streaming.md', LlmModelInfo: 'core.md', LlmProviderInfo: 'core.md', + ResolvedRetryPolicy: 'llm-streaming.md', Message: 'core.md', MessageSource: 'core.md', PromptDecision: 'core.md',