agent-lyceum 0.18.0 → 0.20.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/README.md CHANGED
@@ -11,6 +11,7 @@ Give a task to a **team of agents** (Claude Code and/or Codex) instead of one ov
11
11
  - **Roles that stay in role.** Each agent has its own persona (`AGENT.md`) and long-term memory, so a reviewer keeps reviewing instead of drifting into writing code.
12
12
  - **Agents can't rewrite the rules.** An agent can only write its own memory and outbox. It can't touch other agents' context or its own persona, and this is enforced by the OS sandbox where possible, not just by a prompt. See [Write scope](docs/write-scope.md).
13
13
  - **Mix runtimes.** Claude Code and Codex agents work in the same team.
14
+ - **Backup models.** If a model is rate-limited, out of quota or unreachable, a run can switch to a backup model or runtime instead of stopping. See [Configuration](docs/configuration.md#fallback-models).
14
15
  - **Nothing hidden.** Agents talk through plain-file mailboxes, so every message, decision and memory is a file you can read, grep and diff. The dispatcher never merges for you.
15
16
 
16
17
  **Not a fit** for small, one-shot edits: a single Claude Code or Codex session is faster and cheaper. Use a team when the task is big enough to split, review or run in parallel.
package/README.zh-TW.md CHANGED
@@ -11,6 +11,7 @@
11
11
  - **角色不會跑掉。** 每個 agent 有自己的人設(`AGENT.md`)與長期記憶,reviewer 會一直做 review,不會漸漸變成在寫程式。
12
12
  - **agent 改不了規則。** agent 只能寫自己的記憶與寄件匣(outbox),不能改其他 agent 的 context,也不能改自己的人設;能做到的地方由 OS 沙箱強制,而不只靠 prompt。見 [Write scope](docs/write-scope.zh-TW.md)。
13
13
  - **混用 runtime。** Claude Code 與 Codex agent 可在同一個團隊。
14
+ - **備用模型。** 模型被限流、額度用完或連不上時,run 可以改用備用的模型或 runtime 繼續,而不是直接停下。見[設定](docs/configuration.zh-TW.md#備援模型)。
14
15
  - **沒有黑箱。** agent 透過純檔案信箱溝通,每則訊息、決策與記憶都是你能讀、能 grep、能 diff 的檔案;dispatcher 不會替你 merge。
15
16
 
16
17
  **不適合**小而單次的修改:單一 Claude Code 或 Codex session 更快更省。任務大到值得拆分、review 或平行執行時,再用團隊。
@@ -1,7 +1,19 @@
1
1
  import type { ResolvedAgent, ResolvedProject } from "../config.js";
2
+ import type { ErrorKind } from "../schema.js";
2
3
  import type { Invocation, WakeInput } from "./types.js";
3
4
  export declare function buildClaudeSettings(project: ResolvedProject, agent: ResolvedAgent): Record<string, unknown>;
4
5
  export declare function buildClaudeInvocation(input: WakeInput): Invocation;
6
+ /**
7
+ * The text the rules read: the result message (only when it is an error message: pass `result` for an `is_error` run), stderr, and the HTTP status
8
+ * the CLI reports as `api_error_status`. With no `result` and no result in stdout, stdout itself, but only when it is plain text: structured
9
+ * output (starting with `{` or `[`) without a result is a transcript, and the agent's own words there must not read as an error.
10
+ */
11
+ export declare function classifyClaudeError(out: {
12
+ code: number | null;
13
+ stdout: string;
14
+ stderr: string;
15
+ result?: string;
16
+ }): ErrorKind;
5
17
  interface ClaudeResult {
6
18
  result?: string;
7
19
  session_id?: string;
@@ -88,9 +88,45 @@ export function buildClaudeInvocation(input) {
88
88
  stream: () => claudeStream(),
89
89
  };
90
90
  }
91
+ /**
92
+ * Ordered, first match wins: put the more specific kind before the broader one (a usage limit is `quota`, not `rate_limit`).
93
+ * Samples: observed = captured from a real run, documented = from the CLI/API docs, not yet seen in a log.
94
+ */
95
+ const CLAUDE_RULES = [
96
+ // observed: `Invalid API key · Fix external API key` with api_error_status 401; documented: `Not logged in · Please run /login`
97
+ { kind: "auth", re: /\bHTTP 401\b|invalid api key|not logged in|please run \/login/i },
98
+ // documented: `5-hour limit reached ∙ resets 3pm`, API 400 `Credit balance is too low`
99
+ { kind: "quota", re: /\b(5-hour|weekly|usage) limit reached\b|credit balance is too low/i },
100
+ // documented: API 429 `rate_limit_error`, 529 `overloaded_error`
101
+ { kind: "rate_limit", re: /\bHTTP (429|529)\b|rate_limit_error|rate limit (reached|exceeded)|overloaded_error|^api error: overloaded/im },
102
+ // observed: `API Error: Connection refused — ... (ECONNREFUSED)`; documented: the other Node error codes
103
+ { kind: "network", re: /ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|socket hang up/i },
104
+ // documented: a native crash prints one of these (a signal kill has no exit code, see `isSignalKill`)
105
+ { kind: "crash", re: /segmentation fault|heap out of memory/i },
106
+ ];
107
+ /** A process that ended without an exit code was killed by a signal that was not ours (timeouts and cancels never get here). */
108
+ const isSignalKill = (code) => code === null;
109
+ function classifyWith(rules, code, text) {
110
+ const hit = rules.find((r) => r.re.test(text));
111
+ if (hit)
112
+ return hit.kind;
113
+ return isSignalKill(code) ? "crash" : "unknown"; // never default to crash: a wrong guess would switch models for nothing
114
+ }
115
+ /**
116
+ * The text the rules read: the result message (only when it is an error message: pass `result` for an `is_error` run), stderr, and the HTTP status
117
+ * the CLI reports as `api_error_status`. With no `result` and no result in stdout, stdout itself, but only when it is plain text: structured
118
+ * output (starting with `{` or `[`) without a result is a transcript, and the agent's own words there must not read as an error.
119
+ */
120
+ export function classifyClaudeError(out) {
121
+ const status = /"api_error_status":\s*(\d{3})/.exec(out.stdout)?.[1];
122
+ const j = extractClaudeResult(out.stdout);
123
+ const structured = /^[[{]/.test(out.stdout.trimStart());
124
+ const message = out.result ?? (j ? (j.is_error ? String(j.result ?? "") : "") : structured ? "" : out.stdout);
125
+ return classifyWith(CLAUDE_RULES, out.code, [message, out.stderr, status ? `HTTP ${status}` : ""].join("\n"));
126
+ }
91
127
  function interpretClaude(j, stdout, stderr, code) {
92
128
  if (!j)
93
- return { ok: false, text: stdout, error: (stderr || stdout || `exit code ${code}`).slice(0, 500) };
129
+ return { ok: false, text: stdout, error: (stderr || stdout || `exit code ${code}`).slice(0, 500), kind: classifyClaudeError({ code, stdout, stderr }) };
94
130
  const ok = code === 0 && !j.is_error;
95
131
  return {
96
132
  ok,
@@ -98,6 +134,7 @@ function interpretClaude(j, stdout, stderr, code) {
98
134
  sessionId: j.session_id,
99
135
  outputTokens: j.usage?.output_tokens,
100
136
  error: ok ? undefined : String(j.result ?? stderr).slice(0, 500),
137
+ kind: ok ? undefined : classifyClaudeError({ code, stdout, stderr, result: j.is_error && j.result !== undefined ? String(j.result) : undefined }),
101
138
  };
102
139
  }
103
140
  /** Reads `--output-format json` output line by line: only JSON lines are kept, and only the last few, so a flood of other output costs nothing. */
@@ -1,5 +1,12 @@
1
+ import type { ErrorKind } from "../schema.js";
1
2
  import type { Invocation, WakeInput } from "./types.js";
2
3
  export declare function codexWritableRoots(input: WakeInput): string[];
3
4
  /** Sum `output_tokens` over `turn.completed` events in `codex exec --json` output. */
4
5
  export declare function codexOutputTokens(jsonl: string): number | undefined;
5
6
  export declare function buildCodexInvocation(input: WakeInput): Invocation;
7
+ export declare function classifyCodexError(out: {
8
+ code: number | null;
9
+ stdout: string;
10
+ stderr: string;
11
+ result?: string;
12
+ }): ErrorKind;
@@ -100,6 +100,46 @@ export function buildCodexInvocation(input) {
100
100
  },
101
101
  };
102
102
  }
103
+ /**
104
+ * Ordered, first match wins: put the more specific kind before the broader one (an exhausted quota also arrives as a 429).
105
+ * Samples: observed = captured from a real `codex exec`, documented = from the CLI/API docs, not yet seen in a log.
106
+ */
107
+ const CODEX_RULES = [
108
+ // documented: `You exceeded your current quota` / `insufficient_quota`, ChatGPT plans: `You've hit your usage limit`
109
+ { kind: "quota", re: /insufficient_quota|exceeded your current quota|hit your usage limit/i },
110
+ // documented: `exceeded retry limit, last status: 429 Too Many Requests`, `rate_limit_exceeded`
111
+ { kind: "rate_limit", re: /\bstatus:? 429\b|\b429 too many requests|rate_limit_exceeded|rate limit reached/i },
112
+ // observed: `unexpected status 401 Unauthorized: Incorrect API key provided ... invalid_api_key`; documented: `Not logged in`
113
+ { kind: "auth", re: /\bstatus:? 401\b|\b401 unauthorized|invalid_api_key|incorrect api key|not logged in|codex login/i },
114
+ // `Reconnecting... waiting for network (Connection failed: error sending request)` is observed as a retry event only (a real codex with a dead
115
+ // network never ends by itself: the runner times it out); a final-failure sample is documented-not-observed, as are DNS / Node error codes
116
+ { kind: "network", re: /waiting for network|error sending request|dns error|ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT/i },
117
+ { kind: "crash", re: /segmentation fault|panicked at/i },
118
+ ];
119
+ /** The message of the last `turn.failed` event: the final verdict, as opposed to the reconnect noise before it. */
120
+ function failureMessage(stdout) {
121
+ for (const line of stdout.split("\n").reverse()) {
122
+ if (!line.includes('"turn.failed"'))
123
+ continue;
124
+ try {
125
+ const m = JSON.parse(line).error?.message;
126
+ if (typeof m === "string")
127
+ return m;
128
+ }
129
+ catch {
130
+ /* cut off by the tail window: not JSON */
131
+ }
132
+ }
133
+ return undefined;
134
+ }
135
+ export function classifyCodexError(out) {
136
+ // Only error channels are read: the final turn.failed message, else stderr. stdout also carries command output and the agent's own words.
137
+ const text = out.result ?? failureMessage(out.stdout) ?? out.stderr;
138
+ const hit = CODEX_RULES.find((r) => r.re.test(text));
139
+ if (hit)
140
+ return hit.kind;
141
+ return out.code === null ? "crash" : "unknown"; // killed by a signal; otherwise never guess crash
142
+ }
103
143
  function finishCodex(lastFile, sessionId, outputTokens, stdout, stderr, code) {
104
144
  let text = "";
105
145
  try {
@@ -109,5 +149,6 @@ function finishCodex(lastFile, sessionId, outputTokens, stdout, stderr, code) {
109
149
  /* no last message */
110
150
  }
111
151
  const ok = code === 0;
112
- return { ok, text, sessionId, outputTokens, error: ok ? undefined : (stderr || stdout || `exit code ${code}`).slice(-500) };
152
+ // `error` is the last 500 chars of stderr||stdout; `kind` comes from the classifier's own, narrower source preference
153
+ return { ok, text, sessionId, outputTokens, error: ok ? undefined : (stderr || stdout || `exit code ${code}`).slice(-500), kind: ok ? undefined : classifyCodexError({ code, stdout, stderr }) };
113
154
  }
@@ -1,3 +1,4 @@
1
+ import type { ErrorKind } from "../schema.js";
1
2
  import type { ResolvedAgent, ResolvedProject } from "../config.js";
2
3
  import type { AgentWorkspace } from "../worktree.js";
3
4
  export interface WakeInput {
@@ -26,6 +27,8 @@ export interface WakeResult {
26
27
  error?: string;
27
28
  /** Stopped because the run was cancelled, not because it failed. */
28
29
  cancelled?: boolean;
30
+ /** Why it failed, for the fallback decision. Unset on success and on cancellation. */
31
+ kind?: ErrorKind;
29
32
  }
30
33
  export type Parsed = {
31
34
  ok: boolean;
@@ -33,6 +36,8 @@ export type Parsed = {
33
36
  sessionId?: string;
34
37
  outputTokens?: number;
35
38
  error?: string;
39
+ /** Set on failure only; `unknown` when no rule recognizes the output. */
40
+ kind?: ErrorKind;
36
41
  };
37
42
  /** Reads a runtime's stdout line by line while it runs, keeping only what it needs. */
38
43
  export interface StreamParser {
package/dist/call.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type Invoker } from "./adapters/index.js";
2
2
  import { type ResolvedAgent, type ResolvedProject } from "./config.js";
3
+ import { type Tried } from "./fallback.js";
3
4
  export declare const CALL_PROJECT = "(call)";
4
5
  export declare const callsDir: (home: string) => string;
5
6
  export interface CallOptions {
@@ -11,6 +12,8 @@ export interface CallOptions {
11
12
  invoker?: Invoker;
12
13
  signal?: AbortSignal;
13
14
  say?: (line: string) => void;
15
+ /** Only used with a fallback chain, to wait out a cooldown; tests pass one that does not wait. */
16
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
14
17
  }
15
18
  export interface CallResult {
16
19
  callId: string;
@@ -20,6 +23,8 @@ export interface CallResult {
20
23
  /** The agent's final reply (empty when the call failed). */
21
24
  text: string;
22
25
  error?: string;
26
+ /** With a fallback chain: each model that failed, in order. */
27
+ tried?: Tried[];
23
28
  /** Protected files the agent changed and agent-lyceum put back. */
24
29
  reverted: string[];
25
30
  }
package/dist/call.js CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { realInvoker } from "./adapters/index.js";
4
4
  import { must } from "./assert.js";
5
5
  import { ConfigError, loadGlobal, resolveProjectFrom } from "./config.js";
6
+ import { agentForCandidate, candidatesFor, Cooldowns, runWithFallback } from "./fallback.js";
6
7
  import { atomicWrite } from "./fs-util.js";
7
8
  import { ProtectedGuard } from "./guard.js";
8
9
  import { assertName, homePaths, isInside } from "./paths.js";
@@ -137,23 +138,45 @@ export async function callAgent(o) {
137
138
  const guard = new ProtectedGuard(project);
138
139
  say(`Calling ${agent.name} in ${dir} (call ${callId})`);
139
140
  const invoke = o.invoker ?? realInvoker;
140
- const result = await invoke({
141
- project,
142
- agent,
143
- workDir: callDir,
144
- systemPrompt: buildSoloSystemPrompt(project, agent),
145
- userPrompt: buildCallUserPrompt(agent, o.task),
146
- timeoutSec: project.dispatcher.wake_timeout_sec,
141
+ const all = candidatesFor(agent);
142
+ const chain = all.length > 1;
143
+ // The primary logs to log/ as always; each backup to a directory of its own, so its run does not overwrite the primary's logs.
144
+ const logDirFor = (c) => (c.primary ? path.join(callDir, "log") : path.join(callDir, "log", `fallback-${all.indexOf(c)}`));
145
+ // A call is a single shot: no retries on one model, and cooldowns that live only as long as it does.
146
+ const outcome = await runWithFallback({
147
+ candidates: all,
148
+ legacy: !chain,
149
+ retry: 0,
150
+ cooldowns: new Cooldowns(),
151
+ cooldownSec: project.dispatcher.fallback_cooldown_sec,
147
152
  signal: o.signal,
148
- logDir: path.join(callDir, "log"),
149
- }).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message }));
153
+ sleep: o.sleep,
154
+ onSwitch: (_from, to, kind) => {
155
+ if (o.signal?.aborted)
156
+ return;
157
+ say(` ${agent.name} falling back to ${to.model ? `${to.runtime}/${to.model}` : to.runtime} (${kind ?? "unknown error"})`);
158
+ },
159
+ invoke: (c) => invoke({
160
+ project,
161
+ agent: agentForCandidate(agent, c),
162
+ workDir: callDir,
163
+ systemPrompt: buildSoloSystemPrompt(project, agent),
164
+ userPrompt: buildCallUserPrompt(agent, o.task),
165
+ timeoutSec: project.dispatcher.wake_timeout_sec,
166
+ signal: o.signal,
167
+ logDir: logDirFor(c),
168
+ }).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message })),
169
+ });
170
+ const result = must(outcome.result, "the call's result");
171
+ const tried = chain ? outcome.tried : undefined;
150
172
  const reverted = guard.check([agent]).map((v) => v.file);
151
173
  for (const f of reverted)
152
174
  say(`warn ${agent.name} changed ${f}; it was put back.`);
153
175
  const cancelled = !!result.cancelled;
154
176
  const error = result.ok ? undefined : cancelled ? "cancelled" : (result.error ?? (result.timedOut ? "timed out" : "failed"));
155
- atomicWrite(path.join(callDir, "result.md"), result.ok ? `${result.text.trimEnd()}\n` : `# Call ${cancelled ? "cancelled" : "failed"}\n\n${error}\n`);
156
- return { callId, callDir, ok: result.ok, cancelled, text: result.ok ? result.text : "", error, reverted };
177
+ const triedList = !result.ok && !cancelled && tried?.length ? `\nTried:\n${tried.map((t) => `- ${t.model ? `${t.runtime}/${t.model}` : t.runtime} (${t.kind ?? "unknown"}): ${t.error ?? "failed"}`).join("\n")}\n` : "";
178
+ atomicWrite(path.join(callDir, "result.md"), result.ok ? `${result.text.trimEnd()}\n` : `# Call ${cancelled ? "cancelled" : "failed"}\n\n${error}\n${triedList}`);
179
+ return { callId, callDir, ok: result.ok, cancelled, text: result.ok ? result.text : "", error, ...(tried ? { tried } : {}), reverted };
157
180
  }
158
181
  finally {
159
182
  lease.release();
package/dist/cli.js CHANGED
@@ -159,11 +159,14 @@ function cancelOnSignals() {
159
159
  };
160
160
  const onInt = handler("Interrupted");
161
161
  const onTerm = handler("Terminated");
162
+ const onHup = handler("Hung up");
162
163
  process.on("SIGINT", onInt);
163
164
  process.on("SIGTERM", onTerm);
165
+ process.on("SIGHUP", onHup); // the terminal went away
164
166
  return { signal: ac.signal, dispose: () => {
165
167
  process.off("SIGINT", onInt);
166
168
  process.off("SIGTERM", onTerm);
169
+ process.off("SIGHUP", onHup);
167
170
  },
168
171
  };
169
172
  }
package/dist/config.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type ProjectPaths } from "./paths.js";
2
2
  import type { AgentWorkspace } from "./worktree.js";
3
- import { type DispatcherSettings, type Effort, type Runtime, type WebLevel } from "./schema.js";
3
+ import { type DispatcherSettings, type Effort, type FallbackEntry, type Runtime, type WebLevel } from "./schema.js";
4
4
  export declare class ConfigError extends Error {
5
5
  }
6
6
  export type Source = "global" | "project" | "default" | "model";
@@ -22,6 +22,15 @@ export interface ResolvedAgent {
22
22
  canAskUser: boolean;
23
23
  /** Web access for this agent; undefined = not configured, so the runtime keeps its own default. */
24
24
  allowWeb?: WebLevel;
25
+ /** Ordered failover chain (runtime and model filled in); empty = no fallback. */
26
+ fallback: FallbackEntry[];
27
+ /** True when the chain came from `dispatcher.fallback` rather than the agent's own setting. */
28
+ fallbackShared: boolean;
29
+ /** Entries of the chain that could not be resolved (left out of `fallback`); `validate` reports them. */
30
+ fallbackInvalid: {
31
+ index: number;
32
+ reason: "empty" | "no-runtime";
33
+ }[];
25
34
  /** Where each field's value came from (for `validate` output). */
26
35
  sources: Record<string, Source>;
27
36
  }
@@ -65,6 +74,11 @@ export declare function loadGlobal(home: string): {
65
74
  can_edit_agent_md?: boolean | undefined;
66
75
  can_ask_user?: boolean | undefined;
67
76
  allow_web?: "fetch" | "none" | "search" | undefined;
77
+ fallback?: {
78
+ runtime?: "claude-code" | "codex" | undefined;
79
+ model?: string | undefined;
80
+ effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
81
+ }[] | undefined;
68
82
  owns?: unknown;
69
83
  }>;
70
84
  };
@@ -82,6 +96,12 @@ export declare function loadProjectRaw(home: string, name: string): {
82
96
  strict?: boolean | undefined;
83
97
  workspace_mode?: unknown;
84
98
  log_max_bytes?: number | undefined;
99
+ fallback?: {
100
+ runtime?: "claude-code" | "codex" | undefined;
101
+ model?: string | undefined;
102
+ effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
103
+ }[] | undefined;
104
+ fallback_cooldown_sec?: number | undefined;
85
105
  };
86
106
  agents: Record<string, {
87
107
  runtime?: "claude-code" | "codex" | undefined;
@@ -97,6 +117,11 @@ export declare function loadProjectRaw(home: string, name: string): {
97
117
  can_edit_agent_md?: boolean | undefined;
98
118
  can_ask_user?: boolean | undefined;
99
119
  allow_web?: "fetch" | "none" | "search" | undefined;
120
+ fallback?: {
121
+ runtime?: "claude-code" | "codex" | undefined;
122
+ model?: string | undefined;
123
+ effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
124
+ }[] | undefined;
100
125
  owns?: unknown;
101
126
  }>;
102
127
  };
package/dist/config.js CHANGED
@@ -58,7 +58,7 @@ export function resolveProjectFrom(home, name, g, p) {
58
58
  const paths = projectPaths(home, name);
59
59
  const hp = homePaths(home);
60
60
  const lead = p.team.lead;
61
- const { max_parallel: _mp, max_concurrent_runs: _mc, workspace_mode: _wm, ...liveDispatcher } = p.dispatcher;
61
+ const { max_parallel: _mp, max_concurrent_runs: _mc, workspace_mode: _wm, fallback: dispatcherFallback, ...liveDispatcher } = p.dispatcher;
62
62
  const dispatcher = { ...DISPATCHER_DEFAULTS, ...stripUndef(liveDispatcher) };
63
63
  const agents = {};
64
64
  for (const agentName of Object.keys(p.agents)) {
@@ -129,6 +129,21 @@ export function resolveProjectFrom(home, name, g, p) {
129
129
  break;
130
130
  }
131
131
  }
132
+ // Precedence: the agent's own chain (project > global), then dispatcher.fallback, then none. `[]` is a real value: no fallback.
133
+ let rawFallback = pick("fallback");
134
+ let fallbackShared = false;
135
+ if (rawFallback === undefined) {
136
+ fallbackShared = dispatcherFallback !== undefined;
137
+ if (dispatcherFallback !== undefined) {
138
+ rawFallback = dispatcherFallback;
139
+ sources.fallback = "project";
140
+ }
141
+ else {
142
+ rawFallback = [];
143
+ sources.fallback = "default";
144
+ }
145
+ }
146
+ const { entries: fallback, invalid: fallbackInvalid } = resolveFallback(rawFallback, runtime);
132
147
  const isLead = agentName === lead;
133
148
  const resume = pick("resume");
134
149
  const canMessage = pick("can_message");
@@ -156,6 +171,9 @@ export function resolveProjectFrom(home, name, g, p) {
156
171
  canEditAgentMd: canEdit ?? isLead,
157
172
  canAskUser: canAsk ?? isLead,
158
173
  allowWeb,
174
+ fallback,
175
+ fallbackShared,
176
+ fallbackInvalid,
159
177
  sources,
160
178
  };
161
179
  }
@@ -170,6 +188,24 @@ export function resolveProjectFrom(home, name, g, p) {
170
188
  ignoredSettings: collectIgnored(hp.globalConfig, g, paths.config, p),
171
189
  };
172
190
  }
191
+ /** Fill each entry's runtime: `inferRuntime(model)`, else the agent's own. Entries with nothing to go on are set aside, not guessed. */
192
+ function resolveFallback(raw, agentRuntime) {
193
+ const entries = [];
194
+ const invalid = [];
195
+ for (const [index, e] of raw.entries()) {
196
+ if (e.runtime === undefined && !e.model) {
197
+ invalid.push({ index, reason: "empty" });
198
+ continue;
199
+ }
200
+ const runtime = e.runtime ?? inferRuntime(e.model) ?? agentRuntime;
201
+ if (!runtime) {
202
+ invalid.push({ index, reason: "no-runtime" });
203
+ continue;
204
+ }
205
+ entries.push({ runtime, model: e.model ?? "", ...(e.effort ? { effort: e.effort } : {}) });
206
+ }
207
+ return { entries, invalid };
208
+ }
173
209
  /** The removed keys present in the global and project files, by full path, per file (empty files omitted). */
174
210
  function collectIgnored(globalFile, g, projectFile, p) {
175
211
  const out = [];
@@ -223,7 +259,7 @@ export function findProjectForCwd(home, cwd) {
223
259
  }
224
260
  return best?.name;
225
261
  }
226
- const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "can_ask_user", "allow_web"];
262
+ const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "can_ask_user", "allow_web", "fallback"];
227
263
  /** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
228
264
  export function resolveProjectWithSources(home, name) {
229
265
  const project = resolveProject(home, name);
@@ -234,7 +270,10 @@ export function resolveProjectWithSources(home, name) {
234
270
  dir: { file: projectFile, key: "dir" },
235
271
  "team.lead": { file: projectFile, key: "team.lead" },
236
272
  };
273
+ const anyFallback = Object.values(project.agents).some((a) => a.fallback.length > 0);
237
274
  for (const k of Object.keys(DISPATCHER_DEFAULTS)) {
275
+ if (k === "fallback_cooldown_sec" && !anyFallback)
276
+ continue;
238
277
  sources[`dispatcher.${k}`] = raw.dispatcher[k] !== undefined ? { file: projectFile, key: `dispatcher.${k}` } : { default: true };
239
278
  }
240
279
  for (const a of Object.values(project.agents)) {
@@ -250,6 +289,8 @@ export function resolveProjectWithSources(home, name) {
250
289
  const src = a.sources[f];
251
290
  if (src === undefined)
252
291
  continue; // not set anywhere (e.g. no effort)
292
+ if (f === "fallback" && a.fallback.length === 0)
293
+ continue;
253
294
  if (src === "model")
254
295
  sources[`agents.${a.name}.${f}`] = (() => {
255
296
  const m = at(a.sources.model, "model");
@@ -258,6 +299,9 @@ export function resolveProjectWithSources(home, name) {
258
299
  // A project-level AGENT.md found by convention is not a setting in project.yaml.
259
300
  else if (f === "agent_md" && src === "project" && raw.agents[a.name]?.agent_md === undefined)
260
301
  sources[`agents.${a.name}.${f}`] = { default: true };
302
+ // A chain inherited from dispatcher.fallback lives under that key, not under the agent.
303
+ else if (f === "fallback" && src === "project" && raw.agents[a.name]?.fallback === undefined)
304
+ sources[`agents.${a.name}.${f}`] = { file: projectFile, key: "dispatcher.fallback" };
261
305
  else
262
306
  sources[`agents.${a.name}.${f}`] = at(src, f);
263
307
  }
@@ -267,8 +311,12 @@ export function resolveProjectWithSources(home, name) {
267
311
  /** Every effective setting under the same keys as `resolveProjectWithSources` reports sources for. */
268
312
  export function flattenResolved(project) {
269
313
  const v = { dir: project.dir, "team.lead": project.lead };
270
- for (const [k, val] of Object.entries(project.dispatcher))
314
+ const anyFallback = Object.values(project.agents).some((a) => a.fallback.length > 0);
315
+ for (const [k, val] of Object.entries(project.dispatcher)) {
316
+ if (k === "fallback_cooldown_sec" && !anyFallback)
317
+ continue; // keep `doctor --resolved` unchanged without fallback
271
318
  v[`dispatcher.${k}`] = val;
319
+ }
272
320
  for (const a of Object.values(project.agents)) {
273
321
  const p = `agents.${a.name}`;
274
322
  v[`${p}.runtime`] = a.runtime;
@@ -282,6 +330,8 @@ export function flattenResolved(project) {
282
330
  v[`${p}.can_edit_agent_md`] = a.canEditAgentMd;
283
331
  v[`${p}.can_ask_user`] = a.canAskUser;
284
332
  v[`${p}.allow_web`] = a.allowWeb;
333
+ if (a.fallback.length)
334
+ v[`${p}.fallback`] = a.fallback;
285
335
  }
286
336
  return v;
287
337
  }
package/dist/doctor.d.ts CHANGED
@@ -35,7 +35,7 @@ export interface DoctorReport {
35
35
  checks: DoctorCheck[];
36
36
  runtimes: RuntimeCapabilities[];
37
37
  }
38
- export declare function probeProjectRuntimes(project: ResolvedProject, opts?: ProbeOptions): Promise<RuntimeCapabilities[]>;
38
+ export declare function probeProjectRuntimes(project: ResolvedProject, opts?: ProbeOptions, withFallback?: boolean): Promise<RuntimeCapabilities[]>;
39
39
  /** Before a run starts: what is explicitly unsupported (refuse) and what is merely unknown (warn). */
40
40
  export declare function preflightRuntimes(project: ResolvedProject, opts?: ProbeOptions): Promise<{
41
41
  errors: string[];
package/dist/doctor.js CHANGED
@@ -76,7 +76,13 @@ function judgeRuntimes(project, caps) {
76
76
  const cmd = BINARY[c.runtime];
77
77
  const users = Object.values(project.agents).filter((a) => a.runtime === c.runtime);
78
78
  if (c.binary === "missing") {
79
- checks.push({ level: "error", subject: c.runtime, message: `${cmd} not found on PATH (needed by ${users.map((a) => a.name).join(", ")}).` });
79
+ if (!users.length) {
80
+ // Only a fallback chain uses it: a backup that is not installed must not stop a run that works until the primary fails.
81
+ const backups = Object.values(project.agents).filter((a) => a.fallback.some((f) => f.runtime === c.runtime));
82
+ checks.push({ level: "warn", subject: c.runtime, message: `${cmd} not found on PATH (it is a fallback for ${backups.map((a) => a.name).join(", ")}).` });
83
+ }
84
+ else
85
+ checks.push({ level: "error", subject: c.runtime, message: `${cmd} not found on PATH (needed by ${users.map((a) => a.name).join(", ")}).` });
80
86
  continue;
81
87
  }
82
88
  checks.push({ level: "ok", subject: c.runtime, message: `${cmd} ${c.version ?? "(version unknown)"}` });
@@ -96,9 +102,12 @@ function judgeRuntimes(project, caps) {
96
102
  }
97
103
  return checks;
98
104
  }
99
- const runtimesUsed = (project) => [...new Set(Object.values(project.agents).map((a) => a.runtime).filter((r) => !!r))];
100
- export async function probeProjectRuntimes(project, opts = {}) {
101
- return Promise.all(runtimesUsed(project).map((r) => probeRuntime(r, opts)));
105
+ /** Runtimes of the agents themselves; with `withFallback`, also those that only a fallback chain uses. */
106
+ const runtimesUsed = (project, withFallback) => [
107
+ ...new Set(Object.values(project.agents).flatMap((a) => [a.runtime, ...(withFallback ? a.fallback.map((f) => f.runtime) : [])]).filter((r) => !!r)),
108
+ ];
109
+ export async function probeProjectRuntimes(project, opts = {}, withFallback = false) {
110
+ return Promise.all(runtimesUsed(project, withFallback).map((r) => probeRuntime(r, opts)));
102
111
  }
103
112
  /** Before a run starts: what is explicitly unsupported (refuse) and what is merely unknown (warn). */
104
113
  export async function preflightRuntimes(project, opts = {}) {
@@ -160,7 +169,7 @@ export async function diagnoseProject(project, opts = {}) {
160
169
  }
161
170
  for (const a of tidyAdvice(project))
162
171
  checks.push({ level: "info", subject: "memory", message: `${a.agent}: ${a.reason}. Consider: agent-lyceum tidy -p ${project.name} --agent ${a.agent} (doctor only suggests; it never tidies).` });
163
- const runtimes = await probeProjectRuntimes(project, opts);
172
+ const runtimes = await probeProjectRuntimes(project, opts, true);
164
173
  checks.push(...judgeRuntimes(project, runtimes));
165
174
  checks.push({ level: "info", subject: "login", message: "Login status is unknown: agent-lyceum does not read credentials, and the CLIs offer no read-only login check it can rely on." });
166
175
  return { project: project.name, ok: !checks.some((c) => c.level === "error"), checks, runtimes };
@@ -0,0 +1,111 @@
1
+ import type { ResolvedAgent } from "./config.js";
2
+ import type { Effort, ErrorKind, Runtime } from "./schema.js";
3
+ /** One model a wake may run on: the agent's own (`primary`) or a link of its fallback chain. */
4
+ export interface Candidate {
5
+ runtime: Runtime;
6
+ /** Unset = the runtime's own default model. */
7
+ model?: string;
8
+ effort?: Effort;
9
+ primary: boolean;
10
+ /** `<runtime>/<model>`; the cooldown and session key. */
11
+ key: string;
12
+ }
13
+ /**
14
+ * The primary model followed by the agent's fallback chain, in order. An entry with the same key as the primary or as an
15
+ * earlier entry is dropped: a chain shared through `dispatcher.fallback` may list the agent's own primary (spec 20.12).
16
+ */
17
+ export declare function candidatesFor(agent: ResolvedAgent): Candidate[];
18
+ /**
19
+ * The agent as it runs on candidate `c`: itself for the primary; for a backup, the same agent on the backup's runtime, model
20
+ * and effort, at the primary's effective web level (spec 20.12). A set `allow_web` carries over. Unset, claude-code has no web
21
+ * tools while codex keeps its own default, which may search: a codex backup of such an agent gets `none`.
22
+ */
23
+ export declare function agentForCandidate(agent: ResolvedAgent, c: Candidate): ResolvedAgent;
24
+ /** Models that failed with `rate_limit`/`quota`, and until when (epoch ms) wakes skip them. Shared by a run, kept in `state.json`. */
25
+ export declare class Cooldowns {
26
+ private table;
27
+ /** Cool `key` down for `sec` seconds from `now`; never shortens a longer cooldown already in place. */
28
+ mark(key: string, kind: ErrorKind, now: number, sec: number): void;
29
+ /** Milliseconds of cooldown left for `key` at `now`; 0 = none. */
30
+ active(key: string, now: number): number;
31
+ toJSON(): Record<string, {
32
+ until: string;
33
+ kind: ErrorKind;
34
+ }>;
35
+ /** Rebuild from `toJSON()` output; a missing table (older `state.json`) is an empty one. */
36
+ static from(json?: Record<string, {
37
+ until: string;
38
+ kind: ErrorKind;
39
+ }>): Cooldowns;
40
+ }
41
+ /** The candidates for one wake: the chain minus those cooling down, or the whole chain when every one is (something must be tried). */
42
+ export declare function planAttempts(agent: ResolvedAgent, cooldowns: Cooldowns, now: number): Candidate[];
43
+ export type Step = "retry-same" | "next-candidate" | "stop";
44
+ /**
45
+ * What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
46
+ * candidate, the failed one included; each candidate gets `retry` retries. `legacy` (the agent has no fallback chain)
47
+ * keeps the old loop: every kind is retried `retry` times, then the wake fails.
48
+ */
49
+ export declare function nextStep(kind: ErrorKind | undefined, attemptsOnCurrent: number, retry: number, hasNext: boolean, legacy: boolean): Step;
50
+ /** Wait before the `n`th retry (1-based) on the same candidate: 5s, 15s, 45s, then 60s. */
51
+ export declare function backoffMs(n: number): number;
52
+ /** `setTimeout` as a promise that resolves early (never rejects) when `signal` aborts. */
53
+ export declare function defaultSleep(ms: number, signal?: AbortSignal): Promise<void>;
54
+ /** What the driver needs to know about a call's result. */
55
+ export interface AttemptResult {
56
+ ok: boolean;
57
+ kind?: ErrorKind;
58
+ cancelled?: boolean;
59
+ error?: string;
60
+ }
61
+ export interface AttemptCtx<R> {
62
+ /** Calls made so far in this wake, across candidates (0 = the first). */
63
+ attempt: number;
64
+ /** Calls made so far on this candidate; restarts at 0 on a switch. */
65
+ candidateAttempt: number;
66
+ /** The previous call's failed result, for a retry prompt. */
67
+ previous?: R;
68
+ /** Set on the first call after switching: the candidate given up on. */
69
+ switchedFrom?: Candidate;
70
+ }
71
+ /** A failed call, in order, for `failure.tried`. */
72
+ export interface Tried {
73
+ runtime: Runtime;
74
+ model?: string;
75
+ kind?: ErrorKind;
76
+ error?: string;
77
+ }
78
+ export interface RunWithFallbackOptions<R extends AttemptResult> {
79
+ /** From `planAttempts`; tried in order. */
80
+ candidates: Candidate[];
81
+ /** The agent has no fallback chain (see `nextStep`): no backoff, no cooldowns. */
82
+ legacy: boolean;
83
+ /** Retries per candidate (`dispatcher.retry`). */
84
+ retry: number;
85
+ /** Marked on `rate_limit`/`quota` failures. */
86
+ cooldowns: Cooldowns;
87
+ cooldownSec: number;
88
+ invoke(candidate: Candidate, ctx: AttemptCtx<R>): Promise<R>;
89
+ /** Runs before each call; "stop" ends the loop without calling (e.g. the round limit). */
90
+ beforeAttempt?(candidate: Candidate, ctx: AttemptCtx<R>): "stop" | undefined;
91
+ /** Runs after each call, awaited before the next decision. */
92
+ afterAttempt?(candidate: Candidate, result: R, ctx: AttemptCtx<R>): void | Promise<void>;
93
+ onSwitch?(from: Candidate, to: Candidate, kind: ErrorKind | undefined, result: R): void;
94
+ sleep?(ms: number, signal?: AbortSignal): Promise<void>;
95
+ now?(): number;
96
+ signal?: AbortSignal;
97
+ }
98
+ export interface FallbackOutcome<R> {
99
+ /** The last call's result; undefined only when stopped before the first call. */
100
+ result: R | undefined;
101
+ tried: Tried[];
102
+ /** The candidate of the last call (or the first one, when none was made). */
103
+ used: Candidate;
104
+ /** Why the loop ended without a final verdict from a call: `beforeAttempt` said stop, or the signal aborted during a backoff. */
105
+ stopped?: "beforeAttempt" | "aborted";
106
+ }
107
+ /**
108
+ * The one retry/fallback loop shared by `RunSession.wake` and `call` (spec 20.2 decision 9): call each candidate,
109
+ * retry it or move on as `nextStep` says, back off between retries. Success and cancellation end the loop as they are.
110
+ */
111
+ export declare function runWithFallback<R extends AttemptResult>(o: RunWithFallbackOptions<R>): Promise<FallbackOutcome<R>>;