Plugin configuration lives in the fallbacks settings namespace. It can be edited in the dsh settings document (default $DSH_HOME/settings.yaml) or in the web settings GUI via 插件配置 (Plugin Settings) → Fallbacks card — both read and write the same namespace. The card's reads/writes go through the plugin's own gateway channel (/api/fallbacks/get / /api/fallbacks/set / /api/fallbacks/reset) and do not depend on any settings-exposure mechanism of the dsh host; the fallbacks namespace not appearing in the host's describe exposure is by design. The plugin makes zero local modifications to the dsh source tree (pure mount: bundle row insert + client inject + its own gateway), so dsh upgrades never require re-patching.
Since iter-20260813 the configuration follows a two-block model — you only need to remember two blocks:
| Block | In one sentence | Config location |
|---|---|---|
| Block 1 | The root agent's failures follow this one chain only; empty = no fallback | rootChain |
| Block 2 | Declare roles first, then let rules reference them; no match inherits root | roles.list + roles.rules |
Do not mix them up:
'inherit'= the built-in role id (rule target / no-match default; forbidden inroles.list[].id);'inherit-root'= the chain-append policy on a role entity (default; runs the role chain, then appendsrootChain);- the old "role-resolution fallback field" has been removed and is no longer valid configuration (see the migration mapping table below for how to rewrite it).
| Field | Type | Default | Description |
|---|---|---|---|
triggerCodes |
string[] | ['AUTH', 'QUOTA', 'RATE_LIMIT'] |
Failures with these codes enter chain decision. Retryable failures (5xx / RATE_LIMIT etc.) are first retried with backoff by llm-retry and enter the decision the same way once its budget is exhausted — no extra triggerCodes entries are needed for 5xx |
rootChain |
string[] | [] |
主代理 — the all-day chain. Must END with exactly one official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-v4-pro — the card's 默认模型 panel); leading entries (the card's 默认降级链 block) are walked first, then the tail is the last-resort fallback. An empty chain or a chain whose last entry is not an official model (including the retired deepseek-v4-flash and the never-served legacy deepseek-official/deepseek-pro ids) warns at startup, keeps the v0.2.2 fallback-only walk, and refuses the virtual picker delegate, but cannot be saved as-is through the card/gateway. Used whenever no timeSlots row matches |
roles.list |
Array | [] |
Block 2. Declarative role-entity collection (id/persona + optional chain/fallback; entry fields in the table below). The id must match /^[a-z0-9-]{1,32}$/ and be unique within the collection; 'inherit' is a reserved word and must not be used as an id |
roles.rules |
Array | [] |
子代理 — role rules (SUBAGENT-ONLY): match to a role in order by provider, model patterns (omitted fields are unconstrained; first match wins; root requests NEVER match rules and resolve to inherit); role may only reference roles.list[].id or the built-in 'inherit'. The legacy per-rule origin field is accepted for config compatibility and IGNORED at match time |
cooldownMs |
number | 300000 |
Cooldown duration (milliseconds). Switched-away / failed models are not re-selected during the cooldown period |
revertPolicy |
'cooldown-expiry' | 'never' |
'cooldown-expiry' |
Primary-return policy after cooldown expiry: return to the primary model on expiry / keep the fallback model for the session |
recovery |
'timer' | 'half-open' |
'timer' |
Cooldown-expiry recovery mode: 'timer' restores the preferred candidate when the cooldown expires (today's behavior, byte-identical); 'half-open' leaves the route half-open for one logged probe line instead (see Recovery mode). Optional key — unset configs resolve to the default via the schema; YAML-only (no card / TUI control) |
maxSwitchesPerStep |
number | 8 |
Per-step safety valve: the switch-count cap per step; beyond it switching stops and the original error semantics are kept, preventing chain loops from amplifying latency |
alwaysModeRetryCap |
number | 5 |
Always-mode retry cap: providers with retryPolicy.mode === 'always' switch after this many retries within the same request; 0 disables |
presets |
'bundled' | 'none' |
'bundled' |
Preset-role switch: 'bundled' declares the 5 bundled preset roles as seeded roles.list rows on apply; 'none' disables declaration (zero declarations, zero writes from this switch). Optional key — unset configs resolve to the default via the schema. See Preset roles |
roleAutoMatch |
boolean | true |
Dispatch-time LLM role auto-match switch: when true (default), a subagent-origin request whose role is not resolved by an explicit agentPreset or a rule may have the best-fit declared role picked by the LLM (three-stage resolution — see Dispatch-time role resolution and injection); false disables ONLY the LLM auto-match stage — with no explicit role it reproduces the previous rules-only behavior (the explicit agentPreset stage is independent new behavior, not gated by the toggle). Optional key — unset configs resolve to the default via the schema |
timeSlots |
Array | [] |
Extra time-slot rows (see Time slots (分时切换)): the FIRST row whose window contains the current moment wins and its chain replaces the all-day chain; no match → rootChain. Rows are { kind: 'preset' | 'custom', preset?, start?, end?, days?, chain } — preset windows are frozen code constants, custom windows may wrap midnight. There is NO timeSlots.enabled master switch — adding rows IS the opt-in. The gateway rejects malformed rows on save (unknown preset ids, duplicate presets, preset rows carrying windows, non-HH:mm custom bounds, out-of-range days, empty chains) |
tz |
string | 'Asia/Shanghai' |
Config-level timezone for slot matching (standard Intl timezone rules, DST-safe). Not per-slot; no settings-card picker this iteration (YAML only) |
The defaults are defined by
defaultFallbacksConfiginsrc/config.ts; the card shows the default value next to numeric fields (cooldownMs/maxSwitchesPerStep/alwaysModeRetryCap) and the currently effective value for all other fields (which equals the default when unset).
No feature switch (breaking change): there is NO config-level
enabledkey. The plugin intervenes exactly when content is configured — a non-emptyrootChain, at least onetimeSlotsrow, or one declared role — and an empty configuration is a pass-through exactly like an uninstalled plugin (no-op invariant). The Plugins-page row toggle is the master switch (disabling the plugin row stops the plugin entirely). A stored profile still carrying the removedenabled: true/falseline loads cleanly — the key is tolerated, never read, and silently stripped from snapshots and saves (it is never re-persisted; there is no successor key and no migration entry) — but it no longer gates anything. If you relied onenabled: falseto keep the plugin inert, empty the section instead.
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Role id: /^[a-z0-9-]{1,32}$/, unique within the collection; 'inherit' is reserved and forbidden |
persona |
string | Recommended | Personality hint (free text, not validated); schema default is the empty string, absence does not block saving |
chain |
string[] | No (enforced by the settings card on save) | The role's own ordered fallback chain (entry syntax same as rootChain). Required semantics: a role without model config is meaningless — the settings card enforces at least one entry on save (an empty chain blocks the save + inline hint); a hand-written YAML with a missing/empty chain warns at startup (no crash); at runtime a missing chain still falls back to rootChain defensively |
fallback |
'inherit-root' | 'none' |
No (default 'inherit-root') |
Chain-append policy: inherit-root = append rootChain after the role chain; none = the role's own chain only |
prompt / permissions |
string / object | No | Reserved fields (see next section) |
Seeded rows: a companion plugin may also auto-provision role rows through the service seeding API — a seeded role is a plain roles.list row ({ id, persona }, two keys only): seeds never write chain / fallback (a new seeded role keeps an empty chain until you fill it), its persona can be reverted to the currently declared seed default via the service or the TUI /fallbacks config revert-seed <role-id> command (the web settings card offers no revert), and the card shows a source badge (bundled / the declaring set's name, external when unnamed / User). The card presents a seeded row as reference material, not editable config: no id field (the collapse title carries the immutable id, R2) and the persona renders as a read-only single-line brief with an expandable full view (no persona editor, no revert button), while chain and fallback stay editable. See docs/consumer-api.md → Role seeds. The plugin itself also auto-provisions 5 bundled preset roles on apply by default (see Preset roles) — same seed semantics, same source badge.
| Field | Type | Description |
|---|---|---|
origin |
'root' | 'subagent' |
Legacy field (ignored at match time) — rules are subagent-only; kept so pre-feedback configs parse and save unchanged |
provider |
string | Provider constraint; omitted = unconstrained |
model |
string | Model constraint; omitted = unconstrained |
role |
string | Rule target: must reference roles.list[].id or the built-in 'inherit'; an undeclared reference → warning + legacyKeys, the entry does not take effect |
prompt and permissions (allow / deny) on roles.list entries are schema-reserved fields:
- Writing them in YAML does not change this round's fallback behavior — there is no runtime consumer this round;
- The UI does not show them this round — the Fallbacks card does not render these two fields;
- Persona injection has since shipped — but from
persona, not from these two fields: a declared role'spersonais installed on the child at the subagent dispatch seam, independent of routing, so a role with an emptychaingets its persona exactly like a chained one (see README.md → Role persona on the child (chain-independent)).promptandpermissionsthemselves still have no runtime consumer, so the tool filtering they were reserved for (permissions.allow/deny) remains the plannedfallbacks-explicit-role-tool(roadmap item — no plan file yet).
The plugin ships 5 bundled omp-style preset roles — generic subagent roles available out of the box: reviewer / scout / security-reviewer / sonic / task. Each persona is a concise instruction set distilled from the omp bundled agent prompts (packages/coding-agent/src/prompts/agents/, snapshot 2026-08-16) — a distillation, not a verbatim copy of a full prompt. The bundled set previously also carried designer and librarian; those two were removed (see the upgrade note below).
presets value |
Effect |
|---|---|
'bundled' (default) |
On apply, the plugin automatically declares the 5 preset roles through the role-seeds surface: they materialize as plain roles.list rows ({ id, persona }, two keys only) |
'none' |
No declarations, no writes — this apply round makes zero settings writes on account of this switch |
- Idempotent: re-declaring the same preset payload is a no-op — repeated
apply/ HMR / fiber swaps never duplicate rows and never drop an override. - Not gated by anything: the self-declaration fires on apply unconditionally (it was never gated by the removed
enabledswitch, and that switch no longer exists) — even a content-less install materializes the 5 preset rows. The only zero-declaration path ispresets: 'none'. - Upgrade behavior: with the default configuration, the first
applyafter upgrading materializes the 5 rows intoroles.list; each row shows a source badge in the settings card (existing capability — no extra configuration). Settingpresets: 'none'stops further declarations but does not retract already-materialized rows — delete them by hand if you want them gone. Honest limitation: a hand-deleted row is re-materialized by the nextapply(the plugin cannot distinguish "operator deleted" from "never existed"). - Trim upgrade note (honest copy): versions before the trim also declared
designerandlibrarian. After upgrading, rows saved by those earlier versions keep their persona and survive bundled declares untouched, but they are no longer in the bundled producer's slice — the plugin no longer has a live declaration for them. They now show sourceuser(theUserbadge, no seeded-row treatment) only when no live producer still declares the id, even though the operator never wrote those rows. Only then are they ordinary editable config rows again (the label means "no live declaration", not "operator-written"); a companion's live declaration keeps them seeded and read-only with the winning producer's source label (externalwhen unnamed, otherwise its registered set name), including when the mstar merge-preserve pattern copies currently seeded non-own ids verbatim. Delete them by hand if unwanted. - Per-row source: every row carries a provenance label, read-time derived and never persisted — preset rows read back with source
bundled; a row no live declaration covers readsuser. The contract → docs/consumer-api.md → Per-row source. - Same-name operator rows are never overwritten: a row the operator already defined keeps its persona — the declaration is flagged with a loud
logger.warn('persona-source'conflict, seed semantics unchanged) and the row still derivesseeded=true, so the source badge is available. The preset persona is only ever applied to a brand-new row. - The
presetskey is an optional, YAML-only switch — the settings card does not render a control for it this round. Unset configs resolve to the default through the schema; an invalid value (anything other than'bundled'/'none') fails at config resolve, likerevertPolicy.
Chain entries (the values of roles.list[].chain / timeSlots[].chain, ordered; the all-day rootChain is an ordered chain whose LAST entry must be exactly one official model — leading entries are optional walk targets before that last-resort tail):
provider/model— exact switch: switch to the specified model; the model id may itself contain/(e.g. NVIDIA NIMnvidia/minimaxai/minimax-m3or Hugging Faceorg/repo-style names);provider/*— keep the failed model id and switch the provider only; when the target provider lacks this model id the candidate is skipped (fuzzy near-match resolution is out of scope for this iteration).
The chain-key namespace is removed: the three key semantics of the old
chainskey (provider/modelexact,provider/*wildcard, role-name keys) no longer exist — model-specific routing on failure is now approximated byroles.rules(matching to a role by provider/model pattern), and role membership is expressed by declared entities. The entry-sideprovider/*wildcard stays a valid YAML entry everywhere (role chains andtimeSlots[].chain); the settings GUI no longer offers a wildcard checkbox in any chain editor (role and time-slot chains alike) — chains are edited as provider/model lines, a hand-writtenprovider/*entry reads back with a conversion hint and becomes an exact entry once a model is picked, and provider-any matching is expressed through…
Whitespace padding: whitespace padding in a selector (e.g. other/ gpt-4o) is preserved as-is on save (the GUI does not rewrite user input); runtime parsing normalizes it (parseSelector tolerates whitespace), so the semantics are identical to the unpadded form.
Invalid/unknown entries (missing separator, empty segment, wildcard inside a model id, etc.) warn at save validation and block the save (card) or warn at startup (validation function); they never crash and never take effect. * is only valid as the entire model segment (the provider/* wildcard) and is rejected inside a model id. In a running dsh environment (with a model-catalog service) */* never matches — the target provider has no * model catalog, so the existence probe skips that candidate.
Role resolution (PR #62 feedback: rules are SUBAGENT-ONLY — root requests never match rules and always resolve to inherit; ordered matching for subagents, first match wins):
roles.rulesmatches byprovider/modelpattern (omitted fields are unconstrained; the legacy per-ruleoriginfield is ignored) → the target role of the matched rule;- no rule matches (or the agent is root-origin) → the built-in
'inherit'role (no own chain →rootChain).
inherit is a reserved role id: it serves only as a rule target / no-match default and must not be written to roles.list[].id. A matched rule whose target role is not declared in roles.list → defensive fallback to 'inherit' with a warning.
Chain composition (append-not-replace): the actual candidate chain for a matched role is
[...role.chain, ...(role.fallback === 'none' ? [] : rootChain)]
fallback: inherit-root(default): the role's own chain first,rootChainas the trailing fallback;fallback: none: the role's own chain only; an empty own chain withnone→ no-op pass-through;- no rule matched (
inherit) or role undeclared: candidate chain =rootChain.
Root tail is slot-effective (分时切换): for ROOT-origin agents the trailing
rootChainin the composition above is replaced by the effective chain at request time —resolveEffectiveChain(config, now, tz): the first matchingtimeSlotsrow's chain, else the all-dayrootChain(see Time slots). Subagent walks keep the rawrootChain(role-inject unchanged).
Candidate filtering (skipped on hit): same as the current model, in cooldown, already failed this step, or the provider/* entry's target provider lacks this model id.
Roles require model config: a declared role without model config is meaningless — either give the role at least one
chainentry or have rules reference the built-ininheritdirectly. The settings card enforces this on save (an empty chain blocks the save + inline hint); a hand-written YAML with a missing/empty chain triggers alogger.warnat startup (no crash); at runtime a missing chain still falls back torootChaindefensively (the existing "no chain → rootChain" behavior is unchanged).
Runtime landing note: the new role-resolution / chain-composition semantics above are consumed by the runtime (
src/roles.ts/src/chains.ts/src/index.ts, fallbacks-role-runtime Plan 2); old-shape fields (chains/roles.default/ undeclared role references) are flagged for migration at startup viadetectLegacyKeys(see the migration mapping table below), and decision behavior follows the new model.
Dispatch-time resolution runs at a subagent-origin agent's first request only (a per-agent once-marker; a later request never re-evaluates). The role is resolved in three ordered stages (first hit wins):
- Explicit — the subagent's
agentPresetsession-header value, trimmed, matches a declared role id → that role. The reserved'inherit'and any undeclared preset are never dispatchable explicit roles and fall through to rules. - Rules — the same deterministic
roles.rulesmatching as failure-time, subagent-only (a declared role wins; no rule match → the built-in'inherit'). - LLM auto-match — only when stage 2 resolved to
'inherit'androleAutoMatch: true(the default): the model picks the best-fit declared role from the taxonomy (id + persona). The call is bounded — one completion per decision, a smallmaxTokens, a 5s timeout that abandons the stream;none, an unknown/garbage id, a timeout, any throw, an empty taxonomy, or an absentllmservice all resolve to'inherit'and never block the request.
When a specific role resolves (anything but 'inherit'), its chain head — the first exact (non-wildcard) candidate of the role's concatenated chain — is injected into the agent's first request when it differs from the request's current model: the request is overridden to the head (overrideConfig), and an explicit role → model log line is written (no durable fallbacks/switch event is appended — issue #52 stop-write; the role-inject reason survives only in the event vocabulary for legacy events). Injection is idempotent (first request only) and not a failure decision — it writes no pending switch, no cooldown, and no failure bookkeeping (no commit()).
Key semantics:
roleAutoMatch: falsedisables stage 3 (the LLM auto-match) only — the explicit (agentPreset) and rules stages still resolve and inject underfalse. "Reproduces the previous rules-only behavior" applies precisely when there is no explicit role and no rule match; the explicit stage is independent new behavior, not gated by the toggle. The key is toggleable in the settings card: the Fallbacks card always renders an "Enable role auto-match" switch (defaulttrue, read/write via the same draft →setsave path). The schema default applies whenever the key is unset — a legacy config that never declared the key still shows the toggle on, and its first save pinsroleAutoMatch: true(semantically identical to the default).- Judgment-call route: the auto-match LLM call routes via the roles' declared chain selectors first (the first exact
provider/modelacross the declared chains in declaration order), then the agent's own provider/model, else it is skipped (inherit). A session whose active provider is outside the declared taxonomy therefore never auto-matches — it fails safe to'inherit', it never errors. - First-request failure is permanent (anti-hotloop): the dispatch-injection once-marker is set before the resolution try, so a transient first-request failure (e.g. an auto-match timeout) permanently forgoes dispatch injection for that agent — the deliberate anti-hotloop choice; the agent keeps its
'inherit'(no specific role) routing for its lifetime. - Role id
noneedge: a declared role idnonecollides with the auto-matchnonedecline token — such a role can be selected via the explicit/rules stages but can never be picked by auto-match. 'inherit'never injects: the "no specific role" outcome always leaves the request's model untouched.- Root agents are untouched: the dispatch-time path applies to
session.header.origin === 'subagent'only; a root agent's model follows the normal selection/fallback path. - Host policy constrains the inject (dsh 0.1.2): when the host
subagent-model-selectionpolicy is enabled, an explicit authorized spawn route skips inject entirely (the authorized route becomes the chain head), and an injected head must be on the effective allowlist — an empty intersection skips the inject and leaves the host seed. Policy off → the injection above is unchanged from 0.3.5. See Host subagent model selection. role-injectis an additive event reason (vocabulary only): thefallbacks/switchpayload stays a superset of the failure-time shape; for legacy events (the plugin writes no new durable events — issue #52), renderers show a localizedrole-injectlabel and an explicitrole → modelline (conversation badge card / recent-switch lines), with any unknown reason still rendered raw.- Documented degradation (honesty): dispatch injection reuses the same
agent/requestoverride path as the failure-time fallback, so whether it survives a manual web model selection depends on waterfall listener order — the same documented degradation as the failure-time switch (see the model-selection coordination note in the card-usage section and docs/verification.md §4.3).
dsh 0.1.2 adds optional host-side child-model selection for subagents: a subagent-model-selection settings allowlist ({ enabled: boolean; allowedModels: [{ provider, model }] }), a per-session subagent/model-selection-policy event (payload { allowedModels: [{ provider, model }] }, written once by the host before the first model request), and spawn-time provider/model/reasoning_effort routing. The plugin reconciles its role-inject and failure-switch behavior with that policy under one runtime arbiter — plugin roles/chains remain the failure-recovery layer the host does not provide. Nothing here changes root (non-subagent) routing.
Effective policy read (per session, in order):
- the session
subagent/model-selection-policyevent (read structurally, never throws); otherwise - the host
subagent-model-selectionsettings service snapshot whenenabled: true.
Missing service, enabled !== true, and no session event → the policy is off. A present-but-malformed event payload, or enabled settings with an unreadable/empty route list, resolves to unprovable (fail-closed, below).
The allowlist is a hard constraint: no code path writes, overrides, or sends an out-of-allowlist plugin-originated route. Route identity is exact provider + exact model — no aliasing, no case-folding, no catalog-id substitution; the intersection runs on resolved candidates (wildcards already expanded by the existing chain pipeline).
- Authorized head preserved: a subagent carrying an explicit authorized route at its first request — spawn options with explicit provider+model, a durable
request/headerconfig, or amodel/selectionselection for that session — keeps that route as the chain head. Role-inject is skipped (info logauthorized child route … is the chain head — skipping role-inject); the plugin chain applies only from failure time onward. Pure inheritance is not an authorized route: when the child's only route equals the delegating parent's current route, the three-stage dispatch resolution still runs (constrained, next bullet). A route requires BOTH provider and model — an effort alone is not a route. - Allowlist-constrained inject: the injected chain head is plugin-originated, so the resolved three-stage candidates are intersected with the effective allowlist in order — the first in-allowlist candidate is injected (head source label
injected). Empty intersection → inject is skipped, the host seed stands, and a warn line is logged (role-inject candidates are all outside the subagent allowlist — skipping inject (host seed stands)). - Allowlist-constrained failure switching: at failure time the resolved surviving candidates (after the existing cooldown / step-failed / same-as-current / missing-id filters) are intersected with the allowlist in walk order — the first in-allowlist candidate wins. Empty intersection → no switch: the session stays on the current route, no out-of-allowlist request is sent, a warn line is logged (
fallback switch … blocked: resolved candidates are all outside the subagent allowlist — no switch) and a blocked attempt is recorded in-process (warn log + card warning; no durable session write — issue #52 stands). Root-origin walks are untouched. - Fail-closed (unprovable): with the policy on but unreadable, plugin-originated injects and switches are skipped (warn lines
… policy is on but unreadable — skipping role-inject/… — skipping fallback switch); the host seed and all non-switch behavior are untouched. The policy read never throws. - Virtual-route carve-out (explicit
FallbacksChain/Autospawn): a subagent explicitly spawned on the plugin's virtual provider is user-authorized for that route (spec D2 — explicit selection); its requests route through the virtual adapter's thin delegate to the configured effective chain head (see FallbacksChain in the model picker). That internal delegation is the route's documented purpose and is outside D1's selection semantics — D1 governs provider/model selection the plugin makes, not delegation inside an explicitly chosen virtual route — so the delegated head is not intersected with the allowlist. Every plugin-originated selection above (authorized-head detection, inject, failure switch) stays allowlist-constrained.
Inject and failure-switch selection are unchanged from 0.3.5: no allowlist filter, no authorized-route skip — the first exact resolved candidate injects, and the failure walk picks the first surviving resolved candidate. The full vitest suite pins this regression.
reasoningEffort on every overrideConfig path (role-inject, pending-switch apply, always-cap switch) follows the upstream 0.1.2 resolveChildAgentOptions routeChanged rule:
| Case | Result |
|---|---|
| Same provider+model route | seed reasoningEffort preserved |
| Route changed, no explicit effort on the override | seed effort dropped (a stale effort never crosses into a different provider/model) |
| Explicit effort named on the override | explicit effort wins (either route) |
This rule applies whether the host policy is on or off (adoption, not policy-gated).
The Fallbacks card Subagents section (#fallbacks-subagents) gains a read-only host-policy status area fed by the same runtime reader the override paths use (gateway /api/fallbacks/get, additive subagentPolicy field — old readers tolerate its absence; set/reset reject it as a config key):
- enabled: the allowlist routes (each
provider/model), the effective chain head with its source label (authorized/injected) once one is recorded, and — when a switch was blocked — the empty-intersection warning (No in-allowlist fallback candidate — switch skipped). Enabled without a recorded head yet → allowlist shown, head line omitted. - unprovable: a distinct warning line (
Policy present but unreadable — plugin switches disabled), never an active allowlist. - disabled / absent: the status area is hidden — never an active allowlist.
Roles/rules editors stay editable; the status area is never a second write-face. zh is the locale key-set source of truth with en twins (subagents.policy.*).
Whenever the plugin has content configured (PR #62 feedback's registration gate re-keyed from the removed enabled switch to content presence — conformance is NOT part of it), the plugin registers a virtual FallbacksChain provider with one catalog row, Auto, on the host LLM runtime. Web and dsh-tui both see the row because they share the same adapter catalog (session.models / listModels) — no settings-page control or TUI surface is involved.
-
Picker label: the catalog row's
idstaysAuto; itsname(what the host ModelSelect trigger and menu render) is live —Auto: <displayName>[<slot>], e.g.Auto: DeepSeek Flash[Liang Peak]. The display name comes from the head provider's catalog (listModels), not the model id, so the same id on different platforms stays distinguishable. Slot label comes fromresolveSlotState. BareAutowhen the all-day chain is non-conforming. The host reloads the catalog on picker open, so the label refreshes then. -
Select-is-primary is a delegation, not a rewrite: selecting
FallbacksChain/Automeans "use the configured chain as the root primary". A root-origin seed of the virtual pair is served unchanged (planmodel-change-notice-loopTask 1 removed the formeragent/requestrewrite); the virtual adapter'sstream()delegate is what dispatches the first dispatchable exactprovider/modelhead of the effective chain at request time —effectiveHeadOf→resolveEffectiveChain(config, now, tz)fromsrc/time-slots.ts, slot-aware when atimeSlotsrow matches,rootChainotherwise; the resolver is the single source and there is deliberately norootChain[0]fallback branch. Serving the selection unchanged is what keeps the durablerequest/headerequal to the session selection, so the hostmodel-selectionnotice fires on a genuine selection change instead of being re-armed on every step. Every consumer that has to compare a recorded route against the chain anchors the virtual pair at the served head instead (anchorServedRouteinsrc/index.ts;servedRouteForfor rule matching) — left on the virtual pair, the failure walk would treat the head that just failed as a fresh candidate and key cooldown / step bookkeeping on the picker row, the rule-6 half-open success observer could never close the head's circuit (its durableassistant/messagesource records the request route), and a dispatch-time role rule keyed on the real head would stop matching a delegated child whose recorded pair is the virtual row. -
Misconfiguration surfaces at the request, not at
agent/request: the two routing-path warns that shipped with the removed rewrite ("the all-day rootChain is not conforming … no primary override" and "the effective chain has no exact head … no primary override") are gone with it. A non-conforming all-day chain is therefore observed through the failed request: the delegate throwsUNDISPATCHABLE_EFFECTIVE_HEAD(non-conforming all-day, wildcard selector, malformed selector, or a self-route toFallbacksChain/*) orEMPTY_EFFECTIVE_CHAIN(empty chain), and the runtime normalizes that into a terminal error finish. There is no longer a plugin log line at the moment the row refuses to serve. -
Real models stay fallback-only: selecting any real catalog model keeps the v0.2.2 semantics — the session model is primary, the chain engages only after it fails.
-
No
rootMode: there is no config key, YAML field, settings toggle, or gateway flag for this mode; the mode is the current session{provider, model}selection itself. -
Thin delegate on every origin: the row is served by the same
stream()delegate for a root agent and for a subagent session that inherits the selection — with the rewrite gone there is no origin gate on the route itself (the removed rewrite was root-only, so subagent-origin virtual seeds were already served by the delegate). Subagent role resolution and injection keep their semantics: a delegated child records the parent'srequest/headerpair — the virtual row when the parent runs on the virtual route — while the delegate serves it the chain head, and dispatch-time rule matching accepts either pair (the optional served route onresolveRole), so a rule keyed on the real head keeps matching and a rule that names the virtual row keeps matching too. The virtual row stays a thin delegate, never a second routing engine. The walk anchor (anchorServedRoute) has no origin gate either: a subagent-origin virtual route is anchored at the served head too, while a subagent's own walk tail stays the rawrootChain. That asymmetry is an accepted gap — gating the anchor by origin would make the decision'sfromname a route the delegate never served. A second accepted gap sits in the clock: the anchor stamps its ownnew Date()whiledecidere-resolves the effective chain with a fresh one, so a time-slot boundary landing between those two reads can name a different head for the same step. The window is bounded by one decision for thedecide/anchorServedRoutepair, and by roughly one request duration for the rule-6 success observer, which stamps its ownnew Date()when theassistant/messagearrives rather than reusing the head resolved at request time — a slot boundary crossing inside a long request can therefore anchor that observation to a different head than the one served. Both windows self-heal on the next request and neither can be closed from inside the plugin. A third accepted gap sits at the outer ownership pass of a virtual-route session: a durable assistant message whose recorded source names a real provider loses itsreplayStatebefore the virtual adapter'sstream()is entered, because that pass targets the virtual adapter — which does not own the historical provider — so the runtime's ownership check strips the envelope first. It is reachable whenever a session carries real-route envelope history, which is the very scenario this route creates (e.g. switching toFallbacksChain/Autoinside a session that already has pi-ai-backed history), and it is a regression introduced by the change that removed the root rewrite, not a pre-existing condition: the removed rewrite made the root request carry the head pair, so that pass aimed at the head adapter and kept history recorded on that head — only the cross-adapter-instance rotation subset predates it. Evidence strength, stated exactly: the post-change half is executed (the real-route boundary arm intests/virtual-adapter.spec.tsasserts the envelope is withheld); the "the removed rewrite kept it" half is reasoned from the deleted rewrite plus the runtime's instance-ownership rule, not measured. It cannot be closed from inside the plugin — the envelope is already absent from the delegated history — so the seams are upstream: the loop records the served route rather than the request route, or the ownership pass consults the envelope's own provenance (the same shape this route's own re-stamp uses). -
Registration lifecycle: the row registers whenever the plugin has content (content presence — see the field-overview note) — an idempotent transition-reconcile on committed config snapshots, unregistering on deactivation. The condition deliberately ignores
timeSlotsand conformance, so slot-row / chain edits never churn registration. A duplicate registration across fibers degrades to the first fiber owning the route (DUPLICATE_ADAPTERcaught). -
Tail-conformance gate for the delegate: a successful
stream()delegate requires the all-day chain to be tail-conforming — its LAST entry must be exactly one official model (isAllDayConforminginsrc/time-slots.ts; leading 默认降级链 entries are walked first). A legacy multi-model or empty chain keeps the row visible, but the delegate refuses it:UNDISPATCHABLE_EFFECTIVE_HEADfor a non-conforming multi-model chain,EMPTY_EFFECTIVE_CHAINfor an empty one. The routing-path warn that used to accompany the refusal left with the rewrite (see the misconfiguration bullet above). -
Thin delegate: the virtual adapter's
stream()resolves the effective head and dispatches that real pair through the host LLM runtime — no chain walk, cooldown, caps, revert bookkeeping, or state writes live in the route (those stay in theagent/request/agent/request-errorlisteners). A failure inside the delegate surfaces atagent/request-error, where the existing engine walks from there. An empty effective chain, an undispatchable head (non-conforming all-day / wildcard / malformed / self-route), or a vanished runtime throws an explicitLlmError, normalized by the runtime into a terminal error finish — the documented listener-order degradation stays graceful. -
Metadata and retry policy follow the head:
resolveModel('Auto')proxies the current effective head's model info (modalities / context window / reasoning) when resolvable, with a permissive identity default otherwise (never throws).providerRetryPolicy('FallbacksChain')returns the effective head provider's policy resolved through the live runtime (src/virtual-adapter.ts), so a user'sllm-deepseek.retryPolicy— includingmode: 'always'— behaves on the virtual route exactly as it does on the head the delegate dispatches; an unresolvable head, a vanished runtime, or an unregistered head provider degrades toundefined(the host's own default) and never throws. Two consequences, stated one-directionally: the host captures that policy once, insideprepareRoutesatregisterAdaptertime (@deepseek-ai/dsh-llmlib/index.js), so a laterllm-deepseek.retryPolicyedit — or a slot-driven head-provider rotation — is only reflected after the plugin re-registers; and because the capture is a snapshot, a rotated head can leave the captured policy more permissive than the live head's. Before this change the virtual route always answered with the permissive default, so the snapshot is strictly better than that pre-fix state only in the direction measured here — it is not a blanket improvement over the live policy. Retry accounting follows the route the runtime sees, which on the virtual route is the virtual pair:llm/retryevents are keyed byFallbacksChain, and the plugin's own cap counting reads the same key. -
Stale selection after unregister: if the row disappears (plugin disabled) while
FallbacksChain/Autois the session selection, the host keeps showing it as sessioncurrentwithroutable: false(host-native catalog semantics) — select a real model to continue.
Time slots are the wall-clock peak/valley model: each slot row — a frozen UTC+8 preset (Liang Peak / Liang Valley / GLM Peak / GLM Valley) or a custom window — carries its own fallback chain, and the first matching row becomes the effective root chain while the all-day chain stays as the last resort (see the featured overview on README.md).
Time-slot rows rotate the effective root chain by wall-clock windows. At every root request the resolver (resolveEffectiveChain in src/time-slots.ts) walks the stored rows top-to-bottom: the FIRST row whose window contains now (in the config-level tz, default Asia/Shanghai) wins and its chain replaces the all-day chain (never concatenated); no match → the all-day rootChain. Subagent walks and role-inject are unchanged. The all-day row is always last and required.
Copy split (never mix): slot rotation is a 分时切换 / time-slot switch — a routing seed: it applies on the next root request (no mid-step preemption), is exempt from cooldownMs and does not count against maxSwitchesPerStep, and is mount-only (info log + card / /fallbacks status line; no durable fallbacks/switch event). The failure walk keeps 降级切换 / fallback switch and the conversation notice 模型已降级 / Model downgraded stays on the failure path only.
{ kind: 'preset' | 'custom', preset?, start?, end?, days?, name?, chain }
| Field | Type | Rules |
|---|---|---|
kind |
'preset' | 'custom' |
Required. Anything else is rejected on save (the resolver warns and skips at load) |
preset |
string | kind: 'preset' only — one of liang-peak / liang-valley / glm-peak / glm-valley. At most ONE row per preset id (duplicates are rejected on save, first row wins at load). Preset rows must NOT carry start/end/days/name — their windows are frozen code constants and their name is the frozen label |
start / end |
string (HH:mm) |
kind: 'custom' only — strict 24h format. end is EXCLUSIVE; start > end wraps midnight |
days |
number[] | Custom only — 0=Sunday … 6=Saturday; omitted/empty = every day |
name |
string | Custom only — display name (shown in the settings card's collapsed rows and the /fallbacks status line); omitted = custom start-end |
chain |
string[] | Always required and editable (entry syntax below). A matched row's chain is the effective chain |
| preset | Window (not user-editable) |
|---|---|
liang-peak |
09:00–12:00 and 14:00–18:00 (both clocks, Monday–Friday; ONE row) |
liang-valley |
all UTC+8 times that are not Liang Peak (complement of the peak) |
glm-peak |
Monday–Friday 14:00–18:00 |
glm-valley |
all other times (complement of GLM Peak) |
The all-day rootChain is a chain: its LAST entry (the tail — the card's 默认模型 panel) must be exactly one official model — deepseek-official/deepseek-flash XOR deepseek-official/deepseek-v4-pro; leading entries (the card's 默认降级链 block) are optional, ordered walk targets before that last-resort fallback. The card renders the tail as an exclusive 2-choose-1 panel (required, not removable) and the rest as a selector list above it (UI order = walk order), and both the card and the gateway reject any value whose last entry is not an official model on save — an empty chain or a legacy non-official-tail rootChain cannot be saved as-is (no migration wizard; pick one of the two models). At load, a non-conforming tail earns ONE startup warn, slot rows stay inert, the virtual picker row refuses the delegate, and the v0.2.2 fallback-only walk continues over the raw chain.
Retired id: the previous deepseek-official/deepseek-v4-flash tail is no longer legal — the catalog dropped the model (its sibling deepseek-v4-pro is the legal Pro tail again, see below). A saved rootChain whose tail is deepseek-official/deepseek-v4-flash is now non-conforming: startup warns, time-slot rows and the virtual picker row go inert, and the card/gateway refuse to save it until a legal tail (Flash or Pro) is picked.
Pro availability: the 0.2.0-rc.2 official default catalog serves the Pro tail as deepseek-official/deepseek-v4-pro (alongside the default deepseek-official/deepseek-flash), and the settings card offers it as a selectable option — no disabled state, no availability caveat. A hand-written legacy deepseek-official/deepseek-pro tail was never catalog-served nor selectable: it reads back as a non-conforming tail (one startup warn, inert slot rows, unselected in the card) — no migration wizard; re-pick the tail as deepseek-official/deepseek-v4-pro. The plugin does not probe catalog availability: a chain containing deepseek-official/deepseek-v4-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.
The gateway (/api/fallbacks/set) and the card's pre-save validation apply the same guards: all-day must conform; preset ids must be known and unique; preset rows carry no windows; custom rows need strict HH:mm bounds and 0–6 integer days; every row needs a non-empty chain. Malformed rows are rejected — they are never persisted.
The following configuration demonstrates the full two-block shape — a root chain, role entities (including their fallback policy), and rules referencing declared roles / the built-in inherit (write it into $DSH_HOME/settings.yaml):
fallbacks:
triggerCodes:
- AUTH
- QUOTA
- RATE_LIMIT
rootChain: # All-day chain: LAST entry is 默认模型 (official model)
- anthropic/claude-3-5-sonnet # leading entries = 默认降级链 (walked first)
- deepseek-official/deepseek-flash # last = Flash / Pro last-resort fallback
timeSlots: # Optional: rotate the effective root chain by wall-clock windows
- kind: preset # Frozen UTC+8 windows; only the chain is editable (locks tz to Asia/Shanghai)
preset: liang-peak # Monday–Friday 09:00–12:00 AND 14:00–18:00
chain:
- anthropic/claude-3-5-sonnet
- kind: custom # Custom window: HH:mm, may wrap midnight
name: 晚班 # Optional display name (custom rows)
start: '22:00'
end: '02:00'
days: [1, 5] # Optional; omitted/empty = every day (0=Sunday…6=Saturday)
chain:
- openai/gpt-4o
tz: Asia/Shanghai # Config-level slot timezone (default Asia/Shanghai; preset rows lock it)
roles: # Block 2: declare roles first, then let rules reference them
list:
- id: reviewer # Role entity: unique id matching /^[a-z0-9-]{1,32}$/; 'inherit' is reserved
persona: Code review subagent # Personality hint (free text)
chain: # The role's own chain
- openai/gpt-4o-mini
fallback: inherit-root # Default: append rootChain after the role's own chain
- id: cheap
persona: Cost first
chain:
- deepseek/deepseek-chat
fallback: none # Role's own chain only; no rootChain appended
rules: # Subagent-only: match provider/model in order, first hit wins; specific rules before broad ones
- provider: deepseek # Most specific first: exact provider/model → explicitly targets the built-in inherit (root chain)
model: deepseek-reasoner
role: inherit
- role: reviewer # All subagents → reviewer role
- provider: deepseek # Broad rules last: other deepseek providers' agents → cheap role
role: cheap
cooldownMs: 300000
revertPolicy: cooldown-expiry
recovery: timer # Optional: 'timer' (default) | 'half-open' — evidence-driven recovery
maxSwitchesPerStep: 8
alwaysModeRetryCap: 5Key points:
- There is no
enabledkey to set (it was removed): the example's content — a non-emptyrootChain— is what activates the plugin; an empty section is the no-op pass-through. - The first chain entry is the first fallback target after the primary model; entries in the chain are ordered by priority.
- Declaring a role without any rule = that role is never hit (a no-match goes to
inherit→rootChain); to have a role hit you must also write aroles.rulesentry referencing it. role: inheritis a valid rule target: it explicitly points a class of requests at the built-in inherit (the root chain).timeSlotsrows rotate the effective root chain by wall-clock windows: first matching row wins (its chain replaces the all-day chain), no match → the all-dayrootChain; the all-day row is always last and required (exactly one official model — the example above shows one of the two).- Switching only changes the provider/model routing of subsequent requests; it does not reset session context or tool state.
- Each chain-target model needs its own credentials and quota configured (costs/quotas can differ between providers).
Legacy-format (iter-20260812 and earlier) configuration is not migrated automatically: once detected, the plugin flags it through three channels (see the next section) and the user rewrites it manually per the table below.
| Old (iter-20260812 and earlier) | New |
|---|---|
chains: { default: [...] } |
rootChain: [...] |
chains: { reviewer: [...] } |
roles.list: [{ id: reviewer, chain: [...] }] (also write a roles.rules entry for the role to be hit; declaring without referencing = never hit, no-match goes to inherit) |
chains: { deepseek/*: [...] } |
roles.rules: [{ provider: deepseek, role: <declared id> }] (requires a corresponding roles.list entry first; move the old chain entries into that roles.list[].chain; or delete the key) |
chains: { deepseek/deepseek-chat: [...] } |
roles.rules: [{ provider: deepseek, model: deepseek-chat, role: <declared id> }] (move the old chain entries into the corresponding roles.list[].chain) |
roles.rules[].role any string |
Reference roles.list[].id or the built-in 'inherit' (enum); an undeclared reference → legacyKeys + warning, the entry does not take effect |
roles.default: 'default' (or any string) |
Delete this field; no rule match → the built-in 'inherit' (→ rootChain). Rewrite "all subagents default to some chain" as one { role: <id> } entry (rules are subagent-only; a legacy origin field is ignored) |
| Role chain without a fallback | fallback: inherit-root (default) → [...role.chain, ...rootChain]; fallback: none → role.chain only |
(no old counterpart) prompt / permissions |
schema reserved; no UI and no runtime consumption this round; writing them in YAML does not change this round's fallback behavior |
roles.list[].label |
Delete this field — the role id serves as the name |
roles.list[].description |
Rename to roles.list[].persona (personality hint); the old key stays inert (flagged via legacyKeys + warning) until removed |
(no old counterpart) role id = inherit |
forbidden in roles.list; inherit serves only as a rule target / no-match default |
After an upgrade, legacy-format configuration is flagged through three channels — nothing is silently dropped and no file is rewritten automatically:
- UI banner (live this round): the Fallbacks card renders a migration banner at the top of its body (when the
get/set/resetresponse carries a non-emptylegacyKeys) — "Legacy config fields detected (...): now shown in the new model — rewrite them manually following the migration table in docs/configuration.md (the plugin will not rewrite them automatically)." It does not block editing or touch disk; saving does not delete the old-format keys (setis merge-semantics, so oldchains/roles.defaultstay in the user layer) — clean them up by editing YAML manually (the card's Reset-to-defaults button was removed in PR #62 UX round 3; the gatewayresetRPC stays as a host API for callers outside the card). - Startup warn (shipped): on plugin startup / config read, detected legacy fields are reported via
logger.warn—apply()detects them throughdetectLegacyKeys, and thelegacyKeyspipeline reports synchronously; the three channels are closed. - This document's migration table: the "Migration mapping table" section above is the reference for manual rewriting.
- Entry: web settings GUI → Settings → Plugin Settings page → Fallbacks card (rc.7 keyed slot: key
fallbacks, the settings namespace the card edits, rendered after the bash / agent-loop / web-search / advisor cards in registration order; the card replaces the old standalone Settings navigation page). - Flat always-open card (the official Plugins-page form language): the card renders its whole form unconditionally in any state — first open, loading, error — with no collapsible chrome (no header button, no chevron, no unsaved pill) and no feature switch (the config-level
enabledkey is removed; the Plugins-page row toggle is the master switch). The 主代理 / 子代理 / 高级选项 headings are flat, non-collapsible section markers. The config comes from the gateway channelget(presentwhen it succeeds); whengetfails / the channel is unreachable, the always-visible informational notice renders flat atop the form and the card stays the usable last-known skeleton (failures are reported truthfully, see below). - Legacy banner: a non-empty
legacyKeysin thegetresponse → a migration banner (zh/en) renders at the top of the card body, pointing at this document's migration table; it does not block editing or touch disk. - Readable labels: enumerable config items show readable labels instead of raw enum values —
RATE_LIMIT→ "Rate limit (429)",QUOTA→ "Quota exceeded",AUTH→ "Auth / permission failure";cooldown-expiry→ "Return to the primary model",never→ "Keep the fallback model (until session end)";inherit-root→ "Inherit root (append rootChain after the role chain)",none→ "Role chain only (no rootChain)". Numeric fields show the default value beside them; other fields show the currently effective value (the default when unset). The Advanced options (高级选项) group (triggerCodes/revertPolicy/cooldownMs/maxSwitchesPerStep/alwaysModeRetryCap+ theroleAutoMatchtoggle) is a flat, non-collapsible section — no disclosure button; its fields render unconditionally, also in a read-only view (!writable), where the whole form renders inert (fieldset disabled). - 主代理 section (PR #62 feedback round): the section groups three blocks. 分时槽设置 — the extra-row list: "Add preset" (four frozen ids in a picker; an already-added preset is not offered again) / "Add custom time slot" buttons; per-row remove + move-up/down + drag-reorder (HTML5 drag on the row card); rows are collapsible to name + first model. A preset row shows its frozen name (梁文峰 / 梁文谷 / GLM峰 / GLM谷) + a read-only window summary and edits the model chain only (no start/end/days controls — windows are code constants; GLM rows carry a "仅配置了 zai-coding-cn 时有效" caveat); a custom row edits an optional display name, start/end (
HH:mm) + optional weekday toggles (none checked = every day) + the model chain. The timezone picker lives inside this block and is locked to Asia/Shanghai while any preset row exists (preset windows are frozen UTC+8 constants; custom rows follow the selection). There is notimeSlots.enabledmaster switch (adding a row is the opt-in) and norootModecontrol (picker selection is the mode). 默认降级链 — the all-day chain as a configurable provider/model selector list (add/remove; the preemption hints are removed). 默认模型 — the official Flash | Pro exclusive 2-choose-1 panel (the chain's required head): a legacy/empty head reads back with no selection + a "pick one of the two official models" notice; saving is blocked until one is picked. Trailing 默认降级链 entries composerootChain = [默认模型, ...默认降级链]. - roles.list area (子代理): one entity card per role — collapsible to id + first chain model (or
inherit-rootwhen the chain is empty under the inherit-root strategy); expanded shows id (text, format-validated:/^[a-z0-9-]{1,32}$/, unique, theinheritreserved word is invalid), persona (personality hint, multiline text, recommended, on its own line below the id), chain selector rows (appendable), fallback dropdown (inherit-root/none), and a delete button; an "Add role" button.prompt/permissionsare not rendered this round. - roles.rules area: no origin control — rules are subagent-only (PR #62 feedback); per-row editing of provider (catalog dropdown/any) + model (cascade dropdown/any) + role (dropdown:
inherit+ declared role ids, linked within the page — role add/remove reflects immediately); an "Add rule" button. Empty fields do not participate in matching; a persisted legacyoriginon the wire is ignored. - Pre-save validation (blocks save): id format/uniqueness/reserved word, rule role references, invalid selectors, empty role chain (no model config), non-official all-day head (默认模型 must be exactly one official model), and malformed time-slot rows (empty chain, non-
HH:mmcustom window, invalid kind/preset) → inline annotation (red border/hint) + error banner; a failed validation blockssave()— clicking save writes nothing and shows the error; only a passing validation writes the user layer via the gatewayset(the gateway applies the same guards). - model-selection coordination (AC-2, documented degradation): with an active model-selection (the user picked a provider/model in the settings page or
settings.yaml), a switch after a trigger-code failure is still decided and recorded in the info log (no durablefallbacks/switchevent — issue #52 stop-write; cooldown unchanged; the step's actual routing may be overridden by the active selection, with the final provider/model following the re-applied selection) — this is host-native behavior after removing the local patch-marker coordination (T2 conclusion). request-error-triggered chains are unaffected; without an active selection the request routes to the chain target. This degradation is documented in docs/verification.md §4.7 (the card no longer carries the former one-linestatus.selectionNote, which was trimmed with the status block). Selecting the virtualFallbacksChain/Autorow is a separate case, not part of this degradation: that route is served and recorded as the virtual pair (see FallbacksChain in the model picker), so the durablerequest/headerequals the session selection and the host's model-change notice fires on a genuine selection change instead of being re-armed on every step. - Reset to defaults (removed from the card, PR #62 UX round 3): the card no longer offers a Reset button. The gateway RPC
fallbacks/reset(clears the user layer; the composition defaults take effect — an empty content config, i.e. the no-op pass-through) and the storeresetToDefaults()remain as host APIs with their own store/gateway tests — only the card affordance was removed. - ONE footer save (replaces the per-section saves): the card has exactly one Save/Discard footer pair. Save is disabled =
!dirty || invalid || saving || !writableand writes the whole validated draft — everything saves together or nothing does; the former per-section split (PR #62 UX round 3) existed so one section's Save could not ride along another section's unsaved edits, which is moot with ONE save over ONE document. Whole-form validation (validateDraft) gates the save, with each violation rendering under its owning section heading (主代理 / 子代理 / 高级选项). Discard is a pure client-side revert of the WHOLE draft to the last accepted config (disabled =!dirty || saving; kept — this card's staged edits survive refresh, the documented divergence from the official no-discard form). After a save, only clean sections re-seed from the accepted config; unsaved staged edits in dirty sections survive (the per-section reseed contract). - Saving and error presentation: saving writes the user layer via the gateway
set(merge semantics) with no revision guard — on concurrent/write failure an error banner truthfully presents the save result, and the skeleton and draft are kept (no silent overwrite). - Read-only status block: trimmed (compass AC-2) to the recent-switch line only — the most recent
fallbacks/switchfrom the current session's raw event surface, newest first, rendered asfrom → to (role · reason)and read naturally for dispatch-timerole-injectentries (role → to (role-inject)). The plugin writes no new durable events (issue #52 stop-write), so the line reflects only events already in the session history (e.g. legacyfallbacks/switchevents — the repair script cannot make such sessions loadable, see the README Features note); new switches are not visible here — not in-process and not after a restart (they are recorded in the info logs instead). The former "current effective model" line (D-6 — the derivation stays a tested store export, seederiveEffectiveModel) and theselectionNotedegradation line were removed from the card (the selectionNote degradation is re-homed to docs/verification.md §4.7; the D-6 effective-model line removal is documented there at §4.3 step 4). Empty / loading / error states still render compactly. The line refreshes via push (no polling) onsettings/document-updated(fallbacks namespace) /llm/adapters-updated(catalog only) / session switch / connection reset — with no durable events written, a switch occurring while the page is open never appears, with or without a page reload or host restart. The status block is read-only and not editable. The same in-session diagnostics are available via the/fallbackscommand (see README; it shows the role's own chain entries, annotated(inherit-root)whenrootChainis the fallback, without renderingrootChainentries one by one;rootChainentries render in full only when the role has no own chain — matching the runtime composition order). Legacy note: for users with only old-formatchainsconfigured (not migrated, norootChain), the recent-switch surface stays empty (no switches are produced under the old shape) and the migration signal is the startup warn plus the card-top migration banner (see "Three-channel legacy notice"); the runtime no longer reads the oldchainskey (decisions work only on the new shape; old-only fields behave as a no-op pass-through).
In a terminal (dsh-tui) profile the plugin's write surface is the /settings screen: with dsh-tui ≥ v0.8.5 (commit c51661f or later on main; the tuiSettingsSections seam shipped in v0.8.0, the groups shape + validation in v0.8.5; reference dsh-TUI main 2747b87) the plugin registers a fallbacks section with full parity to the web Settings → 插件配置 → Fallbacks card — booleans/numbers/selects use native field kinds, complex structures (rootChain, timeSlots, roles.list, roles.rules) are JSON text fields, triggerCodes is a comma-separated text field, and tz a plain text field. Complex-field parsing mirrors the gateway validation (invalid JSON / non-conforming chain / malformed time-slot row → draft invalid → save blocked; a blank draft stages a clear that re-inherits the composition layer). On an older dsh-tui the section is absent and file editing remains the only TUI surface. Configuration also lives in the same files as everywhere else and is read back through the command:
- Where config lives: the shared
$DSH_HOME/settings.yaml(fallbacks:section — the same file the web card writes) for global settings; the profile patch layer~/.dsh/profiles/dsh-tui/cordis.patch.yml(plugin-rowconfig:overrides) for dsh-tui-specific values. The namespace itself is not TUI-specific — the composed config the TUI reads is the same composed config the runtime uses. - Readback:
/fallbacks configprints the composedfallbacksnamespace — first lineFallbacks 配置: 已启用/未启用(the command renders in its default localezh, like the rest of/fallbacks; the en dictionary mirrors the same line asFallbacks config: enabled/disabled), then trigger codes, root chain, time slots (N — {preset} (chain: n, window …)/custom {start}-{end} (chain: n), long lists truncated with…), timezone, roles (N — id (chain: n)per role, long lists truncated with…), role rules (N — provider/model → role,*for wildcard), cooldown, revert policy, max switches/step, always-mode cap, presets, role auto-match (enabled/disabled), and edit hints pointing at/settings(file editing still documented). Bare/fallbacksstays the session diagnostic (origin / role / chain / current time-slot winner — 分时 side — / recent fallback switches — 降级 side — / cooldown) — the two surfaces are distinct from their first line. - Action command:
/fallbacks config revert-seed <role-id>restores a seeded role's persona to its declared seed default and prints the outcome — the web settings card presents seeded personas read-only (no revert affordance), so the command is this action's only surface. - Menu + completion:
/fallbacks,/fallbacks configand theconfig→revert-seedleaf appear in the TUI/menu with subcommand completion when the profile carries thetuiCommandTreesservice (thedsh-tui-command-treesbundle row; the shipped dsh-tui bundle has it).
Installation for the dsh-tui profile → docs/install.md §5.
Chain decision is entered when the plugin has content configured (content presence — see the field-overview note), a matching candidate chain exists, and the failure code ∈ triggerCodes (default AUTH/QUOTA/RATE_LIMIT):
AUTH/QUOTAare non-retryable codes and reach this plugin directly without backoff;- retryable codes such as
RATE_LIMITand 5xx are first retried with backoff by llm-retry and are delegated to this plugin only when its budget is exhausted; - failures that do not hit
triggerCodes(including non-triggerCode failures under always mode) always pass through, taking the llm-retry or original-error path.
A candidate hit → record a pending switch + push the current model into cooldown + bookkeeping (no durable fallbacks/switch event is written — issue #52 stop-write; the switch is recorded in the info log) → return a retry → the next request builds on the target model, and the current step/turn continues to completion without interrupting the task.
A switched-away / failed model is not re-selected within cooldownMs (cooldown and "already failed this step" double suppression); with cooldown-expiry the model can be re-selected after the cooldown expires (return to primary); never does not return within the session (infinite cooldown).
recovery selects how an expired cooldown brings the route back — 'timer' (default) or 'half-open':
'timer'(default) — today's behavior, byte-identical: when the cooldown expires the model is re-selected as the preferred candidate (return to primary, subject torevertPolicy).'half-open'— evidence-driven recovery: when the cooldown expires the route is not restored as the preferred candidate. It goes half-open: while the episode is unresolved, every real user request routed to it is admitted normally — exactly as under'timer', there is no admission limit. The episode's one logged probe is a single info log line (llm-fallbacks: agent "…" half-open probe …; theprobeLoggedmarker) emitted for the first admitted request; later admissions route silently. The probe is a normal user request through the existing chain — no synthetic health checks, no background traffic. An observed completion on that route closes the circuit — the entry is cleared and the preference is fully restored (restoration is evidence-backed, not timer-backed). This holds whether or not a decision read has already marked the route half-open: a completion observed after the cooldown lapsed closes the circuit directly (a manual re-selection of the route, or a route no failure walk ever consulted because the current route kept succeeding), while a completion observed while the cooldown is still active is ignored — a stale in-flight success never cancels a fresher suppression. A cancelled (interrupted) completion is neutral: it neither closes nor fails the circuit, so the route stays half-open for the next probe. A probe failure re-suppresses the route with an escalated duration and the request falls over per the usual switch-away path, consuming the normalmaxSwitchesPerStepbudget.
Escalation: consecutive failures multiply the suppression duration by 2 per failure, capped at 1 hour — cooldownMs → 2× → 4× → … → 1 h (the default 300000 escalates 5 m → 10 m → 20 m → 40 m → 1 h → 1 h …). The first failure of an episode is the flat cooldownMs; escalation changes the suppression duration only, never the per-step switch count (maxSwitchesPerStep is untouched). If cooldownMs is at or above the 1-hour cap, escalation is inert (every suppression is already the cap) — validateFallbacksConfig warns about this at startup.
Interaction with revertPolicy: revertPolicy: 'never' makes the half-open mechanism entirely inert — the infinite cooldown never expires, so no probe is admitted and nothing escalates.
Session-scoped state: half-open flags and escalation counters are in-memory and session-scoped (the same lifetime as the cooldown store) — a restart resets every route to a flat first cooldown.
YAML-only discovery: recovery is a YAML-only key — the settings card and the TUI /settings section render no control for it, and /fallbacks config prints no recovery line (default-mode output is byte-identical). Machine readback flows only through the gateway get passthrough. Set it in $DSH_HOME/settings.yaml (or the profile patch layer). While a route is half-open, /fallbacks shows a marker row {key} half-open (awaiting recovery probe) instead of a suppression time.
- Safety valve: per step the failed-model set and switch count are recorded; beyond
maxSwitchesPerStepno more decisions are made and the step ends with the original error semantics (the original error code and message are preserved verbatim); the counters reset when the step advances. - always-mode cap: for providers with
retryPolicy.mode === 'always', persistedllm/retryevents are counted per turn/step/provider at the request-building boundary; at ≥alwaysModeRetryCap(0 disables) a switch is triggered (reason: always-cap). llm-retry's always mode delegates downstream before backing off; before the cap this plugin never preempts (see spec ADR-2).
With no configured content (no rootChain, no timeSlots rows, no declared roles — the removed enabled: false re-keyed to content absence) / no role chain hit / no triggerCodes hit / role-resolution failure / chain exhausted / safety-valve cap exceeded: the plugin always passes through, the request and session event stream are identical to an uninstalled plugin, and no fallbacks/switch events are produced.
This plugin does not modify llm-retry's or providers' retryPolicy: fallback only intervenes after llm-retry delegates/exhausts its budget (guaranteed by bundle layer order, see docs/install.md); llm/retry events are used only for always-mode cap counting. On plugin unload (HMR/dispose) the listeners unload with the fiber and all per-agent state is cleared entirely — no residual state.
Agent presets are a dsh mechanism for composing plugins — a declarative child-plugin entry list applied as a set, carrying no model fields of their own. The fallbacks plugin needs no preset-specific configuration: a preset that includes the dsh-llm-fallbacks plugin row gives that preset's sessions the same chains/roles configured for the plugin — the fallback applies wherever the plugin's row is mounted. Two caveats:
- A user preset override replaces the entire child list (no merge): a preset that omits the fallbacks row silently unmounts the plugin for sessions using that preset. Restate every plugin row you want to keep.
- Preset-scoped chain configs are possible by mounting the row inside a preset: the plugin row's
config:overrides in the preset composition produce a chain configuration that applies to that preset's sessions only (patch rows replace the wholeconfig, so restate every field).
The plugin's own settings-namespace chains stay the sanctioned pattern for model routing — dsh's own subagent-model-selection policy works the same way (a settings namespace consumed by a tool package), and presets own no model config.
Since dsh 0.2.0-rc.2, account-provider credentials expire on an inference 401 and rejected tokens sign the session out (host-side account work). An AUTH-triggered fallback away from a deepseek-account route therefore now coincides with the host's own credential-expiry/sign-out handling — the plugin's switch-away-and-cooldown semantics are unchanged and aligned with it: the failure walk moves the session to the next candidate (or the primary after cooldown), while the host refreshes or re-prompts for credentials on its side. No plugin configuration is needed; listing or omitting AUTH in triggerCodes behaves as documented above.
