dsh-llm-fallbacks 0.6.13 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  * Dispatch-time role resolution (plan fallbacks-role-automatch Task 2; the
3
3
  * dispatch-role stage added by plan dsh-148-agent-team-role-delivery Task 1).
4
4
  *
5
- * Precedence (first hit wins) across four ordered stages:
5
+ * Precedence (first hit wins) across three ordered stages:
6
6
  * 1. explicit — `session.header.agentPreset` trimmed matches a declared role
7
7
  * id → that role's DECLARED RAW id (via the `roleIds` trimmed-id map, the
8
8
  * same canonicalization the rules path uses); the reserved `'inherit'`
@@ -16,51 +16,30 @@
16
16
  * not an error;
17
17
  * 3. rules — the existing `resolveRole` passthrough, unchanged (a declared
18
18
  * role wins; no-rule-match / an explicit rule targeting `'inherit'`
19
- * resolve to `'inherit'`);
20
- * 4. auto-match — only when stage 3 resolved to `'inherit'` AND
21
- * `opts.automatchEnabled`: call `opts.automatch` with the agent. The hook
22
- * (Task 3 supplies the bounded LLM caller) MUST return a declared raw id
23
- * or `null`; the resolver defensively validates the return against
24
- * `roleIds` — an unknown id warns and resolves to `'inherit'`, never an
25
- * undeclared role. When `automatch` is undefined or `automatchEnabled`
26
- * is false the hook is skipped.
19
+ * resolve to `'inherit'`).
27
20
  *
28
21
  * `'inherit'` is the single "no specific role" outcome — reached by
29
- * no-rule-match OR an explicit rule targeting `'inherit'` — and both are
30
- * auto-match eligible when enabled. An operator who wants a class to never
31
- * auto-match sets `roleAutoMatch: false` (→ `automatchEnabled: false`).
22
+ * no-rule-match OR an explicit rule targeting `'inherit'`.
32
23
  *
33
- * Pure ordering logic: no config object and no LLM service inside this
34
- * module — the LLM is an injectable hook owned by Task 3/4.
24
+ * There is deliberately NO auto-match stage: the only host point it ever had
25
+ * was the deleted `agent/request` inject, and running an LLM call inside the
26
+ * walk's `stream()` would be a nested request per failure (design
27
+ * dsh-022-rc1-upgrade §2.2). Resolution is therefore pure and synchronous —
28
+ * the walk must throw its pre-attempt errors before the first chunk.
35
29
  *
36
30
  * @module dsh-llm-fallbacks/role-resolution
37
31
  */
38
32
  import { type FallbacksRoleRule } from './config.ts';
39
33
  import { type AgentLike } from './roles.ts';
40
- import type { Selector } from './selectors.ts';
41
34
  export type { FallbacksRoleRule } from './config.ts';
42
- /** Options for {@link resolveRoleAtDispatch}. */
43
- export interface ResolveRoleAtDispatchOptions {
35
+ /** Options for {@link resolveDeclaredRole}. */
36
+ export interface DeclaredRoleOptions {
44
37
  /**
45
38
  * Dispatch-declared role: the DECLARED RAW id the seam resolved from the
46
39
  * Assignment/teammate prompt (`**Execute as**: <id>`). Validated against the
47
40
  * live `roleIds` map; absent, `inherit`, or no-longer-declared → skipped.
48
41
  */
49
42
  dispatchRole?: string;
50
- /**
51
- * Whether the auto-match stage may run (the `roleAutoMatch` config switch).
52
- * When `false`, stage 4 never runs and the outcome is identical to the
53
- * explicit/dispatch/rules stages alone.
54
- */
55
- automatchEnabled: boolean;
56
- /**
57
- * Auto-match hook — Task 3 supplies the bounded LLM caller; the resolver
58
- * itself contains no LLM. Contract: given the agent being resolved, return
59
- * a declared raw role id or `null`. The resolver defensively validates the
60
- * return against `roleIds` — an unknown id warns and resolves to
61
- * `'inherit'`, never an undeclared role. Skipped when undefined.
62
- */
63
- automatch?: (agent: AgentLike) => Promise<string | null>;
64
43
  /** Warning sink — the decision path injects the plugin logger. */
65
44
  warn: (message: string) => void;
66
45
  /**
@@ -76,11 +55,7 @@ export interface ResolveRoleAtDispatchOptions {
76
55
  };
77
56
  }
78
57
  /**
79
- * Resolve the dispatch-time role for an agent across four ordered stages
80
- * (explicit → dispatch → rules → auto-match hook). Never throws on its own
81
- * inputs; the hook's own rejection is deliberately NOT caught here (the
82
- * Task-3/4 wiring bounds the LLM call so it never throws out of the request
83
- * path).
58
+ * The dispatch role: explicit → dispatch-declared → rules.
84
59
  *
85
60
  * `roleIds` is the canonical trimmed-id map (`trimmed id → declared raw id`,
86
61
  * built in `src/index.ts` apply()): every stage canonicalizes its reference
@@ -88,18 +63,4 @@ export interface ResolveRoleAtDispatchOptions {
88
63
  * `chains.ts` / `commands.ts` find the stored `roles.list[]` entry exactly
89
64
  * (qc2 F-001 — no silent role inertness on padded YAML ids).
90
65
  */
91
- export declare function resolveRoleAtDispatch(agent: AgentLike, rules: FallbacksRoleRule[], roleIds: ReadonlyMap<string, string>, opts: ResolveRoleAtDispatchOptions): Promise<string>;
92
- /**
93
- * The dispatch-time chain head (plan fallbacks-role-automatch Task 4): the
94
- * FIRST candidate of a `resolveChainViews` pass whose `wildcard` flag is
95
- * false — an exact `provider/model` entry. `provider/*` entries are NOT
96
- * dispatch injection targets (no failing model to anchor them; a guessed
97
- * injection risks a hard failure whose code may sit outside `triggerCodes`),
98
- * so a wildcard-only chain yields `undefined` → no injection (today's
99
- * behavior). No cooldown/failed filtering and no existence probe run here —
100
- * a fresh subagent's first request has no failure-scoped state (see the Task
101
- * 4 decision) and exact heads are never probed. Pairs with
102
- * {@link resolveChainViews}'s parallel `wildcard` provenance (`src/chains.ts`);
103
- * the caller resolves the role's concatenated chain first.
104
- */
105
- export declare function firstExactCandidate(all: readonly Selector[], wildcard: readonly boolean[]): Selector | undefined;
66
+ export declare function resolveDeclaredRole(agent: AgentLike, rules: FallbacksRoleRule[], roleIds: ReadonlyMap<string, string>, opts: DeclaredRoleOptions): string;
package/dist/roles.d.ts CHANGED
@@ -4,11 +4,10 @@
4
4
  *
5
5
  * This module is the RULES stage. Dispatch-time role resolution
6
6
  * (plan fallbacks-role-automatch; plan dsh-148 added the dispatch stage) is
7
- * the four-stage resolver in `role-resolution.ts` — explicit
7
+ * the three-stage resolver in `role-resolution.ts` — explicit
8
8
  * (`session.header.agentPreset`) → dispatch-declared role (the seam record
9
- * resolved from `**Execute as**`) → this rules stage → an optional auto-match
10
- * hook; `AgentLike` carries the additive `session.header.agentPreset?`
11
- * carrier for that explicit stage.
9
+ * resolved from `**Execute as**`) → this rules stage; `AgentLike` carries the
10
+ * additive `session.header.agentPreset?` carrier for that explicit stage.
12
11
  *
13
12
  * Precedence (first hit wins):
14
13
  * 1. the first `rules` entry whose specified provider/model patterns match
package/dist/state.d.ts CHANGED
@@ -1,69 +1,25 @@
1
1
  /**
2
- * Per-agent fallback state machine (spec §5.1; plan Task 3).
2
+ * Per-agent fallback state (direction C, plan dsh-022-rc1-upgrade).
3
3
  *
4
4
  * One `AgentFallbackState` per `agent.id`, held in a `FallbackStateStore`
5
- * created at plugin apply, removed on `agent/disposed`, defensively pruned on
6
- * `agent/status` idle, and cleared wholesale on plugin dispose (spec §6:
7
- * 无残留状态).
5
+ * created at plugin apply, removed on `agent/disposed`, and cleared wholesale
6
+ * on plugin dispose (spec §6: 无残留状态).
8
7
  *
9
- * **pendingSwitch lifecycle** (spec §5.1):
10
- * 1. **Produce** (decision point — request-error trigger-code or
11
- * agent/request always-cap): `writePending` stores the decision and clears
12
- * `appliedTurnStep`, so a *fresh* decision is always applicable at the
13
- * current (turn, step) — required for chains (decision B→C must apply even
14
- * though A→B was already applied at the same (turn, step)).
15
- * 2. **Apply** (`agent/request`): `applyPending` returns the pending switch
16
- * when one exists and has not already been applied at the current
17
- * (turn, step), records `appliedTurnStep`, and clears `pendingSwitch`
18
- * (anti-replay guard).
19
- * 3. **Discard**: chain exhaustion / safety valve → the caller delegates
20
- * (`next()`) and the pending state dies with the agent.
21
- *
22
- * **stepFailures** resets whenever (turn, step) advances (`syncStep`);
23
- * **cooldown** (`CooldownStore`) is a `provider/model → expiry epoch ms` map
24
- * with lazy expiry (`revertPolicy: 'never'` = `Infinity` TTL). The time-boxed
25
- * cooldown deliberately survives idle cleanup so `cooldown-expiry` revert
26
- * (US-4 / T4 integration) works across turns.
8
+ * The store is the CROSS-CALL memory the chain walk reads: **cooldown**
9
+ * (`CooldownStore` — a `provider/model → expiry epoch ms` map with lazy expiry;
10
+ * `revertPolicy: 'never'` = `Infinity` TTL) plus the **recovery** circuit
11
+ * (`RecoveryStore`). The time-boxed cooldown deliberately survives idle
12
+ * cleanup so `cooldown-expiry` revert (US-4 / T4 integration) works across
13
+ * turns. Per-request bookkeeping (the failed-candidate set, the switch cap) is
14
+ * walk-local: a walk lives inside one `stream()` call, so nothing durable is
15
+ * kept for it here.
27
16
  *
28
17
  * @module dsh-llm-fallbacks/state
29
18
  */
30
19
  import type { FallbackSwitchReason } from './events.ts';
31
- import { CooldownStore, StepFailureSet } from './cooldown.ts';
20
+ import { CooldownStore } from './cooldown.ts';
32
21
  import { RecoveryStore } from './recovery.ts';
33
22
  import type { RecoveryPolicy } from './config.ts';
34
- /**
35
- * What a switch's failure was actually about.
36
- *
37
- * `'route'` (the default) is every failure the plugin has ever walked: the
38
- * provider/model itself is unhealthy (auth, quota, rate limit, ...), so the
39
- * from-route is cooled down and counted as a route failure.
40
- * `'request'` is a failure of the REQUEST on a healthy route — a
41
- * `CONTEXT_WINDOW_EXCEEDED` rejection is the request being too big, not the
42
- * model being down. `commit()` keeps the step-scoped bookkeeping for those
43
- * but writes no suppression and no recovery failure (see `src/index.ts`
44
- * `REQUEST_SCOPED_CODES`).
45
- */
46
- export type SwitchScope = 'route' | 'request';
47
- /** Decision awaiting application at the next `agent/request` boundary (spec §5.1). */
48
- export interface PendingSwitch {
49
- from: {
50
- provider: string;
51
- model: string;
52
- };
53
- to: {
54
- provider: string;
55
- model: string;
56
- };
57
- role: string;
58
- reason: FallbackSwitchReason;
59
- /**
60
- * What the failure was about — absent means `'route'`, the semantics every
61
- * switch had before request-scoped codes existed (same optional-with-a-
62
- * documented-default idiom as `FallbacksRole.fallback`). Only the
63
- * `agent/request-error` listener ever sets `'request'`.
64
- */
65
- scope?: SwitchScope;
66
- }
67
23
  /**
68
24
  * One plugin-originated subagent switch the host allowlist blocked (plan
69
25
  * dsh-012-subagent-routing T3; spec D1). The intersection of the resolved
@@ -85,8 +41,9 @@ export interface BlockedSwitchAttempt {
85
41
  /**
86
42
  * How the effective subagent chain head was chosen (plan dsh-012-subagent-routing
87
43
  * T5 / T2 M3). Lives on a per-agent map (mirrors {@link BlockedSwitchAttempt}),
88
- * not on {@link AgentFallbackState} — recording a head is not a switch intent
89
- * (F-004: the fallback store grows only inside `commit()`).
44
+ * not on {@link AgentFallbackState}. The write point is the dispatch seam
45
+ * (direction C): `authorized` = the spawn request carried an explicit route,
46
+ * `injected` = the seam mounted the child on the virtual chain row.
90
47
  */
91
48
  export type ChainHeadSource = 'authorized' | 'injected';
92
49
  /**
@@ -104,34 +61,16 @@ export interface EffectiveChainHead {
104
61
  /** `authorized` = host spawn preserved; `injected` = plugin role-inject. */
105
62
  source: ChainHeadSource;
106
63
  }
107
- /** The current (turn, step)'s failed-model set and switch budget (spec §5.1). */
108
- export interface StepFailures {
109
- turn: number;
110
- step: number;
111
- /** `${provider}/${model}` keys failed in this step (double suppression with cooldown). */
112
- failed: StepFailureSet;
113
- /** Switches committed in this step; the caller judges it against `maxSwitchesPerStep`. */
114
- switchCount: number;
115
- }
116
- /** One agent's whole fallback runtime state (spec §5.1). */
64
+ /** One agent's fallback runtime state. */
117
65
  export interface AgentFallbackState {
118
- /** Decision produced but not yet applied at an `agent/request` boundary. */
119
- pendingSwitch?: PendingSwitch;
120
- /** The (turn, step) the last pending switch was applied to — anti-replay. */
121
- appliedTurnStep?: {
122
- turn: number;
123
- step: number;
124
- };
125
- /** Current (turn, step)'s failure bookkeeping. */
126
- stepFailures: StepFailures;
127
66
  /** `provider/model → expiry epoch ms`; lazily expired on read. */
128
67
  cooldown: CooldownStore;
129
68
  /** Half-open recovery bookkeeping (plan fallbacks-half-open-recovery P2). */
130
69
  recovery: RecoveryStore;
131
70
  }
132
71
  /**
133
- * The `Map<agent.id, AgentFallbackState>` store plus the state-machine
134
- * operations (spec §5.1).
72
+ * The `Map<agent.id, AgentFallbackState>` store plus the state operations the
73
+ * walk and the diagnostics surfaces read.
135
74
  */
136
75
  export declare class FallbackStateStore {
137
76
  private readonly states;
@@ -146,29 +85,6 @@ export declare class FallbackStateStore {
146
85
  delete(agentId: string): void;
147
86
  /** Remove every agent's state (plugin dispose — no residual state). */
148
87
  clear(): void;
149
- /** Reset step-scoped bookkeeping when (turn, step) advances (spec §5.1). */
150
- syncStep(state: AgentFallbackState, turn: number, step: number): void;
151
- /** Record the current model as failed in this step (spec §5.1). */
152
- recordFailure(state: AgentFallbackState, key: string): void;
153
- /** Bump the step's switch budget (spec §5.1). */
154
- recordSwitch(state: AgentFallbackState): void;
155
- /**
156
- * Produce a pending switch. A fresh decision always supersedes a previous
157
- * one and must be applicable at the current (turn, step), so the applied
158
- * marker is cleared here (see module doc — chains).
159
- */
160
- writePending(state: AgentFallbackState, pending: PendingSwitch): void;
161
- /**
162
- * Apply the pending switch at (turn, step) when one exists and has not
163
- * already been applied there; records the applied marker and clears the
164
- * pending switch (spec §5.1 lifecycle step 2).
165
- */
166
- applyPending(state: AgentFallbackState, turn: number, step: number): PendingSwitch | undefined;
167
- /**
168
- * Defensive `agent/status` idle cleanup: drop per-step state. The time-boxed
169
- * cooldown survives so `cooldown-expiry` revert works across turns (US-4).
170
- */
171
- clearStepState(state: AgentFallbackState): void;
172
88
  /** Suppress `key` until `untilEpochMs` (`Infinity` for `revertPolicy: 'never'`). */
173
89
  suppress(state: AgentFallbackState, key: string, untilEpochMs: number): void;
174
90
  /**
@@ -9,8 +9,11 @@
9
9
  * STRUCTURALLY — the upstream reader is not published from
10
10
  * `@deepseek-ai/dsh-tool-subagent` and throws on malformed payloads,
11
11
  * which is incompatible with the fail-closed no-throw requirement;
12
- * 2. the host settings service snapshot (`SubagentModelSelectionConfig`
13
- * `.current()`), typed via a type-only peer import that is erased at emit.
12
+ * 2. the host's live subagent model-selection settings value (the
13
+ * `subagent-model-selection-settings` settings namespace), typed via a
14
+ * type-only peer import that is erased at emit. rc.1 made the settings
15
+ * service's own snapshot reader private, so the wiring reads the settings
16
+ * form value and narrows it structurally here.
14
17
  *
15
18
  * The session event wins over settings (ADR read order). Anything present but
16
19
  * unreadable yields `'unprovable'` (fail-closed): callers must not emit a
@@ -80,12 +83,22 @@ export type PolicySettings = {
80
83
  /** Assignability guard: fails typecheck if the peer's settings shape drifts from the reader's snapshot contract. */
81
84
  type AssertSettingsShape<T extends PolicySettings> = T;
82
85
  /**
83
- * The host settings service snapshot type, re-exported from the published
84
- * peer subpath (type-only). Wiring reads the service's `current()` and passes
85
- * the result in as plain data; if upstream drifts, this alias fails typecheck
86
- * instead of drifting silently at runtime (plan ADR).
86
+ * The host settings value's shape, re-exported from the published peer
87
+ * subpath (type-only). Wiring passes the settings form's namespace value in as
88
+ * plain data; if upstream drifts, this alias fails typecheck instead of
89
+ * drifting silently at runtime (plan ADR).
87
90
  */
88
91
  export type PeerSettingsSnapshot = AssertSettingsShape<SubagentModelSelectionSettings>;
92
+ /**
93
+ * Narrow a host settings value into the reader's snapshot contract.
94
+ * Reads STRUCTURALLY like {@link readSessionPolicyEvent} — rc.1 publishes no
95
+ * settings value reader — so a malformed value is reported, never raised; the
96
+ * wiring decides its failure face (warn + fail-closed).
97
+ *
98
+ * @param value - untrusted settings-form value for the policy namespace.
99
+ * @returns a detached snapshot, or undefined when the value is not one.
100
+ */
101
+ export declare function policySettingsFrom(value: unknown): PolicySettings | undefined;
89
102
  /**
90
103
  * Structurally read the session's `subagent/model-selection-policy` event.
91
104
  * Never throws: a present-but-malformed payload is reported, not raised.
@@ -116,8 +129,9 @@ export declare function readSessionPolicyEvent(events: readonly {
116
129
  * malformed route list, yields `'unprovable'`. Never throws.
117
130
  *
118
131
  * @param event - result of {@link readSessionPolicyEvent}.
119
- * @param settings - settings service snapshot, or undefined when the host
120
- * does not compose the service.
132
+ * @param settings - host settings value narrowed by
133
+ * {@link policySettingsFrom}, or undefined when the host composes no such
134
+ * settings entry.
121
135
  */
122
136
  export declare function effectivePolicy(event: SessionPolicyEventRead, settings: PolicySettings | undefined): EffectivePolicy;
123
137
  export {};
@@ -58,8 +58,25 @@
58
58
  * (the runtime mints its own child id and `agent/created` has already run), in
59
59
  * which case this module resolves the same question against the live roles
60
60
  * source and carries the verdict onto the record keyed off the RESOLVED id,
61
- * before the child's first pre-step. This module writes no request field,
62
- * reads no provider persona capability, and injects no agent preset.
61
+ * before the child's first pre-step. This module reads no provider persona
62
+ * capability and injects no agent preset.
63
+ *
64
+ * Virtual chain mount (plan dsh-022-rc1-upgrade §1.3; direction C): the SAME
65
+ * resolution point is where a dispatch child is mounted on the virtual
66
+ * `FallbacksChain/Auto` row — `request.agentOptions` is stamped (rc.1 merges
67
+ * it over the parent's route when `manager.startLocal` creates the child) when
68
+ * all five conditions hold: a resolved role, no explicit `agentOptions` route
69
+ * (explicit = authorized, untouched), a LOCAL backend, an active config whose
70
+ * resolved role chain has a dispatchable exact head ON the host allowlist when
71
+ * the subagent policy is `enabled`, and a policy that is not `unprovable`. The
72
+ * record carries `virtualRouteInjected` (sticky) and the effective chain head
73
+ * is labelled in `chainHeads` (`injected` / `authorized`). Injection is
74
+ * creation-time only BY CONSTRUCTION: rc.1's `startActivation` rejects any
75
+ * child id that already exists (`DUPLICATE_CHILD`), so this path never attaches
76
+ * to an existing child and cannot overwrite its durable selection — a
77
+ * caller-reserved `childId` is a pre-chosen identity for a NEW child. A
78
+ * non-injectable dispatch forwards the caller's spec untouched (external
79
+ * backends keep pure inheritance).
63
80
  *
64
81
  * In-session notice row (plan Task 3): the SAME install point registers the
65
82
  * `agent/pre-step` emitter (`./role-notice.ts`) over the record map it owns, so
@@ -89,7 +106,9 @@
89
106
  * @module dsh-llm-fallbacks/subagents-seam
90
107
  */
91
108
  import type { Context } from '@deepseek-ai/cordis';
92
- import { type FallbacksRole } from './config.ts';
109
+ import { type FallbacksConfig, type FallbacksRole } from './config.ts';
110
+ import type { EffectiveChainHead } from './state.ts';
111
+ import type { EffectivePolicy } from './subagent-policy.ts';
93
112
  /**
94
113
  * The cordis service-read waterfall event the seam registers on. Referenced
95
114
  * ONCE (registration) so a future cordis rename is a one-line change here.
@@ -149,6 +168,18 @@ export interface SubagentSeamRecord {
149
168
  * `true` survives.
150
169
  */
151
170
  personaNotApplied?: boolean;
171
+ /**
172
+ * `true` when this dispatch MOUNTED the child on the virtual
173
+ * `FallbacksChain/Auto` row (design §1.3): the plugin stamped the creation
174
+ * options, so the child's chain is plugin-originated and its switches are
175
+ * allowlist-filtered. Read by the chain walk (`ChainWalkDeps.seamRecord`)
176
+ * and never by a host surface.
177
+ *
178
+ * STICKY, like `personaNotApplied`: the mount happened at spawn and a record
179
+ * rewrite (a resume reusing the `childId`) carries no verdict of its own, so
180
+ * the first `true` survives.
181
+ */
182
+ virtualRouteInjected?: true;
152
183
  }
153
184
  /** One consumed prompt content block (`@deepseek-ai/dsh-llm` `ContentBlock` text members). */
154
185
  export interface SubagentPromptBlockView {
@@ -169,14 +200,25 @@ export interface SubagentStartRequestView {
169
200
  readonly id?: string;
170
201
  };
171
202
  };
203
+ /**
204
+ * rc.1 `SubagentStartRequest.agentOptions` (design §1.1) — the route the
205
+ * caller (or the model, on the tool path) asked the child to run on. Read
206
+ * only: an explicit pair here is the AUTHORIZED route and is never touched.
207
+ */
208
+ readonly agentOptions?: {
209
+ readonly provider?: string;
210
+ readonly model?: string;
211
+ };
172
212
  }
173
213
  /**
174
214
  * Structural view of the activation spec (rc.1 `SubagentActivationSpec`,
175
- * consumed fields only): `childId` is the caller-reserved identity a resume
176
- * already carries — also the record fallback when the request carries no
177
- * Assignment header — and `label` is the catalog join's other half (the
178
- * cataloged child membership carries this exact string). `signal`, `delivery`
179
- * and every future field travel with the caller's spec object.
215
+ * consumed fields only): `childId` is the caller-RESERVED identity of a NEW
216
+ * local child (rc.1 `startLocal` rejects an id that already exists with
217
+ * `DUPLICATE_CHILD`, so `startActivation` never attaches to a child that
218
+ * outlived an earlier activation) — also the record fallback when the request
219
+ * carries no Assignment header — and `label` is the catalog join's other half
220
+ * (the cataloged child membership carries this exact string). `signal`,
221
+ * `delivery` and every future field travel with the caller's spec object.
180
222
  */
181
223
  export interface SubagentActivationSpecView {
182
224
  readonly provider: string;
@@ -199,6 +241,23 @@ export interface SubagentSeamOptions {
199
241
  * wired) ⇒ no verdict, exactly like a listener that never saw the child.
200
242
  */
201
243
  roles?: () => readonly FallbacksRole[];
244
+ /**
245
+ * Live config reader — the SAME `source()` the runtime and the adapter walk
246
+ * read. Absent ⇒ the seam never mounts a child on the virtual route (a
247
+ * partial wiring keeps the native dispatch unchanged).
248
+ */
249
+ source?: () => FallbacksConfig;
250
+ /**
251
+ * The host subagent model-selection policy for one agent (guarded read,
252
+ * fail-closed). Absent ⇒ treated as `'disabled'` (no policy to fail closed
253
+ * on); an `'unprovable'` answer skips the mount.
254
+ */
255
+ policyFor?: (agentId: string) => EffectivePolicy;
256
+ /**
257
+ * Per-agent effective chain head, keyed by child session id (the Subagents
258
+ * card's read). Absent ⇒ the seam records no head label.
259
+ */
260
+ chainHeads?: Map<string, EffectiveChainHead>;
202
261
  /** Contained debug sink (at most one line per contained degrade). */
203
262
  debug?: (message: string) => void;
204
263
  }
@@ -220,8 +279,8 @@ export interface SubagentSeam {
220
279
  * deliberately NOT cleared on `agent/disposed` — a continuable child's durable
221
280
  * session survives the activation, so clearing the marker would let a
222
281
  * re-activation resume append a second notice row to the same log. It is
223
- * bounded (`NOTICE_EMITTED_LIMIT`) and cleared only in the plugin dispose
224
- * effect.
282
+ * bounded (`NOTICE_EMITTED_LIMIT`) and cleared only when the LAST applied
283
+ * fiber of its root disposes (a surviving fiber's children still need it).
225
284
  */
226
285
  readonly noticeEmitted: Set<string>;
227
286
  /**
@@ -233,7 +292,7 @@ export interface SubagentSeam {
233
292
  * message.
234
293
  */
235
294
  readonly ownsWrapper: boolean;
236
- /** Stop intercepting service reads (the owning fiber's teardown also does). */
295
+ /** Withdraw this fiber's registrations (its own lifecycle effect does this on teardown). */
237
296
  dispose(): void;
238
297
  }
239
298
  /**
@@ -114,12 +114,14 @@ export declare const FALLBACKS_TUI_SECTION_NS = "fallbacks";
114
114
  */
115
115
  export declare const JSON_FIELD_MAX_DRAFT_BYTES: number;
116
116
  /**
117
- * The `fallbacks` /settings section: 12 fields covering all 14 web-card
117
+ * The `fallbacks` /settings section: 10 fields covering the web-card
118
118
  * capabilities (the default-model choice rides `rootChain`'s last entry and
119
119
  * the per-role fallback strategy rides `roles.list` JSON; the config-level
120
120
  * `enabled` switch is REMOVED — plan fallbacks-web-ux-alignment T2, the
121
121
  * Plugins-page row toggle is the master switch and the runtime gates read
122
- * content presence). Scalar
122
+ * content presence; the always-mode retry cap and the dispatch role
123
+ * auto-match switch are REMOVED with their mechanisms — direction C, only a
124
+ * `FallbacksChain/Auto` session walks a chain). Scalar
123
125
  * capabilities use native kinds (boolean/number/select); complex structures
124
126
  * use `text` fields with custom `format`/`parse` that mirror the gateway's
125
127
  * validation. Built fresh per call — the host deep-freezes whatever it
@@ -24,22 +24,22 @@
24
24
  * re-narrow the condition to ignore `timeSlots`. Conformance-only chain
25
25
  * edits still never churn (conformance gates the delegate, not the row).
26
26
  *
27
- * Adapter behavior (P1/P3): `listModels` advertises exactly the one virtual
28
- * row; `stream()` is a THIN single-hop delegate to the effective chain head
29
- * through the host LLM runtime — no chain walk, no cooldown/caps/revert
30
- * bookkeeping, no state writes (a failure inside the delegate surfaces at
31
- * `agent/request-error`, where the existing engine walks from there).
32
- * `resolveModel` proxies the current effective head's metadata when
33
- * resolvable (modalities/context-window/reasoning follow the head) with a
34
- * permissive default otherwise — never throws. `providerRetryPolicy` and
35
- * `imageRequestPricing` mirror the same head for the host-captured retry
36
- * policy and the token meter — also never throwing.
27
+ * Adapter behavior (P1/P3, direction C): `listModels` advertises exactly the
28
+ * one virtual row; `stream()` WALKS the session's chain (cooldown / failed-set
29
+ * / switch cap / recovery bookkeeping) in `src/chain-walk.ts` — the walk is
30
+ * injected at install and owns the pre-attempt guards, so this module stays
31
+ * the catalog + metadata surface. `resolveModel` proxies the current effective
32
+ * head's metadata when resolvable (modalities/context-window/reasoning follow
33
+ * the head) with a permissive default otherwise — never throws.
34
+ * `providerRetryPolicy` and `imageRequestPricing` mirror the same head for the
35
+ * host-captured retry policy and the token meter — also never throwing.
37
36
  *
38
37
  * @module dsh-llm-fallbacks/virtual-adapter
39
38
  */
40
39
  import type { Context } from '@deepseek-ai/cordis';
41
40
  import { LlmAdapter, type GenerateOptions, type LlmImageRequestPricing, type LlmModelInfo, type LlmResolvedModelInfo, type LlmRuntime, type ResolvedRetryPolicy, type StreamChunk } from '@deepseek-ai/dsh-llm';
42
41
  import { type FallbacksConfig } from './config.ts';
42
+ import type { PolicyRoute } from './subagent-policy.ts';
43
43
  /** Provider route of the virtual adapter (exact string, spec lock). */
44
44
  export declare const FALLBACKS_PROVIDER = "FallbacksChain";
45
45
  /**
@@ -49,19 +49,6 @@ export declare const FALLBACKS_PROVIDER = "FallbacksChain";
49
49
  * {@link pickerDisplayName} (`Auto: <model>[<slot>]`).
50
50
  */
51
51
  export declare const FALLBACKS_CHAIN_MODEL = "Auto";
52
- /**
53
- * `LlmError` code: the effective chain is empty — the virtual route has no
54
- * head to delegate to (P1 guard).
55
- */
56
- export declare const EMPTY_EFFECTIVE_CHAIN_CODE = "EMPTY_EFFECTIVE_CHAIN";
57
- /**
58
- * `LlmError` code: the effective chain head is not a dispatchable real pair
59
- * (non-conforming all-day, wildcard selector, malformed selector, or a
60
- * self-route back to `FallbacksChain/*` — the P1 recursion guard).
61
- */
62
- export declare const UNDISPATCHABLE_HEAD_CODE = "UNDISPATCHABLE_EFFECTIVE_HEAD";
63
- /** `LlmError` code (defensive): the `llm` runtime disappeared mid-flight. */
64
- export declare const LLM_UNAVAILABLE_CODE = "LLM_UNAVAILABLE";
65
52
  /** One dispatchable exact head: `provider/model`. Wildcards are never seeds (P3). */
66
53
  export interface EffectiveHead {
67
54
  provider: string;
@@ -69,16 +56,25 @@ export interface EffectiveHead {
69
56
  }
70
57
  /**
71
58
  * The FIRST DISPATCHABLE exact head of a chain — the single definition of
72
- * "effective head" (F-001) shared by the virtual adapter's delegate paths and
73
- * the routing engine's served-route anchor (`anchorServedRoute` in
74
- * `src/index.ts`). Walks the SAME chain `resolveEffectiveChain` produces,
75
- * skipping entries that can never
59
+ * "effective head" (F-001) shared by this module's metadata paths
60
+ * (`pickerDisplayName` / `resolveModel` / `providerRetryPolicy` /
61
+ * `imageRequestPricing`), the walk's head and served-route resolution
62
+ * (`servedRouteOf` in `src/chain-walk.ts`), and the seam's mount gate
63
+ * (`injectRoute` in `src/subagents-seam.ts`). Walks the SAME chain
64
+ * `resolveEffectiveChain` produces, skipping entries that can never
76
65
  * be dispatched: malformed selectors (config-warning path), `provider/*`
77
66
  * `provider/*` wildcards (no real pair), and self-routes back to
78
67
  * `FallbacksChain/*` (the P1 recursion guard). `undefined` when the chain
79
68
  * is empty or no entry is dispatchable.
69
+ *
70
+ * `allowedModels` (spec D1) additionally restricts the result to the host
71
+ * subagent allowlist: a PLUGIN-originated route is only ever attempted on a
72
+ * route the host authorizes, so the seam's mount gate and a plugin-originated
73
+ * walk's start pass the enabled policy's list and an entry it excludes is
74
+ * skipped like any other non-dispatchable entry. An empty intersection is
75
+ * `undefined` — the caller declines the mount / refuses the walk.
80
76
  */
81
- export declare function firstDispatchableExactHead(chain: readonly string[]): EffectiveHead | undefined;
77
+ export declare function firstDispatchableExactHead(chain: readonly string[], allowedModels?: readonly PolicyRoute[]): EffectiveHead | undefined;
82
78
  /**
83
79
  * The effective chain head at `now` (P1): the first DISPATCHABLE exact head
84
80
  * of the SAME `resolveEffectiveChain` the routing engine uses — thin
@@ -92,8 +88,9 @@ export declare function firstDispatchableExactHead(chain: readonly string[]): Ef
92
88
  * for a successful delegate —
93
89
  * a legacy multi-model (or empty) all-day chain earns no primary
94
90
  * semantics even though the picker row is visible. `resolveModel` then
95
- * falls back to the permissive default and `stream()` throws
96
- * {@link UNDISPATCHABLE_HEAD_CODE}.
91
+ * falls back to the permissive default. The delegate side of that gate is the
92
+ * chain walk's ROOT-origin rule (`UNDISPATCHABLE_EFFECTIVE_HEAD`); a subagent
93
+ * walks its role chain instead, so the all-day shape never gates a child.
97
94
  */
98
95
  export declare function effectiveHeadOf(config: FallbacksConfig, now: Date): EffectiveHead | undefined;
99
96
  /**
@@ -105,17 +102,14 @@ export declare function effectiveHeadOf(config: FallbacksConfig, now: Date): Eff
105
102
  */
106
103
  export declare function pickerDisplayName(config: FallbacksConfig, now?: Date, modelDisplayName?: string): string;
107
104
  /**
108
- * The virtual adapter (P1/P3). `stream()` is a thin head-delegate, never a
109
- * second routing engine: no chain walk, cooldown, caps, revert bookkeeping,
110
- * or state writes live here — those stay in the `agent/request` /
111
- * `agent/request-error` listeners. Exported so the defensive guard paths
112
- * the registration lifecycle cannot reach (a registered route whose `llm`
113
- * vanished mid-teardown) stay unit-testable by direct construction.
105
+ * The virtual adapter (P1/P3). `stream()` hands the call to the injected walk
106
+ * (see `src/chain-walk.ts`) — registration metadata stays here.
114
107
  */
115
108
  export declare class FallbacksChainAdapter extends LlmAdapter {
116
109
  private readonly readConfig;
117
110
  private readonly getLlm;
118
- constructor(readConfig: () => FallbacksConfig, getLlm: () => LlmRuntime | undefined);
111
+ private readonly walk;
112
+ constructor(readConfig: () => FallbacksConfig, getLlm: () => LlmRuntime | undefined, walk: (options: GenerateOptions) => AsyncIterable<StreamChunk>);
119
113
  /** Minimal honest descriptor (P3) — the provider route IS the display name. */
120
114
  providerInfo(provider: string): {
121
115
  id: string;
@@ -157,14 +151,14 @@ export declare class FallbacksChainAdapter extends LlmAdapter {
157
151
  */
158
152
  imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined;
159
153
  /**
160
- * Thin head-delegate (P1): resolve the effective chain head and dispatch
161
- * that REAL pair through the host LLM runtime. Throws an explicit
162
- * `LlmError` only when there is no real pair to delegate to: an empty
163
- * effective chain, an undispatchable head (wildcard / malformed /
164
- * self-route — the recursion guard), or a vanished `llm` runtime. The
165
- * runtime normalizes the throw into a terminal error finish chunk, so the
166
- * documented listener-order degradation stays graceful instead of a hard
167
- * outage.
154
+ * The virtual route's entry point (P1, direction C): the session's chain is
155
+ * WALKED (`src/chain-walk.ts`) — the walk owns the pre-attempt guards
156
+ * (`EMPTY_EFFECTIVE_CHAIN` / `UNDISPATCHABLE_EFFECTIVE_HEAD` /
157
+ * `LLM_UNAVAILABLE`, thrown synchronously before any chunk, so the runtime
158
+ * normalizes them exactly like an adapter throw) because only it can resolve
159
+ * WHICH chain the calling session walks: a subagent's role chain is not the
160
+ * root's all-day chain, so a root-only gate here would refuse a child that
161
+ * has a perfectly walkable role chain.
168
162
  */
169
163
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
170
164
  }
@@ -174,7 +168,9 @@ export declare class FallbacksChainAdapter extends LlmAdapter {
174
168
  * @param ctx - the plugin context.
175
169
  * @param readConfig - live config reader (the same `source()` the runtime
176
170
  * reads, so reconcile always sees COMMITTED composed snapshots).
171
+ * @param walk - the chain walk the adapter's `stream()` runs for one virtual
172
+ * call (built by `apply()` over its live state — see `src/chain-walk.ts`).
177
173
  * @returns the reconcile thunk — call it from the settings `onChange` hook
178
174
  * (child activation runs one reconcile on its own).
179
175
  */
180
- export declare function installFallbacksAdapter(ctx: Context, readConfig: () => FallbacksConfig): () => void;
176
+ export declare function installFallbacksAdapter(ctx: Context, readConfig: () => FallbacksConfig, walk: (options: GenerateOptions) => AsyncIterable<StreamChunk>): () => void;
package/docs/DETAIL.md ADDED
@@ -0,0 +1 @@
1
+ ![A request failing on an auth code in the FallbacksChain walk, the next provider/model serving it, the switch log line, and the FallbacksChain / Auto row in the model picker](preview.svg)