dsh-llm-fallbacks 0.4.2 → 0.4.4

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
@@ -7,7 +7,7 @@
7
7
  ![node](https://img.shields.io/badge/node-%3E%3D22-339933.svg)
8
8
  ![pnpm](https://img.shields.io/badge/pnpm-%3E%3D10-f69220.svg)
9
9
  ![dsh tui](https://img.shields.io/badge/dsh%20tui-compatible-4B32C3.svg)
10
- ![dsh](https://img.shields.io/badge/DSH-0.1.2--rc.1-4B32C3.svg)
10
+ ![dsh](https://img.shields.io/badge/DSH-0.1.5--rc.1-4B32C3.svg)
11
11
  [![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
12
 
13
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.
@@ -31,7 +31,7 @@ Four frozen UTC+8 presets (windows are code constants; preset rows lock `tz` to
31
31
 
32
32
  GLM Peak and GLM Valley are offered in the card picker only when `zai-coding-cn` is configured.
33
33
 
34
- The first extra row whose window contains the current moment (in `fallbacks.tz`, default Asia/Shanghai) wins; no match → the all-day `rootChain`, whose tail (Default model) must be exactly one official V4 model — `deepseek-official/deepseek-v4-flash` XOR `deepseek-official/deepseek-v4-pro`. Slot rotation is a routing seed, not a failure decision: it applies on the next root request, consumes no cooldown, and is logged as a time-slot switch — failure walks keep fallback switch. Full semantics → [Time-slot presets](#time-slot-presets) and [docs/configuration.md](docs/configuration.md).
34
+ The first extra row whose window contains the current moment (in `fallbacks.tz`, default Asia/Shanghai) wins; no match → the all-day `rootChain`, whose tail (Default model) must be exactly one official model — `deepseek-official/deepseek-flash` or `deepseek-official/deepseek-pro` (XOR). Slot rotation is a routing seed, not a failure decision: it applies on the next root request, consumes no cooldown, and is logged as a time-slot switch — failure walks keep fallback switch. Full semantics → [Time-slot presets](#time-slot-presets) and [docs/configuration.md](docs/configuration.md).
35
35
 
36
36
  ## Quick start
37
37
 
@@ -46,17 +46,16 @@ Same plugin, either front end — the only difference is the `--profile` flag. P
46
46
 
47
47
  ### Repair existing sessions (versions before 0.2.2)
48
48
 
49
- Versions before 0.2.2 wrote durable `fallbacks/switch` session events that newer dsh releases refuse to load (issue #52 — the apply()-time event-type registration is ineffective because plugin and host resolve different module instances). If existing sessions fail to open after an upgrade, clone this repository and repair the logs (stop dsh first):
49
+ Versions before 0.2.2 wrote durable `fallbacks/switch` session events that newer dsh releases refuse to load (issue #52 — the apply()-time event-type registration is ineffective because plugin and host resolve different module instances). If existing sessions fail to open after an upgrade, clone this repository and run the detector:
50
50
 
51
51
  ```sh
52
52
  git clone https://github.com/omdsh-dev/dsh-llm-fallbacks.git
53
53
  cd dsh-llm-fallbacks
54
54
  pnpm install
55
- pnpm repair:fallbacks-switch-logs -- --dry-run # preview which sessions would change
56
- pnpm repair:fallbacks-switch-logs -- --apply --backup # mark legacy events ignorable
55
+ pnpm repair:fallbacks-switch-logs -- --dry-run
57
56
  ```
58
57
 
59
- The script scans `~/.dsh/sessions` by default (override with `--root <dir>`), marks legacy `fallbacks/switch` events `ignorable: true` so the host read path accepts the session again, and keeps a `<file>.bak` per repaired log. `--apply` requires `--backup` and must run with dsh stopped. From 0.2.2 on, the plugin stops writing durable switch events, so no new sessions need repair.
58
+ The script scans `~/.dsh/sessions` by default (override with `--root <dir>`). **It cannot repair these logs**: the released session-format migration chain (v0→v1) refuses unknown event types even when marked `ignorable: true`, so a "repaired" log would still be rejected on load. The script therefore fails closed — it only reports which sessions contain `fallbacks/switch` events, never writes or backs up any file, and exits non-zero when it finds any (the legacy `--apply` / `--backup` flags are accepted as no-ops). The durable fix belongs upstream at the migration edges. From 0.2.2 on, the plugin stops writing durable switch events, so no new sessions need attention.
60
59
 
61
60
  ### Configuration surfaces
62
61
 
@@ -77,9 +76,9 @@ Add a `fallbacks:` section to the shared settings document (`$DSH_HOME/settings.
77
76
  ```yaml
78
77
  fallbacks:
79
78
  enabled: true # feature switch — defaults to off (plugin is a no-op otherwise)
80
- rootChain: # all-day chain: leading entries = fallback walk, last = Default model (official V4)
79
+ rootChain: # all-day chain: leading entries = fallback walk, last = Default model (official)
81
80
  - anthropic/claude-3-5-sonnet # walked first
82
- - deepseek-official/deepseek-v4-flash # last resort (Flash or Pro)
81
+ - deepseek-official/deepseek-flash # last resort (Flash or Pro)
83
82
  timeSlots: # optional: rotate the effective root chain by wall-clock windows
84
83
  - kind: preset # frozen UTC+8 window; only the chain is editable
85
84
  preset: liang-peak # Monday–Friday 09:00–12:00 and 14:00–18:00
@@ -109,7 +108,7 @@ Build the section up in four steps:
109
108
 
110
109
  **2. Set the all-day `rootChain`.** Leading entries are the fallback chain, walked first when a request fails; the **last** entry is the Default model.
111
110
 
112
- > **Conformance**: the last entry must be exactly one official V4 model — `deepseek-official/deepseek-v4-flash` XOR `deepseek-official/deepseek-v4-pro`. The settings card and gateway reject any other tail on save; a legacy non-official tail warns at startup and keeps working as a fallback-only walk, but cannot be saved as-is.
111
+ > **Conformance**: the last entry must be exactly one official model — `deepseek-official/deepseek-flash` or `deepseek-official/deepseek-pro` (XOR). The settings card and gateway reject any other tail on save; a legacy non-official tail warns at startup and keeps working as a fallback-only walk, but cannot be saved as-is. The retired `deepseek-v4-flash` / `deepseek-v4-pro` ids are no longer legal tails — a saved V4 tail now warns, goes inert (slot rows + virtual picker), and blocks save until a legal tail is picked. `deepseek-pro` is a legal selector whose model is not yet served by the catalog: the card shows it disabled ("not yet available"), and requests to it fail at the provider until the gateway enables the id. The plugin does not probe catalog availability — a chain containing `deepseek-pro` dispatches to it like any other exact entry, and the override resolves the first exact head of the effective chain, so a chain with a working entry before Pro still routes to that earlier entry.
113
112
 
114
113
  **3. Add `timeSlots` (optional).** Rows rotate the effective root chain by wall-clock windows. Preset rows use frozen UTC+8 windows (only their chain is editable; while a preset row exists, `tz` locks to `Asia/Shanghai`); custom rows take `start`/`end` (may wrap midnight) and an optional `days` list. The first row whose window contains the current moment wins; no match → the all-day `rootChain`. Rotation is a routing seed — it applies on the next root request and consumes no cooldown (see [Time slots](#time-slots)).
115
114
 
@@ -130,10 +129,12 @@ Save the config and restart the session, then type `/fallbacks` — the read-onl
130
129
  - **Chain as root primary from the picker**: when `enabled` is on, the host model picker (web and TUI alike) shows a virtual `FallbacksChain` / `Auto` row — selecting it uses the configured chain as the root primary (a conforming all-day head is required for the override to succeed); selecting a real model keeps fallback-only (see [FallbacksChain in the model picker](#fallbackschain-in-the-model-picker)).
131
130
  - **Time slots**: optional `fallbacks.timeSlots` rows rotate the effective root chain by wall-clock windows in the config-level `tz` timezone (default `Asia/Shanghai`) — four frozen UTC+8 presets (`liang-peak` / `liang-valley` / `glm-peak` / `glm-valley`, windows are code constants, models-only edits) or custom `start`/`end`/`days` windows. The first matching row wins; the all-day row is always last. A slot change applies on the **next** root request and is logged as a **time-slot switch** — a routing seed, never a failure decision: it consumes no cooldown and does not count against `maxSwitchesPerStep`. Failure walks keep the **fallback switch** copy (see [Time-slot presets](#time-slot-presets)).
132
131
  - **Dispatch-time role resolution**: on a subagent's first request its role is resolved in three stages — explicit (`agentPreset` matches a declared role id) → deterministic rules (unchanged) → LLM auto-match from the declared role taxonomy (`fallbacks.roleAutoMatch`, default `true`). The resolved role's chain-head model is injected into the first request and recorded via an explicit `role → model` log line (no durable `fallbacks/switch` event is written — issue #52 stop-write); set `roleAutoMatch: false` to disable the LLM auto-match stage (the explicit `agentPreset` stage still applies — with no explicit role this reproduces the previous rules-only behavior). The settings card always renders an **Enable role auto-match** switch (default `true`) to toggle it — the schema default applies even to legacy configs that never declared the key.
132
+ - **Subagent role badge**: when a subagent's dispatch resolves a non-`inherit` role (policy on or off), its session shows a compact role badge next to the session title in the web session header — hover shows `role → provider/model`, the effective route after any override/inject. `inherit`/unresolved sessions render no badge; records are in-process only (a host restart clears them — the `role → model` info log stays the durable record).
133
+ - **Context-window-aware fallback**: `triggerCodes` accepts any dsh failure code, including `CONTEXT_WINDOW_EXCEEDED` — a request too big for the current model fails over to a candidate with a **larger** context window (candidates that cannot fit either are skipped), and because the route itself is healthy the switch is request-scoped: no cooldown, no half-open probe burned (see [Fallback triggers](#fallback-triggers-triggercodes)).
133
134
  - **Cooldown and revert**: failed / switched-away models are not re-selected during cooldown; `revertPolicy: cooldown-expiry` returns to the primary model automatically.
134
135
  - **Host subagent model policy (dsh 0.1.2)**: when the host `subagent-model-selection` policy is enabled, its allowlist is a hard constraint on every plugin-originated subagent route — an explicit authorized spawn route stays the chain head (role-inject skipped), inheritance inject heads and failure-switch targets are intersected with the effective allowlist, and an empty intersection skips the inject/switch (warn log + read-only card warning; no out-of-allowlist request is ever sent). A present-but-unreadable policy fails closed. Policy off/absent → inject and failure-switch selection exactly as 0.3.5. Override `reasoningEffort` follows the upstream routeChanged rule on every path (same route → keep; route change → drop unless explicit). See [Host subagent model policy](#host-subagent-model-policy-dsh-012).
135
136
  - **Half-open recovery (opt-in)**: `recovery: half-open` makes recovery evidence-driven — an expired cooldown leaves the route half-open for one logged probe instead of restoring the preference; consecutive failures escalate the suppression duration (×2 per failure, capped at 1 h); an observed completion closes the circuit and fully restores the preference. `revertPolicy: 'never'` keeps the mechanism inert; state is session-scoped in-memory (a restart resets). YAML-only — the default `timer` keeps every existing behavior byte-identical (see [docs/configuration.md](docs/configuration.md#recovery-mode-recovery-key)).
136
- - **Visible behavior**: every switch is recorded in an info-level log line (from/to/role/reason) — no silent model switching. The plugin deliberately writes **no** durable `fallbacks/switch` session events (issue #52: the apply()-time event-type registration was proven ineffective, and a session containing the event refused to load after a dsh restart). Sessions written by older plugin versions that contain such events are repaired by `scripts/repair-fallbacks-switch-logs.ts`, which marks legacy events ignorable so affected sessions load again.
137
+ - **Visible behavior**: every switch is recorded in an info-level log line (from/to/role/reason) — no silent model switching. The plugin deliberately writes **no** durable `fallbacks/switch` session events (issue #52: the apply()-time event-type registration was proven ineffective, and a session containing the event refused to load after a dsh restart). Sessions written by older plugin versions that contain such events **cannot** be repaired by an `ignorable` flag — the released session-format migration chain (v0→v1) refuses unknown event types even when marked ignorable — so `scripts/repair-fallbacks-switch-logs.ts` now fails closed and only reports such logs (the durable fix belongs upstream at the migration edges).
137
138
  - **Safety valves**: `maxSwitchesPerStep` caps switches per step and `alwaysModeRetryCap` caps always-mode retries — chain loops cannot amplify latency.
138
139
  - **No-config no-op**: with no chains configured the plugin behaves exactly like not being installed (`enabled` is off by default — see [Minimal configuration](#minimal-configuration)).
139
140
 
@@ -142,7 +143,7 @@ Save the config and restart the session, then type `/fallbacks` — the read-onl
142
143
  In a dsh-tui profile the plugin has three operator surfaces, with a strict duty split:
143
144
 
144
145
  - **`/fallbacks`** — what happened this session: origin, resolved role, effective chain, recent fallback switches, cooldown status (half-open marker rows when `recovery: half-open` is active). Read-only.
145
- - **`/fallbacks config`** — what is configured: composed-config readback (trigger codes, root chain, time slots, timezone, roles, role rules, cooldown, revert policy, safety valves, presets, role auto-match). Read-only apart from the one action command **`/fallbacks config revert-seed <role-id>`**, which restores a seeded role's persona to its declared seed default (a web-card action the settings seam cannot express).
146
+ - **`/fallbacks config`** — what is configured: composed-config readback (trigger codes, root chain, time slots, timezone, roles, role rules, cooldown, revert policy, safety valves, presets, role auto-match). Read-only apart from the one action command **`/fallbacks config revert-seed <role-id>`**, which restores a seeded role's persona to its declared seed default — the web settings card presents seeded personas read-only (no revert affordance), so the command is that action's only surface.
146
147
  - **`/settings`** — the edit surface. The plugin registers a **fallbacks** section with full parity to the web settings card: booleans (`enabled`, `roleAutoMatch`) render as toggles, selects (`presets`, `revertPolicy`) as pickers, and numbers (`cooldownMs`, `maxSwitchesPerStep`, `alwaysModeRetryCap`) as numeric inputs; complex structures (`rootChain`, `timeSlots`, `roles.list`, `roles.rules`) are JSON text fields and `triggerCodes` a comma-separated text field. Invalid drafts (bad JSON, non-conforming chains, malformed time-slot rows) block the save — the section never corrupts the config.
147
148
 
148
149
  **Requirements**: the `/settings` fallbacks section needs **dsh-tui ≥ v0.8.5** (commit `c51661f` or later on `main`; the settings seam shipped in v0.8.0, the groups shape + validation in v0.8.5). On an older dsh-tui the section is absent, and file editing remains the only TUI edit surface.
@@ -159,9 +160,9 @@ There is **no `rootMode` switch** — no config key, YAML field, settings toggle
159
160
 
160
161
  Notes:
161
162
 
162
- - **Picker label**: the row's catalog `name` (what the composer trigger shows) is live — `Auto: DeepSeek V4 Flash[Liang Peak]` / `Auto: DeepSeek V4 Flash[all-day]` (catalog display name, not the model id); the id stays `Auto`. Bare `Auto` if the all-day tail is not conforming. Refresh by reopening the picker.
163
+ - **Picker label**: the row's catalog `name` (what the composer trigger shows) is live — `Auto: DeepSeek Flash[Liang Peak]` / `Auto: DeepSeek Flash[all-day]` (catalog display name, not the model id); the id stays `Auto`. Bare `Auto` if the all-day tail is not conforming. Refresh by reopening the picker.
163
164
  - **Root only**: the row is about the root agent. Subagent role resolution and injection are unchanged; a subagent session that inherits the selection still routes through the chain head — the virtual row is a thin delegate, never a second routing engine.
164
- - **Conformance gate on the tail**: a successful override/delegate requires the all-day chain to be **tail-conforming** — its last entry must be exactly one official V4 model (`deepseek-official/deepseek-v4-flash` or `deepseek-official/deepseek-v4-pro`, the card's Default model panel); leading entries (Default fallback chain) are walked first. Disabling the plugin hides the row again (slot-row/chain edits never churn registration).
165
+ - **Conformance gate on the tail**: a successful override/delegate requires the all-day chain to be **tail-conforming** — its last entry must be exactly one official model (`deepseek-official/deepseek-flash` or `deepseek-official/deepseek-pro`, the card's Default model panel); leading entries (Default fallback chain) are walked first. Disabling the plugin hides the row again (slot-row/chain edits never churn registration).
165
166
  - **Stale selection**: if the row disappears (plugin disabled) while `FallbacksChain / Auto` is selected, the session keeps showing it as the current model with `routable: false` — pick a real model from the catalog to continue (host-native catalog semantics).
166
167
  - **Capabilities follow the head**: the row's model metadata (context window, modalities, reasoning) mirrors the current effective head; retry attribution follows the permissive default — retries/failures are accounted to the real head pair, not to the `FallbacksChain` provider. Full semantics → [docs/configuration.md](docs/configuration.md).
167
168
 
@@ -169,19 +170,46 @@ Notes:
169
170
 
170
171
  Time slots are introduced in the [featured overview](#time-slots) above; this section is the reference. Time-slot rows rotate the **effective root chain** by wall-clock windows — useful for peak/valley pricing without confusing wall-clock rotation with failure fallback. The copy split is strict: slot rotation logs and UI say **time-slot switch**; the failure walk keeps **fallback switch**; the conversation notice Model downgraded stays on the failure path only.
171
172
 
172
- - **Match order**: at every root request, the first extra row whose window contains the current moment (in `fallbacks.tz`, default `Asia/Shanghai` / UTC+8) wins — that row's chain **replaces** the all-day chain. No row matches → the all-day `rootChain` is used. The all-day row is always last and **required**: its last entry must be exactly one official V4 model (Flash XOR Pro; leading Default fallback chain entries are walked first).
173
+ - **Match order**: at every root request, the first extra row whose window contains the current moment (in `fallbacks.tz`, default `Asia/Shanghai` / UTC+8) wins — that row's chain **replaces** the all-day chain. No row matches → the all-day `rootChain` is used. The all-day row is always last and **required**: its last entry must be exactly one official model (Flash / Pro XOR; leading Default fallback chain entries are walked first).
173
174
  - **Presets** (frozen, not user-editable): `liang-peak` = Monday–Friday 09:00–12:00 **and** 14:00–18:00; `liang-valley` = every other UTC+8 time; `glm-peak` = Monday–Friday 14:00–18:00; `glm-valley` = every other time. One preset id = one row; the card picker never offers a duplicate.
174
175
  - **Custom rows**: `start` / `end` (`HH:mm`, may wrap midnight) + optional `days` (0=Sunday…6=Saturday; omitted/empty = every day) + models.
175
176
  - **Next-request apply**: a slot boundary crossing never preempts an in-flight step — the new row takes effect on the next root request. Rotation is mount-only: info log + card/`/fallbacks` status line, no durable switch event.
176
- - **Settings card**: the Main agent section groups Time slots (extra rows — add preset / add custom / remove / reorder by buttons or **drag**; preset rows show a read-only window summary and edit models only; custom rows carry an editable name; the **timezone picker** lives here and **locks to Asia/Shanghai while any preset row exists**, since preset windows are frozen UTC+8 constants), Default fallback chain (walked first when no slot matches) and Default model (the official V4 Flash | Pro last-resort fallback). Rows are collapsible to name + first model. There is no `timeSlots.enabled` master switch (adding a row is the opt-in) and no `rootMode` control.
177
+ - **Settings card**: the Main agent section groups Time slots (extra rows — add preset / add custom / remove / reorder by buttons or **drag**; preset rows show a read-only window summary and edit models only; custom rows carry an editable name; the **timezone picker** lives here and **locks to Asia/Shanghai while any preset row exists**, since preset windows are frozen UTC+8 constants), Default fallback chain (walked first when no slot matches) and Default model (the official Flash | Pro last-resort fallback). Rows are collapsible to name + first model. There is no `timeSlots.enabled` master switch (adding a row is the opt-in) and no `rootMode` control.
177
178
 
178
179
  ## Preset roles
179
180
 
180
- The plugin ships **7 bundled generic subagent roles** out of the box — `designer` / `librarian` / `reviewer` / `scout` / `security-reviewer` / `sonic` / `task` — declared automatically on `apply` as seeded `roles.list` rows (`{ id, persona }`): idempotent, and never overwriting an operator persona. They appear in the Settings card (seed badge, id immutable) and in the `/fallbacks config` role summary, ready for `roles.rules` to reference.
181
+ The plugin ships **5 bundled generic subagent roles** out of the box — `reviewer` / `scout` / `security-reviewer` / `sonic` / `task` — declared automatically on `apply` as seeded `roles.list` rows (`{ id, persona }`): idempotent, and never overwriting an operator persona. They appear in the Settings card as read-only rows (a source badge on every row; the id and persona cannot be edited there) and in the `/fallbacks config` role summary, ready for `roles.rules` to reference. (`designer` and `librarian` are no longer bundled: rows saved by earlier versions keep their persona but now show source `user`, even though the operator never wrote them.)
181
182
 
182
183
  - **Switch**: `fallbacks.presets` — `'bundled'` (default) declares the preset roles on apply; `'none'` disables the automatic declaration (already-materialized rows stay).
183
184
  - Full semantics (upgrade behavior, conflict handling, library reuse of `presetRoles`) → [docs/configuration.md](docs/configuration.md).
184
185
 
186
+ ## Fallback triggers (`triggerCodes`)
187
+
188
+ `fallbacks.triggerCodes` is a plain list of dsh failure codes, and **any** code the harness can report is accepted — not only the three defaults (`AUTH` / `QUOTA` / `RATE_LIMIT`). A failure whose code is not listed passes straight through to llm-retry or to the original error, exactly as if the plugin were not installed. Retryable failures (5xx / `TRANSPORT` / `TIMEOUT` / `EMPTY_RESPONSE`) need no extra entries: llm-retry backs them off first and the failure enters the chain decision the same way once that budget is exhausted.
189
+
190
+ ### Context-window rejections
191
+
192
+ A request larger than the model's context window is reported as `CONTEXT_WINDOW_EXCEEDED` — dsh core's canonical code for a provider 400 "maximum context length". It is not a retryable code, so list it explicitly to fail over to a larger-context model:
193
+
194
+ ```yaml
195
+ fallbacks:
196
+ enabled: true
197
+ triggerCodes:
198
+ - AUTH
199
+ - QUOTA
200
+ - RATE_LIMIT
201
+ - CONTEXT_WINDOW_EXCEEDED # fail over when the request does not fit
202
+ rootChain:
203
+ - anthropic/claude-3-5-sonnet # walked first
204
+ - deepseek-official/deepseek-flash # last resort (Flash or Pro)
205
+ ```
206
+
207
+ **Ordering — the fallback runs before compaction.** With `CONTEXT_WINDOW_EXCEEDED` in `triggerCodes`, this plugin's `agent/request-error` listener handles the rejection **before** the harness's compaction plugin gets to compact: the conversation moves to the fallback model on the first overflow, and the context is never compacted. Leave the code out of `triggerCodes` if you would rather have compaction tried first.
208
+
209
+ **Request-scoped switching.** A context-window rejection says the *request* was too big, not that the route is unhealthy. Such a switch is **request-scoped**: the from-route is recorded as failed for the current step (so the step does not bounce straight back to it) and still counts against `maxSwitchesPerStep`, but it is **not** put on cooldown for `cooldownMs` and does not feed the half-open recovery counter — the next, shorter prompt goes to the primary model as usual, and no half-open probe is spent on a route that never failed. Every other trigger code stays **route-scoped** (cooldown + recovery bookkeeping) exactly as before.
210
+
211
+ **Candidates that cannot fit are skipped.** On a context-window walk the chain skips any candidate whose advertised context window is not larger than the failing model's — falling over from a 128k model to an 8k one would only fail again. Windows come from the host model catalog: the catalog row's `contextWindow` when it carries one, otherwise `llm.resolveModelInfo(provider, model)`'s `context.contextWindow`. A model that discloses no window either way is kept as a candidate, so a provider without capacity metadata never empties a chain. Skipped candidates are named in the switch log line as `skipped: context-window`.
212
+
185
213
  ## Host subagent model policy (dsh 0.1.2)
186
214
 
187
215
  dsh 0.1.2 adds host-side child-model selection for subagents: a `subagent-model-selection` settings allowlist, a per-session `subagent/model-selection-policy` event, and spawn-time `provider/model/reasoning_effort` routing. The plugin reconciles with it under one runtime arbiter — plugin roles/chains remain the failure-recovery layer the host does not provide.
package/README.zh-CN.md CHANGED
@@ -7,7 +7,7 @@
7
7
  ![node](https://img.shields.io/badge/node-%3E%3D22-339933.svg)
8
8
  ![pnpm](https://img.shields.io/badge/pnpm-%3E%3D10-f69220.svg)
9
9
  ![dsh tui](https://img.shields.io/badge/dsh%20tui-compatible-4B32C3.svg)
10
- ![dsh](https://img.shields.io/badge/DSH-0.1.2--rc.1-4B32C3.svg)
10
+ ![dsh](https://img.shields.io/badge/DSH-0.1.5--rc.1-4B32C3.svg)
11
11
  [![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
12
 
13
13
  dsh(DeepSeek Harness)的自动模型降级插件:当 root agent 或 subagent 的模型请求持续失败(重试耗尽、权限、配额超限、限流 429)时,按角色/模型 fallback 链自动切换 provider/model,当前 step/turn 在目标模型上继续完成——任务不因模型问题中断。
@@ -31,7 +31,7 @@ dsh(DeepSeek Harness)的自动模型降级插件:当 root agent 或 subage
31
31
 
32
32
  GLM 峰与 GLM 谷仅在已配置 `zai-coding-cn` 时出现在设置卡选择器中。
33
33
 
34
- 每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 Asia/Shanghai)的额外行生效;无行命中 → 全时段 `rootChain`——其链尾(默认模型)必须是恰好一个官方 V4 模型:`deepseek-official/deepseek-v4-flash` 或 `deepseek-official/deepseek-v4-pro`(二选一)。分时切换是路由种子而非失败决策:在下一个 root 请求生效、不消耗冷却、不计入 `maxSwitchesPerStep`,日志记为**分时切换**;失败降级保持**降级切换**。完整语义 → [分时槽预设(分时切换)](#分时槽预设分时切换) 与 [docs/configuration.md](docs/configuration.md)。
34
+ 每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 Asia/Shanghai)的额外行生效;无行命中 → 全时段 `rootChain`——其链尾(默认模型)必须是恰好一个官方模型:`deepseek-official/deepseek-flash` 或 `deepseek-official/deepseek-pro`(二选一)。分时切换是路由种子而非失败决策:在下一个 root 请求生效、不消耗冷却、不计入 `maxSwitchesPerStep`,日志记为**分时切换**;失败降级保持**降级切换**。完整语义 → [分时槽预设(分时切换)](#分时槽预设分时切换) 与 [docs/configuration.md](docs/configuration.md)。
35
35
 
36
36
  ## 快速开始
37
37
 
@@ -46,17 +46,16 @@ dsh plugin --profile dsh-tui add dsh-llm-fallbacks # dsh-tui 终端 profile
46
46
 
47
47
  ### 修复旧会话(0.2.2 之前的版本)
48
48
 
49
- 0.2.2 之前的版本会把 `fallbacks/switch` 事件写入会话持久化日志,而新版 dsh 拒绝加载这类会话(issue #52——apply() 时的注册因插件与宿主解析到不同模块实例而无效)。如果升级后已有会话打不开,clone 本仓库并修复日志(先停 dsh):
49
+ 0.2.2 之前的版本会把 `fallbacks/switch` 事件写入会话持久化日志,而新版 dsh 拒绝加载这类会话(issue #52——apply() 时的注册因插件与宿主解析到不同模块实例而无效)。如果升级后已有会话打不开,clone 本仓库并运行检测器:
50
50
 
51
51
  ```sh
52
52
  git clone https://github.com/omdsh-dev/dsh-llm-fallbacks.git
53
53
  cd dsh-llm-fallbacks
54
54
  pnpm install
55
- pnpm repair:fallbacks-switch-logs -- --dry-run # 预览哪些会话会被改动
56
- pnpm repair:fallbacks-switch-logs -- --apply --backup # 给旧事件打 ignorable 标记
55
+ pnpm repair:fallbacks-switch-logs -- --dry-run
57
56
  ```
58
57
 
59
- 脚本默认扫描 `~/.dsh/sessions`(可用 `--root <dir>` 覆盖),把遗留 `fallbacks/switch` 事件标记为 `ignorable: true`,宿主读路径即可重新接受该会话;每个被修复的日志保留一份 `<file>.bak`。`--apply` 必须搭配 `--backup`,且须在 dsh 停止时运行。从 0.2.2 起插件不再写 durable 切换事件,新会话无需修复。
58
+ 脚本默认扫描 `~/.dsh/sessions`(可用 `--root <dir>` 覆盖)。**它无法修复这些日志**:已发布的 session-format 迁移链(v0→v1)即使事件带 `ignorable: true` 也拒绝未知事件类型,因此「修复后」的日志仍会被拒绝加载。脚本因此 fail-closed——只报告哪些会话含 `fallbacks/switch` 事件,绝不写入或备份任何文件,发现即非零退出(遗留的 `--apply` / `--backup` 参数按 no-op 接受)。持久修复应在上游迁移边界完成。从 0.2.2 起插件不再写 durable 切换事件,新会话无需处理。
60
59
 
61
60
  ### 配置界面
62
61
 
@@ -77,9 +76,9 @@ pnpm repair:fallbacks-switch-logs -- --apply --backup # 给旧事件打 igno
77
76
  ```yaml
78
77
  fallbacks:
79
78
  enabled: true # 功能开关——默认关闭(否则插件完全 no-op)
80
- rootChain: # 全时段链:前面的条目 = 降级路径,最后一项 = 默认模型(官方 V4)
79
+ rootChain: # 全时段链:前面的条目 = 降级路径,最后一项 = 默认模型(官方模型)
81
80
  - anthropic/claude-3-5-sonnet # 先走
82
- - deepseek-official/deepseek-v4-flash # 最后一档(Flash 或 Pro)
81
+ - deepseek-official/deepseek-flash # 最后一档(Flash 或 Pro)
83
82
  timeSlots: # 可选:按墙钟窗口轮换生效 root 链
84
83
  - kind: preset # 冻结的 UTC+8 窗口;仅链可编辑
85
84
  preset: liang-peak # 周一至周五 09:00–12:00 与 14:00–18:00
@@ -109,7 +108,7 @@ fallbacks:
109
108
 
110
109
  **2. 配置全时段 `rootChain`。** 前面的条目是降级链,请求失败时先走;**最后**一项是默认模型。
111
110
 
112
- > **链尾合规**:最后一项必须是恰好一个官方 V4 模型——`deepseek-official/deepseek-v4-flash` 或 `deepseek-official/deepseek-v4-pro`(二选一)。设置卡与 gateway 在保存时拒绝其它尾巴;遗留的非合规尾巴启动时告警并继续按 fallback-only 走原链,但无法原样保存。
111
+ > **链尾合规**:最后一项必须是恰好一个官方模型——`deepseek-official/deepseek-flash` 或 `deepseek-official/deepseek-pro`(二选一)。设置卡与 gateway 在保存时拒绝其它尾巴;遗留的非合规尾巴启动时告警并继续按 fallback-only 走原链,但无法原样保存。已退役的 `deepseek-v4-flash` / `deepseek-v4-pro` 不再是合法链尾——已保存的 V4 尾巴现在会告警、进入惰性(分时行 + 虚拟选择器)、并在选到合法链尾前阻止保存。`deepseek-pro` 是合法选择器但其模型尚未进入目录:设置卡中显示为禁用(「暂不可用」),请求在 gateway 启用该 id 前会在 provider 处失败。插件不探测目录可用性——含 `deepseek-pro` 的链会像其它精确条目一样派发到它;覆盖解析生效链的第一个精确链头,因此 Pro 之前有可用条目的链仍路由到更早的条目。
113
112
 
114
113
  **3. 添加 `timeSlots`(可选)。** 各行按墙钟窗口轮换生效 root 链。预设行使用冻结的 UTC+8 窗口(仅链可编辑;存在预设行时 `tz` 锁定 `Asia/Shanghai`);自定义行使用 `start`/`end`(可跨午夜)与可选的 `days` 列表。第一个窗口包含当前时刻的行生效;无行命中 → 全时段 `rootChain`。分时切换是路由种子——在下一个 root 请求生效、不消耗冷却(见 [峰谷无忧](#峰谷无忧))。
115
114
 
@@ -130,10 +129,12 @@ fallbacks:
130
129
  - **选择器里把链当主模型**:`enabled` 开启时,宿主模型选择器(web 与 TUI 一致)出现虚拟 `FallbacksChain` / `Auto` 行——选中它即以配置的链作为 root 主模型(需要 all-day 链头合规才能成功覆盖);选真实模型则保持 fallback-only(见 [模型选择器中的 FallbacksChain](#模型选择器中的-fallbackschain))。
131
130
  - **峰谷无忧(分时切换)**:可选的 `fallbacks.timeSlots` 行按墙钟窗口(配置级 `tz` 时区,默认 `Asia/Shanghai`)轮换 root 生效链——四个冻结的 UTC+8 预设(`liang-peak` / `liang-valley` / `glm-peak` / `glm-valley`,窗口为代码常量、仅模型链可编辑),或自定义 `start`/`end`/`days` 窗口。第一条命中的行生效;全时段行固定最后。时段切换在**下一个** root 请求生效,日志记为**分时切换**——路由种子而非失败决策:不消耗冷却、不计入 `maxSwitchesPerStep`。失败降级保留**降级切换**文案(见 [分时槽预设(分时切换)](#分时槽预设分时切换))。
132
131
  - **派发时角色解析**:在 subagent 的首次请求上,其角色按三个阶段解析——显式(`agentPreset` 匹配已声明角色 id)→ 确定性规则(不变)→ LLM 自动匹配(从已声明角色体系中选择,`fallbacks.roleAutoMatch` 默认 `true`)。解析出的角色的链头模型注入首次请求,并以显式 `role → model` 日志行记录(不写 durable `fallbacks/switch` 事件——issue #52 停写);设 `roleAutoMatch: false` 仅关闭 LLM 自动匹配阶段(显式 `agentPreset` 阶段仍生效——无显式角色时即复现原有仅规则行为)。设置卡总是渲染「启用角色自动匹配」开关(默认 `true`)以切换之——即使是从未声明过该键的旧配置,schema 默认值同样生效。
132
+ - **子代理角色徽标**:当 subagent 的派发解析出非 `inherit` 角色(策略开或关)时,其会话在 Web 会话头部的标题旁显示一个紧凑的角色徽标——悬停显示 `role → provider/model`(覆盖/注入后的实际生效路由)。`inherit`/未解析角色的会话不显示徽标;记录仅存于进程内(宿主重启即清空——`role → model` info 日志仍是持久记录)。
133
+ - **上下文窗口感知降级**:`triggerCodes` 接受任意 dsh 失败码,包括 `CONTEXT_WINDOW_EXCEEDED`——请求超出当前模型上下文时,降级到上下文窗口**更大**的候选(装不下的候选被跳过);由于路由本身健康,该切换是请求级的:不冷却、不浪费半开探针(见 [降级触发码](#降级触发码triggercodes))。
133
134
  - **冷却与回主**:被切离/失败的模型在冷却期内不再入选;`revertPolicy: cooldown-expiry` 冷却到期后自动回主模型。
134
135
  - **宿主子代理模型策略(dsh 0.1.2)**:当宿主 `subagent-model-selection` 策略启用时,其允许列表对每个插件发起的 subagent 路由都是硬约束——显式授权的派发路由保持为链头(跳过角色注入),继承注入的链头与失败切换目标都与生效允许列表求交集,交集为空则跳过注入/切换(warn 日志 + 只读卡片警告;绝不发送允许列表之外的请求)。策略存在但不可读时 fail-closed。策略关闭/缺省时,注入与失败切换的选择与 0.3.5 完全一致。覆盖路径上的 `reasoningEffort` 遵循上游 routeChanged 规则(同路由 → 保留;跨路由 → 除非显式指定否则丢弃)。见 [宿主子代理模型策略](#宿主子代理模型策略dsh-012)。
135
136
  - **半开恢复(可选)**:`recovery: half-open` 让恢复以证据驱动——冷却到期后路由进入 **half-open**,以一次记录探针(logged probe)放行,而不是直接恢复首选;连续失败使抑制时长按 **×2** 逐次升级、**1 小时**封顶;观察到完成即闭合回路、完全恢复首选。`revertPolicy: 'never'` 使该机制完全失效;状态为会话级内存态(重启即重置)。仅 YAML 配置——默认 `timer` 保持所有既有行为逐字节一致(见 [docs/configuration.md](docs/configuration.md#recovery-mode-recovery-key))。
136
- - **行为可见**:每次切换以 info 级日志行(from/to/role/reason)记录——无静默换模型。插件**刻意不写** durable `fallbacks/switch` 会话事件(issue #52——apply() 时的事件类型注册被证伪无效,含该事件的会话在 dsh 重启后拒绝加载)。由旧版插件写入、含此类事件的会话由 `scripts/repair-fallbacks-switch-logs.ts` 修复——旧事件被标记 ignorable 后,受影响会话可重新加载。
137
+ - **行为可见**:每次切换以 info 级日志行(from/to/role/reason)记录——无静默换模型。插件**刻意不写** durable `fallbacks/switch` 会话事件(issue #52——apply() 时的事件类型注册被证伪无效,含该事件的会话在 dsh 重启后拒绝加载)。由旧版插件写入、含此类事件的会话**无法**通过 `ignorable` 标记修复——已发布的 session-format 迁移链(v0→v1)即使事件带 `ignorable` 也拒绝未知事件类型——因此 `scripts/repair-fallbacks-switch-logs.ts` 现在 fail-closed,只报告此类日志(持久修复应在上游迁移边界完成)。
137
138
  - **安全阀**:`maxSwitchesPerStep` 限制每 step 切换次数、`alwaysModeRetryCap` 限制 always 模式重试——链循环不会放大延迟。
138
139
  - **无配置回归(no-op)**:未配置任何链时行为与未安装插件完全一致——`enabled` 默认关闭(见 [最小配置](#最小配置))。
139
140
 
@@ -142,7 +143,7 @@ fallbacks:
142
143
  在 dsh-tui profile 中,插件有三个操作面——职责严格区分:
143
144
 
144
145
  - **`/fallbacks`** —— 本次会话发生了什么:来源、解析角色、生效链、最近降级切换、冷却状态(`recovery: half-open` 生效时显示 half-open 标记行)。只读。
145
- - **`/fallbacks config`** —— 配置了什么:组合配置回读(触发码、根链、分时槽、时区、角色、角色规则、冷却、回主策略、安全阀、预置、角色自动匹配)。除唯一的动作命令 **`/fallbacks config revert-seed <role-id>`** 外只读——该命令把某个 seed 角色的 persona 还原为已声明的默认(设置 seam 无法表达 Web 卡的这类动作能力)。
146
+ - **`/fallbacks config`** —— 配置了什么:组合配置回读(触发码、根链、分时槽、时区、角色、角色规则、冷却、回主策略、安全阀、预置、角色自动匹配)。除唯一的动作命令 **`/fallbacks config revert-seed <role-id>`** 外只读——该命令把某个 seed 角色的 persona 还原为已声明的默认(Web 设置卡将 seed 角色的 persona 呈现为只读、不提供还原入口,此命令是该动作的唯一入口)。
146
147
  - **`/settings`** —— 编辑界面。插件注册 **fallbacks** 区块,与 **Web 设置卡完全一致**:布尔(`enabled`、`roleAutoMatch`)渲染为开关、下拉(`presets`、`revertPolicy`)为选择器、数值(`cooldownMs`、`maxSwitchesPerStep`、`alwaysModeRetryCap`)为数字输入;复杂结构(`rootChain`、`timeSlots`、`roles.list`、`roles.rules`)为 JSON 文本字段,`triggerCodes` 为逗号分隔文本字段。非法草稿(JSON 解析失败、链尾不合规、分时行畸形)会阻止保存——区块绝不写入损坏配置。
147
148
 
148
149
  **版本要求**:`/settings` 的 fallbacks 区块需要 **dsh-tui ≥ v0.8.5**(`main` 上 commit `c51661f` 及以后;settings seam 于 v0.8.0 引入,groups 结构与校验于 v0.8.5 引入)。更旧的 dsh-tui 没有该区块,文件编辑仍是 TUI 唯一编辑面。
@@ -159,9 +160,9 @@ fallbacks:
159
160
 
160
161
  注意:
161
162
 
162
- - **选择器文案**:目录行的 `name`(composer 触发器显示)是动态的——`Auto: DeepSeek V4 Flash[Liang Peak]` / `Auto: DeepSeek V4 Flash[all-day]`(用 catalog 显示名,不是 model id);id 仍是 `Auto`。all-day 尾巴不合规则只显示 `Auto`。重新打开选择器即可刷新。
163
+ - **选择器文案**:目录行的 `name`(composer 触发器显示)是动态的——`Auto: DeepSeek Flash[Liang Peak]` / `Auto: DeepSeek Flash[all-day]`(用 catalog 显示名,不是 model id);id 仍是 `Auto`。all-day 尾巴不合规则只显示 `Auto`。重新打开选择器即可刷新。
163
164
  - **仅 root**:这一行只关乎 root 代理。subagent 的角色解析与注入不变;继承了该选择的 subagent 会话仍经链头路由——虚拟行只是薄委托,绝不是第二个路由引擎。
164
- - **链尾合规门槛**:覆盖/委托成功要求 all-day 链**尾巴合规**——最后一项必须是恰好一个官方 V4 模型(`deepseek-official/deepseek-v4-flash` 或 `deepseek-official/deepseek-v4-pro`,即设置卡的「默认模型」面板);前面的默认降级链先走。禁用插件后该行隐藏(slot/链编辑不会触发注册抖动)。
165
+ - **链尾合规门槛**:覆盖/委托成功要求 all-day 链**尾巴合规**——最后一项必须是恰好一个官方模型(`deepseek-official/deepseek-flash` 或 `deepseek-official/deepseek-pro`,即设置卡的「默认模型」面板);前面的默认降级链先走。禁用插件后该行隐藏(slot/链编辑不会触发注册抖动)。
165
166
  - **过期选择**:行消失(插件禁用)而会话仍选中 `FallbacksChain / Auto` 时,会话继续把它显示为当前模型,但 `routable: false`——从目录选一个真实模型即可继续(宿主原生目录语义)。
166
167
  - **能力跟随链头**:该行的模型元数据(上下文窗口、模态、推理)镜像当前生效链头;重试归属保持宽松默认——重试/失败记到被委托的真实链头,而非 `FallbacksChain` provider。完整语义 → [docs/configuration.md](docs/configuration.md)。
167
168
 
@@ -169,19 +170,46 @@ fallbacks:
169
170
 
170
171
  峰谷无忧在[首页专题](#峰谷无忧)中介绍,本节是完整参考。分时槽行按墙钟窗口轮换**生效 root 链**——适合按峰谷切换模型,且不会把墙钟轮换误认为故障降级。文案严格区分:时段轮换的日志与 UI 用**分时切换**;失败降级保持**降级切换**;会话内「模型已降级」提示只出现在失败路径。
171
172
 
172
- - **匹配顺序**:每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 `Asia/Shanghai` / UTC+8)的额外行生效——该行的模型链**取代**全时段链;无行命中则用全时段 `rootChain`。全时段行固定最后且**必选**:最后一项必须是恰好一个官方 V4 模型(Flash 或 Pro;前面的降级条目先走)。
173
+ - **匹配顺序**:每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 `Asia/Shanghai` / UTC+8)的额外行生效——该行的模型链**取代**全时段链;无行命中则用全时段 `rootChain`。全时段行固定最后且**必选**:最后一项必须是恰好一个官方模型(Flash 或 Pro;前面的降级条目先走)。
173
174
  - **预设**(冻结,不可编辑窗口):`liang-peak` = 周一至周五 09:00–12:00 **与** 14:00–18:00;`liang-valley` = 其它所有 UTC+8 时间;`glm-peak` = 周一至周五 14:00–18:00;`glm-valley` = 其余时间。一个预设 id 对应一行;设置卡的选择器不会重复提供已添加的预设。
174
175
  - **自定义行**:`start` / `end`(`HH:mm`,可跨午夜)+ 可选 `days`(0=周日…6=周六;缺省/空 = 每天)+ 模型。
175
176
  - **下一请求生效**:时段边界跨越绝不打断进行中的 step——新行在下一个 root 请求生效。轮换仅挂载生效:info 日志 + 设置卡/`/fallbacks` 状态行,无 durable 切换事件。
176
- - **设置卡**:主代理区块下分三块——**分时槽设置**(额外行:添加预设 / 添加自定义 / 删除 / 按钮或**拖拽**排序;预设行只读展示窗口摘要、仅可编辑模型链;自定义行带可编辑名称;**时区选择器**在此区块内,只要存在预设行就**锁定 Asia/Shanghai**——预设窗口是冻结的 UTC+8 常量)、**默认降级链**(all-day 链,可配置的 provider/model 选择器列表)与**默认模型**(官方 V4 Flash | Pro 二选一链头)。行可折叠为「名称 + 首个模型」。没有 `timeSlots.enabled` 总开关(添加行即开启),也没有 `rootMode` 控件。
177
+ - **设置卡**:主代理区块下分三块——**分时槽设置**(额外行:添加预设 / 添加自定义 / 删除 / 按钮或**拖拽**排序;预设行只读展示窗口摘要、仅可编辑模型链;自定义行带可编辑名称;**时区选择器**在此区块内,只要存在预设行就**锁定 Asia/Shanghai**——预设窗口是冻结的 UTC+8 常量)、**默认降级链**(all-day 链,可配置的 provider/model 选择器列表)与**默认模型**(官方 Flash | Pro 二选一链头)。行可折叠为「名称 + 首个模型」。没有 `timeSlots.enabled` 总开关(添加行即开启),也没有 `rootMode` 控件。
177
178
 
178
179
  ## 预设角色(Preset roles)
179
180
 
180
- 插件内置 **7 个通用子代理角色**,开箱即用——`designer` / `librarian` / `reviewer` / `scout` / `security-reviewer` / `sonic` / `task`——`apply` 时自动以 seeded `roles.list` 行(`{ id, persona }`)声明:幂等,且绝不覆盖 operator 同名 persona。它们出现在设置卡的 seed 徽标(id 不可改)与 `/fallbacks config` 的角色摘要中,可直接被 `roles.rules` 引用。
181
+ 插件内置 **5 个通用子代理角色**,开箱即用——`reviewer` / `scout` / `security-reviewer` / `sonic` / `task`——`apply` 时自动以 seeded `roles.list` 行(`{ id, persona }`)声明:幂等,且绝不覆盖 operator 同名 persona。它们以只读行的形式出现在设置卡中(每行带来源徽标,id 与 persona 均不可在卡内编辑),并出现在 `/fallbacks config` 的角色摘要中,可直接被 `roles.rules` 引用。(`designer` 与 `librarian` 不再内置:早期版本保存的行保留其 persona,但现在显示 source `user`——尽管 operator 并未写过这些行。)
181
182
 
182
183
  - **开关**:`fallbacks.presets`——`'bundled'`(默认)在 apply 时声明预设角色;`'none'` 关闭自动声明(已物化行保留)。
183
184
  - 完整语义(升级行为、冲突处理、`presetRoles` 库复用)→ [docs/configuration.md](docs/configuration.md)。
184
185
 
186
+ ## 降级触发码(`triggerCodes`)
187
+
188
+ `fallbacks.triggerCodes` 是一份 dsh 失败码列表,**任何**宿主可上报的失败码都被接受——不限于三个默认值(`AUTH` / `QUOTA` / `RATE_LIMIT`)。未列出的失败码原样透传给 llm-retry 或原始错误,与未安装插件完全一致。可重试型失败(5xx / `TRANSPORT` / `TIMEOUT` / `EMPTY_RESPONSE`)无需额外条目:llm-retry 先行退避,预算耗尽后同样进入链决策。
189
+
190
+ ### 上下文窗口超限(context-window)
191
+
192
+ 请求超出模型上下文窗口时,上报的失败码是 `CONTEXT_WINDOW_EXCEEDED`——dsh 内核对 provider 400 "maximum context length" 的规范码。它不是可重试码,需显式列出,才能降级到上下文更大的模型:
193
+
194
+ ```yaml
195
+ fallbacks:
196
+ enabled: true
197
+ triggerCodes:
198
+ - AUTH
199
+ - QUOTA
200
+ - RATE_LIMIT
201
+ - CONTEXT_WINDOW_EXCEEDED # 请求装不下时降级
202
+ rootChain:
203
+ - anthropic/claude-3-5-sonnet # 先走
204
+ - deepseek-official/deepseek-flash # 兜底(Flash 或 Pro)
205
+ ```
206
+
207
+ **顺序——降级先于压缩。** 只要 `triggerCodes` 含 `CONTEXT_WINDOW_EXCEEDED`,本插件的 `agent/request-error` 监听就会**先于**宿主压缩插件处理该拒绝:首次超限即把会话切到 fallback 模型,上下文完全不会被压缩。若更希望先尝试压缩,就不要把该码列入 `triggerCodes`。
208
+
209
+ **请求级切换。** 上下文超限说明**这一次请求**太大,而不是路由不健康。此类切换是**请求级(request-scoped)**:from 路由仍记入本 step 的失败集合(本 step 不会立刻弹回该模型)、仍计入 `maxSwitchesPerStep`,但**不**进入 `cooldownMs` 冷却、也不累加半开恢复计数器——下一条更短的提示词照常走主模型,不会在从未故障的路由上浪费一次半开探针。其余触发码一律保持**路由级(route-scoped)**(冷却 + 恢复记账),与既有行为一致。
210
+
211
+ **装不下的候选被跳过。** 上下文超限走链时,凡是已知上下文窗口不大于失败模型的候选一律跳过——从 128k 模型降到 8k 只会再失败一次。窗口取自宿主模型目录:目录行自带 `contextWindow` 时取之,否则取 `llm.resolveModelInfo(provider, model)` 的 `context.contextWindow`。两处都未公开窗口的模型保留为候选,因此缺少容量元数据的 provider 绝不会把链清空。被跳过的候选在切换日志行中标注 `skipped: context-window`。
212
+
185
213
  ## 宿主子代理模型策略(dsh 0.1.2)
186
214
 
187
215
  dsh 0.1.2 为 subagent 增加了宿主侧的子模型选择:`subagent-model-selection` 设置允许列表、每会话的 `subagent/model-selection-policy` 事件、派发时的 `provider/model/reasoning_effort` 路由。插件在单一运行时仲裁点下与之协调——插件的角色/链仍是宿主不提供的失败恢复层。
package/dist/chains.d.ts CHANGED
@@ -27,7 +27,11 @@
27
27
  * direct-call default.
28
28
  *
29
29
  * Cooldown / failed-set / same-as-current filtering is caller-side;
30
- * {@link createCandidateFilter} provides the ready-made predicate.
30
+ * {@link createCandidateFilter} provides the ready-made predicate. A
31
+ * `CONTEXT_WINDOW_EXCEEDED` walk adds one more caller-side dimension to the
32
+ * same predicate — candidates whose disclosed context window is not larger
33
+ * than the failing model's cannot fit the request either
34
+ * ({@link fitsContextWindow}; unknown windows keep the candidate).
31
35
  *
32
36
  * @module dsh-llm-fallbacks/chains
33
37
  */
@@ -134,15 +138,30 @@ export interface CandidateFilterOptions {
134
138
  failed: Pick<StepFailureSet, 'has'>;
135
139
  /** Optional existence probe: candidates the target provider lacks are skipped. */
136
140
  modelExists?: (provider: string, model: string) => boolean;
141
+ /**
142
+ * The failing model's context window, when known — set only for a
143
+ * `CONTEXT_WINDOW_EXCEEDED` walk. Candidates whose own known window is not
144
+ * LARGER than this cannot fit the request either and are skipped.
145
+ */
146
+ minContextWindow?: number;
147
+ /** Context-window lookup for a candidate route; `undefined` = not disclosed. */
148
+ contextWindowOf?: (provider: string, model: string) => number | undefined;
137
149
  }
138
150
  /**
139
151
  * The caller-side candidate filter (Task 3): a candidate is usable when it
140
152
  * differs from the current model, is not cooldown-suppressed, has not failed
141
- * in this step, and (when `modelExists` is given) exists on its provider.
153
+ * in this step, can fit the request that overflowed (context-window walks
154
+ * only — see {@link fitsContextWindow}), and (when `modelExists` is given)
155
+ * exists on its provider.
142
156
  */
143
157
  export declare function createCandidateFilter(options: CandidateFilterOptions): (candidate: Selector) => boolean;
144
- /** Why one considered candidate was excluded from the selection (spec §2 行为可见性). */
145
- export type CandidateSkipReason = 'same-as-current' | 'cooldown' | 'step-failed' | 'missing-id';
158
+ /**
159
+ * Why one considered candidate was excluded from the selection (spec §2
160
+ * 行为可见性). `context-window` is the `CONTEXT_WINDOW_EXCEEDED` walk's
161
+ * exclusion: the candidate's disclosed window is not larger than the failing
162
+ * model's, so the request that just overflowed cannot fit there either.
163
+ */
164
+ export type CandidateSkipReason = 'same-as-current' | 'cooldown' | 'step-failed' | 'context-window' | 'missing-id';
146
165
  /** One entry of the ordered, per-candidate annotation (T3 review Minor 1). */
147
166
  export interface AnnotatedCandidate {
148
167
  candidate: Selector;
@@ -164,4 +183,4 @@ export interface AnnotatedCandidate {
164
183
  * This is the diagnostic counterpart of {@link createCandidateFilter} — pure,
165
184
  * order-preserving, and duplicate-preserving.
166
185
  */
167
- export declare function annotateCandidates(candidates: readonly Selector[], surviving: readonly Selector[], options: Pick<CandidateFilterOptions, 'current' | 'cooldown' | 'failed'>): AnnotatedCandidate[];
186
+ export declare function annotateCandidates(candidates: readonly Selector[], surviving: readonly Selector[], options: Pick<CandidateFilterOptions, 'current' | 'cooldown' | 'failed' | 'minContextWindow' | 'contextWindowOf'>): AnnotatedCandidate[];
@@ -0,0 +1,23 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
3
+ import type { FallbacksSettingsController } from './fallbacks-store.ts';
4
+ /**
5
+ * Injected dependencies of {@link SubagentRoleBadge} (slot `inject`): the
6
+ * shared controller, the same seam the settings card and the General row use.
7
+ * The badge reads through {@link FallbacksSettingsController.fetchSubagentRole}
8
+ * — the controller's rpc face (the same `/api` caller the store rides); no
9
+ * second channel, no extra inject member.
10
+ */
11
+ export interface SubagentRoleBadgeInjected {
12
+ /** The shared controller (its `fetchSubagentRole` rides the gateway channel). */
13
+ controller: FallbacksSettingsController;
14
+ }
15
+ /** Props delivered by the utilities slot outlet: runtime share + locale seat + inject face. */
16
+ export type SubagentRoleBadgeProps = PropsRuntime<'conversation.session.header.utilities'> & PropsLocale<'fallbacks'> & SubagentRoleBadgeInjected;
17
+ /**
18
+ * Render the viewed session's dispatch-resolved role badge.
19
+ * @param props - composed slot props (the `sessionId` session-kit seat is
20
+ * read structurally — see the module docblock).
21
+ * @returns the badge element, or `null` when there is nothing to show.
22
+ */
23
+ export declare function SubagentRoleBadge(props: SubagentRoleBadgeProps): ReactNode;
@@ -169,6 +169,47 @@ export type SubagentPolicyView = {
169
169
  * throws on an old or misshapen field (old-payload tolerance).
170
170
  */
171
171
  export declare function parseSubagentPolicyWire(value: unknown): SubagentPolicyView | undefined;
172
+ /**
173
+ * One dispatch-resolved subagent role record as the session-header badge
174
+ * reads it (plan subagent-role-badge T3) — a structural twin of the gateway
175
+ * wire type, parsed from unknown, never imported from host modules
176
+ * (`src/client/**` consumes the gateway channel only — the
177
+ * `SubagentPolicyView` precedent). `model` is optional on the wire (gateway
178
+ * version skew); the writer always sets it.
179
+ */
180
+ export interface SubagentRoleView {
181
+ /** The dispatch-resolved role id (never `inherit` — the writer skips it). */
182
+ role: string;
183
+ /** The route the subagent actually runs (override target, else the seed). */
184
+ model?: {
185
+ provider: string;
186
+ model: string;
187
+ };
188
+ /** Record time, Unix epoch milliseconds. */
189
+ at: number;
190
+ }
191
+ /**
192
+ * Shape-guard one wire role record (`fallbacks/subagent-roles` readback).
193
+ * A non-object, a blank/non-string `role`, or a non-number `at` fails the
194
+ * WHOLE record (`undefined` → the badge renders nothing). A present-but-
195
+ * malformed `model` drops the FIELD while the record still parses (the
196
+ * `parseSeedsWire` `source` rule) — the badge then hovers the role alone
197
+ * instead of disappearing. Records are NORMALIZED to the known keys, so a
198
+ * wire record can never smuggle a non-string `model` past the guard's type.
199
+ */
200
+ export declare function parseSubagentRoleRecord(value: unknown): SubagentRoleView | undefined;
201
+ /**
202
+ * Read this session's dispatch-resolved role record through the plugin
203
+ * gateway channel (plan subagent-role-badge T3): the batch-of-ids endpoint
204
+ * called with the one id the badge cares about. NEVER throws — a channel
205
+ * down, an old gateway without the endpoint (rpc failure), or a malformed
206
+ * payload all resolve `undefined` (the badge renders nothing —
207
+ * degrade-never-crash, the fail-closed shape of `parseSubagentPolicyWire`).
208
+ * @param rpc - the connection's generic RPC caller (`/api` channel).
209
+ * @param sessionId - the session whose record to read.
210
+ * @returns the parsed record, or `undefined` when absent/failed.
211
+ */
212
+ export declare function fetchSubagentRoleRecord(rpc: ClientConnectionRpc, sessionId: string): Promise<SubagentRoleView | undefined>;
172
213
  /**
173
214
  * The provider dropdown's offer set (spec §2.5 D-4): catalog providers whose
174
215
  * settings profile resolves in the describe namespaces — the Models page's
@@ -308,6 +349,13 @@ export interface RoleRow {
308
349
  fallback: FallbackStrategy;
309
350
  /** UI-only collapse state — never serialized (dropped by rowsToRoles). */
310
351
  collapsed: boolean;
352
+ /**
353
+ * UI-only seeded-persona brief disclosure (plan role-card-seeded-ux) —
354
+ * never serialized (dropped by rowsToRoles). Only seeded rows render the
355
+ * affordance (a read-only single-line brief + the expandable full text);
356
+ * ordinary rows ignore the flag.
357
+ */
358
+ personaOpen: boolean;
311
359
  }
312
360
  /** Project the declared roles into editable rows (chain selectors classified). */
313
361
  export declare function rolesToRows(roles: readonly FallbacksRole[], catalog?: CatalogLookup): RoleRow[];
@@ -501,24 +549,17 @@ export declare class FallbacksSettingsController {
501
549
  */
502
550
  resetToDefaults(): Promise<void>;
503
551
  /**
504
- * Revert one seeded role to its CURRENT declared seed default (spec §9.4,
505
- * AC-3) through the gateway channel (`/api/fallbacks/revert-seed`). Same
506
- * write guards as {@link save} — writable / saving / write-generation —
507
- * and the same KD-G3 error handling: any business rejection or transport
508
- * failure surfaces its message in `state.error` for the error banner and
509
- * the form stays editable for retry. A business `{ reverted: false,
510
- * reason }` outcome is still a successful RPC — the post-write read
511
- * result (config / legacyKeys / seeds) lands either way, and the revert
512
- * button stays disabled while the write is in flight.
513
- *
514
- * Returns the seed-default persona when the outcome is `{ reverted:
515
- * true, persona }` — including the persist no-op (persisted already
516
- * equals the seed). The card applies that string to the row's **draft**
517
- * so an unsaved persona edit still snaps back (issue #59).
518
- * @param id - the seeded role id; the host matches it by trimmed id
519
- * against the seed registry (spec §9.3).
552
+ * Read one session's dispatch-resolved subagent role record through the
553
+ * gateway channel (plan subagent-role-badge T3 — the session-header badge's
554
+ * data face). Thin delegate over {@link fetchSubagentRoleRecord} on the
555
+ * SAME rpc caller the store rides: the batch-of-ids readback called with
556
+ * the one id the badge cares about. Never throws — a channel down, an old
557
+ * gateway without the endpoint, or a malformed payload all resolve
558
+ * `undefined` (the badge renders nothing).
559
+ * @param sessionId - the session whose record to read.
560
+ * @returns the parsed record, or `undefined` when absent/failed.
520
561
  */
521
- revertSeed(id: string): Promise<string | undefined>;
562
+ fetchSubagentRole(sessionId: string): Promise<SubagentRoleView | undefined>;
522
563
  /** Stop in-flight responses from publishing after plugin disposal. */
523
564
  dispose(): void;
524
565
  /**
@@ -36,6 +36,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis';
36
36
  export type { FallbacksCardInjected, FallbacksCardProps } from './FallbacksCard.tsx';
37
37
  export type { GeneralFallbacksRowInjected, GeneralFallbacksRowProps } from './GeneralFallbacksRow.tsx';
38
38
  export type { ConversationFallbackSwitchProps, FallbacksSwitchChatData, } from './ConversationFallbackSwitch.tsx';
39
+ export type { SubagentRoleBadgeInjected, SubagentRoleBadgeProps } from './SubagentRoleBadge.tsx';
39
40
  export type { FallbacksSettingsState } from './fallbacks-store.ts';
40
41
  export { FallbacksSettingsController, FALLBACKS_SETTINGS_NS } from './fallbacks-store.ts';
41
42
  /**