dsh-llm-fallbacks 0.6.12 → 0.7.0

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.md CHANGED
@@ -1,3 +1,5 @@
1
+ ![preview](https://raw.githubusercontent.com/omdsh-dev/dsh-llm-fallbacks/main/docs/preview.png)
2
+
1
3
  # dsh-llm-fallbacks
2
4
 
3
5
  [English](README.md) | [Chinese](README.zh-CN.md)
@@ -7,10 +9,10 @@
7
9
  ![node](https://img.shields.io/badge/node-%3E%3D22-339933.svg)
8
10
  ![pnpm](https://img.shields.io/badge/pnpm-%3E%3D10-f69220.svg)
9
11
  ![dsh tui](https://img.shields.io/badge/dsh%20tui-compatible-4B32C3.svg)
10
- ![dsh](https://img.shields.io/badge/DSH-0.2.1--rc.3-4B32C3.svg)
12
+ ![dsh](https://img.shields.io/badge/DSH-0.2.2--rc.1-4B32C3.svg)
11
13
  [![dshfind](https://dshfind.com/api/badge/omdsh-dev/dsh-llm-fallbacks?lang=en)](https://dshfind.com/zh/plugins/omdsh-dev/dsh-llm-fallbacks?ref=badge)
12
14
 
13
- Automatic provider/model fallback chains for dsh (DeepSeek Harness): when an agent's LLM requests keep failing — retries exhausted, auth errors, quota exceeded, rate limiting (429) — the plugin switches provider/model along the fallback chain for the current role, and the current step/turn continues on the target model: tasks are not interrupted by model problems.
15
+ Automatic provider/model fallback chains for dsh (DeepSeek Harness): a session whose model selection routes to the plugin's virtual **`FallbacksChain / Auto`** row walks a configured fallback chain — an LLM request that keeps failing on a listed code (auth, quota, rate limiting 429, …) is retried on the next available provider/model, so tasks are not interrupted by model problems. A session on any **real** provider/model has **no plugin fallback**: `llm-retry` alone handles it. Select `FallbacksChain / Auto` in the model picker to get a chain.
14
16
 
15
17
  Works in both dsh front ends: the **web** profile (Settings → Plugins → Fallbacks card) and the **dsh-tui** terminal profile (`/fallbacks` session diagnostics, `/fallbacks config` readback, and the `/settings` fallbacks section for editing).
16
18
 
@@ -18,12 +20,14 @@ Works in both dsh front ends: the **web** profile (Settings → Plugins → Fall
18
20
 
19
21
  ### Automatic model failover
20
22
 
21
- Any agent — root or subagent — that keeps failing on a listed failure code walks its chain to the next available provider/model and keeps the step alive. No manual model switching, and nothing happens until you configure content.
23
+ A session routed to `FallbacksChain / Auto` — the root agent, or an inheriting subagent the dispatch seam mounted on that row — walks its chain: a request that fails on a listed code **before emitting any output** is retried on the next available provider/model inside the same request, and the step stays alive. Sessions on a real provider/model get no plugin fallback at all. No manual model switching, and nothing happens until you configure content.
22
24
 
23
- - **Triggers**: `triggerCodes` (default `AUTH` / `QUOTA` / `RATE_LIMIT`) — **any** dsh failure code is accepted, including `CONTEXT_WINDOW_EXCEEDED`, which fails over to a larger-context candidate. Retryable failures (5xx, `TRANSPORT`, `TIMEOUT`, …) stay with llm-retry first.
25
+ - **Who gets a chain**: only sessions whose model selection routes to `FallbacksChain / Auto`. That is the root agent when the row is selected, and a subagent the seam mounted there at dispatch (local backend, no explicit route on the request). A real provider/model selection — root or subagent — is left entirely to llm-retry.
26
+ - **Triggers**: `triggerCodes` (default `AUTH` / `QUOTA` / `RATE_LIMIT`) — **any** dsh failure code is accepted, including `CONTEXT_WINDOW_EXCEEDED`, which fails over to a larger-context candidate. Retryable failures (5xx, `TRANSPORT`, `TIMEOUT`, …) stay with llm-retry first; each of its retries re-enters the walk, which starts past the head that just went on cooldown.
27
+ - **Partial output is committed**: once an attempt has emitted a chunk, a later failure is **not** switched inside the request — yielded output cannot be unsent. The failure surfaces to the loop; recovery is llm-retry re-entry (a fresh walk) or the terminal error.
24
28
  - **Recovery**: a failed / switched-away model is held out of selection for `cooldownMs`, and `revertPolicy: cooldown-expiry` returns to the primary model on expiry. The opt-in `recovery: half-open` mode (YAML-only) makes that return evidence-driven instead of timer-based.
25
- - **Safety valves**: `maxSwitchesPerStep` caps switches per step and `alwaysModeRetryCap` caps always-mode retries — chain loops cannot amplify latency.
26
- - **Visibility**: every switch is recorded in an info-level log line (from/to/role/reason) — no silent model switching.
29
+ - **Safety valve**: `maxSwitchesPerStep` caps switches per request walk (attempts ≤ cap + 1) — chain loops cannot amplify latency.
30
+ - **Visibility**: every switch is recorded in an info-level log line (from/to/role/reason/candidates) — no silent model switching.
27
31
 
28
32
  → [Automatic model failover](docs/features.md#automatic-model-failover) · [Context-window-aware fallback](docs/features.md#context-window-aware-fallback)
29
33
 
@@ -50,7 +54,7 @@ GLM Peak and GLM Valley are offered in the card picker only when `zai-coding-cn`
50
54
 
51
55
  A subagent's role is declared in its dispatch prompt — the Assignment header `**Execute as**: <id>` — and it is resolved against the role ids declared in `roles.list`. Only that **declared header** delivers the child's identity: the role's `persona` is installed on the child as a system-prompt section, the child's own session gets exactly one `[role: <id>]` notice row, and its session header shows a role badge.
52
56
 
53
- Role **routing** is resolved separately, in four ordered stages, first hit winning — explicit `agentPreset` → **dispatch-declared** `**Execute as**: <id>` header → rules → LLM auto-match. If the child's dispatch prompt declares `**Execute as**: <id>`, that role's persona, `[role: <id>]` notice and badge are delivered **regardless of which routing stage wins** — an earlier explicit `agentPreset` can route the child as another role while the identity carriers stay the declared header's. A child with **no declared dispatch header** gets no persona, no notice row and no role half of the badge (the model half still renders when the child has a recorded route), even when rules or auto-match resolve a role for routing. Making the resolved role's chain head the child's model is plugin-originated and conditional: with the host `subagent-model-selection` policy enabled, an explicit authorized route is preserved (inject skipped) and an injected head must be inside the effective allowlist — an empty intersection injects nothing ([host policy](docs/features.md#host-subagent-model-selection-policy-dsh-012)).
57
+ Role **routing** is resolved separately, in three ordered stages, first hit winning — explicit `agentPreset` → **dispatch-declared** `**Execute as**: <id>` header → rules. If the child's dispatch prompt declares `**Execute as**: <id>`, that role's persona, `[role: <id>]` notice and badge are delivered **regardless of which routing stage wins** — an earlier explicit `agentPreset` can route the child as another role while the identity carriers stay the declared header's. A child with **no declared dispatch header** gets no persona, no notice row and no role half of the badge (the model half still renders when the child has a recorded route), even when rules resolve a role for routing. Routing also decides whether the child gets a fallback chain: at dispatch the seam mounts an inheriting child on the virtual `FallbacksChain / Auto` row — local backends only, and only when the request carries no explicit `provider`/`model` route; a request that does carry one is **authorized** and never rewritten (card label `authorized`, a mounted child is labelled `injected`). A mounted child's candidates are intersected with the host allowlist when `subagent-model-selection` is enabled, and an external-backend child keeps pure inheritance — which means no chain ([host policy](docs/features.md#host-subagent-model-selection-policy-dsh-012)).
54
58
 
55
59
  This covers an **ordinary subagent dispatch and an Agent Team teammate** (`spawn_teammate`) alike: both are children dispatched with the same header, which the model writes — the same dispatch seam, no mechanism-specific configuration.
56
60
 
@@ -120,11 +124,11 @@ Save the config and restart the session, then type `/fallbacks` — the read-onl
120
124
 
121
125
  ## Feature highlights
122
126
 
123
- - **Automatic failover** for root and subagents — any agent walks its chain on a listed failure code and the step continues. → [details](docs/features.md#automatic-model-failover)
127
+ - **Automatic failover** on the virtual route — a `FallbacksChain / Auto` session (root or seam-mounted subagent) walks its chain on a listed failure code and the step continues; real-model sessions have no plugin fallback. → [details](docs/features.md#automatic-model-failover)
124
128
  - **Context-window-aware fallback** — `CONTEXT_WINDOW_EXCEEDED` fails over to a larger-context candidate; the switch is request-scoped (no cooldown). → [details](docs/features.md#context-window-aware-fallback)
125
129
  - **Peak/valley time slots** — four frozen UTC+8 presets or custom windows rotate the effective root chain. → [details](docs/features.md#time-slots)
126
- - **Role-based subagents** — a dispatch-declared role brings persona, notice row and badge to the child; its chain head is injected subject to host policy. → [details](docs/features.md#role-based-subagents)
127
- - **FallbacksChain in the picker** — select `FallbacksChain / Auto` to make the chain the root primary; any real model stays fallback-only. → [details](docs/features.md#fallbackschain-in-the-model-picker)
130
+ - **Role-based subagents** — a dispatch-declared role brings persona, notice row and badge to the child; inheriting children are mounted on the virtual chain route subject to host policy. → [details](docs/features.md#role-based-subagents)
131
+ - **FallbacksChain in the picker** — select `FallbacksChain / Auto` to make the configured chain the session route; any real model is served by llm-retry alone, with no plugin fallback. → [details](docs/features.md#fallbackschain-in-the-model-picker)
128
132
  - **Host subagent model policy** — the dsh 0.1.2 allowlist is a hard constraint on every plugin-originated subagent route. → [details](docs/features.md#host-subagent-model-selection-policy-dsh-012)
129
133
  - **Five bundled preset roles** — `reviewer` / `scout` / `security-reviewer` / `sonic` / `task`, declared on apply. → [details](docs/features.md#preset-roles)
130
134
  - **dsh-tui surfaces** — `/fallbacks`, `/fallbacks config`, and a `/settings` section with web-card parity. → [details](docs/features.md#dsh-tui-surfaces)
package/README.zh-CN.md CHANGED
@@ -1,3 +1,5 @@
1
+ ![preview](https://raw.githubusercontent.com/omdsh-dev/dsh-llm-fallbacks/main/docs/preview.zh.png)
2
+
1
3
  # dsh-llm-fallbacks
2
4
 
3
5
  [English](README.md) | [中文](README.zh-CN.md)
@@ -7,10 +9,10 @@
7
9
  ![node](https://img.shields.io/badge/node-%3E%3D22-339933.svg)
8
10
  ![pnpm](https://img.shields.io/badge/pnpm-%3E%3D10-f69220.svg)
9
11
  ![dsh tui](https://img.shields.io/badge/dsh%20tui-compatible-4B32C3.svg)
10
- ![dsh](https://img.shields.io/badge/DSH-0.2.1--rc.3-4B32C3.svg)
12
+ ![dsh](https://img.shields.io/badge/DSH-0.2.2--rc.1-4B32C3.svg)
11
13
  [![dshfind](https://dshfind.com/api/badge/omdsh-dev/dsh-llm-fallbacks?lang=zh)](https://dshfind.com/zh/plugins/omdsh-dev/dsh-llm-fallbacks?ref=badge)
12
14
 
13
- dsh(DeepSeek Harness)的自动模型降级插件:当 agent 的模型请求持续失败(重试耗尽、权限错误、配额超限、限流 429)时,插件按当前角色的 fallback 链切换 provider/model,当前 step/turn 在目标模型上继续——任务不会因模型问题中断。
15
+ dsh(DeepSeek Harness)的自动模型降级插件:只有模型选择走插件虚拟行 **`FallbacksChain / Auto`** 的会话才沿配置的降级链行走——llm 请求在列出的失败码上持续失败(权限错误、配额超限、限流 429 等)时,请求会在下一个可用 provider/model 上重试,任务不会因模型问题中断。选择**真实** provider/model 的会话**没有插件降级**,只由 `llm-retry` 处理。想要降级链,请在模型选择器中选择 `FallbacksChain / Auto`。
14
16
 
15
17
  两个 dsh 前端均可用:**web** profile(设置 → 插件配置 → Fallbacks 卡片)与 **dsh-tui** 终端 profile(`/fallbacks` 会话诊断、`/fallbacks config` 回读、`/settings` 的 fallbacks 分区用于编辑)。
16
18
 
@@ -18,12 +20,14 @@ dsh(DeepSeek Harness)的自动模型降级插件:当 agent 的模型请求
18
20
 
19
21
  ### 自动模型降级
20
22
 
21
- root 或 subagent,只要持续命中触发码,就沿自身链切到下一个可用 provider/model,step 继续运行。无需手动切换模型;在配置了内容之前,插件不做任何事。
23
+ 会话模型选择走 `FallbacksChain / Auto` 时——root agent,或被分发 seam 挂到该行的继承型 subagent——沿自身链行走:请求在**输出任何内容之前**命中触发码,就在同一个请求内改到下一个可用 provider/model,step 继续运行。选择真实 provider/model 的会话不获得任何插件降级。无需手动切换模型;在配置了内容之前,插件不做任何事。
22
24
 
23
- - **触发**:`triggerCodes`(默认 `AUTH` / `QUOTA` / `RATE_LIMIT`)——**任何** dsh 失败码都可用,包括 `CONTEXT_WINDOW_EXCEEDED`(会降级到上下文窗口更大的候选)。可重试失败(5xx、`TRANSPORT`、`TIMEOUT` 等)先交给 llm-retry。
25
+ - **谁能拿到链**:只有模型选择走 `FallbacksChain / Auto` 的会话。包括选中该行的 root agent,以及分发时被 seam 挂到该行的 subagent(本地 backend、请求上没有显式路由)。真实 provider/model 选择——无论 root 还是 subagent——完全交给 llm-retry。
26
+ - **触发**:`triggerCodes`(默认 `AUTH` / `QUOTA` / `RATE_LIMIT`)——**任何** dsh 失败码都可用,包括 `CONTEXT_WINDOW_EXCEEDED`(会降级到上下文窗口更大的候选)。可重试失败(5xx、`TRANSPORT`、`TIMEOUT` 等)先交给 llm-retry;它的每次重试都会重新进入 walk,并从刚进入冷却的链头之后开始。
27
+ - **已输出即提交**:某个尝试一旦吐出 chunk,之后的失败**不会**在该请求内切换——已产出的输出无法撤回。失败上抛给 loop;恢复路径是 llm-retry 重入(新的一次 walk)或终止失败。
24
28
  - **恢复**:失败或被切走的模型在 `cooldownMs` 内不再被选中,`revertPolicy: cooldown-expiry` 在冷却到期后回到主模型;可选的 `recovery: half-open` 模式(YAML-only)把这次回归改为证据驱动,而非定时器驱动。
25
- - **保险丝**:`maxSwitchesPerStep` 限制单 step 的切换次数,`alwaysModeRetryCap` 限制 always 模式的重试次数——链式循环不会放大延迟。
26
- - **可见性**:每次切换都写入 info 日志(from/to/role/reason),不存在静默切换。
29
+ - **保险丝**:`maxSwitchesPerStep` 限制单次请求 walk 的切换次数(尝试数 ≤ 上限 + 1)——链式循环不会放大延迟。
30
+ - **可见性**:每次切换都写入 info 日志(from/to/role/reason/candidates),不存在静默切换。
27
31
 
28
32
  → [自动模型降级](docs/features.md#automatic-model-failover) · [上下文窗口降级](docs/features.md#context-window-aware-fallback)
29
33
 
@@ -50,7 +54,7 @@ GLM Peak / GLM Valley 仅在配置了 `zai-coding-cn` 时才会出现在卡片
50
54
 
51
55
  subagent 的角色由分发提示词声明——Assignment 头部字段 `**Execute as**: <id>`;它与 `roles.list` 中已声明的角色 id 匹配。只有这个**已声明头部**才投递子代理的身份:`persona` 以 system-prompt 分区安装到子代理,子会话恰好写入一行 `[role: <id>]` 通知行,会话头部显示角色徽章。
52
56
 
53
- 角色**路由**是另一套解析,按四个有序阶段、先命中者生效——显式 `agentPreset` → **派发声明** `**Execute as**: <id>` 头部 → 规则 → LLM 自动匹配。只要子代理的分发提示词声明了 `**Execute as**: <id>`,该角色的 persona、`[role: <id>]` 通知行与徽章就会投递,**无论哪个路由阶段胜出**——更早的显式 `agentPreset` 可以把子代理路由成另一个角色,而身份载体仍属已声明头部。完全没有声明分发头部的子代理不会获得 persona、通知行与徽章,即使规则或自动匹配为路由解析出了角色。把解析到的角色链头作为子代理模型由插件发起、且带条件:宿主 `subagent-model-selection` 策略启用时,显式授权路由会被保留(跳过注入),注入的链头必须落在生效 allowlist 内——交集为空则不注入任何内容([宿主策略](docs/features.md#host-subagent-model-selection-policy-dsh-012))。
57
+ 角色**路由**是另一套解析,按三个有序阶段、先命中者生效——显式 `agentPreset` → **派发声明** `**Execute as**: <id>` 头部 → 规则。只要子代理的分发提示词声明了 `**Execute as**: <id>`,该角色的 persona、`[role: <id>]` 通知行与徽章就会投递,**无论哪个路由阶段胜出**——更早的显式 `agentPreset` 可以把子代理路由成另一个角色,而身份载体仍属已声明头部。完全没有声明分发头部的子代理不会获得 persona、通知行与徽章,即使规则为路由解析出了角色。路由同时决定子代理是否拥有降级链:分发时 seam 把继承型子代理挂到虚拟 `FallbacksChain / Auto` 行上——仅限本地 backend,且仅当请求没有显式 `provider`/`model` 路由;带显式路由的请求属于**已授权**,插件永不改写(卡片标签 `authorized`,被挂载的子代理标签 `injected`)。宿主 `subagent-model-selection` 策略启用时,被挂载子代理的候选会与 allowlist 求交;外部 backend 的子代理保持纯继承——也就是没有降级链([宿主策略](docs/features.md#host-subagent-model-selection-policy-dsh-012))。
54
58
 
55
59
  这一套同时覆盖**普通 subagent 分发与 Agent Team 队友**(`spawn_teammate`):两者都是带着同一头部(由模型写入)经同一分发 seam 创建的子代理,不需要任何按机制区分的配置。
56
60
 
@@ -120,11 +124,11 @@ fallbacks:
120
124
 
121
125
  ## 能力一览
122
126
 
123
- - **自动降级**(root 与 subagent 通用)——命中触发码的 agent 沿链切换,step 继续运行。 → [详情](docs/features.md#automatic-model-failover)
127
+ - **自动降级**(仅虚拟路由)——`FallbacksChain / Auto` 会话(root 或 seam 挂载的 subagent)命中触发码时沿链切换,step 继续运行;真实模型会话没有插件降级。 → [详情](docs/features.md#automatic-model-failover)
124
128
  - **上下文窗口感知降级**——`CONTEXT_WINDOW_EXCEEDED` 会切到窗口更大的候选;该切换是请求级的(不消耗冷却)。 → [详情](docs/features.md#context-window-aware-fallback)
125
129
  - **峰谷无忧(分时槽)**——四个冻结的 UTC+8 预设或自定义窗口轮换生效 root 链。 → [详情](docs/features.md#time-slots)
126
- - **角色化 subagent**——派发声明的角色为子代理带来 persona、通知行与徽章;链头注入受宿主策略约束。 → [详情](docs/features.md#role-based-subagents)
127
- - **模型选择器中的 FallbacksChain**——选择 `FallbacksChain / Auto` 即把配置链作为 root 主模型;选择真实模型仍是仅降级。 → [详情](docs/features.md#fallbackschain-in-the-model-picker)
130
+ - **角色化 subagent**——派发声明的角色为子代理带来 persona、通知行与徽章;继承型子代理被挂到虚拟链路由,受宿主策略约束。 → [详情](docs/features.md#role-based-subagents)
131
+ - **模型选择器中的 FallbacksChain**——选择 `FallbacksChain / Auto` 即由配置链承载该会话;选择真实模型则只由 llm-retry 处理,没有插件降级。 → [详情](docs/features.md#fallbackschain-in-the-model-picker)
128
132
  - **宿主子代理模型策略**——dsh 0.1.2 的 allowlist 是插件发起的所有 subagent 路由的硬约束。 → [详情](docs/features.md#host-subagent-model-selection-policy-dsh-012)
129
133
  - **五个内置预设角色**——`reviewer` / `scout` / `security-reviewer` / `sonic` / `task`,随 apply 自动声明。 → [详情](docs/features.md#preset-roles)
130
134
  - **dsh-tui 操作面**——`/fallbacks`、`/fallbacks config`,以及与 web 卡片等价的 `/settings` 分区。 → [详情](docs/features.md#dsh-tui-surfaces)
@@ -0,0 +1,211 @@
1
+ /**
2
+ * The chain walk (design dsh-022-rc1-upgrade §2/§3/§6.1): the fallback
3
+ * decision, executed inside the virtual `FallbacksChain/Auto` adapter's
4
+ * `stream()` instead of on the `agent/request` / `agent/request-error`
5
+ * waterfalls.
6
+ *
7
+ * One walk per `stream()` call: resolve the session's agent view, resolve the
8
+ * role and the chain tail, then attempt the chain's heads in order. A
9
+ * pre-output terminal `finish` whose failure code is a trigger code is
10
+ * swallowed, booked (cooldown / recovery) and the walk advances; anything
11
+ * else — a normal finish, an aborted finish, a non-trigger failure, or any
12
+ * failure after the attempt already yielded a chunk — is forwarded verbatim
13
+ * and ends the walk.
14
+ *
15
+ * The COMMITTED rule (§3.1 / §7.5) is why a post-output failure cannot be
16
+ * replayed: yielded chunks cannot be unsent, so a second provider's output
17
+ * cannot be concatenated behind a partial one. Such a failure is still BOOKED
18
+ * (cooldown / recovery) before it is forwarded — the attempt boundary is where
19
+ * both transactions happen — and then surfaces to the loop, which settles the
20
+ * partial attempt; llm-retry (or the terminal error) takes over from there, and
21
+ * a retry re-enters this walk, which resumes past the cooled head on its own.
22
+ *
23
+ * Bookkeeping survives a walk exactly as far as the state store does: cooldown
24
+ * (`cooldownMs` / `revertPolicy` / half-open escalation) is the cross-call
25
+ * memory keyed by session, so an llm-retry re-entry starts past a failed head;
26
+ * the per-call failed set and the switch cap live only for this walk
27
+ * (`maxSwitchesPerStep` = switches per walk, attempts ≤ cap + 1).
28
+ *
29
+ * The pre-attempt guards (`EMPTY_EFFECTIVE_CHAIN` / `UNDISPATCHABLE_EFFECTIVE_HEAD`
30
+ * / `LLM_UNAVAILABLE`) and the ROOT-only all-day conformance gate are owned
31
+ * here, and `slotWinners` carries the 分时切换 rotation log — all of it needs the
32
+ * session's resolved role/origin, which the adapter does not have.
33
+ *
34
+ * @module dsh-llm-fallbacks/chain-walk
35
+ */
36
+ import { type GenerateOptions, type LlmRuntime, type StreamChunk } from '@deepseek-ai/dsh-llm';
37
+ import { type FailingModel } from './chains.ts';
38
+ import type { FallbacksConfig } from './config.ts';
39
+ import type { FallbackSwitchReason } from './events.ts';
40
+ import type { FallbackStateStore } from './state.ts';
41
+ import type { EffectivePolicy } from './subagent-policy.ts';
42
+ /**
43
+ * `LlmError` code: the effective chain is empty — the virtual route has no
44
+ * head to walk (the pre-attempt guard, owned here because only the walk knows
45
+ * WHICH chain the calling session walks: a subagent's role chain, not the
46
+ * root's all-day chain).
47
+ */
48
+ export declare const EMPTY_EFFECTIVE_CHAIN_CODE = "EMPTY_EFFECTIVE_CHAIN";
49
+ /**
50
+ * `LlmError` code: the chain head is not a dispatchable real pair — an
51
+ * undispatchable entry (wildcard selector, malformed selector, or a self-route
52
+ * back to `FallbacksChain/*` — the recursion guard), or, for a ROOT walk, a
53
+ * non-conforming all-day `rootChain` (the picker row's conformance gate).
54
+ */
55
+ export declare const UNDISPATCHABLE_HEAD_CODE = "UNDISPATCHABLE_EFFECTIVE_HEAD";
56
+ /** `LlmError` code (defensive): the `llm` runtime disappeared mid-flight. */
57
+ export declare const LLM_UNAVAILABLE_CODE = "LLM_UNAVAILABLE";
58
+ /**
59
+ * dsh core's canonical failure code for a request larger than the model's
60
+ * context window (`@deepseek-ai/dsh-llm` `CONTEXT_WINDOW_EXCEEDED_CODE`; the
61
+ * DeepSeek adapter's `httpErrorCode()` and llm-pi-ai's `mapStopReason()` both
62
+ * map the provider 400 "maximum context length" to it). Declared as a literal
63
+ * for the same reason `triggerCodes` is `string[]` — the plugin compares dsh
64
+ * failure codes as plain strings and never imports a peer value.
65
+ *
66
+ * It is NOT in llm-retry's retryable set, so the plugin sees it only when the
67
+ * operator lists it in `triggerCodes` — and once listed, this walk handles the
68
+ * rejection BEFORE the harness's compaction plugin compacts anything
69
+ * (measured 2026-09-09: the first overflow switches to the fallback, no
70
+ * compaction happens). Operators who want compaction tried first leave the
71
+ * code out of `triggerCodes`.
72
+ *
73
+ * A `CONTEXT_WINDOW_EXCEEDED` switch is REQUEST-scoped
74
+ * ({@link REQUEST_SCOPED_CODES}): the route is healthy and the request was too
75
+ * big, so the per-call failed key is recorded but no cooldown and no recovery
76
+ * counter are written.
77
+ */
78
+ export declare const CONTEXT_WINDOW_EXCEEDED_CODE = "CONTEXT_WINDOW_EXCEEDED";
79
+ /**
80
+ * Failure codes whose switch is REQUEST-scoped: the route is healthy and the
81
+ * request was the problem, so a switch records the per-call failed key but
82
+ * writes no cooldown and no recovery counter. Cooling the route down would
83
+ * send the next SHORT prompt to the fallback for no reason.
84
+ */
85
+ export declare const REQUEST_SCOPED_CODES: ReadonlySet<string>;
86
+ /**
87
+ * Model-catalog view the wildcard / context-window probes read. `contextWindow`
88
+ * is a host-disclosed extra on the advisory catalog row (the dsh `LlmModelInfo`
89
+ * does not type it), while `resolveModelInfo` is where the host actually
90
+ * carries it — both are optional and read structurally, so a host offering
91
+ * neither reports every window as unknown (never a skip).
92
+ */
93
+ export interface ModelCatalogLike {
94
+ listModels(provider: string): Promise<readonly {
95
+ id: string;
96
+ contextWindow?: number;
97
+ }[]>;
98
+ resolveModelInfo?(provider: string, model: string): Promise<{
99
+ context?: {
100
+ contextWindow?: number;
101
+ };
102
+ }>;
103
+ }
104
+ /**
105
+ * The `provider/*`-entry existence probe: the target provider's advertised
106
+ * catalog, fetched once per expansion and cached per provider. A
107
+ * missing/unknown provider or a failing catalog reads as "no such model", so
108
+ * wildcard candidates to it are skipped; without a catalog no filtering
109
+ * happens (`() => true`).
110
+ *
111
+ * simplify: catalog fetched per expansion, never cached across walks. Walks
112
+ * are failure-driven and rare; cache per provider in the plugin if they ever
113
+ * become hot.
114
+ */
115
+ export declare function makeModelExists(catalog: ModelCatalogLike | undefined, providers: readonly string[]): Promise<(provider: string, model: string) => boolean>;
116
+ /**
117
+ * The context-window lookup for a `CONTEXT_WINDOW_EXCEEDED` expansion: a
118
+ * `provider/model → contextWindow` map over the failing route plus every
119
+ * resolved candidate route, fetched once per expansion (the
120
+ * {@link makeModelExists} pattern — failure-driven and rare, never cached).
121
+ *
122
+ * Two sources, cheapest first: the advertised catalog row when it discloses a
123
+ * window (one `listModels` per distinct provider), then — for routes still
124
+ * unknown — the exact-route `resolveModelInfo` metadata, which is where the
125
+ * dsh host puts `context.contextWindow`. A missing catalog, a throwing probe,
126
+ * or a route that discloses nothing all read as UNKNOWN (`undefined`), and
127
+ * unknown always KEEPS the candidate: an undisclosed window must never empty a
128
+ * chain.
129
+ */
130
+ export declare function makeContextWindowOf(catalog: ModelCatalogLike | undefined, routes: readonly FailingModel[]): Promise<(provider: string, model: string) => number | undefined>;
131
+ /**
132
+ * The minimal agent view one walk needs (design §2.2): `GenerateOptions`
133
+ * carries only `sessionId`, so the session's agent is read opportunistically
134
+ * through the host `agents` registry. `undefined` when the id is absent or the
135
+ * agent left the registry — treated as a root agent with no role (the
136
+ * effective-chain walk still runs).
137
+ */
138
+ export interface AgentChainView {
139
+ /** `agent.session.header.origin`; a missing header is a root agent. */
140
+ origin: 'root' | 'subagent';
141
+ /** `agent.session.header.agentPreset` — the explicit role stage. */
142
+ agentPreset?: string;
143
+ /** `agent.options` — the pair the rules stage matches (a virtual pair on this path). */
144
+ optionsRoute?: {
145
+ provider: string;
146
+ model: string;
147
+ };
148
+ sessionId: string;
149
+ }
150
+ /** Everything one walk reads from the plugin wiring (design §6.1). */
151
+ export interface ChainWalkDeps {
152
+ readConfig(): FallbacksConfig;
153
+ getLlm(): LlmRuntime | undefined;
154
+ resolveAgentView(sessionId: GenerateOptions['sessionId']): AgentChainView | undefined;
155
+ states: FallbackStateStore;
156
+ /** The live `trimmed id → declared raw id` map (re-derived on settings change). */
157
+ roleIds(): ReadonlyMap<string, string>;
158
+ /** The dispatch seam's record for one child — the dispatch-role stage. */
159
+ seamRecord(sessionId: SessionKey): {
160
+ role?: string;
161
+ virtualRouteInjected?: boolean;
162
+ } | undefined;
163
+ /** The host subagent policy for one session: guarded read + fail-closed. */
164
+ policyFor(sessionId: SessionKey): EffectivePolicy;
165
+ /** Record a plugin-originated switch the allowlist emptied (in-memory, card-only). */
166
+ recordBlocked?(sessionId: SessionKey, route: {
167
+ provider: string;
168
+ model: string;
169
+ }, reason: FallbackSwitchReason): void;
170
+ /**
171
+ * Per-agent last time-slot winner (P7 分时切换 marker): the caller owns the
172
+ * map (re-baselined on a settings change, cleared on dispose), the walk reads
173
+ * and writes it — the log only, never a failure decision. Absent ⇒ no log.
174
+ */
175
+ slotWinners?: Map<string, {
176
+ key: string;
177
+ label: string;
178
+ }>;
179
+ log: {
180
+ info(message: string, ...args: unknown[]): void;
181
+ warn(message: string, ...args: unknown[]): void;
182
+ };
183
+ }
184
+ /** A non-null session id — the key the state store and cooldown memory are held under. */
185
+ type SessionKey = NonNullable<GenerateOptions['sessionId']>;
186
+ /**
187
+ * The route a `FallbacksChain/Auto` selection really SERVED, for the rules
188
+ * stage's pair-wise matching (QC1 I-1 in the pre-C path): a child whose
189
+ * recorded pair is the virtual row keeps matching a rule keyed on the real
190
+ * chain head. `effectiveHeadOf` is the same head the picker row advertises, and
191
+ * a non-conforming all-day chain yields `undefined` — no anchor, no widening.
192
+ * Shared by the walk (`resolveWalk`) and the `/fallbacks` snapshot, so the
193
+ * diagnostic surface resolves the role exactly as the run does.
194
+ */
195
+ export declare function servedRouteOf(config: FallbacksConfig, route: {
196
+ readonly provider?: string;
197
+ readonly model?: string;
198
+ } | undefined, now: Date): {
199
+ provider: string;
200
+ model: string;
201
+ } | undefined;
202
+ /**
203
+ * Walk one virtual call's chain (design §6.1): an async iterable of the
204
+ * attempts' chunks, ending in exactly one terminal `finish`.
205
+ *
206
+ * Throws only for the pre-attempt conditions (empty chain, undispatchable
207
+ * head, vanished runtime) — every per-attempt failure is either swallowed into
208
+ * another attempt or forwarded as that attempt's own terminal `finish`.
209
+ */
210
+ export declare function walkChain(deps: ChainWalkDeps, options: GenerateOptions): AsyncIterable<StreamChunk>;
211
+ export {};
package/dist/chains.d.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * such model id" skip is judged by {@link resolveCandidate} (via
18
18
  * `modelExists`) or by the caller's filter.
19
19
  *
20
- * The concatenation itself lives in ONE helper — {@link buildRoleEntries}
20
+ * The concatenation itself lives in ONE helper — {@link roleChainEntries}
21
21
  * (qc1 S-3 SSOT) — shared by {@link resolveChainViews} and
22
22
  * {@link hasWildcardEntry}, so the wildcard probe is exact (qc2 F-003:
23
23
  * `fallback: 'none'` excludes `rootChain`) and can never diverge from the
@@ -36,7 +36,7 @@
36
36
  * @module dsh-llm-fallbacks/chains
37
37
  */
38
38
  import { type FallbacksRole } from './config.ts';
39
- import type { CooldownStore, StepFailureSet } from './cooldown.ts';
39
+ import type { CooldownStore } from './cooldown.ts';
40
40
  import { type Selector } from './selectors.ts';
41
41
  /** The model that just failed — the anchor for chain resolution. */
42
42
  export interface FailingModel {
@@ -55,6 +55,23 @@ export interface FailingModel {
55
55
  * path is {@link parseSelector}, used by the Task 3 config-warning path.
56
56
  */
57
57
  export declare function resolveCandidate(entry: string, failing: FailingModel, modelExists?: (provider: string, model: string) => boolean): Selector | null;
58
+ /**
59
+ * The role's effective concatenated chain entries (spec §7.2) — the SINGLE
60
+ * concatenation SSOT (qc1 S-3): the declared role's own `chain` followed by
61
+ * `rootChain` unless `fallback: 'none'` (append-not-replace — role entries
62
+ * first, rootChain as the tail); the built-in `inherit` id and any unknown
63
+ * role id resolve to `rootChain` alone. {@link resolveChainViews} (candidate
64
+ * resolution) and {@link hasWildcardEntry} (wildcard probe) both derive
65
+ * their candidate source here, so the probe never diverges from the
66
+ * resolution (grep-verifiable: one concatenation expression). Role ids are
67
+ * compared by trim (qc2 F-001 — the runtime canonicalizes padded declared
68
+ * ids, and `resolveRole` returns the declared raw id).
69
+ *
70
+ * Exported for the chain walk's head anchor (`src/chain-walk.ts`): the walk
71
+ * resolves the FIRST DISPATCHABLE exact entry over these same entries, so the
72
+ * head it starts at is the head the candidate walk below it starts from.
73
+ */
74
+ export declare function roleChainEntries(roles: readonly FallbacksRole[], rootChain: readonly string[], role: string): readonly string[];
58
75
  /**
59
76
  * Single-pass chain resolution for the decision path (T1 review Important
60
77
  * #2). Walks the role's concatenated entries ONCE and returns the unfiltered
@@ -91,7 +108,7 @@ export declare function selectCandidates(all: readonly Selector[], wildcard: rea
91
108
  * Ordered fallback candidates for the failing (provider, model).
92
109
  *
93
110
  * Candidates are the role's concatenated entries (spec §7.2, see
94
- * {@link buildRoleEntries} — the single concatenation SSOT): the declared
111
+ * {@link roleChainEntries} — the single concatenation SSOT): the declared
95
112
  * role's own `chain` followed by `rootChain` unless `fallback: 'none'`
96
113
  * (append-not-replace — role entries first, rootChain as the tail). The
97
114
  * reserved built-in `inherit` id resolves to `rootChain` silently; a truly
@@ -118,7 +135,7 @@ export declare function resolveChain(roles: readonly FallbacksRole[], rootChain:
118
135
  * `resolveChain` walks to decide whether the catalog existence probe is
119
136
  * needed at all.
120
137
  *
121
- * The probe walks {@link buildRoleEntries} — the SAME concatenation
138
+ * The probe walks {@link roleChainEntries} — the SAME concatenation
122
139
  * {@link resolveChainViews} resolves (qc1 S-3 single SSOT) — so it is
123
140
  * exact, never an over-approximation (qc2 F-003 / qc3 S-1): for
124
141
  * `fallback: 'none'` roles `rootChain` is excluded, and a wildcard that can
@@ -135,7 +152,9 @@ export interface CandidateFilterOptions {
135
152
  /** Cooldown store: suppressed candidates are skipped (keyed `provider/model`). */
136
153
  cooldown: Pick<CooldownStore, 'isSuppressed'>;
137
154
  /** Failed-set for the current step: already-failed candidates are skipped. */
138
- failed: Pick<StepFailureSet, 'has'>;
155
+ failed: {
156
+ has(key: string): boolean;
157
+ };
139
158
  /** Optional existence probe: candidates the target provider lacks are skipped. */
140
159
  modelExists?: (provider: string, model: string) => boolean;
141
160
  /**
@@ -160,8 +179,11 @@ export declare function createCandidateFilter(options: CandidateFilterOptions):
160
179
  * 行为可见性). `context-window` is the `CONTEXT_WINDOW_EXCEEDED` walk's
161
180
  * exclusion: the candidate's disclosed window is not larger than the failing
162
181
  * model's, so the request that just overflowed cannot fit there either.
182
+ * `allowlist` is the subagent-policy exclusion: the candidate survived every
183
+ * filter but sits outside the host allowlist, so a plugin-originated walk may
184
+ * not switch to it. The walk owns that label — the policy read lives there.
163
185
  */
164
- export type CandidateSkipReason = 'same-as-current' | 'cooldown' | 'step-failed' | 'context-window' | 'missing-id';
186
+ export type CandidateSkipReason = 'same-as-current' | 'cooldown' | 'step-failed' | 'context-window' | 'missing-id' | 'allowlist';
165
187
  /** One entry of the ordered, per-candidate annotation (T3 review Minor 1). */
166
188
  export interface AnnotatedCandidate {
167
189
  candidate: Selector;