agent-accelerator 0.5.2 → 0.5.4
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 +117 -32
- package/examples/prompts/SYSTEM_PROMPT_AGENT.md +14 -20
- package/package.json +1 -1
- package/src/agent/agent.ts +355 -38
- package/src/agent/context.ts +3 -1
- package/src/agent/delegation.ts +208 -524
- package/src/agent/loop.ts +139 -18
- package/src/agent/subagents-tool.ts +87 -0
- package/src/agent/subagents.md +31 -0
- package/src/agent/turns.ts +60 -0
- package/src/agent/worker.ts +38 -30
- package/src/agent/workers.ts +405 -0
- package/src/index.ts +5 -3
- package/src/models/catalog-cache.ts +3 -10
- package/src/scheduler/scheduler.ts +2 -0
- package/src/scheduler/store.ts +34 -21
- package/src/session/journal.ts +212 -0
- package/src/session/store.ts +203 -90
- package/src/session/tool-log.ts +62 -0
- package/src/session/usage.ts +201 -0
- package/src/telemetry-file.ts +4 -1
- package/src/tools/executor.ts +72 -15
- package/src/tools/subagent-result.ts +1 -1
- package/src/tools/tool.ts +0 -6
- package/src/types/agent.ts +45 -6
- package/src/types/message.ts +4 -0
- package/src/types/response.ts +2 -0
- package/src/types/tool.ts +19 -7
- package/src/utils/durable.ts +218 -0
- package/src/utils/headers.ts +1 -1
- package/src/utils/retry.ts +15 -1
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent sub-agent workers. A worker is a plain `Agent` with its own instructions, tools and session: it keeps its
|
|
3
|
+
* conversation, can be messaged again until it is closed, and is saved under `<parent session>/workers/<id>/` (its own
|
|
4
|
+
* journal, tool logs and resume, plus `worker.json` with what the registry needs to rebuild it after a restart).
|
|
5
|
+
* `agent.workers` is the programmatic API; the `subagents` tool is a thin wrapper over the same functions.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import type { Agent } from "./agent.ts";
|
|
10
|
+
import type { AgentResponse, SubAgentExecutionMetadata } from "../types/response.ts";
|
|
11
|
+
import type { Message } from "../types/message.ts";
|
|
12
|
+
import { ConfigError, ValidationError } from "../types/errors.ts";
|
|
13
|
+
import { writeFileDurable } from "../utils/durable.ts";
|
|
14
|
+
import { sessionDirFor, loadSessionDir } from "../session/store.ts";
|
|
15
|
+
import { telemetryBus } from "../telemetry.ts";
|
|
16
|
+
import { hashSessionPart, newRunId, newTurnId, nowMs } from "../utils/session.ts";
|
|
17
|
+
import { buildDynamicWorkerConfig, grantWorkerTools, sanitizeToolName } from "./worker.ts";
|
|
18
|
+
import { createFixedChildSessionId, openWorkerTrace, reserveSpawn, workerMetadata, type WorkerRunResult } from "./delegation.ts";
|
|
19
|
+
import { finishedTurns, historyUpTo, type Turn } from "./turns.ts";
|
|
20
|
+
import { modelName, readUsage, type UsageNode } from "../session/usage.ts";
|
|
21
|
+
|
|
22
|
+
/** What `spawn` needs to start a worker. */
|
|
23
|
+
export interface WorkerSpec {
|
|
24
|
+
/** Short label; the worker's id is this name plus a short suffix. */
|
|
25
|
+
name: string;
|
|
26
|
+
role?: string;
|
|
27
|
+
/** The worker's own system prompt (the parent's is never copied). */
|
|
28
|
+
instructions?: string;
|
|
29
|
+
/** The first assignment. */
|
|
30
|
+
task: string;
|
|
31
|
+
/** Names from the developer's `dynamicSubagents.tools` pool to grant this worker. Unknown names throw. */
|
|
32
|
+
tools?: string[];
|
|
33
|
+
/** `"none"` (default) or a number N: start from a copy of the parent's first N finished turns (see `workers.turns()`). */
|
|
34
|
+
history?: "none" | number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One worker as `list()` shows it. */
|
|
38
|
+
export interface WorkerInfo {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
role?: string;
|
|
42
|
+
tools: string[];
|
|
43
|
+
/** A call is running on it right now. */
|
|
44
|
+
busy: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The answer to one `spawn` or `message`. A failed call carries `error` and a text that says so; it never throws. */
|
|
48
|
+
export interface WorkerReply {
|
|
49
|
+
id: string;
|
|
50
|
+
text: string;
|
|
51
|
+
error?: string;
|
|
52
|
+
metadata: SubAgentExecutionMetadata;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Options of one `spawn`/`message` call. */
|
|
56
|
+
export interface WorkerCallOptions {
|
|
57
|
+
/** Aborts the worker's run. */
|
|
58
|
+
signal?: AbortSignal;
|
|
59
|
+
/** The calling tool call's id: makes the call exactly-once across a crash and replay (the `subagents` tool passes it). */
|
|
60
|
+
callId?: string;
|
|
61
|
+
/** Receives the worker's live text and thinking deltas. */
|
|
62
|
+
onSubagentEvent?: (event: { trackingId: string; delta?: string; thinkingDelta?: string; partialText?: string; partialThinking?: string }) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Saved in `worker.json`: everything needed to rebuild a worker after a restart.
|
|
66
|
+
interface WorkerMeta {
|
|
67
|
+
id: string;
|
|
68
|
+
name: string;
|
|
69
|
+
role?: string;
|
|
70
|
+
instructions: string;
|
|
71
|
+
tools: string[];
|
|
72
|
+
sessionId: string;
|
|
73
|
+
status: "open" | "closed";
|
|
74
|
+
createdAt: number;
|
|
75
|
+
closedAt?: number;
|
|
76
|
+
/** The last call delivered: its key, a hash of its text, and how many runs the worker had then. Detects a replayed call. */
|
|
77
|
+
last?: { key: string; text: string; marks: number };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface Entry {
|
|
81
|
+
meta: WorkerMeta;
|
|
82
|
+
/** The worker's session folder (unset when the parent does not persist). */
|
|
83
|
+
dir?: string;
|
|
84
|
+
agent?: Agent;
|
|
85
|
+
/** Its spending, kept when it is closed (so a worker that was never saved to disk still shows in the report). */
|
|
86
|
+
kept?: { model: string; totals: ReturnType<typeof readUsage>; workers: UsageNode[] };
|
|
87
|
+
busy: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const META_FILE = "worker.json";
|
|
91
|
+
|
|
92
|
+
// Fails with a one-line message when a required text field is missing; in: spawn/message input, out: the caller's ValidationError.
|
|
93
|
+
function requireText(field: string, value: unknown): string {
|
|
94
|
+
if (typeof value !== "string" || !value.trim()) throw new ValidationError(`${field}: required, a non-empty string.`);
|
|
95
|
+
return value.trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Joins the text parts of the last assistant message (the reply a worker already gave); in: a worker's history, out: a replayed call's saved answer.
|
|
99
|
+
function lastAssistantText(messages: Message[]): string | undefined {
|
|
100
|
+
const last = messages[messages.length - 1];
|
|
101
|
+
if (!last || last.role !== "assistant") return undefined;
|
|
102
|
+
if (typeof last.content === "string") return last.content;
|
|
103
|
+
return last.content.map((p) => (p.type === "text" ? p.text : "")).join("");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Reads a worker that is not in memory from its folder: its saved spending, model, and the workers saved inside it; in: a worker's session folder, out: usageNodes.
|
|
107
|
+
function readSavedWorker(dir: string | undefined): { model: string; totals: ReturnType<typeof readUsage>; workers: UsageNode[] } {
|
|
108
|
+
if (!dir) return { model: "unknown", totals: readUsage(undefined), workers: [] };
|
|
109
|
+
let session: ReturnType<typeof loadSessionDir>;
|
|
110
|
+
try {
|
|
111
|
+
session = loadSessionDir(dir);
|
|
112
|
+
} catch {
|
|
113
|
+
session = null; // a session over the load limits: its spending is unknown here
|
|
114
|
+
}
|
|
115
|
+
const workers: UsageNode[] = [];
|
|
116
|
+
const root = path.join(dir, "workers");
|
|
117
|
+
let names: fs.Dirent[] = [];
|
|
118
|
+
try {
|
|
119
|
+
names = fs.readdirSync(root, { withFileTypes: true });
|
|
120
|
+
} catch {
|
|
121
|
+
// no workers saved inside this one
|
|
122
|
+
}
|
|
123
|
+
for (const d of names) {
|
|
124
|
+
if (!d.isDirectory()) continue;
|
|
125
|
+
try {
|
|
126
|
+
const meta = JSON.parse(fs.readFileSync(path.join(root, d.name, META_FILE), "utf8")) as WorkerMeta;
|
|
127
|
+
const inner = readSavedWorker(path.join(root, d.name));
|
|
128
|
+
workers.push({ id: meta.id, name: meta.name, ...(meta.role ? { role: meta.role } : {}), model: inner.model, status: meta.status, totals: inner.totals, workers: inner.workers });
|
|
129
|
+
} catch {
|
|
130
|
+
// a damaged worker.json is skipped, as in load()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { model: session?.session.model || "unknown", totals: readUsage(session?.session.usage), workers };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The workers of one parent `Agent` (`agent.workers`). */
|
|
137
|
+
export class WorkerRegistry {
|
|
138
|
+
private readonly entries = new Map<string, Entry>();
|
|
139
|
+
|
|
140
|
+
constructor(private readonly parent: Agent) {
|
|
141
|
+
this.load();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Where this parent's workers live on disk, or undefined when it does not persist to a folder; in: the parent's persist config, out: load, create.
|
|
145
|
+
private root(): string | undefined {
|
|
146
|
+
const dir = this.parent.persistConfig?.dir;
|
|
147
|
+
if (!dir || this.parent.stateless) return undefined;
|
|
148
|
+
try {
|
|
149
|
+
return path.join(sessionDirFor(dir, this.parent.sessionId), "workers");
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined; // not a usable session id for a folder name
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Reads every saved `worker.json` under the parent's session folder; a damaged one is reported on the bus and skipped; in: the constructor, out: this.entries.
|
|
156
|
+
private load(): void {
|
|
157
|
+
const root = this.root();
|
|
158
|
+
if (!root) return;
|
|
159
|
+
let names: fs.Dirent[];
|
|
160
|
+
try {
|
|
161
|
+
names = fs.readdirSync(root, { withFileTypes: true });
|
|
162
|
+
} catch {
|
|
163
|
+
return; // nothing saved yet
|
|
164
|
+
}
|
|
165
|
+
for (const d of names) {
|
|
166
|
+
if (!d.isDirectory()) continue;
|
|
167
|
+
const dir = path.join(root, d.name);
|
|
168
|
+
try {
|
|
169
|
+
const meta = JSON.parse(fs.readFileSync(path.join(dir, META_FILE), "utf8")) as WorkerMeta;
|
|
170
|
+
if (typeof meta.id !== "string" || typeof meta.sessionId !== "string" || !Array.isArray(meta.tools)) throw new Error("missing fields");
|
|
171
|
+
this.entries.set(meta.id, { meta, dir, busy: false });
|
|
172
|
+
} catch (err) {
|
|
173
|
+
telemetryBus.emitWarning("worker.invalid", `[Agent Accelerator] ${path.join(dir, META_FILE)} is not a valid worker and is skipped: ${err instanceof Error ? err.message : String(err)}`, { dir });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Saves a worker's `worker.json` durably (a no-op when the parent does not persist); in: spawn, deliver, close, out: the worker folder.
|
|
179
|
+
private save(entry: Entry): void {
|
|
180
|
+
if (entry.dir) writeFileDurable(path.join(entry.dir, META_FILE), JSON.stringify(entry.meta, null, 2) + "\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// True when this call is the one that last reached the worker (same call key and text): a replay after a crash; in: spawn and deliver, out: exactly-once handling.
|
|
184
|
+
private isReplay(entry: Entry, key: string | undefined, text: string): boolean {
|
|
185
|
+
return !!key && entry.meta.last?.key === key && entry.meta.last.text === hashSessionPart(text);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The key that identifies one tool call across a restart: the parent run plus the call id; in: WorkerCallOptions.callId, out: replay detection.
|
|
189
|
+
private callKey(callId: string | undefined): string | undefined {
|
|
190
|
+
return callId ? `${this.parent.runMarks[this.parent.runMarks.length - 1]?.id ?? ""}:${callId}` : undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Starts a worker on `spec.task` and waits for its first reply. With `opts.callId` a replay of the same call (after a
|
|
195
|
+
* crash) finds the worker it already started instead of creating a second one.
|
|
196
|
+
*/
|
|
197
|
+
async spawn(spec: WorkerSpec, opts: WorkerCallOptions = {}): Promise<WorkerReply> {
|
|
198
|
+
const name = requireText("name", spec?.name);
|
|
199
|
+
const task = requireText("task", spec?.task);
|
|
200
|
+
const key = this.callKey(opts.callId);
|
|
201
|
+
const slug = sanitizeToolName(name).slice(0, 24).replace(/-+$/, "") || "worker";
|
|
202
|
+
let id = "";
|
|
203
|
+
let entry: Entry | undefined;
|
|
204
|
+
for (let n = 0; ; n++) {
|
|
205
|
+
id = `${slug}-${hashSessionPart(n === 0 ? (key ?? globalThis.crypto.randomUUID()) : `${key ?? globalThis.crypto.randomUUID()}#${n}`)}`;
|
|
206
|
+
entry = this.entries.get(id);
|
|
207
|
+
if (!entry || this.isReplay(entry, key, task)) break;
|
|
208
|
+
}
|
|
209
|
+
entry ??= await this.create(id, name, task, key, spec);
|
|
210
|
+
return this.deliver(entry, task, key, opts);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Sends `text` to an open worker and waits for its reply; the worker remembers the conversation. */
|
|
214
|
+
async message(id: string, text: string, opts: WorkerCallOptions = {}): Promise<WorkerReply> {
|
|
215
|
+
const entry = this.open(id);
|
|
216
|
+
return this.deliver(entry, requireText("text", text), this.callKey(opts.callId), opts);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Closes a worker: it can no longer be messaged, its session stays on disk (and in `usageReport`). */
|
|
220
|
+
close(id: string): WorkerInfo {
|
|
221
|
+
const entry = this.open(id);
|
|
222
|
+
if (entry.busy) throw new ValidationError(`Worker "${id}" is busy with a call; wait for it to finish before closing it.`);
|
|
223
|
+
entry.meta.status = "closed";
|
|
224
|
+
entry.meta.closedAt = Date.now();
|
|
225
|
+
this.save(entry);
|
|
226
|
+
if (entry.agent) entry.kept = { model: modelName(entry.agent.modelStringOrSpec), totals: readUsage(entry.agent.usageTotals), workers: entry.agent.workers.usageNodes() };
|
|
227
|
+
entry.agent?.disposeSession(entry.meta.sessionId); // frees its provider chain; never reset(): that could rewrite the saved history
|
|
228
|
+
entry.agent = undefined;
|
|
229
|
+
return this.info(entry);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The open workers. */
|
|
233
|
+
list(): WorkerInfo[] {
|
|
234
|
+
return [...this.entries.values()].filter((e) => e.meta.status === "open").map((e) => this.info(e));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The parent's finished turns, the numbers `spawn` accepts for `history`. */
|
|
238
|
+
turns(): Turn[] {
|
|
239
|
+
return finishedTurns(this.parent.context.messages, this.parent.runMarks);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Every worker (open and closed) as a usage tree: live ones from memory, the rest from their saved sessions, nested workers included. */
|
|
243
|
+
usageNodes(): UsageNode[] {
|
|
244
|
+
return [...this.entries.values()].map((entry) => {
|
|
245
|
+
const { id, name, role, status } = entry.meta;
|
|
246
|
+
const base = { id, name, ...(role ? { role } : {}), status };
|
|
247
|
+
if (entry.agent) return { ...base, model: modelName(entry.agent.modelStringOrSpec), totals: entry.agent.usageTotals, workers: entry.agent.workers.usageNodes() };
|
|
248
|
+
return { ...base, ...(entry.kept ?? readSavedWorker(entry.dir)) };
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// The open worker with this id, or a ValidationError that says what to do; in: message and close, out: the entry.
|
|
253
|
+
private open(id: string): Entry {
|
|
254
|
+
const entry = this.entries.get(id);
|
|
255
|
+
if (!entry) throw new ValidationError(`No worker with id "${id}". Use list to see the open workers.`);
|
|
256
|
+
if (entry.meta.status === "closed") throw new ValidationError(`Worker "${id}" is closed. Spawn a new worker instead.`);
|
|
257
|
+
return entry;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// The public view of one worker; in: an entry, out: list and close.
|
|
261
|
+
private info(entry: Entry): WorkerInfo {
|
|
262
|
+
const { id, name, role, tools } = entry.meta;
|
|
263
|
+
return { id, name, ...(role ? { role } : {}), tools: [...tools], busy: entry.busy };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Checks a new worker's request, builds its Agent (seeded with the copied history) and saves `worker.json`; in: spawn, out: a new open entry.
|
|
267
|
+
private async create(id: string, name: string, task: string, key: string | undefined, spec: WorkerSpec): Promise<Entry> {
|
|
268
|
+
const parent = this.parent;
|
|
269
|
+
if (!parent.subagentModel) {
|
|
270
|
+
throw new ConfigError(
|
|
271
|
+
"[Agent Accelerator] Cannot spawn workers: no sub-agent model is configured. Set dynamicSubagents.model in Agent config or set the SUB_AGENT_MODEL environment variable."
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const maxOpen = parent.dynamicSubagents?.maxSpawn ?? 4;
|
|
275
|
+
if (this.list().length >= maxOpen) {
|
|
276
|
+
throw new ValidationError(`Too many open workers (${maxOpen} is the limit). Close one first; open workers: ${this.list().map((w) => w.id).join(", ")}.`);
|
|
277
|
+
}
|
|
278
|
+
const pool = parent.dynamicSubagents?.tools ?? {};
|
|
279
|
+
const requested = spec.tools ?? [];
|
|
280
|
+
const unknown = requested.filter((n) => Object.keys(grantWorkerTools(pool, [n])).length === 0);
|
|
281
|
+
if (unknown.length > 0) {
|
|
282
|
+
const names = Object.keys(pool);
|
|
283
|
+
throw new ValidationError(
|
|
284
|
+
`tools: unknown worker tool(s) ${unknown.map((n) => `"${n}"`).join(", ")}; ` + (names.length > 0 ? `the pool is [${names.join(", ")}].` : "no worker tools are available, omit tools.")
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const history = spec.history ?? "none";
|
|
288
|
+
const messages = history === "none" ? [] : historyUpTo(parent.context.messages, parent.runMarks, history);
|
|
289
|
+
reserveSpawn(parent, 1);
|
|
290
|
+
|
|
291
|
+
const sessionId = createFixedChildSessionId(parent.sessionId, id);
|
|
292
|
+
const root = this.root();
|
|
293
|
+
const meta: WorkerMeta = {
|
|
294
|
+
id,
|
|
295
|
+
name,
|
|
296
|
+
...(spec.role?.trim() ? { role: spec.role.trim() } : {}),
|
|
297
|
+
instructions: spec.instructions?.trim() ?? "",
|
|
298
|
+
tools: Object.keys(grantWorkerTools(pool, requested)),
|
|
299
|
+
sessionId,
|
|
300
|
+
status: "open",
|
|
301
|
+
createdAt: Date.now(),
|
|
302
|
+
...(key ? { last: { key, text: hashSessionPart(task), marks: 0 } } : {}),
|
|
303
|
+
};
|
|
304
|
+
const entry: Entry = { meta, ...(root ? { dir: path.join(root, sessionId) } : {}), busy: false };
|
|
305
|
+
// Registered before the first await: parallel spawns of one model turn must see each other for the open-worker limit.
|
|
306
|
+
this.entries.set(id, entry);
|
|
307
|
+
try {
|
|
308
|
+
entry.agent = await this.build(entry);
|
|
309
|
+
if (messages.length > 0) entry.agent.context.messages = messages;
|
|
310
|
+
this.save(entry);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
this.entries.delete(id);
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
return entry;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Builds a worker's Agent from its saved meta; a worker reloaded after a restart gets its saved history back; in: create and deliver, out: the worker Agent.
|
|
319
|
+
private async build(entry: Entry): Promise<Agent> {
|
|
320
|
+
const { Agent: AgentClass } = await import("./agent.ts");
|
|
321
|
+
const { meta } = entry;
|
|
322
|
+
const root = this.root();
|
|
323
|
+
const agent = new AgentClass(
|
|
324
|
+
buildDynamicWorkerConfig(this.parent, {
|
|
325
|
+
name: meta.name,
|
|
326
|
+
role: meta.role,
|
|
327
|
+
instructions: meta.instructions,
|
|
328
|
+
sessionId: meta.sessionId,
|
|
329
|
+
tools: grantWorkerTools(this.parent.dynamicSubagents?.tools ?? {}, meta.tools),
|
|
330
|
+
persistent: { dir: root },
|
|
331
|
+
})
|
|
332
|
+
);
|
|
333
|
+
(agent as unknown as { parentSessionId?: string }).parentSessionId = this.parent.sessionId;
|
|
334
|
+
// A cut-off run was already loaded by the constructor; otherwise bring back what the worker said so far.
|
|
335
|
+
const saved = entry.dir && agent.context.messages.length === 0 ? loadSessionDir(entry.dir)?.session : undefined;
|
|
336
|
+
if (saved) agent.importSession(saved);
|
|
337
|
+
return agent;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Sends one text to a worker and turns what happens into a reply: runs it, or after a crash continues the cut-off run or returns the answer it had already given; in: spawn and message, out: a WorkerReply.
|
|
341
|
+
private async deliver(entry: Entry, text: string, key: string | undefined, opts: WorkerCallOptions): Promise<WorkerReply> {
|
|
342
|
+
if (entry.busy) throw new ValidationError(`Worker "${entry.meta.id}" is busy with another call; wait for it, or message another worker.`);
|
|
343
|
+
entry.busy = true;
|
|
344
|
+
const { meta } = entry;
|
|
345
|
+
const startTime = nowMs();
|
|
346
|
+
try {
|
|
347
|
+
const agent = (entry.agent ??= await this.build(entry));
|
|
348
|
+
const live = openWorkerTrace({
|
|
349
|
+
name: meta.name,
|
|
350
|
+
sessionId: meta.sessionId,
|
|
351
|
+
parentSessionId: this.parent.sessionId,
|
|
352
|
+
task: text,
|
|
353
|
+
role: meta.role,
|
|
354
|
+
parent: this.parent,
|
|
355
|
+
emit: opts.onSubagentEvent,
|
|
356
|
+
});
|
|
357
|
+
const lineage = { name: meta.name, displayId: live.displayId, sessionId: meta.sessionId, parentSessionId: this.parent.sessionId, role: meta.role, task: text, runId: newRunId(), turnId: newTurnId() };
|
|
358
|
+
const replayed = this.isReplay(entry, key, text) && agent.runMarks.length > meta.last!.marks;
|
|
359
|
+
if (!replayed) {
|
|
360
|
+
if (key) meta.last = { key, text: hashSessionPart(text), marks: agent.runMarks.length };
|
|
361
|
+
else delete meta.last;
|
|
362
|
+
this.save(entry);
|
|
363
|
+
}
|
|
364
|
+
const onAbort = (): void => agent.interrupt(opts.signal?.reason);
|
|
365
|
+
try {
|
|
366
|
+
if (opts.signal?.aborted) throw new Error("aborted before the worker started");
|
|
367
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
368
|
+
let result: WorkerRunResult | undefined;
|
|
369
|
+
if (replayed && agent.interrupted().length === 0) {
|
|
370
|
+
// The crash came after the worker answered: that answer is the reply, nothing runs again.
|
|
371
|
+
const saved = lastAssistantText(agent.context.messages);
|
|
372
|
+
if (saved !== undefined) result = { text: saved };
|
|
373
|
+
}
|
|
374
|
+
if (!result) {
|
|
375
|
+
let response: AgentResponse | undefined;
|
|
376
|
+
if (replayed) {
|
|
377
|
+
response = await agent.resume();
|
|
378
|
+
} else {
|
|
379
|
+
const stream = agent.stream(text, { onTurn: live.onWorkerTurn });
|
|
380
|
+
const detach = live.attachToStream(stream as unknown as Parameters<typeof live.attachToStream>[0]);
|
|
381
|
+
try {
|
|
382
|
+
response = await stream.result();
|
|
383
|
+
} finally {
|
|
384
|
+
detach();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (!response) throw new Error("the worker had nothing to resume");
|
|
388
|
+
result = response;
|
|
389
|
+
}
|
|
390
|
+
const reply = result.text.trim() ? result.text : `(the worker produced no text${result.finishReason ? `; finish reason: ${result.finishReason}` : ""})`;
|
|
391
|
+
live.finish(result);
|
|
392
|
+
return { id: meta.id, text: reply, metadata: workerMetadata({ ...lineage, durationMs: Math.max(0, nowMs() - startTime), result, steps: live.steps() }) };
|
|
393
|
+
} catch (err) {
|
|
394
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
395
|
+
live.fail(message);
|
|
396
|
+
const errorText = `Error in worker ${meta.id}: ${message}`;
|
|
397
|
+
return { id: meta.id, text: errorText, error: message, metadata: workerMetadata({ ...lineage, durationMs: Math.max(0, nowMs() - startTime), error: message, errorText, steps: live.steps() }) };
|
|
398
|
+
} finally {
|
|
399
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
400
|
+
}
|
|
401
|
+
} finally {
|
|
402
|
+
entry.busy = false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -61,7 +61,6 @@ export { assertPathAllowed, enforceFileAccess, fetchRemoteBytes, isPrivateAddres
|
|
|
61
61
|
export {
|
|
62
62
|
buildAgentTools,
|
|
63
63
|
agentToTool,
|
|
64
|
-
createSubagentSpawnTool,
|
|
65
64
|
createChildSessionId,
|
|
66
65
|
createFixedChildSessionId,
|
|
67
66
|
isSessionDescendant,
|
|
@@ -85,7 +84,8 @@ export {
|
|
|
85
84
|
MAX_SUBAGENT_STEPS_PER_TRACE,
|
|
86
85
|
SUBAGENT_SUBSCRIBE_TTL_MS,
|
|
87
86
|
} from "./agent/delegation.ts";
|
|
88
|
-
export type {
|
|
87
|
+
export type { AgentAsToolTarget, SubAgentTrace, StreamingLoopHandle } from "./agent/delegation.ts";
|
|
88
|
+
export type { WorkerRegistry, WorkerSpec, WorkerInfo, WorkerReply, WorkerCallOptions } from "./agent/workers.ts";
|
|
89
89
|
|
|
90
90
|
// Scheduled actions: dynamic sub-agents that run later and save their answer to a file
|
|
91
91
|
export {
|
|
@@ -317,7 +317,8 @@ export {
|
|
|
317
317
|
formatSessionBanner,
|
|
318
318
|
emptyTotals,
|
|
319
319
|
} from "./session/store.ts";
|
|
320
|
-
export type {
|
|
320
|
+
export type { UsageReport, UsageLine, UsageTotals, AgentUsage, WorkerUsage, CostSource } from "./session/usage.ts";
|
|
321
|
+
export type { SessionLimits, SessionPayloadStatus, PersistedAgentSession, SessionTotals, SessionAgentLike, LoadedSessionDir, PersistedSubAgentTrace, SessionRunMark, SessionRunEnd, SessionPending, SessionPendingPrompt, SessionToolRecord } from "./session/store.ts";
|
|
321
322
|
|
|
322
323
|
// Utilities
|
|
323
324
|
/**
|
|
@@ -414,6 +415,7 @@ export type {
|
|
|
414
415
|
Budget,
|
|
415
416
|
BudgetState,
|
|
416
417
|
SubAgentRunIds,
|
|
418
|
+
InterruptedRun,
|
|
417
419
|
} from "./types/agent.ts";
|
|
418
420
|
|
|
419
421
|
// Minimalist core: harness modules (RLM/RSI, OTel, testing, CLI doctor, hooks,
|
|
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { getEnv } from "../utils/env.ts";
|
|
5
5
|
import { telemetryBus } from "../telemetry.ts";
|
|
6
6
|
import { onHomeChange, resolveHome } from "../utils/home.ts";
|
|
7
|
+
import { writeFileDurable } from "../utils/durable.ts";
|
|
7
8
|
|
|
8
9
|
/** Default TTL: 12 Hours (in milliseconds). */
|
|
9
10
|
export const DEFAULT_CATALOG_TTL_MS = 12 * 60 * 60 * 1000;
|
|
@@ -102,17 +103,9 @@ function readJsonFileSync(filePath: string): any {
|
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
// Writes the catalog cache durably but without a `.bak` (it is re-downloadable); in: path + payload from refreshModelCatalog, out: the cache file.
|
|
105
107
|
function writeJsonFileSync(filePath: string, data: any): void {
|
|
106
|
-
|
|
107
|
-
if (!fs.existsSync(dir)) {
|
|
108
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
109
|
-
}
|
|
110
|
-
// Atomic tmp+rename with 0600 so a crash never leaves half-written JSON (Q-16a).
|
|
111
|
-
const rand = Math.random().toString(36).slice(2, 10);
|
|
112
|
-
const tmp = `${filePath}.tmp-${process.pid}-${rand}`;
|
|
113
|
-
fs.writeFileSync(tmp, JSON.stringify(data), { encoding: "utf-8", mode: 0o600 });
|
|
114
|
-
try { fs.chmodSync(tmp, 0o600); } catch {}
|
|
115
|
-
fs.renameSync(tmp, filePath);
|
|
108
|
+
writeFileDurable(filePath, JSON.stringify(data), { backup: false });
|
|
116
109
|
}
|
|
117
110
|
|
|
118
111
|
/** Verifies optional sha256 pin from MODELS_DEV_SHA256 (Q-16a). Returns false on mismatch. */
|
|
@@ -16,6 +16,7 @@ import type { Agent } from "../agent/agent.ts";
|
|
|
16
16
|
import { createChildSessionId } from "../agent/delegation.ts";
|
|
17
17
|
import { buildDynamicWorkerConfig, grantWorkerTools, normalizeWorkerTimeoutValue } from "../agent/worker.ts";
|
|
18
18
|
import { telemetryBus } from "../telemetry.ts";
|
|
19
|
+
import { lockFolder } from "../utils/durable.ts";
|
|
19
20
|
import { ConfigError, ValidationError } from "../types/errors.ts";
|
|
20
21
|
import type { ToolDefinition } from "../types/tool.ts";
|
|
21
22
|
import type { AgentResponse } from "../types/response.ts";
|
|
@@ -649,6 +650,7 @@ const schedulers = new Map<string, Scheduler>();
|
|
|
649
650
|
export function getScheduler(home: string): Scheduler {
|
|
650
651
|
let s = schedulers.get(home);
|
|
651
652
|
if (!s) {
|
|
653
|
+
lockFolder(home); // every scheduler function goes through here: one process per home
|
|
652
654
|
migrateLegacyFolder(home);
|
|
653
655
|
schedulers.set(home, (s = new Scheduler(home)));
|
|
654
656
|
}
|
package/src/scheduler/store.ts
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
* <home>/scheduled_actions/<id>/<date>.md one file per run
|
|
7
7
|
*
|
|
8
8
|
* The files are the source of truth: every write re-reads first (never writes
|
|
9
|
-
* from a stale copy) and goes through `
|
|
9
|
+
* from a stale copy) and goes through `writeFileDurable` (temp file, fsync, `.bak`, rename).
|
|
10
10
|
* Sync `fs`, like the session store.
|
|
11
11
|
*/
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import { z } from "zod";
|
|
15
|
-
import {
|
|
15
|
+
import { readWithBackup, writeFileDurable } from "../utils/durable.ts";
|
|
16
16
|
import { telemetryBus } from "../telemetry.ts";
|
|
17
17
|
import { resolveHome } from "../utils/home.ts";
|
|
18
18
|
export { resolveHome };
|
|
@@ -73,36 +73,49 @@ export function migrateLegacyFolder(home: string): void {
|
|
|
73
73
|
|
|
74
74
|
type Raw = Record<string, unknown>;
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
function
|
|
78
|
-
let text: string;
|
|
76
|
+
// Parses a `{ version, <key>: [...] }` file into its object entries; undefined when the text is not that shape; in: raw file text, out: readList.
|
|
77
|
+
function parseList(text: string, key: "tasks" | "pending"): Raw[] | undefined {
|
|
79
78
|
try {
|
|
80
|
-
|
|
79
|
+
const list = (JSON.parse(text) as Raw | null)?.[key];
|
|
80
|
+
if (!Array.isArray(list)) return undefined;
|
|
81
|
+
return list.filter((x): x is Raw => !!x && typeof x === "object" && !Array.isArray(x));
|
|
81
82
|
} catch {
|
|
82
|
-
return
|
|
83
|
+
return undefined;
|
|
83
84
|
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Reads a `{ version, <key>: [...] }` file. Missing = empty. A damaged file is renamed `*.corrupt-<ts>`; if its `.bak`
|
|
89
|
+
* (the last good version) is valid it is restored, else the list starts empty.
|
|
90
|
+
*/
|
|
91
|
+
function readList(file: string, key: "tasks" | "pending"): Raw[] {
|
|
92
|
+
const read = readWithBackup(file, (t) => parseList(t, key) !== undefined);
|
|
93
|
+
if (!read) return [];
|
|
94
|
+
const list = parseList(read.text, key);
|
|
95
|
+
if (list && !read.fromBackup) return list;
|
|
96
|
+
const moved = `${file}.corrupt-${Date.now()}`;
|
|
84
97
|
try {
|
|
85
|
-
|
|
86
|
-
if (!Array.isArray(list)) throw new Error(`"${key}" is not an array`);
|
|
87
|
-
return list.filter((x): x is Raw => !!x && typeof x === "object" && !Array.isArray(x));
|
|
98
|
+
fs.renameSync(file, moved);
|
|
88
99
|
} catch {
|
|
89
|
-
|
|
100
|
+
// why: quarantine is best-effort; the file is ignored either way.
|
|
101
|
+
}
|
|
102
|
+
if (list) {
|
|
90
103
|
try {
|
|
91
|
-
|
|
104
|
+
writeFileDurable(file, read.text, { backup: false }); // keep the good .bak where it is
|
|
92
105
|
} catch {
|
|
93
|
-
// why:
|
|
106
|
+
// why: the backup content is still returned for this read.
|
|
94
107
|
}
|
|
95
|
-
telemetryBus.emitWarning(
|
|
96
|
-
"scheduled_action.store_corrupt",
|
|
97
|
-
`[Agent Accelerator] ${path.basename(file)} is not valid; moved to ${path.basename(moved)} and starting empty.`,
|
|
98
|
-
{ file: moved }
|
|
99
|
-
);
|
|
100
|
-
return [];
|
|
101
108
|
}
|
|
109
|
+
telemetryBus.emitWarning(
|
|
110
|
+
"scheduled_action.store_corrupt",
|
|
111
|
+
`[Agent Accelerator] ${path.basename(file)} is not valid; moved to ${path.basename(moved)} and ${list ? "restored from its backup" : "starting empty"}.`,
|
|
112
|
+
{ file: moved, restored: !!list }
|
|
113
|
+
);
|
|
114
|
+
return list ?? [];
|
|
102
115
|
}
|
|
103
116
|
|
|
104
117
|
function writeList(file: string, key: "tasks" | "pending", list: Raw[]): void {
|
|
105
|
-
|
|
118
|
+
writeFileDurable(file, JSON.stringify({ version: 1, [key]: list }, null, 2) + "\n");
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
/** Every record as stored. Records may be invalid: check with {@link validateRecord} before acting on one. */
|
|
@@ -212,7 +225,7 @@ export function writeRunFile(home: string, id: string, deliverAt: Date, header:
|
|
|
212
225
|
const base = runFileName(deliverAt);
|
|
213
226
|
let name = `${base}.md`;
|
|
214
227
|
for (let n = 2; fs.existsSync(path.join(dir, name)); n++) name = `${base}-${n}.md`;
|
|
215
|
-
|
|
228
|
+
writeFileDurable(path.join(dir, name), renderRun(header, body), { backup: false }); // written once, never rewritten
|
|
216
229
|
return `${id}/${name}`;
|
|
217
230
|
}
|
|
218
231
|
|