dsh-llm-fallbacks 0.2.1 → 0.3.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.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Dispatch-time three-stage role resolution (plan fallbacks-role-automatch
3
+ * Task 2).
4
+ *
5
+ * Precedence (first hit wins):
6
+ * 1. explicit — `session.header.agentPreset` trimmed matches a declared role
7
+ * id → that role's DECLARED RAW id (via the `roleIds` trimmed-id map, the
8
+ * same canonicalization the rules path uses); the reserved `'inherit'`
9
+ * and undeclared presets are NEVER dispatchable explicit roles and fall
10
+ * through to rules;
11
+ * 2. rules — the existing `resolveRole` passthrough, unchanged (a declared
12
+ * role wins; no-rule-match / an explicit rule targeting `'inherit'`
13
+ * resolve to `'inherit'`);
14
+ * 3. auto-match — only when stage 2 resolved to `'inherit'` AND
15
+ * `opts.automatchEnabled`: call `opts.automatch` with the agent. The hook
16
+ * (Task 3 supplies the bounded LLM caller) MUST return a declared raw id
17
+ * or `null`; the resolver defensively validates the return against
18
+ * `roleIds` — an unknown id warns and resolves to `'inherit'`, never an
19
+ * undeclared role. When `automatch` is undefined or `automatchEnabled`
20
+ * is false the hook is skipped.
21
+ *
22
+ * `'inherit'` is the single "no specific role" outcome — reached by
23
+ * no-rule-match OR an explicit rule targeting `'inherit'` — and both are
24
+ * auto-match eligible when enabled. An operator who wants a class to never
25
+ * auto-match sets `roleAutoMatch: false` (→ `automatchEnabled: false`).
26
+ *
27
+ * Pure ordering logic: no config object and no LLM service inside this
28
+ * module — the LLM is an injectable hook owned by Task 3/4.
29
+ *
30
+ * @module dsh-llm-fallbacks/role-resolution
31
+ */
32
+ import { type FallbacksRoleRule } from './config.ts';
33
+ import { type AgentLike } from './roles.ts';
34
+ import type { Selector } from './selectors.ts';
35
+ export type { FallbacksRoleRule } from './config.ts';
36
+ /** Options for {@link resolveRoleAtDispatch}. */
37
+ export interface ResolveRoleAtDispatchOptions {
38
+ /**
39
+ * Whether the auto-match stage may run (the `roleAutoMatch` config switch).
40
+ * When `false`, stage 3 never runs and the outcome is identical to the
41
+ * rules stage alone.
42
+ */
43
+ automatchEnabled: boolean;
44
+ /**
45
+ * Auto-match hook — Task 3 supplies the bounded LLM caller; the resolver
46
+ * itself contains no LLM. Contract: given the agent being resolved, return
47
+ * a declared raw role id or `null`. The resolver defensively validates the
48
+ * return against `roleIds` — an unknown id warns and resolves to
49
+ * `'inherit'`, never an undeclared role. Skipped when undefined.
50
+ */
51
+ automatch?: (agent: AgentLike) => Promise<string | null>;
52
+ /** Warning sink — the decision path injects the plugin logger. */
53
+ warn: (message: string) => void;
54
+ }
55
+ /**
56
+ * Resolve the dispatch-time role for an agent across three ordered stages
57
+ * (explicit → rules → auto-match hook). Never throws on its own inputs; the
58
+ * hook's own rejection is deliberately NOT caught here (the Task-3/4 wiring
59
+ * bounds the LLM call so it never throws out of the request path).
60
+ *
61
+ * `roleIds` is the canonical trimmed-id map (`trimmed id → declared raw id`,
62
+ * built in `src/index.ts` apply()): every stage canonicalizes its reference
63
+ * by trim and returns the DECLARED RAW id, so roleDef lookups in
64
+ * `chains.ts` / `commands.ts` find the stored `roles.list[]` entry exactly
65
+ * (qc2 F-001 — no silent role inertness on padded YAML ids).
66
+ */
67
+ export declare function resolveRoleAtDispatch(agent: AgentLike, rules: FallbacksRoleRule[], roleIds: ReadonlyMap<string, string>, opts: ResolveRoleAtDispatchOptions): Promise<string>;
68
+ /**
69
+ * The dispatch-time chain head (plan fallbacks-role-automatch Task 4): the
70
+ * FIRST candidate of a `resolveChainViews` pass whose `wildcard` flag is
71
+ * false — an exact `provider/model` entry. `provider/*` entries are NOT
72
+ * dispatch injection targets (no failing model to anchor them; a guessed
73
+ * injection risks a hard failure whose code may sit outside `triggerCodes`),
74
+ * so a wildcard-only chain yields `undefined` → no injection (today's
75
+ * behavior). No cooldown/failed filtering and no existence probe run here —
76
+ * a fresh subagent's first request has no failure-scoped state (see the Task
77
+ * 4 decision) and exact heads are never probed. Pairs with
78
+ * {@link resolveChainViews}'s parallel `wildcard` provenance (`src/chains.ts`);
79
+ * the caller resolves the role's concatenated chain first.
80
+ */
81
+ export declare function firstExactCandidate(all: readonly Selector[], wildcard: readonly boolean[]): Selector | undefined;
package/dist/roles.d.ts CHANGED
@@ -2,9 +2,20 @@
2
2
  * Role resolution for fallback chains (spec §7.1; plan fallbacks-role-runtime
3
3
  * Task 1).
4
4
  *
5
+ * This module is the RULES stage. Dispatch-time role resolution
6
+ * (plan fallbacks-role-automatch) is the three-stage resolver in
7
+ * `role-resolution.ts` — explicit (`session.header.agentPreset`) → this
8
+ * rules stage → an optional auto-match hook; `AgentLike` carries the additive
9
+ * `session.header.agentPreset?` carrier for that explicit stage.
10
+ *
5
11
  * Precedence (first hit wins):
6
- * 1. the first `rules` entry whose specified origin/provider/model patterns
7
- * all match the agent;
12
+ * 1. the first `rules` entry whose specified provider/model patterns match
13
+ * the agent — rules are SUBAGENT-ONLY (PR #62 feedback): a root-origin
14
+ * agent never matches rules and resolves straight to the built-in
15
+ * `'inherit'` (→ `rootChain`). The legacy per-rule `origin` field (a
16
+ * pre-feedback config may still carry `origin: root|subagent`) is
17
+ * IGNORED at match time — every rule applies to subagents regardless
18
+ * of the stored origin value;
8
19
  * 2. the built-in `'inherit'` role (no-rule-match default, spec §7.1 / D4).
9
20
  *
10
21
  * A matched rule must target a declared role id (`roleIds`, derived from
@@ -21,8 +32,9 @@
21
32
  * (`' coder '`) with a trimmed rule reference (`'coder'`) resolves to the
22
33
  * same role instead of silently degrading to `'inherit'`.
23
34
  *
24
- * A missing agent origin is treated as `'root'`. Origin is read from
25
- * `session.header.origin` — a native `SessionHeader` field the store folds
35
+ * A missing agent origin is treated as `'root'` — and root agents never
36
+ * match rules (rules are subagent-only, PR #62 feedback). Origin is read
37
+ * from `session.header.origin` — a native `SessionHeader` field the store folds
26
38
  * from `CreateSessionOptions.meta.origin` (`packages/core/session/src/
27
39
  * index.ts:884`); subagent children set it via `childSessionMeta`
28
40
  * (`packages/subagent/subagent/src/child-agent.ts:115`), root agents carry
@@ -36,7 +48,8 @@
36
48
  */
37
49
  import { type FallbacksRoleRule } from './config.ts';
38
50
  export type { FallbacksRoleRule } from './config.ts';
39
- /** Agent origins understood by role rules (spec §3). */
51
+ /** Agent origins (spec §3) — an agent property, not a rule constraint:
52
+ * rules are subagent-only (PR #62 feedback). */
40
53
  export type Origin = 'root' | 'subagent';
41
54
  /** Loose agent shape sufficient for role resolution (spec §3 / brief). */
42
55
  export interface AgentLike {
@@ -47,6 +60,8 @@ export interface AgentLike {
47
60
  session?: {
48
61
  header?: {
49
62
  origin?: Origin;
63
+ /** Dispatch-time explicit role carrier (plan fallbacks-role-automatch Task 2). */
64
+ agentPreset?: string;
50
65
  };
51
66
  };
52
67
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Time-slot rows for `fallbacks` (plan fallbacks-timeslots Task 1, pins
3
+ * P4–P6): frozen preset windows, the official-V4 all-day conformance
4
+ * guard, and the pure `resolveEffectiveChain` / `resolveSlotState`
5
+ * resolver.
6
+ *
7
+ * Pure module — no `@deepseek-ai/*` runtime imports: the client card
8
+ * imports the row types type-only (mirroring `config.ts`) and the runtime
9
+ * wires these helpers at request time (Task 2). Malformed rows NEVER
10
+ * throw: they warn ONCE per row instance (through `console.warn` — the
11
+ * resolver's 3-argument contract has no logger parameter) and are
12
+ * skipped; a legacy non-empty non-conforming `rootChain` keeps the
13
+ * v0.2.2 failure walk verbatim (P6).
14
+ *
15
+ * @module dsh-llm-fallbacks/time-slots
16
+ */
17
+ import type { FallbacksConfig } from './config.ts';
18
+ /** Official V4 models — the ONLY legal all-day selectors (length 1, XOR). */
19
+ export declare const OFFICIAL_V4_FLASH = "deepseek-official/deepseek-v4-flash";
20
+ export declare const OFFICIAL_V4_PRO = "deepseek-official/deepseek-v4-pro";
21
+ /** The four frozen preset ids (exact strings, spec lock). */
22
+ export declare const PRESET_IDS: readonly ["liang-peak", "liang-valley", "glm-peak", "glm-valley"];
23
+ export type PresetId = (typeof PRESET_IDS)[number];
24
+ /** A frozen UTC+8 window: `start ≤ t < end`, wrap-midnight when `start > end`. */
25
+ export interface SlotWindow {
26
+ /** Window start, `HH:mm` (24h). */
27
+ start: string;
28
+ /** Window end, `HH:mm` — EXCLUSIVE (a window contains `t` iff the day
29
+ * matches AND (`start ≤ end` ? `start ≤ t < end` : `start ≤ t || t < end`)). */
30
+ end: string;
31
+ /** Day mask: 0=Sunday … 6=Saturday. Omitted/empty = every day. */
32
+ days?: number[];
33
+ }
34
+ /** One extra time-slot row (P4 storage shape; `chain` is always editable). */
35
+ export interface SlotRowConfig {
36
+ kind: 'preset' | 'custom';
37
+ /** Preset id — required for `kind: 'preset'`; windows live in
38
+ * {@link PRESETS}, never stored. */
39
+ preset?: PresetId;
40
+ /** Custom-only: window start `HH:mm`. */
41
+ start?: string;
42
+ /** Custom-only: window end `HH:mm` (exclusive; may wrap midnight). */
43
+ end?: string;
44
+ /** Custom-only: day mask 0=Sunday…6=Saturday; omitted/empty = all days. */
45
+ days?: number[];
46
+ /** Custom-only display name (PR #62 feedback round, collapsed rows). */
47
+ name?: string;
48
+ /** Models for this row (editable even on preset rows). */
49
+ chain: string[];
50
+ }
51
+ /** Frozen preset definition (P4): windows are code constants, never stored. */
52
+ export interface PresetDefinition {
53
+ windows: readonly SlotWindow[];
54
+ /** `true` = matches iff the peak windows do NOT match (valley derives
55
+ * from its peak — no duplicated window enumerations that can drift). */
56
+ complement: boolean;
57
+ /** Display label for the status strip / settings card. */
58
+ label: string;
59
+ }
60
+ /**
61
+ * Frozen preset windows (UTC+8). `liang-*` presets have NO day mask (they
62
+ * apply every day, weekends included); `glm-peak` is Monday–Friday only.
63
+ * The two valleys are `complement: true` of their peak.
64
+ */
65
+ export declare const PRESETS: Record<PresetId, PresetDefinition>;
66
+ /**
67
+ * All-day conformance (P6): the all-day chain is conforming when its LAST
68
+ * entry (the tail — the card's 默认模型 panel) is exactly one official V4
69
+ * model — Flash XOR Pro. Leading entries (the card's 默认降级链 block) are
70
+ * the ordered walk before that last-resort fallback. An empty chain or a
71
+ * chain whose tail is not an official V4 model keeps slot rows inert and
72
+ * refuses the virtual-row override/delegate; the v0.2.2 failure walk over
73
+ * the raw chain stays verbatim.
74
+ */
75
+ export declare function isAllDayConforming(chain: readonly string[]): boolean;
76
+ /**
77
+ * Effective chain for a root request at `now` (P5): the FIRST extra row
78
+ * whose descriptor contains `now` (stored order) — that row's `chain`
79
+ * REPLACES the all-day chain (never concatenated); no match ⇒ `rootChain`
80
+ * (the all-day row, always last and required). Malformed rows warn once
81
+ * and are skipped; never throws.
82
+ *
83
+ * P6 (qc1 F-001): without a conforming all-day the slot rows are inert, so
84
+ * this IS the raw `rootChain` — the gate lives in {@link resolveSlotState},
85
+ * the single source every slot surface (this, the 分时切换 log, the
86
+ * `/fallbacks` strip, select-is-primary, the virtual adapter delegate) reads.
87
+ */
88
+ export declare function resolveEffectiveChain(config: FallbacksConfig, now: Date, tz: string): string[];
89
+ /**
90
+ * Slot winner + display label (P5): drives 分时切换 detection (per-root-agent
91
+ * last-winner marker, in-process only) and the card / `/fallbacks` status
92
+ * strip. `winner` is the matching row or `'all-day'`; `label` names the
93
+ * slot (frozen preset label or `custom HH:mm-HH:mm`).
94
+ *
95
+ * P6 gate (qc1 F-001): without a conforming all-day
96
+ * (`isAllDayConforming(config.rootChain)`) the winner is ALWAYS `'all-day'`
97
+ * — a legacy multi-model (or empty) chain earns no slot rows, so every
98
+ * surface fed by this resolver reports the inert state and routing stays on
99
+ * the raw `rootChain` (the v0.2.2 walk verbatim).
100
+ */
101
+ export declare function resolveSlotState(config: FallbacksConfig, now: Date, tz: string): {
102
+ winner: SlotRowConfig | 'all-day';
103
+ label: string;
104
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Virtual `FallbacksChain/Auto` LLM adapter (plan fallbacks-virtual-chain
3
+ * Task 1, technical pins P1–P3; PR #62 feedback round): a mount-only
4
+ * catalog row that makes the configured effective chain selectable as the
5
+ * session **primary** in the host model picker, without patching dsh or
6
+ * dsh-TUI (web and TUI share the adapter catalog).
7
+ *
8
+ * Wiring (P2): `installFallbacksAdapter` installs ONE conditional
9
+ * `ctx.inject(['llm'])` child — absent `llm` service (test harness) is a
10
+ * clean no-op, and fiber unload ⇒ the child's disposer unregisters the
11
+ * route. Registration is an idempotent transition-reconcile on COMMITTED
12
+ * config snapshots: register on `enabled` false→true, unregister on
13
+ * true→false, driven by the settings `onChange` hook (the returned
14
+ * reconcile thunk, wired by `apply()`) plus child activation. The row is
15
+ * visible whenever the plugin is enabled — a non-conforming all-day chain
16
+ * does NOT hide it (PR #62 feedback); conformance still gates a
17
+ * successful override/delegate (`effectiveHeadOf` below refuses a
18
+ * non-conforming all-day). The condition deliberately ignores `timeSlots`
19
+ * and conformance, so slot-row edits and chain edits never churn
20
+ * registration.
21
+ *
22
+ * Adapter behavior (P1/P3): `listModels` advertises exactly the one virtual
23
+ * row; `stream()` is a THIN single-hop delegate to the effective chain head
24
+ * through the host LLM runtime — no chain walk, no cooldown/caps/revert
25
+ * bookkeeping, no state writes (a failure inside the delegate surfaces at
26
+ * `agent/request-error`, where the existing engine walks from there).
27
+ * `resolveModel` proxies the current effective head's metadata when
28
+ * resolvable (modalities/context-window/reasoning follow the head) with a
29
+ * permissive default otherwise — never throws.
30
+ *
31
+ * @module dsh-llm-fallbacks/virtual-adapter
32
+ */
33
+ import type { Context } from '@deepseek-ai/cordis';
34
+ import type { FallbacksConfig } from './config.ts';
35
+ /** Provider route of the virtual adapter (exact string, spec lock). */
36
+ export declare const FALLBACKS_PROVIDER = "FallbacksChain";
37
+ /**
38
+ * Model id of the virtual catalog row (exact string, spec lock). "Auto" is
39
+ * the hardcoded picker id (not i18n — user decision 2026-08-18). The
40
+ * catalog `name` the host picker renders is dynamic — see
41
+ * {@link pickerDisplayName} (`Auto: <model>[<slot>]`).
42
+ */
43
+ export declare const FALLBACKS_CHAIN_MODEL = "Auto";
44
+ /**
45
+ * `LlmError` code: the effective chain is empty — the virtual route has no
46
+ * head to delegate to (P1 guard).
47
+ */
48
+ export declare const EMPTY_EFFECTIVE_CHAIN_CODE = "EMPTY_EFFECTIVE_CHAIN";
49
+ /**
50
+ * `LlmError` code: the effective chain head is not a dispatchable real pair
51
+ * (non-conforming all-day, wildcard selector, malformed selector, or a
52
+ * self-route back to `FallbacksChain/*` — the P1 recursion guard).
53
+ */
54
+ export declare const UNDISPATCHABLE_HEAD_CODE = "UNDISPATCHABLE_EFFECTIVE_HEAD";
55
+ /** `LlmError` code (defensive): the `llm` runtime disappeared mid-flight. */
56
+ export declare const LLM_UNAVAILABLE_CODE = "LLM_UNAVAILABLE";
57
+ /** One dispatchable exact head: `provider/model`. Wildcards are never seeds (P3). */
58
+ export interface EffectiveHead {
59
+ provider: string;
60
+ model: string;
61
+ }
62
+ /**
63
+ * The FIRST DISPATCHABLE exact head of a chain — the single definition of
64
+ * "effective head" (F-001) shared by the root select-is-primary override
65
+ * (`src/index.ts`) and the virtual adapter's delegate paths. Walks the SAME
66
+ * chain `resolveEffectiveChain` produces, skipping entries that can never
67
+ * be dispatched: malformed selectors (config-warning path), `provider/*`
68
+ * `provider/*` wildcards (no real pair), and self-routes back to
69
+ * `FallbacksChain/*` (the P1 recursion guard). `undefined` when the chain
70
+ * is empty or no entry is dispatchable.
71
+ */
72
+ export declare function firstDispatchableExactHead(chain: readonly string[]): EffectiveHead | undefined;
73
+ /**
74
+ * Host picker label: `Auto: <displayName>[<slot>]`.
75
+ * Display name first so the trigger stays readable and platforms stay
76
+ * distinguishable (catalog `name`, not the model id). Bare `Auto` when
77
+ * there is no dispatchable head. Slot label from {@link resolveSlotState}.
78
+ * @param modelDisplayName - catalog/resolved name; falls back to the id.
79
+ */
80
+ export declare function pickerDisplayName(config: FallbacksConfig, now?: Date, modelDisplayName?: string): string;
81
+ /**
82
+ * Install the virtual adapter registration lifecycle (P2).
83
+ *
84
+ * @param ctx - the plugin context.
85
+ * @param readConfig - live config reader (the same `source()` the runtime
86
+ * reads, so reconcile always sees COMMITTED composed snapshots).
87
+ * @returns the reconcile thunk — call it from the settings `onChange` hook
88
+ * (child activation runs one reconcile on its own).
89
+ */
90
+ export declare function installFallbacksAdapter(ctx: Context, readConfig: () => FallbacksConfig): () => void;
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "dsh",
6
6
  "dsh-plugin"
7
7
  ],
8
- "version": "0.2.1",
8
+ "version": "0.3.0",
9
9
  "type": "module",
10
10
  "main": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts",
@@ -48,6 +48,7 @@
48
48
  "build-client": "tsx scripts/build-client.ts",
49
49
  "release:prepare": "tsx scripts/prepare-release.ts",
50
50
  "release:validate": "tsx scripts/validate-release-version.ts",
51
+ "repair:fallbacks-switch-logs": "tsx scripts/repair-fallbacks-switch-logs.ts",
51
52
  "test": "vitest run",
52
53
  "typecheck": "tsc -p tsconfig.build.json --noEmit && tsc --noEmit",
53
54
  "prepare": "pnpm run build"
@@ -57,24 +58,24 @@
57
58
  },
58
59
  "peerDependencies": {
59
60
  "@deepseek-ai/cordis": "^4.0.1",
60
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
61
- "@deepseek-ai/dsh-api-gateway": "^0.1.0-rc.6",
62
- "@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6",
63
- "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6",
64
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
65
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
66
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.0-rc.6",
67
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
68
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
69
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
70
- "@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
71
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
72
- "@deepseek-ai/dsh-commands": "^0.1.0-rc.6",
73
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
74
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
75
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
76
- "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6",
77
- "@deepseek-ai/dsh-typert-registry": "^0.1.0-rc.6",
61
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.7",
62
+ "@deepseek-ai/dsh-api-gateway": "^0.1.0-rc.7",
63
+ "@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.7",
64
+ "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.7",
65
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
66
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
67
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.0-rc.7",
68
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.7",
69
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.7",
70
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
71
+ "@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.7",
72
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.7",
73
+ "@deepseek-ai/dsh-commands": "^0.1.0-rc.7",
74
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
75
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.7",
76
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
77
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.7",
78
+ "@deepseek-ai/dsh-typert-registry": "^0.1.0-rc.7",
78
79
  "react": "^18.2.0",
79
80
  "@deepseek-ai/schemastery": "^3.18.1"
80
81
  },