dsh-llm-fallbacks 0.4.3 → 0.5.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 +89 -33
- package/README.zh-CN.md +89 -33
- package/dist/client/index.js +68 -61
- package/dist/client/locales.d.ts +4 -0
- package/dist/events.d.ts +5 -3
- package/dist/index.d.ts +6 -4
- package/dist/index.js +165 -69
- package/dist/role-resolution.d.ts +11 -0
- package/dist/roles.d.ts +4 -1
- package/dist/seeds.d.ts +63 -21
- package/dist/time-slots.d.ts +21 -11
- package/dist/virtual-adapter.d.ts +42 -6
- package/package.json +47 -26
- package/scripts/build-client.ts +9 -5
- package/scripts/repair-session-logs.ts +1858 -0
- package/scripts/session-logs/catalog.ts +547 -0
- package/scripts/session-logs/classify.ts +47 -0
- package/scripts/session-logs/publish.ts +530 -0
- package/scripts/session-logs/rules.ts +1227 -0
- package/scripts/repair-fallbacks-switch-logs.ts +0 -374
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|

|
|
8
8
|

|
|
9
9
|

|
|
10
|
-

|
|
11
11
|
[](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
|
|
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
|
|
|
@@ -44,20 +44,6 @@ dsh plugin --profile dsh-tui add dsh-llm-fallbacks # dsh-tui terminal profile
|
|
|
44
44
|
|
|
45
45
|
Same plugin, either front end — the only difference is the `--profile` flag. Pin a version with `@<version>`. A registry install fetches the **built package** (`dist/`), nothing builds on the target machine. Registry / git / local-directory variants, uninstall, and `--dump-config` verification → [docs/install.md](docs/install.md).
|
|
46
46
|
|
|
47
|
-
### Repair existing sessions (versions before 0.2.2)
|
|
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):
|
|
50
|
-
|
|
51
|
-
```sh
|
|
52
|
-
git clone https://github.com/omdsh-dev/dsh-llm-fallbacks.git
|
|
53
|
-
cd dsh-llm-fallbacks
|
|
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
|
|
57
|
-
```
|
|
58
|
-
|
|
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.
|
|
60
|
-
|
|
61
47
|
### Configuration surfaces
|
|
62
48
|
|
|
63
49
|
The plugin's settings live in a shared `fallbacks:` namespace, editable from three surfaces:
|
|
@@ -77,9 +63,9 @@ Add a `fallbacks:` section to the shared settings document (`$DSH_HOME/settings.
|
|
|
77
63
|
```yaml
|
|
78
64
|
fallbacks:
|
|
79
65
|
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
|
|
66
|
+
rootChain: # all-day chain: leading entries = fallback walk, last = Default model (official)
|
|
81
67
|
- anthropic/claude-3-5-sonnet # walked first
|
|
82
|
-
- deepseek-official/deepseek-
|
|
68
|
+
- deepseek-official/deepseek-flash # last resort (Flash or Pro)
|
|
83
69
|
timeSlots: # optional: rotate the effective root chain by wall-clock windows
|
|
84
70
|
- kind: preset # frozen UTC+8 window; only the chain is editable
|
|
85
71
|
preset: liang-peak # Monday–Friday 09:00–12:00 and 14:00–18:00
|
|
@@ -109,7 +95,7 @@ Build the section up in four steps:
|
|
|
109
95
|
|
|
110
96
|
**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
97
|
|
|
112
|
-
> **Conformance**: the last entry must be exactly one official
|
|
98
|
+
> **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 on the virtual route the `stream()` delegate serves the first dispatchable exact head of the effective chain, so a chain with a working entry before Pro still routes to that earlier entry.
|
|
113
99
|
|
|
114
100
|
**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
101
|
|
|
@@ -123,19 +109,89 @@ Full reference (role entities, fallback strategies, rules, selectors, preset rol
|
|
|
123
109
|
|
|
124
110
|
Save the config and restart the session, then type `/fallbacks` — the read-only in-session diagnostics (origin, resolved role, chain, recent fallback switches, cooldown status). In a dsh-tui profile, `/fallbacks config` reads back the composed configuration; see [dsh-tui profile (terminal)](#dsh-tui-profile-terminal).
|
|
125
111
|
|
|
112
|
+
## Repair existing sessions
|
|
113
|
+
|
|
114
|
+
A session log reaches the GUI only through the **frozen released migration chain**. That chain throws at the first row it cannot classify, so a pre-V3 log written by an older release — or by any plugin that merged a custom message-source kind — makes the session fail to load, and the bytes never change on their own. Two classes cover nearly every case, and both are the same kind of root cause: content outside what a released format edge admits.
|
|
115
|
+
|
|
116
|
+
| Class | What the log carries | Root cause |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| `source-kind` | a message `source.kind` outside the released vocabulary | the vocabulary is frozen **per format edge**, so a plugin that merged a custom kind into `MessageSourceMap` leaves the V2→V3 edge refusing the log (`cannot safely transform unclassified message source`). The released kinds are `user`, `plugin`, `model`, `tool`, `agent-instructions`, `session-reference`, `team-message`, `goal`, `skill-invocation`, `skill-catalog`, `coordinator`, `subagent-report`, `subagent-settled`, `webhook`, `agent-message` |
|
|
119
|
+
| `subagent-descriptor-version` | a `subagent/descriptor` row with `version: 2` | the V0→V1 edge admits only descriptor `version: 3`, and dsh releases `v0.1.0-rc.7` … `v0.1.1-rc.2` wrote version 2 |
|
|
120
|
+
|
|
121
|
+
A third class — the legacy `fallbacks/switch` event type — is **not** repairable by a rewrite: see [Lossy recovery for `fallbacks/switch`](#lossy-recovery-for-fallbacksswitch-opt-in).
|
|
122
|
+
|
|
123
|
+
**If you author a plugin: never add a custom `source.kind`.** The persisted vocabulary is frozen per format edge, so a custom kind makes every session that carries it unreadable by later dsh releases. Use the sanctioned `plugin` arm instead — `{ kind: 'plugin', plugin: '<stable-id>', form: … }` — exactly as dsh's own `model-selection` notice does; the stable id records what the original kind was.
|
|
124
|
+
|
|
125
|
+
### Usage
|
|
126
|
+
|
|
127
|
+
The tool is run from this repository (clone + `pnpm install`). Its sources do ship in the npm tarball, but there is no runnable entry there — no `bin` is registered and `tsx` is a dev dependency — so a registry install cannot run it. It is a **read-only report by default**: it walks a session root, classifies every pre-V3 log into exactly one refusal class and prints per-class counts. Nothing is written unless `--apply` is given.
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
git clone https://github.com/omdsh-dev/dsh-llm-fallbacks.git
|
|
131
|
+
cd dsh-llm-fallbacks
|
|
132
|
+
pnpm install
|
|
133
|
+
pnpm repair:session-logs # read-only report (the default)
|
|
134
|
+
pnpm repair:session-logs -- --root ~/.dsh/sessions # explicit session root
|
|
135
|
+
pnpm repair:session-logs -- --apply # publish a repaired successor generation
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`--dry-run` no longer exists: the report **is** the default mode, and `--dry-run` is rejected as an unknown argument (exit 2).
|
|
139
|
+
|
|
140
|
+
| Flag | Meaning |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `--root DIR` | session root to walk (default: `$DSH_HOME` or `~/.dsh`, then `/sessions`) |
|
|
143
|
+
| `--apply` | run the rules' proofs and publish a current-generation successor beside each repaired original (requires a resolved released catalog) |
|
|
144
|
+
| `--class NAME` | restrict what `--apply` repairs to one refusal class; the listing, the class table and the exit code still cover every log, and the repairable verdict is always computed over the **full** policy, so it can never hide a refusal or promise a repair this invocation cannot perform |
|
|
145
|
+
| `--catalog PATH` | explicit released catalog path (package directory, a directory holding it, or its module entry file). The resolved module is **executed**, not parsed — the same privilege as running this tool — and must declare a current format version ≥ 3; a below-version catalog, or a file candidate owned by a different package, is refused (exit 2) rather than trusted to verify its own output |
|
|
146
|
+
| `--backup` | copy the original generation to `<name>.bak` before publishing; a run that publishes nothing removes the copy it created again (a pre-existing one is never touched, and one holding different bytes blocks the repair until you inspect and delete it) |
|
|
147
|
+
| `--drop-legacy-events` | opt-in **lossy** recovery for legacy `fallbacks/switch` rows (see below) |
|
|
148
|
+
| `--json` | machine-readable report instead of the text report |
|
|
149
|
+
| `--quiet` | suppress the per-log lines and the by-class table (warnings and errors are never suppressed) |
|
|
150
|
+
| `--help`, `-h` | print the usage text (including the `--apply` precondition) and exit 0 |
|
|
151
|
+
|
|
152
|
+
The original generation is never modified and never truncated; rollback is deleting the published successor, which makes the original the generation dsh opens again.
|
|
153
|
+
|
|
154
|
+
**`--apply` precondition: run it only while NO dsh instance is writing the sessions under `--root`.** The publisher links the successor into the session directory **without observing the host's flock lease** (that lease is host-internal and cannot be taken from this repo), so a dsh that is still appending to the old generation would be orphaned once the host prefers the successor. Stop dsh first.
|
|
155
|
+
|
|
156
|
+
**Runtime floor: Node ≥ 22.15.** Reading and writing need `node:zlib` zstd, which that release added (`engines.node` allows `>= 22`); an older runtime fails closed with an actionable message instead of a module-link stack trace.
|
|
157
|
+
|
|
158
|
+
**Two policies, and `ok-truncated`.** A log's class and its `ok` come from the **host loader's** policy — the one that decides whether the GUI opens the session — but that policy can swallow a refusal and drop every row after it. Every `ok` log is therefore cross-checked with the **same loader policy under strict recovery** (one axis apart, so a refusal there means rows were dropped rather than "not current-shaped"): when it refuses, the log is reported **`ok-truncated`** (a `strictRefusal` reason in `--json`, an `ok-truncated` count in the summary) because the session opens **without** the rows that refusal swallowed. Such sessions still exit `0` — they do load.
|
|
159
|
+
|
|
160
|
+
**Discovery never fails open.** A namespace/session directory that cannot be read, a canonical generation **below the format floor** that is a symlink or not a regular file, and a leftover `session.repair.*.jsonl.zstd.tmp` are **reported** (a `skipped`/`staleStagingFiles` entry, always printed and never hidden by `--quiet`), suppress the "no session log …" line, and make the run exit `1`. Symlinks are reported, never followed: a repair writes beside the generation it repairs, which must stay inside `--root`. An unreadable `--root` is fatal (exit `2`) — never an empty report.
|
|
161
|
+
|
|
162
|
+
**`--apply` refuses a revision it did not decode.** The digest of the bytes this run read is compared with the file before anything is staged, so a concurrent append is refused before the first write. If the source moves *after* a publication this run created, that successor is removed again; if it moves after accepting a pre-existing identical successor, the failure names that file and says to delete it — never "nothing was published".
|
|
163
|
+
|
|
164
|
+
**Exit codes**: `0` = every log loads (a session that opens with rows dropped under the strict policy is `ok-truncated` and still exits `0`); `1` = at least one log is still refused/unrepairable, a repair failed, a log was left unpublished (including one `--class` excluded), an input could not be inspected (a `skipped` path), or a stale `session.repair.*.jsonl.zstd.tmp` was found under the root; `2` = fatal (bad arguments, a missing or unreadable `--root`, `--apply` without a resolved catalog, a catalog below format v3, `--apply --drop-legacy-events` without `--backup`, or a runtime without `node:zlib` zstd).
|
|
165
|
+
|
|
166
|
+
### Lossy recovery for `fallbacks/switch` (opt-in)
|
|
167
|
+
|
|
168
|
+
Versions before 0.2.2 wrote durable `fallbacks/switch` session events (issue #52: the apply()-time event-type registration is ineffective because plugin and host resolve different module instances). The frozen V0→V1 edge refuses that event type **even when the row carries `ignorable: true`**, so no rewrite can make such a row load — removal is the only in-repo recovery. Opting in means accepting two consequences:
|
|
169
|
+
|
|
170
|
+
1. **The session's recorded provider/model switch audit rows are deleted.** They survive only in the original generation (and in its `.bak` when `--backup` is used): the published successor is the only readable generation that lacks them.
|
|
171
|
+
2. **The surviving events are renumbered.** The same edge requires every event's `seq` to equal its running event count, so each surviving event receives the `seq` of its position in the surviving event stream and every Session-seq reference it carries is shifted with it. Their content is otherwise unchanged, byte for byte.
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
pnpm repair:session-logs -- --drop-legacy-events # report: count what would be dropped
|
|
175
|
+
pnpm repair:session-logs -- --drop-legacy-events --apply --backup # --backup is required here
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The mode is **off by default**, report mode writes nothing, and `--apply --drop-legacy-events` **requires `--backup`** (exit 2 without it). It fails closed, writing nothing, when a surviving row references a seq that would be dropped, when the source rows are not densely numbered, or when the renumber would cross the header's seed cut. `--json` reports `legacyEventCount` (legacy rows in the source log), `droppedEventCount`, `renumberedEventCount` and a machine-readable `lossyRefusal` reason per log; text runs print the dropped/renumbered counts as a loud warning. A row of any *other* unknown event type is never dropped — such a log stays unrepairable. Without the flag those sessions stay unreadable by design, with their bytes preserved.
|
|
179
|
+
|
|
180
|
+
The durable fix belongs upstream at the migration edges (the frozen V0→V1 edge admitting the descriptor versions it shipped, and custom message-source kinds moving to the `plugin` arm).
|
|
181
|
+
|
|
126
182
|
## Features
|
|
127
183
|
|
|
128
184
|
- **Automatic fallback for root and subagents**: any agent switches down the chain to the next available provider/model on model failure — no manual model switching.
|
|
129
185
|
- **Two-block config**: `rootChain` for the root agent; declared role entities (`roles.list`) referenced by `roles.rules` (or the built-in `inherit`).
|
|
130
|
-
- **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
|
|
186
|
+
- **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 delegation to succeed); selecting a real model keeps fallback-only (see [FallbacksChain in the model picker](#fallbackschain-in-the-model-picker)).
|
|
131
187
|
- **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
|
-
- **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 (
|
|
188
|
+
- **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 (matching either the recorded pair or the served head — see [FallbacksChain in the model picker](#fallbackschain-in-the-model-picker)) → 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.
|
|
133
189
|
- **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).
|
|
134
190
|
- **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)).
|
|
135
191
|
- **Cooldown and revert**: failed / switched-away models are not re-selected during cooldown; `revertPolicy: cooldown-expiry` returns to the primary model automatically.
|
|
136
192
|
- **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).
|
|
137
193
|
- **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)).
|
|
138
|
-
- **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
|
|
194
|
+
- **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 `pnpm repair:session-logs` reports them (and recovers them only with the opt-in lossy `--drop-legacy-events` — see [Repair existing sessions](#repair-existing-sessions)).
|
|
139
195
|
- **Safety valves**: `maxSwitchesPerStep` caps switches per step and `alwaysModeRetryCap` caps always-mode retries — chain loops cannot amplify latency.
|
|
140
196
|
- **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)).
|
|
141
197
|
|
|
@@ -153,33 +209,33 @@ File editing still works everywhere: the shared `$DSH_HOME/settings.yaml` (`fall
|
|
|
153
209
|
|
|
154
210
|
## FallbacksChain in the model picker
|
|
155
211
|
|
|
156
|
-
When `enabled: true`, the plugin registers a virtual provider, **FallbacksChain**, with a single catalog row: **Auto**. The web profile and dsh-tui both see the row: they share the same adapter catalog, so the row needs no settings-page wiring or host patch (it is independent of the `/settings` fallbacks section, which edits configuration rather than the picker catalog). The row is visible whenever the plugin is enabled — a legacy or empty all-day chain does NOT hide it (the
|
|
212
|
+
When `enabled: true`, the plugin registers a virtual provider, **FallbacksChain**, with a single catalog row: **Auto**. The web profile and dsh-tui both see the row: they share the same adapter catalog, so the row needs no settings-page wiring or host patch (it is independent of the `/settings` fallbacks section, which edits configuration rather than the picker catalog). The row is visible whenever the plugin is enabled — a legacy or empty all-day chain does NOT hide it (the delegate just refuses to serve it).
|
|
157
213
|
|
|
158
|
-
Selecting **FallbacksChain / Auto** uses the configured chain as the root **primary**:
|
|
214
|
+
Selecting **FallbacksChain / Auto** uses the configured chain as the root **primary**: the request stays on the virtual pair and the adapter's thin delegate dispatches the effective chain's first exact `provider/model` at request time, so the fallback engine degrades from that head as usual. Serving the selection unchanged (instead of rewriting the route to the head) is also what keeps the host's model-change notice quiet: the recorded route equals the session selection, so the notice appears once on a genuine selection change rather than being re-armed on every step. Selecting any real catalog model keeps the v0.2.2 fallback-only behavior — the session model is primary and the chain engages only after it fails.
|
|
159
215
|
|
|
160
216
|
There is **no `rootMode` switch** — no config key, YAML field, settings toggle, or gateway flag. The mode is the session's `{provider, model}` selection itself: `FallbacksChain` = chain primary; any real model = fallback-only.
|
|
161
217
|
|
|
162
218
|
Notes:
|
|
163
219
|
|
|
164
|
-
- **Picker label**: the row's catalog `name` (what the composer trigger shows) is live — `Auto: DeepSeek
|
|
165
|
-
- **
|
|
166
|
-
- **Conformance gate on the tail**: a successful
|
|
220
|
+
- **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.
|
|
221
|
+
- **Thin delegate on every origin**: the row is served by the same `stream()` delegate for the root agent and for a subagent session that inherits the selection — subagent role resolution and injection keep their semantics apart from one deliberate widening: dispatch-time rule matching accepts **either** the recorded pair or the served head (a superset of the previous matching, so a rule keyed on the real head keeps matching), and the virtual row is never a second routing engine. A subagent that inherits the selection still routes through the chain head.
|
|
222
|
+
- **Conformance gate on the tail**: a successful delegation 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).
|
|
167
223
|
- **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).
|
|
168
|
-
- **Capabilities follow the head**: the row's model metadata (context window, modalities, reasoning) mirrors the current effective head
|
|
224
|
+
- **Capabilities and retry policy follow the head**: the row's model metadata (context window, modalities, reasoning) mirrors the current effective head, and `providerRetryPolicy` returns **that head's** policy — so a user's `llm-deepseek.retryPolicy` applies on this route too, not the permissive default. The host captures the policy once at registration, so a later policy edit or a slot-driven head-provider rotation shows up only after the plugin re-registers. Retry events are keyed by the virtual provider — the route the runtime actually sees. Full semantics → [docs/configuration.md](docs/configuration.md).
|
|
169
225
|
|
|
170
226
|
## Time-slot presets
|
|
171
227
|
|
|
172
228
|
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.
|
|
173
229
|
|
|
174
|
-
- **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
|
|
230
|
+
- **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).
|
|
175
231
|
- **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.
|
|
176
232
|
- **Custom rows**: `start` / `end` (`HH:mm`, may wrap midnight) + optional `days` (0=Sunday…6=Saturday; omitted/empty = every day) + models.
|
|
177
233
|
- **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.
|
|
178
|
-
- **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
|
|
234
|
+
- **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.
|
|
179
235
|
|
|
180
236
|
## Preset roles
|
|
181
237
|
|
|
182
|
-
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
|
|
238
|
+
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 and now show source `user` as ordinary editable rows only when no live producer still declares the id, even though the operator never wrote them; a companion's live declaration keeps them seeded and read-only with source `external` when unnamed or its registered set name otherwise.)
|
|
183
239
|
|
|
184
240
|
- **Switch**: `fallbacks.presets` — `'bundled'` (default) declares the preset roles on apply; `'none'` disables the automatic declaration (already-materialized rows stay).
|
|
185
241
|
- Full semantics (upgrade behavior, conflict handling, library reuse of `presetRoles`) → [docs/configuration.md](docs/configuration.md).
|
|
@@ -202,7 +258,7 @@ fallbacks:
|
|
|
202
258
|
- CONTEXT_WINDOW_EXCEEDED # fail over when the request does not fit
|
|
203
259
|
rootChain:
|
|
204
260
|
- anthropic/claude-3-5-sonnet # walked first
|
|
205
|
-
- deepseek-official/deepseek-
|
|
261
|
+
- deepseek-official/deepseek-flash # last resort (Flash or Pro)
|
|
206
262
|
```
|
|
207
263
|
|
|
208
264
|
**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.
|
|
@@ -228,7 +284,7 @@ When the policy is **enabled**:
|
|
|
228
284
|
|
|
229
285
|
When the policy is **disabled or absent**, inject and failure-switch **selection** are unchanged from 0.3.5 (no allowlist filter, no authorized-route skip).
|
|
230
286
|
|
|
231
|
-
**Effort rule (policy-independent)**: `reasoningEffort` on every override path (role-inject, failure switch, always-cap switch
|
|
287
|
+
**Effort rule (policy-independent)**: `reasoningEffort` on every override path (role-inject, failure switch, always-cap switch) follows the upstream 0.1.2 `routeChanged` rule — an unchanged provider+model route keeps the seed effort; a route change drops it unless an effort is explicitly named for the override (explicit always survives). A stale effort is never carried into a different provider.
|
|
232
288
|
|
|
233
289
|
Full semantics → [docs/configuration.md](docs/configuration.md#host-subagent-model-selection-dsh-012).
|
|
234
290
|
|
package/README.zh-CN.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|

|
|
8
8
|

|
|
9
9
|

|
|
10
|
-

|
|
11
11
|
[](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
|
|
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
|
|
|
@@ -44,20 +44,6 @@ dsh plugin --profile dsh-tui add dsh-llm-fallbacks # dsh-tui 终端 profile
|
|
|
44
44
|
|
|
45
45
|
同一个插件、两个前端——区别只在 `--profile` 参数。钉版本:加 `@<version>`。registry 安装拉取的是**已构建产物**(`dist/`),目标机无需构建。registry / git / 本地目录变体、卸载与 `--dump-config` 验证 → [docs/install.md](docs/install.md)。
|
|
46
46
|
|
|
47
|
-
### 修复旧会话(0.2.2 之前的版本)
|
|
48
|
-
|
|
49
|
-
0.2.2 之前的版本会把 `fallbacks/switch` 事件写入会话持久化日志,而新版 dsh 拒绝加载这类会话(issue #52——apply() 时的注册因插件与宿主解析到不同模块实例而无效)。如果升级后已有会话打不开,clone 本仓库并修复日志(先停 dsh):
|
|
50
|
-
|
|
51
|
-
```sh
|
|
52
|
-
git clone https://github.com/omdsh-dev/dsh-llm-fallbacks.git
|
|
53
|
-
cd dsh-llm-fallbacks
|
|
54
|
-
pnpm install
|
|
55
|
-
pnpm repair:fallbacks-switch-logs -- --dry-run # 预览哪些会话会被改动
|
|
56
|
-
pnpm repair:fallbacks-switch-logs -- --apply --backup # 给旧事件打 ignorable 标记
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
脚本默认扫描 `~/.dsh/sessions`(可用 `--root <dir>` 覆盖),把遗留 `fallbacks/switch` 事件标记为 `ignorable: true`,宿主读路径即可重新接受该会话;每个被修复的日志保留一份 `<file>.bak`。`--apply` 必须搭配 `--backup`,且须在 dsh 停止时运行。从 0.2.2 起插件不再写 durable 切换事件,新会话无需修复。
|
|
60
|
-
|
|
61
47
|
### 配置界面
|
|
62
48
|
|
|
63
49
|
插件的设置存在于共享的 `fallbacks:` 命名空间中,可通过三个界面编辑:
|
|
@@ -77,9 +63,9 @@ pnpm repair:fallbacks-switch-logs -- --apply --backup # 给旧事件打 igno
|
|
|
77
63
|
```yaml
|
|
78
64
|
fallbacks:
|
|
79
65
|
enabled: true # 功能开关——默认关闭(否则插件完全 no-op)
|
|
80
|
-
rootChain: # 全时段链:前面的条目 = 降级路径,最后一项 =
|
|
66
|
+
rootChain: # 全时段链:前面的条目 = 降级路径,最后一项 = 默认模型(官方模型)
|
|
81
67
|
- anthropic/claude-3-5-sonnet # 先走
|
|
82
|
-
- deepseek-official/deepseek-
|
|
68
|
+
- deepseek-official/deepseek-flash # 最后一档(Flash 或 Pro)
|
|
83
69
|
timeSlots: # 可选:按墙钟窗口轮换生效 root 链
|
|
84
70
|
- kind: preset # 冻结的 UTC+8 窗口;仅链可编辑
|
|
85
71
|
preset: liang-peak # 周一至周五 09:00–12:00 与 14:00–18:00
|
|
@@ -109,7 +95,7 @@ fallbacks:
|
|
|
109
95
|
|
|
110
96
|
**2. 配置全时段 `rootChain`。** 前面的条目是降级链,请求失败时先走;**最后**一项是默认模型。
|
|
111
97
|
|
|
112
|
-
>
|
|
98
|
+
> **链尾合规**:最后一项必须是恰好一个官方模型——`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` 的链会像其它精确条目一样派发到它;在虚拟路由上由 `stream()` 委派服务生效链的第一个可派发精确链头,因此 Pro 之前有可用条目的链仍路由到更早的条目。
|
|
113
99
|
|
|
114
100
|
**3. 添加 `timeSlots`(可选)。** 各行按墙钟窗口轮换生效 root 链。预设行使用冻结的 UTC+8 窗口(仅链可编辑;存在预设行时 `tz` 锁定 `Asia/Shanghai`);自定义行使用 `start`/`end`(可跨午夜)与可选的 `days` 列表。第一个窗口包含当前时刻的行生效;无行命中 → 全时段 `rootChain`。分时切换是路由种子——在下一个 root 请求生效、不消耗冷却(见 [峰谷无忧](#峰谷无忧))。
|
|
115
101
|
|
|
@@ -123,19 +109,89 @@ fallbacks:
|
|
|
123
109
|
|
|
124
110
|
保存配置并重启会话,然后键入 `/fallbacks`——只读的会话内诊断(来源、解析角色、链、最近降级切换、冷却状态)。在 dsh-tui profile 中,`/fallbacks config` 回读组合配置;见 [dsh-tui profile(终端)](#dsh-tui-profile终端)。
|
|
125
111
|
|
|
112
|
+
## 修复已有会话
|
|
113
|
+
|
|
114
|
+
会话日志只能经**冻结的已发布迁移链**进入 GUI。该链在第一条无法归类的记录处即抛错,因此由旧版发行版写入的 pre-V3 日志——或由任何合并过自定义 message source kind 的插件写入的日志——会让会话加载失败,而其字节不会自行改变。以下两类覆盖了几乎所有情况,且根因同类:内容超出了某条已发布格式边界的准入范围。
|
|
115
|
+
|
|
116
|
+
| 类别 | 日志携带什么 | 根因 |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| `source-kind` | 消息的 `source.kind` 不在已发布词汇表内 | 词汇表按**格式边界冻结**:插件把自定义 kind 合并进 `MessageSourceMap` 后,V2→V3 边界会拒绝该日志(`cannot safely transform unclassified message source`)。已发布 kind 为 `user`、`plugin`、`model`、`tool`、`agent-instructions`、`session-reference`、`team-message`、`goal`、`skill-invocation`、`skill-catalog`、`coordinator`、`subagent-report`、`subagent-settled`、`webhook`、`agent-message` |
|
|
119
|
+
| `subagent-descriptor-version` | `subagent/descriptor` 记录的 `version: 2` | V0→V1 边界只准入 descriptor `version: 3`,而 dsh `v0.1.0-rc.7` … `v0.1.1-rc.2` 写入的是 version 2 |
|
|
120
|
+
|
|
121
|
+
第三类——遗留 `fallbacks/switch` 事件类型——**无法**通过改写修复:见 [fallbacks/switch 的有损恢复(opt-in)](#fallbacksswitch-的有损恢复opt-in)。
|
|
122
|
+
|
|
123
|
+
**如果你在编写插件:绝不要新增自定义 `source.kind`。** 持久化词汇表按格式边界冻结,自定义 kind 会让携带它的每个会话在后续 dsh 发行版中都无法读取。请改用受支持的 `plugin` 分支——`{ kind: 'plugin', plugin: '<stable-id>', form: … }`——正如 dsh 自身的 `model-selection` notice 那样;稳定 id 记录了原 kind 是什么。
|
|
124
|
+
|
|
125
|
+
### 用法
|
|
126
|
+
|
|
127
|
+
工具在本仓库内运行(clone + `pnpm install`)。其源码确实随 npm tarball 一起发布,但那里没有可运行的入口——没有注册 `bin`,且 `tsx` 是 devDependency——因此 registry 安装无法运行它。它**默认只读报告**:遍历会话根目录、把每个 pre-V3 日志归入恰好一个拒绝类别,并打印各类别计数。只有给出 `--apply` 才会写入。
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
git clone https://github.com/omdsh-dev/dsh-llm-fallbacks.git
|
|
131
|
+
cd dsh-llm-fallbacks
|
|
132
|
+
pnpm install
|
|
133
|
+
pnpm repair:session-logs # 只读报告(默认)
|
|
134
|
+
pnpm repair:session-logs -- --root ~/.dsh/sessions # 指定会话根目录
|
|
135
|
+
pnpm repair:session-logs -- --apply # 发布修复后的后继世代
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`--dry-run` 已不存在:报告**就是**默认模式,`--dry-run` 会作为未知参数被拒绝(exit 2)。
|
|
139
|
+
|
|
140
|
+
| 参数 | 含义 |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `--root DIR` | 要遍历的会话根目录(默认 `$DSH_HOME` 或 `~/.dsh`,再退到 `/sessions`) |
|
|
143
|
+
| `--apply` | 运行各规则的证明,并在每个被修复的原件旁发布当前世代的后继(需要先解析出已发布 catalog) |
|
|
144
|
+
| `--class NAME` | 把 `--apply` 的修复范围限制为单一拒绝类别;列表、按类别表与退出码仍覆盖 `--root` 下的每个日志,且「可修复」判定始终基于**完整**策略计算,因此该参数绝不会隐藏拒绝,也不会承诺本次调用无法完成的修复 |
|
|
145
|
+
| `--catalog PATH` | 显式指定已发布 catalog 路径(包目录、包含它的目录,或其模块入口文件)。解析出的模块会被**执行**而非解析(与本工具自身的运行权限相同),且必须声明当前格式版本 ≥ 3;版本过低的 catalog、或归属其他包的模块文件都会被拒绝(exit 2),而不是被信任去校验自己写出的后继 |
|
|
146
|
+
| `--backup` | 发布前把原始世代复制为 `<name>.bak`;若本次运行最终没有发布任何内容,则会把**本次创建**的副本再删除(已存在的副本绝不会被触碰;若其字节与当前原件不同,则会阻断修复并提示该文件,直到你检查并删除它) |
|
|
147
|
+
| `--drop-legacy-events` | 针对遗留 `fallbacks/switch` 行的 opt-in **有损**恢复(见下) |
|
|
148
|
+
| `--json` | 输出机器可读报告以替代文本报告 |
|
|
149
|
+
| `--quiet` | 抑制逐日志行与按类别表(警告与错误永不抑制) |
|
|
150
|
+
| `--help`、`-h` | 打印用法文本(含 `--apply` 前置条件)并以 exit 0 退出 |
|
|
151
|
+
|
|
152
|
+
原始世代绝不会被修改、也绝不会被截断;回滚即删除已发布的后继世代,原世代重新成为 dsh 打开的世代。
|
|
153
|
+
|
|
154
|
+
**`--apply` 前置条件:仅当没有 dsh 实例正在写入 `--root` 下的会话时才可运行。** 发布器把后继世代链接进会话目录时**不观察宿主的 flock 租约**(该租约是宿主内部的,本仓库无法取得),因此仍在向旧世代追加写入的 dsh 会在宿主改用后继世代后被孤立。请先停止 dsh。
|
|
155
|
+
|
|
156
|
+
**运行时下限:Node ≥ 22.15。** 读取与写入需要 `node:zlib` 的 zstd(该版本引入;`engines.node` 允许 `>= 22`);更旧的运行时会 fail-closed 并给出可操作的提示,而不是模块链接堆栈。
|
|
157
|
+
|
|
158
|
+
**两种策略,以及 `ok-truncated`。** 日志的类别与 `ok` 来自**宿主加载器**的策略——即决定 GUI 能否打开该会话的策略——但该策略可能吞掉一次拒绝并丢弃其后的所有行。因此每个 `ok` 日志都会用**同一加载器策略在严格恢复下**复核(只差 recovery 这一个维度,因此那里的拒绝意味着确实丢行,而不是“形状不是当前格式”):若该复核拒绝,则报告为 **`ok-truncated`**(`--json` 中的 `strictRefusal` 原因、summary 中的 `ok-truncated` 计数),因为该会话是在**缺少**被吞掉的那些行的情况下打开的。这类会话仍以 `0` 退出——它们确实能加载。
|
|
159
|
+
|
|
160
|
+
**发现阶段绝不 fail-open。** 无法读取的命名空间/会话目录、**低于格式下限**的作为符号链接或非普通文件的规范世代、以及残留的 `session.repair.*.jsonl.zstd.tmp`,都会被**报告**(`skipped`/`staleStagingFiles` 条目,始终打印,不会被 `--quiet` 隐藏)、抑制 “no session log …” 行,并使运行以 `1` 退出。符号链接只报告、绝不跟随:修复会在其目标旁写入,而写入必须留在 `--root` 内。无法读取的 `--root` 是致命错误(exit 2),而绝不是一份空报告。
|
|
161
|
+
|
|
162
|
+
**`--apply` 拒绝发布它不是基于其解码的那一版。** 本次运行读取到的字节摘要会在暂存任何内容之前与文件比对,因此并发追加会在首次写入前被拒绝。若源文件在本次运行**创建**的后继发布之后发生变化,该后继会被移除;若是在接受一个已存在的相同后继之后发生变化,失败信息会指出该文件并说明需删除它以回滚——绝不会说 “nothing was published”。
|
|
163
|
+
|
|
164
|
+
**退出码**:`0` = 每个日志都能加载(在严格策略下会丢行打开的会话报告为 `ok-truncated`,仍以 `0` 退出);`1` = 至少一个日志仍被拒绝/不可修复、修复失败、有日志未发布(包括被 `--class` 排除的)、某个输入无法被检查(`skipped` 路径),或发现残留的 `session.repair.*.jsonl.zstd.tmp`;`2` = 致命错误(参数非法、缺少或无法读取 `--root`、`--apply` 未解析出 catalog、catalog 低于格式 v3、`--apply --drop-legacy-events` 缺少 `--backup`,或运行时没有 `node:zlib` zstd)。
|
|
165
|
+
|
|
166
|
+
### fallbacks/switch 的有损恢复(opt-in)
|
|
167
|
+
|
|
168
|
+
0.2.2 之前的版本会写入 durable `fallbacks/switch` 会话事件(issue #52:apply() 时的注册无效,因为插件与宿主解析到不同模块实例)。冻结的 V0→V1 边界**即使该行带 `ignorable: true` 也**拒绝该事件类型,因此没有任何改写能让这样的行加载——删除是本仓库内唯一的恢复方式。选择 opt-in 意味着接受两个后果:
|
|
169
|
+
|
|
170
|
+
1. **该会话记录的 provider/model 切换审计行会被删除。** 它们此后只存在于原始世代中(使用 `--backup` 时还有其 `.bak`):已发布的后继世代是唯一缺少它们、却可读的世代。
|
|
171
|
+
2. **幸存事件会被重新编号。** 同一边界要求每个事件的 `seq` 等于其运行中的事件计数,因此每个幸存事件都会取得它在幸存事件流中的位置所对应的 `seq`,它携带的每个 Session-seq 引用也随之一并平移。除此之外内容逐字节不变。
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
pnpm repair:session-logs -- --drop-legacy-events # 报告:统计将被删除的行
|
|
175
|
+
pnpm repair:session-logs -- --drop-legacy-events --apply --backup # 此处必须带 --backup
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
该模式**默认关闭**,报告模式不写任何文件,且 `--apply --drop-legacy-events` **必须带 `--backup`**(否则 exit 2)。以下情况 fail-closed、不写任何文件:幸存行引用了将被删除的 seq、源行的编号不稠密、或重新编号会跨越头部的种子切点。`--json` 会按日志报告 `legacyEventCount`(源日志中的遗留行数)、`droppedEventCount`、`renumberedEventCount` 以及机器可读的 `lossyRefusal` 原因;文本模式把 dropped/renumbered 计数作为醒目警告打印。其它名字的未知事件类型永不删除——这类日志保持不可修复。不加该参数时,这些会话按设计保持不可读,字节原样保留。
|
|
179
|
+
|
|
180
|
+
持久修复属于上游迁移边界(让冻结的 V0→V1 边界准入它曾发行的 descriptor 版本,并让自定义 message source kind 迁移到 `plugin` 分支)。
|
|
181
|
+
|
|
126
182
|
## 能力一览
|
|
127
183
|
|
|
128
184
|
- **root / subagent 自动降级**:任意 agent 在模型故障下按链切换到下一个可用 provider/model,无需手动换模型。
|
|
129
185
|
- **两块制配置**:`rootChain` 管 root 代理;声明式角色实体(`roles.list`)供 `roles.rules` 引用(或内置 `inherit`)。
|
|
130
|
-
- **选择器里把链当主模型**:`enabled` 开启时,宿主模型选择器(web 与 TUI 一致)出现虚拟 `FallbacksChain` / `Auto` 行——选中它即以配置的链作为 root 主模型(需要 all-day
|
|
186
|
+
- **选择器里把链当主模型**:`enabled` 开启时,宿主模型选择器(web 与 TUI 一致)出现虚拟 `FallbacksChain` / `Auto` 行——选中它即以配置的链作为 root 主模型(需要 all-day 链头合规才能成功委托);选真实模型则保持 fallback-only(见 [模型选择器中的 FallbacksChain](#模型选择器中的-fallbackschain))。
|
|
131
187
|
- **峰谷无忧(分时切换)**:可选的 `fallbacks.timeSlots` 行按墙钟窗口(配置级 `tz` 时区,默认 `Asia/Shanghai`)轮换 root 生效链——四个冻结的 UTC+8 预设(`liang-peak` / `liang-valley` / `glm-peak` / `glm-valley`,窗口为代码常量、仅模型链可编辑),或自定义 `start`/`end`/`days` 窗口。第一条命中的行生效;全时段行固定最后。时段切换在**下一个** root 请求生效,日志记为**分时切换**——路由种子而非失败决策:不消耗冷却、不计入 `maxSwitchesPerStep`。失败降级保留**降级切换**文案(见 [分时槽预设(分时切换)](#分时槽预设分时切换))。
|
|
132
|
-
- **派发时角色解析**:在 subagent 的首次请求上,其角色按三个阶段解析——显式(`agentPreset` 匹配已声明角色 id)→
|
|
188
|
+
- **派发时角色解析**:在 subagent 的首次请求上,其角色按三个阶段解析——显式(`agentPreset` 匹配已声明角色 id)→ 确定性规则(匹配记录 pair 或实际服务的链头——见 [模型选择器中的 FallbacksChain](#模型选择器中的-fallbackschain))→ LLM 自动匹配(从已声明角色体系中选择,`fallbacks.roleAutoMatch` 默认 `true`)。解析出的角色的链头模型注入首次请求,并以显式 `role → model` 日志行记录(不写 durable `fallbacks/switch` 事件——issue #52 停写);设 `roleAutoMatch: false` 仅关闭 LLM 自动匹配阶段(显式 `agentPreset` 阶段仍生效——无显式角色时即复现原有仅规则行为)。设置卡总是渲染「启用角色自动匹配」开关(默认 `true`)以切换之——即使是从未声明过该键的旧配置,schema 默认值同样生效。
|
|
133
189
|
- **子代理角色徽标**:当 subagent 的派发解析出非 `inherit` 角色(策略开或关)时,其会话在 Web 会话头部的标题旁显示一个紧凑的角色徽标——悬停显示 `role → provider/model`(覆盖/注入后的实际生效路由)。`inherit`/未解析角色的会话不显示徽标;记录仅存于进程内(宿主重启即清空——`role → model` info 日志仍是持久记录)。
|
|
134
190
|
- **上下文窗口感知降级**:`triggerCodes` 接受任意 dsh 失败码,包括 `CONTEXT_WINDOW_EXCEEDED`——请求超出当前模型上下文时,降级到上下文窗口**更大**的候选(装不下的候选被跳过);由于路由本身健康,该切换是请求级的:不冷却、不浪费半开探针(见 [降级触发码](#降级触发码triggercodes))。
|
|
135
191
|
- **冷却与回主**:被切离/失败的模型在冷却期内不再入选;`revertPolicy: cooldown-expiry` 冷却到期后自动回主模型。
|
|
136
192
|
- **宿主子代理模型策略(dsh 0.1.2)**:当宿主 `subagent-model-selection` 策略启用时,其允许列表对每个插件发起的 subagent 路由都是硬约束——显式授权的派发路由保持为链头(跳过角色注入),继承注入的链头与失败切换目标都与生效允许列表求交集,交集为空则跳过注入/切换(warn 日志 + 只读卡片警告;绝不发送允许列表之外的请求)。策略存在但不可读时 fail-closed。策略关闭/缺省时,注入与失败切换的选择与 0.3.5 完全一致。覆盖路径上的 `reasoningEffort` 遵循上游 routeChanged 规则(同路由 → 保留;跨路由 → 除非显式指定否则丢弃)。见 [宿主子代理模型策略](#宿主子代理模型策略dsh-012)。
|
|
137
193
|
- **半开恢复(可选)**:`recovery: half-open` 让恢复以证据驱动——冷却到期后路由进入 **half-open**,以一次记录探针(logged probe)放行,而不是直接恢复首选;连续失败使抑制时长按 **×2** 逐次升级、**1 小时**封顶;观察到完成即闭合回路、完全恢复首选。`revertPolicy: 'never'` 使该机制完全失效;状态为会话级内存态(重启即重置)。仅 YAML 配置——默认 `timer` 保持所有既有行为逐字节一致(见 [docs/configuration.md](docs/configuration.md#recovery-mode-recovery-key))。
|
|
138
|
-
- **行为可见**:每次切换以 info 级日志行(from/to/role/reason)记录——无静默换模型。插件**刻意不写** durable `fallbacks/switch` 会话事件(issue #52——apply() 时的事件类型注册被证伪无效,含该事件的会话在 dsh
|
|
194
|
+
- **行为可见**:每次切换以 info 级日志行(from/to/role/reason)记录——无静默换模型。插件**刻意不写** durable `fallbacks/switch` 会话事件(issue #52——apply() 时的事件类型注册被证伪无效,含该事件的会话在 dsh 重启后拒绝加载)。由旧版插件写入、含此类事件的会话**无法**通过 `ignorable` 标记修复——已发布的 session-format 迁移链(v0→v1)即使事件带 `ignorable` 也拒绝未知事件类型——因此 `pnpm repair:session-logs` 会报告此类日志(只有显式 opt-in 的有损 `--drop-legacy-events` 才会恢复它们——见 [修复已有会话](#修复已有会话))。
|
|
139
195
|
- **安全阀**:`maxSwitchesPerStep` 限制每 step 切换次数、`alwaysModeRetryCap` 限制 always 模式重试——链循环不会放大延迟。
|
|
140
196
|
- **无配置回归(no-op)**:未配置任何链时行为与未安装插件完全一致——`enabled` 默认关闭(见 [最小配置](#最小配置))。
|
|
141
197
|
|
|
@@ -153,33 +209,33 @@ fallbacks:
|
|
|
153
209
|
|
|
154
210
|
## 模型选择器中的 FallbacksChain
|
|
155
211
|
|
|
156
|
-
当 `enabled: true` 时,插件注册一个虚拟 provider **FallbacksChain**,目录中只有一行:**Auto**。web profile 与 dsh-tui 都能看到这一行:两者共享同一个 adapter catalog,无需设置页接线或宿主补丁(它与 `/settings` 的 fallbacks 区块相互独立——区块编辑的是配置,不是选择器目录)。该行只要插件启用就可见——遗留多模型或空的 all-day
|
|
212
|
+
当 `enabled: true` 时,插件注册一个虚拟 provider **FallbacksChain**,目录中只有一行:**Auto**。web profile 与 dsh-tui 都能看到这一行:两者共享同一个 adapter catalog,无需设置页接线或宿主补丁(它与 `/settings` 的 fallbacks 区块相互独立——区块编辑的是配置,不是选择器目录)。该行只要插件启用就可见——遗留多模型或空的 all-day 链**不会**隐藏它(只是委托会拒绝服务)。
|
|
157
213
|
|
|
158
|
-
选择 **FallbacksChain / Auto** = 把配置的链作为 root
|
|
214
|
+
选择 **FallbacksChain / Auto** = 把配置的链作为 root **主模型**:请求保留在虚拟对上,由 adapter 的薄委托在请求时刻派发到生效链的第一个精确 `provider/model`,失败后由降级引擎从该链头照常沿链切换。之所以让选择原样下发(而不是把路由改写成链头),也正是为了不让宿主的模型变更提示被反复触发:落盘的路由与会话选择一致,因此该提示只在**选择真的发生变化**时出现一次,而不再每一步重新注入。选择任何真实目录模型则保持 v0.2.2 的 fallback-only 行为——会话模型为主,链只在它失败后介入。
|
|
159
215
|
|
|
160
216
|
**没有 `rootMode` 开关**——没有配置键、YAML 字段、设置开关或 gateway 标志。模式就是会话的 `{provider, model}` 选择本身:`FallbacksChain` = 链为主模型;任意真实模型 = fallback-only。
|
|
161
217
|
|
|
162
218
|
注意:
|
|
163
219
|
|
|
164
|
-
- **选择器文案**:目录行的 `name`(composer 触发器显示)是动态的——`Auto: DeepSeek
|
|
165
|
-
-
|
|
166
|
-
-
|
|
220
|
+
- **选择器文案**:目录行的 `name`(composer 触发器显示)是动态的——`Auto: DeepSeek Flash[Liang Peak]` / `Auto: DeepSeek Flash[all-day]`(用 catalog 显示名,不是 model id);id 仍是 `Auto`。all-day 尾巴不合规则只显示 `Auto`。重新打开选择器即可刷新。
|
|
221
|
+
- **全来源同一个薄委托**:root 代理与继承了该选择的 subagent 会话由同一个 `stream()` 薄委托服务——subagent 的角色解析与注入语义不变,只有一处刻意的放宽:派发时规则匹配接受**记录 pair 或实际服务的链头**(此前匹配的超集,因此以真实链头为键的规则仍能命中),虚拟行绝不是第二个路由引擎。继承了该选择的 subagent 仍经链头路由。
|
|
222
|
+
- **链尾合规门槛**:委托成功要求 all-day 链**尾巴合规**——最后一项必须是恰好一个官方模型(`deepseek-official/deepseek-flash` 或 `deepseek-official/deepseek-pro`,即设置卡的「默认模型」面板);前面的默认降级链先走。禁用插件后该行隐藏(slot/链编辑不会触发注册抖动)。
|
|
167
223
|
- **过期选择**:行消失(插件禁用)而会话仍选中 `FallbacksChain / Auto` 时,会话继续把它显示为当前模型,但 `routable: false`——从目录选一个真实模型即可继续(宿主原生目录语义)。
|
|
168
|
-
-
|
|
224
|
+
- **能力与重试策略跟随链头**:该行的模型元数据(上下文窗口、模态、推理)镜像当前生效链头,`providerRetryPolicy` 返回**该链头的**策略——因此用户配置的 `llm-deepseek.retryPolicy` 在这条路由上同样生效,不再退回宽松默认。宿主在注册时一次性捕获该策略,因此之后修改策略、或分时槽导致链头 provider 轮换,都只有在插件重新注册后才会反映。重试事件按虚拟 provider 记账——即运行时实际看到的路由。完整语义 → [docs/configuration.md](docs/configuration.md)。
|
|
169
225
|
|
|
170
226
|
## 分时槽预设(分时切换)
|
|
171
227
|
|
|
172
228
|
峰谷无忧在[首页专题](#峰谷无忧)中介绍,本节是完整参考。分时槽行按墙钟窗口轮换**生效 root 链**——适合按峰谷切换模型,且不会把墙钟轮换误认为故障降级。文案严格区分:时段轮换的日志与 UI 用**分时切换**;失败降级保持**降级切换**;会话内「模型已降级」提示只出现在失败路径。
|
|
173
229
|
|
|
174
|
-
- **匹配顺序**:每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 `Asia/Shanghai` / UTC+8)的额外行生效——该行的模型链**取代**全时段链;无行命中则用全时段 `rootChain
|
|
230
|
+
- **匹配顺序**:每个 root 请求时刻,第一条窗口包含当前时刻(按 `fallbacks.tz`,默认 `Asia/Shanghai` / UTC+8)的额外行生效——该行的模型链**取代**全时段链;无行命中则用全时段 `rootChain`。全时段行固定最后且**必选**:最后一项必须是恰好一个官方模型(Flash 或 Pro;前面的降级条目先走)。
|
|
175
231
|
- **预设**(冻结,不可编辑窗口):`liang-peak` = 周一至周五 09:00–12:00 **与** 14:00–18:00;`liang-valley` = 其它所有 UTC+8 时间;`glm-peak` = 周一至周五 14:00–18:00;`glm-valley` = 其余时间。一个预设 id 对应一行;设置卡的选择器不会重复提供已添加的预设。
|
|
176
232
|
- **自定义行**:`start` / `end`(`HH:mm`,可跨午夜)+ 可选 `days`(0=周日…6=周六;缺省/空 = 每天)+ 模型。
|
|
177
233
|
- **下一请求生效**:时段边界跨越绝不打断进行中的 step——新行在下一个 root 请求生效。轮换仅挂载生效:info 日志 + 设置卡/`/fallbacks` 状态行,无 durable 切换事件。
|
|
178
|
-
- **设置卡**:主代理区块下分三块——**分时槽设置**(额外行:添加预设 / 添加自定义 / 删除 / 按钮或**拖拽**排序;预设行只读展示窗口摘要、仅可编辑模型链;自定义行带可编辑名称;**时区选择器**在此区块内,只要存在预设行就**锁定 Asia/Shanghai**——预设窗口是冻结的 UTC+8 常量)、**默认降级链**(all-day 链,可配置的 provider/model 选择器列表)与**默认模型**(官方
|
|
234
|
+
- **设置卡**:主代理区块下分三块——**分时槽设置**(额外行:添加预设 / 添加自定义 / 删除 / 按钮或**拖拽**排序;预设行只读展示窗口摘要、仅可编辑模型链;自定义行带可编辑名称;**时区选择器**在此区块内,只要存在预设行就**锁定 Asia/Shanghai**——预设窗口是冻结的 UTC+8 常量)、**默认降级链**(all-day 链,可配置的 provider/model 选择器列表)与**默认模型**(官方 Flash | Pro 二选一链头)。行可折叠为「名称 + 首个模型」。没有 `timeSlots.enabled` 总开关(添加行即开启),也没有 `rootMode` 控件。
|
|
179
235
|
|
|
180
236
|
## 预设角色(Preset roles)
|
|
181
237
|
|
|
182
|
-
插件内置 **5 个通用子代理角色**,开箱即用——`reviewer` / `scout` / `security-reviewer` / `sonic` / `task`——`apply` 时自动以 seeded `roles.list` 行(`{ id, persona }`)声明:幂等,且绝不覆盖 operator 同名 persona。它们以只读行的形式出现在设置卡中(每行带来源徽标,id 与 persona 均不可在卡内编辑),并出现在 `/fallbacks config` 的角色摘要中,可直接被 `roles.rules` 引用。(`designer` 与 `librarian` 不再内置:早期版本保存的行保留其 persona
|
|
238
|
+
插件内置 **5 个通用子代理角色**,开箱即用——`reviewer` / `scout` / `security-reviewer` / `sonic` / `task`——`apply` 时自动以 seeded `roles.list` 行(`{ id, persona }`)声明:幂等,且绝不覆盖 operator 同名 persona。它们以只读行的形式出现在设置卡中(每行带来源徽标,id 与 persona 均不可在卡内编辑),并出现在 `/fallbacks config` 的角色摘要中,可直接被 `roles.rules` 引用。(`designer` 与 `librarian` 不再内置:早期版本保存的行保留其 persona,现在只有在没有任何生产者仍声明其 id 时才显示 source `user` 并成为普通可编辑行——尽管 operator 并未写过这些行;伴随插件仍在声明的行会继续保持 seeded 只读状态,source 为未命名时的 `external`,或其已注册集合名。)
|
|
183
239
|
|
|
184
240
|
- **开关**:`fallbacks.presets`——`'bundled'`(默认)在 apply 时声明预设角色;`'none'` 关闭自动声明(已物化行保留)。
|
|
185
241
|
- 完整语义(升级行为、冲突处理、`presetRoles` 库复用)→ [docs/configuration.md](docs/configuration.md)。
|
|
@@ -202,7 +258,7 @@ fallbacks:
|
|
|
202
258
|
- CONTEXT_WINDOW_EXCEEDED # 请求装不下时降级
|
|
203
259
|
rootChain:
|
|
204
260
|
- anthropic/claude-3-5-sonnet # 先走
|
|
205
|
-
- deepseek-official/deepseek-
|
|
261
|
+
- deepseek-official/deepseek-flash # 兜底(Flash 或 Pro)
|
|
206
262
|
```
|
|
207
263
|
|
|
208
264
|
**顺序——降级先于压缩。** 只要 `triggerCodes` 含 `CONTEXT_WINDOW_EXCEEDED`,本插件的 `agent/request-error` 监听就会**先于**宿主压缩插件处理该拒绝:首次超限即把会话切到 fallback 模型,上下文完全不会被压缩。若更希望先尝试压缩,就不要把该码列入 `triggerCodes`。
|
|
@@ -228,7 +284,7 @@ dsh 0.1.2 为 subagent 增加了宿主侧的子模型选择:`subagent-model-se
|
|
|
228
284
|
|
|
229
285
|
策略**关闭或缺省**时,注入与失败切换的**选择**与 0.3.5 一致(无允许列表过滤、无授权路由跳过)。
|
|
230
286
|
|
|
231
|
-
**Effort 规则(与策略无关)**:所有覆盖路径(角色注入、失败切换、always
|
|
287
|
+
**Effort 规则(与策略无关)**:所有覆盖路径(角色注入、失败切换、always 上限切换)上的 `reasoningEffort` 遵循上游 0.1.2 的 `routeChanged` 规则——provider+model 路由不变则保留种子 effort;路由变化且未显式指定 effort 则丢弃(显式指定的 effort 永远保留)。陈旧的 effort 绝不会被带入另一个 provider。
|
|
232
288
|
|
|
233
289
|
完整语义 → [docs/configuration.md](docs/configuration.md#host-subagent-model-selection-dsh-012)。
|
|
234
290
|
|