agent-lyceum 0.5.0 → 0.6.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 +6 -3
- package/README.zh-TW.md +6 -3
- package/dist/adapters/claude.js +2 -0
- package/dist/ask-answer.d.ts +10 -0
- package/dist/ask-answer.js +21 -0
- package/dist/ask-reply.d.ts +65 -0
- package/dist/ask-reply.js +165 -0
- package/dist/cli.js +195 -14
- package/dist/config.d.ts +4 -0
- package/dist/config.js +5 -1
- package/dist/doctor.js +3 -0
- package/dist/mailbox.d.ts +9 -1
- package/dist/mailbox.js +11 -3
- package/dist/memory-tidy.d.ts +115 -0
- package/dist/memory-tidy.js +351 -0
- package/dist/prompt.d.ts +20 -0
- package/dist/prompt.js +75 -2
- package/dist/run-outcome.js +2 -0
- package/dist/run-session.d.ts +10 -0
- package/dist/run-session.js +98 -1
- package/dist/run-store.d.ts +3 -1
- package/dist/run-store.js +4 -3
- package/dist/schema.d.ts +5 -2
- package/dist/schema.js +4 -1
- package/dist/status.d.ts +9 -1
- package/dist/status.js +32 -2
- package/package.json +2 -2
package/dist/prompt.js
CHANGED
|
@@ -34,13 +34,31 @@ export function buildSystemPrompt(project, agent) {
|
|
|
34
34
|
? [
|
|
35
35
|
`That directory is your own isolated git worktree (branch \`${project.workspaces?.[agent.name]?.branch}\`): teammates cannot see your changes while you work, and you cannot see theirs. Do not commit, push, switch branches or touch any other checkout; the dispatcher collects your changes from this directory after you finish.`,
|
|
36
36
|
]
|
|
37
|
-
: []), `The team lead is **${project.lead}**${isLead ? " (that is you)" : ""}. Teammates: ${mates.join(", ") || "(none)"}.`, "", "## Sending mail", "You cannot talk to teammates directly. Mail you receive is shown in your prompt. To send mail, create a Markdown file", `(any name ending in \`.md\`) directly inside \`${outbox}\` with this exact shape:`, "", "```", "---", "to: <agent-name>",
|
|
37
|
+
: []), `The team lead is **${project.lead}**${isLead ? " (that is you)" : ""}. Teammates: ${mates.join(", ") || "(none)"}.`, "", "## Sending mail", "You cannot talk to teammates directly. Mail you receive is shown in your prompt. To send mail, create a Markdown file", `(any name ending in \`.md\`) directly inside \`${outbox}\` with this exact shape:`, "", "```", "---", "to: <agent-name>", `type: reply # task | reply${agent.canAskUser ? " | ask" : ""}${isLead ? " | done" : ""}`, "subject: <short subject>", "reply_to: <message id> # optional", "---", "", "<body>", "```", "", `You may send to: ${recipients.join(", ") || "(nobody)"}.`, isLead
|
|
38
38
|
? `When the whole job is finished, send a message with \`type: done\` (no \`to\` needed). That ends the run. A summary in your reply text is never delivered and does not end the run: after the last teammate reply is verified you MUST send \`done\` (or a \`task\` for the remaining work), or the run stops as "idle" with no final report.`
|
|
39
39
|
: `Report results to the lead by mail; the lead decides what happens next.`, isLead
|
|
40
40
|
? "Each wake-up handles one message, except that several `reply`/`failure` messages waiting together are handed to you in one wake-up: weigh them together, then decide the next step once."
|
|
41
41
|
: "Each wake-up handles one message. Do the work it asks, then send the replies that are needed, then stop.", "", "## Message format", "The body of every `task` and `reply` must contain these `##` headings, exactly as written (write `None` under a heading if there is nothing to say).", "Mail missing a heading is still delivered, but is flagged to the recipient.", "", `- \`task\`: ${REQUIRED_SECTIONS.task.map((h) => `\`## ${h}\``).join(", ")} (Upstream = id of the mail this task derives from, or \`None\`).`, `- \`reply\`: ${REQUIRED_SECTIONS.reply.map((h) => `\`## ${h}\``).join(", ")}.`, isLead
|
|
42
42
|
? "- `done`: your final report to the user (shown when the run ends, saved as `result.md`). Its frontmatter must contain `outcome: completed|partial|blocked|failed`, and the body the headings `## Result` (the actual deliverable or conclusion, not just \"done\"), `## Files` (paths created or changed), `## Verification` (what you ran or checked, and what it showed) and `## Not done` (anything skipped or unverified, or `None`). Only `completed` means success, and it needs all four headings and every checklist step ticked; if work remains or is blocked, say `partial`/`blocked`/`failed` (those need only `## Result` and `## Not done`). A `done` that breaks this is sent back to you and does not end the run."
|
|
43
|
-
: "- `done` has no required headings.", ...(
|
|
43
|
+
: "- `done` has no required headings.", ...(agent.canAskUser
|
|
44
|
+
? [
|
|
45
|
+
"",
|
|
46
|
+
"## Asking the user",
|
|
47
|
+
"You may ask the human user with `type: ask` (no `to` needed). The run then pauses until they answer, so ask only when the answer changes what you do next and you cannot work it out from the code, the memory or the task. Ask everything you need in ONE batch (at most 10 questions); prefer questions with `<options>` and a `<suggested>` value (with a `reason`) so the user can answer quickly. The body is exactly this:",
|
|
48
|
+
"",
|
|
49
|
+
"```",
|
|
50
|
+
"<ask>",
|
|
51
|
+
' <question id="q1">',
|
|
52
|
+
" <text>Session or JWT for login?</text>",
|
|
53
|
+
" <options><option>session</option><option>jwt</option></options>",
|
|
54
|
+
' <suggested reason="existing code already uses cookies">session</suggested>',
|
|
55
|
+
" </question>",
|
|
56
|
+
"</ask>",
|
|
57
|
+
"```",
|
|
58
|
+
"",
|
|
59
|
+
"The answers come back to you as a `reply` mail containing an `<answers>` block. A user answer that starts with `other:` is free text. Do not ask what you could decide yourself, and do not ask again for something already answered.",
|
|
60
|
+
]
|
|
61
|
+
: []), ...(isLead
|
|
44
62
|
? [
|
|
45
63
|
"",
|
|
46
64
|
"## Progress tracking (optional, shown in `agent-lyceum status`)",
|
|
@@ -123,3 +141,58 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1) {
|
|
|
123
141
|
}
|
|
124
142
|
return parts.join("\n");
|
|
125
143
|
}
|
|
144
|
+
/** The prompts of a memory-tidy wake-up: a maintenance job, not a task, so it has none of the mail protocol. */
|
|
145
|
+
export function buildTidyPrompts(agent, ctx) {
|
|
146
|
+
const systemPrompt = [
|
|
147
|
+
readPersona(agent),
|
|
148
|
+
"",
|
|
149
|
+
"---",
|
|
150
|
+
"",
|
|
151
|
+
"# Memory tidy (injected by agent-lyceum)",
|
|
152
|
+
"",
|
|
153
|
+
`You are **${agent.name}**. This wake-up is a maintenance job, not a task: there is no mail to send or read, and nothing to do in the repository. Your only job is to tidy your own ${ctx.layer} memory so it stays small, accurate and useful.`,
|
|
154
|
+
"",
|
|
155
|
+
"## What you may touch",
|
|
156
|
+
`Only files inside \`${ctx.dir}\`. Everything else is read-only. Do not touch your other memory directories, \`${ctx.dir}/.tidy-state.json\` or the archive folders of earlier tidies.`,
|
|
157
|
+
"",
|
|
158
|
+
"## What to do",
|
|
159
|
+
"- Merge entries that say the same thing; keep one file with the combined, current content.",
|
|
160
|
+
"- Rewrite entries that are stale (see the project changes and the missing paths below); correct them if you can tell what is true now, otherwise keep them and say so in the report.",
|
|
161
|
+
"- Keep `MEMORY.md` as the index: one line per entry, each pointing at an existing file, and nothing for files that were archived.",
|
|
162
|
+
"- **When unsure, keep it.** Prefer leaving an entry alone over losing information.",
|
|
163
|
+
"",
|
|
164
|
+
"## Never delete",
|
|
165
|
+
`To retire a file, move it into the archive folder, keeping its relative path (\`${ctx.dir}/x/y.md\` goes to \`${ctx.archiveDir}/x/y.md\`). Never delete a file. \`MEMORY.md\` itself is never archived. A file whose content you merged into another one is archived too.`,
|
|
166
|
+
"",
|
|
167
|
+
"## Before you finish",
|
|
168
|
+
`Write \`${ctx.archiveDir}/tidy-report.md\` — even when you changed nothing — with these headings: \`## Merged\` (what was combined into what), \`## Archived\` (each archived file and why), \`## Unsure\` (things you kept but doubt). Write \`None\` under a heading with nothing to say. The tidy is rolled back if the report is missing or any file went missing.`,
|
|
169
|
+
].join("\n");
|
|
170
|
+
const parts = [
|
|
171
|
+
`Archive directory: ${ctx.archiveDir}`,
|
|
172
|
+
`Layer: ${ctx.layer}${ctx.layer === "global" ? " (shared by every project; do not judge entries by one project's code)" : ""}`,
|
|
173
|
+
`Memory directory: ${ctx.dir}`,
|
|
174
|
+
"",
|
|
175
|
+
"# Files",
|
|
176
|
+
...(ctx.files.length ? ctx.files.map((f) => `${f.rel} ${f.bytes} B modified ${f.mtime.slice(0, 10)}`) : ["(none)"]),
|
|
177
|
+
"",
|
|
178
|
+
`# MEMORY.md (${ctx.dir}/MEMORY.md)`,
|
|
179
|
+
ctx.index.trim() ? ctx.index.trim() : "(no MEMORY.md)",
|
|
180
|
+
];
|
|
181
|
+
if (ctx.layer === "project") {
|
|
182
|
+
const c = ctx.changes;
|
|
183
|
+
parts.push("", "# Changes in the project since the last tidy");
|
|
184
|
+
if (!c)
|
|
185
|
+
parts.push("(not available)");
|
|
186
|
+
else {
|
|
187
|
+
if (c.note)
|
|
188
|
+
parts.push(c.note);
|
|
189
|
+
if (c.log.length)
|
|
190
|
+
parts.push("", "Commits:", ...c.log);
|
|
191
|
+
if (c.stat.length)
|
|
192
|
+
parts.push("", "Files changed:", ...c.stat);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
parts.push("", "# Paths named in the memory that no longer exist");
|
|
196
|
+
parts.push(...(ctx.missing.length ? ctx.missing.map((m) => `- \`${m.path}\` (mentioned in ${m.file})`) : ["(none found)"]));
|
|
197
|
+
return { systemPrompt, userPrompt: parts.join("\n") };
|
|
198
|
+
}
|
package/dist/run-outcome.js
CHANGED
|
@@ -16,6 +16,8 @@ export function endOutcome(endReason, done) {
|
|
|
16
16
|
return { outcome: done?.outcome ?? "partial", note: done?.note };
|
|
17
17
|
case "lead_failed":
|
|
18
18
|
return { outcome: "failed", note: "The lead's wake-up failed." };
|
|
19
|
+
case "waiting":
|
|
20
|
+
return { outcome: "waiting", note: "Waiting for your answers. Fill in ask-reply.md or run `agent-lyceum answer <run-id>`, then continue with `agent-lyceum resume`." };
|
|
19
21
|
case "cancelled":
|
|
20
22
|
return { outcome: "cancelled", note: "Cancelled before the lead finished. Unread mail was kept; continue with `agent-lyceum resume`." };
|
|
21
23
|
case "idle":
|
package/dist/run-session.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export type { ActiveWake, EndReason, RunState, SentTopic, WakeRecord, WakeTopic
|
|
|
8
8
|
export declare const RESULT_FILE = "result.md";
|
|
9
9
|
/** How many times a non-conforming `done` goes back to the lead before it is accepted as `partial`. */
|
|
10
10
|
export declare const MAX_DONE_REJECTIONS = 2;
|
|
11
|
+
/** How many times a malformed `ask` goes back to its sender before the lead is told instead. */
|
|
12
|
+
export declare const MAX_ASK_REJECTIONS = 2;
|
|
11
13
|
export interface RunSummary {
|
|
12
14
|
runId: string;
|
|
13
15
|
runDir: string;
|
|
@@ -21,6 +23,8 @@ export interface RunSummary {
|
|
|
21
23
|
subject: string;
|
|
22
24
|
body: string;
|
|
23
25
|
};
|
|
26
|
+
/** Set when the run waits for the user: the file with the questions. */
|
|
27
|
+
askReplyPath?: string;
|
|
24
28
|
}
|
|
25
29
|
export interface RunOptions {
|
|
26
30
|
project: ResolvedProject;
|
|
@@ -63,6 +67,8 @@ export declare class RunSession {
|
|
|
63
67
|
/** The attempt that produced each batch member's output; committed together with its claim. */
|
|
64
68
|
private readonly attempts;
|
|
65
69
|
constructor(opts: RunOptions);
|
|
70
|
+
/** A waiting run only continues once every unanswered question has a valid answer. */
|
|
71
|
+
private static assertAnswered;
|
|
66
72
|
private saveState;
|
|
67
73
|
private journal;
|
|
68
74
|
private note;
|
|
@@ -76,6 +82,10 @@ export declare class RunSession {
|
|
|
76
82
|
private integrateMember;
|
|
77
83
|
/** After a batch (or, on resume, before the first one): revert guarded edits, route mail, detect done / lead failure. */
|
|
78
84
|
private settle;
|
|
85
|
+
/** The user answered: send each asker its answers as a `reply`, then mark them delivered. Safe to repeat after a crash. */
|
|
86
|
+
private deliverAnswers;
|
|
87
|
+
/** Store the questions of this pass's valid `ask` mails; malformed ones go back to their sender. Returns true when the run should now wait. */
|
|
88
|
+
private handleAsks;
|
|
79
89
|
/** On resume: sort out what the interrupted process left behind before anything is woken. */
|
|
80
90
|
private recover;
|
|
81
91
|
run(): Promise<RunSummary>;
|
package/dist/run-session.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { realInvoker } from "./adapters/index.js";
|
|
4
4
|
import { briefOf, doneContract, parseSteps } from "./format.js";
|
|
5
5
|
import { ProtectedGuard } from "./guard.js";
|
|
6
|
+
import { addBatch, askersWithAnswers, askReplyPath, checkAnswers, describeCheck, isComplete, markDelivered, parseAskBody, readAskReply, renderAnswers, writeAskReply } from "./ask-reply.js";
|
|
6
7
|
import { deliver, deliverOnce, ensureProjectDirs, finishDone, listUnread, rejectDone, routeOutboxes, unreadFiles } from "./mailbox.js";
|
|
7
8
|
import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, commitClaim, finishAttempt, markOutputReady, parkOutbox, recoverRunMail, RouteJournal } from "./message-store.js";
|
|
8
9
|
import { isInside } from "./paths.js";
|
|
@@ -19,6 +20,8 @@ export { newRunId };
|
|
|
19
20
|
export const RESULT_FILE = "result.md";
|
|
20
21
|
/** How many times a non-conforming `done` goes back to the lead before it is accepted as `partial`. */
|
|
21
22
|
export const MAX_DONE_REJECTIONS = 2;
|
|
23
|
+
/** How many times a malformed `ask` goes back to its sender before the lead is told instead. */
|
|
24
|
+
export const MAX_ASK_REJECTIONS = 2;
|
|
22
25
|
/** Prompt for a repeat attempt: the earlier one failed and may have changed things already. */
|
|
23
26
|
function retryPrompt(prompt, error) {
|
|
24
27
|
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.`;
|
|
@@ -53,6 +56,8 @@ export class RunSession {
|
|
|
53
56
|
attempts = new Map();
|
|
54
57
|
constructor(opts) {
|
|
55
58
|
const { task, resume, runDir } = opts;
|
|
59
|
+
if (resume?.end_reason === "waiting")
|
|
60
|
+
RunSession.assertAnswered(runDir, resume.run_id);
|
|
56
61
|
if (!task && !resume)
|
|
57
62
|
throw new Error("runTeam needs a task or a run to resume");
|
|
58
63
|
const invoke = opts.invoker ?? realInvoker;
|
|
@@ -129,6 +134,16 @@ export class RunSession {
|
|
|
129
134
|
this.guard = guard;
|
|
130
135
|
this.state = state;
|
|
131
136
|
}
|
|
137
|
+
/** A waiting run only continues once every unanswered question has a valid answer. */
|
|
138
|
+
static assertAnswered(runDir, runId) {
|
|
139
|
+
const reply = readAskReply(runDir);
|
|
140
|
+
if (!reply)
|
|
141
|
+
return;
|
|
142
|
+
const check = checkAnswers(reply);
|
|
143
|
+
if (!isComplete(check)) {
|
|
144
|
+
throw new Error(`Run ${runId} is still waiting for answers in ${askReplyPath(runDir)}:\n${describeCheck(check).map((l) => ` ${l}`).join("\n")}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
132
147
|
saveState() {
|
|
133
148
|
saveRunState(this.runDir, this.state);
|
|
134
149
|
}
|
|
@@ -354,6 +369,7 @@ export class RunSession {
|
|
|
354
369
|
contract = undefined;
|
|
355
370
|
}
|
|
356
371
|
}
|
|
372
|
+
const askAccepted = this.handleAsks(route);
|
|
357
373
|
for (const d of route.delivered)
|
|
358
374
|
log("route", d);
|
|
359
375
|
const handedOff = (from, t) => {
|
|
@@ -402,6 +418,10 @@ export class RunSession {
|
|
|
402
418
|
this.endReason = "done";
|
|
403
419
|
return;
|
|
404
420
|
}
|
|
421
|
+
if (askAccepted) {
|
|
422
|
+
this.endReason = "waiting";
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
405
425
|
const leadIdx = batch.findIndex((a) => a.name === project.lead);
|
|
406
426
|
const leadResult = leadIdx >= 0 ? results[leadIdx] : undefined;
|
|
407
427
|
if (leadResult && !leadResult.ok && !leadResult.cancelled) {
|
|
@@ -409,9 +429,86 @@ export class RunSession {
|
|
|
409
429
|
return;
|
|
410
430
|
}
|
|
411
431
|
}
|
|
432
|
+
/** The user answered: send each asker its answers as a `reply`, then mark them delivered. Safe to repeat after a crash. */
|
|
433
|
+
deliverAnswers() {
|
|
434
|
+
const { runDir, project, log } = this;
|
|
435
|
+
const reply = readAskReply(runDir);
|
|
436
|
+
if (!reply)
|
|
437
|
+
return;
|
|
438
|
+
const ids = reply.questions.filter((q) => !q.delivered).map((q) => q.id).join(",");
|
|
439
|
+
for (const asker of askersWithAnswers(reply)) {
|
|
440
|
+
deliverOnce(project, this.journal(), `ask-answer:${ids}:${asker}`, {
|
|
441
|
+
from: "user",
|
|
442
|
+
to: asker,
|
|
443
|
+
type: "reply",
|
|
444
|
+
subject: "Answers to your questions",
|
|
445
|
+
body: renderAnswers(reply, asker),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
writeAskReply(runDir, markDelivered(reply));
|
|
449
|
+
log("ask-answered", { questions: ids });
|
|
450
|
+
}
|
|
451
|
+
/** Store the questions of this pass's valid `ask` mails; malformed ones go back to their sender. Returns true when the run should now wait. */
|
|
452
|
+
handleAsks(route) {
|
|
453
|
+
const { say, runDir, project, log, state } = this;
|
|
454
|
+
if (route.asks.length === 0)
|
|
455
|
+
return false;
|
|
456
|
+
let reply = readAskReply(runDir);
|
|
457
|
+
const accepted = [];
|
|
458
|
+
for (const a of route.asks) {
|
|
459
|
+
const parsed = parseAskBody(a.body);
|
|
460
|
+
const added = parsed.ok ? addBatch(reply, a.from, state.rounds, parsed.questions) : parsed;
|
|
461
|
+
if (!added.ok) {
|
|
462
|
+
const n = (state.ask_rejections?.[a.from] ?? 0) + 1;
|
|
463
|
+
state.ask_rejections = { ...state.ask_rejections, [a.from]: n };
|
|
464
|
+
rejectDone(a.file);
|
|
465
|
+
const reason = added.error;
|
|
466
|
+
say(` ask from ${a.from} rejected: ${reason}`);
|
|
467
|
+
log("ask-rejected", { from: a.from, reason, count: n });
|
|
468
|
+
if (n <= MAX_ASK_REJECTIONS) {
|
|
469
|
+
deliverOnce(project, this.journal(), `ask-reject:${a.source}`, {
|
|
470
|
+
from: "dispatcher",
|
|
471
|
+
to: a.from,
|
|
472
|
+
type: "failure",
|
|
473
|
+
subject: "Your ask was not accepted",
|
|
474
|
+
body: `Your \`ask\` was not accepted: ${reason}.\n\nSend it again with this shape (at most 10 questions; \`options\` and \`suggested\` are optional):\n\n<ask>\n <question id="q1">\n <text>Question?</text>\n <options><option>a</option><option>b</option></options>\n <suggested reason="why">a</suggested>\n </question>\n</ask>\n\n(Reminder ${n} of ${MAX_ASK_REJECTIONS}.)`,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
deliverOnce(project, this.journal(), `ask-reject:${a.source}`, {
|
|
479
|
+
from: "dispatcher",
|
|
480
|
+
to: project.lead,
|
|
481
|
+
type: "failure",
|
|
482
|
+
subject: `${a.from}'s ask was rejected ${n} times`,
|
|
483
|
+
body: `${a.from} sent a malformed \`ask\` rejected ${n} times (last problem: ${reason}). It was dropped. Decide without asking the user, or send the question yourself in the correct format.`,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
reply = added.reply;
|
|
489
|
+
accepted.push(a.file);
|
|
490
|
+
}
|
|
491
|
+
if (!accepted.length || !reply)
|
|
492
|
+
return false;
|
|
493
|
+
if (route.done) {
|
|
494
|
+
for (const f of accepted)
|
|
495
|
+
finishDone(f);
|
|
496
|
+
this.note("An ask was ignored because the lead also sent done in the same round.");
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
// Questions are stored first, the outbox file moves second: a crash in between re-routes the same ask, and addBatch skips it.
|
|
500
|
+
writeAskReply(runDir, reply);
|
|
501
|
+
for (const f of accepted)
|
|
502
|
+
finishDone(f);
|
|
503
|
+
log("ask", { questions: reply.questions.filter((q) => !q.delivered).map((q) => q.id) });
|
|
504
|
+
say(` waiting for your answers: ${askReplyPath(runDir)}`);
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
412
507
|
/** On resume: sort out what the interrupted process left behind before anything is woken. */
|
|
413
508
|
recover() {
|
|
414
509
|
const { say, runId, runDir, project, wsMode, log } = this;
|
|
510
|
+
if (this.resume?.end_reason === "waiting")
|
|
511
|
+
this.deliverAnswers();
|
|
415
512
|
const rec = recoverRunMail(runDir, { outboxOf: (agent) => outboxDir(project, agent) });
|
|
416
513
|
for (const i of rec.interrupted) {
|
|
417
514
|
this.note(`${i.attempt.agent} attempt ${i.attempt.n} was interrupted${i.parked.length ? `; ${i.parked.length} output file(s) set aside in ${path.join("mail", "attempts", i.attempt.id, "outbox")}` : ""}. ` +
|
|
@@ -500,6 +597,6 @@ export class RunSession {
|
|
|
500
597
|
this.saveState();
|
|
501
598
|
if (this.pendingDoneFile)
|
|
502
599
|
finishDone(this.pendingDoneFile);
|
|
503
|
-
return { runId, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage };
|
|
600
|
+
return { runId, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: this.doneVerification, doneMessage: this.doneMessage, askReplyPath: endReason === "waiting" ? askReplyPath(runDir) : undefined };
|
|
504
601
|
}
|
|
505
602
|
}
|
package/dist/run-store.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Step } from "./format.js";
|
|
|
3
3
|
import type { RunOutcome } from "./schema.js";
|
|
4
4
|
export declare const STATE_FILE = "state.json";
|
|
5
5
|
export declare const STATE_SCHEMA_VERSION = 2;
|
|
6
|
-
export type EndReason = "done" | "idle" | "max_rounds" | "lead_failed" | "cancelled";
|
|
6
|
+
export type EndReason = "done" | "idle" | "max_rounds" | "lead_failed" | "cancelled" | "waiting";
|
|
7
7
|
export type MailLayout = "run" | "legacy";
|
|
8
8
|
export interface WakeTopic {
|
|
9
9
|
from: string;
|
|
@@ -56,6 +56,8 @@ export interface RunState {
|
|
|
56
56
|
verification?: string;
|
|
57
57
|
/** How many `done` mails were sent back because they broke the completion contract. */
|
|
58
58
|
done_rejections?: number;
|
|
59
|
+
/** How many malformed `ask` mails each agent had sent back (the limit is MAX_ASK_REJECTIONS). */
|
|
60
|
+
ask_rejections?: Record<string, number>;
|
|
59
61
|
/** Recovery remarks (interrupted attempts whose side effects may have happened). */
|
|
60
62
|
notes?: string[];
|
|
61
63
|
/** "worktree": non-lead agents work in their own git worktrees. */
|
package/dist/run-store.js
CHANGED
|
@@ -23,12 +23,13 @@ const WireState = z
|
|
|
23
23
|
ended_at: z.string().optional(),
|
|
24
24
|
rounds: nonNeg.optional(),
|
|
25
25
|
max_rounds: nonNeg.optional(),
|
|
26
|
-
end_reason: z.enum(["done", "idle", "max_rounds", "lead_failed", "cancelled"]).optional(),
|
|
27
|
-
outcome: z.enum(["completed", "partial", "blocked", "failed", "cancelled"]).optional(),
|
|
26
|
+
end_reason: z.enum(["done", "idle", "max_rounds", "lead_failed", "cancelled", "waiting"]).optional(),
|
|
27
|
+
outcome: z.enum(["completed", "partial", "blocked", "failed", "waiting", "cancelled"]).optional(),
|
|
28
28
|
outcome_note: z.string().optional(),
|
|
29
29
|
verification: z.string().optional(),
|
|
30
30
|
done_rejections: nonNeg.optional(),
|
|
31
31
|
notes: z.array(z.string()).optional(),
|
|
32
|
+
ask_rejections: z.record(z.string(), nonNeg).optional(),
|
|
32
33
|
sessions: z.record(z.string(), z.string()).optional(),
|
|
33
34
|
output_tokens: nonNeg.optional(),
|
|
34
35
|
last_wake: z.record(z.string(), z.any()).optional(),
|
|
@@ -115,5 +116,5 @@ export function outcomeOf(s) {
|
|
|
115
116
|
return { outcome: s.outcome, verified: true };
|
|
116
117
|
if (!s.end_reason)
|
|
117
118
|
return undefined;
|
|
118
|
-
return { outcome: s.end_reason === "lead_failed" ? "failed" : s.end_reason === "cancelled" ? "cancelled" : "partial", verified: false };
|
|
119
|
+
return { outcome: s.end_reason === "lead_failed" ? "failed" : s.end_reason === "cancelled" ? "cancelled" : s.end_reason === "waiting" ? "waiting" : "partial", verified: false };
|
|
119
120
|
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -30,6 +30,7 @@ export declare const AgentPartial: z.ZodObject<{
|
|
|
30
30
|
resume: z.ZodOptional<z.ZodBoolean>;
|
|
31
31
|
can_message: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodArray<z.ZodString>]>>;
|
|
32
32
|
can_edit_agent_md: z.ZodOptional<z.ZodBoolean>;
|
|
33
|
+
can_ask_user: z.ZodOptional<z.ZodBoolean>;
|
|
33
34
|
owns: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
34
35
|
}, z.core.$strict>;
|
|
35
36
|
export type AgentPartialT = z.infer<typeof AgentPartial>;
|
|
@@ -69,6 +70,7 @@ export declare const GlobalConfig: z.ZodObject<{
|
|
|
69
70
|
resume: z.ZodOptional<z.ZodBoolean>;
|
|
70
71
|
can_message: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodArray<z.ZodString>]>>;
|
|
71
72
|
can_edit_agent_md: z.ZodOptional<z.ZodBoolean>;
|
|
73
|
+
can_ask_user: z.ZodOptional<z.ZodBoolean>;
|
|
72
74
|
owns: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
73
75
|
}, z.core.$strict>>>;
|
|
74
76
|
}, z.core.$strict>;
|
|
@@ -112,6 +114,7 @@ export declare const ProjectConfig: z.ZodObject<{
|
|
|
112
114
|
resume: z.ZodOptional<z.ZodBoolean>;
|
|
113
115
|
can_message: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodArray<z.ZodString>]>>;
|
|
114
116
|
can_edit_agent_md: z.ZodOptional<z.ZodBoolean>;
|
|
117
|
+
can_ask_user: z.ZodOptional<z.ZodBoolean>;
|
|
115
118
|
owns: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
116
119
|
}, z.core.$strict>>>;
|
|
117
120
|
}, z.core.$strict>;
|
|
@@ -128,6 +131,6 @@ export interface DispatcherSettings {
|
|
|
128
131
|
}
|
|
129
132
|
export declare const DISPATCHER_DEFAULTS: DispatcherSettings;
|
|
130
133
|
/** How a run turned out, independent of why it stopped (`EndReason`). Only "completed" means success. */
|
|
131
|
-
export declare const RUN_OUTCOMES: readonly ["completed", "partial", "blocked", "failed", "cancelled"];
|
|
134
|
+
export declare const RUN_OUTCOMES: readonly ["completed", "partial", "blocked", "failed", "waiting", "cancelled"];
|
|
132
135
|
export type RunOutcome = (typeof RUN_OUTCOMES)[number];
|
|
133
|
-
export declare function exitCodeForOutcome(outcome: RunOutcome): 0 | 1 | 2 | 130;
|
|
136
|
+
export declare function exitCodeForOutcome(outcome: RunOutcome): 0 | 1 | 2 | 3 | 130;
|
package/dist/schema.js
CHANGED
|
@@ -34,6 +34,7 @@ export const AgentPartial = z
|
|
|
34
34
|
resume: z.boolean().optional(),
|
|
35
35
|
can_message: z.union([z.literal("all"), z.array(z.string())]).optional(),
|
|
36
36
|
can_edit_agent_md: z.boolean().optional(),
|
|
37
|
+
can_ask_user: z.boolean().optional(),
|
|
37
38
|
owns: z.array(z.string()).optional(),
|
|
38
39
|
})
|
|
39
40
|
.strict();
|
|
@@ -71,13 +72,15 @@ export const DISPATCHER_DEFAULTS = {
|
|
|
71
72
|
log_max_bytes: 8 * 1024 * 1024,
|
|
72
73
|
};
|
|
73
74
|
/** How a run turned out, independent of why it stopped (`EndReason`). Only "completed" means success. */
|
|
74
|
-
export const RUN_OUTCOMES = ["completed", "partial", "blocked", "failed", "cancelled"];
|
|
75
|
+
export const RUN_OUTCOMES = ["completed", "partial", "blocked", "failed", "waiting", "cancelled"];
|
|
75
76
|
export function exitCodeForOutcome(outcome) {
|
|
76
77
|
switch (outcome) {
|
|
77
78
|
case "completed":
|
|
78
79
|
return 0;
|
|
79
80
|
case "failed":
|
|
80
81
|
return 1;
|
|
82
|
+
case "waiting":
|
|
83
|
+
return 3;
|
|
81
84
|
case "cancelled":
|
|
82
85
|
return 130;
|
|
83
86
|
default:
|
package/dist/status.d.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface RunReport {
|
|
|
44
44
|
run_id: string;
|
|
45
45
|
dir: string;
|
|
46
46
|
/** running: its dispatcher is alive; interrupted: it died mid-run; ended: it stopped (see end_reason). */
|
|
47
|
-
state: "running" | "interrupted" | "ended";
|
|
47
|
+
state: "running" | "interrupted" | "ended" | "waiting";
|
|
48
48
|
live: boolean;
|
|
49
49
|
end_reason?: EndReason;
|
|
50
50
|
outcome?: RunOutcome;
|
|
@@ -83,6 +83,12 @@ export interface RunReport {
|
|
|
83
83
|
ok: boolean;
|
|
84
84
|
error?: string;
|
|
85
85
|
}>;
|
|
86
|
+
/** Present while the run waits for the user: the file to fill in and how many questions are still open. */
|
|
87
|
+
ask?: {
|
|
88
|
+
path: string;
|
|
89
|
+
total: number;
|
|
90
|
+
unanswered: number;
|
|
91
|
+
};
|
|
86
92
|
}
|
|
87
93
|
export interface AgentReport {
|
|
88
94
|
name: string;
|
|
@@ -104,6 +110,8 @@ export interface StatusReport {
|
|
|
104
110
|
};
|
|
105
111
|
agents: AgentReport[];
|
|
106
112
|
run?: RunReport;
|
|
113
|
+
/** Hints that a `memory tidy` might help. Only a suggestion; nothing runs it. */
|
|
114
|
+
memory_advice?: string[];
|
|
107
115
|
}
|
|
108
116
|
export interface TaskListReport {
|
|
109
117
|
schema_version: 1;
|
package/dist/status.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { askReplyPath, readAskReply } from "./ask-reply.js";
|
|
3
4
|
import { RESULT_FILE } from "./dispatcher.js";
|
|
4
5
|
import { bindRunProject, loadRunState, outcomeOf } from "./run-store.js";
|
|
5
6
|
import { listUnread } from "./mailbox.js";
|
|
7
|
+
import { tidyAdvice } from "./memory-tidy.js";
|
|
6
8
|
import { inspectProjectLock, lockHolderAlive } from "./project-lock.js";
|
|
7
9
|
export function latestRun(project) {
|
|
8
10
|
const root = project.paths.runs;
|
|
@@ -76,8 +78,10 @@ function alive(pid) {
|
|
|
76
78
|
return e.code === "EPERM";
|
|
77
79
|
}
|
|
78
80
|
}
|
|
79
|
-
const OUTCOME_ZH = { completed: "完成", partial: "部分完成", blocked: "受阻", failed: "失敗", cancelled: "已取消" };
|
|
81
|
+
const OUTCOME_ZH = { completed: "完成", partial: "部分完成", blocked: "受阻", failed: "失敗", waiting: "等待回答", cancelled: "已取消" };
|
|
80
82
|
export function runStateLabel(s, live = alive(s.pid)) {
|
|
83
|
+
if (s.end_reason === "waiting")
|
|
84
|
+
return "等待回答";
|
|
81
85
|
if (s.end_reason) {
|
|
82
86
|
const o = outcomeOf(s);
|
|
83
87
|
// Runs from before outcomes existed: keep what they recorded, but never present it as a checked success.
|
|
@@ -155,10 +159,23 @@ export function buildRunReport(run, project, opts = {}) {
|
|
|
155
159
|
const result = path.join(run.dir, RESULT_FILE);
|
|
156
160
|
const hasResult = detail && s.end_reason === "done" && fs.existsSync(result);
|
|
157
161
|
const cur = s.steps?.find((x) => !x.done);
|
|
162
|
+
let ask;
|
|
163
|
+
if (s.end_reason === "waiting") {
|
|
164
|
+
try {
|
|
165
|
+
const r = readAskReply(run.dir);
|
|
166
|
+
if (r) {
|
|
167
|
+
const open = r.questions.filter((q) => !q.delivered);
|
|
168
|
+
ask = { path: askReplyPath(run.dir), total: open.length, unanswered: open.filter((q) => !q.answer?.value.trim()).length };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
ask = { path: askReplyPath(run.dir), total: 0, unanswered: 0 };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
158
175
|
return {
|
|
159
176
|
run_id: s.run_id,
|
|
160
177
|
dir: run.dir,
|
|
161
|
-
state: s.end_reason ? "ended" : live ? "running" : "interrupted",
|
|
178
|
+
state: s.end_reason === "waiting" ? "waiting" : s.end_reason ? "ended" : live ? "running" : "interrupted",
|
|
162
179
|
live,
|
|
163
180
|
end_reason: s.end_reason,
|
|
164
181
|
outcome: o?.outcome,
|
|
@@ -186,6 +203,7 @@ export function buildRunReport(run, project, opts = {}) {
|
|
|
186
203
|
mail_layout: s.mail_layout,
|
|
187
204
|
workspace_mode: s.workspace_mode,
|
|
188
205
|
last_wake: s.last_wake,
|
|
206
|
+
...(ask ? { ask } : {}),
|
|
189
207
|
};
|
|
190
208
|
}
|
|
191
209
|
const projectInfo = (p) => ({ name: p.name, dir: p.dir, lead: p.lead });
|
|
@@ -203,6 +221,13 @@ export function buildStatusReport(base, runId) {
|
|
|
203
221
|
// Mail is per run: count the unread mail of the run being shown.
|
|
204
222
|
const project = run ? bindRunProject(base, run.dir, run.state.mail_layout) : base;
|
|
205
223
|
const report = run ? buildRunReport(run, base) : undefined;
|
|
224
|
+
let advice = [];
|
|
225
|
+
try {
|
|
226
|
+
advice = tidyAdvice(base).map((a) => `${a.agent}: ${a.reason}`);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
/* a hint only: never let it break status */
|
|
230
|
+
}
|
|
206
231
|
return {
|
|
207
232
|
schema_version: 1,
|
|
208
233
|
project: projectInfo(base),
|
|
@@ -214,6 +239,7 @@ export function buildStatusReport(base, runId) {
|
|
|
214
239
|
last_wake: run?.state.last_wake[a.name],
|
|
215
240
|
})),
|
|
216
241
|
run: report,
|
|
242
|
+
...(advice.length ? { memory_advice: advice } : {}),
|
|
217
243
|
};
|
|
218
244
|
}
|
|
219
245
|
/** Every run of the project, newest first. */
|
|
@@ -274,6 +300,8 @@ function summaryBlock(run, now, c) {
|
|
|
274
300
|
const lastWake = (s.wakes ?? []).at(-1);
|
|
275
301
|
if (lastWake)
|
|
276
302
|
lines.push(`${L("上一次:")}#${lastWake.round} ${c.cyan(lastWake.agent)} ${okText(c, lastWake.ok)} → ${sentText(lastWake.sent)}`);
|
|
303
|
+
if (s.ask)
|
|
304
|
+
lines.push(`${L("等你回答:")}${s.ask.unanswered}/${s.ask.total} 題未答 → ${s.ask.path}`, c.dim(` 填完後執行 agent-lyceum answer ${s.run_id}(或 resume)`));
|
|
277
305
|
lines.push(`${L("流程:")}${flowLine(s, c)}`);
|
|
278
306
|
return lines;
|
|
279
307
|
}
|
|
@@ -401,5 +429,7 @@ export function formatStatusReport(report, now = Date.now(), color = false) {
|
|
|
401
429
|
const lines = [`${c.bold("專案:")}${report.project.name} ${c.dim(`(repo:${report.project.dir})`)}`, "", ...summaryBlock(run, now, c), "", ...table];
|
|
402
430
|
if (run)
|
|
403
431
|
lines.push("", `輸出 tokens:${tokens(run.output_tokens)}`);
|
|
432
|
+
if (report.memory_advice?.length)
|
|
433
|
+
lines.push("", `記憶:${report.memory_advice.join(";")}(建議執行 agent-lyceum memory tidy -p ${report.project.name};status 只提示,不會自動整理)`);
|
|
404
434
|
return lines.join("\n");
|
|
405
435
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-lyceum",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"@types/node": "^20.19.43",
|
|
59
59
|
"tsx": "^4.19.0",
|
|
60
60
|
"typescript": "^7.0.2",
|
|
61
|
-
"vite": "^
|
|
61
|
+
"vite": "^8.3.3",
|
|
62
62
|
"vitest": "^4.1.11"
|
|
63
63
|
}
|
|
64
64
|
}
|