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 +4 -0
- package/README.zh-TW.md +4 -0
- package/dist/call.js +2 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +32 -3
- package/dist/duplicate-notices.d.ts +9 -0
- package/dist/duplicate-notices.js +34 -0
- package/dist/fallback.d.ts +4 -2
- package/dist/fallback.js +11 -4
- package/dist/guard.d.ts +17 -1
- package/dist/guard.js +37 -6
- package/dist/mailbox.d.ts +14 -0
- package/dist/mailbox.js +48 -21
- package/dist/paths.d.ts +2 -0
- package/dist/paths.js +5 -2
- package/dist/prompt.d.ts +9 -0
- package/dist/prompt.js +30 -3
- package/dist/run-session.d.ts +2 -0
- package/dist/run-session.js +57 -8
- package/dist/run-store.d.ts +4 -1
- package/dist/scaffold.d.ts +1 -0
- package/dist/scaffold.js +7 -1
- package/dist/schema.d.ts +15 -0
- package/dist/schema.js +10 -0
- package/dist/status.js +26 -1
- package/dist/tiers.d.ts +27 -0
- package/dist/tiers.js +49 -0
- package/dist/validate.js +28 -1
- package/dist/worktree.js +2 -1
- package/package.json +1 -1
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
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 {
|
|
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 (
|
|
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
|
+
}
|
package/dist/fallback.d.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 !
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
/**
|