dsh-llm-fallbacks 0.6.13 → 0.8.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/dist/gateway.d.ts CHANGED
@@ -26,9 +26,9 @@
26
26
  * Data: `get` reads the `FallbacksSettingsBridge` source — the same live
27
27
  * composed config the runtime reads (schema defaults → plugin-row base →
28
28
  * profile patch). There is NO hard-gate resolver (unlike advisor's
29
- * `resolveAdvisorConfig`): the fallbacks decision path runs at
30
- * `agent/request` time in `src/index.ts`, so the gateway returns the raw
31
- * composed config. There is NO config-level `enabled` key on the contract
29
+ * `resolveAdvisorConfig`): the fallbacks decision path runs inside the
30
+ * virtual route's chain walk (`src/chain-walk.ts`), so the gateway returns
31
+ * the raw composed config. There is NO config-level `enabled` key on the contract
32
32
  * (plan fallbacks-web-ux-alignment T2): the Plugins-page row toggle is the
33
33
  * master switch, the runtime gates read content presence
34
34
  * (`isFallbackActive` in `src/config.ts`), and a stored `enabled:` line is
@@ -66,7 +66,7 @@
66
66
  * @module dsh-llm-fallbacks/gateway
67
67
  */
68
68
  import type { Context } from '@deepseek-ai/cordis';
69
- import { type SettingsNamespace } from '@deepseek-ai/dsh-settings';
69
+ import SettingsForms, { type SettingsNamespace } from '@deepseek-ai/dsh-settings';
70
70
  import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
71
71
  import type { TypertContribution } from '@deepseek-ai/dsh-typert-registry';
72
72
  import type { FallbacksConfig } from './config';
@@ -91,61 +91,6 @@ export declare const FALLBACKS_SETTINGS_NAMESPACE: SettingsNamespace;
91
91
  * reference `apply()` reads — no separate registration step.
92
92
  */
93
93
  export declare const FALLBACKS_PROFILE_ENTRY = "llm-fallbacks";
94
- /** One exact child LLM route on the additive `subagentPolicy` wire field (T5). */
95
- export type SubagentPolicyRoute = {
96
- provider: string;
97
- model: string;
98
- };
99
- /**
100
- * Additive `/api/fallbacks/get` host-policy snapshot (spec D4 / T5).
101
- * Omitted only when the snapshot is unwired (3-arg constructor) so older
102
- * clients and gateway unit tests keep a byte-identical `{ config,
103
- * legacyKeys, seeds }` payload. A wired snapshot always emits the field —
104
- * `{ state: 'disabled' }` included — so a write after an enabled get cannot
105
- * keep-last an allowlist. Never written through `set`/`reset`.
106
- */
107
- export type SubagentPolicyWire = {
108
- state: 'enabled';
109
- allowedModels: SubagentPolicyRoute[];
110
- head?: {
111
- route: SubagentPolicyRoute;
112
- source: 'authorized' | 'injected';
113
- };
114
- blockedAttempt?: {
115
- at: number;
116
- route: SubagentPolicyRoute;
117
- reason: string;
118
- };
119
- } | {
120
- state: 'disabled';
121
- } | {
122
- state: 'unprovable';
123
- };
124
- /**
125
- * Live host-policy snapshot the gateway projects onto the wire. Built in
126
- * `apply()` from the SAME T1 reader the override paths use — no second
127
- * policy source. Optional: the 3-arg constructor (gateway unit tests)
128
- * omits the field entirely.
129
- */
130
- export type SubagentPolicySnapshotFn = () => {
131
- policy: {
132
- state: 'disabled';
133
- } | {
134
- state: 'enabled';
135
- allowedModels: readonly SubagentPolicyRoute[];
136
- } | {
137
- state: 'unprovable';
138
- };
139
- head?: {
140
- route: SubagentPolicyRoute;
141
- source: 'authorized' | 'injected';
142
- };
143
- blockedAttempt?: {
144
- at: number;
145
- route: SubagentPolicyRoute;
146
- reason: string;
147
- };
148
- };
149
94
  /**
150
95
  * The live configuration source for the gateway (guide §7 — the same bridge
151
96
  * shape the runtime reads through). `source()` returns the live composed
@@ -155,7 +100,15 @@ export type SubagentPolicySnapshotFn = () => {
155
100
  * `onChange` fan-out was removed in the QC fix wave — nothing subscribed).
156
101
  */
157
102
  export interface FallbacksSettingsBridge {
103
+ /** The EFFECTIVE config the runtime reads (v1 already upgraded in memory). */
158
104
  source(): FallbacksConfig;
105
+ /**
106
+ * The STORED view, before the in-memory upgrade. Legacy detection reads it,
107
+ * so the migration banner keeps reporting keys the effective config no
108
+ * longer carries (plan fallbacks-v1-auto-migration). Optional: a caller
109
+ * without one is assumed to hold the raw shape itself.
110
+ */
111
+ rawSource?(): FallbacksConfig;
159
112
  }
160
113
  /** Patch shape accepted by `fallbacks.set` — any subset of the config keys. */
161
114
  export type FallbacksConfigPatch = Partial<FallbacksConfig>;
@@ -168,13 +121,6 @@ export interface FallbacksReadResult {
168
121
  config: FallbacksConfig;
169
122
  legacyKeys: string[];
170
123
  seeds: SeedsWireStatus[];
171
- /**
172
- * Host subagent-model-selection policy (spec D4). Additive — old readers
173
- * ignore it; omitted only when no snapshot is wired. A wired snapshot
174
- * always carries the field (`disabled` included) so keep-last cannot
175
- * retain an allowlist after policy-off.
176
- */
177
- subagentPolicy?: SubagentPolicyWire;
178
124
  }
179
125
  /** The `fallbacks/revert-seed` response — a read result plus the revert outcome. */
180
126
  export interface FallbacksRevertResult extends FallbacksReadResult {
@@ -198,12 +144,6 @@ export declare class FallbacksConfigGateway extends TypertRemoteService {
198
144
  private readonly seeds;
199
145
  /** The live settings service once the optional inject child activates. */
200
146
  private settings;
201
- /**
202
- * Optional host-policy snapshot (T5). When omitted the additive
203
- * `subagentPolicy` field is absent on every read — the 3-arg constructor
204
- * used by gateway unit tests stays byte-identical to the pre-T5 payload.
205
- */
206
- private readonly subagentPolicy;
207
147
  /**
208
148
  * @param ctx - owning context (the plugin fiber's ctx inside `apply`).
209
149
  * @param bridge - the same `FallbacksSettingsBridge` the runtime reads, so
@@ -211,22 +151,21 @@ export declare class FallbacksConfigGateway extends TypertRemoteService {
211
151
  * @param seeds - the per-apply `FallbacksSeedManager` constructed in
212
152
  * `apply()` — the gateway delegates badge state and revert to it (no
213
153
  * copied manager logic), through the io seam built over this bridge.
214
- * @param subagentPolicy - live host-policy snapshot from `apply()`; omit
215
- * to leave the additive field off the wire.
216
154
  */
217
- constructor(ctx: Context, bridge: FallbacksSettingsBridge, seeds: FallbacksSeedManager, subagentPolicy?: SubagentPolicySnapshotFn);
155
+ constructor(ctx: Context, bridge: FallbacksSettingsBridge, seeds: FallbacksSeedManager);
218
156
  /**
219
157
  * Read the current composed config (schema defaults → entry base → profile
220
158
  * patch). No hard-gate resolver (ADR-2): the raw composed config is
221
159
  * the wire value. The removed `enabled` key never crosses the wire (the
222
160
  * read whitelist drops it — the snapshot-strip half of LEGACY_KEYS).
223
- * @returns the wire-normalized composed config plus `legacyKeys` — legacy
224
- * two-block-era fields (`chains` / `roles.default` / undeclared rule
225
- * role refs) detected on the composed source (schemastery retains them,
226
- * plan Task 1 Step 1), so the client can show a migration banner (spec
227
- * §9, incremental field — old clients ignore it) — plus the additive
228
- * `seeds` badge state (spec §9.4, legacyKeys precedent — old clients
229
- * ignore it).
161
+ * @returns the wire-normalized composed config plus `legacyKeys` — every
162
+ * v1 field detected on the composed source (`rootChain`, top-level
163
+ * `timeSlots`, `roles.rules`, `roles.default`, `roles.list[].fallback`,
164
+ * the v1 ARRAY form of `roles.list[].chain`, plus the older
165
+ * `alwaysModeRetryCap` / `roleAutoMatch` / renamed role fields —
166
+ * schemastery retains them), so the client can show a migration banner
167
+ * (incremental field — old clients ignore it) — plus the additive `seeds`
168
+ * badge state (legacyKeys precedent — old clients ignore it).
230
169
  */
231
170
  get(): FallbacksReadResult;
232
171
  /**
@@ -275,22 +214,6 @@ export declare class FallbacksConfigGateway extends TypertRemoteService {
275
214
  * write channel is unavailable (KD-G5).
276
215
  */
277
216
  revertSeed(id: string): Promise<FallbacksRevertResult>;
278
- /**
279
- * Merge the CURRENT host allowlist with the exact routes configured in this
280
- * plugin's chains (every `roles.list[].chain` entry plus `rootChain`), plus
281
- * the virtual `FallbacksChain/Auto` route. De-duplicated by exact
282
- * provider+model with first-seen order kept (the current allowlist first).
283
- * Wildcard (`provider/*`) and unparseable entries are skipped — never
284
- * expanded into routes. This is the only allowlist write face; it runs
285
- * exclusively from the explicit card button (never silently) and writes
286
- * ONLY `allowedModels` — never `enabled`.
287
- * @returns the post-write read result (the fresh `subagentPolicy` snapshot
288
- * reflects the new allowlist).
289
- * @throws when no live policy snapshot is wired, the policy is not proven
290
- * enabled (`disabled` / `unprovable`), or no settings service is composed
291
- * (KD-G5: no allowlist write without a proven-enabled policy).
292
- */
293
- syncAllowlist(): Promise<FallbacksReadResult>;
294
217
  /**
295
218
  * Read the live composed config and normalize it to the typertGateway JSON
296
219
  * wire boundary. Containment (guide §10): a malformed stored patch section
@@ -316,26 +239,59 @@ export declare class FallbacksConfigGateway extends TypertRemoteService {
316
239
  */
317
240
  private readResult;
318
241
  /**
319
- * The io seam the seed manager writes through (spec §9.1): `read` walks
320
- * the same live bridge source the gateway reads; `writeRoles` persists a
321
- * full `{ list, rules }` to the plugin's profile-entry section — both
322
- * arrays always computed from a fresh composed read, so the write stays
323
- * correct under the settings merge's array-replace semantics and never
324
- * touches operator rules. The write channel fails with the same KD-G5
325
- * message as set/reset when no settings service is composed. Built fresh
326
- * per call so the mutable settings capture is read at call time (the
327
- * inject child swaps it when the settings service appears/disappears).
242
+ * The io seam the seed manager writes through (spec §9.1): `read` walks the
243
+ * same live bridge source the gateway reads; `writeRoles` persists the
244
+ * declared roles as `{ list }` — the v2 shape, whose rows are `{ id,
245
+ * persona, chain? }` — computed from a fresh composed read, so the write
246
+ * stays correct under the settings merge's replace-whole-array semantics
247
+ * and every operator-specified `chain` pointer survives verbatim. It goes
248
+ * through {@link writeSection}, so a seed write also drops any stored v1
249
+ * field. The write channel fails with the same KD-G5 message as set/reset
250
+ * when no settings service is composed. Built fresh per call so the mutable
251
+ * settings capture is read at call time (the inject child swaps it when the
252
+ * settings service appears/disappears).
328
253
  */
329
254
  private seedsIo;
255
+ /**
256
+ * Write a CLEANED patch and, in the same save, remove v1 fields the
257
+ * persisted section may still carry. `settings.update` is merge-only, so a
258
+ * stored `rootChain` / top-level `timeSlots` / `roles.rules` /
259
+ * `roles.list[].fallback` would otherwise survive every v2 save; the
260
+ * removals ride `settings.mutate`'s `unset` path ops, which touch only the
261
+ * named fields (unrelated configuration, foreign keys and redacted secrets
262
+ * are preserved — a wholesale `replace` rebuilt from a descriptor would not
263
+ * be).
264
+ * @param patch - already stripped of v1 fields and null values.
265
+ */
266
+ private writeSection;
330
267
  }
268
+ /**
269
+ * Persist a CLEANED patch and, in the same operation, remove the v1 fields the
270
+ * ACTIVE PROFILE's persisted section still holds. `settings.update` is
271
+ * merge-only, so a stored `rootChain` / top-level `timeSlots` / `roles.rules` /
272
+ * `roles.list[].fallback` would otherwise survive every v2 save; the removals
273
+ * ride `settings.mutate`'s path ops, which touch only the named fields
274
+ * (unrelated configuration, foreign keys and redacted secrets are preserved —
275
+ * a wholesale `replace` rebuilt from a descriptor would delete them).
276
+ *
277
+ * Exported because the plugin has TWO write entry points — the gateway
278
+ * (`set` / `writeRoles`) and the apply-level `SeedsIo` (`declareSeeds`,
279
+ * `revertSeededPersona`, the bundled preset self-declaration) — and the same
280
+ * logical operation must clean the same way through both.
281
+ * @param settings - the live settings service.
282
+ * @param patch - a patch; v1 fields are stripped here, callers need not.
283
+ * @param ns - profile entry id (defaults to the plugin's own).
284
+ */
285
+ export declare function writeFallbacksSection(settings: SettingsForms, patch: Record<string, unknown>, ns?: string): Promise<void>;
331
286
  /**
332
287
  * Reject a patch the `Config` schema cannot express: non-object input, unknown
333
288
  * top-level keys (schemastery merges them silently — the settings service
334
289
  * would accept them), and schema type violations — plus the Task 3 semantic
335
290
  * guards the permissive schema deliberately does not carry: nested `roles`
336
- * keys, `timeSlots` row shapes (unknown preset ids, duplicate presets, preset
337
- * rows carrying windows, non-`HH:mm` custom bounds, out-of-range days, empty
338
- * chains), and the all-day `rootChain` head conformance.
291
+ * keys, `chains[]` row shape (id pattern, case-sensitive uniqueness, label),
292
+ * per-chain `timeSlots` row shapes (unknown preset ids, duplicate presets,
293
+ * preset rows carrying windows, non-`HH:mm` custom bounds, out-of-range days,
294
+ * empty chains), and each chain's non-empty `entries` tail conformance.
339
295
  *
340
296
  * Exported (plan fallbacks-tui-settings Task 1): the TUI settings section's
341
297
  * JSON-field `parse` mirrors the gateway save rules through this single
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
- * dsh-llm-fallbacks host half (plan Task 3: settings + waterfall + state
3
- * machine + events).
2
+ * dsh-llm-fallbacks host half: settings, the virtual chain row, the dispatch
3
+ * seam, role seeds and the diagnostics surfaces (direction C, plan
4
+ * dsh-022-rc1-upgrade).
4
5
  *
5
6
  * Cordis function plugin mounted by the profile bundle patch row
6
7
  * `llm-fallbacks` (see `bundle/cordis.patch.yml`), composed AFTER llm-retry.
@@ -11,38 +12,29 @@
11
12
  * `apply` reads the Loader's live config reference, and the
12
13
  * `settings/document-updated` listener re-derives the runtime caches —
13
14
  * the old `installSection` setSource/onChange contract, spec §4).
14
- * - `agent/request-error` waterfall: inactive config (`isFallbackActive`) /
15
- * code ∉ `triggerCodes`
16
- * (**always mode included**) → `next()`; otherwise resolve role + chain,
17
- * and when a candidate survives the filter (current / cooldown /
18
- * step-failed / `provider/*`-missing-id) write the pending switch +
19
- * cooldown + failure bookkeeping, then return
20
- * `{ kind: 'retry' }` (own recovery, no `next()`).
21
- * - `agent/request` waterfall: apply a pending switch after `await next()`
22
- * (provider/model override; the inherited `reasoningEffort` follows the
23
- * upstream routeChanged rule — dropped on a route change unless explicitly
24
- * named, preserved on a same-route override; spec D3); a
25
- * root-origin `FallbacksChain/Auto` seed is served UNCHANGED (plan
26
- * model-change-notice-loop Task 1 — the route the loop serves and records
27
- * must equal the session selection, otherwise the host `model-selection`
28
- * notice is re-armed on every step; the virtual adapter's `stream()`
29
- * delegate is what reaches the chain head); then the always-mode cap check
30
- * (count `llm/retry` events for the current turn/step/provider; ≥
31
- * `alwaysModeRetryCap` → same decision path, reason `always-cap` —
32
- * ADR-2).
33
- * - Per-agent state (`FallbackStateStore`): `agent/disposed` removes it,
34
- * `agent/status` idle prunes per-step state defensively, plugin dispose
35
- * clears everything (spec §6 — no residual state).
15
+ * - The virtual `FallbacksChain/Auto` adapter is the ONLY fallback path: its
16
+ * `stream()` WALKS the session's chain (`src/chain-walk.ts`). No
17
+ * `agent/request` / `agent/request-error` listener is registered — rc.1
18
+ * rejects a request that changes the captured selection, and a
19
+ * request-error retry `continue`s with that captured selection, so the old
20
+ * waterfall could not route a retried attempt at all. Real-model sessions
21
+ * therefore have no plugin fallback; `llm-retry` owns their recovery.
22
+ * - Subagents: the dispatch seam (`src/subagents-seam.ts`) mounts an
23
+ * inheriting child on the virtual row by stamping its creation options
24
+ * (design §1.3) — creation-time only, so a resumed child's own durable
25
+ * selection always wins.
26
+ * - Per-agent state (`FallbackStateStore`): `agent/disposed` removes it, plugin
27
+ * dispose clears everything (spec §6 — no residual state). The cooldown /
28
+ * recovery circuit is the cross-call memory the walk reads; per-request
29
+ * bookkeeping lives inside one walk.
36
30
  *
37
31
  * @module dsh-llm-fallbacks
38
32
  */
39
33
  import type { Context } from '@deepseek-ai/cordis';
40
- import type { Session } from '@deepseek-ai/dsh-session';
41
34
  import { detectLegacyKeys, validateFallbacksConfig, type FallbacksConfig } from './config.ts';
42
35
  import { Config } from './schema.ts';
43
36
  import { resolveChain } from './chains.ts';
44
- import { resolveRole } from './roles.ts';
45
- import { FallbackStateStore, type BlockedSwitchAttempt, type EffectiveChainHead } from './state.ts';
37
+ import { FallbackStateStore } from './state.ts';
46
38
  import { type EffectiveRolesReadback, type SeedDeclaration, type SeedDeclareOutcome, type SeedRevertOutcome, type SeedsDeclareOptions } from './seeds.ts';
47
39
  /** The plugin row id mounted by the profile bundle patch. */
48
40
  export declare const name = "llm-fallbacks";
@@ -69,7 +61,6 @@ export interface FallbacksService {
69
61
  name: 'llm-fallbacks';
70
62
  /** Package.json version string (module-load snapshot). */
71
63
  version: string;
72
- resolveRole: typeof resolveRole;
73
64
  resolveChain: typeof resolveChain;
74
65
  validateFallbacksConfig: typeof validateFallbacksConfig;
75
66
  detectLegacyKeys: typeof detectLegacyKeys;
@@ -113,12 +104,9 @@ export { Config };
113
104
  /** The plugin's composition config — the `fallbacks` settings schema (spec §4). */
114
105
  export type Config = FallbacksConfig;
115
106
  export type { FallbackSwitchReason, FallbacksSwitchEventData } from './events.ts';
116
- export type { AgentFallbackState, BlockedSwitchAttempt, FallbackStateStore, PendingSwitch, StepFailures, SwitchScope } from './state.ts';
117
- /** @internal T5 test-seam types (mirrors `BlockedSwitchAttempt`; not public API). */
118
- export type { ChainHeadSource, EffectiveChainHead } from './state.ts';
119
- export { resolveRole, type AgentLike, type Origin, } from './roles.ts';
107
+ export type { AgentFallbackState, FallbackStateStore } from './state.ts';
120
108
  export { annotateCandidates, createCandidateFilter, hasWildcardEntry, resolveCandidate, resolveChain, resolveChainViews, selectCandidates, type AnnotatedCandidate, type CandidateFilterOptions, type CandidateSkipReason, type FailingModel, } from './chains.ts';
121
- export { defaultFallbacksConfig, detectLegacyKeys, INHERIT_ROLE_ID, ROLE_ID_PATTERN, validateFallbacksConfig, type FallbacksConfig, type FallbacksConfigLogger, type FallbacksRole, type FallbacksRoles, type FallbacksRoleRule, type FallbackStrategy, type RevertPolicy, type RecoveryPolicy, } from './config.ts';
109
+ export { CHAIN_ID_PATTERN, defaultFallbacksConfig, detectLegacyKeys, INHERIT_ROLE_ID, ROLE_ID_PATTERN, validateFallbacksConfig, type FallbacksConfig, type FallbacksConfigLogger, type FallbacksRole, type FallbacksRoles, type NamedChain, type RevertPolicy, type RecoveryPolicy, } from './config.ts';
122
110
  export { parseSelector, SelectorError, type Selector, } from './selectors.ts';
123
111
  export { FallbacksSeedManager, type EffectiveRole, type EffectiveRolesReadback, type SeedConflict, type SeedDeclaration, type SeedDeclareOutcome, type SeedRevertFailReason, type SeedRevertOutcome, type SeedSkipReason, type SeedSource, type SeedsDeclareOptions, type SeedsIo, type SeedsWireStatus, } from './seeds.ts';
124
112
  export { presetRoles } from './presets.ts';
@@ -130,37 +118,6 @@ export { ROLE_PROJECTION_KEY, readRoleProjectionValue } from './role-projection-
130
118
  * request must not grow the store) without reaching into the closure.
131
119
  */
132
120
  export declare function stateStore(ctx: Context): FallbackStateStore | undefined;
133
- /**
134
- * @internal Test seam (mirrors `stateStore`): the per-agent blocked-switch
135
- * attempts recorded by the T3 allowlist gate for the plugin applied to `ctx`
136
- * (spec D1; in-memory only, issue #52). Not part of the plugin's public
137
- * surface; lets tests (and the T5 gateway projection) read blocked attempts
138
- * without reaching into the closure. `undefined` when no plugin is applied.
139
- */
140
- export declare function blockedAttempts(ctx: Context): ReadonlyMap<string, BlockedSwitchAttempt> | undefined;
141
- /**
142
- * @internal Test seam (mirrors `blockedAttempts`): the per-agent effective
143
- * chain heads recorded by the T2 inject path (source `authorized` |
144
- * `injected`) for the plugin applied to `ctx` (spec D4 / T2 M3). Not part
145
- * of the plugin's public surface; lets tests and the T5 gateway projection
146
- * read the head without reaching into the closure. `undefined` when no
147
- * plugin is applied.
148
- */
149
- export declare function chainHeads(ctx: Context): ReadonlyMap<string, EffectiveChainHead> | undefined;
150
- /**
151
- * Count durable `llm/retry` events for the current (turn, step, provider) in
152
- * **always mode** (ADR-2; spec §2 clause 5). Normal-mode retries belong to
153
- * llm-retry's bounded budget and must not preempt the fallback, so only
154
- * `mode: 'always'` events count toward `alwaysModeRetryCap` (T3 review
155
- * Minor 2 — the real event carries the discriminator, llm-retry types.ts).
156
- *
157
- * Fast path (T3 review Minor 4): the session log is append-ordered, so the
158
- * scan runs backwards and stops at the first event older than the target
159
- * (turn, step) — a long session's earlier turns are never scanned.
160
- *
161
- * Exported for direct unit testing of the counting + fast path.
162
- */
163
- export declare function countRetryEvents(session: Session, turn: number, step: number, provider: string): number;
164
121
  /**
165
122
  * The live config reference the Loader hands `apply` for a
166
123
  * volatile Config schema: an identity-stable `{ get }` view (cosmokit's