agent-lyceum 0.23.0 → 0.24.1
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 +0 -1
- package/dist/fs-util.d.ts +18 -0
- package/dist/fs-util.js +54 -0
- package/dist/message-store.js +22 -1
- package/dist/paths.d.ts +0 -1
- package/dist/paths.js +0 -1
- package/dist/prompt.d.ts +3 -0
- package/dist/prompt.js +12 -2
- package/dist/redo.d.ts +57 -0
- package/dist/redo.js +281 -0
- package/dist/run-session.d.ts +5 -0
- package/dist/run-session.js +50 -8
- package/package.json +1 -1
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
|
+
}
|
package/dist/message-store.js
CHANGED
|
@@ -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 =
|
|
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
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
|
-
|
|
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
|
+
}
|
package/dist/run-session.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/run-session.js
CHANGED
|
@@ -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
|
-
/**
|
|
36
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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();
|