@narumitw/pi-subagents 0.13.0 → 0.14.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.
@@ -0,0 +1,759 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { DEFAULT_MAX_CONTEXT_BYTES, DEFAULT_MAX_OUTPUT_BYTES, truncateUtf8 } from "./limits.js";
3
+ import {
4
+ type AgentTurnRunner,
5
+ normalizeTransport,
6
+ type SubagentTransport,
7
+ } from "./transport.js";
8
+
9
+ export type AgentLifecycleState =
10
+ | "starting"
11
+ | "running"
12
+ | "idle"
13
+ | "completed"
14
+ | "interrupted"
15
+ | "failed"
16
+ | "closed";
17
+
18
+ export interface AgentTurn {
19
+ task: string;
20
+ output: string;
21
+ startedAt: number;
22
+ completedAt: number;
23
+ exitCode: number;
24
+ truncated?: boolean;
25
+ }
26
+
27
+ export interface AgentMailboxMessage {
28
+ id: string;
29
+ senderId: string;
30
+ recipientId: string;
31
+ content: string;
32
+ createdAt: number;
33
+ readAt?: number;
34
+ deduplicationKey?: string;
35
+ }
36
+
37
+ export interface ManagedAgent {
38
+ id: string;
39
+ agent: string;
40
+ parentId?: string;
41
+ rootId: string;
42
+ depth: number;
43
+ children: string[];
44
+ state: AgentLifecycleState;
45
+ createdAt: number;
46
+ updatedAt: number;
47
+ cwd: string;
48
+ agentScope?: "user" | "project" | "both";
49
+ currentTask?: string;
50
+ history: AgentTurn[];
51
+ error?: string;
52
+ context?: string;
53
+ contextSourceIds?: string[];
54
+ contextTruncated?: boolean;
55
+ policy?: { inherited: string[]; overridden: string[]; unsupported: string[] };
56
+ mailbox: AgentMailboxMessage[];
57
+ currentMailboxMessageIds?: string[];
58
+ }
59
+
60
+ export interface TurnOutcome {
61
+ output: string;
62
+ exitCode: number;
63
+ aborted?: boolean;
64
+ truncated?: boolean;
65
+ error?: string;
66
+ policy?: ManagedAgent["policy"];
67
+ }
68
+
69
+ export interface AgentTurnCompletion {
70
+ agent: ManagedAgent;
71
+ task: string;
72
+ output: string;
73
+ error?: string;
74
+ }
75
+
76
+ export interface AgentRegistryOptions {
77
+ maxAgents?: number;
78
+ maxActiveTurns?: number;
79
+ maxHistoryTurns?: number;
80
+ maxDepth?: number;
81
+ maxChildrenPerAgent?: number;
82
+ maxMailboxMessages?: number;
83
+ maxMailboxMessageBytes?: number;
84
+ maxTaskBytes?: number;
85
+ maxTurnOutputBytes?: number;
86
+ idleTtlMs?: number;
87
+ now?: () => number;
88
+ onChange?: (agents: ManagedAgent[]) => void | Promise<void>;
89
+ onTurnComplete?: (completion: AgentTurnCompletion) => void | Promise<void>;
90
+ }
91
+
92
+ function positiveInteger(value: number, label: string): number {
93
+ if (!Number.isSafeInteger(value) || value < 1) {
94
+ throw new Error(`${label} must be a positive safe integer`);
95
+ }
96
+ return value;
97
+ }
98
+
99
+ function nonNegativeInteger(value: number, label: string): number {
100
+ if (!Number.isSafeInteger(value) || value < 0) {
101
+ throw new Error(`${label} must be a non-negative safe integer`);
102
+ }
103
+ return value;
104
+ }
105
+
106
+ function waitAbortError(): Error {
107
+ const error = new Error("Subagent wait was aborted");
108
+ error.name = "AbortError";
109
+ return error;
110
+ }
111
+
112
+ export class AgentRegistry {
113
+ private readonly agents = new Map<string, ManagedAgent>();
114
+ private readonly controllers = new Map<string, AbortController>();
115
+ private readonly running = new Map<string, Promise<ManagedAgent>>();
116
+ private readonly queue: Array<{ agent: ManagedAgent; task: string; resolve: (agent: ManagedAgent) => void }> = [];
117
+ private changeQueue: Promise<void> = Promise.resolve();
118
+ private readonly maxAgents: number;
119
+ private readonly maxActiveTurns: number;
120
+ private readonly maxHistoryTurns: number;
121
+ private readonly maxDepth: number;
122
+ private readonly maxChildrenPerAgent: number;
123
+ private readonly maxMailboxMessages: number;
124
+ private readonly maxMailboxMessageBytes: number;
125
+ private readonly maxTaskBytes: number;
126
+ private readonly maxTurnOutputBytes: number;
127
+ private readonly idleTtlMs: number;
128
+ private readonly transport: SubagentTransport;
129
+ private readonly now: () => number;
130
+
131
+ constructor(transport: SubagentTransport | AgentTurnRunner, private readonly options: AgentRegistryOptions = {}) {
132
+ this.transport = normalizeTransport(transport);
133
+ this.maxAgents = positiveInteger(options.maxAgents ?? 16, "maxAgents");
134
+ this.maxActiveTurns = positiveInteger(options.maxActiveTurns ?? 4, "maxActiveTurns");
135
+ this.maxHistoryTurns = positiveInteger(options.maxHistoryTurns ?? 20, "maxHistoryTurns");
136
+ this.maxDepth = nonNegativeInteger(options.maxDepth ?? 3, "maxDepth");
137
+ this.maxChildrenPerAgent = positiveInteger(
138
+ options.maxChildrenPerAgent ?? 8,
139
+ "maxChildrenPerAgent",
140
+ );
141
+ this.maxMailboxMessages = positiveInteger(
142
+ options.maxMailboxMessages ?? 100,
143
+ "maxMailboxMessages",
144
+ );
145
+ this.maxMailboxMessageBytes = positiveInteger(
146
+ options.maxMailboxMessageBytes ?? 16 * 1024,
147
+ "maxMailboxMessageBytes",
148
+ );
149
+ this.maxTaskBytes = positiveInteger(
150
+ options.maxTaskBytes ?? DEFAULT_MAX_CONTEXT_BYTES,
151
+ "maxTaskBytes",
152
+ );
153
+ this.maxTurnOutputBytes = positiveInteger(
154
+ options.maxTurnOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
155
+ "maxTurnOutputBytes",
156
+ );
157
+ this.idleTtlMs = positiveInteger(options.idleTtlMs ?? 60 * 60 * 1000, "idleTtlMs");
158
+ this.now = options.now ?? Date.now;
159
+ }
160
+
161
+ restore(records: readonly ManagedAgent[]): void {
162
+ const candidates = new Map(
163
+ records
164
+ .slice(-this.maxAgents)
165
+ .filter((record) => record.id && record.state !== "closed")
166
+ .map((record) => [record.id, record]),
167
+ );
168
+ for (const record of candidates.values()) {
169
+ if (record.parentId && !candidates.has(record.parentId)) continue;
170
+ if (record.parentId === record.id) continue;
171
+ const seen = new Set([record.id]);
172
+ let parentId = record.parentId;
173
+ let rootId = record.id;
174
+ let cyclic = false;
175
+ while (parentId) {
176
+ if (seen.has(parentId)) {
177
+ cyclic = true;
178
+ break;
179
+ }
180
+ seen.add(parentId);
181
+ rootId = parentId;
182
+ parentId = candidates.get(parentId)?.parentId;
183
+ }
184
+ const depth = seen.size - 1;
185
+ if (cyclic || depth > this.maxDepth) continue;
186
+ this.agents.set(record.id, {
187
+ ...record,
188
+ state: "idle",
189
+ rootId,
190
+ depth,
191
+ currentTask: undefined,
192
+ currentMailboxMessageIds: undefined,
193
+ children: [],
194
+ contextSourceIds: [...(record.contextSourceIds ?? [])],
195
+ mailbox: (record.mailbox ?? [])
196
+ .slice(-this.maxMailboxMessages)
197
+ .map((message) => ({ ...message, recipientId: record.id })),
198
+ history: record.history.slice(-this.maxHistoryTurns).map((turn) => ({ ...turn })),
199
+ });
200
+ }
201
+ for (const agent of this.agents.values()) {
202
+ if (!agent.parentId) continue;
203
+ const parent = this.agents.get(agent.parentId);
204
+ if (parent && !parent.children.includes(agent.id)) parent.children.push(agent.id);
205
+ }
206
+ }
207
+
208
+ async spawn(input: {
209
+ agent: string;
210
+ task: string;
211
+ cwd: string;
212
+ agentScope?: "user" | "project" | "both";
213
+ parentId?: string;
214
+ context?: string;
215
+ contextSourceIds?: string[];
216
+ contextTruncated?: boolean;
217
+ }): Promise<ManagedAgent> {
218
+ if (!input.task.trim()) throw new Error("Subagent tasks cannot be empty");
219
+ const task = truncateUtf8(input.task, this.maxTaskBytes).text;
220
+ const expired = this.evictExpired();
221
+ let expiryReleaseError: unknown;
222
+ try {
223
+ await this.releaseAgents(expired);
224
+ } catch (error) {
225
+ expiryReleaseError = error;
226
+ }
227
+ if (expired.length > 0) await this.changed();
228
+ if (expiryReleaseError) throw expiryReleaseError;
229
+ if (this.retainedCount() >= this.maxAgents) {
230
+ throw new Error(`Subagent capacity reached (${this.maxAgents})`);
231
+ }
232
+ const parent = input.parentId ? this.require(input.parentId) : undefined;
233
+ if (parent?.state === "closed") throw new Error(`Cannot spawn under closed agent ${parent.id}`);
234
+ if (parent && parent.children.length >= this.maxChildrenPerAgent) {
235
+ throw new Error(`Agent ${parent.id} child capacity reached (${this.maxChildrenPerAgent})`);
236
+ }
237
+ const depth = parent ? parent.depth + 1 : 0;
238
+ if (depth > this.maxDepth) throw new Error(`Subagent depth limit reached (${this.maxDepth})`);
239
+ const now = this.now();
240
+ const id = `sa_${randomUUID()}`;
241
+ const record: ManagedAgent = {
242
+ id,
243
+ agent: input.agent,
244
+ parentId: parent?.id,
245
+ rootId: parent?.rootId ?? id,
246
+ depth,
247
+ children: [],
248
+ state: "starting",
249
+ createdAt: now,
250
+ updatedAt: now,
251
+ cwd: input.cwd,
252
+ agentScope: input.agentScope,
253
+ currentTask: task,
254
+ history: [],
255
+ mailbox: [],
256
+ context: input.context,
257
+ contextSourceIds: input.contextSourceIds,
258
+ contextTruncated: input.contextTruncated,
259
+ };
260
+ this.agents.set(record.id, record);
261
+ if (parent) {
262
+ parent.children.push(record.id);
263
+ parent.updatedAt = now;
264
+ }
265
+ await this.changed();
266
+ this.startTurn(record, task);
267
+ return this.copy(record);
268
+ }
269
+
270
+ async followUp(id: string, task: string): Promise<ManagedAgent> {
271
+ if (!task.trim()) throw new Error("Subagent tasks cannot be empty");
272
+ const boundedTask = truncateUtf8(task, this.maxTaskBytes).text;
273
+ const agent = this.require(id);
274
+ if (!["idle", "completed", "interrupted", "failed"].includes(agent.state)) {
275
+ throw new Error(`Agent ${id} cannot accept follow-up while ${agent.state}`);
276
+ }
277
+ const unread = agent.mailbox.filter((message) => !message.readAt);
278
+ const readAt = this.now();
279
+ for (const message of unread) message.readAt = readAt;
280
+ agent.currentMailboxMessageIds = unread.map((message) => message.id);
281
+ this.startTurn(agent, boundedTask);
282
+ return this.copy(agent);
283
+ }
284
+
285
+ async sendMessage(
286
+ recipientId: string,
287
+ content: string,
288
+ senderId = "root",
289
+ deduplicationKey?: string,
290
+ ): Promise<AgentMailboxMessage> {
291
+ if (!content.trim()) throw new Error("Subagent mailbox messages cannot be empty");
292
+ if (deduplicationKey && deduplicationKey.length > 256) {
293
+ throw new Error("Subagent mailbox deduplication keys cannot exceed 256 characters");
294
+ }
295
+ const recipient = this.require(recipientId);
296
+ if (recipient.state === "closed") throw new Error(`Cannot message closed agent ${recipient.id}`);
297
+ if (senderId !== "root") {
298
+ const sender = this.require(senderId);
299
+ if (sender.state === "closed") throw new Error(`Closed agent ${sender.id} cannot send messages`);
300
+ if (sender.rootId !== recipient.rootId) {
301
+ throw new Error("Subagent mailbox messages cannot cross agent trees");
302
+ }
303
+ }
304
+ const message = this.enqueueMessage(recipient, content, senderId, deduplicationKey);
305
+ await this.changed();
306
+ return { ...message };
307
+ }
308
+
309
+ async readMessages(
310
+ id: string,
311
+ acknowledge = true,
312
+ limit = this.maxMailboxMessages,
313
+ ): Promise<AgentMailboxMessage[]> {
314
+ if (!Number.isSafeInteger(limit) || limit < 1) {
315
+ throw new Error("Subagent mailbox read limit must be a positive safe integer");
316
+ }
317
+ const agent = this.require(id);
318
+ const unread = agent.mailbox.filter((message) => !message.readAt).slice(0, limit);
319
+ if (acknowledge && unread.length > 0) {
320
+ const readAt = this.now();
321
+ for (const message of unread) message.readAt = readAt;
322
+ await this.changed();
323
+ }
324
+ return unread.map((message) => ({ ...message }));
325
+ }
326
+
327
+ async wait(
328
+ id: string,
329
+ timeoutMs = 30_000,
330
+ signal?: AbortSignal,
331
+ ): Promise<{ timedOut: boolean; agent: ManagedAgent }> {
332
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1) {
333
+ throw new Error("Subagent wait timeout must be a positive finite number");
334
+ }
335
+ if (signal?.aborted) throw waitAbortError();
336
+ const agent = this.require(id);
337
+ const running = this.running.get(id);
338
+ if (!running) return { timedOut: false, agent: this.copy(agent) };
339
+ let timer: NodeJS.Timeout | undefined;
340
+ let onAbort: (() => void) | undefined;
341
+ const timeout = new Promise<"timeout">((resolve) => {
342
+ timer = setTimeout(() => resolve("timeout"), Math.max(1, timeoutMs));
343
+ });
344
+ const aborted = new Promise<"aborted">((resolve) => {
345
+ onAbort = () => resolve("aborted");
346
+ signal?.addEventListener("abort", onAbort, { once: true });
347
+ });
348
+ const result = await Promise.race([running, timeout, aborted]);
349
+ if (timer) clearTimeout(timer);
350
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
351
+ if (result === "aborted") throw waitAbortError();
352
+ return result === "timeout"
353
+ ? { timedOut: true, agent: this.copy(this.require(id)) }
354
+ : { timedOut: false, agent: this.copy(result) };
355
+ }
356
+
357
+ async interruptTree(id: string): Promise<ManagedAgent[]> {
358
+ const results: ManagedAgent[] = [];
359
+ for (const target of this.descendants(id).reverse()) {
360
+ const agent = this.require(target);
361
+ if (agent.state === "running" || agent.state === "starting") {
362
+ results.push(await this.interrupt(target));
363
+ }
364
+ }
365
+ return results;
366
+ }
367
+
368
+ async interrupt(id: string): Promise<ManagedAgent> {
369
+ const agent = this.require(id);
370
+ if (agent.state !== "running" && agent.state !== "starting") throw new Error(`Agent ${id} is not running`);
371
+ if (agent.state === "starting") {
372
+ const index = this.queue.findIndex((entry) => entry.agent.id === id);
373
+ if (index >= 0) {
374
+ const [entry] = this.queue.splice(index, 1);
375
+ agent.state = "interrupted";
376
+ agent.currentTask = undefined;
377
+ agent.currentMailboxMessageIds = undefined;
378
+ agent.updatedAt = this.now();
379
+ const completion: AgentTurnCompletion = {
380
+ agent: this.copy(agent),
381
+ task: entry.task,
382
+ output: "",
383
+ error: "Interrupted before execution",
384
+ };
385
+ entry.resolve(agent);
386
+ this.running.delete(id);
387
+ await this.notifyTurnComplete(completion);
388
+ await this.changed();
389
+ return this.copy(agent);
390
+ }
391
+ }
392
+ this.controllers.get(id)?.abort();
393
+ await this.running.get(id);
394
+ return this.copy(this.require(id));
395
+ }
396
+
397
+ async closeTree(id: string): Promise<ManagedAgent[]> {
398
+ const results: ManagedAgent[] = [];
399
+ const failures: unknown[] = [];
400
+ for (const target of this.descendants(id).reverse()) {
401
+ const agent = this.require(target);
402
+ if (agent.state === "closed") continue;
403
+ try {
404
+ results.push(await this.close(target));
405
+ } catch (error) {
406
+ failures.push(error);
407
+ const closed = this.get(target);
408
+ if (closed?.state === "closed") results.push(closed);
409
+ }
410
+ }
411
+ if (failures.length > 0) {
412
+ throw new AggregateError(failures, `Failed to release ${failures.length} subagent(s)`);
413
+ }
414
+ return results;
415
+ }
416
+
417
+ async close(id: string): Promise<ManagedAgent> {
418
+ const agent = this.require(id);
419
+ if (agent.state === "closed") throw new Error(`Agent ${id} is already closed`);
420
+ if (agent.children.some((childId) => this.agents.get(childId)?.state !== "closed")) {
421
+ throw new Error(`Agent ${id} has active descendants; close the subtree instead`);
422
+ }
423
+ if (agent.state === "starting") {
424
+ const index = this.queue.findIndex((entry) => entry.agent.id === id);
425
+ if (index >= 0) {
426
+ const [entry] = this.queue.splice(index, 1);
427
+ entry.resolve(agent);
428
+ this.running.delete(id);
429
+ }
430
+ }
431
+ this.controllers.get(id)?.abort();
432
+ await this.running.get(id)?.catch(() => undefined);
433
+ agent.state = "closed";
434
+ agent.updatedAt = this.now();
435
+ if (agent.parentId) {
436
+ const parent = this.agents.get(agent.parentId);
437
+ if (parent) parent.children = parent.children.filter((childId) => childId !== id);
438
+ }
439
+ agent.currentTask = undefined;
440
+ agent.currentMailboxMessageIds = undefined;
441
+ let releaseError: unknown;
442
+ try {
443
+ await this.transport.release?.(this.copy(agent));
444
+ } catch (error) {
445
+ releaseError = error;
446
+ }
447
+ this.pruneClosedAgents();
448
+ await this.changed();
449
+ if (releaseError) throw releaseError;
450
+ return this.copy(agent);
451
+ }
452
+
453
+ async closeAll(): Promise<void> {
454
+ const roots = [...this.agents.values()]
455
+ .filter((agent) => agent.state !== "closed" && !agent.parentId)
456
+ .map((agent) => agent.id);
457
+ const results = await Promise.allSettled(roots.map((id) => this.closeTree(id)));
458
+ const failures = results.flatMap((result) =>
459
+ result.status === "rejected" ? [result.reason] : [],
460
+ );
461
+ if (failures.length > 0) {
462
+ throw new AggregateError(failures, `Failed to close ${failures.length} subagent tree(s)`);
463
+ }
464
+ }
465
+
466
+ async shutdown(): Promise<void> {
467
+ for (const entry of this.queue.splice(0)) {
468
+ entry.agent.state = "idle";
469
+ entry.agent.currentTask = undefined;
470
+ entry.agent.currentMailboxMessageIds = undefined;
471
+ entry.resolve(entry.agent);
472
+ this.running.delete(entry.agent.id);
473
+ }
474
+ for (const controller of this.controllers.values()) controller.abort();
475
+ await Promise.all([...this.running.values()].map((turn) => turn.catch(() => undefined)));
476
+ for (const agent of this.agents.values()) {
477
+ if (agent.state !== "closed") {
478
+ agent.state = "idle";
479
+ agent.currentTask = undefined;
480
+ agent.currentMailboxMessageIds = undefined;
481
+ }
482
+ }
483
+ let shutdownError: unknown;
484
+ try {
485
+ await this.transport.shutdown?.();
486
+ } catch (error) {
487
+ shutdownError = error;
488
+ }
489
+ await this.changed();
490
+ if (shutdownError) throw shutdownError;
491
+ }
492
+
493
+ list(includeClosed = false, rootId?: string): ManagedAgent[] {
494
+ return [...this.agents.values()]
495
+ .filter((agent) => !rootId || agent.rootId === rootId)
496
+ .filter((agent) => includeClosed || agent.state !== "closed")
497
+ .sort((a, b) => a.createdAt - b.createdAt)
498
+ .map((agent) => this.copy(agent));
499
+ }
500
+
501
+ get(id: string): ManagedAgent | undefined {
502
+ const agent = this.agents.get(id);
503
+ return agent ? this.copy(agent) : undefined;
504
+ }
505
+
506
+ async sweepExpired(): Promise<number> {
507
+ const removed = this.evictExpired();
508
+ let releaseError: unknown;
509
+ try {
510
+ await this.releaseAgents(removed);
511
+ } catch (error) {
512
+ releaseError = error;
513
+ }
514
+ if (removed.length > 0) await this.changed();
515
+ if (releaseError) throw releaseError;
516
+ return removed.length;
517
+ }
518
+
519
+ private startTurn(agent: ManagedAgent, task: string): void {
520
+ agent.state = "starting";
521
+ agent.error = undefined;
522
+ agent.currentTask = task;
523
+ agent.updatedAt = this.now();
524
+ let resolveQueued!: (agent: ManagedAgent) => void;
525
+ const completion = new Promise<ManagedAgent>((resolve) => {
526
+ resolveQueued = resolve;
527
+ });
528
+ this.running.set(agent.id, completion);
529
+ this.queue.push({ agent, task, resolve: resolveQueued });
530
+ void this.changed();
531
+ this.pumpQueue();
532
+ }
533
+
534
+ private pumpQueue(): void {
535
+ while (this.controllers.size < this.maxActiveTurns && this.queue.length > 0) {
536
+ const next = this.queue.shift();
537
+ if (!next) return;
538
+ this.runQueuedTurn(next.agent, next.task, next.resolve);
539
+ }
540
+ }
541
+
542
+ private runQueuedTurn(agent: ManagedAgent, task: string, resolveQueued: (agent: ManagedAgent) => void): void {
543
+ const controller = new AbortController();
544
+ this.controllers.set(agent.id, controller);
545
+ agent.state = "running";
546
+ agent.updatedAt = this.now();
547
+ const startedAt = this.now();
548
+ const completionKey = `completion:${agent.id}:${randomUUID()}`;
549
+ let completionContent = "";
550
+ let completionOutput = "";
551
+ let completionError: string | undefined;
552
+ void this.transport.runTurn(this.copy(agent), task, controller.signal)
553
+ .then(async (outcome) => {
554
+ const output = truncateUtf8(outcome.output, this.maxTurnOutputBytes).text;
555
+ const error = outcome.error
556
+ ? truncateUtf8(outcome.error, this.maxTurnOutputBytes).text
557
+ : undefined;
558
+ agent.history.push({
559
+ task,
560
+ output,
561
+ startedAt,
562
+ completedAt: this.now(),
563
+ exitCode: outcome.exitCode,
564
+ truncated: outcome.truncated,
565
+ });
566
+ agent.history = agent.history.slice(-this.maxHistoryTurns);
567
+ agent.state = outcome.aborted ? "interrupted" : outcome.exitCode === 0 ? "completed" : "failed";
568
+ agent.error = error;
569
+ agent.policy = outcome.policy;
570
+ completionOutput = output;
571
+ completionError = error;
572
+ completionContent = output || error || `${agent.id} ${agent.state}`;
573
+ return agent;
574
+ })
575
+ .catch((error) => {
576
+ agent.state = controller.signal.aborted ? "interrupted" : "failed";
577
+ agent.error = truncateUtf8(
578
+ error instanceof Error ? error.message : String(error),
579
+ this.maxTurnOutputBytes,
580
+ ).text;
581
+ agent.history.push({
582
+ task,
583
+ output: "",
584
+ startedAt,
585
+ completedAt: this.now(),
586
+ exitCode: controller.signal.aborted ? 130 : 1,
587
+ });
588
+ agent.history = agent.history.slice(-this.maxHistoryTurns);
589
+ completionError = agent.error;
590
+ completionContent = agent.error;
591
+ return agent;
592
+ })
593
+ .finally(async () => {
594
+ const turnCompletion: AgentTurnCompletion = {
595
+ agent: this.copy(agent),
596
+ task,
597
+ output: completionOutput,
598
+ error: completionError,
599
+ };
600
+ if (agent.parentId) {
601
+ const parent = this.agents.get(agent.parentId);
602
+ if (parent && parent.state !== "closed") {
603
+ this.enqueueMessage(parent, completionContent, agent.id, completionKey);
604
+ }
605
+ }
606
+ agent.currentTask = undefined;
607
+ agent.currentMailboxMessageIds = undefined;
608
+ agent.updatedAt = this.now();
609
+ this.controllers.delete(agent.id);
610
+ this.running.delete(agent.id);
611
+ resolveQueued(agent);
612
+ this.pumpQueue();
613
+ await this.notifyTurnComplete(turnCompletion);
614
+ await this.changed();
615
+ });
616
+ }
617
+
618
+ private enqueueMessage(
619
+ recipient: ManagedAgent,
620
+ content: string,
621
+ senderId: string,
622
+ deduplicationKey?: string,
623
+ ): AgentMailboxMessage {
624
+ if (deduplicationKey) {
625
+ const existing = recipient.mailbox.find(
626
+ (message) =>
627
+ message.deduplicationKey === deduplicationKey && message.senderId === senderId,
628
+ );
629
+ if (existing) return existing;
630
+ }
631
+ const bounded = truncateUtf8(content, this.maxMailboxMessageBytes);
632
+ const message: AgentMailboxMessage = {
633
+ id: `msg_${randomUUID()}`,
634
+ senderId,
635
+ recipientId: recipient.id,
636
+ content: bounded.text,
637
+ createdAt: this.now(),
638
+ deduplicationKey,
639
+ };
640
+ recipient.mailbox.push(message);
641
+ recipient.mailbox = recipient.mailbox.slice(-this.maxMailboxMessages);
642
+ recipient.updatedAt = this.now();
643
+ return message;
644
+ }
645
+
646
+ private descendants(id: string): string[] {
647
+ const root = this.require(id);
648
+ const result: string[] = [];
649
+ const visit = (agent: ManagedAgent) => {
650
+ result.push(agent.id);
651
+ for (const childId of agent.children) {
652
+ const child = this.agents.get(childId);
653
+ if (child) visit(child);
654
+ }
655
+ };
656
+ visit(root);
657
+ return result;
658
+ }
659
+
660
+ private require(id: string): ManagedAgent {
661
+ const agent = this.agents.get(id);
662
+ if (!agent) throw new Error(`Unknown subagent: ${id}`);
663
+ return agent;
664
+ }
665
+
666
+ private retainedCount(): number {
667
+ return [...this.agents.values()].filter((agent) => agent.state !== "closed").length;
668
+ }
669
+
670
+ private evictExpired(): ManagedAgent[] {
671
+ const cutoff = this.now() - this.idleTtlMs;
672
+ const protectedIds = new Set<string>();
673
+ for (const agent of this.agents.values()) {
674
+ if (agent.state !== "running" && agent.state !== "starting") continue;
675
+ let current: ManagedAgent | undefined = agent;
676
+ while (current) {
677
+ protectedIds.add(current.id);
678
+ current = current.parentId ? this.agents.get(current.parentId) : undefined;
679
+ }
680
+ }
681
+ const removed: ManagedAgent[] = [];
682
+ const candidates = [...this.agents.values()].sort((left, right) => right.depth - left.depth);
683
+ for (const agent of candidates) {
684
+ if (protectedIds.has(agent.id) || agent.updatedAt >= cutoff) continue;
685
+ if (agent.children.some((childId) => this.agents.get(childId)?.state !== "closed")) continue;
686
+ this.agents.delete(agent.id);
687
+ if (agent.parentId) {
688
+ const parent = this.agents.get(agent.parentId);
689
+ if (parent) parent.children = parent.children.filter((childId) => childId !== agent.id);
690
+ }
691
+ removed.push(this.copy(agent));
692
+ }
693
+ return removed;
694
+ }
695
+
696
+ private async releaseAgents(agents: readonly ManagedAgent[]): Promise<void> {
697
+ if (!this.transport.release || agents.length === 0) return;
698
+ const results = await Promise.allSettled(
699
+ agents.map((agent) => this.transport.release?.(agent)),
700
+ );
701
+ const failures = results.flatMap((result) =>
702
+ result.status === "rejected" ? [result.reason] : [],
703
+ );
704
+ if (failures.length > 0) {
705
+ throw new AggregateError(
706
+ failures,
707
+ `Failed to release ${failures.length} subagent transport session(s)`,
708
+ );
709
+ }
710
+ }
711
+
712
+ private pruneClosedAgents(): void {
713
+ const closed = [...this.agents.values()]
714
+ .filter((agent) => agent.state === "closed")
715
+ .sort((left, right) => right.updatedAt - left.updatedAt);
716
+ for (const agent of closed.slice(this.maxAgents)) this.agents.delete(agent.id);
717
+ }
718
+
719
+ private async notifyTurnComplete(completion: AgentTurnCompletion): Promise<void> {
720
+ try {
721
+ await this.options.onTurnComplete?.(completion);
722
+ } catch {
723
+ // Completion notifications are best-effort and must not destabilize agent lifecycle.
724
+ }
725
+ }
726
+
727
+ private changed(): Promise<void> {
728
+ const snapshot = this.list(true);
729
+ const next = this.changeQueue.then(async () => {
730
+ try {
731
+ await this.options.onChange?.(snapshot);
732
+ } catch {
733
+ // Persistence is best-effort; lifecycle operations must remain usable if storage fails.
734
+ }
735
+ });
736
+ this.changeQueue = next;
737
+ return next;
738
+ }
739
+
740
+ private copy(agent: ManagedAgent): ManagedAgent {
741
+ return {
742
+ ...agent,
743
+ children: [...agent.children],
744
+ contextSourceIds: [...(agent.contextSourceIds ?? [])],
745
+ currentMailboxMessageIds: agent.currentMailboxMessageIds
746
+ ? [...agent.currentMailboxMessageIds]
747
+ : undefined,
748
+ history: agent.history.map((turn) => ({ ...turn })),
749
+ mailbox: agent.mailbox.map((message) => ({ ...message })),
750
+ policy: agent.policy
751
+ ? {
752
+ inherited: [...agent.policy.inherited],
753
+ overridden: [...agent.policy.overridden],
754
+ unsupported: [...agent.policy.unsupported],
755
+ }
756
+ : undefined,
757
+ };
758
+ }
759
+ }