@stackstackstack/dsh-llm-retry 0.1.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepSeek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/llm/llm-retry/README.md
5
+ README.md: 4f4a4209296581f7b275294123df0668034c51d0
6
+ README.zh.md: dea6882d1c5107985d490a13eb67a5d0f4da5b3c
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # `@stackstackstack/dsh-llm-retry`
2
+
3
+ English | [中文](README.zh.md)
4
+
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.
6
+
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: two 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.
8
+
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.
10
+
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 `@stackstackstack/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.
12
+
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.
14
+
15
+ ```yaml
16
+ - name: '@stackstackstack/dsh-llm-deepseek'
17
+ config:
18
+ apiKeyEnv: DEEPSEEK_API_KEY
19
+ retryPolicy:
20
+ mode: always
21
+ backoff:
22
+ initialDelayMs: 1000
23
+ maxDelayMs: 30000
24
+ jitterRatio: 0.2
25
+
26
+ - name: '@stackstackstack/dsh-llm-retry'
27
+ ```
28
+
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.
30
+
31
+ ## Model Experience
32
+
33
+ ### Model-request recovery
34
+
35
+ #### What the model sees
36
+
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.
38
+
39
+ #### Token effect
40
+
41
+ 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.
42
+
43
+ #### KV Cache effect
44
+
45
+ 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.
46
+
47
+ ## Known Limitations and Deferred Work
48
+
49
+ - **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
+ - **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
+ - **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
+ - **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
+ - **`llm/retry` records scheduling, not completion** — later step and turn events establish success, exhaustion, or cancellation.
package/README.zh.md ADDED
@@ -0,0 +1,53 @@
1
+ # `@stackstackstack/dsh-llm-retry`
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 一个函数插件,通过 agent loop(智能体循环)在已关闭步骤上触发的 `agent/request-error` waterfall(瀑布式事件)应用确切提供方重试策略。它不包装 `ctx.llm.stream()`:每次适配器调用仍是一次提供方尝试,每次重试都会开启新的编号轮次。
6
+
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(资源释放)会在活跃的委托恢复完全停稳后终止它。
8
+
9
+ 两种 mode 都使用带对称 jitter 的有界指数退避。有效 `providerRetryAfterMs` 不超过 `maxDelayMs` 时会替换本地退避,并且不加 jitter。超出上限的提供方延迟会使 normal mode 继续委托;always mode 则改用已配置的本地退避,避免该指令终止重试。
10
+
11
+ 等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含共享 `retryId`、提供方、mode、已解析策略的规范 key、失败和计划延迟。该载荷由可安全用于浏览器的 `@stackstackstack/dsh-llm-retry/types` 子路径导出,因此远程渲染器无需加载策略运行时即可使用该持久状态。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,用限制、code 成员关系或退避不同的路由替换后,会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待完成时,插件会在返回 `{ kind: 'retry' }` 前立即追加 `llm/retry-started`,其中带有相同的 `retryId`、轮次、步骤与重试编号;退避期间取消则不会写入 started 事件。随后循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前等待活跃的委托恢复结算,并使 dispose 前捕获的 callback 只能以失败结束。
12
+
13
+ 单独发布的 `./invariant` 配套模块会检查每个已调度重试是否指向当前开启轮次及其最新已关闭步骤,是否与失败请求的持久提供方匹配,是否携带非空的提供方与策略标识,是否满足 mode 特定边界,是否拥有唯一步骤记录和正确的提供方策略重试编号,以及是否携带有界定时器延迟。它还要求每个 `llm/retry-started` 事件通过相同的 `retryId`、轮次、步骤与重试编号指向一个先前调度的尝试,并拒绝重复的 started 事件。full jitter 可以在下界调度为零毫秒。
14
+
15
+ ```yaml
16
+ - name: '@stackstackstack/dsh-llm-deepseek'
17
+ config:
18
+ apiKeyEnv: DEEPSEEK_API_KEY
19
+ retryPolicy:
20
+ mode: always
21
+ backoff:
22
+ initialDelayMs: 1000
23
+ maxDelayMs: 30000
24
+ jitterRatio: 0.2
25
+
26
+ - name: '@stackstackstack/dsh-llm-retry'
27
+ ```
28
+
29
+ 执行器没有策略配置。`dsh-llm-pi-ai` 等多提供方适配器会把 `retryPolicy` 放在每个提供方 profile 内,避免维护第二份提供方名称列表。
30
+
31
+ ## 模型体验
32
+
33
+ ### 模型请求恢复
34
+
35
+ #### 模型看到的内容
36
+
37
+ 模型不会看到重试事件、延迟、提供方错误或失败的部分输出。重试轮次会从持久表层历史中重建相同的显式提供方/模型请求,除非下游恢复策略有意更改该表层;失败分片绝不会进入派生消息。
38
+
39
+ #### Token 影响
40
+
41
+ 每次重试都是新的提供方请求,可能重复计费输入 token。normal mode 具有有限预算;always mode 可以在成功或取消前消耗无界数量的请求。`llm/retry` 自身不产生 token。
42
+
43
+ #### KV Cache 影响
44
+
45
+ 重建请求保留之前的前缀,并可根据该提供方的规则复用 cache。非表层重试事件不会改变 cache 身份。
46
+
47
+ ## 已知限制与暂缓事项
48
+
49
+ - **agent 轮次是唯一重试边界**:直接 `ctx.llm.stream()` 消费方仍只尝试一次,因为原始流无法持久地区分各次尝试已经发出的分片。
50
+ - **always mode 会重试永久性失败**:身份验证、配额、无效请求、协议和无法恢复的上下文错误都会继续重试,直至成功、取消或 dispose;部署负责提供方特定的成本与延迟控制。
51
+ - **有限插件预算可叠加**:normal mode 只统计已配置 code 和确切提供方策略,上下文溢出压缩(compaction)则拥有独立预算。任何重叠策略都必须定义注册顺序行为。
52
+ - **恢复策略按 waterfall 顺序组合**:always mode 会先接受下游重试,再应用自己的回退。后续策略如果忽略取消且永不结算,也会阻止回退、轮次完全停稳和插件 dispose 完成。
53
+ - **`llm/retry` 记录调度,不是完成**:后续步骤与轮次事件用于确立成功、耗尽或取消。
package/lib/index.js ADDED
@@ -0,0 +1,164 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import z from "@deepseek-ai/schemastery";
3
+ //#region lib/types/brand.js
4
+ /**
5
+ * Brand an implementation-minted retry-chain identity.
6
+ * @param id - opaque retry identity.
7
+ * @returns the same string, branded; no validation is performed.
8
+ */
9
+ function RetryId(id) {
10
+ return id;
11
+ }
12
+ //#endregion
13
+ //#region lib/types/index.js
14
+ /**
15
+ * Provider-routed model-request retry policy on the agent loop's request
16
+ * recovery extension point. Each scheduled retry is durable before its cancellable wait.
17
+ *
18
+ * @module @stackstackstack/dsh-llm-retry
19
+ */
20
+ const name = "llm-retry";
21
+ const inject = ["agents"];
22
+ /** Runtime schema for {@link Config}. */
23
+ const Config = z.object({});
24
+ function validateConfig(config) {
25
+ const [key] = Object.keys(config);
26
+ if (key === void 0) return;
27
+ if (key === "retryPolicy") throw new Error("llm-retry: retryPolicy belongs under each provider configuration");
28
+ throw new Error(`llm-retry: unknown key "${key}"`);
29
+ }
30
+ async function settleDownstream(next) {
31
+ try {
32
+ return {
33
+ type: "decision",
34
+ decision: await next()
35
+ };
36
+ } catch (error) {
37
+ return {
38
+ type: "error",
39
+ error
40
+ };
41
+ }
42
+ }
43
+ function localDelay(config, retry, random) {
44
+ const exponent = Math.min(retry - 1, 1024);
45
+ const exponential = Math.min(config.initialDelayMs * 2 ** exponent, config.maxDelayMs);
46
+ const jitter = 1 - config.jitterRatio + 2 * config.jitterRatio * random();
47
+ return Math.min(exponential * jitter, config.maxDelayMs);
48
+ }
49
+ function retryPolicyKey(policy) {
50
+ return policy.mode === "always" ? JSON.stringify([
51
+ policy.mode,
52
+ policy.initialDelayMs,
53
+ policy.maxDelayMs,
54
+ policy.jitterRatio
55
+ ]) : JSON.stringify([
56
+ policy.mode,
57
+ policy.maxRetries,
58
+ [...policy.retryableCodes].sort(),
59
+ policy.initialDelayMs,
60
+ policy.maxDelayMs,
61
+ policy.jitterRatio
62
+ ]);
63
+ }
64
+ function cancellableDelay(delayMs, signal) {
65
+ if (signal.aborted) return Promise.resolve(false);
66
+ return new Promise((resolve) => {
67
+ const timer = setTimeout(() => {
68
+ signal.removeEventListener("abort", onAbort);
69
+ resolve(true);
70
+ }, delayMs);
71
+ function onAbort() {
72
+ clearTimeout(timer);
73
+ resolve(false);
74
+ }
75
+ signal.addEventListener("abort", onAbort, { once: true });
76
+ });
77
+ }
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
+ */
84
+ function apply(ctx, config = {}, internals = {}) {
85
+ validateConfig(config);
86
+ const random = internals.random ?? Math.random;
87
+ const lifetime = new AbortController();
88
+ const active = /* @__PURE__ */ new Set();
89
+ function track(operation) {
90
+ const tracked = operation.finally(() => active.delete(tracked));
91
+ active.add(tracked);
92
+ return tracked;
93
+ }
94
+ async function backoff(agent, turn, step, failure, provider, policy, policyKey, retry, retryId, delayMs, signal) {
95
+ const fusedSignal = AbortSignal.any([signal, lifetime.signal]);
96
+ if (fusedSignal.aborted) return;
97
+ const eventData = policy.mode === "normal" ? {
98
+ retryId,
99
+ turn,
100
+ step,
101
+ provider,
102
+ mode: policy.mode,
103
+ policyKey,
104
+ retry,
105
+ maxRetries: policy.maxRetries,
106
+ delayMs,
107
+ failure
108
+ } : {
109
+ retryId,
110
+ turn,
111
+ step,
112
+ provider,
113
+ mode: policy.mode,
114
+ policyKey,
115
+ retry,
116
+ delayMs,
117
+ failure
118
+ };
119
+ agent.session.append("llm/retry", eventData);
120
+ if (!await cancellableDelay(delayMs, fusedSignal)) return;
121
+ agent.session.append("llm/retry-started", {
122
+ retryId,
123
+ turn,
124
+ step,
125
+ retry
126
+ });
127
+ return { kind: "retry" };
128
+ }
129
+ async function recover({ agent, turn, step, provider, failure, retryPolicy: policy, signal }, next) {
130
+ if (policy === void 0) return next();
131
+ if (policy.mode === "always") {
132
+ if (signal.aborted || lifetime.signal.aborted) return;
133
+ const fusedSignal = AbortSignal.any([signal, lifetime.signal]);
134
+ const downstream = await settleDownstream(next);
135
+ if (fusedSignal.aborted) return;
136
+ if (downstream.type === "error") ctx.logger.warn(`llm-retry: provider "${provider}" always policy ignored a downstream recovery failure: %o`, downstream.error);
137
+ if (downstream.type === "decision" && downstream.decision?.kind === "retry") return downstream.decision;
138
+ } else if (!policy.retryableCodes.includes(failure.code)) return next();
139
+ 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;
142
+ if (policy.mode === "normal" && previousRetry >= policy.maxRetries) return next();
143
+ const retry = previousRetry + 1;
144
+ const retryId = priorPolicyRetry?.data.retryId ?? RetryId(randomUUID());
145
+ let delayMs;
146
+ if (failure.providerRetryAfterMs !== void 0 && Number.isFinite(failure.providerRetryAfterMs) && failure.providerRetryAfterMs > 0) if (failure.providerRetryAfterMs > policy.maxDelayMs) {
147
+ if (policy.mode === "normal") return next();
148
+ delayMs = localDelay(policy, retry, random);
149
+ } else delayMs = failure.providerRetryAfterMs;
150
+ else delayMs = localDelay(policy, retry, random);
151
+ return backoff(agent, turn, step, failure, provider, policy, policyKey, retry, retryId, delayMs, signal);
152
+ }
153
+ const disposeListener = ctx.on("agent/request-error", (payload, next) => {
154
+ if (lifetime.signal.aborted) return Promise.resolve(void 0);
155
+ return track(recover(payload, next));
156
+ });
157
+ ctx.effect(() => async () => {
158
+ disposeListener();
159
+ lifetime.abort(/* @__PURE__ */ new Error("llm-retry plugin disposed"));
160
+ await Promise.allSettled([...active]);
161
+ }, "llm-retry: abort and drain active recovery");
162
+ }
163
+ //#endregion
164
+ export { Config, RetryId, apply, inject, name };
@@ -0,0 +1,108 @@
1
+ import { MAX_TIMER_DELAY_MS } from "@stackstackstack/dsh-timeout";
2
+ //#region lib/types/history.js
3
+ /** Durable request-route lookup for one open model step. @module @stackstackstack/dsh-llm-retry/history */
4
+ /**
5
+ * Find the provider in force for one currently open step.
6
+ * Request headers remain effective across turn boundaries until a newer full
7
+ * snapshot changes them; every provider change requires a newer full snapshot.
8
+ * @param events - session events ending inside the open step.
9
+ * @param turn - turn that owns the failed step.
10
+ * @param step - failed step whose provider is required.
11
+ * @returns the provider from the request header in force for the step.
12
+ */
13
+ function providerForOpenStep(events, turn, step) {
14
+ const stepStartIndex = events.findLastIndex((event) => event.type === "step/start" && event.data.turn === turn && event.data.step === step);
15
+ if (stepStartIndex < 0 || events.slice(stepStartIndex + 1).some((event) => event.type === "step/end" || event.type === "turn/end")) return void 0;
16
+ for (let index = events.length - 1; index >= 0; index -= 1) {
17
+ const event = events[index];
18
+ if (event.type === "request/header") return event.data.header.config.provider;
19
+ }
20
+ }
21
+ //#endregion
22
+ //#region lib/types/invariant.js
23
+ /** Package-owned durable retry-event invariants. @module @stackstackstack/dsh-llm-retry/invariant */
24
+ const PACKAGE_NAME = "@stackstackstack/dsh-llm-retry";
25
+ /** Cordis companion plugin name. */
26
+ const name = "llm-retry-invariant";
27
+ /** Service required before the companion can reserve package ownership. */
28
+ const inject = ["invariants"];
29
+ /** Validate the complete provider-neutral failure payload at the durable boundary. */
30
+ function validateFailure(value, fail) {
31
+ if (typeof value !== "object" || value === null) fail("llm/retry failure must be an object");
32
+ const failure = value;
33
+ if (typeof failure.message !== "string" || failure.message.length === 0) fail("llm/retry failure.message must be a non-empty string");
34
+ if (typeof failure.code !== "string" || failure.code.length === 0) fail("llm/retry failure.code must be a non-empty string");
35
+ if (failure.status !== void 0 && (!Number.isInteger(failure.status) || failure.status < 100 || failure.status > 599)) fail("llm/retry failure.status must be an integer from 100 through 599 when present");
36
+ if (failure.providerRetryAfterMs !== void 0 && (!Number.isFinite(failure.providerRetryAfterMs) || failure.providerRetryAfterMs <= 0)) fail("llm/retry failure.providerRetryAfterMs must be a positive finite number when present");
37
+ if (failure.requestId !== void 0 && (typeof failure.requestId !== "string" || failure.requestId.length === 0)) fail("llm/retry failure.requestId must be a non-empty string when present");
38
+ }
39
+ /** Validate one retry record against the currently open request step. */
40
+ function validateRetry(history, event, fail) {
41
+ const { retryId, turn, step, provider, mode, policyKey, retry, delayMs } = event.data;
42
+ if (typeof retryId !== "string" || retryId.length === 0) fail("llm/retry retryId must be a non-empty string");
43
+ const failure = event.data.failure;
44
+ validateFailure(failure, fail);
45
+ if (!Number.isSafeInteger(retry) || retry < 1) fail("llm/retry retry must be a positive safe integer");
46
+ if (typeof provider !== "string" || provider.length === 0) fail("llm/retry provider must be a non-empty string");
47
+ if (typeof policyKey !== "string" || policyKey.length === 0) fail("llm/retry policyKey must be a non-empty string");
48
+ switch (mode) {
49
+ case "normal": {
50
+ const { maxRetries } = event.data;
51
+ if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`);
52
+ break;
53
+ }
54
+ case "always":
55
+ if ("maxRetries" in event.data) fail("llm/retry always mode must omit maxRetries");
56
+ break;
57
+ default: fail(`llm/retry mode must be normal or always, got ${String(mode)}`);
58
+ }
59
+ 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}`);
60
+ const turnBoundary = history.findLast((prior) => prior.type === "turn/start" || prior.type === "turn/end");
61
+ if (turnBoundary?.type !== "turn/start") fail("llm/retry must be appended inside an open turn");
62
+ if (turn !== turnBoundary.data.turn) fail(`llm/retry names turn ${turn}, but the open turn is ${turnBoundary.data.turn}`);
63
+ const stepBoundary = history.findLast((prior) => prior.type === "step/start" || prior.type === "step/end");
64
+ if (stepBoundary?.type !== "step/start") fail("llm/retry must be appended inside an open step");
65
+ if (step !== stepBoundary.data.step || turn !== stepBoundary.data.turn) fail(`llm/retry names turn ${turn}/step ${step}, but the open step is ${stepBoundary.data.turn}/${stepBoundary.data.step}`);
66
+ const routedProvider = providerForOpenStep(history, turn, step);
67
+ if (routedProvider !== provider) fail(`llm/retry provider ${provider} does not match the failed request provider ${String(routedProvider)}`);
68
+ const priorPolicyRetry = history.findLast((prior) => prior.type === "llm/retry" && prior.data.turn === turn && prior.data.step === step && prior.data.provider === provider && prior.data.policyKey === policyKey);
69
+ const expectedRetry = (priorPolicyRetry?.data.retry ?? 0) + 1;
70
+ if (retry !== expectedRetry) fail(`llm/retry retry ${retry} must equal provider policy retry ${expectedRetry}`);
71
+ if (priorPolicyRetry !== void 0 && priorPolicyRetry.data.retryId !== retryId) fail("llm/retry must preserve retryId across one provider-policy chain");
72
+ if (priorPolicyRetry === void 0 && history.some((prior) => (prior.type === "llm/retry" || prior.type === "llm/retry-started") && prior.data.retryId === retryId)) fail(`llm/retry retryId ${JSON.stringify(retryId)} is already owned by another chain`);
73
+ }
74
+ /** Validate one wait-complete transition against its scheduled attempt. */
75
+ function validateStarted(history, event, fail) {
76
+ const { retryId, turn, step, retry } = event.data;
77
+ if (typeof retryId !== "string" || retryId.length === 0) fail("llm/retry-started retryId must be a non-empty string");
78
+ const scheduled = history.findLast((prior) => prior.type === "llm/retry" && prior.data.retryId === retryId && prior.data.retry === retry);
79
+ if (scheduled === void 0) fail("llm/retry-started pairs no prior scheduled attempt");
80
+ if (scheduled.data.turn !== turn || scheduled.data.step !== step) fail("llm/retry-started turn/step must match its scheduled attempt");
81
+ if (history.some((prior) => prior.type === "llm/retry-started" && prior.data.retryId === retryId && prior.data.retry === retry)) fail("llm/retry-started repeats one scheduled attempt");
82
+ }
83
+ /** Validate every retry record already present in one loaded session. */
84
+ function validateSession(session, fail) {
85
+ for (const [index, event] of session.events.entries()) if (event.type === "llm/retry") validateRetry(session.events.slice(0, index), event, fail);
86
+ else if (event.type === "llm/retry-started") validateStarted(session.events.slice(0, index), event, fail);
87
+ }
88
+ /** Install validation for loaded and newly appended retry records. */
89
+ const install = Object.assign((ctx, fail) => {
90
+ for (const session of ctx.sessions.list()) validateSession(session, fail);
91
+ ctx.on("session/created", (session) => {
92
+ validateSession(session, fail);
93
+ }, { global: true });
94
+ ctx.on("internal/dispatch", (_mode, eventName, args) => {
95
+ if (eventName !== "session/event") return;
96
+ const [session, event] = args;
97
+ if (event.type === "llm/retry") validateRetry(session.events, event, fail);
98
+ else if (event.type === "llm/retry-started") validateStarted(session.events, event, fail);
99
+ }, { global: true });
100
+ }, { inject: ["sessions"] });
101
+ /**
102
+ * Register the LLM retry invariant companion.
103
+ * @param ctx - Cordis context carrying the invariant service.
104
+ * @returns the installed registration's disposer after setup succeeds.
105
+ */
106
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
107
+ //#endregion
108
+ export { apply, inject, name };
@@ -0,0 +1,10 @@
1
+ import type { Branded } from '@stackstackstack/dsh-brand';
2
+ /** Stable identity shared by every attempt in one request-step retry chain. */
3
+ export type RetryId = Branded<'RetryId'>;
4
+ /**
5
+ * Brand an implementation-minted retry-chain identity.
6
+ * @param id - opaque retry identity.
7
+ * @returns the same string, branded; no validation is performed.
8
+ */
9
+ export declare function RetryId(id: string): RetryId;
10
+ //# sourceMappingURL=brand.d.ts.map
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Brand an implementation-minted retry-chain identity.
3
+ * @param id - opaque retry identity.
4
+ * @returns the same string, branded; no validation is performed.
5
+ */
6
+ export function RetryId(id) {
7
+ return id;
8
+ }
9
+ //# sourceMappingURL=brand.js.map
@@ -0,0 +1,13 @@
1
+ /** Durable request-route lookup for one open model step. @module @stackstackstack/dsh-llm-retry/history */
2
+ import type { SessionEvent } from '@stackstackstack/dsh-session';
3
+ /**
4
+ * Find the provider in force for one currently open step.
5
+ * Request headers remain effective across turn boundaries until a newer full
6
+ * snapshot changes them; every provider change requires a newer full snapshot.
7
+ * @param events - session events ending inside the open step.
8
+ * @param turn - turn that owns the failed step.
9
+ * @param step - failed step whose provider is required.
10
+ * @returns the provider from the request header in force for the step.
11
+ */
12
+ export declare function providerForOpenStep(events: readonly SessionEvent[], turn: number, step: number): string | undefined;
13
+ //# sourceMappingURL=history.d.ts.map
@@ -0,0 +1,26 @@
1
+ /** Durable request-route lookup for one open model step. @module @stackstackstack/dsh-llm-retry/history */
2
+ /**
3
+ * Find the provider in force for one currently open step.
4
+ * Request headers remain effective across turn boundaries until a newer full
5
+ * snapshot changes them; every provider change requires a newer full snapshot.
6
+ * @param events - session events ending inside the open step.
7
+ * @param turn - turn that owns the failed step.
8
+ * @param step - failed step whose provider is required.
9
+ * @returns the provider from the request header in force for the step.
10
+ */
11
+ export function providerForOpenStep(events, turn, step) {
12
+ const stepStartIndex = events.findLastIndex(event => event.type === 'step/start'
13
+ && event.data.turn === turn
14
+ && event.data.step === step);
15
+ if (stepStartIndex < 0 || events.slice(stepStartIndex + 1).some(event => event.type === 'step/end' || event.type === 'turn/end'))
16
+ return undefined;
17
+ for (let index = events.length - 1; index >= 0; index -= 1) {
18
+ // The loop bounds prove this indexed read exists.
19
+ // oxlint-disable-next-line typescript/no-non-null-assertion
20
+ const event = events[index];
21
+ if (event.type === 'request/header')
22
+ return event.data.header.config.provider;
23
+ }
24
+ return undefined;
25
+ }
26
+ //# sourceMappingURL=history.js.map
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Provider-routed model-request retry policy on the agent loop's request
3
+ * recovery extension point. Each scheduled retry is durable before its cancellable wait.
4
+ *
5
+ * @module @stackstackstack/dsh-llm-retry
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis';
8
+ import z from '@deepseek-ai/schemastery';
9
+ export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts';
10
+ export { RetryId } from './brand.ts';
11
+ export declare const name = "llm-retry";
12
+ export declare const inject: string[];
13
+ /** This policy executor has no config; providers own `retryPolicy`. */
14
+ export type Config = Readonly<Record<string, never>>;
15
+ /** Runtime schema for {@link Config}. */
16
+ export declare const Config: z<Config>;
17
+ /** Non-serializable hooks used to make timing policy deterministic in tests. */
18
+ export interface RetryInternals {
19
+ /** Random sample in the inclusive zero-to-one range used for jitter. */
20
+ random?: () => number;
21
+ }
22
+ /**
23
+ * Install provider-routed normal or unbounded request recovery.
24
+ * @param ctx - plugin context that owns the listener and active waits.
25
+ * @param config - empty executor config; provider registrations own policy.
26
+ * @param internals - non-serializable deterministic hooks for tests.
27
+ */
28
+ export declare function apply(ctx: Context, config?: Config, internals?: RetryInternals): void;
29
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Provider-routed model-request retry policy on the agent loop's request
3
+ * recovery extension point. Each scheduled retry is durable before its cancellable wait.
4
+ *
5
+ * @module @stackstackstack/dsh-llm-retry
6
+ */
7
+ import { randomUUID } from 'node:crypto';
8
+ import z from '@deepseek-ai/schemastery';
9
+ import { RetryId } from "./brand.js";
10
+ export { RetryId } from "./brand.js";
11
+ export const name = 'llm-retry';
12
+ export const inject = ['agents'];
13
+ /** Runtime schema for {@link Config}. */
14
+ export const Config = z.object({});
15
+ function validateConfig(config) {
16
+ const [key] = Object.keys(config);
17
+ if (key === undefined)
18
+ return;
19
+ if (key === 'retryPolicy') {
20
+ throw new Error('llm-retry: retryPolicy belongs under each provider configuration');
21
+ }
22
+ throw new Error(`llm-retry: unknown key "${key}"`);
23
+ }
24
+ async function settleDownstream(next) {
25
+ try {
26
+ return { type: 'decision', decision: await next() };
27
+ }
28
+ catch (error) {
29
+ return { type: 'error', error };
30
+ }
31
+ }
32
+ function localDelay(config, retry, random) {
33
+ const exponent = Math.min(retry - 1, 1024);
34
+ const exponential = Math.min(config.initialDelayMs * 2 ** exponent, config.maxDelayMs);
35
+ const jitter = 1 - config.jitterRatio + 2 * config.jitterRatio * random();
36
+ return Math.min(exponential * jitter, config.maxDelayMs);
37
+ }
38
+ function retryPolicyKey(policy) {
39
+ return policy.mode === 'always'
40
+ ? JSON.stringify([policy.mode, policy.initialDelayMs, policy.maxDelayMs, policy.jitterRatio])
41
+ : JSON.stringify([
42
+ policy.mode,
43
+ policy.maxRetries,
44
+ [...policy.retryableCodes].sort(),
45
+ policy.initialDelayMs,
46
+ policy.maxDelayMs,
47
+ policy.jitterRatio,
48
+ ]);
49
+ }
50
+ function cancellableDelay(delayMs, signal) {
51
+ if (signal.aborted)
52
+ return Promise.resolve(false);
53
+ return new Promise((resolve) => {
54
+ const timer = setTimeout(() => {
55
+ signal.removeEventListener('abort', onAbort);
56
+ resolve(true);
57
+ }, delayMs);
58
+ function onAbort() {
59
+ clearTimeout(timer);
60
+ resolve(false);
61
+ }
62
+ signal.addEventListener('abort', onAbort, { once: true });
63
+ });
64
+ }
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
+ */
71
+ export function apply(ctx, config = {}, internals = {}) {
72
+ validateConfig(config);
73
+ const random = internals.random ?? Math.random;
74
+ const lifetime = new AbortController();
75
+ const active = new Set();
76
+ function track(operation) {
77
+ const tracked = operation.finally(() => active.delete(tracked));
78
+ active.add(tracked);
79
+ return tracked;
80
+ }
81
+ async function backoff(agent, turn, step, failure, provider, policy, policyKey, retry, retryId, delayMs, signal) {
82
+ const fusedSignal = AbortSignal.any([signal, lifetime.signal]);
83
+ if (fusedSignal.aborted)
84
+ return;
85
+ const eventData = policy.mode === 'normal'
86
+ ? {
87
+ retryId,
88
+ turn,
89
+ step,
90
+ provider,
91
+ mode: policy.mode,
92
+ policyKey,
93
+ retry,
94
+ maxRetries: policy.maxRetries,
95
+ delayMs,
96
+ failure,
97
+ }
98
+ : {
99
+ retryId,
100
+ turn,
101
+ step,
102
+ provider,
103
+ mode: policy.mode,
104
+ policyKey,
105
+ retry,
106
+ delayMs,
107
+ failure,
108
+ };
109
+ agent.session.append('llm/retry', eventData);
110
+ if (!await cancellableDelay(delayMs, fusedSignal))
111
+ return;
112
+ agent.session.append('llm/retry-started', { retryId, turn, step, retry });
113
+ return { kind: 'retry' };
114
+ }
115
+ async function recover({ agent, turn, step, provider, failure, retryPolicy: policy, signal }, next) {
116
+ if (policy === undefined)
117
+ return next();
118
+ if (policy.mode === 'always') {
119
+ if (signal.aborted || lifetime.signal.aborted)
120
+ return;
121
+ const fusedSignal = AbortSignal.any([signal, lifetime.signal]);
122
+ // The loop and plugin lifetime stay open until delegated recovery settles.
123
+ // An abort then wins before the decision or fallback can mutate later state.
124
+ const downstream = await settleDownstream(next);
125
+ if (fusedSignal.aborted)
126
+ return;
127
+ if (downstream.type === 'error') {
128
+ ctx.logger.warn(`llm-retry: provider "${provider}" always policy ignored a downstream recovery failure: %o`, downstream.error);
129
+ }
130
+ if (downstream.type === 'decision' && downstream.decision?.kind === 'retry') {
131
+ return downstream.decision;
132
+ }
133
+ }
134
+ else if (!policy.retryableCodes.includes(failure.code)) {
135
+ return next();
136
+ }
137
+ 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;
144
+ if (policy.mode === 'normal' && previousRetry >= policy.maxRetries)
145
+ return next();
146
+ const retry = previousRetry + 1;
147
+ const retryId = priorPolicyRetry?.data.retryId ?? RetryId(randomUUID());
148
+ let delayMs;
149
+ if (failure.providerRetryAfterMs !== undefined
150
+ && Number.isFinite(failure.providerRetryAfterMs)
151
+ && failure.providerRetryAfterMs > 0) {
152
+ if (failure.providerRetryAfterMs > policy.maxDelayMs) {
153
+ if (policy.mode === 'normal')
154
+ return next();
155
+ delayMs = localDelay(policy, retry, random);
156
+ }
157
+ else {
158
+ delayMs = failure.providerRetryAfterMs;
159
+ }
160
+ }
161
+ else {
162
+ delayMs = localDelay(policy, retry, random);
163
+ }
164
+ return backoff(agent, turn, step, failure, provider, policy, policyKey, retry, retryId, delayMs, signal);
165
+ }
166
+ const disposeListener = ctx.on('agent/request-error', (payload, next) => {
167
+ // A waterfall may have captured this callback before its registration was
168
+ // removed. Lifetime cancellation must prevent that stale callback from
169
+ // entering a downstream policy after disposal.
170
+ if (lifetime.signal.aborted)
171
+ return Promise.resolve(undefined);
172
+ return track(recover(payload, next));
173
+ });
174
+ ctx.effect(() => async () => {
175
+ disposeListener();
176
+ lifetime.abort(new Error('llm-retry plugin disposed'));
177
+ await Promise.allSettled([...active]);
178
+ }, 'llm-retry: abort and drain active recovery');
179
+ }
180
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,13 @@
1
+ /** Package-owned durable retry-event invariants. @module @stackstackstack/dsh-llm-retry/invariant */
2
+ import type { Context } from '@deepseek-ai/cordis';
3
+ /** Cordis companion plugin name. */
4
+ export declare const name = "llm-retry-invariant";
5
+ /** Service required before the companion can reserve package ownership. */
6
+ export declare const inject: string[];
7
+ /**
8
+ * Register the LLM retry invariant companion.
9
+ * @param ctx - Cordis context carrying the invariant service.
10
+ * @returns the installed registration's disposer after setup succeeds.
11
+ */
12
+ export declare const apply: (ctx: Context) => Promise<() => void>;
13
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,152 @@
1
+ /** Package-owned durable retry-event invariants. @module @stackstackstack/dsh-llm-retry/invariant */
2
+ import { MAX_TIMER_DELAY_MS } from '@stackstackstack/dsh-timeout';
3
+ import { providerForOpenStep } from "./history.js";
4
+ const PACKAGE_NAME = '@stackstackstack/dsh-llm-retry';
5
+ /** Cordis companion plugin name. */
6
+ export const name = 'llm-retry-invariant';
7
+ /** Service required before the companion can reserve package ownership. */
8
+ export const inject = ['invariants'];
9
+ /** Validate the complete provider-neutral failure payload at the durable boundary. */
10
+ function validateFailure(value, fail) {
11
+ if (typeof value !== 'object' || value === null) {
12
+ fail('llm/retry failure must be an object');
13
+ }
14
+ const failure = value;
15
+ if (typeof failure.message !== 'string' || failure.message.length === 0) {
16
+ fail('llm/retry failure.message must be a non-empty string');
17
+ }
18
+ if (typeof failure.code !== 'string' || failure.code.length === 0) {
19
+ fail('llm/retry failure.code must be a non-empty string');
20
+ }
21
+ if (failure.status !== undefined
22
+ && (!Number.isInteger(failure.status) || failure.status < 100 || failure.status > 599)) {
23
+ fail('llm/retry failure.status must be an integer from 100 through 599 when present');
24
+ }
25
+ if (failure.providerRetryAfterMs !== undefined
26
+ && (!Number.isFinite(failure.providerRetryAfterMs) || failure.providerRetryAfterMs <= 0)) {
27
+ fail('llm/retry failure.providerRetryAfterMs must be a positive finite number when present');
28
+ }
29
+ if (failure.requestId !== undefined
30
+ && (typeof failure.requestId !== 'string' || failure.requestId.length === 0)) {
31
+ fail('llm/retry failure.requestId must be a non-empty string when present');
32
+ }
33
+ }
34
+ /** Validate one retry record against the currently open request step. */
35
+ function validateRetry(history, event, fail) {
36
+ const { retryId, turn, step, provider, mode, policyKey, retry, delayMs } = event.data;
37
+ if (typeof retryId !== 'string' || retryId.length === 0) {
38
+ fail('llm/retry retryId must be a non-empty string');
39
+ }
40
+ const failure = event.data.failure;
41
+ validateFailure(failure, fail);
42
+ if (!Number.isSafeInteger(retry) || retry < 1) {
43
+ fail('llm/retry retry must be a positive safe integer');
44
+ }
45
+ if (typeof provider !== 'string' || provider.length === 0) {
46
+ fail('llm/retry provider must be a non-empty string');
47
+ }
48
+ if (typeof policyKey !== 'string' || policyKey.length === 0) {
49
+ fail('llm/retry policyKey must be a non-empty string');
50
+ }
51
+ switch (mode) {
52
+ case 'normal': {
53
+ const { maxRetries } = event.data;
54
+ if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) {
55
+ fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`);
56
+ }
57
+ break;
58
+ }
59
+ case 'always':
60
+ if ('maxRetries' in event.data)
61
+ fail('llm/retry always mode must omit maxRetries');
62
+ break;
63
+ default:
64
+ fail(`llm/retry mode must be normal or always, got ${String(mode)}`);
65
+ }
66
+ if (typeof delayMs !== 'number' || !Number.isFinite(delayMs)
67
+ || delayMs < 0 || delayMs > MAX_TIMER_DELAY_MS) {
68
+ fail(`llm/retry delayMs must be a finite number within 0..${MAX_TIMER_DELAY_MS}`);
69
+ }
70
+ const turnBoundary = history.findLast(prior => prior.type === 'turn/start' || prior.type === 'turn/end');
71
+ if (turnBoundary?.type !== 'turn/start') {
72
+ fail('llm/retry must be appended inside an open turn');
73
+ }
74
+ if (turn !== turnBoundary.data.turn) {
75
+ fail(`llm/retry names turn ${turn}, but the open turn is ${turnBoundary.data.turn}`);
76
+ }
77
+ const stepBoundary = history.findLast(prior => prior.type === 'step/start' || prior.type === 'step/end');
78
+ if (stepBoundary?.type !== 'step/start') {
79
+ fail('llm/retry must be appended inside an open step');
80
+ }
81
+ if (step !== stepBoundary.data.step || turn !== stepBoundary.data.turn) {
82
+ fail(`llm/retry names turn ${turn}/step ${step}, but the open step is ${stepBoundary.data.turn}/${stepBoundary.data.step}`);
83
+ }
84
+ const routedProvider = providerForOpenStep(history, turn, step);
85
+ if (routedProvider !== provider) {
86
+ fail(`llm/retry provider ${provider} does not match the failed request provider ${String(routedProvider)}`);
87
+ }
88
+ const priorPolicyRetry = history.findLast((prior) => prior.type === 'llm/retry'
89
+ && prior.data.turn === turn
90
+ && prior.data.step === step
91
+ && prior.data.provider === provider
92
+ && prior.data.policyKey === policyKey);
93
+ const expectedRetry = (priorPolicyRetry?.data.retry ?? 0) + 1;
94
+ if (retry !== expectedRetry) {
95
+ fail(`llm/retry retry ${retry} must equal provider policy retry ${expectedRetry}`);
96
+ }
97
+ if (priorPolicyRetry !== undefined && priorPolicyRetry.data.retryId !== retryId) {
98
+ fail('llm/retry must preserve retryId across one provider-policy chain');
99
+ }
100
+ if (priorPolicyRetry === undefined && history.some(prior => (prior.type === 'llm/retry' || prior.type === 'llm/retry-started')
101
+ && prior.data.retryId === retryId)) {
102
+ fail(`llm/retry retryId ${JSON.stringify(retryId)} is already owned by another chain`);
103
+ }
104
+ }
105
+ /** Validate one wait-complete transition against its scheduled attempt. */
106
+ function validateStarted(history, event, fail) {
107
+ const { retryId, turn, step, retry } = event.data;
108
+ if (typeof retryId !== 'string' || retryId.length === 0) {
109
+ fail('llm/retry-started retryId must be a non-empty string');
110
+ }
111
+ const scheduled = history.findLast((prior) => prior.type === 'llm/retry' && prior.data.retryId === retryId && prior.data.retry === retry);
112
+ if (scheduled === undefined)
113
+ fail('llm/retry-started pairs no prior scheduled attempt');
114
+ if (scheduled.data.turn !== turn || scheduled.data.step !== step) {
115
+ fail('llm/retry-started turn/step must match its scheduled attempt');
116
+ }
117
+ if (history.some(prior => prior.type === 'llm/retry-started'
118
+ && prior.data.retryId === retryId && prior.data.retry === retry)) {
119
+ fail('llm/retry-started repeats one scheduled attempt');
120
+ }
121
+ }
122
+ /** Validate every retry record already present in one loaded session. */
123
+ function validateSession(session, fail) {
124
+ for (const [index, event] of session.events.entries()) {
125
+ if (event.type === 'llm/retry')
126
+ validateRetry(session.events.slice(0, index), event, fail);
127
+ else if (event.type === 'llm/retry-started')
128
+ validateStarted(session.events.slice(0, index), event, fail);
129
+ }
130
+ }
131
+ /** Install validation for loaded and newly appended retry records. */
132
+ const install = Object.assign((ctx, fail) => {
133
+ for (const session of ctx.sessions.list())
134
+ validateSession(session, fail);
135
+ ctx.on('session/created', (session) => { validateSession(session, fail); }, { global: true });
136
+ ctx.on('internal/dispatch', (_mode, eventName, args) => {
137
+ if (eventName !== 'session/event')
138
+ return;
139
+ const [session, event] = args;
140
+ if (event.type === 'llm/retry')
141
+ validateRetry(session.events, event, fail);
142
+ else if (event.type === 'llm/retry-started')
143
+ validateStarted(session.events, event, fail);
144
+ }, { global: true });
145
+ }, { inject: ['sessions'] });
146
+ /**
147
+ * Register the LLM retry invariant companion.
148
+ * @param ctx - Cordis context carrying the invariant service.
149
+ * @returns the installed registration's disposer after setup succeeds.
150
+ */
151
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
152
+ //# sourceMappingURL=invariant.js.map
@@ -0,0 +1,42 @@
1
+ import type { LlmFailure } from '@stackstackstack/dsh-llm/types';
2
+ import type { RetryId } from './brand.ts';
3
+ export type { RetryId };
4
+ declare module '@stackstackstack/dsh-session/types' {
5
+ interface SessionEventMap {
6
+ /** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */
7
+ 'llm/retry': LlmRetryEventData;
8
+ /** Durable transition written after a retry wait succeeds and before the next request attempt starts. */
9
+ 'llm/retry-started': LlmRetryStartedEventData;
10
+ }
11
+ }
12
+ /** Durable payload recorded before one provider-routed model-request retry wait. */
13
+ export type LlmRetryEventData = {
14
+ retryId: RetryId;
15
+ turn: number;
16
+ step: number;
17
+ provider: string;
18
+ mode: 'normal';
19
+ policyKey: string;
20
+ retry: number;
21
+ maxRetries: number;
22
+ delayMs: number;
23
+ failure: LlmFailure;
24
+ } | {
25
+ retryId: RetryId;
26
+ turn: number;
27
+ step: number;
28
+ provider: string;
29
+ mode: 'always';
30
+ policyKey: string;
31
+ retry: number;
32
+ delayMs: number;
33
+ failure: LlmFailure;
34
+ };
35
+ /** Durable transition recorded after one retry delay completes. */
36
+ export interface LlmRetryStartedEventData {
37
+ retryId: RetryId;
38
+ turn: number;
39
+ step: number;
40
+ retry: number;
41
+ }
42
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@stackstackstack/dsh-llm-retry",
3
+ "description": "Provider-routed LLM request retry policy for the DeepSeek Harness",
4
+ "version": "0.1.5",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/llm/llm-retry"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./invariant": {
22
+ "types": "./lib/types/invariant.d.ts",
23
+ "default": "./lib/invariant.js"
24
+ },
25
+ "./types": {
26
+ "types": "./lib/types/types.d.ts",
27
+ "default": "./lib/types/types.js"
28
+ },
29
+ "./package.json": "./package.json"
30
+ },
31
+ "files": [
32
+ "lib/index.js",
33
+ "lib/invariant.js",
34
+ "lib/types/**/*.js",
35
+ "lib/types/**/*.d.ts"
36
+ ],
37
+ "license": "MIT",
38
+ "peerDependencies": {
39
+ "@stackstackstack/dsh-brand": "^0.1.5",
40
+ "@stackstackstack/dsh-agent": "^0.1.5",
41
+ "@stackstackstack/dsh-invariants": "^0.1.5",
42
+ "@stackstackstack/dsh-llm": "^0.1.5",
43
+ "@stackstackstack/dsh-timeout": "^0.1.5",
44
+ "@stackstackstack/dsh-session": "^0.1.5",
45
+ "@deepseek-ai/cordis": "^4.0.1"
46
+ },
47
+ "dependencies": {
48
+ "@deepseek-ai/schemastery": "^3.18.1"
49
+ },
50
+ "devDependencies": {
51
+ "@stackstackstack/dsh-brand": "^0.1.5",
52
+ "@deepseek-ai/cordis-plugin-include": "^1.0.6",
53
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.2",
54
+ "@stackstackstack/dsh-agent": "^0.1.5",
55
+ "@stackstackstack/dsh-agent-loop": "^0.1.5",
56
+ "@stackstackstack/dsh-agent-loop-testkit": "^0.1.5",
57
+ "@stackstackstack/dsh-invariants": "^0.1.5",
58
+ "@stackstackstack/dsh-session": "^0.1.5",
59
+ "@stackstackstack/dsh-llm-deepseek": "^0.1.5",
60
+ "@stackstackstack/dsh-session-persistence-jsonl": "^0.1.5",
61
+ "@stackstackstack/dsh-session-persistence-sqlite": "^0.1.5",
62
+ "@stackstackstack/dsh-llm-mock-server": "^0.1.5",
63
+ "@stackstackstack/dsh-system-prompt": "^0.1.5",
64
+ "@stackstackstack/dsh-tools": "^0.1.5",
65
+ "@stackstackstack/dsh-timeout": "^0.1.5",
66
+ "@deepseek-ai/cordis": "^4.0.1",
67
+ "@stackstackstack/dsh-llm": "^0.1.5"
68
+ }
69
+ }