agent-lyceum 0.23.0 → 0.24.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/dist/call.js CHANGED
@@ -43,7 +43,6 @@ export function callProject(home, agent, dir, callDir) {
43
43
  config: path.join(callDir, "call.yaml"),
44
44
  agentsDir: path.join(callDir, "agents"),
45
45
  shared: path.join(callDir, "shared"),
46
- common: path.join(callDir, "shared", "COMMON.md"),
47
46
  inboxRoot: path.join(callDir, "mail", "inbox"),
48
47
  outboxRoot: path.join(callDir, "mail", "outbox"),
49
48
  runs: path.join(callDir, "runs"),
package/dist/fs-util.d.ts CHANGED
@@ -15,3 +15,21 @@ export declare function readFileCapped(file: string, limit: number): {
15
15
  * fstat check). undefined for anything else or on any error: never throws, never hangs.
16
16
  */
17
17
  export declare function readRegularFileNoFollow(file: string): Buffer | undefined;
18
+ /** `buf` cut to at most `limit` bytes, backed up to a character boundary so a multi-byte character is never split. */
19
+ export declare function cutUtf8(buf: Buffer, limit: number): Buffer;
20
+ /** `buf` without a trailing incomplete UTF-8 sequence (a cut-off lead byte and its continuation bytes). */
21
+ export declare function trimPartialUtf8(buf: Buffer): Buffer;
22
+ export type CappedRead = {
23
+ ok: true;
24
+ buf: Buffer;
25
+ size: number;
26
+ truncated: boolean;
27
+ } | {
28
+ ok: false;
29
+ reason: string;
30
+ };
31
+ /**
32
+ * Up to `limit` bytes of a regular file, opened without following a symlink and without blocking; reads at most `limit + 1` bytes.
33
+ * `size` is the file's size from fstat. Never throws: a refusal says why, from the one open (`symlink`, `not_regular`, `open_error:<code>`).
34
+ */
35
+ export declare function readRegularFileCappedNoFollow(file: string, limit: number): CappedRead;
package/dist/fs-util.js CHANGED
@@ -82,3 +82,57 @@ export function readRegularFileNoFollow(file) {
82
82
  }
83
83
  }
84
84
  }
85
+ /** `buf` cut to at most `limit` bytes, backed up to a character boundary so a multi-byte character is never split. */
86
+ export function cutUtf8(buf, limit) {
87
+ if (buf.length <= limit)
88
+ return buf;
89
+ let cut = limit;
90
+ while (cut > 0 && ((buf[cut] ?? 0) & 0xc0) === 0x80)
91
+ cut--;
92
+ return buf.subarray(0, cut);
93
+ }
94
+ /** `buf` without a trailing incomplete UTF-8 sequence (a cut-off lead byte and its continuation bytes). */
95
+ export function trimPartialUtf8(buf) {
96
+ for (let i = 1; i <= Math.min(3, buf.length); i++) {
97
+ const b = buf[buf.length - i];
98
+ if ((b & 0xc0) === 0x80)
99
+ continue;
100
+ const need = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1;
101
+ return need > i ? buf.subarray(0, buf.length - i) : buf;
102
+ }
103
+ return buf;
104
+ }
105
+ /**
106
+ * Up to `limit` bytes of a regular file, opened without following a symlink and without blocking; reads at most `limit + 1` bytes.
107
+ * `size` is the file's size from fstat. Never throws: a refusal says why, from the one open (`symlink`, `not_regular`, `open_error:<code>`).
108
+ */
109
+ export function readRegularFileCappedNoFollow(file, limit) {
110
+ let fd;
111
+ try {
112
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
113
+ const st = fs.fstatSync(fd);
114
+ if (!st.isFile())
115
+ return { ok: false, reason: "not_regular" };
116
+ const buf = Buffer.alloc(limit + 1);
117
+ let n = 0;
118
+ while (n < buf.length) {
119
+ const r = fs.readSync(fd, buf, n, buf.length - n, n);
120
+ if (r === 0)
121
+ break;
122
+ n += r;
123
+ }
124
+ return { ok: true, buf: buf.subarray(0, Math.min(n, limit)), size: st.size, truncated: n > limit };
125
+ }
126
+ catch (e) {
127
+ const code = e.code ?? "unknown";
128
+ return { ok: false, reason: code === "ELOOP" ? "symlink" : `open_error:${code}` };
129
+ }
130
+ finally {
131
+ if (fd !== undefined) {
132
+ try {
133
+ fs.closeSync(fd);
134
+ }
135
+ catch { }
136
+ }
137
+ }
138
+ }
@@ -158,10 +158,30 @@ export function listAttempts(runDir) {
158
158
  }
159
159
  return out;
160
160
  }
161
+ /**
162
+ * One more than the highest number any entry under `attempts/` already uses for this claim. Only names count (a damaged JSON or a
163
+ * bare folder still holds its number), because a new attempt's paths come from the name and a rename would overwrite them.
164
+ */
165
+ function nextAttemptNumber(runDir, claimId) {
166
+ const dir = path.join(mailDir(runDir), "attempts");
167
+ const re = new RegExp(`^${claimId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-a(\\d+)(\\.json)?$`);
168
+ let max = 0;
169
+ if (fs.existsSync(dir)) {
170
+ for (const name of fs.readdirSync(dir)) {
171
+ const m = re.exec(name);
172
+ const used = m ? Number(m[1]) : 0;
173
+ if (Number.isSafeInteger(used) && used > max)
174
+ max = used;
175
+ }
176
+ }
177
+ if (!Number.isSafeInteger(max + 1))
178
+ throw new Error(`attempt numbers exhausted for claim ${claimId} (highest used: ${max}) in ${dir}`);
179
+ return max + 1;
180
+ }
161
181
  /** Start the next attempt for a claim (a1, a2, ...). */
162
182
  export function beginAttempt(runDir, claimId) {
163
183
  const claim = loadClaim(runDir, claimId);
164
- const n = listAttempts(runDir).filter((a) => a.claim_id === claimId).length + 1;
184
+ const n = nextAttemptNumber(runDir, claimId);
165
185
  const rec = { id: `${claimId}-a${n}`, claim_id: claimId, agent: claim.agent, n, status: "started", started_at: new Date().toISOString() };
166
186
  saveAttempt(runDir, rec);
167
187
  return rec;
@@ -182,6 +202,7 @@ export function parkOutbox(runDir, outbox, attemptId) {
182
202
  continue;
183
203
  fs.mkdirSync(dest, { recursive: true });
184
204
  fs.renameSync(path.join(outbox, e.name), path.join(dest, e.name));
205
+ fault("park:moved");
185
206
  moved.push(e.name);
186
207
  }
187
208
  return moved;
package/dist/paths.d.ts CHANGED
@@ -16,7 +16,6 @@ export interface ProjectPaths {
16
16
  config: string;
17
17
  agentsDir: string;
18
18
  shared: string;
19
- common: string;
20
19
  inboxRoot: string;
21
20
  outboxRoot: string;
22
21
  runs: string;
package/dist/paths.js CHANGED
@@ -37,7 +37,6 @@ export function projectPaths(home, project) {
37
37
  config: path.join(root, "project.yaml"),
38
38
  agentsDir: path.join(root, "agents"),
39
39
  shared,
40
- common: path.join(shared, "COMMON.md"),
41
40
  inboxRoot: path.join(shared, "inbox"),
42
41
  outboxRoot: path.join(shared, "outbox"),
43
42
  runs: path.join(root, "runs"),
package/dist/prompt.d.ts CHANGED
@@ -6,6 +6,9 @@ export declare const MEMORY_INDEX_LIMIT: number;
6
6
  export declare const HANDOFF_LIMIT: number;
7
7
  /** Cap of each kind of project record (decisions, lessons) in a prompt. */
8
8
  export declare const CONTEXT_LIMIT: number;
9
+ /** Largest mail body put in a prompt (the inline task limit), and the total for the mails handled in one wake-up. */
10
+ export declare const MAIL_BODY_LIMIT: number;
11
+ export declare const MAIL_BATCH_LIMIT: number;
9
12
  export declare const LEAD_BATCH_LIMIT = 5;
10
13
  export declare function readPersona(agent: ResolvedAgent): string;
11
14
  /** The protocol of an agent working alone: no teammates, no ask. `run --agent` still ends with a `done` mail; `call` just answers. */
package/dist/prompt.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import fs from "node:fs";
2
- import { readFileCapped } from "./fs-util.js";
2
+ 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 { buildRecordsView, RECORDS_BUDGET } from "./records.js";
@@ -14,6 +14,9 @@ export const MEMORY_INDEX_LIMIT = 4 * 1024;
14
14
  export const HANDOFF_LIMIT = COMMON_LIMIT;
15
15
  /** Cap of each kind of project record (decisions, lessons) in a prompt. */
16
16
  export const CONTEXT_LIMIT = RECORDS_BUDGET;
17
+ /** Largest mail body put in a prompt (the inline task limit), and the total for the mails handled in one wake-up. */
18
+ export const MAIL_BODY_LIMIT = 16 * 1024;
19
+ export const MAIL_BATCH_LIMIT = 64 * 1024;
17
20
  export const LEAD_BATCH_LIMIT = 5;
18
21
  const BATCHABLE = new Set(["reply", "failure"]);
19
22
  const readCapped = readFileCapped;
@@ -203,10 +206,17 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1, handoff
203
206
  parts.push("", current.length > 1 ? `# Messages to handle now (${current.length}, oldest first — decide on them together)` : "# Message to handle now");
204
207
  if (current.length === 0)
205
208
  parts.push("(no message)");
209
+ let remaining = MAIL_BATCH_LIMIT;
206
210
  current.forEach((m, i) => {
207
211
  if (current.length > 1)
208
212
  parts.push("", `## Message ${i + 1} of ${current.length}`);
209
- parts.push(`id: ${m.meta.id}`, `from: ${m.meta.from}`, `type: ${m.meta.type}`, `thread: ${m.meta.thread}`, `subject: ${m.meta.subject}`, "", m.body);
213
+ const raw = Buffer.from(m.body, "utf8");
214
+ const shown = cutUtf8(raw, Math.min(MAIL_BODY_LIMIT, remaining));
215
+ remaining -= shown.length;
216
+ const body = shown.length === raw.length
217
+ ? m.body
218
+ : `${shown.toString("utf8")}\n[agent-lyceum: this message was cut at ${shown.length} of ${raw.length} bytes; read the whole message in \`${m.file}\` (it moves to \`read/\` in the same folder after this wake-up)]`;
219
+ parts.push(`id: ${m.meta.id}`, `from: ${m.meta.from}`, `type: ${m.meta.type}`, `thread: ${m.meta.thread}`, `subject: ${m.meta.subject}`, "", body);
210
220
  });
211
221
  if (queued.length) {
212
222
  parts.push("", "# Queued messages (not handled in this wake-up; each gets its own wake-up later, titles only)");
package/dist/redo.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ /** An earlier attempt of the same agent at the same message that failed: what a redo starts from. */
2
+ export interface PriorAttempt {
3
+ /** `<claim id>-a<n>` */
4
+ id: string;
5
+ claimId: string;
6
+ n: number;
7
+ /** The attempt's error; "unknown (attempt record unreadable)" when its record cannot be read. */
8
+ error: string;
9
+ /** For sorting: finished_at ?? started_at ?? the folder's mtime (epoch ms). */
10
+ at: number;
11
+ /** Files the record says were set aside; absent when the record is unreadable. */
12
+ parked?: string[];
13
+ }
14
+ /** Something left out; `path` is relative to the run folder. */
15
+ export interface Skip {
16
+ path: string;
17
+ reason: string;
18
+ }
19
+ export interface PriorResult {
20
+ /** Valid candidates, newest first. */
21
+ prior: PriorAttempt[];
22
+ /** Records or folders that were passed over; they never affect the other candidates. */
23
+ skipped: Skip[];
24
+ }
25
+ export interface DraftsResult {
26
+ /** The whole drafts block; "" when there is nothing usable. */
27
+ text: string;
28
+ injected: {
29
+ path: string;
30
+ bytes: number;
31
+ truncated: boolean;
32
+ }[];
33
+ skipped: Skip[];
34
+ /** Attempts whose folder and record disagree on what was set aside. */
35
+ reconciled: {
36
+ attempt: string;
37
+ onlyOnDisk: string[];
38
+ onlyInRecord: string[];
39
+ }[];
40
+ }
41
+ /** Attempts of `agent` that failed before this invoke, on a claim for one of `messageIds`. Never throws. */
42
+ export declare function priorFailedAttempts(runDir: string, agent: string, messageIds: string[], exclude: string): PriorResult;
43
+ /** The drafts block for the attempts in `prior` (newest first), read from their folders, at most `budget` bytes and `maxFiles` files. */
44
+ export declare function collectDrafts(runDir: string, prior: PriorAttempt[], budget?: number, maxFiles?: number): DraftsResult;
45
+ /** Prompt for a repeat attempt in the same wake-up: the earlier one failed and may have changed things already. */
46
+ export declare function retryPrompt(prompt: string, error: string | undefined, o: {
47
+ drafts: boolean;
48
+ taskMemory?: string;
49
+ }): string;
50
+ /** The user prompt for this invoke: `base`, a Retry or Redo notice when there is something to redo, then the drafts. No IO. */
51
+ export declare function redoUserPrompt(base: string, o: {
52
+ attempt: number;
53
+ error?: string;
54
+ prior: PriorAttempt[];
55
+ drafts: string;
56
+ taskMemory?: string;
57
+ }): string;
package/dist/redo.js ADDED
@@ -0,0 +1,281 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { cutUtf8, readRegularFileCappedNoFollow, trimPartialUtf8 } from "./fs-util.js";
5
+ const METADATA_LIMIT = 64 * 1024;
6
+ const DRAFT_BUDGET = 4096;
7
+ const DRAFT_FILES = 5;
8
+ const OUTBOX_SCAN = 64;
9
+ const UNREADABLE = "unknown (attempt record unreadable)";
10
+ const hasControl = (s) => [...s].some((ch) => ch.charCodeAt(0) < 0x20);
11
+ /** Can `s` be one path segment (a claim or attempt id taken from a file)? */
12
+ function isSegment(s) {
13
+ return typeof s === "string" && s.length >= 1 && s.length <= 200 && !/[/\\]/.test(s) && !s.includes("\0") && s !== "." && s !== ".." && !s.startsWith(".");
14
+ }
15
+ /** Read a claim or attempt JSON without following a symlink or blocking on a FIFO, at most 64 KiB. Never throws. */
16
+ function readMetadataJson(file) {
17
+ const r = readRegularFileCappedNoFollow(file, METADATA_LIMIT);
18
+ if (!r.ok)
19
+ return { skip: r.reason };
20
+ if (r.truncated)
21
+ return { skip: "metadata_too_large" };
22
+ try {
23
+ return { value: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(r.buf)) };
24
+ }
25
+ catch {
26
+ return { skip: "metadata_unreadable" };
27
+ }
28
+ }
29
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
30
+ const str = (v) => (typeof v === "string" ? v : undefined);
31
+ /** The attempt record, or the reason it is not usable. The id that builds paths must equal the file name. */
32
+ function validAttempt(v, fileBase) {
33
+ if (!isObject(v))
34
+ return "bad_record";
35
+ const { id, claim_id: claimId, agent, status, n } = v;
36
+ if (typeof id !== "string" || typeof agent !== "string" || typeof status !== "string")
37
+ return "bad_record";
38
+ if (!isSegment(claimId) || !Number.isSafeInteger(n) || n < 1)
39
+ return "bad_record";
40
+ if (id !== fileBase || id !== `${claimId}-a${n}`)
41
+ return "bad_record";
42
+ return { ...v, id, claim_id: claimId, agent, status, n, error: str(v.error), started_at: str(v.started_at) ?? "", finished_at: str(v.finished_at) };
43
+ }
44
+ function validClaim(v, claimId) {
45
+ if (!isObject(v) || v.id !== claimId || typeof v.agent !== "string" || typeof v.status !== "string")
46
+ return "bad_record";
47
+ if (!Array.isArray(v.message_ids) || !v.message_ids.every((x) => typeof x === "string"))
48
+ return "bad_record";
49
+ return v;
50
+ }
51
+ const rel = (...p) => path.join(...p);
52
+ /** lstat each level: a real folder, or the reason it is not one ("missing" when it does not exist). */
53
+ function realDir(file) {
54
+ try {
55
+ const st = fs.lstatSync(file);
56
+ if (st.isSymbolicLink())
57
+ return "ancestor_symlink";
58
+ return st.isDirectory() ? "ok" : "not_dir";
59
+ }
60
+ catch {
61
+ return "missing";
62
+ }
63
+ }
64
+ /** The attempt folder's mtime in ms, 0 when it cannot be read. */
65
+ function dirMtime(dir) {
66
+ try {
67
+ return fs.lstatSync(dir).mtimeMs;
68
+ }
69
+ catch {
70
+ return 0;
71
+ }
72
+ }
73
+ /** Attempts of `agent` that failed before this invoke, on a claim for one of `messageIds`. Never throws. */
74
+ export function priorFailedAttempts(runDir, agent, messageIds, exclude) {
75
+ const skipped = [];
76
+ const empty = () => ({ prior: [], skipped });
77
+ const levels = [".", "mail", rel("mail", "attempts"), rel("mail", "claims")];
78
+ for (const l of levels) {
79
+ const st = realDir(path.join(runDir, l));
80
+ if (st === "ancestor_symlink" || st === "not_dir") {
81
+ skipped.push({ path: l, reason: st });
82
+ return empty();
83
+ }
84
+ if (st === "missing") {
85
+ if (l === rel("mail", "claims") && realDir(path.join(runDir, "mail", "attempts")) === "ok")
86
+ skipped.push({ path: l, reason: "metadata_missing" });
87
+ return empty();
88
+ }
89
+ }
90
+ const dir = path.join(runDir, "mail", "attempts");
91
+ let names;
92
+ try {
93
+ names = fs.readdirSync(dir);
94
+ }
95
+ catch (e) {
96
+ skipped.push({ path: rel("mail", "attempts"), reason: `open_error:${e.code ?? "unknown"}` });
97
+ return empty();
98
+ }
99
+ // Candidates by claim id; an attempt without a usable record is kept as "unreadable".
100
+ const found = [];
101
+ const recorded = new Set();
102
+ for (const name of names.filter((x) => x.endsWith(".json")).sort()) {
103
+ const base = name.slice(0, -5);
104
+ const file = rel("mail", "attempts", name);
105
+ const r = readMetadataJson(path.join(dir, name));
106
+ if ("skip" in r) {
107
+ skipped.push({ path: file, reason: r.skip });
108
+ continue;
109
+ }
110
+ const rec = validAttempt(r.value, base);
111
+ if (typeof rec === "string") {
112
+ skipped.push({ path: file, reason: rec });
113
+ continue;
114
+ }
115
+ recorded.add(base);
116
+ if (rec.status !== "failed" || rec.agent !== agent || rec.id === exclude)
117
+ continue;
118
+ const t = [rec.finished_at, rec.started_at].map((s) => (s ? Date.parse(s) : NaN)).find((x) => Number.isFinite(x));
119
+ found.push({ id: rec.id, claimId: rec.claim_id, n: rec.n, error: rec.error ?? "unknown error", at: t ?? dirMtime(path.join(dir, rec.id)), parked: Array.isArray(rec.parked) ? rec.parked.filter((x) => typeof x === "string") : undefined });
120
+ }
121
+ for (const name of names.filter((x) => !x.endsWith(".json") && !recorded.has(x))) {
122
+ const m = /^(.+)-a([1-9]\d*)$/.exec(name);
123
+ if (!m || !isSegment(m[1]) || name === exclude || realDir(path.join(dir, name)) !== "ok")
124
+ continue;
125
+ found.push({ id: name, claimId: m[1], n: Number(m[2]), error: UNREADABLE, at: dirMtime(path.join(dir, name)) });
126
+ }
127
+ const accepted = new Map();
128
+ const usable = (claimId) => {
129
+ const known = accepted.get(claimId);
130
+ if (known !== undefined)
131
+ return known;
132
+ const file = rel("mail", "claims", `${claimId}.json`);
133
+ let ok = false;
134
+ const r = readMetadataJson(path.join(runDir, file));
135
+ if ("skip" in r)
136
+ skipped.push({ path: file, reason: r.skip });
137
+ else {
138
+ const claim = validClaim(r.value, claimId);
139
+ if (typeof claim === "string")
140
+ skipped.push({ path: file, reason: claim });
141
+ else
142
+ ok = claim.agent === agent && (claim.status === "claimed" || claim.status === "abandoned") && claim.message_ids.some((id) => messageIds.includes(id));
143
+ }
144
+ accepted.set(claimId, ok);
145
+ return ok;
146
+ };
147
+ const prior = found.filter((a) => usable(a.claimId)).sort((a, b) => b.at - a.at || b.n - a.n);
148
+ return { prior, skipped };
149
+ }
150
+ /** The drafts block for the attempts in `prior` (newest first), read from their folders, at most `budget` bytes and `maxFiles` files. */
151
+ export function collectDrafts(runDir, prior, budget = DRAFT_BUDGET, maxFiles = DRAFT_FILES) {
152
+ const result = { text: "", injected: [], skipped: [], reconciled: [] };
153
+ const candidates = [];
154
+ for (const a of prior) {
155
+ const outboxRel = rel("mail", "attempts", a.id, "outbox");
156
+ const parts = [rel("mail", "attempts", a.id), outboxRel];
157
+ const bad = parts.map((p) => ({ p, st: realDir(path.join(runDir, p)) })).find((x) => x.st !== "ok");
158
+ if (bad) {
159
+ // A missing outbox just means nothing was set aside.
160
+ if (bad.st !== "missing")
161
+ result.skipped.push({ path: bad.p, reason: bad.st });
162
+ continue;
163
+ }
164
+ let entries;
165
+ try {
166
+ entries = fs.readdirSync(path.join(runDir, outboxRel), { withFileTypes: true }).sort((x, y) => (x.name < y.name ? -1 : 1));
167
+ }
168
+ catch (e) {
169
+ result.skipped.push({ path: outboxRel, reason: `open_error:${e.code ?? "unknown"}` });
170
+ continue;
171
+ }
172
+ if (entries.length > OUTBOX_SCAN)
173
+ result.skipped.push({ path: outboxRel, reason: "over_entry_cap" });
174
+ const onDisk = [];
175
+ for (const e of entries.slice(0, OUTBOX_SCAN)) {
176
+ const file = rel(outboxRel, e.name);
177
+ if (e.name.startsWith("."))
178
+ continue;
179
+ if (!e.isFile()) {
180
+ result.skipped.push({ path: file, reason: e.isSymbolicLink() ? "symlink" : "not_regular" });
181
+ continue;
182
+ }
183
+ if (hasControl(e.name) || e.name.length > 200) {
184
+ result.skipped.push({ path: file, reason: "bad_name" });
185
+ continue;
186
+ }
187
+ onDisk.push(e.name);
188
+ candidates.push({ attempt: a, rel: file, name: e.name });
189
+ }
190
+ if (a.parked) {
191
+ const onlyOnDisk = onDisk.filter((x) => !a.parked?.includes(x));
192
+ const onlyInRecord = a.parked.filter((x) => !onDisk.includes(x));
193
+ if (onlyOnDisk.length || onlyInRecord.length)
194
+ result.reconciled.push({ attempt: a.id, onlyOnDisk, onlyInRecord });
195
+ }
196
+ }
197
+ if (candidates.length === 0)
198
+ return result;
199
+ const header = "# Drafts from your failed attempt (unverified, not delivered)\nThese files were in your outbox when an earlier attempt at this message failed. They were set aside and never delivered; they may be incomplete or wrong. Check them against the repository before reusing any of them.\n";
200
+ const more = (n, c) => `(${n} more draft file(s) not shown: ${rel("mail", "attempts", c.attempt.id, "outbox")}/)`;
201
+ const render = (c, content, truncated, size) => {
202
+ const longest = Math.max(0, ...(content.match(/`+/g) ?? []).map((x) => x.length));
203
+ const fence = "`".repeat(Math.max(3, longest + 1));
204
+ return `\n## ${c.rel} (attempt ${c.attempt.n}, ${c.attempt.error})\n${fence}\n${content}\n${fence}\n${truncated ? `[truncated: ${c.rel}, ${size} B]\n` : ""}`;
205
+ };
206
+ const bytes = (s) => Buffer.byteLength(s);
207
+ const reserve = Math.max(...candidates.map((c) => bytes(`\n${more(999, c)}\n`)));
208
+ let used = bytes(header);
209
+ let text = header;
210
+ const seen = new Set();
211
+ let shown = 0;
212
+ let left = candidates.length;
213
+ for (const c of candidates) {
214
+ if (shown >= maxFiles)
215
+ break;
216
+ const room = budget - used - reserve;
217
+ const overhead = bytes(render(c, "", true, Number.MAX_SAFE_INTEGER));
218
+ if (room - overhead < 1)
219
+ break;
220
+ left--;
221
+ const r = readRegularFileCappedNoFollow(path.join(runDir, c.rel), room - overhead);
222
+ if (!r.ok) {
223
+ result.skipped.push({ path: c.rel, reason: r.reason });
224
+ continue;
225
+ }
226
+ // The capped read can end inside a character: drop the incomplete tail before decoding.
227
+ if (r.truncated)
228
+ r.buf = trimPartialUtf8(r.buf);
229
+ if (r.buf.includes(0)) {
230
+ result.skipped.push({ path: c.rel, reason: "binary" });
231
+ continue;
232
+ }
233
+ if (!r.truncated) {
234
+ const h = crypto.createHash("sha256").update(r.buf).digest("hex");
235
+ if (seen.has(h))
236
+ continue;
237
+ seen.add(h);
238
+ }
239
+ // Backtick-heavy content lengthens the fence; trim until the block fits.
240
+ let lim = r.buf.length;
241
+ let block = render(c, "", r.truncated, r.size);
242
+ for (;;) {
243
+ const cut = cutUtf8(r.buf, lim);
244
+ block = render(c, cut.toString("utf8"), r.truncated || cut.length < r.buf.length, r.size);
245
+ if (bytes(block) <= room || lim <= 0)
246
+ break;
247
+ lim = cut.length - (bytes(block) - room);
248
+ }
249
+ if (bytes(block) > room) {
250
+ left++;
251
+ break;
252
+ }
253
+ text += block;
254
+ used += bytes(block);
255
+ shown++;
256
+ result.injected.push({ path: c.rel, bytes: r.size, truncated: block.includes(`[truncated: ${c.rel},`) });
257
+ }
258
+ if (shown === 0)
259
+ return result;
260
+ const rest = candidates.filter((c) => !result.injected.some((i) => i.path === c.rel) && !result.skipped.some((s) => s.path === c.rel));
261
+ if (left > 0 && rest[0])
262
+ text += `${more(rest.length, rest[0])}\n`;
263
+ result.text = text.trimEnd();
264
+ return result;
265
+ }
266
+ const TASK_MEMORY = (dir) => (dir ? ` Read your task memory (\`${dir}\`) first: progress you recorded there survives a failed attempt.` : "");
267
+ /** Prompt for a repeat attempt in the same wake-up: the earlier one failed and may have changed things already. */
268
+ export function retryPrompt(prompt, error, o) {
269
+ const mail = o.drafts ? "Its mail was set aside, not delivered: see the drafts below." : "Its mail was discarded.";
270
+ return `${prompt}\n\n# Retry notice\nYour previous attempt at this wake-up failed${error ? ` (${error})` : ""} after possibly changing files already. ${mail} Check the current state of the repository before redoing the work, and send the mail again.${TASK_MEMORY(o.taskMemory)}`;
271
+ }
272
+ /** The user prompt for this invoke: `base`, a Retry or Redo notice when there is something to redo, then the drafts. No IO. */
273
+ export function redoUserPrompt(base, o) {
274
+ const tail = o.drafts ? `\n\n${o.drafts}` : "";
275
+ if (o.attempt > 0)
276
+ return `${retryPrompt(base, o.error, { drafts: !!o.drafts, taskMemory: o.taskMemory })}${tail}`;
277
+ const last = o.prior[0];
278
+ if (!last)
279
+ return base;
280
+ return `${base}\n\n# Redo notice\nAn earlier attempt at this message did not finish (${last.error}; attempt ${last.id}). It may have changed files already, and its mail was set aside, not delivered${o.drafts ? ": see the drafts below" : ""}. Check the current state of the repository before redoing the work, and send the mail again.${TASK_MEMORY(o.taskMemory)}${tail}`;
281
+ }
@@ -113,6 +113,11 @@ export declare class RunSession {
113
113
  private pendingAgents;
114
114
  private pickBatch;
115
115
  private wake;
116
+ /**
117
+ * The earlier failed attempts at these messages and the drafts they left, for the invoke that `attemptId` is about to make.
118
+ * Every skip and the outcome go to the log; a read problem never fails the wake-up.
119
+ */
120
+ private redoContext;
116
121
  /** Copy the shared cooldown table into the state (only once something has cooled down); true when it was. */
117
122
  private syncCooldowns;
118
123
  /**
@@ -6,11 +6,12 @@ import { briefOf, digestOf, doneContract, mergeSteps, parseSteps } from "./forma
6
6
  import { ProtectedGuard } from "./guard.js";
7
7
  import { addBatch, askersWithAnswers, askReplyPath, checkAnswers, describeCheck, isComplete, markDelivered, parseAskBody, readAskReply, renderAnswers, resolveOptionLetters, writeAskReply } from "./ask-reply.js";
8
8
  import { deliver, deliverOnce, ensureProjectDirs, finishDone, listUnread, rejectDone, routeOutboxes, unreadFiles } from "./mailbox.js";
9
- import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, commitClaim, finishAttempt, markOutputReady, parkOutbox, recoverRunMail, RouteJournal } from "./message-store.js";
9
+ import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, commitClaim, fault, finishAttempt, markOutputReady, parkOutbox, recoverRunMail, RouteJournal } from "./message-store.js";
10
10
  import { createRunLog } from "./run-log.js";
11
11
  import { endOutcome, resolveDoneOutcome } from "./run-outcome.js";
12
12
  import { outboxDir, sharedIndex } from "./policy.js";
13
13
  import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
14
+ import { collectDrafts, priorFailedAttempts, redoUserPrompt } from "./redo.js";
14
15
  import { atomicWrite, readFileCapped } from "./fs-util.js";
15
16
  import { assertWorktreeRunnable, collectAgentChanges, dirtyPaths, existingAgentWorkspace, finishLeadBranch, integrateAgentChanges, prepareAgentWorkspace, recutLeadWorkspace, refusedIntegration, snapshotBase, snapshotHead, toplevel } from "./worktree.js";
16
17
  import { bindRunProject, newRunId, newRunState, roundLimit, roundsLabel, saveRunState } from "./run-store.js";
@@ -32,10 +33,8 @@ function readIndex(file) {
32
33
  return undefined;
33
34
  return readFileCapped(file, INDEX_COMPARE_LIMIT)?.text ?? "";
34
35
  }
35
- /** Prompt for a repeat attempt: the earlier one failed and may have changed things already. */
36
- function retryPrompt(prompt, error) {
37
- return `${prompt}\n\n# Retry notice\nYour previous attempt at this wake-up failed${error ? ` (${error})` : ""} after possibly changing files already. Its mail was discarded. Check the current state of the repository before redoing the work, and send the mail again.`;
38
- }
36
+ /** The `state.sessions` key of the session an agent runs in on candidate `c`: a backup has a session of its own. */
37
+ const sessionKeyOf = (agent, c) => (c.primary ? agent.name : `${agent.name}@${c.key}`);
39
38
  /** `runtime/model`, or the runtime alone for its default model. */
40
39
  const modelLabel = (c) => (c.model ? `${c.runtime}/${c.model}` : c.runtime);
41
40
  /** Each model given up on once, in order, with its last failure (retries on the same model are one entry). */
@@ -343,7 +342,8 @@ export class RunSession {
343
342
  if (opts.signal?.aborted)
344
343
  return;
345
344
  say(` ${agent.name} falling back to ${modelLabel(to)} (${kind ?? "unknown error"})`);
346
- this.note(`${agent.name} fell back from ${modelLabel(from)} to ${modelLabel(to)} (${kind ?? "unknown error"}); the backup starts a fresh session and redoes this wake-up. ` +
345
+ const resumes = agent.resume && !!state.sessions[sessionKeyOf(agent, to)];
346
+ this.note(`${agent.name} fell back from ${modelLabel(from)} to ${modelLabel(to)} (${kind ?? "unknown error"}); the backup ${resumes ? "resumes its own earlier session" : "starts a fresh session"} and redoes this wake-up. ` +
347
347
  `Anything the failed attempts already did outside the mailbox (file edits, commands) was not undone: side effects may have happened.`);
348
348
  },
349
349
  invoke: async (c, ctx) => {
@@ -352,10 +352,13 @@ export class RunSession {
352
352
  const attempt = ctx.attempt;
353
353
  // A backup runs as the same agent on another runtime/model, in a session of its own.
354
354
  const runAs = agentForCandidate(agent, c);
355
- const sessionKey = c.primary ? agent.name : `${agent.name}@${c.key}`;
355
+ const sessionKey = sessionKeyOf(agent, c);
356
356
  const round = ++state.rounds;
357
357
  const started = Date.now();
358
358
  const att = beginAttempt(runDir, claim.id);
359
+ // Looked up again for every call: a retry sees what the attempt before it just set aside.
360
+ const redo = this.redoContext(agent.name, unread.map((m) => m.meta.id), att.id, attempt);
361
+ const userPrompt = redoUserPrompt(base.userPrompt, { attempt, error: ctx.previous?.error, prior: redo.prior, drafts: redo.drafts, taskMemory: agent.memory.task });
359
362
  const retryN = chain ? ctx.candidateAttempt : attempt;
360
363
  say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${chain ? modelLabel(c) : agent.runtime})${retryN ? ` — retry ${retryN}` : ""}`);
361
364
  const sessionId = agent.resume ? state.sessions[sessionKey] : undefined;
@@ -369,7 +372,7 @@ export class RunSession {
369
372
  agent: runAs,
370
373
  signal: opts.signal,
371
374
  logDir: attemptLogDir(runDir, att.id),
372
- userPrompt: attempt ? retryPrompt(base.userPrompt, ctx.previous?.error) : base.userPrompt,
375
+ userPrompt,
373
376
  sessionId,
374
377
  }).catch((e) => ({
375
378
  ok: false,
@@ -411,6 +414,7 @@ export class RunSession {
411
414
  duration_ms: Date.now() - started,
412
415
  messages: unread.map((m) => m.meta.id),
413
416
  error: result.error,
417
+ ...(redo.prior.length ? { redo_of: redo.prior.map((a) => a.id) } : {}),
414
418
  ...(chain ? { model: c.model, fallback_from: which.fallback_from, kind: result.kind } : {}),
415
419
  });
416
420
  this.saveState();
@@ -425,11 +429,13 @@ export class RunSession {
425
429
  if (result.cancelled) {
426
430
  // Stopped on purpose: not a failure to report. Its output is set aside (its input stays unread for the resume, below).
427
431
  const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
432
+ fault("park:before-finish");
428
433
  finishAttempt(runDir, att.id, "failed", { error: "cancelled", parked });
429
434
  return result;
430
435
  }
431
436
  // A failed attempt's output is never trusted: park it under the attempt, away from the next attempt's result.
432
437
  const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
438
+ fault("park:before-finish");
433
439
  finishAttempt(runDir, att.id, "failed", { error: result.error, parked });
434
440
  say(` ${agent.name} failed: ${result.error ?? "unknown error"}`);
435
441
  this.note(`${agent.name} attempt ${att.n} failed (${result.error ?? "unknown error"})${parked.length ? `; ${parked.length} output file(s) set aside in ${path.join("mail", "attempts", att.id, "outbox")}` : ""}. ` +
@@ -456,6 +462,42 @@ export class RunSession {
456
462
  // The agent failed every time it was called: the run stops here (see settle) and its message stays unread for the resume.
457
463
  return { ok: false, error: result?.error, attempts: calls, ...(chain ? { tried: lastPerModel(outcome.tried) } : {}) };
458
464
  }
465
+ /**
466
+ * The earlier failed attempts at these messages and the drafts they left, for the invoke that `attemptId` is about to make.
467
+ * Every skip and the outcome go to the log; a read problem never fails the wake-up.
468
+ */
469
+ redoContext(agent, messageIds, attemptId, attempt) {
470
+ const { log, runDir } = this;
471
+ let prior = [];
472
+ let drafts;
473
+ const skipped = [];
474
+ try {
475
+ const found = priorFailedAttempts(runDir, agent, messageIds, attemptId);
476
+ prior = found.prior;
477
+ skipped.push(...found.skipped);
478
+ if (prior.length) {
479
+ drafts = collectDrafts(runDir, prior);
480
+ skipped.push(...drafts.skipped);
481
+ }
482
+ }
483
+ catch (e) {
484
+ prior = [];
485
+ drafts = undefined;
486
+ skipped.push({ path: "mail/attempts", reason: `error:${e.message}` });
487
+ }
488
+ for (const s of skipped)
489
+ log("draft_skip", { agent, attempt: attemptId, path: s.path, reason: s.reason });
490
+ if (!prior.length)
491
+ return { prior, drafts: "" };
492
+ log("redo", { agent, attempt: attemptId, prior: prior.map((a) => a.id), notice: attempt > 0 ? "retry" : "redo" });
493
+ for (const r of drafts?.reconciled ?? [])
494
+ log("draft_reconcile", { agent, attempt: r.attempt, only_on_disk: r.onlyOnDisk, only_in_record: r.onlyInRecord });
495
+ if (drafts?.injected.length)
496
+ log("draft_inject", { agent, attempt: attemptId, files: drafts.injected });
497
+ else
498
+ log("draft_none", { agent, attempt: attemptId, prior: prior.map((a) => a.id) });
499
+ return { prior, drafts: drafts?.text ?? "" };
500
+ }
459
501
  /** Copy the shared cooldown table into the state (only once something has cooled down); true when it was. */
460
502
  syncCooldowns() {
461
503
  const table = this.cooldowns.toJSON();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Config + dispatcher for teams of Claude Code / Codex agents with scoped context and file-based mailboxes",
5
5
  "keywords": [
6
6
  "ai",