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.
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { realInvoker } from "./adapters/index.js";
4
+ import { agentForCandidate, candidatesFor, Cooldowns, planAttempts, runWithFallback } from "./fallback.js";
4
5
  import { briefOf, digestOf, doneContract, mergeSteps, parseSteps } from "./format.js";
5
6
  import { ProtectedGuard } from "./guard.js";
6
7
  import { addBatch, askersWithAnswers, askReplyPath, checkAnswers, describeCheck, isComplete, markDelivered, parseAskBody, readAskReply, renderAnswers, resolveOptionLetters, writeAskReply } from "./ask-reply.js";
@@ -35,6 +36,21 @@ function readIndex(file) {
35
36
  function retryPrompt(prompt, error) {
36
37
  return `${prompt}\n\n# Retry notice\nYour previous attempt at this wake-up failed${error ? ` (${error})` : ""} after possibly changing files already. Its mail was discarded. Check the current state of the repository before redoing the work, and send the mail again.`;
37
38
  }
39
+ /** `runtime/model`, or the runtime alone for its default model. */
40
+ const modelLabel = (c) => (c.model ? `${c.runtime}/${c.model}` : c.runtime);
41
+ /** Each model given up on once, in order, with its last failure (retries on the same model are one entry). */
42
+ function lastPerModel(tried) {
43
+ const out = [];
44
+ for (const t of tried) {
45
+ const last = out.at(-1);
46
+ const entry = { runtime: t.runtime, ...(t.model ? { model: t.model } : {}), ...(t.kind ? { kind: t.kind } : {}), ...(t.error ? { error: t.error } : {}) };
47
+ if (last && last.runtime === t.runtime && last.model === t.model)
48
+ out[out.length - 1] = entry;
49
+ else
50
+ out.push(entry);
51
+ }
52
+ return out;
53
+ }
38
54
  export class RunSession {
39
55
  opts;
40
56
  resume;
@@ -58,6 +74,8 @@ export class RunSession {
58
74
  doneOutcome;
59
75
  doneNote;
60
76
  failure;
77
+ /** Models cooling down after a rate limit or quota error; one table for the run, shared by parallel wake-ups. */
78
+ cooldowns;
61
79
  doneVerification;
62
80
  /** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
63
81
  openPrRequested = false;
@@ -225,6 +243,7 @@ export class RunSession {
225
243
  this.log = log;
226
244
  this.guard = guard;
227
245
  this.state = state;
246
+ this.cooldowns = Cooldowns.from(state.cooldowns);
228
247
  // Only a segment that starts here has a baseline: a fresh run or a follow-up reopen. A plain resume continues one begun by an earlier process.
229
248
  this.sharedAtStart = !resume || opts.followup ? readIndex(sharedIndex(project)) : undefined;
230
249
  }
@@ -273,7 +292,7 @@ export class RunSession {
273
292
  return pending;
274
293
  }
275
294
  async wake(agent) {
276
- const { invoke, say, cfg, runDir, project, log, state, claims, attempts, noSessionNoted, opts } = this;
295
+ const { invoke, say, cfg, runDir, project, log, state, claims, attempts, noSessionNoted, opts, cooldowns } = this;
277
296
  const queue = listUnread(project, agent.name);
278
297
  if (queue.length === 0)
279
298
  return { ok: true };
@@ -293,92 +312,153 @@ export class RunSession {
293
312
  userPrompt: buildUserPrompt(project, agent, queue, unread.length, (state.reopened?.length ?? 0) > 0),
294
313
  timeoutSec: cfg.wake_timeout_sec,
295
314
  };
296
- let result;
297
- for (let attempt = 0; attempt <= cfg.retry; attempt++) {
298
- if (state.max_rounds > 0 && state.rounds >= state.max_rounds) {
315
+ // Without a fallback chain (one candidate) the loop, its output and its records are exactly those of plain retries (spec 20.2 decision 11).
316
+ const all = candidatesFor(agent);
317
+ const chain = all.length > 1;
318
+ const candidates = chain ? planAttempts(agent, cooldowns, Date.now()) : all;
319
+ // The model a backup's calls stand in for: the one switched away from in this wake, or the cooling-down primary it started past.
320
+ let replaced = candidates[0]?.primary ? undefined : all[0];
321
+ let calls = 0;
322
+ let stoppedByCancel = false;
323
+ const outcome = await runWithFallback({
324
+ candidates,
325
+ legacy: !chain,
326
+ retry: cfg.retry,
327
+ cooldowns,
328
+ cooldownSec: cfg.fallback_cooldown_sec,
329
+ signal: opts.signal,
330
+ sleep: opts.sleep,
331
+ beforeAttempt: (_c, ctx) => {
332
+ // A cancel that came during the previous call: no new model is tried (without a chain the call still goes ahead, as before).
333
+ if (chain && ctx.attempt > 0 && opts.signal?.aborted) {
334
+ stoppedByCancel = true;
335
+ return "stop";
336
+ }
299
337
  // The round limit (when one is set) ends the run; this message stays unread for the resume.
300
- abandonClaim(runDir, claim.id);
301
- claims.delete(agent.name);
302
- return { ok: false, cancelled: true };
303
- }
304
- const round = ++state.rounds;
305
- const started = Date.now();
306
- const att = beginAttempt(runDir, claim.id);
307
- say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${agent.runtime})${attempt ? ` — retry ${attempt}` : ""}`);
308
- const sessionId = agent.resume ? state.sessions[agent.name] : undefined;
309
- const handling = unread.map((m) => ({ from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body), digest: digestOf(m.meta.type, m.body) }));
310
- // What the panel shows for the message being worked on is uncut; the wake record below keeps the short digest.
311
- const activeHandling = unread.map((m, i) => ({ ...handling[i], digest: digestOf(m.meta.type, m.body, true) }));
312
- state.active[agent.name] = { round, since: new Date(started).toISOString(), handling: activeHandling };
313
- this.saveState();
314
- result = await invoke({ ...base, signal: opts.signal, logDir: attemptLogDir(runDir, att.id), userPrompt: attempt ? retryPrompt(base.userPrompt, result?.error) : base.userPrompt, sessionId }).catch((e) => ({
315
- ok: false,
316
- text: "",
317
- exitCode: null,
318
- timedOut: false,
319
- error: e.message,
320
- }));
321
- if (result.sessionId && agent.resume)
322
- state.sessions[agent.name] = result.sessionId;
323
- if (result.outputTokens)
324
- state.output_tokens += result.outputTokens;
325
- delete state.active[agent.name];
326
- state.wakes.push({
327
- round,
328
- agent: agent.name,
329
- at: new Date().toISOString(),
330
- duration_ms: Date.now() - started,
331
- ok: result.ok,
332
- output_tokens: result.outputTokens,
333
- handling,
334
- error: result.error,
335
- });
336
- state.last_wake[agent.name] = { at: new Date().toISOString(), ok: result.ok, error: result.error };
337
- log("wake", {
338
- agent: agent.name,
339
- runtime: agent.runtime,
340
- attempt,
341
- ok: result.ok,
342
- exit_code: result.exitCode,
343
- timed_out: result.timedOut,
344
- duration_ms: Date.now() - started,
345
- messages: unread.map((m) => m.meta.id),
346
- error: result.error,
347
- });
348
- this.saveState();
349
- if (result.ok) {
350
- attempts.set(agent.name, finishAttempt(runDir, att.id, "output_ready"));
351
- if (agent.resume && !result.sessionId && !state.sessions[agent.name] && !noSessionNoted.has(agent.name)) {
352
- noSessionNoted.add(agent.name);
353
- this.note(`${agent.name} has resume: true but its ${agent.runtime} CLI reported no session id, so every wake-up starts a fresh session.`);
338
+ return state.max_rounds > 0 && state.rounds >= state.max_rounds ? "stop" : undefined;
339
+ },
340
+ onSwitch: (from, to, kind) => {
341
+ replaced = from;
342
+ this.syncCooldowns();
343
+ if (opts.signal?.aborted)
344
+ return;
345
+ say(` ${agent.name} falling back to ${modelLabel(to)} (${kind ?? "unknown error"})`);
346
+ this.note(`${agent.name} fell back from ${modelLabel(from)} to ${modelLabel(to)} (${kind ?? "unknown error"}); the backup starts a fresh session and redoes this wake-up. ` +
347
+ `Anything the failed attempts already did outside the mailbox (file edits, commands) was not undone: side effects may have happened.`);
348
+ },
349
+ invoke: async (c, ctx) => {
350
+ calls++;
351
+ this.syncCooldowns();
352
+ const attempt = ctx.attempt;
353
+ // A backup runs as the same agent on another runtime/model, in a session of its own.
354
+ const runAs = agentForCandidate(agent, c);
355
+ const sessionKey = c.primary ? agent.name : `${agent.name}@${c.key}`;
356
+ const round = ++state.rounds;
357
+ const started = Date.now();
358
+ const att = beginAttempt(runDir, claim.id);
359
+ const retryN = chain ? ctx.candidateAttempt : attempt;
360
+ say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${chain ? modelLabel(c) : agent.runtime})${retryN ? ` — retry ${retryN}` : ""}`);
361
+ const sessionId = agent.resume ? state.sessions[sessionKey] : undefined;
362
+ const handling = unread.map((m) => ({ from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body), digest: digestOf(m.meta.type, m.body) }));
363
+ // What the panel shows for the message being worked on is uncut; the wake record below keeps the short digest.
364
+ const activeHandling = unread.map((m, i) => ({ ...handling[i], digest: digestOf(m.meta.type, m.body, true) }));
365
+ state.active[agent.name] = { round, since: new Date(started).toISOString(), handling: activeHandling };
366
+ this.saveState();
367
+ const result = await invoke({
368
+ ...base,
369
+ agent: runAs,
370
+ signal: opts.signal,
371
+ logDir: attemptLogDir(runDir, att.id),
372
+ userPrompt: attempt ? retryPrompt(base.userPrompt, ctx.previous?.error) : base.userPrompt,
373
+ sessionId,
374
+ }).catch((e) => ({
375
+ ok: false,
376
+ text: "",
377
+ exitCode: null,
378
+ timedOut: false,
379
+ error: e.message,
380
+ }));
381
+ if (result.sessionId && agent.resume)
382
+ state.sessions[sessionKey] = result.sessionId;
383
+ if (result.outputTokens)
384
+ state.output_tokens += result.outputTokens;
385
+ delete state.active[agent.name];
386
+ // With a chain only: which model ran, what it replaced, why it failed.
387
+ const which = chain ? { runtime: c.runtime, model: c.model, fallback_from: c.primary || !replaced ? undefined : modelLabel(replaced), kind: result.kind } : {};
388
+ state.wakes.push({
389
+ round,
390
+ agent: agent.name,
391
+ at: new Date().toISOString(),
392
+ duration_ms: Date.now() - started,
393
+ ok: result.ok,
394
+ output_tokens: result.outputTokens,
395
+ handling,
396
+ error: result.error,
397
+ ...which,
398
+ });
399
+ state.last_wake[agent.name] = { at: new Date().toISOString(), ok: result.ok, error: result.error, ...(chain ? { runtime: c.runtime, model: c.model } : {}) };
400
+ log("wake", {
401
+ agent: agent.name,
402
+ runtime: c.runtime,
403
+ attempt,
404
+ ok: result.ok,
405
+ exit_code: result.exitCode,
406
+ timed_out: result.timedOut,
407
+ duration_ms: Date.now() - started,
408
+ messages: unread.map((m) => m.meta.id),
409
+ error: result.error,
410
+ ...(chain ? { model: c.model, fallback_from: which.fallback_from, kind: result.kind } : {}),
411
+ });
412
+ this.saveState();
413
+ if (result.ok) {
414
+ attempts.set(agent.name, finishAttempt(runDir, att.id, "output_ready"));
415
+ if (agent.resume && !result.sessionId && !state.sessions[sessionKey] && !noSessionNoted.has(agent.name)) {
416
+ noSessionNoted.add(agent.name);
417
+ this.note(`${agent.name} has resume: true but its ${c.runtime} CLI reported no session id, so every wake-up starts a fresh session.`);
418
+ }
419
+ return result;
354
420
  }
355
- break;
356
- }
357
- if (result.cancelled) {
358
- // Stopped on purpose: not a failure to report. Its output is set aside and its input stays unread for the resume.
421
+ if (result.cancelled) {
422
+ // Stopped on purpose: not a failure to report. Its output is set aside (its input stays unread for the resume, below).
423
+ const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
424
+ finishAttempt(runDir, att.id, "failed", { error: "cancelled", parked });
425
+ return result;
426
+ }
427
+ // A failed attempt's output is never trusted: park it under the attempt, away from the next attempt's result.
359
428
  const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
360
- finishAttempt(runDir, att.id, "failed", { error: "cancelled", parked });
361
- abandonClaim(runDir, claim.id);
362
- claims.delete(agent.name);
363
- say(` ${agent.name} stopped (cancelled)`);
364
- return { ok: false, cancelled: true };
365
- }
366
- // A failed attempt's output is never trusted: park it under the attempt, away from the next attempt's result.
367
- const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
368
- finishAttempt(runDir, att.id, "failed", { error: result.error, parked });
369
- say(` ${agent.name} failed: ${result.error ?? "unknown error"}`);
370
- this.note(`${agent.name} attempt ${att.n} failed (${result.error ?? "unknown error"})${parked.length ? `; ${parked.length} output file(s) set aside in ${path.join("mail", "attempts", att.id, "outbox")}` : ""}. ` +
371
- `Anything it already did outside its mailbox (file edits, commands) was not undone: side effects may have happened.`);
372
- }
373
- if (result?.ok)
429
+ finishAttempt(runDir, att.id, "failed", { error: result.error, parked });
430
+ say(` ${agent.name} failed: ${result.error ?? "unknown error"}`);
431
+ this.note(`${agent.name} attempt ${att.n} failed (${result.error ?? "unknown error"})${parked.length ? `; ${parked.length} output file(s) set aside in ${path.join("mail", "attempts", att.id, "outbox")}` : ""}. ` +
432
+ `Anything it already did outside its mailbox (file edits, commands) was not undone: side effects may have happened.`);
433
+ return result;
434
+ },
435
+ });
436
+ if (this.syncCooldowns())
437
+ this.saveState();
438
+ const { result } = outcome;
439
+ if (result?.ok) {
374
440
  markOutputReady(runDir, claim.id);
375
- if (!result?.ok) {
376
- // The agent failed every time it was called: the run stops here (see settle) and its message stays unread for the resume.
377
- abandonClaim(runDir, claim.id);
378
- claims.delete(agent.name);
379
- return { ok: false, error: result?.error, attempts: cfg.retry + 1 };
441
+ return { ok: true };
380
442
  }
381
- return { ok: true };
443
+ // The input stays unread for the resume: the round limit, or a cancel (during a call or a backoff).
444
+ abandonClaim(runDir, claim.id);
445
+ claims.delete(agent.name);
446
+ if (outcome.stopped === "beforeAttempt" && !stoppedByCancel)
447
+ return { ok: false, cancelled: true };
448
+ if (result?.cancelled || outcome.stopped) {
449
+ say(` ${agent.name} stopped (cancelled)`);
450
+ return { ok: false, cancelled: true };
451
+ }
452
+ // The agent failed every time it was called: the run stops here (see settle) and its message stays unread for the resume.
453
+ return { ok: false, error: result?.error, attempts: calls, ...(chain ? { tried: lastPerModel(outcome.tried) } : {}) };
454
+ }
455
+ /** Copy the shared cooldown table into the state (only once something has cooled down); true when it was. */
456
+ syncCooldowns() {
457
+ const table = this.cooldowns.toJSON();
458
+ if (!Object.keys(table).length)
459
+ return false;
460
+ this.state.cooldowns = table;
461
+ return true;
382
462
  }
383
463
  /**
384
464
  * A finished member's files go from its worktree into the repo before anyone reads its mail, so the lead
@@ -543,7 +623,8 @@ export class RunSession {
543
623
  const failedIdx = results.findIndex((r) => !r.ok && !r.cancelled);
544
624
  if (failedIdx >= 0) {
545
625
  const failed = must(batch[failedIdx], "failed agent");
546
- this.failure = { agent: failed.name, error: results[failedIdx]?.error, attempts: results[failedIdx]?.attempts ?? this.cfg.retry + 1 };
626
+ const r = results[failedIdx];
627
+ this.failure = { agent: failed.name, error: r?.error, attempts: r?.attempts ?? this.cfg.retry + 1, ...(r?.tried ? { tried: r.tried } : {}) };
547
628
  state.failure = this.failure;
548
629
  this.endReason = failed.name === project.lead ? "lead_failed" : "agent_failed";
549
630
  return;
@@ -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"];