agent-accelerator 0.5.3 → 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 +66 -30
- package/examples/prompts/SYSTEM_PROMPT_AGENT.md +14 -20
- package/package.json +1 -1
- package/src/agent/agent.ts +73 -24
- package/src/agent/context.ts +1 -1
- package/src/agent/delegation.ts +208 -524
- package/src/agent/loop.ts +13 -11
- 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 +3 -2
- package/src/session/journal.ts +12 -1
- package/src/session/store.ts +24 -10
- package/src/session/usage.ts +201 -0
- package/src/tools/subagent-result.ts +1 -1
- package/src/types/agent.ts +19 -6
- package/src/types/tool.ts +1 -1
- package/src/utils/headers.ts +1 -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,6 +317,7 @@ export {
|
|
|
317
317
|
formatSessionBanner,
|
|
318
318
|
emptyTotals,
|
|
319
319
|
} from "./session/store.ts";
|
|
320
|
+
export type { UsageReport, UsageLine, UsageTotals, AgentUsage, WorkerUsage, CostSource } from "./session/usage.ts";
|
|
320
321
|
export type { SessionLimits, SessionPayloadStatus, PersistedAgentSession, SessionTotals, SessionAgentLike, LoadedSessionDir, PersistedSubAgentTrace, SessionRunMark, SessionRunEnd, SessionPending, SessionPendingPrompt, SessionToolRecord } from "./session/store.ts";
|
|
321
322
|
|
|
322
323
|
// Utilities
|
package/src/session/journal.ts
CHANGED
|
@@ -51,6 +51,8 @@ export class SessionJournal {
|
|
|
51
51
|
private lastRun: unknown;
|
|
52
52
|
/** JSON of the last journaled waiting prompts. */
|
|
53
53
|
private pending = EMPTY_PENDING;
|
|
54
|
+
/** JSON of the last journaled spending totals. */
|
|
55
|
+
private usage = "";
|
|
54
56
|
/** Run ids whose ending is on disk. */
|
|
55
57
|
private readonly endsOnDisk = new Set<string>();
|
|
56
58
|
/** How many of the current run's finished tool results are on disk, and the last one. */
|
|
@@ -90,6 +92,8 @@ export class SessionJournal {
|
|
|
90
92
|
if (meta !== this.meta) records.push(`{"t":"meta","m":${meta}}`);
|
|
91
93
|
const pending = this.pendingOf(agent);
|
|
92
94
|
if (pending !== this.pending) records.push(`{"t":"pending","p":${pending}}`);
|
|
95
|
+
const usage = this.usageOf(agent);
|
|
96
|
+
if (usage !== this.usage) records.push(`{"t":"usage","u":${usage}}`);
|
|
93
97
|
if (records.length === 0) return;
|
|
94
98
|
const text = records.join("\n") + "\n";
|
|
95
99
|
try {
|
|
@@ -110,6 +114,7 @@ export class SessionJournal {
|
|
|
110
114
|
this.runCount = runs.length;
|
|
111
115
|
this.lastRun = runs[runs.length - 1];
|
|
112
116
|
this.pending = pending;
|
|
117
|
+
this.usage = usage;
|
|
113
118
|
this.toolCount = tools.length;
|
|
114
119
|
this.lastTool = tools[tools.length - 1];
|
|
115
120
|
for (const mark of runs) if (mark.end) this.endsOnDisk.add(mark.id);
|
|
@@ -134,6 +139,7 @@ export class SessionJournal {
|
|
|
134
139
|
writeFileDurable(path.join(this.dir, JOURNAL_FILE), header, { backup: false });
|
|
135
140
|
this.epoch = epoch;
|
|
136
141
|
this.pending = pending;
|
|
142
|
+
this.usage = this.usageOf(agent); // the snapshot holds it
|
|
137
143
|
this.runs(agent);
|
|
138
144
|
this.count = messages.length;
|
|
139
145
|
this.last = messages[messages.length - 1];
|
|
@@ -167,9 +173,14 @@ export class SessionJournal {
|
|
|
167
173
|
return JSON.stringify({ steer: slim(waiting.steer), queue: slim(waiting.queue) } satisfies SessionPending);
|
|
168
174
|
}
|
|
169
175
|
|
|
176
|
+
// JSON of the agent's spending totals (`null` before its first model call); in: the live agent, out: change detection + the `usage` record.
|
|
177
|
+
private usageOf(agent: SessionAgentLike): string {
|
|
178
|
+
return agent.usageTotals && agent.usageTotals.turns > 0 ? JSON.stringify(agent.usageTotals) : "null";
|
|
179
|
+
}
|
|
180
|
+
|
|
170
181
|
// JSON of everything in the snapshot that is not a message, identity or totals; in: the live agent, out: change detection + the `meta` record.
|
|
171
182
|
private metaOf(agent: SessionAgentLike): string {
|
|
172
|
-
const { version: _v, sessionId: _s, messages: _m, totals: _t, runs: _r, ...rest } = buildSessionData(
|
|
183
|
+
const { version: _v, sessionId: _s, messages: _m, totals: _t, runs: _r, usage: _u, ...rest } = buildSessionData(
|
|
173
184
|
{ ...agent, context: { ...agent.context, messages: [] } },
|
|
174
185
|
null
|
|
175
186
|
);
|
package/src/session/store.ts
CHANGED
|
@@ -33,6 +33,7 @@ import type { SubAgentStep } from "../types/response.ts";
|
|
|
33
33
|
import { SessionLoadError } from "../types/errors.ts";
|
|
34
34
|
import { createSessionId } from "../utils/session.ts";
|
|
35
35
|
import { writeFileDurable, readWithBackup } from "../utils/durable.ts";
|
|
36
|
+
import type { UsageTotals } from "./usage.ts";
|
|
36
37
|
|
|
37
38
|
/** Cumulative per-session token + cost totals (JSON-safe). */
|
|
38
39
|
export interface SessionTotals {
|
|
@@ -118,6 +119,8 @@ export interface PersistedAgentSession {
|
|
|
118
119
|
epoch?: string;
|
|
119
120
|
/** Where each run began, oldest first. */
|
|
120
121
|
runs?: SessionRunMark[];
|
|
122
|
+
/** What this agent spent on its own model calls (see `usage.ts`); the journal keeps it current with `usage` records. */
|
|
123
|
+
usage?: UsageTotals;
|
|
121
124
|
/** Read from the journal only (never in `session.json`): prompts that were still waiting. */
|
|
122
125
|
pending?: SessionPending;
|
|
123
126
|
/** Read from the journal only: results of tool calls finished in the current run (used to resume without repeating them). */
|
|
@@ -335,6 +338,8 @@ export interface SessionAgentLike {
|
|
|
335
338
|
pendingPrompts?: () => SessionPending;
|
|
336
339
|
/** Tool calls finished in the current run (cleared when the next run starts). */
|
|
337
340
|
toolRecords?: SessionToolRecord[];
|
|
341
|
+
/** What this agent spent on its own model calls. */
|
|
342
|
+
usageTotals?: UsageTotals;
|
|
338
343
|
context: { systemPrompt?: string; messages: Message[] };
|
|
339
344
|
}
|
|
340
345
|
|
|
@@ -482,6 +487,7 @@ export function buildSessionData(agent: SessionAgentLike, telemetry?: SessionTel
|
|
|
482
487
|
...(parentSessionId ? { parentSessionId } : {}),
|
|
483
488
|
...(subagents ? { subagents } : {}),
|
|
484
489
|
...(agent.runMarks && agent.runMarks.length > 0 ? { runs: agent.runMarks.map((r) => ({ ...r })) } : {}),
|
|
490
|
+
...(agent.usageTotals && agent.usageTotals.turns > 0 ? { usage: JSON.parse(JSON.stringify(agent.usageTotals)) as UsageTotals } : {}),
|
|
485
491
|
};
|
|
486
492
|
}
|
|
487
493
|
|
|
@@ -671,6 +677,7 @@ function parseSession(raw: string, limits?: SessionLimits): PersistedAgentSessio
|
|
|
671
677
|
subagents: obj.subagents,
|
|
672
678
|
...(typeof obj.epoch === "string" ? { epoch: obj.epoch } : {}),
|
|
673
679
|
...(Array.isArray(obj.runs) ? { runs: obj.runs as SessionRunMark[] } : {}),
|
|
680
|
+
...(obj.usage && typeof obj.usage === "object" ? { usage: obj.usage as UsageTotals } : {}),
|
|
674
681
|
};
|
|
675
682
|
} catch {
|
|
676
683
|
return null;
|
|
@@ -985,7 +992,7 @@ export const JOURNAL_FILE = "journal.ndjson";
|
|
|
985
992
|
// Snapshot fields a `meta` journal record replaces (everything except messages, identity and totals).
|
|
986
993
|
const JOURNAL_META_KEYS = ["model", "subagentModel", "thinkingLevel", "cache", "systemPrompt", "parentSessionId", "subagents"] as const;
|
|
987
994
|
|
|
988
|
-
// Applies a journal to its snapshot: records of the snapshot's own epoch only, `msg` by index (already-present ones skipped), `run` adds a run boundary, `end` closes one, `tool` keeps a finished tool result, `pending` replaces the waiting prompts, `meta` replaces config fields, unknown record types ignored; a torn last line is dropped, damage earlier stops the replay with a bus warning; in: the parsed snapshot + journal text, out: loadSessionDir.
|
|
995
|
+
// Applies a journal to its snapshot: records of the snapshot's own epoch only, `msg` by index (already-present ones skipped), `run` adds a run boundary, `end` closes one, `usage` replaces the agent's spending totals, `tool` keeps a finished tool result, `pending` replaces the waiting prompts, `meta` replaces config fields, unknown record types ignored; a torn last line is dropped, damage earlier stops the replay with a bus warning; in: the parsed snapshot + journal text, out: loadSessionDir.
|
|
989
996
|
function replayJournal(snapshot: PersistedAgentSession, text: string, file: string): PersistedAgentSession {
|
|
990
997
|
if (!snapshot.epoch) return snapshot;
|
|
991
998
|
const lines = text.split("\n");
|
|
@@ -997,7 +1004,7 @@ function replayJournal(snapshot: PersistedAgentSession, text: string, file: stri
|
|
|
997
1004
|
for (let k = 0; k < lines.length; k++) {
|
|
998
1005
|
const line = lines[k]!.trim();
|
|
999
1006
|
if (!line) continue;
|
|
1000
|
-
let rec: { t?: unknown; e?: unknown; i?: unknown; m?: unknown; p?: unknown; id?: unknown; at?: unknown; status?: unknown; call?: unknown; name?: unknown; result?: unknown; isError?: unknown; stopped?: unknown };
|
|
1007
|
+
let rec: { t?: unknown; e?: unknown; i?: unknown; m?: unknown; p?: unknown; id?: unknown; at?: unknown; u?: unknown; status?: unknown; call?: unknown; name?: unknown; result?: unknown; isError?: unknown; stopped?: unknown };
|
|
1001
1008
|
try {
|
|
1002
1009
|
rec = JSON.parse(line);
|
|
1003
1010
|
} catch {
|
|
@@ -1023,6 +1030,8 @@ function replayJournal(snapshot: PersistedAgentSession, text: string, file: stri
|
|
|
1023
1030
|
if (mark) session.runs = session.runs!.map((r) => (r === mark ? { ...r, end: rec.status as SessionRunEnd } : r));
|
|
1024
1031
|
} else if (rec.t === "tool" && typeof rec.call === "string" && typeof rec.name === "string") {
|
|
1025
1032
|
session.tools = [...(session.tools ?? []).filter((x) => x.id !== rec.call), { id: rec.call, name: rec.name, result: rec.result, ...(rec.isError ? { isError: true } : {}), ...(rec.stopped ? { stopped: true } : {}) }];
|
|
1033
|
+
} else if (rec.t === "usage" && rec.u && typeof rec.u === "object") {
|
|
1034
|
+
session.usage = rec.u as UsageTotals; // latest record wins
|
|
1026
1035
|
} else if (rec.t === "pending" && rec.p && typeof rec.p === "object") {
|
|
1027
1036
|
const p = rec.p as SessionPending; // latest record wins
|
|
1028
1037
|
if (Array.isArray(p.steer) && Array.isArray(p.queue)) {
|
|
@@ -1328,15 +1337,16 @@ function toDollars(microUSD: number): number {
|
|
|
1328
1337
|
* `cost.context_over_200k`, the over-200k tier prices apply; otherwise base
|
|
1329
1338
|
* `pricing.*` (falling back to legacy `cost.*`) applies.
|
|
1330
1339
|
*/
|
|
1340
|
+
// Cache prices stay undefined when the catalog has none: priceUsage then bills those tokens at the input rate (never free).
|
|
1331
1341
|
function resolveEffectivePricing(
|
|
1332
1342
|
spec: ModelSpec | undefined,
|
|
1333
1343
|
inputTokens: number
|
|
1334
|
-
): { input: number; output: number; cacheRead: number; cacheWrite: number; audio: number; tier: string } {
|
|
1344
|
+
): { input: number; output: number; cacheRead: number | undefined; cacheWrite: number | undefined; audio: number; tier: string } {
|
|
1335
1345
|
const base = {
|
|
1336
1346
|
input: spec?.pricing?.inputPerMillion ?? (spec?.cost?.input as number | undefined) ?? 0,
|
|
1337
1347
|
output: spec?.pricing?.outputPerMillion ?? (spec?.cost?.output as number | undefined) ?? 0,
|
|
1338
|
-
cacheRead: spec?.pricing?.cacheReadPerMillion ?? (spec?.cost?.cache_read as number | undefined)
|
|
1339
|
-
cacheWrite: spec?.pricing?.cacheWritePerMillion ?? (spec?.cost?.cache_write as number | undefined)
|
|
1348
|
+
cacheRead: spec?.pricing?.cacheReadPerMillion ?? (spec?.cost?.cache_read as number | undefined),
|
|
1349
|
+
cacheWrite: spec?.pricing?.cacheWritePerMillion ?? (spec?.cost?.cache_write as number | undefined),
|
|
1340
1350
|
audio:
|
|
1341
1351
|
spec?.pricing?.inputAudioPerMillion ?? (spec?.cost as { input_audio?: number } | undefined)?.input_audio ?? 0,
|
|
1342
1352
|
};
|
|
@@ -1388,8 +1398,9 @@ function resolveEffectivePricing(
|
|
|
1388
1398
|
* - Audio tokens (`usage.audioTokens` / `usage.inputAudioTokens` when present)
|
|
1389
1399
|
* price at `pricing.inputAudioPerMillion` / `cost.input_audio` and fold into
|
|
1390
1400
|
* the total (separately itemized in `breakdown.audioMicroUSD`).
|
|
1391
|
-
* - `opts.serviceTier` (`flex`
|
|
1401
|
+
* - `opts.serviceTier` (`flex` 0.5x, `priority` 2x, estimates) scales the
|
|
1392
1402
|
* catalog total; provider-reported costs are never rescaled.
|
|
1403
|
+
* - Cache reads and writes are subsets of the input tokens; a missing cache price bills at the input rate.
|
|
1393
1404
|
*/
|
|
1394
1405
|
export function priceUsage(
|
|
1395
1406
|
usage: TokenUsage,
|
|
@@ -1438,12 +1449,15 @@ export function priceUsage(
|
|
|
1438
1449
|
return undefined;
|
|
1439
1450
|
}
|
|
1440
1451
|
const { multiplier, tier: serviceTier } = serviceTierMultiplier(opts?.serviceTier, opts?.modelId ?? spec?.id);
|
|
1441
|
-
|
|
1452
|
+
// Cache reads and writes are both a SUBSET of the input tokens (OpenRouter `prompt_tokens` includes them), so the
|
|
1453
|
+
// plain input rate applies only to what is neither read from nor written to the cache.
|
|
1454
|
+
const freshInput = Math.max(0, inputTokens - cachedTokens - cacheWriteTokens);
|
|
1442
1455
|
// Integer micro-USD accumulation: microUSD = tokens * pricePerMillion
|
|
1443
1456
|
// ($P per 1M tokens == $P/1e6 per token == P micro-USD per token).
|
|
1444
|
-
|
|
1445
|
-
const
|
|
1446
|
-
const
|
|
1457
|
+
// A catalog entry without a cache price bills those tokens at the input rate: cache reads/writes are never free.
|
|
1458
|
+
const inputMicroUSD = Math.round(freshInput * pricing.input * multiplier);
|
|
1459
|
+
const cacheReadMicroUSD = Math.round(cachedTokens * (pricing.cacheRead ?? pricing.input) * multiplier);
|
|
1460
|
+
const cacheWriteMicroUSD = Math.round(cacheWriteTokens * (pricing.cacheWrite ?? pricing.input) * multiplier);
|
|
1447
1461
|
const outputMicroUSD = Math.round(outputTokens * pricing.output * multiplier);
|
|
1448
1462
|
const audioMicroUSD = Math.round(audioCount * pricing.audio * multiplier);
|
|
1449
1463
|
const totalMicroUSD = inputMicroUSD + cacheReadMicroUSD + cacheWriteMicroUSD + outputMicroUSD + audioMicroUSD;
|