agent-lyceum 0.18.0 → 0.19.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.
@@ -1,6 +1,6 @@
1
1
  import type { ResolvedProject } from "./config.js";
2
2
  import type { DigestSection, Step } from "./format.js";
3
- import type { RunOutcome } from "./schema.js";
3
+ import { type ErrorKind, type Runtime, type RunOutcome } from "./schema.js";
4
4
  import type { ResultBranch } from "./worktree.js";
5
5
  export declare const STATE_FILE = "state.json";
6
6
  export declare const STATE_SCHEMA_VERSION = 2;
@@ -36,6 +36,27 @@ export interface WakeRecord {
36
36
  /** Mail this wake handed to others; `to` is "(done)" for the lead's final message. */
37
37
  sent?: SentTopic[];
38
38
  error?: string;
39
+ /** With a fallback chain only: the runtime and model this call ran on. */
40
+ runtime?: Runtime;
41
+ model?: string;
42
+ /** With a fallback chain only: the `runtime/model` this call replaced (set on a backup's calls). */
43
+ fallback_from?: string;
44
+ /** With a fallback chain only: why the call failed. */
45
+ kind?: ErrorKind;
46
+ }
47
+ /** A model given up on during a wake with a fallback chain, for `failure.tried`. */
48
+ export interface TriedModel {
49
+ runtime: Runtime;
50
+ model?: string;
51
+ kind?: ErrorKind;
52
+ error?: string;
53
+ }
54
+ /** The run stopped because an agent kept failing: who, the last error, how many times it was called; `tried` only with a fallback chain. */
55
+ export interface RunFailure {
56
+ agent: string;
57
+ error?: string;
58
+ attempts: number;
59
+ tried?: TriedModel[];
39
60
  }
40
61
  export interface RunState {
41
62
  /** 1 for runs written before versioning; 2 for runs with their own mailboxes. */
@@ -84,10 +105,13 @@ export interface RunState {
84
105
  pr_url?: string;
85
106
  sessions: Record<string, string>;
86
107
  output_tokens: number;
108
+ /** `runtime`/`model` only for agents with a fallback chain. */
87
109
  last_wake: Record<string, {
88
110
  at: string;
89
111
  ok: boolean;
90
112
  error?: string;
113
+ runtime?: Runtime;
114
+ model?: string;
91
115
  }>;
92
116
  /** Process running this dispatcher, so a monitor can tell "running" from "interrupted". */
93
117
  pid: number;
@@ -98,11 +122,12 @@ export interface RunState {
98
122
  /** Finished wakes, oldest first. */
99
123
  wakes: WakeRecord[];
100
124
  /** Set when the run stopped because an agent kept failing: who, the last error, how many times it was called. */
101
- failure?: {
102
- agent: string;
103
- error?: string;
104
- attempts: number;
105
- };
125
+ failure?: RunFailure;
126
+ /** Models cooling down after `rate_limit`/`quota` (`<runtime>/<model>` → until when); absent until one is. */
127
+ cooldowns?: Record<string, {
128
+ until: string;
129
+ kind: ErrorKind;
130
+ }>;
106
131
  /** Checklist progress: seeded from the task, then replaced by the lead's latest `## Steps` (after a reopen, added to `step_base`). */
107
132
  steps?: Step[];
108
133
  /** The checklist as it stood at the last reopen: the follow-up's steps are shown after it, and its open steps do not hold up the follow-up's done. */
package/dist/run-store.js CHANGED
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import { atomicWrite } from "./fs-util.js";
6
+ import { ERROR_KINDS, RUNTIMES } from "./schema.js";
6
7
  export const STATE_FILE = "state.json";
7
8
  export const STATE_SCHEMA_VERSION = 2;
8
9
  /** `20261006T010203456Z-<128 random bits>`: sorts by start time and cannot collide, even for two starts in one millisecond. */
@@ -11,6 +12,19 @@ export function newRunId(d = new Date()) {
11
12
  return `${t}-${crypto.randomBytes(16).toString("hex")}`;
12
13
  }
13
14
  const nonNeg = z.number().finite();
15
+ const CooldownEntry = z.object({ until: z.string(), kind: z.enum(ERROR_KINDS) });
16
+ /** `state.cooldowns` is advisory: keep the entries that parse and drop the rest, so one bad entry never makes the run unreadable. */
17
+ function lenientCooldowns(raw) {
18
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
19
+ return undefined;
20
+ const out = {};
21
+ for (const [k, v] of Object.entries(raw)) {
22
+ const e = CooldownEntry.safeParse(v);
23
+ if (e.success)
24
+ out[k] = e.data;
25
+ }
26
+ return out;
27
+ }
14
28
  const WireState = z
15
29
  .object({
16
30
  schema_version: z.number().int().optional(),
@@ -39,7 +53,15 @@ const WireState = z
39
53
  wakes: z.array(z.any()).optional(),
40
54
  solo_agent: z.string().optional(),
41
55
  steps: z.array(z.object({ text: z.string(), done: z.boolean() })).optional(),
42
- failure: z.object({ agent: z.string(), error: z.string().optional(), attempts: z.number() }).optional(),
56
+ failure: z
57
+ .object({
58
+ agent: z.string(),
59
+ error: z.string().optional(),
60
+ attempts: z.number(),
61
+ tried: z.array(z.object({ runtime: z.enum(RUNTIMES), model: z.string().optional(), kind: z.enum(ERROR_KINDS).optional(), error: z.string().optional() })).optional(),
62
+ })
63
+ .optional(),
64
+ cooldowns: z.unknown().transform(lenientCooldowns).optional(),
43
65
  step_base: z.array(z.object({ text: z.string(), done: z.boolean() })).optional(),
44
66
  name: z.string().optional(),
45
67
  reopened: z.array(z.object({ at: z.string() })).optional(),
package/dist/schema.d.ts CHANGED
@@ -11,6 +11,32 @@ export declare function inferRuntime(model: string | undefined): Runtime | undef
11
11
  /** How much of the web an agent may use: nothing, search only, or search plus fetching pages. Unset leaves each runtime's own default alone. */
12
12
  export declare const WEB_LEVELS: readonly ["none", "search", "fetch"];
13
13
  export type WebLevel = (typeof WEB_LEVELS)[number];
14
+ /** Why a wake failed, as far as the CLI's output reveals; decides whether to retry, fall back, or stop. */
15
+ export declare const ERROR_KINDS: readonly ["rate_limit", "quota", "timeout", "network", "crash", "auth", "unknown"];
16
+ export type ErrorKind = (typeof ERROR_KINDS)[number];
17
+ /** One link of a fallback chain after resolution: runtime and model are filled in (model "" = the runtime's own default). */
18
+ export interface FallbackEntry {
19
+ runtime: Runtime;
20
+ model: string;
21
+ effort?: Effort;
22
+ }
23
+ /** A fallback entry as written in a file: `runtime` may be omitted (inferred from `model`, else the agent's). */
24
+ export declare const FallbackEntryPartial: z.ZodObject<{
25
+ runtime: z.ZodOptional<z.ZodEnum<{
26
+ "claude-code": "claude-code";
27
+ codex: "codex";
28
+ }>>;
29
+ model: z.ZodOptional<z.ZodString>;
30
+ effort: z.ZodOptional<z.ZodEnum<{
31
+ high: "high";
32
+ low: "low";
33
+ max: "max";
34
+ medium: "medium";
35
+ minimal: "minimal";
36
+ xhigh: "xhigh";
37
+ }>>;
38
+ }, z.core.$strict>;
39
+ export type FallbackEntryPartialT = z.infer<typeof FallbackEntryPartial>;
14
40
  export declare const AgentPartial: z.ZodObject<{
15
41
  runtime: z.ZodOptional<z.ZodEnum<{
16
42
  "claude-code": "claude-code";
@@ -39,6 +65,21 @@ export declare const AgentPartial: z.ZodObject<{
39
65
  none: "none";
40
66
  search: "search";
41
67
  }>>;
68
+ fallback: z.ZodOptional<z.ZodArray<z.ZodObject<{
69
+ runtime: z.ZodOptional<z.ZodEnum<{
70
+ "claude-code": "claude-code";
71
+ codex: "codex";
72
+ }>>;
73
+ model: z.ZodOptional<z.ZodString>;
74
+ effort: z.ZodOptional<z.ZodEnum<{
75
+ high: "high";
76
+ low: "low";
77
+ max: "max";
78
+ medium: "medium";
79
+ minimal: "minimal";
80
+ xhigh: "xhigh";
81
+ }>>;
82
+ }, z.core.$strict>>>;
42
83
  owns: z.ZodOptional<z.ZodUnknown>;
43
84
  }, z.core.$strict>;
44
85
  export type AgentPartialT = z.infer<typeof AgentPartial>;
@@ -51,6 +92,22 @@ export declare const DispatcherPartial: z.ZodObject<{
51
92
  strict: z.ZodOptional<z.ZodBoolean>;
52
93
  workspace_mode: z.ZodOptional<z.ZodUnknown>;
53
94
  log_max_bytes: z.ZodOptional<z.ZodNumber>;
95
+ fallback: z.ZodOptional<z.ZodArray<z.ZodObject<{
96
+ runtime: z.ZodOptional<z.ZodEnum<{
97
+ "claude-code": "claude-code";
98
+ codex: "codex";
99
+ }>>;
100
+ model: z.ZodOptional<z.ZodString>;
101
+ effort: z.ZodOptional<z.ZodEnum<{
102
+ high: "high";
103
+ low: "low";
104
+ max: "max";
105
+ medium: "medium";
106
+ minimal: "minimal";
107
+ xhigh: "xhigh";
108
+ }>>;
109
+ }, z.core.$strict>>>;
110
+ fallback_cooldown_sec: z.ZodOptional<z.ZodNumber>;
54
111
  }, z.core.$strict>;
55
112
  /** Settings that no longer exist; files that still carry them load, and `validate` warns. */
56
113
  export declare const REMOVED_DISPATCHER_KEYS: readonly ["max_parallel", "max_concurrent_runs", "workspace_mode"];
@@ -84,6 +141,21 @@ export declare const GlobalConfig: z.ZodObject<{
84
141
  none: "none";
85
142
  search: "search";
86
143
  }>>;
144
+ fallback: z.ZodOptional<z.ZodArray<z.ZodObject<{
145
+ runtime: z.ZodOptional<z.ZodEnum<{
146
+ "claude-code": "claude-code";
147
+ codex: "codex";
148
+ }>>;
149
+ model: z.ZodOptional<z.ZodString>;
150
+ effort: z.ZodOptional<z.ZodEnum<{
151
+ high: "high";
152
+ low: "low";
153
+ max: "max";
154
+ medium: "medium";
155
+ minimal: "minimal";
156
+ xhigh: "xhigh";
157
+ }>>;
158
+ }, z.core.$strict>>>;
87
159
  owns: z.ZodOptional<z.ZodUnknown>;
88
160
  }, z.core.$strict>>>;
89
161
  }, z.core.$strict>;
@@ -101,6 +173,22 @@ export declare const ProjectConfig: z.ZodObject<{
101
173
  strict: z.ZodOptional<z.ZodBoolean>;
102
174
  workspace_mode: z.ZodOptional<z.ZodUnknown>;
103
175
  log_max_bytes: z.ZodOptional<z.ZodNumber>;
176
+ fallback: z.ZodOptional<z.ZodArray<z.ZodObject<{
177
+ runtime: z.ZodOptional<z.ZodEnum<{
178
+ "claude-code": "claude-code";
179
+ codex: "codex";
180
+ }>>;
181
+ model: z.ZodOptional<z.ZodString>;
182
+ effort: z.ZodOptional<z.ZodEnum<{
183
+ high: "high";
184
+ low: "low";
185
+ max: "max";
186
+ medium: "medium";
187
+ minimal: "minimal";
188
+ xhigh: "xhigh";
189
+ }>>;
190
+ }, z.core.$strict>>>;
191
+ fallback_cooldown_sec: z.ZodOptional<z.ZodNumber>;
104
192
  }, z.core.$strict>>;
105
193
  agents: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
106
194
  runtime: z.ZodOptional<z.ZodEnum<{
@@ -130,6 +218,21 @@ export declare const ProjectConfig: z.ZodObject<{
130
218
  none: "none";
131
219
  search: "search";
132
220
  }>>;
221
+ fallback: z.ZodOptional<z.ZodArray<z.ZodObject<{
222
+ runtime: z.ZodOptional<z.ZodEnum<{
223
+ "claude-code": "claude-code";
224
+ codex: "codex";
225
+ }>>;
226
+ model: z.ZodOptional<z.ZodString>;
227
+ effort: z.ZodOptional<z.ZodEnum<{
228
+ high: "high";
229
+ low: "low";
230
+ max: "max";
231
+ medium: "medium";
232
+ minimal: "minimal";
233
+ xhigh: "xhigh";
234
+ }>>;
235
+ }, z.core.$strict>>>;
133
236
  owns: z.ZodOptional<z.ZodUnknown>;
134
237
  }, z.core.$strict>>>;
135
238
  }, z.core.$strict>;
@@ -140,6 +243,8 @@ export interface DispatcherSettings {
140
243
  strict: boolean;
141
244
  /** log.jsonl is rotated to log.1.jsonl past this size; 0 = never. */
142
245
  log_max_bytes: number;
246
+ /** How long a primary that hit rate_limit/quota is skipped within a run, unless the CLI says when to come back. */
247
+ fallback_cooldown_sec: number;
143
248
  }
144
249
  export declare const DISPATCHER_DEFAULTS: DispatcherSettings;
145
250
  /** How a run turned out, independent of why it stopped (`EndReason`). Only "completed" means success. */
package/dist/schema.js CHANGED
@@ -20,12 +20,22 @@ export function inferRuntime(model) {
20
20
  }
21
21
  /** How much of the web an agent may use: nothing, search only, or search plus fetching pages. Unset leaves each runtime's own default alone. */
22
22
  export const WEB_LEVELS = ["none", "search", "fetch"];
23
+ /** Why a wake failed, as far as the CLI's output reveals; decides whether to retry, fall back, or stop. */
24
+ export const ERROR_KINDS = ["rate_limit", "quota", "timeout", "network", "crash", "auth", "unknown"];
23
25
  const Memory = z
24
26
  .object({
25
27
  global: z.string().optional(),
26
28
  project: z.string().optional(),
27
29
  })
28
30
  .strict();
31
+ /** A fallback entry as written in a file: `runtime` may be omitted (inferred from `model`, else the agent's). */
32
+ export const FallbackEntryPartial = z
33
+ .object({
34
+ runtime: z.enum(RUNTIMES).optional(),
35
+ model: z.string().optional(),
36
+ effort: z.enum(EFFORTS).optional(),
37
+ })
38
+ .strict();
29
39
  export const AgentPartial = z
30
40
  .object({
31
41
  runtime: z.enum(RUNTIMES).optional(),
@@ -38,6 +48,7 @@ export const AgentPartial = z
38
48
  can_edit_agent_md: z.boolean().optional(),
39
49
  can_ask_user: z.boolean().optional(),
40
50
  allow_web: z.enum(WEB_LEVELS).optional(),
51
+ fallback: z.array(FallbackEntryPartial).optional(),
41
52
  /** Removed setting: still accepted (any value) so old files load; ignored, with a warning from `validate`. */
42
53
  owns: z.unknown().optional(),
43
54
  })
@@ -52,6 +63,8 @@ export const DispatcherPartial = z
52
63
  strict: z.boolean().optional(),
53
64
  workspace_mode: z.unknown().optional(),
54
65
  log_max_bytes: z.number().int().min(0).optional(),
66
+ fallback: z.array(FallbackEntryPartial).optional(),
67
+ fallback_cooldown_sec: z.number().int().min(0).optional(),
55
68
  })
56
69
  .strict();
57
70
  /** Settings that no longer exist; files that still carry them load, and `validate` warns. */
@@ -76,6 +89,7 @@ export const DISPATCHER_DEFAULTS = {
76
89
  retry: 2,
77
90
  strict: false,
78
91
  log_max_bytes: 8 * 1024 * 1024,
92
+ fallback_cooldown_sec: 300,
79
93
  };
80
94
  /** How a run turned out, independent of why it stopped (`EndReason`). Only "completed" means success. */
81
95
  export const RUN_OUTCOMES = ["completed", "partial", "blocked", "failed", "waiting", "cancelled"];
package/dist/status.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ActiveWake, type EndReason, type RunState, type WakeRecord } from "./run-store.js";
1
+ import { type ActiveWake, type EndReason, type RunFailure, type RunState, type WakeRecord } from "./run-store.js";
2
2
  import type { RunOutcome } from "./schema.js";
3
3
  import type { ResolvedProject } from "./config.js";
4
4
  import { type DigestSection, type Step } from "./format.js";
@@ -72,11 +72,7 @@ export interface RunReport {
72
72
  /** 0 = no limit. */
73
73
  max_rounds: number;
74
74
  /** Set when an agent kept failing and the run stopped: `resume` after fixing the cause. */
75
- failure?: {
76
- agent: string;
77
- error?: string;
78
- attempts: number;
79
- };
75
+ failure?: RunFailure;
80
76
  output_tokens: number;
81
77
  task_source: "text" | "file";
82
78
  task_path?: string;
@@ -123,6 +119,19 @@ export interface AgentReport {
123
119
  ok: boolean;
124
120
  error?: string;
125
121
  };
122
+ /** Only for an agent with a fallback chain: the model it last ran on (or its own, before any wake). */
123
+ model?: string;
124
+ /** Only when the agent's latest wake ran on a backup: what it replaced and why. */
125
+ fallback?: {
126
+ from: string;
127
+ kind: string;
128
+ };
129
+ /** Only for an agent with a fallback chain: its models that are cooling down now (`model` is `<runtime>/<model>`). */
130
+ cooldowns?: {
131
+ model: string;
132
+ kind: string;
133
+ remaining_sec: number;
134
+ }[];
126
135
  }
127
136
  export interface StatusReport {
128
137
  schema_version: 1;
@@ -153,7 +162,7 @@ export declare function buildRunReport(run: {
153
162
  detail?: boolean;
154
163
  }): RunReport;
155
164
  /** The status of a project: its agents and one run (the newest running one, else the newest, or the one named by `runId`). */
156
- export declare function buildStatusReport(base: ResolvedProject, runId?: string): StatusReport;
165
+ export declare function buildStatusReport(base: ResolvedProject, runId?: string, now?: number): StatusReport;
157
166
  /** Every run of the project, newest first. */
158
167
  export declare function buildTaskListReport(project: ResolvedProject): TaskListReport;
159
168
  /** Live view: summary and agent table, plus the latest run and any run still going, wake by wake. */
package/dist/status.js CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { askReplyPath, readAskReply } from "./ask-reply.js";
4
4
  import { RESULT_FILE } from "./dispatcher.js";
5
5
  import { bindRunProject, loadRunState, outcomeOf, roundsLabel, runLabel } from "./run-store.js";
6
+ import { candidatesFor } from "./fallback.js";
6
7
  import { briefOf, digestOf } from "./format.js";
7
8
  import { listUnread } from "./mailbox.js";
8
9
  import { tidyAdvice } from "./memory-tidy.js";
@@ -226,8 +227,37 @@ export function buildRunReport(run, project, opts = {}) {
226
227
  };
227
228
  }
228
229
  const projectInfo = (p) => ({ name: p.name, dir: p.dir, lead: p.lead });
230
+ /** The fallback part of an agent's report: nothing at all for an agent without a chain. */
231
+ function fallbackView(a, state, now) {
232
+ if (!a.fallback.length || !a.runtime)
233
+ return {};
234
+ const lw = state?.last_wake[a.name];
235
+ const ran = lw?.runtime ? lw : undefined;
236
+ const model = ran ? ran.model : a.model;
237
+ const mine = (state?.wakes ?? []).filter((w) => w.agent === a.name);
238
+ const last = mine.at(-1);
239
+ let fallback;
240
+ if (last?.fallback_from) {
241
+ // The reason is on the failed wake just before: the nearest earlier wake of this agent that has a kind.
242
+ let kind = "unknown";
243
+ for (let i = mine.length - 2; i >= 0; i--) {
244
+ const k = mine[i].kind;
245
+ if (k) {
246
+ kind = k;
247
+ break;
248
+ }
249
+ }
250
+ fallback = { from: last.fallback_from, kind };
251
+ }
252
+ const keys = new Set(candidatesFor(a).map((c) => c.key));
253
+ const cooldowns = Object.entries(state?.cooldowns ?? {})
254
+ .filter(([k]) => keys.has(k))
255
+ .map(([k, v]) => ({ model: k, kind: v.kind, remaining_sec: Math.ceil((Date.parse(v.until) - now) / 1000) }))
256
+ .filter((c) => Number.isFinite(c.remaining_sec) && c.remaining_sec > 0);
257
+ return { runtime: ran?.runtime ?? a.runtime, ...(model ? { model } : {}), ...(fallback ? { fallback } : {}), ...(cooldowns.length ? { cooldowns } : {}) };
258
+ }
229
259
  /** The status of a project: its agents and one run (the newest running one, else the newest, or the one named by `runId`). */
230
- export function buildStatusReport(base, runId) {
260
+ export function buildStatusReport(base, runId, now = Date.now()) {
231
261
  let run;
232
262
  if (runId) {
233
263
  const dir = path.join(base.paths.runs, runId);
@@ -257,6 +287,7 @@ export function buildStatusReport(base, runId) {
257
287
  unread: listUnread(project, a.name).length,
258
288
  can_message: (a.canMessage === "all" ? Object.keys(project.agents) : a.canMessage).filter((n) => n !== a.name),
259
289
  last_wake: run?.state.last_wake[a.name],
290
+ ...fallbackView(a, run?.state, now),
260
291
  })),
261
292
  run: report,
262
293
  ...(advice.length ? { memory_advice: advice } : {}),
@@ -425,6 +456,20 @@ export function formatTaskListReport(report, color = false) {
425
456
  export function formatStatus(base, now = Date.now(), color = false) {
426
457
  return formatStatusReport(buildStatusReport(base), now, color);
427
458
  }
459
+ const shortSec = (sec) => (sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}m${String(sec % 60).padStart(2, "0")}s`);
460
+ /** `runtime/model`, or the runtime alone for its default model (cooldown keys end in `/` then). */
461
+ const modelName = (key) => key.replace(/\/$/, "");
462
+ /** Agents running on a backup and models cooling down; empty for a run without fallback. */
463
+ function fallbackLines(agents, c) {
464
+ const out = [];
465
+ for (const a of agents) {
466
+ if (a.fallback)
467
+ out.push(` ${c.cyan(a.name)} ${c.yellow(`備援中 fallback: ${a.fallback.kind}`)} (${modelName(`${a.runtime ?? "?"}/${a.model ?? ""}`)})`);
468
+ for (const k of a.cooldowns ?? [])
469
+ out.push(` ${c.cyan(a.name)} ${c.yellow(`冷卻中:${modelName(k.model)} 剩 ${shortSec(k.remaining_sec)}`)}(${k.kind})`);
470
+ }
471
+ return out;
472
+ }
428
473
  export function formatStatusReport(report, now = Date.now(), color = false) {
429
474
  const c = paint(color);
430
475
  const run = report.run;
@@ -452,6 +497,9 @@ export function formatStatusReport(report, now = Date.now(), color = false) {
452
497
  const table = rows.map((r, ri) => r.map((_, i) => cell(r, ri, i)).join(" ").trimEnd());
453
498
  table.splice(1, 0, c.dim(w.map((n) => "-".repeat(n)).join(" ")));
454
499
  const lines = [`${c.bold("專案:")}${report.project.name} ${c.dim(`(repo:${report.project.dir})`)}`, "", ...summaryBlock(run, now, c), "", ...table];
500
+ const fb = fallbackLines(report.agents, c);
501
+ if (fb.length)
502
+ lines.push("", ...fb);
455
503
  if (run)
456
504
  lines.push("", `輸出 tokens:${tokens(run.output_tokens)}`);
457
505
  if (report.memory_advice?.length)
package/dist/validate.js CHANGED
@@ -25,6 +25,7 @@ export function validateProject(project) {
25
25
  if (a.effort && a.runtime && !RUNTIME_EFFORTS[a.runtime].includes(a.effort)) {
26
26
  err(`Agent "${a.name}": effort "${a.effort}" is not supported by ${a.runtime} (use ${RUNTIME_EFFORTS[a.runtime].join(", ")}).`);
27
27
  }
28
+ validateFallback(project, a, err);
28
29
  if (!fs.existsSync(a.agentMd))
29
30
  err(`Agent "${a.name}": AGENT.md not found at ${a.agentMd}`);
30
31
  if (!a.memory.global && !a.memory.project)
@@ -75,6 +76,41 @@ export function validateProject(project) {
75
76
  }
76
77
  return { issues, enforcement, ok: !issues.some((i) => i.level === "error") };
77
78
  }
79
+ const PARITY_FIELDS = ["memory", "agentMd", "otherContext", "shared", "repoInstructions"];
80
+ const norm = (m) => (m ?? "").trim().toLowerCase();
81
+ /** Load-time (fail closed) checks of one agent's fallback chain: well-formed, not a no-op, and no weaker at write-scope than the primary. */
82
+ function validateFallback(project, a, err) {
83
+ for (const bad of a.fallbackInvalid) {
84
+ err(bad.reason === "empty"
85
+ ? `agent ${a.name}: fallback #${bad.index + 1} needs a model or a runtime.`
86
+ : `agent ${a.name}: fallback #${bad.index + 1} has no recognizable runtime (set runtime, or use a recognizable model).`);
87
+ }
88
+ const primary = a.runtime ? enforcementFor(project, a) : undefined;
89
+ for (const f of a.fallback) {
90
+ const label = `${f.runtime}/${f.model || "(default model)"}`;
91
+ if (f.runtime === a.runtime && norm(f.model) === norm(a.model)) {
92
+ // A chain shared through dispatcher.fallback may already contain this agent's primary: just skip it then.
93
+ if (!a.fallbackShared)
94
+ err(`agent ${a.name}: fallback ${label} is the same as the primary runtime and model.`);
95
+ continue;
96
+ }
97
+ if (f.effort && !RUNTIME_EFFORTS[f.runtime].includes(f.effort)) {
98
+ err(`agent ${a.name}: fallback ${label}: effort "${f.effort}" is not supported by ${f.runtime} (use ${RUNTIME_EFFORTS[f.runtime].join(", ")}).`);
99
+ }
100
+ if (!primary)
101
+ continue;
102
+ // `prompt-only` is the one level that is no enforcement at all; everything else (os, tool-rules, post-hoc) still holds the line.
103
+ const swapped = enforcementFor(project, { ...a, runtime: f.runtime, model: f.model || undefined, effort: f.effort });
104
+ for (const k of PARITY_FIELDS) {
105
+ const from = primary[k];
106
+ const to = swapped[k];
107
+ if (from === "n/a" || to === "n/a")
108
+ continue;
109
+ if (to === "prompt-only" && from !== "prompt-only")
110
+ err(`agent ${a.name}: fallback ${label} cannot enforce ${k} (${from} -> ${to})`);
111
+ }
112
+ }
113
+ }
78
114
  export function formatEnforcement(list) {
79
115
  const header = ["agent", "runtime", "memory", "AGENT.md", "others' ctx", "handoff", "repo CLAUDE/AGENTS"];
80
116
  const rows = [header];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Config + dispatcher for teams of Claude Code / Codex agents with scoped context and file-based mailboxes",
5
5
  "keywords": [
6
6
  "ai",