agent-lyceum 0.26.0 → 0.27.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
  | 主題 | 閱讀 |
package/dist/call.js CHANGED
@@ -55,6 +55,8 @@ export function callProject(home, agent, dir, callDir) {
55
55
  lead: agent.name,
56
56
  dispatcher: DISPATCHER_DEFAULTS,
57
57
  agents: { [agent.name]: agent },
58
+ modelTiers: {},
59
+ modelTiersFrom: {},
58
60
  paths,
59
61
  solo: "call",
60
62
  };
package/dist/config.d.ts CHANGED
@@ -33,6 +33,8 @@ export interface ResolvedAgent {
33
33
  }[];
34
34
  /** Where each field's value came from (for `validate` output). */
35
35
  sources: Record<string, Source>;
36
+ /** Spec 24: the tiers this agent can run on (its runtime's ladder up to and including `model`); unset when nothing is below `model`. */
37
+ tiers?: string[];
36
38
  /** 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
39
  instance?: {
38
40
  role: string;
@@ -52,6 +54,10 @@ export interface ResolvedProject {
52
54
  lead: string;
53
55
  dispatcher: DispatcherSettings;
54
56
  agents: Record<string, ResolvedAgent>;
57
+ /** Spec 24: non-empty model ladders per runtime, lowest first (project.yaml replaces team.yaml's per runtime). */
58
+ modelTiers: Partial<Record<Runtime, string[]>>;
59
+ /** Which file each ladder in `modelTiers` came from. */
60
+ modelTiersFrom: Partial<Record<Runtime, "global" | "project">>;
55
61
  paths: ProjectPaths;
56
62
  /** Removed settings still present in config files (ignored); `validate` warns once per file. */
57
63
  ignoredSettings?: {
@@ -73,6 +79,10 @@ export interface ResolvedProject {
73
79
  solo?: "run" | "call";
74
80
  }
75
81
  export declare function loadGlobal(home: string): {
82
+ model_tiers?: {
83
+ "claude-code"?: string[] | undefined;
84
+ codex?: string[] | undefined;
85
+ } | undefined;
76
86
  agents: Record<string, {
77
87
  runtime?: "claude-code" | "codex" | undefined;
78
88
  model?: string | undefined;
@@ -100,6 +110,10 @@ export declare function loadProjectRaw(home: string, name: string): {
100
110
  team: {
101
111
  lead: string;
102
112
  };
113
+ model_tiers?: {
114
+ "claude-code"?: string[] | undefined;
115
+ codex?: string[] | undefined;
116
+ } | undefined;
103
117
  dispatcher: {
104
118
  max_rounds?: number | undefined;
105
119
  max_parallel?: unknown;
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, escapesParent, 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,6 +155,7 @@ 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");
@@ -175,6 +187,7 @@ export function resolveProjectFrom(home, name, g, p) {
175
187
  fallbackShared,
176
188
  fallbackInvalid,
177
189
  sources,
190
+ ...(tiers ? { tiers } : {}),
178
191
  };
179
192
  }
180
193
  return {
@@ -184,6 +197,8 @@ export function resolveProjectFrom(home, name, g, p) {
184
197
  lead,
185
198
  dispatcher,
186
199
  agents,
200
+ modelTiers,
201
+ modelTiersFrom,
187
202
  paths,
188
203
  ignoredSettings: collectIgnored(hp.globalConfig, g, paths.config, p),
189
204
  };
@@ -252,7 +267,7 @@ export function findProjectForCwd(home, cwd) {
252
267
  let best;
253
268
  for (const pr of listProjects(home)) {
254
269
  const rel = path.relative(pr.dir, cwd);
255
- if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
270
+ if (!escapesParent(rel)) {
256
271
  if (!best || pr.dir.length > best.len)
257
272
  best = { name: pr.name, len: pr.dir.length };
258
273
  }
@@ -278,6 +293,11 @@ export function resolveProjectWithSources(home, name) {
278
293
  }
279
294
  if (raw.dispatcher.max_instances !== undefined)
280
295
  sources["dispatcher.max_instances"] = { file: projectFile, key: "dispatcher.max_instances" };
296
+ for (const rt of RUNTIMES) {
297
+ const from = project.modelTiersFrom[rt];
298
+ if (from)
299
+ sources[`model_tiers.${rt}`] = { file: from === "project" ? projectFile : globalFile, key: `model_tiers.${rt}` };
300
+ }
281
301
  for (const a of Object.values(project.agents)) {
282
302
  const at = (src, field) => {
283
303
  const key = `agents.${a.name}.${field}`;
@@ -307,6 +327,10 @@ export function resolveProjectWithSources(home, name) {
307
327
  else
308
328
  sources[`agents.${a.name}.${f}`] = at(src, f);
309
329
  }
330
+ // The ladder is the runtime's, so its source is the ladder's own key (spec 24.9).
331
+ const ladderSrc = a.tiers ? sources[`model_tiers.${a.runtime}`] : undefined;
332
+ if (ladderSrc)
333
+ sources[`agents.${a.name}.model_tiers`] = ladderSrc;
310
334
  }
311
335
  return { project, sources };
312
336
  }
@@ -314,6 +338,9 @@ export function resolveProjectWithSources(home, name) {
314
338
  export function flattenResolved(project) {
315
339
  const v = { dir: project.dir, "team.lead": project.lead };
316
340
  const anyFallback = Object.values(project.agents).some((a) => a.fallback.length > 0);
341
+ for (const rt of RUNTIMES)
342
+ if (project.modelTiers[rt])
343
+ v[`model_tiers.${rt}`] = project.modelTiers[rt];
317
344
  for (const [k, val] of Object.entries(project.dispatcher)) {
318
345
  if (k === "fallback_cooldown_sec" && !anyFallback)
319
346
  continue; // keep `doctor --resolved` unchanged without fallback
@@ -334,6 +361,8 @@ export function flattenResolved(project) {
334
361
  v[`${p}.allow_web`] = a.allowWeb;
335
362
  if (a.fallback.length)
336
363
  v[`${p}.fallback`] = a.fallback;
364
+ if (a.tiers)
365
+ v[`${p}.model_tiers`] = a.tiers;
337
366
  }
338
367
  return v;
339
368
  }
@@ -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,7 @@ 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[];
43
45
  export type Step = "retry-same" | "next-candidate" | "stop";
44
46
  /**
45
47
  * What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
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,9 @@ 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];
70
77
  }
71
78
  /**
72
79
  * What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
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.
package/dist/guard.js CHANGED
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { readRegularFileNoFollow } from "./fs-util.js";
5
+ import { escapesParent } from "./paths.js";
5
6
  import { mayEditProtected, recordsDir, repoInstructionFiles, sharedContextFiles, sharedIndex } from "./policy.js";
6
7
  export const MAX_KEEP_BYTES = 1024 * 1024;
7
8
  function safeName(file) {
@@ -13,18 +14,38 @@ function safeName(file) {
13
14
  */
14
15
  export class ProtectedGuard {
15
16
  project;
17
+ otherLiveRuns;
16
18
  baseline = new Map();
17
19
  /** Regular files of the records tree (including `.archive/`); only the lead may change them. */
18
20
  records = new Map();
19
- constructor(project) {
21
+ /** Changes kept (not reverted) because another live run may have made them; see `takeUnattributed`. */
22
+ unattributed = [];
23
+ /** Other runs that were live at the last `rebase()` (or at `check()` time); computed once per batch. */
24
+ liveAtBase = [];
25
+ /** `otherLiveRuns`: ids of the project's other runs that have a live lock holder right now. */
26
+ constructor(project, otherLiveRuns = () => []) {
20
27
  this.project = project;
28
+ this.otherLiveRuns = otherLiveRuns;
21
29
  this.rebase();
22
30
  }
31
+ /** The unattributed changes since the last call (they were kept and adopted as the baseline). */
32
+ takeUnattributed() {
33
+ const out = this.unattributed;
34
+ this.unattributed = [];
35
+ return out;
36
+ }
37
+ /** Files shared by every run of the project: only these can be changed by another run's lead. */
38
+ crossRunShared(file) {
39
+ if (sharedContextFiles(this.project).includes(file))
40
+ return true;
41
+ return Object.values(this.project.agents).some((a) => a.agentMd === file);
42
+ }
23
43
  /**
24
44
  * Take the current files as the baseline. Called right before a batch is woken, so only what changes while it runs is
25
45
  * judged: edits made in between (you, or another run of the project) are not the batch's doing.
26
46
  */
27
47
  rebase() {
48
+ this.liveAtBase = this.otherLiveRuns();
28
49
  for (const f of ProtectedGuard.protectedFiles(this.project))
29
50
  this.baseline.set(f, read(f));
30
51
  this.records = readTree(recordsDir(this.project));
@@ -69,6 +90,7 @@ export class ProtectedGuard {
69
90
  /** Compare against the baseline after `running` agents finished; revert unauthorized changes. */
70
91
  check(running) {
71
92
  const violations = [];
93
+ const other = [...new Set([...this.liveAtBase, ...this.otherLiveRuns()])];
72
94
  for (const [file, before] of this.baseline) {
73
95
  const now = read(file);
74
96
  if (same(before, now))
@@ -78,6 +100,11 @@ export class ProtectedGuard {
78
100
  this.baseline.set(file, now);
79
101
  continue;
80
102
  }
103
+ if (other.length && this.crossRunShared(file)) {
104
+ this.baseline.set(file, now);
105
+ this.unattributed.push({ file, agents: running.map((a) => a.name), otherRuns: other });
106
+ continue;
107
+ }
81
108
  if (before === null) {
82
109
  const kept = this.keep(file, now);
83
110
  fs.rmSync(file, { force: true });
@@ -90,10 +117,10 @@ export class ProtectedGuard {
90
117
  violations.push({ file, action: "restored", suspects: running.map((a) => a.name), ...kept });
91
118
  }
92
119
  }
93
- this.checkRecords(running, violations);
120
+ this.checkRecords(running, violations, other);
94
121
  return violations;
95
122
  }
96
- checkRecords(running, violations) {
123
+ checkRecords(running, violations, other) {
97
124
  const root = recordsDir(this.project);
98
125
  // Never write through a symlinked parent of the records tree: skip its protection for this batch.
99
126
  if (isLink(path.dirname(root)) || isLink(path.dirname(path.dirname(root))))
@@ -109,6 +136,10 @@ export class ProtectedGuard {
109
136
  continue;
110
137
  if (lead)
111
138
  continue;
139
+ if (other.length) {
140
+ this.unattributed.push({ file, agents: suspects, otherRuns: other });
141
+ continue;
142
+ }
112
143
  const kept = this.keep(file, cur);
113
144
  if (before === null) {
114
145
  if (!insideRoot(root, path.dirname(file)))
@@ -124,7 +155,7 @@ export class ProtectedGuard {
124
155
  violations.push({ file, action: "restored", suspects, ...kept });
125
156
  }
126
157
  }
127
- if (lead)
158
+ if (lead || other.length)
128
159
  this.records = now;
129
160
  }
130
161
  /**
@@ -133,7 +164,7 @@ export class ProtectedGuard {
133
164
  */
134
165
  realDirs(root, dir, done, suspects, violations) {
135
166
  const rel = path.relative(root, dir);
136
- if (rel.startsWith("..") || path.isAbsolute(rel))
167
+ if (escapesParent(rel))
137
168
  return false;
138
169
  let cur = root;
139
170
  for (const part of ["", ...(rel ? rel.split(path.sep) : [])]) {
@@ -167,7 +198,7 @@ function isLink(p) {
167
198
  function insideRoot(root, dir) {
168
199
  try {
169
200
  const r = path.relative(fs.realpathSync(root), fs.realpathSync(dir));
170
- return !r.startsWith("..") && !path.isAbsolute(r);
201
+ return !escapesParent(r);
171
202
  }
172
203
  catch {
173
204
  return false;
package/dist/mailbox.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { ResolvedProject } from "./config.js";
2
2
  import { type Step } from "./format.js";
3
3
  import { atomicWrite } from "./fs-util.js";
4
4
  import { type InstanceCap } from "./instances.js";
5
+ import type { Tier } from "./schema.js";
5
6
  import { RouteJournal } from "./message-store.js";
6
7
  export { atomicWrite };
7
8
  export declare const MESSAGE_TYPES: readonly ["task", "reply", "failure", "done", "ask"];
@@ -17,6 +18,8 @@ export interface MessageMeta {
17
18
  created: string;
18
19
  /** The run this mail belongs to. Stamped by the dispatcher; absent on mail of legacy runs. */
19
20
  run_id?: string;
21
+ /** The lead-chosen difficulty tier, validated by the dispatcher at delivery (spec 24.5.3). Absent = the ceiling. */
22
+ tier?: Tier;
20
23
  }
21
24
  export interface Message {
22
25
  meta: MessageMeta;
@@ -40,6 +43,8 @@ export interface DeliverInput {
40
43
  body: string;
41
44
  thread?: string;
42
45
  reply_to?: string;
46
+ /** Written to the mail only when set (spec 24.5.3), so mail without it is unchanged. */
47
+ tier?: Tier;
43
48
  }
44
49
  /** Deliver a message into `to`'s inbox (dispatcher-side; senders other than agents allowed). */
45
50
  export declare function deliver(project: ResolvedProject, input: DeliverInput & {
@@ -112,6 +117,15 @@ export interface RouteResult {
112
117
  }[];
113
118
  /** Latest `## Steps` checklist the lead sent, if any. */
114
119
  steps?: Step[];
120
+ /** `tier` fields delivered without effect (spec 24.5.2); the caller logs `tier_ignored` and sends the notes. */
121
+ tierIgnored: {
122
+ from: string;
123
+ id: string;
124
+ to: string;
125
+ subject: string;
126
+ type: string;
127
+ reason: "not-lead" | "not-a-task" | "no-tiers";
128
+ }[];
115
129
  }
116
130
  /** Move a handled `done` file out of the outbox (to `read/`). */
117
131
  export declare function finishDone(file: string): void;
package/dist/mailbox.js CHANGED
@@ -2,10 +2,12 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import YAML from "yaml";
5
+ import { duplicateNoticeKey, shouldNotifyDuplicate } from "./duplicate-notices.js";
5
6
  import { formatWarning, missingSections, parseSteps } from "./format.js";
6
7
  import { atomicWrite, readRegularFileNoFollow } from "./fs-util.js";
7
8
  import { countFormatRejection, formatRejectionKey, formatRejections, MAX_FORMAT_REJECTIONS } from "./format-rejections.js";
8
9
  import { parallelFlag, roleOfName } from "./instances.js";
10
+ import { effectiveTier, tierFlag } from "./tiers.js";
9
11
  import { deterministicId, fault, RouteJournal, sourceIdOf } from "./message-store.js";
10
12
  import { inboxDir, outboxDir, sharedDir } from "./policy.js";
11
13
  import { activeRecordsBytes, RECORD_KINDS, RECORDS_BUDGET } from "./records.js";
@@ -64,6 +66,7 @@ export function deliver(project, input) {
64
66
  subject: input.subject,
65
67
  created: input.created ?? new Date().toISOString(),
66
68
  ...(project.run?.layout === "run" ? { run_id: project.run.id } : {}),
69
+ ...(input.tier ? { tier: input.tier } : {}),
67
70
  };
68
71
  const file = path.join(inboxDir(project, input.to), input.fileName ?? `${stamp()}-${input.from}-${id}.md`);
69
72
  atomicWrite(file, serialize(meta, input.body));
@@ -123,6 +126,7 @@ function deliveredMeta(project, input, planned) {
123
126
  subject: input.subject,
124
127
  created: planned.created,
125
128
  ...(project.run?.layout === "run" ? { run_id: project.run.id } : {}),
129
+ ...(input.tier ? { tier: input.tier } : {}),
126
130
  };
127
131
  }
128
132
  export function readMessage(file) {
@@ -199,7 +203,7 @@ function sameText(body) {
199
203
  * An earlier mail from `from` to `to` with the same type, the same reply target and the same text (unread or already read):
200
204
  * sending it again changes nothing and is how two agents end up in a loop. Texts too short to mean anything are never duplicates.
201
205
  */
202
- function earlierDuplicate(project, from, to, type, replyTo, body, held = false) {
206
+ function earlierDuplicate(project, from, to, type, replyTo, body, held = false, tier = "high") {
203
207
  const text = sameText(body);
204
208
  if (text.length < 20)
205
209
  return undefined;
@@ -215,7 +219,7 @@ function earlierDuplicate(project, from, to, type, replyTo, body, held = false)
215
219
  continue;
216
220
  try {
217
221
  const m = readMessage(path.join(d, e.name));
218
- if (m.meta.from === from && m.meta.type === type && (m.meta.reply_to ?? "") === (replyTo ?? "") && sameText(m.body) === text)
222
+ if (m.meta.from === from && m.meta.type === type && (m.meta.reply_to ?? "") === (replyTo ?? "") && sameText(m.body) === text && effectiveTier(m.meta) === tier)
219
223
  return m;
220
224
  }
221
225
  catch {
@@ -227,7 +231,7 @@ function earlierDuplicate(project, from, to, type, replyTo, body, held = false)
227
231
  }
228
232
  /** Validate each agent's outbox and move accepted mail into recipients' inboxes. Safe to run again after a crash. */
229
233
  export function routeOutboxes(project, routing) {
230
- const res = { delivered: [], rejected: [], warnings: [], asks: [] };
234
+ const res = { delivered: [], rejected: [], warnings: [], asks: [], tierIgnored: [] };
231
235
  const journal = project.run ? new RouteJournal(project.run.dir) : undefined;
232
236
  for (const sender of Object.values(project.agents)) {
233
237
  const dir = outboxDir(project, sender.name);
@@ -240,14 +244,15 @@ export function routeOutboxes(project, routing) {
240
244
  .sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs || a.localeCompare(b));
241
245
  for (const file of files) {
242
246
  const source = sourceIdOf(sender.name, file);
243
- const reject = (reason) => {
244
- deliverOnce(project, journal, `reject:${source}`, {
245
- from: "dispatcher",
246
- to: sender.name,
247
- type: "failure",
248
- subject: `Message ${path.basename(file)} was rejected`,
249
- body: `Your outbox message \`${path.basename(file)}\` was not delivered: ${reason}\n\nIt was moved to \`rejected/\`.`,
250
- });
247
+ const reject = (reason, notify = true) => {
248
+ if (notify)
249
+ deliverOnce(project, journal, `reject:${source}`, {
250
+ from: "dispatcher",
251
+ to: sender.name,
252
+ type: "failure",
253
+ subject: `Message ${path.basename(file)} was rejected`,
254
+ body: `Your outbox message \`${path.basename(file)}\` was not delivered: ${reason}\n\nIt was moved to \`rejected/\`.`,
255
+ });
251
256
  const dest = path.join(dir, "rejected", path.basename(file));
252
257
  fs.mkdirSync(path.dirname(dest), { recursive: true });
253
258
  fs.renameSync(file, dest);
@@ -332,6 +337,18 @@ export function routeOutboxes(project, routing) {
332
337
  continue;
333
338
  }
334
339
  }
340
+ // `tier` is judged per recipient like `parallel`, before the duplicate check, which needs the effective tier (spec 24.5.2, 24.5.4).
341
+ const tiers = new Map();
342
+ for (const t of targets)
343
+ tiers.set(t, tierFlag({ value: d.tier, senderIsLead: sender.name === project.lead, type, recipientTiers: project.agents[t]?.tiers }));
344
+ if ([...tiers.values()].includes("invalid")) {
345
+ reject(`tier must be low, mid or high, not ${JSON.stringify(d.tier)}. Write \`tier: low\` (or mid/high) on a task to run it on a cheaper model, or leave the line out to use the member's own model.`);
346
+ continue;
347
+ }
348
+ const tierOf = (to) => {
349
+ const f = tiers.get(to);
350
+ return f === "low" || f === "mid" || f === "high" ? f : undefined;
351
+ };
335
352
  const missing = missingSections(type, parsed.body);
336
353
  if (missing.length && project.run?.layout === "run") {
337
354
  const key = formatRejectionKey(sender.name, type, subject);
@@ -348,7 +365,7 @@ export function routeOutboxes(project, routing) {
348
365
  for (const to of targets) {
349
366
  if (journal?.get(source, to))
350
367
  continue;
351
- const earlier = earlierDuplicate(project, sender.name, to, type, replyTo, parsed.body, !!routing?.held(roleOf(to)));
368
+ const earlier = earlierDuplicate(project, sender.name, to, type, replyTo, parsed.body, !!routing?.held(roleOf(to)), tierOf(to) ?? "high");
352
369
  if (earlier)
353
370
  repeats.set(to, earlier);
354
371
  }
@@ -356,18 +373,25 @@ export function routeOutboxes(project, routing) {
356
373
  .map(([to, m]) => `to "${to}" it repeats mail ${m.meta.id} ("${m.meta.subject}", ${m.meta.created}) with the same content`)
357
374
  .join("; ");
358
375
  const REPEAT_HELP = "Do not send it again: if that mail has not been answered yet, wait for the reply; if you need something different, change what you ask for; if the work is finished, the lead sends `done`.";
376
+ // A failure mail wakes the sender; an agent that answers with the same text again would be woken forever, so after
377
+ // MAX_DUPLICATE_NOTICES notices for the same repeated mail the repeat is still set aside and logged, but not announced.
378
+ // Bound to a run directory (either layout); a project-only mailbox has no place to count and stays unbounded.
379
+ const notifyRepeat = !repeats.size ||
380
+ !project.run ||
381
+ shouldNotifyDuplicate(project.run.dir, duplicateNoticeKey(sender.name, [...repeats].map(([to, m]) => [to, m.meta.id])), source);
359
382
  if (repeats.size === targets.length) {
360
- reject(`it is a duplicate: ${repeatNote()}, so it was not sent again. ${REPEAT_HELP}`);
383
+ reject(`it is a duplicate: ${repeatNote()}, so it was not sent again. ${REPEAT_HELP}`, notifyRepeat);
361
384
  continue;
362
385
  }
363
386
  if (repeats.size) {
364
- deliverOnce(project, journal, `dup:${source}`, {
365
- from: "dispatcher",
366
- to: sender.name,
367
- type: "failure",
368
- subject: `Part of ${path.basename(file)} was not sent`,
369
- body: `Your outbox message \`${path.basename(file)}\` was delivered, except that ${repeatNote()}. ${REPEAT_HELP}`,
370
- });
387
+ if (notifyRepeat)
388
+ deliverOnce(project, journal, `dup:${source}`, {
389
+ from: "dispatcher",
390
+ to: sender.name,
391
+ type: "failure",
392
+ subject: `Part of ${path.basename(file)} was not sent`,
393
+ body: `Your outbox message \`${path.basename(file)}\` was delivered, except that ${repeatNote()}. ${REPEAT_HELP}`,
394
+ });
371
395
  res.rejected.push({ from: sender.name, file, reason: `duplicate: ${repeatNote()}` });
372
396
  }
373
397
  const body = missing.length ? `${formatWarning(type, missing)}\n\n${parsed.body}` : parsed.body;
@@ -381,13 +405,16 @@ export function routeOutboxes(project, routing) {
381
405
  continue;
382
406
  const flag = flags.get(to);
383
407
  const role = roleOf(to);
408
+ const tf = tiers.get(to);
409
+ if (typeof tf === "object" && !journal?.get(source, to))
410
+ res.tierIgnored.push({ from: sender.name, id: deterministicId(source, to), to, subject, type, reason: tf.ignored });
384
411
  if (typeof flag === "object" && !journal?.get(source, to))
385
412
  routing?.ignored(flag.ignored, { from: sender.name, id: deterministicId(source, to), to, subject });
386
413
  // The first valid parallel task makes the role's instances durable before a single file of this mail exists.
387
414
  if (routing && flag === "on" && !routing.held(role))
388
415
  routing.enable(role);
389
416
  const held = !!routing?.held(role);
390
- const { msg, fresh } = deliverOnce(project, journal, source, { from: sender.name, to, type, subject, body, thread, reply_to: replyTo }, {
417
+ const { msg, fresh } = deliverOnce(project, journal, source, { from: sender.name, to, type, subject, body, thread, reply_to: replyTo, ...(tierOf(to) ? { tier: tierOf(to) } : {}) }, {
391
418
  ...(held ? { held: true } : {}),
392
419
  ...(held && flag === "on" ? { parallel: true } : {}),
393
420
  ...(routing?.held(roleOf(sender.name)) ? { from: sender.name } : {}),
package/dist/paths.d.ts CHANGED
@@ -23,6 +23,8 @@ export interface ProjectPaths {
23
23
  taskMemory: string;
24
24
  }
25
25
  export declare function projectPaths(home: string, project: string): ProjectPaths;
26
+ /** True when a relative path leaves its base: it is `..`, starts with a `..` segment, or is absolute. A name like `..x` is not an escape. */
27
+ export declare function escapesParent(rel: string, sep?: string): boolean;
26
28
  /** True when `child` is `parent` or inside it. */
27
29
  export declare function isInside(parent: string, child: string): boolean;
28
30
  /**