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 +4 -0
- package/README.zh-TW.md +4 -0
- package/dist/adapters/claude.js +11 -0
- package/dist/adapters/codex.js +2 -0
- package/dist/call.js +5 -2
- package/dist/config.d.ts +37 -1
- package/dist/config.js +69 -4
- package/dist/duplicate-notices.d.ts +9 -0
- package/dist/duplicate-notices.js +34 -0
- package/dist/fallback.d.ts +14 -2
- package/dist/fallback.js +34 -6
- package/dist/guard.d.ts +17 -1
- package/dist/guard.js +37 -6
- package/dist/mailbox.d.ts +14 -0
- package/dist/mailbox.js +67 -23
- package/dist/paths.d.ts +2 -0
- package/dist/paths.js +5 -2
- package/dist/policy.d.ts +33 -0
- package/dist/policy.js +80 -0
- 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 +74 -11
- package/dist/run-store.d.ts +4 -1
- package/dist/sandbox-invocation.d.ts +35 -0
- package/dist/sandbox-invocation.js +186 -0
- package/dist/scaffold.d.ts +1 -0
- package/dist/scaffold.js +7 -1
- package/dist/schema.d.ts +42 -1
- package/dist/schema.js +24 -1
- package/dist/status.js +26 -1
- package/dist/tiers.d.ts +27 -0
- package/dist/tiers.js +49 -0
- package/dist/validate.js +33 -1
- package/dist/worktree.js +2 -1
- package/package.json +1 -1
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";
|
|
@@ -25,14 +27,31 @@ export function serialize(meta, body) {
|
|
|
25
27
|
const fm = YAML.stringify(meta).trimEnd();
|
|
26
28
|
return `---\n${fm}\n---\n\n${body.trim()}\n`;
|
|
27
29
|
}
|
|
30
|
+
const ID_KEYS = new Set(["id", "reply_to", "thread", "about", "run_id"]);
|
|
28
31
|
export function parseRaw(text) {
|
|
29
32
|
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
|
|
30
33
|
if (!m)
|
|
31
34
|
throw new Error("missing frontmatter (--- ... ---)");
|
|
32
|
-
const
|
|
35
|
+
const fm = m[1] ?? "";
|
|
36
|
+
const data = YAML.parse(fm);
|
|
33
37
|
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
34
38
|
throw new Error("frontmatter is not a mapping");
|
|
35
|
-
|
|
39
|
+
const out = data;
|
|
40
|
+
// Ids are 8 hex characters: `79e76911` parses as Infinity and `01234567` as 1234567.
|
|
41
|
+
// Only an id key that came out as a number is re-read from its source text; everything else stays as YAML.parse returned it.
|
|
42
|
+
if (Object.entries(out).some(([k, v]) => ID_KEYS.has(k) && typeof v === "number")) {
|
|
43
|
+
const doc = YAML.parseDocument(fm);
|
|
44
|
+
if (YAML.isMap(doc.contents)) {
|
|
45
|
+
for (const pair of doc.contents.items) {
|
|
46
|
+
const key = YAML.isScalar(pair.key) ? String(pair.key.value) : "";
|
|
47
|
+
if (!ID_KEYS.has(key) || typeof out[key] !== "number" || !YAML.isScalar(pair.value))
|
|
48
|
+
continue;
|
|
49
|
+
if (pair.value.type === "PLAIN" && typeof pair.value.source === "string")
|
|
50
|
+
out[key] = pair.value.source;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { data: out, body: (m[2] ?? "").trim() };
|
|
36
55
|
}
|
|
37
56
|
export function ensureProjectDirs(project) {
|
|
38
57
|
fs.mkdirSync(path.join(project.paths.shared, "common"), { recursive: true });
|
|
@@ -64,6 +83,7 @@ export function deliver(project, input) {
|
|
|
64
83
|
subject: input.subject,
|
|
65
84
|
created: input.created ?? new Date().toISOString(),
|
|
66
85
|
...(project.run?.layout === "run" ? { run_id: project.run.id } : {}),
|
|
86
|
+
...(input.tier ? { tier: input.tier } : {}),
|
|
67
87
|
};
|
|
68
88
|
const file = path.join(inboxDir(project, input.to), input.fileName ?? `${stamp()}-${input.from}-${id}.md`);
|
|
69
89
|
atomicWrite(file, serialize(meta, input.body));
|
|
@@ -123,6 +143,7 @@ function deliveredMeta(project, input, planned) {
|
|
|
123
143
|
subject: input.subject,
|
|
124
144
|
created: planned.created,
|
|
125
145
|
...(project.run?.layout === "run" ? { run_id: project.run.id } : {}),
|
|
146
|
+
...(input.tier ? { tier: input.tier } : {}),
|
|
126
147
|
};
|
|
127
148
|
}
|
|
128
149
|
export function readMessage(file) {
|
|
@@ -199,7 +220,7 @@ function sameText(body) {
|
|
|
199
220
|
* An earlier mail from `from` to `to` with the same type, the same reply target and the same text (unread or already read):
|
|
200
221
|
* 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
222
|
*/
|
|
202
|
-
function earlierDuplicate(project, from, to, type, replyTo, body, held = false) {
|
|
223
|
+
function earlierDuplicate(project, from, to, type, replyTo, body, held = false, tier = "high") {
|
|
203
224
|
const text = sameText(body);
|
|
204
225
|
if (text.length < 20)
|
|
205
226
|
return undefined;
|
|
@@ -215,7 +236,7 @@ function earlierDuplicate(project, from, to, type, replyTo, body, held = false)
|
|
|
215
236
|
continue;
|
|
216
237
|
try {
|
|
217
238
|
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)
|
|
239
|
+
if (m.meta.from === from && m.meta.type === type && (m.meta.reply_to ?? "") === (replyTo ?? "") && sameText(m.body) === text && effectiveTier(m.meta) === tier)
|
|
219
240
|
return m;
|
|
220
241
|
}
|
|
221
242
|
catch {
|
|
@@ -227,7 +248,7 @@ function earlierDuplicate(project, from, to, type, replyTo, body, held = false)
|
|
|
227
248
|
}
|
|
228
249
|
/** Validate each agent's outbox and move accepted mail into recipients' inboxes. Safe to run again after a crash. */
|
|
229
250
|
export function routeOutboxes(project, routing) {
|
|
230
|
-
const res = { delivered: [], rejected: [], warnings: [], asks: [] };
|
|
251
|
+
const res = { delivered: [], rejected: [], warnings: [], asks: [], tierIgnored: [] };
|
|
231
252
|
const journal = project.run ? new RouteJournal(project.run.dir) : undefined;
|
|
232
253
|
for (const sender of Object.values(project.agents)) {
|
|
233
254
|
const dir = outboxDir(project, sender.name);
|
|
@@ -240,14 +261,15 @@ export function routeOutboxes(project, routing) {
|
|
|
240
261
|
.sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs || a.localeCompare(b));
|
|
241
262
|
for (const file of files) {
|
|
242
263
|
const source = sourceIdOf(sender.name, file);
|
|
243
|
-
const reject = (reason) => {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
264
|
+
const reject = (reason, notify = true) => {
|
|
265
|
+
if (notify)
|
|
266
|
+
deliverOnce(project, journal, `reject:${source}`, {
|
|
267
|
+
from: "dispatcher",
|
|
268
|
+
to: sender.name,
|
|
269
|
+
type: "failure",
|
|
270
|
+
subject: `Message ${path.basename(file)} was rejected`,
|
|
271
|
+
body: `Your outbox message \`${path.basename(file)}\` was not delivered: ${reason}\n\nIt was moved to \`rejected/\`.`,
|
|
272
|
+
});
|
|
251
273
|
const dest = path.join(dir, "rejected", path.basename(file));
|
|
252
274
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
253
275
|
fs.renameSync(file, dest);
|
|
@@ -332,6 +354,18 @@ export function routeOutboxes(project, routing) {
|
|
|
332
354
|
continue;
|
|
333
355
|
}
|
|
334
356
|
}
|
|
357
|
+
// `tier` is judged per recipient like `parallel`, before the duplicate check, which needs the effective tier (spec 24.5.2, 24.5.4).
|
|
358
|
+
const tiers = new Map();
|
|
359
|
+
for (const t of targets)
|
|
360
|
+
tiers.set(t, tierFlag({ value: d.tier, senderIsLead: sender.name === project.lead, type, recipientTiers: project.agents[t]?.tiers }));
|
|
361
|
+
if ([...tiers.values()].includes("invalid")) {
|
|
362
|
+
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.`);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const tierOf = (to) => {
|
|
366
|
+
const f = tiers.get(to);
|
|
367
|
+
return f === "low" || f === "mid" || f === "high" ? f : undefined;
|
|
368
|
+
};
|
|
335
369
|
const missing = missingSections(type, parsed.body);
|
|
336
370
|
if (missing.length && project.run?.layout === "run") {
|
|
337
371
|
const key = formatRejectionKey(sender.name, type, subject);
|
|
@@ -348,7 +382,7 @@ export function routeOutboxes(project, routing) {
|
|
|
348
382
|
for (const to of targets) {
|
|
349
383
|
if (journal?.get(source, to))
|
|
350
384
|
continue;
|
|
351
|
-
const earlier = earlierDuplicate(project, sender.name, to, type, replyTo, parsed.body, !!routing?.held(roleOf(to)));
|
|
385
|
+
const earlier = earlierDuplicate(project, sender.name, to, type, replyTo, parsed.body, !!routing?.held(roleOf(to)), tierOf(to) ?? "high");
|
|
352
386
|
if (earlier)
|
|
353
387
|
repeats.set(to, earlier);
|
|
354
388
|
}
|
|
@@ -356,18 +390,25 @@ export function routeOutboxes(project, routing) {
|
|
|
356
390
|
.map(([to, m]) => `to "${to}" it repeats mail ${m.meta.id} ("${m.meta.subject}", ${m.meta.created}) with the same content`)
|
|
357
391
|
.join("; ");
|
|
358
392
|
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`.";
|
|
393
|
+
// A failure mail wakes the sender; an agent that answers with the same text again would be woken forever, so after
|
|
394
|
+
// MAX_DUPLICATE_NOTICES notices for the same repeated mail the repeat is still set aside and logged, but not announced.
|
|
395
|
+
// Bound to a run directory (either layout); a project-only mailbox has no place to count and stays unbounded.
|
|
396
|
+
const notifyRepeat = !repeats.size ||
|
|
397
|
+
!project.run ||
|
|
398
|
+
shouldNotifyDuplicate(project.run.dir, duplicateNoticeKey(sender.name, [...repeats].map(([to, m]) => [to, m.meta.id])), source);
|
|
359
399
|
if (repeats.size === targets.length) {
|
|
360
|
-
reject(`it is a duplicate: ${repeatNote()}, so it was not sent again. ${REPEAT_HELP}
|
|
400
|
+
reject(`it is a duplicate: ${repeatNote()}, so it was not sent again. ${REPEAT_HELP}`, notifyRepeat);
|
|
361
401
|
continue;
|
|
362
402
|
}
|
|
363
403
|
if (repeats.size) {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
404
|
+
if (notifyRepeat)
|
|
405
|
+
deliverOnce(project, journal, `dup:${source}`, {
|
|
406
|
+
from: "dispatcher",
|
|
407
|
+
to: sender.name,
|
|
408
|
+
type: "failure",
|
|
409
|
+
subject: `Part of ${path.basename(file)} was not sent`,
|
|
410
|
+
body: `Your outbox message \`${path.basename(file)}\` was delivered, except that ${repeatNote()}. ${REPEAT_HELP}`,
|
|
411
|
+
});
|
|
371
412
|
res.rejected.push({ from: sender.name, file, reason: `duplicate: ${repeatNote()}` });
|
|
372
413
|
}
|
|
373
414
|
const body = missing.length ? `${formatWarning(type, missing)}\n\n${parsed.body}` : parsed.body;
|
|
@@ -381,13 +422,16 @@ export function routeOutboxes(project, routing) {
|
|
|
381
422
|
continue;
|
|
382
423
|
const flag = flags.get(to);
|
|
383
424
|
const role = roleOf(to);
|
|
425
|
+
const tf = tiers.get(to);
|
|
426
|
+
if (typeof tf === "object" && !journal?.get(source, to))
|
|
427
|
+
res.tierIgnored.push({ from: sender.name, id: deterministicId(source, to), to, subject, type, reason: tf.ignored });
|
|
384
428
|
if (typeof flag === "object" && !journal?.get(source, to))
|
|
385
429
|
routing?.ignored(flag.ignored, { from: sender.name, id: deterministicId(source, to), to, subject });
|
|
386
430
|
// The first valid parallel task makes the role's instances durable before a single file of this mail exists.
|
|
387
431
|
if (routing && flag === "on" && !routing.held(role))
|
|
388
432
|
routing.enable(role);
|
|
389
433
|
const held = !!routing?.held(role);
|
|
390
|
-
const { msg, fresh } = deliverOnce(project, journal, source, { from: sender.name, to, type, subject, body, thread, reply_to: replyTo }, {
|
|
434
|
+
const { msg, fresh } = deliverOnce(project, journal, source, { from: sender.name, to, type, subject, body, thread, reply_to: replyTo, ...(tierOf(to) ? { tier: tierOf(to) } : {}) }, {
|
|
391
435
|
...(held ? { held: true } : {}),
|
|
392
436
|
...(held && flag === "on" ? { parallel: true } : {}),
|
|
393
437
|
...(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
|
/**
|
package/dist/paths.js
CHANGED
|
@@ -44,10 +44,13 @@ export function projectPaths(home, project) {
|
|
|
44
44
|
taskMemory: path.join(root, "task-memory"),
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
|
+
/** 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. */
|
|
48
|
+
export function escapesParent(rel, sep = path.sep) {
|
|
49
|
+
return rel === ".." || rel.startsWith(`..${sep}`) || path.isAbsolute(rel);
|
|
50
|
+
}
|
|
47
51
|
/** True when `child` is `parent` or inside it. */
|
|
48
52
|
export function isInside(parent, child) {
|
|
49
|
-
|
|
50
|
-
return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
53
|
+
return !escapesParent(path.relative(parent, child));
|
|
51
54
|
}
|
|
52
55
|
/**
|
|
53
56
|
* `p` with symlinks resolved as far as it exists: the real path of its deepest existing ancestor plus the rest. A path that does not exist
|
package/dist/policy.d.ts
CHANGED
|
@@ -50,3 +50,36 @@ export declare function setOsSandboxForTest(available: boolean | undefined): voi
|
|
|
50
50
|
export declare function enforcementFor(project: ResolvedProject, agent: ResolvedAgent): AgentEnforcement;
|
|
51
51
|
/** Can `agent` legitimately change this protected file? */
|
|
52
52
|
export declare function mayEditProtected(project: ResolvedProject, agent: ResolvedAgent, file: string): boolean;
|
|
53
|
+
export type SandboxRelation = "equal" | "inside" | "contains";
|
|
54
|
+
export interface SandboxPathConflict {
|
|
55
|
+
key: "write" | "unix_sockets";
|
|
56
|
+
index: number;
|
|
57
|
+
path: string;
|
|
58
|
+
protectedPath: string;
|
|
59
|
+
reason: string;
|
|
60
|
+
relation: SandboxRelation;
|
|
61
|
+
}
|
|
62
|
+
/** Pure (spec 25.4.3): which canonical sandbox paths equal, sit inside or contain a protected path. Callers canonicalize. */
|
|
63
|
+
export declare function sandboxPathConflicts(entries: {
|
|
64
|
+
key: "write" | "unix_sockets";
|
|
65
|
+
index: number;
|
|
66
|
+
path: string;
|
|
67
|
+
}[], protectedPaths: {
|
|
68
|
+
path: string;
|
|
69
|
+
reason: string;
|
|
70
|
+
}[]): SandboxPathConflict[];
|
|
71
|
+
/** What a runtime really applies of an agent's allowances (spec 25.9): the tuple a fallback must not exceed. */
|
|
72
|
+
export interface SandboxCaps {
|
|
73
|
+
network: "none" | "domains" | "all";
|
|
74
|
+
localBinding: boolean;
|
|
75
|
+
unixSockets: boolean;
|
|
76
|
+
}
|
|
77
|
+
export declare const NETWORK_RANK: {
|
|
78
|
+
readonly none: 0;
|
|
79
|
+
readonly domains: 1;
|
|
80
|
+
readonly all: 2;
|
|
81
|
+
};
|
|
82
|
+
/** Without an OS sandbox nothing is applied, so every runtime is equal there. */
|
|
83
|
+
export declare function sandboxCaps(agent: ResolvedAgent, runtime?: string | undefined): SandboxCaps;
|
|
84
|
+
/** Components in which `backup` applies more than `primary`, as `name: primary -> backup`. */
|
|
85
|
+
export declare function sandboxCapExcess(primary: SandboxCaps, backup: SandboxCaps): string[];
|
package/dist/policy.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { findOnPath } from "./fs-util.js";
|
|
4
|
+
import { canonicalPath } from "./paths.js";
|
|
4
5
|
export function commonFile(project) {
|
|
5
6
|
return path.join(project.paths.shared, "common", "COMMON.md");
|
|
6
7
|
}
|
|
@@ -88,6 +89,8 @@ export function writePolicy(project, agent) {
|
|
|
88
89
|
...(ws ? [ws.gitDir] : []),
|
|
89
90
|
...(shared && mayWriteShared ? [shared] : []),
|
|
90
91
|
...(isLead ? [recordsDir(project)] : []),
|
|
92
|
+
// Spec 25: canonical, because the OS sandbox compares real paths (`/tmp` is `/private/tmp` on macOS).
|
|
93
|
+
...(agent.sandbox?.write ?? []).map(canonicalPath),
|
|
91
94
|
];
|
|
92
95
|
const allowFiles = [];
|
|
93
96
|
if (isLead)
|
|
@@ -180,6 +183,8 @@ export function enforcementFor(project, agent) {
|
|
|
180
183
|
if (agent.canEditAgentMd)
|
|
181
184
|
notes.push("can_edit_agent_md widens Codex writable roots to whole agent directories.");
|
|
182
185
|
}
|
|
186
|
+
if (agent.sandbox)
|
|
187
|
+
notes.push(...sandboxNotes(agent, os));
|
|
183
188
|
return {
|
|
184
189
|
agent: agent.name,
|
|
185
190
|
runtime: agent.runtime ?? "?",
|
|
@@ -188,7 +193,82 @@ export function enforcementFor(project, agent) {
|
|
|
188
193
|
notes,
|
|
189
194
|
};
|
|
190
195
|
}
|
|
196
|
+
/** Doctor lines for an agent's sandbox allowances (spec 25.7, 25.10): what is requested and what its runtime really applies. */
|
|
197
|
+
function sandboxNotes(agent, os) {
|
|
198
|
+
const s = agent.sandbox;
|
|
199
|
+
if (!s)
|
|
200
|
+
return [];
|
|
201
|
+
const claude = agent.runtime === "claude-code";
|
|
202
|
+
const notes = [];
|
|
203
|
+
if (!os) {
|
|
204
|
+
notes.push(claude
|
|
205
|
+
? "no OS sandbox; sandbox.write changes Edit tool rules only; network/local_binding/unix_sockets have no effect."
|
|
206
|
+
: "no OS sandbox; enforcement is prompt-only; sandbox allowances provide no OS enforcement.");
|
|
207
|
+
}
|
|
208
|
+
if (s.network.length) {
|
|
209
|
+
notes.push(`network: ${s.network.join(", ")} (${claude ? "claude-code: per-domain" : "codex: all hosts"})`);
|
|
210
|
+
if (!claude)
|
|
211
|
+
notes.push(`warn: sandbox.network is all-or-nothing on Codex: this agent may reach any host, not only ${s.network.join(", ")}.`);
|
|
212
|
+
}
|
|
213
|
+
if (s.localBinding) {
|
|
214
|
+
notes.push(claude ? (process.platform === "darwin" ? "local_binding: on" : "local_binding: requested, no effect on this platform (macOS only)") : "local_binding: requested, not supported by codex");
|
|
215
|
+
if (!claude)
|
|
216
|
+
notes.push("warn: sandbox.local_binding has no Codex setting; whether a workspace-write sandbox lets it bind ports is unverified. sandbox.network turns on all network access, not only binding.");
|
|
217
|
+
}
|
|
218
|
+
if (s.unixSockets.length) {
|
|
219
|
+
notes.push(`unix_sockets: ${s.unixSockets.map(canonicalPath).join(", ")} (${claude ? "applied" : "not applied by codex"})`);
|
|
220
|
+
if (!claude)
|
|
221
|
+
notes.push("warn: sandbox.unix_sockets has no per-socket Codex control; no setting emitted.");
|
|
222
|
+
}
|
|
223
|
+
for (const [key, list] of [["write", s.write], ["unix_sockets", s.unixSockets]]) {
|
|
224
|
+
list.forEach((p, i) => {
|
|
225
|
+
if (!fs.existsSync(p))
|
|
226
|
+
notes.push(`info: sandbox.${key}[${i}] ${p} does not exist yet`);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return notes;
|
|
230
|
+
}
|
|
191
231
|
/** Can `agent` legitimately change this protected file? */
|
|
192
232
|
export function mayEditProtected(project, agent, file) {
|
|
193
233
|
return writePolicy(project, agent).allowFiles.includes(file);
|
|
194
234
|
}
|
|
235
|
+
const within = (parent, p) => p === parent || p.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
|
|
236
|
+
/** Pure (spec 25.4.3): which canonical sandbox paths equal, sit inside or contain a protected path. Callers canonicalize. */
|
|
237
|
+
export function sandboxPathConflicts(entries, protectedPaths) {
|
|
238
|
+
const out = [];
|
|
239
|
+
for (const e of entries) {
|
|
240
|
+
for (const pp of protectedPaths) {
|
|
241
|
+
const relation = e.path === pp.path ? "equal" : within(pp.path, e.path) ? "inside" : within(e.path, pp.path) ? "contains" : undefined;
|
|
242
|
+
if (relation)
|
|
243
|
+
out.push({ ...e, protectedPath: pp.path, reason: pp.reason, relation });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
export const NETWORK_RANK = { none: 0, domains: 1, all: 2 };
|
|
249
|
+
/** Without an OS sandbox nothing is applied, so every runtime is equal there. */
|
|
250
|
+
export function sandboxCaps(agent, runtime = agent.runtime) {
|
|
251
|
+
const s = agent.sandbox;
|
|
252
|
+
const none = { network: "none", localBinding: false, unixSockets: false };
|
|
253
|
+
if (!s || !osSandboxAvailable())
|
|
254
|
+
return none;
|
|
255
|
+
if (runtime === "claude-code") {
|
|
256
|
+
return {
|
|
257
|
+
network: s.network.length ? "domains" : "none",
|
|
258
|
+
localBinding: s.localBinding && process.platform === "darwin",
|
|
259
|
+
unixSockets: s.unixSockets.length > 0,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return { ...none, network: s.network.length ? "all" : "none" };
|
|
263
|
+
}
|
|
264
|
+
/** Components in which `backup` applies more than `primary`, as `name: primary -> backup`. */
|
|
265
|
+
export function sandboxCapExcess(primary, backup) {
|
|
266
|
+
const out = [];
|
|
267
|
+
if (NETWORK_RANK[backup.network] > NETWORK_RANK[primary.network])
|
|
268
|
+
out.push(`network: ${primary.network} -> ${backup.network}`);
|
|
269
|
+
if (backup.localBinding && !primary.localBinding)
|
|
270
|
+
out.push("local_binding: off -> on");
|
|
271
|
+
if (backup.unixSockets && !primary.unixSockets)
|
|
272
|
+
out.push("unix_sockets: off -> on");
|
|
273
|
+
return out;
|
|
274
|
+
}
|
package/dist/prompt.d.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import type { ResolvedAgent, ResolvedProject } from "./config.js";
|
|
2
2
|
import type { Message } from "./mailbox.js";
|
|
3
3
|
import type { Layer, MemoryFile, MissingPath, ProjectChanges } from "./memory-tidy.js";
|
|
4
|
+
/** Lead-only rules that keep the number of wake-ups down; placed after the progress-tracking block. */
|
|
5
|
+
export declare const WAKE_ECONOMY_SECTION: string[];
|
|
4
6
|
/** The lead's system prompt gets this at its very end when same-role instances are possible in the run (spec 23.12). No trailing newline. */
|
|
5
7
|
export declare const PARALLEL_SECTION: (n: number) => string;
|
|
8
|
+
/** The lead's system prompt ends with this when a member can run a task on a cheaper model (spec 24.10); `members` = [name, model of low, mid, high]. No trailing newline. */
|
|
9
|
+
export declare const TIER_SECTION: (members: {
|
|
10
|
+
name: string;
|
|
11
|
+
low: string;
|
|
12
|
+
mid: string;
|
|
13
|
+
high: string;
|
|
14
|
+
}[]) => string;
|
|
6
15
|
export declare const COMMON_LIMIT: number;
|
|
7
16
|
export declare const MEMORY_INDEX_LIMIT: number;
|
|
8
17
|
export declare const HANDOFF_LIMIT: number;
|
package/dist/prompt.js
CHANGED
|
@@ -3,6 +3,7 @@ import { cutUtf8, readFileCapped } from "./fs-util.js";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { REQUIRED_SECTIONS } from "./format.js";
|
|
5
5
|
import { senderLabel, slotName } from "./instances.js";
|
|
6
|
+
import { modelForTier } from "./tiers.js";
|
|
6
7
|
import { buildRecordsView, RECORDS_BUDGET } from "./records.js";
|
|
7
8
|
import { commonFile, memoryDirs, outboxDir, recordsKindDir, repoDirFor, sharedDir, sharedIndex } from "./policy.js";
|
|
8
9
|
function memoryLabel(agent, dir) {
|
|
@@ -13,6 +14,15 @@ function memoryLabel(agent, dir) {
|
|
|
13
14
|
const kind = dir === (agent.memory.global ?? agent.sharedMemory?.global) ? "global (all projects" : "project (all tasks of this project";
|
|
14
15
|
return shared ? `${kind}, read-only for you: ${instance1} maintains it)` : `${kind})`;
|
|
15
16
|
}
|
|
17
|
+
/** Lead-only rules that keep the number of wake-ups down; placed after the progress-tracking block. */
|
|
18
|
+
export const WAKE_ECONOMY_SECTION = [
|
|
19
|
+
"## Keeping wake-ups few",
|
|
20
|
+
"Every wake-up re-reads your whole context (prompt, memory, mail), so the number of wake-ups, yours and your teammates', is the main cost of a run.",
|
|
21
|
+
"- Send all work that is independent of each other in the same wake-up (for example the implementation and the matching docs), each task naming its reviewer, instead of one task per wake-up.",
|
|
22
|
+
"- When a reply arrives while other work you sent is still out and the reply needs nothing from you yet, send no mail: note it in your task memory and end the wake-up. Decide the next step once, when the rest is back; replies waiting together are handed to you in one wake-up.",
|
|
23
|
+
"- Skip the QA layer (the verification role and its reviewer) for a small fix: a change of a few lines that has a reproduction test which failed before and passes after, and an approving code review. Send QA for large changes, changes across modules, and changes to user-visible behaviour.",
|
|
24
|
+
"- Use the most capable (most expensive) model only at three moments: before the plan is final, when the same error shows up a second time, and before you send `done`. In other rounds prefer teammates on cheaper models; read-only search work suits the cheapest one.",
|
|
25
|
+
];
|
|
16
26
|
/** The lead's system prompt gets this at its very end when same-role instances are possible in the run (spec 23.12). No trailing newline. */
|
|
17
27
|
export const PARALLEL_SECTION = (n) => [
|
|
18
28
|
"## Parallel tasks",
|
|
@@ -24,6 +34,13 @@ export const PARALLEL_SECTION = (n) => [
|
|
|
24
34
|
"- When rate limits hit, fewer instances may start; waiting tasks run when an instance is free.",
|
|
25
35
|
"- After parallel work is back, have one member type-check and test the combined result before you report `done`.",
|
|
26
36
|
].join("\n");
|
|
37
|
+
/** The lead's system prompt ends with this when a member can run a task on a cheaper model (spec 24.10); `members` = [name, model of low, mid, high]. No trailing newline. */
|
|
38
|
+
export const TIER_SECTION = (members) => [
|
|
39
|
+
"## Task tiers",
|
|
40
|
+
"Some members can run a task on a cheaper model than their own. Add the frontmatter line `tier: low`, `tier: mid` or `tier: high` to a `task` you send them; without it they use their own model. Pick by difficulty: `low` for mechanical work with an exact spec (renames, a one-line fix, running a given command), `mid` for routine changes in one module, `high` (or no tier) for design, debugging, reviews and anything unclear. A tier lasts for that one task: replies and later mail on the thread run on the member's own model, so put `tier` again on each follow-up task. Each model keeps its own session, so a member that resumes sessions does not see what it did on another model; say in the task what it needs. If a tiered task comes back poor, send it again with a higher tier.",
|
|
41
|
+
"Members and the models each tier uses:",
|
|
42
|
+
...members.map((m) => `- ${m.name}: low ${m.low}, mid ${m.mid}, high ${m.high}`),
|
|
43
|
+
].join("\n");
|
|
27
44
|
export const COMMON_LIMIT = 8 * 1024;
|
|
28
45
|
export const MEMORY_INDEX_LIMIT = 4 * 1024;
|
|
29
46
|
export const HANDOFF_LIMIT = COMMON_LIMIT;
|
|
@@ -148,7 +165,7 @@ export function buildSystemPrompt(project, agent) {
|
|
|
148
165
|
"Your final `done` mail must also include the full `## Steps` checklist. Tick only steps that were actually done and verified; leave undone ones",
|
|
149
166
|
"(e.g. build, app start, commit) unticked and say so in the mail instead of reporting them as finished.",
|
|
150
167
|
]
|
|
151
|
-
: []), ...(isLead && sharedDir(project) ? handoffRule(sharedDir(project)) : []), "", "## Memory");
|
|
168
|
+
: []), ...(isLead ? ["", ...WAKE_ECONOMY_SECTION] : []), ...(isLead && sharedDir(project) ? handoffRule(sharedDir(project)) : []), "", "## Memory");
|
|
152
169
|
const mems = memoryDirs(agent);
|
|
153
170
|
const sharedMems = Object.values(agent.sharedMemory ?? {});
|
|
154
171
|
if (mems.length === 0)
|
|
@@ -169,8 +186,18 @@ export function buildSystemPrompt(project, agent) {
|
|
|
169
186
|
`- You also keep two kinds of durable records for every future run of this project, one Markdown file per entry: decisions in \`${recordsKindDir(project, "decisions")}/\` (what was decided and why) and lessons in \`${recordsKindDir(project, "lessons")}/\` (heuristics and pitfalls that stay true). The file name without \`.md\` is the entry id (lowercase letters, digits, \`-\`). Each file starts with YAML frontmatter between \`---\` lines: \`status: active|superseded\`, \`date: YYYY-MM-DD\`, \`title: <one line>\`, optionally \`run: <run id>\` and \`supersedes: [<ids of entries of the same kind>]\`; the body is the decision or lesson and its reason. Prompts show only effective-active entries (\`status: active\` and not named in another entry's \`supersedes\`), newest first, up to ${CONTEXT_LIMIT / 1024} KB per kind, and list what does not fit as one line each. When an entry becomes outdated, mark it \`status: superseded\` or name it in a new entry's \`supersedes\`. Add or update entries before you send \`done\`.`,
|
|
170
187
|
]
|
|
171
188
|
: []), PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
|
|
172
|
-
|
|
173
|
-
|
|
189
|
+
let text = lines.join("\n");
|
|
190
|
+
if (isLead && project.run?.instances)
|
|
191
|
+
text = `${text}\n\n${PARALLEL_SECTION(project.run.instances.max)}`;
|
|
192
|
+
if (isLead) {
|
|
193
|
+
const members = Object.values(project.agents)
|
|
194
|
+
.filter((a) => a.name !== project.lead && (!a.instance || a.instance.k === 1) && (a.tiers?.length ?? 0) >= 2)
|
|
195
|
+
.sort((x, y) => (x.name < y.name ? -1 : x.name > y.name ? 1 : 0))
|
|
196
|
+
.map((a) => ({ name: a.name, low: modelForTier(a.tiers, "low"), mid: modelForTier(a.tiers, "mid"), high: modelForTier(a.tiers, "high") }));
|
|
197
|
+
if (members.length)
|
|
198
|
+
text = `${text}\n\n${TIER_SECTION(members)}`;
|
|
199
|
+
}
|
|
200
|
+
return text;
|
|
174
201
|
}
|
|
175
202
|
/** Messages one wake-up handles: the oldest one, or a run of `reply`/`failure` mail (up to LEAD_BATCH_LIMIT) so the agent decides once on all of it; the lead takes any such run, other agents only same-thread ones. */
|
|
176
203
|
export function pickMessages(project, agent, queue) {
|
package/dist/run-session.d.ts
CHANGED
|
@@ -127,6 +127,8 @@ export declare class RunSession {
|
|
|
127
127
|
private saveState;
|
|
128
128
|
private journal;
|
|
129
129
|
private note;
|
|
130
|
+
/** Spec 24.5.2: log every tier that was dropped; tell the sender once per reason (`no-tiers` is log-only). */
|
|
131
|
+
private noteTierIgnored;
|
|
130
132
|
/** Bind the project to this run again, after its slot table changed. */
|
|
131
133
|
private rebind;
|
|
132
134
|
/** What the router needs to hold and place mail for roles with several instances (spec 23.7.1). */
|