@deepseek-ai/dsh-llm-retry 0.1.1-rc.2 → 0.1.2-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/llm/llm-retry/README.md
5
- README.md: 1dd530051e387230510414e35701db10c7c7c063
6
- README.zh.md: 52badf6a6e7d2c2571fa951865f6b4b3f08ae4ad
5
+ README.md: 0ea815130212a165582540e43aad7d59fad32d2c
6
+ README.zh.md: 43a0187c68d272e2764d34db35fa26e51a9d3a83
package/README.md CHANGED
@@ -1,16 +1,37 @@
1
- # `@deepseek-ai/dsh-llm-retry`
1
+ ---
2
+ description: "The retry executor for users and maintainers configuring provider-routed model-request recovery at durable agent-step boundaries."
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-llm-retry
2
7
 
3
8
  English | [中文](README.zh.md)
4
9
 
5
- Function plugin that applies exact-provider retry policy through the agent loop's closed-step `agent/request-error` waterfall. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered turn.
10
+ ## Summary
11
+
12
+ `@deepseek-ai/dsh-llm-retry` is the retry executor for failed model requests: it applies each provider's resolved retry policy at the agent loop's open-step `agent/request-error` extension point, so every retry re-runs the same step inside the same open turn over the same durable history. It does not wrap the streaming call itself — every adapter call remains one provider attempt, and direct `ctx.llm.stream()` consumers stay single-attempt. Retry scheduling is durable: the plugin appends `llm/retry` events to the session log before waiting, and cancellation during backoff leaves the log consistent. Normal mode retries a bounded set of failure codes up to `maxRetries` with exponential backoff; always mode asks downstream recovery first, then retries every failure without an attempt limit.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
6
24
 
7
- Each provider adapter owns an optional nested `retryPolicy`, captured when its route registers on `ctx.llm` and carried with each call that reaches that registration's final adapter boundary. An in-flight failure retains that serving policy if the route is later disposed or replaced; a failure before any final adapter is selected has no provider policy and delegates. Omission uses normal mode: five retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, with bounded exponential backoff from 500 ms to 10 seconds and 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion that produced no durable content, so repeating it is safe. 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 after active delegated recovery reaches quiescence.
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
8
27
 
9
- 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.
28
+ Mount this plugin when agent runs should recover from transient model-request failures — rate limits, server errors, timeouts, transport errors — instead of ending the turn. It is the executor: the retry policy itself lives on each provider adapter's configuration, and this package has no configuration of its own.
10
29
 
11
- Before waiting, the plugin appends a non-surface `llm/retry` event with the shared `retryId`, provider, mode, canonical resolved-policy key, failure, and scheduled delay. Its payload is available from the browser-safe `@deepseek-ai/dsh-llm-retry/types` subpath, so remote renderers can consume the durable status without loading the policy runtime. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. When the wait completes, the plugin appends `llm/retry-started` with the same `retryId`, turn, step, and retry number immediately before returning `{ kind: 'retry' }`; cancellation during backoff writes no started event. The loop then closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed.
30
+ ### When to choose it
12
31
 
13
- The separately published `./invariant` companion checks that every scheduled retry names the current open turn and latest closed step, matches the failed request's durable provider, carries non-empty provider and policy identities, has mode-specific bounds, a unique step record, the correct provider-policy retry number, and a bounded timer delay. It also requires each `llm/retry-started` event to name one prior scheduled attempt with the same `retryId`, turn, step, and retry number, and rejects repeated started events. Full jitter may schedule zero milliseconds at its lower boundary.
32
+ Choose it when a composition runs the agent loop and wants durable request recovery. The plugin is a function plugin with no config; provider adapters such as `dsh-llm-deepseek` and `dsh-llm-pi-ai` own the `retryPolicy` for their routes, and multi-provider adapters place it inside each provider profile. Skip it when calls go through `ctx.llm.stream()` directly without the agent loop: those consumers remain single-attempt because a raw stream cannot separate already-emitted chunks durably.
33
+
34
+ ### Minimal configuration
14
35
 
15
36
  ```yaml
16
37
  - name: '@deepseek-ai/dsh-llm-deepseek'
@@ -26,15 +47,72 @@ The separately published `./invariant` companion checks that every scheduled ret
26
47
  - name: '@deepseek-ai/dsh-llm-retry'
27
48
  ```
28
49
 
29
- 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.
50
+ Omission of `retryPolicy` uses normal mode: five retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, with bounded exponential backoff from 500 ms to 10 seconds and 10 percent jitter. Normal mode 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, stopping only on success, cancellation, or plugin disposal.
51
+
52
+ ### What you can observe
53
+
54
+ Each scheduled retry is durable before its wait: the plugin appends a non-surface `llm/retry` event carrying the retry id, provider, mode, policy key, failure, and scheduled delay, then a `llm/retry-started` event immediately before the retry begins. A valid `Retry-After` from the provider replaces local backoff when it fits the policy bounds. When the wait completes, the loop re-runs the failed step inside the same open turn over the same durable history, so the retried request is reconstructable from the session log exactly like the original. Cancellation or plugin disposal aborts active backoff, drains active delegated recovery, and makes a callback captured before disposal fail closed.
55
+
56
+ ### Failures and recovery
57
+
58
+ A failure before any final adapter is selected has no provider policy and delegates downstream unchanged. In normal mode, a failure code outside the eligible set, or an exhausted budget, delegates; in always mode, an over-cap provider delay uses the configured local backoff so the policy cannot terminate on that instruction. Nothing here is model-visible: no retry event, delay, provider error, or failed partial output reaches the model or derived messages.
59
+
60
+ -----
61
+
62
+ <a id="understand-the-implementation"></a>
63
+ ## Understand the implementation
64
+
65
+ <details>
66
+ <summary>Implementation internals — click to expand</summary>
67
+
68
+ This section explains the design behind the executor; the observable behavior is fully covered in [Use this package](#use-this-package).
69
+
70
+ ### Design philosophy
71
+
72
+ The executor is built on one rule: **durable before wait, open-step boundaries.** A retry is scheduled through the session log before any timer starts, so a crash or cancellation never leaves an invisible pending retry. Recovery runs on the agent loop's `agent/request-error` waterfall, the open-step extension point, rather than wrapping `ctx.llm.stream()` — a raw stream cannot separate already-emitted chunks durably, while the loop can re-run the failed step inside the same open turn.
73
+
74
+ ### Source map
75
+
76
+ | File | Role |
77
+ |---|---|
78
+ | [`src/index.ts`](src/index.ts) | The function plugin: waterfall listener, policy lookup, backoff, durable event appends |
79
+ | [`src/history.ts`](src/history.ts) | Durable retry-history lookup from the session log |
80
+ | [`src/types.ts`](src/types.ts) | Browser-safe `llm/retry` and `llm/retry-started` event payload types |
81
+ | [`src/brand.ts`](src/brand.ts) | The `RetryId` brand shared by the event payloads |
30
82
 
83
+ ### Recovery flow
84
+
85
+ A failed step arrives on the waterfall with its provider and resolved policy. Always mode settles downstream recovery first and honors a downstream `retry` decision; normal mode first checks that the failure code is eligible and the budget is not exhausted. The plugin computes the delay — provider `Retry-After` when valid and within bounds, otherwise local bounded exponential backoff with symmetric jitter — appends the `llm/retry` event, waits on a cancellable timer, appends `llm/retry-started`, and returns `{ kind: 'retry' }`. The loop then re-runs the failed step inside the same open turn over the same durable history.
86
+
87
+ ### Waterfall composition
88
+
89
+ The plugin is one listener in the `agent/request-error` waterfall. Always mode's "downstream first" posture means a later policy that ignores cancellation and never settles also prevents fallback, turn quiescence, and plugin disposal from completing; success, cancellation, or disposal stops always mode after active delegated recovery reaches quiescence.
90
+
91
+ </details>
92
+
93
+ -----
94
+
95
+ <a id="further-exploration"></a>
96
+ ## Further Exploration
97
+
98
+ Read these pages when the package-level contract is not enough. They move from the service contract to the adapters that own retry policies.
99
+
100
+ - [dsh-llm service](../llm/README.md) — the provider-neutral service whose adapters own `retryPolicy`.
101
+ - [llm-deepseek adapter](../llm-deepseek/README.md) — a provider adapter with a route-level `retryPolicy`.
102
+ - [llm-pi-ai adapter](../llm-pi-ai/README.md) — a multi-provider adapter with per-profile `retryPolicy`.
103
+ - [Terminal LLM stream failures](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md) — how failures reach the service boundary as terminal chunks.
104
+ - [LLM streaming subsystem](../../../docs/subsystems/llm-streaming.md) — the `StreamChunk` protocol and adapter contract.
105
+
106
+ -----
107
+
108
+ <a id="model-experience"></a>
31
109
  ## Model Experience
32
110
 
33
111
  ### Model-request recovery
34
112
 
35
113
  #### What the model sees
36
114
 
37
- No retry event, delay, provider error, or failed partial output is model-visible. The retry turn reconstructs the same explicit provider/model request from durable surface history unless a downstream recovery policy deliberately changes that surface; failed chunks never enter derived messages.
115
+ No retry event, delay, provider error, or failed partial output is model-visible. The retried step reconstructs the same explicit provider/model request from durable surface history unless a downstream recovery policy deliberately changes that surface; failed chunks never enter derived messages.
38
116
 
39
117
  #### Token effect
40
118
 
@@ -46,8 +124,26 @@ The reconstructed request preserves the prior prefix and is eligible for provide
46
124
 
47
125
  ## Known Limitations and Deferred Work
48
126
 
127
+ <a id="known-limitations-and-deferred-work"></a>
128
+
129
+
130
+ These limits define where the executor stops and future work begins. They are current package constraints, not a general retry comparison or a task backlog.
131
+
49
132
  - **Agent turns are the only retry boundary** — direct `ctx.llm.stream()` consumers remain single-attempt because a raw stream cannot separate already-emitted chunks durably.
50
133
  - **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.
51
134
  - **Finite plugin budgets add** — normal mode counts only its configured codes and exact provider policy, while context-overflow compaction owns a separate budget. Any overlapping policy must define registration-order behavior.
52
135
  - **Recovery policies compose by waterfall order** — always mode accepts a downstream retry before applying its fallback. A later policy that ignores cancellation and never settles also prevents fallback, turn quiescence, and plugin disposal from completing.
53
136
  - **`llm/retry` records scheduling, not completion** — later step and turn events establish success, exhaustion, or cancellation.
137
+
138
+ <a id="dev-note"></a>
139
+ ### Dev Note
140
+
141
+ <details>
142
+ <summary>Working context for maintainers — click to expand</summary>
143
+
144
+ This Dev Note is non-authoritative working context: notes for maintainers and open questions. Shipped behavior and accepted rationale live in the sections above, the package code, and the linked Agent Notes.
145
+
146
+ - Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history; the key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership.
147
+ - The separately published `./invariant` companion validates each scheduled retry against the session log — naming the current open turn and latest closed step, matching the failed request's durable provider, and requiring each `llm/retry-started` event to name one prior scheduled attempt with the same retry id, turn, step, and retry number.
148
+
149
+ </details>
package/README.zh.md CHANGED
@@ -1,16 +1,37 @@
1
- # `@deepseek-ai/dsh-llm-retry`
1
+ ---
2
+ description: "面向用户与维护者的重试执行器说明:在持久 agent 步骤边界上配置按提供方路由的模型请求恢复。"
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-llm-retry
2
7
 
3
8
  [English](README.md) | 中文
4
9
 
5
- 一个函数插件,通过 agent loop(智能体循环)在已关闭步骤上触发的 `agent/request-error` waterfall(瀑布式事件)应用确切提供方重试策略。它不包装 `ctx.llm.stream()`:每次适配器调用仍是一次提供方尝试,每次重试都会开启新的编号轮次。
10
+ ## 概述
11
+
12
+ `@deepseek-ai/dsh-llm-retry` 是失败模型请求的重试执行器:它在 agent loop 的打开步骤 `agent/request-error` 扩展点上应用各提供方解析后的重试策略,因此每次重试都会在同一个打开的轮次内重跑同一个步骤(基于同一份持久历史)。它不包装流式调用本身——每次适配器调用仍是一次提供方尝试,直接 `ctx.llm.stream()` 消费方仍是单次尝试。重试调度是持久的:插件在等待之前就把 `llm/retry` 事件追加进会话日志,退避期间取消会让日志保持一致。normal mode 以指数退避重试一组有界的失败 code,最多 `maxRetries` 次;always mode 先询问下游恢复,然后无尝试上限地重试每个失败。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
6
24
 
7
- 每个提供方适配器都拥有可选的嵌套 `retryPolicy`;路由在 `ctx.llm` 上注册时会捕获该策略,任何到达该注册最终适配器边界的调用都会携带它。如果之后释放或替换路由,进行中的失败仍会保留当时为其提供服务的策略;在选中任何最终适配器前发生的失败没有提供方策略,会继续委托。省略策略时使用 normal mode:为 `EMPTY_RESPONSE`、`RATE_LIMIT`、`SERVER`、`TIMEOUT` 和 `TRANSPORT` 重试五次,并采用从 500 ms 到 10 秒的有界指数退避与 10% jitter。`EMPTY_RESPONSE` 是适配器对未产生任何持久内容的退化提供方完成所作的分类,因此可安全重复。normal 策略可以更改其有限预算、符合条件的 code 和退避配置。always mode 会先请求下游恢复,再无次数上限地重试每个模型请求失败;成功、取消或插件 dispose(资源释放)会在活跃的委托恢复完全停稳后终止它。
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
8
27
 
9
- 两种 mode 都使用带对称 jitter 的有界指数退避。有效 `providerRetryAfterMs` 不超过 `maxDelayMs` 时会替换本地退避,并且不加 jitter。超出上限的提供方延迟会使 normal mode 继续委托;always mode 则改用已配置的本地退避,避免该指令终止重试。
28
+ 当 agent 运行应从暂时性模型请求失败——速率限制、服务端错误、超时、传输错误——中恢复而不是结束轮次时,挂载本插件。它是执行器:重试策略本身位于各提供方适配器的配置上,本包没有任何自己的配置。
10
29
 
11
- 等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含共享 `retryId`、提供方、mode、已解析策略的规范 key、失败和计划延迟。该载荷由可安全用于浏览器的 `@deepseek-ai/dsh-llm-retry/types` 子路径导出,因此远程渲染器无需加载策略运行时即可使用该持久状态。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,用限制、code 成员关系或退避不同的路由替换后,会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待完成时,插件会在返回 `{ kind: 'retry' }` 前立即追加 `llm/retry-started`,其中带有相同的 `retryId`、轮次、步骤与重试编号;退避期间取消则不会写入 started 事件。随后循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前等待活跃的委托恢复结算,并使 dispose 前捕获的 callback 只能以失败结束。
30
+ ### 何时选择
12
31
 
13
- 单独发布的 `./invariant` 配套模块会检查每个已调度重试是否指向当前开启轮次及其最新已关闭步骤,是否与失败请求的持久提供方匹配,是否携带非空的提供方与策略标识,是否满足 mode 特定边界,是否拥有唯一步骤记录和正确的提供方策略重试编号,以及是否携带有界定时器延迟。它还要求每个 `llm/retry-started` 事件通过相同的 `retryId`、轮次、步骤与重试编号指向一个先前调度的尝试,并拒绝重复的 started 事件。full jitter 可以在下界调度为零毫秒。
32
+ 当组合运行 agent loop 并需要持久请求恢复时选择它。本插件是无配置的函数插件;`dsh-llm-deepseek` 与 `dsh-llm-pi-ai` 等提供方适配器拥有各自路由的 `retryPolicy`,多提供方适配器把它放进每个 provider profile。当调用不经 agent loop、直接走 `ctx.llm.stream()` 时跳过它:这些消费方仍是单次尝试,因为原始流无法持久地区分已发出的分片。
33
+
34
+ ### 最小配置
14
35
 
15
36
  ```yaml
16
37
  - name: '@deepseek-ai/dsh-llm-deepseek'
@@ -26,28 +47,103 @@
26
47
  - name: '@deepseek-ai/dsh-llm-retry'
27
48
  ```
28
49
 
29
- 执行器没有策略配置。`dsh-llm-pi-ai` 等多提供方适配器会把 `retryPolicy` 放在每个提供方 profile 内,避免维护第二份提供方名称列表。
50
+ 省略 `retryPolicy` 时使用 normal mode:对 `EMPTY_RESPONSE`、`RATE_LIMIT`、`SERVER`、`TIMEOUT` 与 `TRANSPORT` 最多重试五次,退避从 500 毫秒到 10 秒、带 10% 抖动。normal mode 可以更改其有界预算、合格 code 与退避;always mode 先询问下游恢复,然后无尝试上限地重试每个模型请求失败,只在成功、取消或插件释放时停止。
51
+
52
+ ### 你可以观察到什么
53
+
54
+ 每次计划的重试在等待前就是持久的:插件会先追加携带重试 id、提供方、模式、策略键、失败与计划延迟的非 surface `llm/retry` 事件,然后在重试开始前立即追加 `llm/retry-started` 事件。提供方给出且符合策略边界的有效 `Retry-After` 会替换本地退避。等待完成后,loop 会在同一个打开的轮次内重跑失败步骤,仍基于同一份持久历史,因此重试请求与原始请求一样可以从会话日志重建。取消或插件释放会中止进行中的退避、排空活动中的委派恢复,并让释放前捕获的回调快速失败。
55
+
56
+ ### 失败与恢复
57
+
58
+ 在任何最终适配器被选中之前发生的失败没有提供方策略,原样委派下游。normal mode 中,不在合格集合内的失败 code 或已耗尽的预算会委派;always mode 中,超上限的提供方延迟使用配置的本地退避,因此策略不会因该指令终止。这里没有任何模型可见内容:重试事件、延迟、提供方错误或失败的部分输出都不会到达模型或派生消息。
59
+
60
+ -----
61
+
62
+ <a id="understand-the-implementation"></a>
63
+ ## 理解实现
64
+
65
+ <details>
66
+ <summary>实现细节——点击展开</summary>
67
+
68
+ 本节解释执行器背后的设计;可观察行为已在[使用本包](#use-this-package)中完整说明。
69
+
70
+ ### 设计理念
71
+
72
+ 执行器建立在一条规定之上:**先持久、后等待,打开步骤边界。** 任何定时器启动之前,重试就已通过会话日志计划,因此崩溃或取消永远不会留下不可见的待处理重试。恢复运行在 agent loop 的 `agent/request-error` waterfall(打开步骤扩展点)上,而不是包装 `ctx.llm.stream()`——原始流无法持久地区分已发出的分片,而 loop 可以在同一个打开的轮次内重跑失败步骤。
73
+
74
+ ### 源码地图
75
+
76
+ | 文件 | 职责 |
77
+ |---|---|
78
+ | [`src/index.ts`](src/index.ts) | 函数插件:waterfall 监听器、策略查找、退避、持久事件追加 |
79
+ | [`src/history.ts`](src/history.ts) | 从会话日志查找持久重试历史 |
80
+ | [`src/types.ts`](src/types.ts) | 浏览器安全的 `llm/retry` 与 `llm/retry-started` 事件载荷类型 |
81
+ | [`src/brand.ts`](src/brand.ts) | 事件载荷共享的 `RetryId` 品牌 |
30
82
 
83
+ ### 恢复流程
84
+
85
+ 失败步骤连同其提供方与解析后的策略一起到达 waterfall。always mode 先结算下游恢复,并遵循下游的 `retry` 决定;normal mode 先检查失败 code 是否合格、预算是否未耗尽。插件计算延迟——有效且在边界内的提供方 `Retry-After`,否则带对称抖动的本地有界指数退避——追加 `llm/retry` 事件,在可取消定时器上等待,追加 `llm/retry-started`,然后返回 `{ kind: 'retry' }`。loop 随后在同一个打开的轮次内重跑失败步骤(仍基于同一份持久历史)。
86
+
87
+ ### Waterfall 组合
88
+
89
+ 本插件是 `agent/request-error` waterfall 中的一个监听器。always mode 的"下游优先"姿态意味着,之后忽略取消且永不结算的策略也会阻止回退、轮次完全停稳与插件释放完成;成功、取消或释放会在活动委派恢复达到完全停稳后停止 always mode。
90
+
91
+ </details>
92
+
93
+ -----
94
+
95
+ <a id="further-exploration"></a>
96
+ ## 进一步探索
97
+
98
+ 当包级约定不够用时阅读以下页面。它们从服务约定逐步进入拥有重试策略的适配器。
99
+
100
+ - [dsh-llm 服务](../llm/README.zh.md)——其适配器拥有 `retryPolicy` 的提供方无关服务。
101
+ - [llm-deepseek 适配器](../llm-deepseek/README.zh.md)——带路由级 `retryPolicy` 的提供方适配器。
102
+ - [llm-pi-ai 适配器](../llm-pi-ai/README.zh.md)——带逐 profile `retryPolicy` 的多提供方适配器。
103
+ - [LLM 流终止失败](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.zh.md)——失败如何以终止分片到达服务边界。
104
+ - [LLM 流式子系统](../../../docs/subsystems/llm-streaming.zh.md)——`StreamChunk` 协议与适配器约定。
105
+
106
+ -----
107
+
108
+ <a id="model-experience"></a>
31
109
  ## 模型体验
32
110
 
33
111
  ### 模型请求恢复
34
112
 
35
- #### 模型看到的内容
113
+ #### 模型看到什么
36
114
 
37
- 模型不会看到重试事件、延迟、提供方错误或失败的部分输出。重试轮次会从持久表层历史中重建相同的显式提供方/模型请求,除非下游恢复策略有意更改该表层;失败分片绝不会进入派生消息。
115
+ 重试事件、延迟、提供方错误或失败的部分输出都不会对模型可见。除非下游恢复策略刻意改变表面,重试步骤从持久表面历史重建同一显式提供方/模型请求;失败分片绝不进入派生消息。
38
116
 
39
117
  #### Token 影响
40
118
 
41
- 每次重试都是新的提供方请求,可能重复计费输入 token。normal mode 具有有限预算;always mode 可以在成功或取消前消耗无界数量的请求。`llm/retry` 自身不产生 token。
119
+ 每次重试都是一次新的提供方请求,可能重复输入 token 计费。normal mode 有有界预算;always mode 在成功或取消前可能消耗无上限请求。`llm/retry` 本身不贡献任何 token。
42
120
 
43
121
  #### KV Cache 影响
44
122
 
45
- 重建请求保留之前的前缀,并可根据该提供方的规则复用 cache。非表层重试事件不会改变 cache 身份。
123
+ 重建的请求保留此前前缀,有资格按该提供方规则复用提供方缓存。非 surface 重试事件不改变缓存标识。
124
+
125
+ ## 已知限制与延期工作
126
+
127
+ <a id="known-limitations-and-deferred-work"></a>
128
+
129
+
130
+ 这些限制说明执行器在哪里停止、由未来工作接续。它们是当前包约束,不是通用重试对比或任务积压。
131
+
132
+ - **agent 轮次是唯一重试边界**——直接 `ctx.llm.stream()` 消费方仍是单次尝试,因为原始流无法持久地区分已发出的分片。
133
+ - **always mode 会重试永久性失败**——认证、配额、无效请求、协议与不可恢复的上下文错误会持续到成功、取消或释放;部署方负责提供方专属的成本与延迟控制。
134
+ - **有界插件预算相加**——normal mode 只统计其配置的 code 与精确提供方策略,而上下文溢出压缩(compaction)拥有独立预算。任何重叠策略都必须定义注册顺序行为。
135
+ - **恢复策略按 waterfall 顺序组合**——always mode 先接受下游重试,再应用其回退。之后忽略取消且永不结算的策略也会阻止回退、轮次完全停稳与插件释放完成。
136
+ - **`llm/retry` 记录调度,而非完成**——后续步骤与轮次事件才确立成功、耗尽或取消。
137
+
138
+ <a id="dev-note"></a>
139
+ ### 开发备注
140
+
141
+ <details>
142
+ <summary>维护者的工作上下文——点击展开</summary>
143
+
144
+ 本开发备注是不具权威性的工作上下文:维护者备注与开放问题。已交付的行为与既定理由以上文、包代码和相关 Agent Note 为准。
46
145
 
47
- ## 已知限制与暂缓事项
146
+ - 重试编号只在同一提供方与完整策略键的事件间延续,因此限额、code 成员或退避不同的路由替换会开启自己的历史;该键包含每个影响行为的字段,并因资格判断使用集合成员而对 normal mode code 排序。
147
+ - 单独发布的 `./invariant` 伴生插件会对照会话日志校验每次计划的重试——点名当前打开轮次与最新闭合步骤、匹配失败请求的持久提供方,并要求每个 `llm/retry-started` 事件点名一次带相同重试 id、轮次、步骤与重试编号的先前计划尝试。
48
148
 
49
- - **agent 轮次是唯一重试边界**:直接 `ctx.llm.stream()` 消费方仍只尝试一次,因为原始流无法持久地区分各次尝试已经发出的分片。
50
- - **always mode 会重试永久性失败**:身份验证、配额、无效请求、协议和无法恢复的上下文错误都会继续重试,直至成功、取消或 dispose;部署负责提供方特定的成本与延迟控制。
51
- - **有限插件预算可叠加**:normal mode 只统计已配置 code 和确切提供方策略,上下文溢出压缩(compaction)则拥有独立预算。任何重叠策略都必须定义注册顺序行为。
52
- - **恢复策略按 waterfall 顺序组合**:always mode 会先接受下游重试,再应用自己的回退。后续策略如果忽略取消且永不结算,也会阻止回退、轮次完全停稳和插件 dispose 完成。
53
- - **`llm/retry` 记录调度,不是完成**:后续步骤与轮次事件用于确立成功、耗尽或取消。
149
+ </details>
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import z from "@deepseek-ai/schemastery";
3
+ import { z as z$1 } from "zod";
3
4
  //#region lib/types/brand.js
4
5
  /**
5
6
  * Brand an implementation-minted retry-chain identity.
@@ -18,7 +19,7 @@ function RetryId(id) {
18
19
  * @module @deepseek-ai/dsh-llm-retry
19
20
  */
20
21
  const name = "llm-retry";
21
- const inject = ["agents"];
22
+ const inject = ["agents", "sessionProjections"];
22
23
  /** Runtime schema for {@link Config}. */
23
24
  const Config = z.object({});
24
25
  function validateConfig(config) {
@@ -61,6 +62,9 @@ function retryPolicyKey(policy) {
61
62
  policy.jitterRatio
62
63
  ]);
63
64
  }
65
+ function retryStateKey(provider, policyKey) {
66
+ return JSON.stringify([provider, policyKey]);
67
+ }
64
68
  function cancellableDelay(delayMs, signal) {
65
69
  if (signal.aborted) return Promise.resolve(false);
66
70
  return new Promise((resolve) => {
@@ -75,14 +79,32 @@ function cancellableDelay(delayMs, signal) {
75
79
  signal.addEventListener("abort", onAbort, { once: true });
76
80
  });
77
81
  }
78
- /**
79
- * Install provider-routed normal or unbounded request recovery.
80
- * @param ctx - plugin context that owns the listener and active waits.
81
- * @param config - empty executor config; provider registrations own policy.
82
- * @param internals - non-serializable deterministic hooks for tests.
83
- */
82
+ const llmRetryStateSchema = z$1.record(z$1.string(), z$1.object({
83
+ retry: z$1.number().int().nonnegative(),
84
+ retryId: z$1.string()
85
+ }));
84
86
  function apply(ctx, config = {}, internals = {}) {
85
87
  validateConfig(config);
88
+ ctx.sessionProjections.register({
89
+ key: "llmRetry",
90
+ stateVersion: 1,
91
+ stateSchema: llmRetryStateSchema,
92
+ init: () => ({}),
93
+ apply: (state, event) => {
94
+ if (event.type === "step/start" || event.type === "turn/end") return {};
95
+ if (event.type !== "llm/retry") return state;
96
+ const key = retryStateKey(event.data.provider, event.data.policyKey);
97
+ const entry = state[key];
98
+ if (entry?.retry === event.data.retry && entry.retryId === event.data.retryId) return state;
99
+ return {
100
+ ...state,
101
+ [key]: {
102
+ retry: event.data.retry,
103
+ retryId: event.data.retryId
104
+ }
105
+ };
106
+ }
107
+ });
86
108
  const random = internals.random ?? Math.random;
87
109
  const lifetime = new AbortController();
88
110
  const active = /* @__PURE__ */ new Set();
@@ -137,11 +159,11 @@ function apply(ctx, config = {}, internals = {}) {
137
159
  if (downstream.type === "decision" && downstream.decision?.kind === "retry") return downstream.decision;
138
160
  } else if (!policy.retryableCodes.includes(failure.code)) return next();
139
161
  const policyKey = retryPolicyKey(policy);
140
- const priorPolicyRetry = agent.session.events.findLast((event) => event.type === "llm/retry" && event.data.turn === turn && event.data.step === step && event.data.provider === provider && event.data.policyKey === policyKey);
141
- const previousRetry = priorPolicyRetry?.data.retry ?? 0;
162
+ const previous = ctx.sessionProjections.stateOf(agent.session, "llmRetry")[retryStateKey(provider, policyKey)];
163
+ const previousRetry = previous?.retry ?? 0;
142
164
  if (policy.mode === "normal" && previousRetry >= policy.maxRetries) return next();
143
165
  const retry = previousRetry + 1;
144
- const retryId = priorPolicyRetry?.data.retryId ?? RetryId(randomUUID());
166
+ const retryId = previous?.retryId ?? RetryId(randomUUID());
145
167
  let delayMs;
146
168
  if (failure.providerRetryAfterMs !== void 0 && Number.isFinite(failure.providerRetryAfterMs) && failure.providerRetryAfterMs > 0) if (failure.providerRetryAfterMs > policy.maxDelayMs) {
147
169
  if (policy.mode === "normal") return next();
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { Context } from '@deepseek-ai/cordis';
8
8
  import z from '@deepseek-ai/schemastery';
9
+ import { RetryId } from './brand.ts';
9
10
  export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts';
10
11
  export { RetryId } from './brand.ts';
11
12
  export declare const name = "llm-retry";
@@ -25,5 +26,16 @@ export interface RetryInternals {
25
26
  * @param config - empty executor config; provider registrations own policy.
26
27
  * @param internals - non-serializable deterministic hooks for tests.
27
28
  */
29
+ interface RetryStateEntry {
30
+ retry: number;
31
+ retryId: RetryId;
32
+ }
33
+ type LlmRetryState = Record<string, RetryStateEntry>;
34
+ declare module '@deepseek-ai/dsh-session-projection/types' {
35
+ interface SessionProjectionStateMap {
36
+ /** Retry state for the current step by provider and policy. */
37
+ llmRetry: LlmRetryState;
38
+ }
39
+ }
28
40
  export declare function apply(ctx: Context, config?: Config, internals?: RetryInternals): void;
29
41
  //# sourceMappingURL=index.d.ts.map
@@ -6,10 +6,11 @@
6
6
  */
7
7
  import { randomUUID } from 'node:crypto';
8
8
  import z from '@deepseek-ai/schemastery';
9
+ import { z as zod } from 'zod';
9
10
  import { RetryId } from "./brand.js";
10
11
  export { RetryId } from "./brand.js";
11
12
  export const name = 'llm-retry';
12
- export const inject = ['agents'];
13
+ export const inject = ['agents', 'sessionProjections'];
13
14
  /** Runtime schema for {@link Config}. */
14
15
  export const Config = z.object({});
15
16
  function validateConfig(config) {
@@ -47,6 +48,9 @@ function retryPolicyKey(policy) {
47
48
  policy.jitterRatio,
48
49
  ]);
49
50
  }
51
+ function retryStateKey(provider, policyKey) {
52
+ return JSON.stringify([provider, policyKey]);
53
+ }
50
54
  function cancellableDelay(delayMs, signal) {
51
55
  if (signal.aborted)
52
56
  return Promise.resolve(false);
@@ -62,14 +66,30 @@ function cancellableDelay(delayMs, signal) {
62
66
  signal.addEventListener('abort', onAbort, { once: true });
63
67
  });
64
68
  }
65
- /**
66
- * Install provider-routed normal or unbounded request recovery.
67
- * @param ctx - plugin context that owns the listener and active waits.
68
- * @param config - empty executor config; provider registrations own policy.
69
- * @param internals - non-serializable deterministic hooks for tests.
70
- */
69
+ // The cast bridges the branded retry id, which Zod cannot express directly.
70
+ const llmRetryStateSchema = zod.record(zod.string(), zod.object({
71
+ retry: zod.number().int().nonnegative(),
72
+ retryId: zod.string(),
73
+ }));
71
74
  export function apply(ctx, config = {}, internals = {}) {
72
75
  validateConfig(config);
76
+ ctx.sessionProjections.register({
77
+ key: 'llmRetry',
78
+ stateVersion: 1,
79
+ stateSchema: llmRetryStateSchema,
80
+ init: () => ({}),
81
+ apply: (state, event) => {
82
+ if (event.type === 'step/start' || event.type === 'turn/end')
83
+ return {};
84
+ if (event.type !== 'llm/retry')
85
+ return state;
86
+ const key = retryStateKey(event.data.provider, event.data.policyKey);
87
+ const entry = state[key];
88
+ if (entry?.retry === event.data.retry && entry.retryId === event.data.retryId)
89
+ return state;
90
+ return { ...state, [key]: { retry: event.data.retry, retryId: event.data.retryId } };
91
+ },
92
+ });
73
93
  const random = internals.random ?? Math.random;
74
94
  const lifetime = new AbortController();
75
95
  const active = new Set();
@@ -135,16 +155,13 @@ export function apply(ctx, config = {}, internals = {}) {
135
155
  return next();
136
156
  }
137
157
  const policyKey = retryPolicyKey(policy);
138
- const priorPolicyRetry = agent.session.events.findLast((event) => event.type === 'llm/retry'
139
- && event.data.turn === turn
140
- && event.data.step === step
141
- && event.data.provider === provider
142
- && event.data.policyKey === policyKey);
143
- const previousRetry = priorPolicyRetry?.data.retry ?? 0;
158
+ const retryState = ctx.sessionProjections.stateOf(agent.session, 'llmRetry');
159
+ const previous = retryState[retryStateKey(provider, policyKey)];
160
+ const previousRetry = previous?.retry ?? 0;
144
161
  if (policy.mode === 'normal' && previousRetry >= policy.maxRetries)
145
162
  return next();
146
163
  const retry = previousRetry + 1;
147
- const retryId = priorPolicyRetry?.data.retryId ?? RetryId(randomUUID());
164
+ const retryId = previous?.retryId ?? RetryId(randomUUID());
148
165
  let delayMs;
149
166
  if (failure.providerRetryAfterMs !== undefined
150
167
  && Number.isFinite(failure.providerRetryAfterMs)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-llm-retry",
3
3
  "description": "Provider-routed LLM request retry policy for the DeepSeek Harness",
4
- "version": "0.1.1-rc.2",
4
+ "version": "0.1.2-alpha.3",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -36,34 +36,36 @@
36
36
  ],
37
37
  "license": "MIT",
38
38
  "peerDependencies": {
39
- "@deepseek-ai/dsh-brand": "^0.1.1-rc.2",
40
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
41
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
42
- "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
43
- "@deepseek-ai/dsh-timeout": "^0.1.1-rc.2",
44
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
45
- "@deepseek-ai/cordis": "^4.0.1"
39
+ "@deepseek-ai/dsh-brand": "^0.1.2-alpha.3",
40
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.3",
41
+ "@deepseek-ai/dsh-llm": "^0.1.2-alpha.3",
42
+ "@deepseek-ai/dsh-timeout": "^0.1.2-alpha.3",
43
+ "@deepseek-ai/cordis": "^4.0.2",
44
+ "@deepseek-ai/dsh-session-projection": "^0.1.2-alpha.3",
45
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.3",
46
+ "@deepseek-ai/dsh-agent": "^0.1.2-alpha.3"
46
47
  },
47
48
  "dependencies": {
48
- "@deepseek-ai/schemastery": "^3.18.1"
49
+ "zod": "^4.4.3",
50
+ "@deepseek-ai/schemastery": "^3.18.2"
49
51
  },
50
52
  "devDependencies": {
51
- "@deepseek-ai/dsh-brand": "^0.1.1-rc.2",
52
- "@deepseek-ai/cordis-plugin-include": "^1.0.6",
53
- "@deepseek-ai/cordis-plugin-loader": "^1.0.2",
54
- "@deepseek-ai/dsh-agent-loop": "^0.1.1-rc.2",
55
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
56
- "@deepseek-ai/dsh-agent-loop-testkit": "^0.1.1-rc.2",
57
- "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
58
- "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
59
- "@deepseek-ai/dsh-llm-deepseek": "^0.1.1-rc.2",
60
- "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
61
- "@deepseek-ai/dsh-session-persistence-jsonl": "^0.1.1-rc.2",
62
- "@deepseek-ai/dsh-session-persistence-sqlite": "^0.1.1-rc.2",
63
- "@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
64
- "@deepseek-ai/dsh-llm-mock-server": "^0.1.1-rc.2",
65
- "@deepseek-ai/dsh-timeout": "^0.1.1-rc.2",
66
- "@deepseek-ai/cordis": "^4.0.1",
67
- "@deepseek-ai/dsh-tools": "^0.1.1-rc.2"
53
+ "@deepseek-ai/dsh-brand": "^0.1.2-alpha.3",
54
+ "@deepseek-ai/cordis-plugin-include": "^1.0.7",
55
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.3",
56
+ "@deepseek-ai/dsh-agent": "^0.1.2-alpha.3",
57
+ "@deepseek-ai/dsh-agent-loop": "^0.1.2-alpha.3",
58
+ "@deepseek-ai/dsh-agent-loop-testkit": "^0.1.2-alpha.3",
59
+ "@deepseek-ai/dsh-invariants": "^0.1.2-alpha.3",
60
+ "@deepseek-ai/dsh-llm": "^0.1.2-alpha.3",
61
+ "@deepseek-ai/dsh-llm-deepseek": "^0.1.2-alpha.3",
62
+ "@deepseek-ai/dsh-session": "^0.1.2-alpha.3",
63
+ "@deepseek-ai/dsh-session-persistence-jsonl": "^0.1.2-alpha.3",
64
+ "@deepseek-ai/dsh-llm-mock-server": "^0.1.2-alpha.3",
65
+ "@deepseek-ai/dsh-timeout": "^0.1.2-alpha.3",
66
+ "@deepseek-ai/dsh-system-prompt": "^0.1.2-alpha.3",
67
+ "@deepseek-ai/dsh-tools": "^0.1.2-alpha.3",
68
+ "@deepseek-ai/cordis": "^4.0.2",
69
+ "@deepseek-ai/dsh-session-projection": "^0.1.2-alpha.3"
68
70
  }
69
71
  }