dsh-llm-fallbacks 0.1.0-alpha.1

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,70 @@
1
+ /**
2
+ * Role resolution for fallback chains (spec §7.1; plan fallbacks-role-runtime
3
+ * Task 1).
4
+ *
5
+ * Precedence (first hit wins):
6
+ * 1. the first `rules` entry whose specified origin/provider/model patterns
7
+ * all match the agent;
8
+ * 2. the built-in `'inherit'` role (no-rule-match default, spec §7.1 / D4).
9
+ *
10
+ * A matched rule must target a declared role id (`roleIds`, derived from
11
+ * `config.roles.list`) or the built-in `'inherit'` — an undeclared target
12
+ * warns and falls back to `'inherit'` (defensive; startup validation already
13
+ * flagged the reference, spec §7.1 / AC-4).
14
+ *
15
+ * Role ids are canonicalized by trim (qc2 F-001, client-canonical trim
16
+ * alignment — `validateFallbacksConfig` trims ids and rule references, so
17
+ * the runtime must too): `roleIds` maps a TRIMMED id to the DECLARED RAW
18
+ * id, membership compares `rule.role.trim()`, and a matched rule returns
19
+ * the declared raw id — so roleDef lookups in `chains.ts` / `commands.ts`
20
+ * match the stored `roles.list[].id` exactly and a padded YAML id
21
+ * (`' coder '`) with a trimmed rule reference (`'coder'`) resolves to the
22
+ * same role instead of silently degrading to `'inherit'`.
23
+ *
24
+ * A missing agent origin is treated as `'root'`. Origin is read from
25
+ * `session.header.origin` — a native `SessionHeader` field the store folds
26
+ * from `CreateSessionOptions.meta.origin` (`packages/core/session/src/
27
+ * index.ts:884`); subagent children set it via `childSessionMeta`
28
+ * (`packages/subagent/subagent/src/child-agent.ts:115`), root agents carry
29
+ * none. A subagent `persona` is NOT readable at the decision point:
30
+ * `AgentOptions` is provider/model/maxTokens only, and persona is installed
31
+ * as a scoped system-prompt section in the child's creation window — see
32
+ * `.mstar/iterations/iter-20260811-fallbacks-mount-only/guides/
33
+ * role-and-model-selection-exploration.md` (Role section).
34
+ *
35
+ * @module dsh-llm-fallbacks/roles
36
+ */
37
+ import { type FallbacksRoleRule } from './config.ts';
38
+ export type { FallbacksRoleRule } from './config.ts';
39
+ /** Agent origins understood by role rules (spec §3). */
40
+ export type Origin = 'root' | 'subagent';
41
+ /** Loose agent shape sufficient for role resolution (spec §3 / brief). */
42
+ export interface AgentLike {
43
+ options?: {
44
+ provider?: string;
45
+ model?: string;
46
+ };
47
+ session?: {
48
+ header?: {
49
+ origin?: Origin;
50
+ };
51
+ };
52
+ }
53
+ /**
54
+ * Resolve the fallback-chain role for an agent: first matching rule (in
55
+ * listed order) → the built-in `'inherit'` role. A rule targeting an id
56
+ * outside the declared-id set ∪ {'inherit'} warns once and resolves to
57
+ * `'inherit'` (the defensive path — `validateFallbacksConfig` already
58
+ * warned at startup, spec §7.1).
59
+ *
60
+ * `roleIds` is the canonical trimmed-id map built by the wiring
61
+ * (`trimmed id → declared raw id`, see `src/index.ts` apply()): a matched
62
+ * rule returns the declared RAW id so the roleDef lookups in
63
+ * `chains.ts` / `commands.ts` find the stored `roles.list[]` entry exactly
64
+ * (qc2 F-001 — no silent rule inertness on padded YAML ids).
65
+ *
66
+ * `warn` defaults to `console.warn` (direct-call compatibility); the
67
+ * decision path injects the cordis `logger.warn` so every plugin warning
68
+ * flows through the plugin log namespace (qc2 F-002 / qc3 S-3).
69
+ */
70
+ export declare function resolveRole(agent: AgentLike, rules: FallbacksRoleRule[], roleIds: ReadonlyMap<string, string>, warn?: (message: string) => void): string;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Selector parsing for `fallbacks` chains (spec §4, plan Task 2).
3
+ *
4
+ * Grammar: `provider/model` (exact) and `provider/*` (wildcard — the parsed
5
+ * `model` is `undefined`). Illegal selectors throw {@link SelectorError} —
6
+ * the catchable "config warning" path; warn-and-continue lives in Task 3.
7
+ * These modules never crash on their own.
8
+ *
9
+ * @module dsh-llm-fallbacks/selectors
10
+ */
11
+ /** A parsed selector: `provider` + optional `model` (`undefined` = wildcard). */
12
+ export interface Selector {
13
+ provider: string;
14
+ model?: string;
15
+ /** Original selector string, kept for diagnostics/logging. */
16
+ raw: string;
17
+ }
18
+ /** Catchable error for illegal/unknown selectors (config-warning path). */
19
+ export declare class SelectorError extends Error {
20
+ constructor(message: string);
21
+ }
22
+ /** Canonical key: `provider/model`, or `provider/*` for a wildcard model. */
23
+ export declare function selectorKey(provider: string, model?: string): string;
24
+ /**
25
+ * Parse a chain key or entry selector.
26
+ *
27
+ * Accepts `provider/model` and `provider/*`; throws {@link SelectorError}
28
+ * on anything else (missing separator, empty parts, extra separators).
29
+ */
30
+ export declare function parseSelector(input: string): Selector;
31
+ /**
32
+ * Wildcard-entry resolution: keep the failing model id, swap only the
33
+ * provider (`provider/*` entry semantics, spec §2 clause 2).
34
+ */
35
+ export declare function resolveWildcardEntry(failingModel: string, provider: string): Selector;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Per-agent fallback state machine (spec §5.1; plan Task 3).
3
+ *
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
+ * 无残留状态).
8
+ *
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.
27
+ *
28
+ * @module dsh-llm-fallbacks/state
29
+ */
30
+ import type { FallbackSwitchReason } from './events.ts';
31
+ import { CooldownStore, StepFailureSet } from './cooldown.ts';
32
+ /** Decision awaiting application at the next `agent/request` boundary (spec §5.1). */
33
+ export interface PendingSwitch {
34
+ from: {
35
+ provider: string;
36
+ model: string;
37
+ };
38
+ to: {
39
+ provider: string;
40
+ model: string;
41
+ };
42
+ role: string;
43
+ reason: FallbackSwitchReason;
44
+ }
45
+ /** The current (turn, step)'s failed-model set and switch budget (spec §5.1). */
46
+ export interface StepFailures {
47
+ turn: number;
48
+ step: number;
49
+ /** `${provider}/${model}` keys failed in this step (double suppression with cooldown). */
50
+ failed: StepFailureSet;
51
+ /** Switches committed in this step; the caller judges it against `maxSwitchesPerStep`. */
52
+ switchCount: number;
53
+ }
54
+ /** One agent's whole fallback runtime state (spec §5.1). */
55
+ export interface AgentFallbackState {
56
+ /** Decision produced but not yet applied at an `agent/request` boundary. */
57
+ pendingSwitch?: PendingSwitch;
58
+ /** The (turn, step) the last pending switch was applied to — anti-replay. */
59
+ appliedTurnStep?: {
60
+ turn: number;
61
+ step: number;
62
+ };
63
+ /** Current (turn, step)'s failure bookkeeping. */
64
+ stepFailures: StepFailures;
65
+ /** `provider/model → expiry epoch ms`; lazily expired on read. */
66
+ cooldown: CooldownStore;
67
+ }
68
+ /**
69
+ * The `Map<agent.id, AgentFallbackState>` store plus the state-machine
70
+ * operations (spec §5.1).
71
+ */
72
+ export declare class FallbackStateStore {
73
+ private readonly states;
74
+ /** Number of tracked agents. */
75
+ get size(): number;
76
+ has(agentId: string): boolean;
77
+ /** Read without creating; `undefined` for unknown agents. */
78
+ peek(agentId: string): AgentFallbackState | undefined;
79
+ /** Read, creating an empty state on first sight. */
80
+ get(agentId: string): AgentFallbackState;
81
+ /** Remove one agent's state (`agent/disposed`). */
82
+ delete(agentId: string): void;
83
+ /** Remove every agent's state (plugin dispose — no residual state). */
84
+ clear(): void;
85
+ /** Reset step-scoped bookkeeping when (turn, step) advances (spec §5.1). */
86
+ syncStep(state: AgentFallbackState, turn: number, step: number): void;
87
+ /** Record the current model as failed in this step (spec §5.1). */
88
+ recordFailure(state: AgentFallbackState, key: string): void;
89
+ /** Bump the step's switch budget (spec §5.1). */
90
+ recordSwitch(state: AgentFallbackState): void;
91
+ /**
92
+ * Produce a pending switch. A fresh decision always supersedes a previous
93
+ * one and must be applicable at the current (turn, step), so the applied
94
+ * marker is cleared here (see module doc — chains).
95
+ */
96
+ writePending(state: AgentFallbackState, pending: PendingSwitch): void;
97
+ /**
98
+ * Apply the pending switch at (turn, step) when one exists and has not
99
+ * already been applied there; records the applied marker and clears the
100
+ * pending switch (spec §5.1 lifecycle step 2).
101
+ */
102
+ applyPending(state: AgentFallbackState, turn: number, step: number): PendingSwitch | undefined;
103
+ /**
104
+ * Defensive `agent/status` idle cleanup: drop per-step state. The time-boxed
105
+ * cooldown survives so `cooldown-expiry` revert works across turns (US-4).
106
+ */
107
+ clearStepState(state: AgentFallbackState): void;
108
+ /** Suppress `key` until `untilEpochMs` (`Infinity` for `revertPolicy: 'never'`). */
109
+ suppress(state: AgentFallbackState, key: string, untilEpochMs: number): void;
110
+ /** Lazy cooldown read: expired entries are dropped on read (spec §5.1). */
111
+ isSuppressed(state: AgentFallbackState, key: string, now?: number): boolean;
112
+ }
package/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "dsh-llm-fallbacks",
3
+ "description": "Automatic provider/model fallback chains for DeepSeek Harness agents when LLM requests keep failing (retry exhausted, auth, quota, rate limit)",
4
+ "keywords": [
5
+ "dsh",
6
+ "dsh-plugin"
7
+ ],
8
+ "version": "0.1.0-alpha.1",
9
+ "type": "module",
10
+ "main": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./client": {
18
+ "types": "./dist/client/index.d.ts",
19
+ "default": "./dist/client/index.js"
20
+ },
21
+ "./bundle/cordis.patch.yml": "./bundle/cordis.patch.yml",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "bundle",
27
+ "scripts"
28
+ ],
29
+ "dsh": {
30
+ "bundle": {
31
+ "patch": "./bundle/cordis.patch.yml"
32
+ },
33
+ "client": {
34
+ "inject": [
35
+ "@deepseek-ai/dsh-client-runtime",
36
+ "@deepseek-ai/dsh-client-ui-settings-plugins",
37
+ "@deepseek-ai/dsh-client-ui-settings",
38
+ "@deepseek-ai/dsh-client-ui-conversation",
39
+ "@deepseek-ai/dsh-client-locale",
40
+ "@deepseek-ai/dsh-client-connection",
41
+ "@deepseek-ai/dsh-api-remotes"
42
+ ],
43
+ "platform": "web"
44
+ }
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.build.json && tsdown && pnpm run build-client && tsc && node scripts/verify-dist.mjs",
48
+ "build-client": "tsx scripts/build-client.ts",
49
+ "test": "vitest run",
50
+ "prepare": "pnpm run build"
51
+ },
52
+ "engines": {
53
+ "node": ">=22"
54
+ },
55
+ "peerDependencies": {
56
+ "@deepseek-ai/cordis": "^4.0.1",
57
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
58
+ "@deepseek-ai/dsh-api-gateway": "^0.1.0-rc.6",
59
+ "@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6",
60
+ "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6",
61
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
62
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
63
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.0-rc.6",
64
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
65
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
66
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
67
+ "@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
68
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
69
+ "@deepseek-ai/dsh-commands": "^0.1.0-rc.6",
70
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
71
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
72
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6",
73
+ "@deepseek-ai/dsh-typert-registry": "^0.1.0-rc.6",
74
+ "react": "^18.2.0"
75
+ },
76
+ "devDependencies": {
77
+ "@testing-library/dom": "^10.4.1",
78
+ "@testing-library/react": "^16.3.2",
79
+ "@types/node": "^22.20.0",
80
+ "@types/react": "~18.3.31",
81
+ "@types/react-dom": "~18.3.7",
82
+ "jsdom": "29.1.1",
83
+ "react": "^18.3.1",
84
+ "react-dom": "^18.3.1",
85
+ "schemastery": "^3.18.0",
86
+ "tsdown": "^0.22.2",
87
+ "tsx": "^4.22.4",
88
+ "typescript": "^5.9.3",
89
+ "vitest": "^4.1.8"
90
+ }
91
+ }