agent-lyceum 0.26.0 → 0.28.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
@@ -75,6 +75,10 @@ agents:
75
75
 
76
76
  Folder layout, every agent field and the rules `doctor` checks: [Configuration](docs/configuration.md).
77
77
 
78
+ ## Downgrading
79
+
80
+ Older versions refuse a `team.yaml` or `project.yaml` that contains `model_tiers` (a load error, "Unrecognized key"). Before using an older binary, remove `model_tiers` from every `project.yaml` and from `team.yaml`. Other mail and run state are unaffected: older versions ignore a `tier` line in mail. See [Model tiers](docs/configuration.md#model-tiers). (Spec 24.3.2, 24.13)
81
+
78
82
  ## Topics
79
83
 
80
84
  | Topic | Read |
package/README.zh-TW.md CHANGED
@@ -75,6 +75,10 @@ agents:
75
75
 
76
76
  資料夾結構、所有 agent 欄位與 `doctor` 檢查的規則:[設定](docs/configuration.zh-TW.md)。
77
77
 
78
+ ## 降級(改用舊版)
79
+
80
+ 舊版程式會拒絕載入含 `model_tiers` 的 `team.yaml` 或 `project.yaml`(載入錯誤,「Unrecognized key」)。改用舊版前,請先從每個 `project.yaml` 與 `team.yaml` 拿掉 `model_tiers`。其他信件與 run 狀態不受影響:舊版會忽略信件中的 `tier` 行。見[模型分級](docs/configuration.zh-TW.md#模型分級)。(spec 24.3.2、24.13)
81
+
78
82
  ## 主題
79
83
 
80
84
  | 主題 | 閱讀 |
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { repoDirFor, scratchCwd, writePolicy } from "../policy.js";
4
+ import { canonicalPath } from "../paths.js";
4
5
  /** Claude Code path rules: `//abs/path` — a leading "/" plus the absolute path. */
5
6
  const rule = (tool, p) => `${tool}(/${p})`;
6
7
  function denyRules(p) {
@@ -34,6 +35,15 @@ export function buildClaudeSettings(project, agent, override) {
34
35
  for (const f of pol.allowFiles)
35
36
  allow.push(rule("Edit", f));
36
37
  const deny = pol.deny.flatMap(denyRules);
38
+ // Spec 25: network allowances; never with a policy override (a minimal-authority wake).
39
+ const sb = override ? undefined : agent.sandbox;
40
+ const network = {};
41
+ if (sb?.network.length)
42
+ network.allowedDomains = sb.network;
43
+ if (sb?.localBinding)
44
+ network.allowLocalBinding = true;
45
+ if (sb?.unixSockets.length)
46
+ network.allowUnixSockets = sb.unixSockets.map(canonicalPath);
37
47
  return {
38
48
  // The user's own and plugin hooks (e.g. a hook that asks the user a question) would block every tool call of a headless wake-up.
39
49
  disableAllHooks: true,
@@ -45,6 +55,7 @@ export function buildClaudeSettings(project, agent, override) {
45
55
  allowWrite: [override?.cwd ?? ws?.root ?? project.dir, ...pol.allowDirs, ...pol.allowFiles],
46
56
  denyWrite: pol.deny,
47
57
  },
58
+ ...(Object.keys(network).length ? { network } : {}),
48
59
  },
49
60
  };
50
61
  }
@@ -63,6 +63,8 @@ export function buildCodexInvocation(input) {
63
63
  'approval_policy="never"',
64
64
  "-c",
65
65
  `sandbox_workspace_write.writable_roots=${JSON.stringify(roots)}`,
66
+ // Codex has one switch for all network access; spec 25 never uses it under a policy override.
67
+ ...(!input.policy && agent.sandbox?.network.length ? ["-c", "sandbox_workspace_write.network_access=true"] : []),
66
68
  "--json",
67
69
  "-o",
68
70
  lastFile,
package/dist/call.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { invokeWithSandboxChecks } from "./sandbox-invocation.js";
3
4
  import { realInvoker } from "./adapters/index.js";
4
5
  import { must } from "./assert.js";
5
6
  import { ConfigError, loadGlobal, resolveProjectFrom } from "./config.js";
@@ -55,6 +56,8 @@ export function callProject(home, agent, dir, callDir) {
55
56
  lead: agent.name,
56
57
  dispatcher: DISPATCHER_DEFAULTS,
57
58
  agents: { [agent.name]: agent },
59
+ modelTiers: {},
60
+ modelTiersFrom: {},
58
61
  paths,
59
62
  solo: "call",
60
63
  };
@@ -155,7 +158,7 @@ export async function callAgent(o) {
155
158
  return;
156
159
  say(` ${agent.name} falling back to ${to.model ? `${to.runtime}/${to.model}` : to.runtime} (${kind ?? "unknown error"})`);
157
160
  },
158
- invoke: (c) => invoke({
161
+ invoke: (c, ctx) => invokeWithSandboxChecks(invoke, {
159
162
  project,
160
163
  agent: agentForCandidate(agent, c),
161
164
  workDir: callDir,
@@ -164,7 +167,7 @@ export async function callAgent(o) {
164
167
  timeoutSec: project.dispatcher.wake_timeout_sec,
165
168
  signal: o.signal,
166
169
  logDir: logDirFor(c),
167
- }).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message })),
170
+ }, agent, { event: (name, fields) => say(JSON.stringify({ event: name, ...fields })) }, ctx.attempt).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message })),
168
171
  });
169
172
  const result = must(outcome.result, "the call's result");
170
173
  const tried = chain ? outcome.tried : undefined;
package/dist/config.d.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import { type ProjectPaths } from "./paths.js";
2
2
  import type { AgentWorkspace } from "./worktree.js";
3
- import { type DispatcherSettings, type Effort, type FallbackEntry, type Runtime, type WebLevel } from "./schema.js";
3
+ import { type DispatcherSettings, type Effort, type FallbackEntry, type SandboxPartialT, 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";
7
+ export interface SandboxAllowances {
8
+ write: string[];
9
+ network: string[];
10
+ localBinding: boolean;
11
+ unixSockets: string[];
12
+ }
7
13
  export interface ResolvedAgent {
8
14
  name: string;
9
15
  runtime?: Runtime;
@@ -22,6 +28,8 @@ export interface ResolvedAgent {
22
28
  canAskUser: boolean;
23
29
  /** Web access for this agent; undefined = not configured, so the runtime keeps its own default. */
24
30
  allowWeb?: WebLevel;
31
+ /** Spec 25 allowances; unset when none is requested (the only switch that keeps output identical). Paths are absolute, `~` expanded, not yet canonical. */
32
+ sandbox?: SandboxAllowances;
25
33
  /** Ordered failover chain (runtime and model filled in); empty = no fallback. */
26
34
  fallback: FallbackEntry[];
27
35
  /** True when the chain came from `dispatcher.fallback` rather than the agent's own setting. */
@@ -33,6 +41,8 @@ export interface ResolvedAgent {
33
41
  }[];
34
42
  /** Where each field's value came from (for `validate` output). */
35
43
  sources: Record<string, Source>;
44
+ /** Spec 24: the tiers this agent can run on (its runtime's ladder up to and including `model`); unset when nothing is below `model`. */
45
+ tiers?: string[];
36
46
  /** Set when the run bound this agent as an instance of a role that has several (spec 23): which role, and the instance number. */
37
47
  instance?: {
38
48
  role: string;
@@ -52,6 +62,10 @@ export interface ResolvedProject {
52
62
  lead: string;
53
63
  dispatcher: DispatcherSettings;
54
64
  agents: Record<string, ResolvedAgent>;
65
+ /** Spec 24: non-empty model ladders per runtime, lowest first (project.yaml replaces team.yaml's per runtime). */
66
+ modelTiers: Partial<Record<Runtime, string[]>>;
67
+ /** Which file each ladder in `modelTiers` came from. */
68
+ modelTiersFrom: Partial<Record<Runtime, "global" | "project">>;
55
69
  paths: ProjectPaths;
56
70
  /** Removed settings still present in config files (ignored); `validate` warns once per file. */
57
71
  ignoredSettings?: {
@@ -73,6 +87,10 @@ export interface ResolvedProject {
73
87
  solo?: "run" | "call";
74
88
  }
75
89
  export declare function loadGlobal(home: string): {
90
+ model_tiers?: {
91
+ "claude-code"?: string[] | undefined;
92
+ codex?: string[] | undefined;
93
+ } | undefined;
76
94
  agents: Record<string, {
77
95
  runtime?: "claude-code" | "codex" | undefined;
78
96
  model?: string | undefined;
@@ -92,6 +110,12 @@ export declare function loadGlobal(home: string): {
92
110
  model?: string | undefined;
93
111
  effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
94
112
  }[] | undefined;
113
+ sandbox?: {
114
+ write?: string[] | undefined;
115
+ network?: string[] | undefined;
116
+ local_binding?: boolean | undefined;
117
+ unix_sockets?: string[] | undefined;
118
+ } | undefined;
95
119
  owns?: unknown;
96
120
  }>;
97
121
  };
@@ -100,6 +124,10 @@ export declare function loadProjectRaw(home: string, name: string): {
100
124
  team: {
101
125
  lead: string;
102
126
  };
127
+ model_tiers?: {
128
+ "claude-code"?: string[] | undefined;
129
+ codex?: string[] | undefined;
130
+ } | undefined;
103
131
  dispatcher: {
104
132
  max_rounds?: number | undefined;
105
133
  max_parallel?: unknown;
@@ -136,6 +164,12 @@ export declare function loadProjectRaw(home: string, name: string): {
136
164
  model?: string | undefined;
137
165
  effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
138
166
  }[] | undefined;
167
+ sandbox?: {
168
+ write?: string[] | undefined;
169
+ network?: string[] | undefined;
170
+ local_binding?: boolean | undefined;
171
+ unix_sockets?: string[] | undefined;
172
+ } | undefined;
139
173
  owns?: unknown;
140
174
  }>;
141
175
  };
@@ -163,6 +197,8 @@ export type SourceInfo = {
163
197
  } | {
164
198
  default: true;
165
199
  };
200
+ /** Spec 25.4.2: validated, deduplicated allowances; undefined when nothing is requested. */
201
+ export declare function resolveSandbox(agent: string, s: SandboxPartialT | undefined): SandboxAllowances | undefined;
166
202
  /** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
167
203
  export declare function resolveProjectWithSources(home: string, name: string): {
168
204
  project: ResolvedProject;
package/dist/config.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import YAML from "yaml";
4
- import { absPath, homePaths, projectPaths, } from "./paths.js";
5
- import { DISPATCHER_DEFAULTS, GlobalConfig, REMOVED_AGENT_KEYS, REMOVED_DISPATCHER_KEYS, ProjectConfig, inferRuntime, } from "./schema.js";
4
+ import { absPath, canonicalPath, escapesParent, expandHome, homePaths, projectPaths, } from "./paths.js";
5
+ import { availableTiers } from "./tiers.js";
6
+ import { DISPATCHER_DEFAULTS, GlobalConfig, REMOVED_AGENT_KEYS, REMOVED_DISPATCHER_KEYS, ProjectConfig, inferRuntime, RUNTIMES, } from "./schema.js";
6
7
  export class ConfigError extends Error {
7
8
  }
8
9
  function readYaml(file, schema, label) {
@@ -60,6 +61,16 @@ export function resolveProjectFrom(home, name, g, p) {
60
61
  const lead = p.team.lead;
61
62
  const { max_parallel: _mp, max_concurrent_runs: _mc, workspace_mode: _wm, fallback: dispatcherFallback, ...liveDispatcher } = p.dispatcher;
62
63
  const dispatcher = { ...DISPATCHER_DEFAULTS, ...stripUndef(liveDispatcher) };
64
+ const modelTiers = {};
65
+ const modelTiersFrom = {};
66
+ for (const rt of RUNTIMES) {
67
+ const fromProject = p.model_tiers?.[rt] !== undefined;
68
+ const ladder = fromProject ? p.model_tiers?.[rt] : g.model_tiers?.[rt];
69
+ if (ladder?.length) {
70
+ modelTiers[rt] = [...ladder];
71
+ modelTiersFrom[rt] = fromProject ? "project" : "global";
72
+ }
73
+ }
63
74
  const agents = {};
64
75
  for (const agentName of Object.keys(p.agents)) {
65
76
  const gl = resolveLayer(g.agents[agentName], home);
@@ -144,12 +155,14 @@ export function resolveProjectFrom(home, name, g, p) {
144
155
  }
145
156
  }
146
157
  const { entries: fallback, invalid: fallbackInvalid } = resolveFallback(rawFallback, runtime);
158
+ const tiers = runtime ? availableTiers(modelTiers[runtime], model) : undefined;
147
159
  const isLead = agentName === lead;
148
160
  const resume = pick("resume");
149
161
  const canMessage = pick("can_message");
150
162
  const canEdit = pick("can_edit_agent_md");
151
163
  const canAsk = pick("can_ask_user");
152
164
  const allowWeb = pick("allow_web");
165
+ const sandbox = resolveSandbox(agentName, pick("sandbox"));
153
166
  for (const [k, v] of [
154
167
  ["resume", resume],
155
168
  ["can_message", canMessage],
@@ -171,10 +184,12 @@ export function resolveProjectFrom(home, name, g, p) {
171
184
  canEditAgentMd: canEdit ?? isLead,
172
185
  canAskUser: canAsk ?? isLead,
173
186
  allowWeb,
187
+ ...(sandbox ? { sandbox } : {}),
174
188
  fallback,
175
189
  fallbackShared,
176
190
  fallbackInvalid,
177
191
  sources,
192
+ ...(tiers ? { tiers } : {}),
178
193
  };
179
194
  }
180
195
  return {
@@ -184,6 +199,8 @@ export function resolveProjectFrom(home, name, g, p) {
184
199
  lead,
185
200
  dispatcher,
186
201
  agents,
202
+ modelTiers,
203
+ modelTiersFrom,
187
204
  paths,
188
205
  ignoredSettings: collectIgnored(hp.globalConfig, g, paths.config, p),
189
206
  };
@@ -252,14 +269,46 @@ export function findProjectForCwd(home, cwd) {
252
269
  let best;
253
270
  for (const pr of listProjects(home)) {
254
271
  const rel = path.relative(pr.dir, cwd);
255
- if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
272
+ if (!escapesParent(rel)) {
256
273
  if (!best || pr.dir.length > best.len)
257
274
  best = { name: pr.name, len: pr.dir.length };
258
275
  }
259
276
  }
260
277
  return best?.name;
261
278
  }
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"];
279
+ 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", "sandbox"];
280
+ /** Claude pattern metacharacters and control characters: refused in sandbox paths instead of escaped (spec 25.4.1). */
281
+ const PATH_CHARS = /[*?[\]{}()!\\]/;
282
+ const PATH_META = { test: (p) => PATH_CHARS.test(p) || [...p].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127) };
283
+ function sandboxPath(agent, key, i, raw) {
284
+ const at = `agents.${agent}.sandbox.${key}.${i}`;
285
+ if (PATH_META.test(raw))
286
+ throw new ConfigError(`${at}: must not contain pattern characters (* ? [ ] { } ( ) !), backslashes or control characters (got ${JSON.stringify(raw)})`);
287
+ if (/^\$|\$\{?[A-Za-z_]/.test(raw))
288
+ throw new ConfigError(`${at}: environment variables are not expanded; write the absolute path (got "${raw}")`);
289
+ const expanded = expandHome(raw);
290
+ if (!path.isAbsolute(expanded))
291
+ throw new ConfigError(`${at}: must be an absolute path or start with ~/ (got "${raw}")`);
292
+ const resolved = path.resolve(expanded);
293
+ const canon = canonicalPath(resolved);
294
+ for (const p of [resolved, canon])
295
+ if (PATH_META.test(p))
296
+ throw new ConfigError(`${at}: resolved path contains pattern characters (${JSON.stringify(p)})`);
297
+ return resolved;
298
+ }
299
+ /** Spec 25.4.2: validated, deduplicated allowances; undefined when nothing is requested. */
300
+ export function resolveSandbox(agent, s) {
301
+ if (!s)
302
+ return undefined;
303
+ const uniq = (xs) => [...new Set(xs)];
304
+ const write = uniq((s.write ?? []).map((p, i) => sandboxPath(agent, "write", i, p)));
305
+ const unixSockets = uniq((s.unix_sockets ?? []).map((p, i) => sandboxPath(agent, "unix_sockets", i, p)));
306
+ const network = uniq(s.network ?? []);
307
+ const localBinding = s.local_binding === true;
308
+ if (!write.length && !unixSockets.length && !network.length && !localBinding)
309
+ return undefined;
310
+ return { write, network, localBinding, unixSockets };
311
+ }
263
312
  /** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
264
313
  export function resolveProjectWithSources(home, name) {
265
314
  const project = resolveProject(home, name);
@@ -278,6 +327,11 @@ export function resolveProjectWithSources(home, name) {
278
327
  }
279
328
  if (raw.dispatcher.max_instances !== undefined)
280
329
  sources["dispatcher.max_instances"] = { file: projectFile, key: "dispatcher.max_instances" };
330
+ for (const rt of RUNTIMES) {
331
+ const from = project.modelTiersFrom[rt];
332
+ if (from)
333
+ sources[`model_tiers.${rt}`] = { file: from === "project" ? projectFile : globalFile, key: `model_tiers.${rt}` };
334
+ }
281
335
  for (const a of Object.values(project.agents)) {
282
336
  const at = (src, field) => {
283
337
  const key = `agents.${a.name}.${field}`;
@@ -307,6 +361,10 @@ export function resolveProjectWithSources(home, name) {
307
361
  else
308
362
  sources[`agents.${a.name}.${f}`] = at(src, f);
309
363
  }
364
+ // The ladder is the runtime's, so its source is the ladder's own key (spec 24.9).
365
+ const ladderSrc = a.tiers ? sources[`model_tiers.${a.runtime}`] : undefined;
366
+ if (ladderSrc)
367
+ sources[`agents.${a.name}.model_tiers`] = ladderSrc;
310
368
  }
311
369
  return { project, sources };
312
370
  }
@@ -314,6 +372,9 @@ export function resolveProjectWithSources(home, name) {
314
372
  export function flattenResolved(project) {
315
373
  const v = { dir: project.dir, "team.lead": project.lead };
316
374
  const anyFallback = Object.values(project.agents).some((a) => a.fallback.length > 0);
375
+ for (const rt of RUNTIMES)
376
+ if (project.modelTiers[rt])
377
+ v[`model_tiers.${rt}`] = project.modelTiers[rt];
317
378
  for (const [k, val] of Object.entries(project.dispatcher)) {
318
379
  if (k === "fallback_cooldown_sec" && !anyFallback)
319
380
  continue; // keep `doctor --resolved` unchanged without fallback
@@ -334,6 +395,10 @@ export function flattenResolved(project) {
334
395
  v[`${p}.allow_web`] = a.allowWeb;
335
396
  if (a.fallback.length)
336
397
  v[`${p}.fallback`] = a.fallback;
398
+ if (a.sandbox)
399
+ v[`${p}.sandbox`] = { write: a.sandbox.write, network: a.sandbox.network, local_binding: a.sandbox.localBinding, unix_sockets: a.sandbox.unixSockets };
400
+ if (a.tiers)
401
+ v[`${p}.model_tiers`] = a.tiers;
337
402
  }
338
403
  return v;
339
404
  }
@@ -0,0 +1,9 @@
1
+ /** How many distinct sent-again mails of one repeat are announced to the sender before further repeats are set aside silently. */
2
+ export declare const MAX_DUPLICATE_NOTICES = 2;
3
+ /** Key of one sender's repeat of earlier mail; JSON keeps it unambiguous whatever the names contain. */
4
+ export declare function duplicateNoticeKey(sender: string, repeats: Iterable<[string, string]>): string;
5
+ /**
6
+ * Decide whether the repeat found in outbox mail `source` is announced. Idempotent per source: the notified source ids
7
+ * are stored, so a replay after a crash neither counts twice nor loses a notice it already earned.
8
+ */
9
+ export declare function shouldNotifyDuplicate(runDir: string, key: string, source: string): boolean;
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { atomicWrite } from "./fs-util.js";
4
+ /** How many distinct sent-again mails of one repeat are announced to the sender before further repeats are set aside silently. */
5
+ export const MAX_DUPLICATE_NOTICES = 2;
6
+ const FILE = "duplicate-notices.json";
7
+ /** Key of one sender's repeat of earlier mail; JSON keeps it unambiguous whatever the names contain. */
8
+ export function duplicateNoticeKey(sender, repeats) {
9
+ return JSON.stringify([sender, [...repeats].map(([to, id]) => [to, id]).sort()]);
10
+ }
11
+ function load(runDir) {
12
+ try {
13
+ const data = JSON.parse(fs.readFileSync(path.join(runDir, FILE), "utf8"));
14
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
15
+ }
16
+ catch {
17
+ return {};
18
+ }
19
+ }
20
+ /**
21
+ * Decide whether the repeat found in outbox mail `source` is announced. Idempotent per source: the notified source ids
22
+ * are stored, so a replay after a crash neither counts twice nor loses a notice it already earned.
23
+ */
24
+ export function shouldNotifyDuplicate(runDir, key, source) {
25
+ const all = load(runDir);
26
+ const seen = Array.isArray(all[key]) ? all[key] : [];
27
+ if (seen.includes(source))
28
+ return true;
29
+ if (seen.length >= MAX_DUPLICATE_NOTICES)
30
+ return false;
31
+ all[key] = [...seen, source];
32
+ atomicWrite(path.join(runDir, FILE), `${JSON.stringify(all, null, 2)}\n`);
33
+ return true;
34
+ }
@@ -7,6 +7,8 @@ export interface Candidate {
7
7
  model?: string;
8
8
  effort?: Effort;
9
9
  primary: boolean;
10
+ /** The cheaper model a lead's `tier` picked (spec 24.7): not the primary, and not a fallback either. */
11
+ tiered?: true;
10
12
  /** `<runtime>/<model>`; the cooldown and session key. */
11
13
  key: string;
12
14
  }
@@ -14,7 +16,7 @@ export interface Candidate {
14
16
  * 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
17
  * earlier entry is dropped: a chain shared through `dispatcher.fallback` may list the agent's own primary (spec 20.12).
16
18
  */
17
- export declare function candidatesFor(agent: ResolvedAgent): Candidate[];
19
+ export declare function candidatesFor(agent: ResolvedAgent, tierModel?: string): Candidate[];
18
20
  /**
19
21
  * 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
22
  * and effort, at the primary's effective web level (spec 20.12). A set `allow_web` carries over. Unset, claude-code has no web
@@ -39,7 +41,13 @@ export declare class Cooldowns {
39
41
  }>): Cooldowns;
40
42
  }
41
43
  /** 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[];
44
+ export declare function planAttempts(all: readonly Candidate[], cooldowns: Cooldowns, now: number): Candidate[];
45
+ /**
46
+ * The candidates `planAttempts` left out because they are cooling down, soonest to recover first. They are tried last, after
47
+ * waiting for their cooldown to end, so a wake-up whose warm models all fail is not lost to a cooldown that ends moments later.
48
+ * A tier's cheaper model is not one of them: stepping back down to it after the ceiling failed would be wrong.
49
+ */
50
+ export declare function deferredAttempts(all: readonly Candidate[], cooldowns: Cooldowns, now: number): Candidate[];
43
51
  export type Step = "retry-same" | "next-candidate" | "stop";
44
52
  /**
45
53
  * What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
@@ -78,6 +86,8 @@ export interface Tried {
78
86
  export interface RunWithFallbackOptions<R extends AttemptResult> {
79
87
  /** From `planAttempts`; tried in order. */
80
88
  candidates: Candidate[];
89
+ /** From `deferredAttempts`: tried after `candidates`, each one only once its cooldown has ended. */
90
+ deferred?: Candidate[];
81
91
  /** The agent has no fallback chain (see `nextStep`): no backoff, no cooldowns. */
82
92
  legacy: boolean;
83
93
  /** Retries per candidate (`dispatcher.retry`). */
@@ -91,6 +101,8 @@ export interface RunWithFallbackOptions<R extends AttemptResult> {
91
101
  /** Runs after each call, awaited before the next decision. */
92
102
  afterAttempt?(candidate: Candidate, result: R, ctx: AttemptCtx<R>): void | Promise<void>;
93
103
  onSwitch?(from: Candidate, to: Candidate, kind: ErrorKind | undefined, result: R): void;
104
+ /** Runs before the loop sleeps `ms` for `candidate`'s cooldown to end. */
105
+ onCooldownWait?(candidate: Candidate, ms: number): void;
94
106
  sleep?(ms: number, signal?: AbortSignal): Promise<void>;
95
107
  now?(): number;
96
108
  signal?: AbortSignal;
package/dist/fallback.js CHANGED
@@ -4,12 +4,18 @@ const keyOf = (runtime, model) => `${runtime}/${(model ?? "").trim().toLowerCase
4
4
  * The primary model followed by the agent's fallback chain, in order. An entry with the same key as the primary or as an
5
5
  * earlier entry is dropped: a chain shared through `dispatcher.fallback` may list the agent's own primary (spec 20.12).
6
6
  */
7
- export function candidatesFor(agent) {
7
+ export function candidatesFor(agent, tierModel) {
8
8
  // `validate` refuses an agent without a runtime before anything is woken.
9
9
  const runtime = agent.runtime;
10
10
  const primary = { runtime, model: agent.model, effort: agent.effort, primary: true, key: keyOf(runtime, agent.model) };
11
11
  const out = [primary];
12
12
  const seen = new Set([primary.key]);
13
+ // A tier's cheaper model goes first (spec 24.7.1); one that is the ceiling itself adds nothing.
14
+ if (tierModel && keyOf(runtime, tierModel) !== primary.key) {
15
+ const key = keyOf(runtime, tierModel);
16
+ out.unshift({ runtime, model: tierModel, effort: agent.effort, primary: false, tiered: true, key });
17
+ seen.add(key);
18
+ }
13
19
  for (const f of agent.fallback) {
14
20
  const model = f.model || undefined;
15
21
  const key = keyOf(f.runtime, model);
@@ -28,6 +34,8 @@ export function candidatesFor(agent) {
28
34
  export function agentForCandidate(agent, c) {
29
35
  if (c.primary)
30
36
  return agent;
37
+ if (c.tiered)
38
+ return { ...agent, model: c.model };
31
39
  const allowWeb = agent.allowWeb ?? (agent.runtime === "claude-code" && c.runtime === "codex" ? "none" : undefined);
32
40
  return { ...agent, runtime: c.runtime, model: c.model, effort: c.effort, allowWeb };
33
41
  }
@@ -63,10 +71,19 @@ export class Cooldowns {
63
71
  }
64
72
  }
65
73
  /** The candidates for one wake: the chain minus those cooling down, or the whole chain when every one is (something must be tried). */
66
- export function planAttempts(agent, cooldowns, now) {
67
- const all = candidatesFor(agent);
74
+ export function planAttempts(all, cooldowns, now) {
68
75
  const warm = all.filter((c) => cooldowns.active(c.key, now) === 0);
69
- return warm.length ? warm : all;
76
+ return warm.length ? warm : [...all];
77
+ }
78
+ /**
79
+ * The candidates `planAttempts` left out because they are cooling down, soonest to recover first. They are tried last, after
80
+ * waiting for their cooldown to end, so a wake-up whose warm models all fail is not lost to a cooldown that ends moments later.
81
+ * A tier's cheaper model is not one of them: stepping back down to it after the ceiling failed would be wrong.
82
+ */
83
+ export function deferredAttempts(all, cooldowns, now) {
84
+ return all
85
+ .filter((c) => !c.tiered && cooldowns.active(c.key, now) > 0)
86
+ .sort((a, b) => cooldowns.active(a.key, now) - cooldowns.active(b.key, now));
70
87
  }
71
88
  /**
72
89
  * What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
@@ -74,6 +91,9 @@ export function planAttempts(agent, cooldowns, now) {
74
91
  * keeps the old loop: every kind is retried `retry` times, then the wake fails.
75
92
  */
76
93
  export function nextStep(kind, attemptsOnCurrent, retry, hasNext, legacy) {
94
+ // A refused configuration cannot get better by trying again or on another model (spec 25.19.1).
95
+ if (kind === "config")
96
+ return "stop";
77
97
  const retriesLeft = attemptsOnCurrent <= retry;
78
98
  if (legacy)
79
99
  return retriesLeft ? "retry-same" : "stop";
@@ -115,7 +135,8 @@ export function defaultSleep(ms, signal) {
115
135
  * retry it or move on as `nextStep` says, back off between retries. Success and cancellation end the loop as they are.
116
136
  */
117
137
  export async function runWithFallback(o) {
118
- const first = o.candidates[0];
138
+ const list = [...o.candidates, ...(o.deferred ?? [])];
139
+ const first = list[0];
119
140
  if (!first)
120
141
  throw new Error("runWithFallback needs at least one candidate");
121
142
  const sleep = o.sleep ?? defaultSleep;
@@ -135,7 +156,7 @@ export async function runWithFallback(o) {
135
156
  tried.push({ runtime: used.runtime, model: used.model, kind: result.kind, error: result.error });
136
157
  if (!o.legacy && (result.kind === "rate_limit" || result.kind === "quota"))
137
158
  o.cooldowns.mark(used.key, result.kind, now(), o.cooldownSec);
138
- const next = o.candidates[index + 1];
159
+ const next = list[index + 1];
139
160
  const step = nextStep(result.kind, ctx.candidateAttempt + 1, o.retry, !!next, o.legacy);
140
161
  if (step === "stop")
141
162
  return { result, tried, used };
@@ -150,6 +171,13 @@ export async function runWithFallback(o) {
150
171
  }
151
172
  const to = next;
152
173
  o.onSwitch?.(used, to, result.kind, result);
174
+ const wait = o.legacy ? 0 : o.cooldowns.active(to.key, now());
175
+ if (wait > 0) {
176
+ o.onCooldownWait?.(to, wait);
177
+ await sleep(wait, o.signal);
178
+ if (o.signal?.aborted)
179
+ return { result, tried, used, stopped: "aborted" };
180
+ }
153
181
  ctx = { attempt: ctx.attempt + 1, candidateAttempt: 0, previous: result, switchedFrom: used };
154
182
  index++;
155
183
  used = to;
package/dist/guard.d.ts CHANGED
@@ -9,6 +9,12 @@ export interface Violation {
9
9
  /** The kept copy is cut to MAX_KEEP_BYTES. */
10
10
  truncated?: boolean;
11
11
  }
12
+ /** A change to a cross-run shared file that no agent of the batch may make, while another run of the project was live. */
13
+ export interface Unattributed {
14
+ file: string;
15
+ agents: string[];
16
+ otherRuns: string[];
17
+ }
12
18
  export declare const MAX_KEEP_BYTES: number;
13
19
  /**
14
20
  * Detects (and reverts) changes to protected files made by agents that may not edit them.
@@ -16,10 +22,20 @@ export declare const MAX_KEEP_BYTES: number;
16
22
  */
17
23
  export declare class ProtectedGuard {
18
24
  private project;
25
+ private otherLiveRuns;
19
26
  private baseline;
20
27
  /** Regular files of the records tree (including `.archive/`); only the lead may change them. */
21
28
  private records;
22
- constructor(project: ResolvedProject);
29
+ /** Changes kept (not reverted) because another live run may have made them; see `takeUnattributed`. */
30
+ private unattributed;
31
+ /** Other runs that were live at the last `rebase()` (or at `check()` time); computed once per batch. */
32
+ private liveAtBase;
33
+ /** `otherLiveRuns`: ids of the project's other runs that have a live lock holder right now. */
34
+ constructor(project: ResolvedProject, otherLiveRuns?: () => string[]);
35
+ /** The unattributed changes since the last call (they were kept and adopted as the baseline). */
36
+ takeUnattributed(): Unattributed[];
37
+ /** Files shared by every run of the project: only these can be changed by another run's lead. */
38
+ private crossRunShared;
23
39
  /**
24
40
  * Take the current files as the baseline. Called right before a batch is woken, so only what changes while it runs is
25
41
  * judged: edits made in between (you, or another run of the project) are not the batch's doing.