@morlay/dsh-subagent 0.0.5 → 0.0.7

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,1649 @@
1
+ /**
2
+ * Owns subagent startup, message admission, cold resume, and execution lifetimes.
3
+ * One child lock orders input acceptance and closure; one activation map owns
4
+ * capacity, parent relationships, and resource release across all providers.
5
+ *
6
+ * @module @deepseek-ai/dsh-subagent/manager
7
+ */
8
+
9
+ import { randomUUID } from "node:crypto";
10
+ import { isAbsolute, resolve } from "node:path";
11
+ import type {} from "@deepseek-ai/dsh-working-directory";
12
+ import type { Context } from "@deepseek-ai/cordis";
13
+ import type { Agent, AgentOptions, CreateAgentOptions } from "@deepseek-ai/dsh-agent";
14
+ import { brandString } from "@deepseek-ai/dsh-brand";
15
+ import {
16
+ ReasoningEffortId,
17
+ contentHasImage,
18
+ createUserMessage,
19
+ errorChain,
20
+ } from "@deepseek-ai/dsh-llm";
21
+ import type { ContentBlock, MessageId, MessageSource } from "@deepseek-ai/dsh-llm";
22
+ import { SessionLogOffset } from "@deepseek-ai/dsh-session";
23
+ import type { SessionId, SessionEvent, UserMessage } from "@deepseek-ai/dsh-session";
24
+ import type { SessionPersistence } from "@deepseek-ai/dsh-session-persistence";
25
+ import type { SessionObservation, SessionQueryEngine } from "@deepseek-ai/dsh-session-query";
26
+ import type { ObjectJsonSchema } from "@deepseek-ai/dsh-tools";
27
+ import {
28
+ appendDelegatedPolicyOverrides,
29
+ applyChildComposition,
30
+ captureDelegatedPolicyOverrides,
31
+ childSessionMeta,
32
+ resolveChildAgentOptions,
33
+ resolveChildDepth,
34
+ } from "./child-agent.ts";
35
+ import type { ChildComposition, DelegatedPolicyOverrides } from "./child-agent.ts";
36
+ import {
37
+ createAgentMessage,
38
+ withContinuableReturnGuidance,
39
+ createSettlementMessage,
40
+ } from "./continuation-messages.ts";
41
+ import { assertSubagentMaxDepth } from "./depth.ts";
42
+ import { foldSubagentDescriptor, snapshotSubagentDescriptor } from "./descriptor.ts";
43
+ import type { SubagentDescriptorData } from "./descriptor.ts";
44
+ import { establishCatalogChild, establishExternalCatalogChild } from "./catalog.ts";
45
+ import { SubagentError } from "./error.ts";
46
+ import { isAdjacentAgentSendMessageTool } from "./internal.ts";
47
+ import type { ActivationObserver } from "./lifecycle.ts";
48
+ import type {
49
+ ContinuableCreateRequest,
50
+ ContinuableCreateSpec,
51
+ SubagentInterruptAuthority,
52
+ SubagentActivationSpec,
53
+ SubagentActivation,
54
+ SubagentStartRequest,
55
+ SubagentRun,
56
+ SubagentSendMessageOptions,
57
+ SubagentResult,
58
+ ResolvedSubagentStartRequest,
59
+ } from "./types.ts";
60
+ import { attachStructuredRuntime } from "./structured.ts";
61
+ import type { StructuredAttachment } from "./structured.ts";
62
+ import type { SubagentDelivery } from "./control-types.ts";
63
+ import {
64
+ ActivationPool,
65
+ ChildLock,
66
+ activationAgent,
67
+ captureLocalResult,
68
+ observeLocalFailure,
69
+ requireLocalActivation,
70
+ } from "./activation.ts";
71
+ import type { Activation, ActivationResource, LocalActivation } from "./activation.ts";
72
+
73
+ /** Inputs shared by model steering and human prompt delivery. */
74
+ type ChildDeliveryOptions =
75
+ | {
76
+ readonly delivery: "steer";
77
+ /**
78
+ * A provided host source is preserved on the user message; omission attributes
79
+ * an adjacent-Agent message to the parent.
80
+ */
81
+ readonly source?: MessageSource;
82
+ readonly signal: AbortSignal;
83
+ }
84
+ | { readonly delivery: "queue"; readonly source: MessageSource; readonly signal: AbortSignal };
85
+
86
+ /** Package-private hooks supplied by the owning service. */
87
+ interface SubagentHost {
88
+ startExternal(name: string, request: ResolvedSubagentStartRequest): Promise<SubagentRun>;
89
+ /** Resolve one provider's detached continuable-creation contribution. */
90
+ prepareContinuable(
91
+ name: string,
92
+ request: ContinuableCreateRequest,
93
+ ): Promise<ContinuableCreateSpec>;
94
+ /** Build the lifecycle observer for one Activation residency epoch. */
95
+ observeActivation(provider: string, childId: SessionId, parent: Agent): ActivationObserver;
96
+ }
97
+
98
+ /** Inputs shared by fresh and resumed Activation materialization. */
99
+ interface MaterializeBase {
100
+ childId: SessionId;
101
+ provider: string;
102
+ parent: Agent;
103
+ signal: AbortSignal;
104
+ delivery?: "parent" | "caller" | undefined;
105
+ }
106
+
107
+ /** Creation inputs for a local residency or one external backend execution. */
108
+ type MaterializeInputs = MaterializeBase &
109
+ (
110
+ | {
111
+ kind: "local";
112
+ /**
113
+ * Creation inputs; absent for a cold resume, which loads the persisted
114
+ * session — including the delegation policy events a fresh creation seeded,
115
+ * so a resume never re-captures the parent's policy.
116
+ */
117
+ create?: {
118
+ /** Absolute initial directory captured before provider preparation. */
119
+ cwd: string;
120
+ seed: readonly SessionEvent[] | undefined;
121
+ meta: NonNullable<CreateAgentOptions["meta"]>;
122
+ /** Exact parent-log prefix length inside {@link seed}. */
123
+ inheritedEventCount: SessionLogOffset;
124
+ /** Policy captured at delegation: the parent's sandbox override plus the approval pin. */
125
+ delegatedPolicies: DelegatedPolicyOverrides;
126
+ /** Child-owned composition record appended after the inherited marker. */
127
+ descriptor: SubagentDescriptorData;
128
+ };
129
+ agentOptions: AgentOptions;
130
+ composition: ChildComposition;
131
+ outputSchema?: ObjectJsonSchema | undefined;
132
+ }
133
+ | { kind: "external"; start: (signal: AbortSignal) => Promise<SubagentRun> }
134
+ );
135
+
136
+ /**
137
+ * One admitted materialization and the exact live ancestry observed at its
138
+ * synchronous admission point. Retaining identities lets a scoped teardown
139
+ * keep waiting even if an intermediate Agent leaves the registry meanwhile.
140
+ */
141
+ interface Materialization {
142
+ readonly lineage: readonly Agent[];
143
+ readonly settled: Promise<void>;
144
+ readonly controller: AbortController;
145
+ }
146
+
147
+ /** Residency state observed by the natural-settlement watcher. */
148
+ type SettlementState = "closed" | "retry" | "wait" | "ready";
149
+
150
+ /** Result of the final child-lock settlement decision. */
151
+ type SettlementAttempt = Exclude<SettlementState, "ready"> | { readonly done: Promise<void> };
152
+
153
+ /** Render only the safe outer message from execution or teardown failures. */
154
+ function failureMessage(error: unknown): string {
155
+ return error instanceof Error ? error.message : "unknown teardown failure";
156
+ }
157
+
158
+ /** Own subagent requests and the resources of every live activation. */
159
+ export class SubagentManager {
160
+ /** Child session id → its live Activation. Process-local, never durable. */
161
+ private readonly resident = new Map<SessionId, Activation>();
162
+ /** Root identities retain their pool across child settlement without retaining dead roots. */
163
+ private readonly rootPools = new WeakMap<Agent, ActivationPool>();
164
+ /** Materializations admitted before drain, tracked through publication or rollback. */
165
+ private readonly materializations = new Set<Materialization>();
166
+ /** Per-child serializer shared by delivery, release, and disposal. */
167
+ private readonly locks = new ChildLock();
168
+ /** Structural Cordis owner of every Activation handle. */
169
+ private readonly ownerCtx: Context;
170
+ /**
171
+ * Exact roots whose host teardown has begun, with the live lineage members
172
+ * observed under each root. Entries remain until that exact root leaves the
173
+ * Agent registry, closing admission throughout its host's teardown without
174
+ * poisoning a later same-id replacement.
175
+ */
176
+ private readonly closingScopes = new Map<Agent, Set<Agent>>();
177
+ private draining = false;
178
+
179
+ /**
180
+ * Build one manager inside the service's Agent-injected context.
181
+ * @param ctx - context providing Agents, Sessions, and teardown ownership.
182
+ * @param host - provider dispatch and lifecycle observation owned by the service.
183
+ * @param maxActiveSubagents - the current configured child capacity.
184
+ */
185
+ constructor(
186
+ private readonly ctx: Context,
187
+ private readonly host: SubagentHost,
188
+ private readonly maxActiveSubagents: () => number,
189
+ ) {
190
+ // Ordinary Cordis owner effects unwind in reverse registration order, which
191
+ // cannot express the dynamic child graph. Register the private scope's
192
+ // structural disposer FIRST and the drain SECOND, so reverse unwind invokes
193
+ // the drain before releasing the scope; a cleanup effect on the same scope
194
+ // as the Agent handles would let structural handle disposal bypass
195
+ // child-first ordering.
196
+ const scope = ctx.plugin(function activationOwner() {});
197
+ this.ownerCtx = scope.ctx;
198
+ ctx.on("agent/disposed", ({ agent }) => {
199
+ this.closingScopes.delete(agent);
200
+ });
201
+ ctx.effect(
202
+ function* (this: SubagentManager) {
203
+ yield scope.dispose;
204
+ yield () => this.drain();
205
+ }.bind(this),
206
+ "subagents.manager()",
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Interrupt one live continuable child's current turn under the supplied authority.
212
+ * @param targetSessionId - the durable child session id to interrupt.
213
+ * @param authority - the human parent address or exact live ancestor Agent.
214
+ */
215
+ interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void {
216
+ if (authority.kind === "ancestor") {
217
+ const caller = authority.agent;
218
+ if (this.ctx.agents.get(caller.id) !== caller) {
219
+ throw new SubagentError(
220
+ `interrupting "${targetSessionId}" requires the exact live ancestor agent`,
221
+ "UNAUTHORIZED",
222
+ );
223
+ }
224
+ if (caller.id === targetSessionId) {
225
+ throw new SubagentError(`agent "${caller.id}" cannot interrupt itself`, "UNAUTHORIZED");
226
+ }
227
+ }
228
+ const activation = this.resident.get(targetSessionId);
229
+ if (activation === undefined) return;
230
+ if (authority.kind === "user") {
231
+ if (activation.parent.id !== authority.parentSessionId) {
232
+ throw new SubagentError(
233
+ `subagent "${targetSessionId}" belongs to another parent session`,
234
+ "UNAUTHORIZED",
235
+ );
236
+ }
237
+ } else if (!activation.ancestry.has(authority.agent)) {
238
+ throw new SubagentError(
239
+ `subagent "${targetSessionId}" is not a live descendant of agent "${authority.agent.id}"`,
240
+ "UNAUTHORIZED",
241
+ );
242
+ }
243
+ // Disposal already stopped the target with a whole-Activation teardown;
244
+ // a second cancel would be a redundant signal on a closing handle.
245
+ if (activation.closing !== undefined) return;
246
+ const kind = authority.kind === "user" ? "user" : "parent";
247
+ if (activation.kind === "local") {
248
+ activation.handle.agent.cancel({ kind }, { keepInbox: true });
249
+ } else {
250
+ activation.controller.abort({ kind });
251
+ // The settlement observer reports teardown failures.
252
+ void this.dispose(activation).catch(() => undefined);
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Close admission, await every already-admitted materialization through
258
+ * publication or rollback, then dispose the stable live Activation graph
259
+ * child-first.
260
+ */
261
+ async drain(): Promise<void> {
262
+ this.draining = true;
263
+ for (const pending of this.materializations) pending.controller.abort();
264
+ await Promise.all([...this.materializations].map((materialization) => materialization.settled));
265
+ const owned = new Set<SessionId>();
266
+ for (const activation of this.resident.values()) {
267
+ for (const child of activation.ownedChildren) owned.add(child);
268
+ }
269
+ const roots = [...this.resident.values()].filter(
270
+ (activation) => !owned.has(activation.childId),
271
+ );
272
+ await this.disposeRoots(roots, "activation(s)");
273
+ }
274
+
275
+ /**
276
+ * Join progressing child work, leaving idle parked inboxes resident.
277
+ * @param parent - exact live parent whose descendants are observed.
278
+ * @returns whether work was joined and the host must recheck parent completion.
279
+ */
280
+ async waitForChildren(parent: Agent): Promise<boolean> {
281
+ let observed = false;
282
+ while (true) {
283
+ const pending = [...this.materializations].filter((item) => item.lineage.includes(parent));
284
+ const children = [...this.resident.values()].filter(
285
+ (item) => activationAgent(item) !== parent && item.ancestry.has(parent),
286
+ );
287
+ observed ||= children.some(
288
+ (item) => item.kind === "local" && item.handle.agent.status !== "idle",
289
+ );
290
+ await Promise.all(
291
+ children.flatMap((item) => (item.kind === "local" ? [item.handle.agent.whenIdle()] : [])),
292
+ );
293
+ const waits: Promise<unknown>[] = pending.map((item) => item.settled);
294
+ for (const item of children) {
295
+ if (item.kind === "external" || item.closing !== undefined) {
296
+ waits.push(item.result.promise.catch(() => undefined));
297
+ continue;
298
+ }
299
+ if (item.handle.agent.status !== "idle") {
300
+ waits.push(item.handle.agent.whenIdle());
301
+ continue;
302
+ }
303
+ if (item.ownedChildren.size > 0) {
304
+ // Published descendants are observed separately; preparation holds
305
+ // keep the owner pending until publication or rollback wakes it.
306
+ if ([...item.ownedChildren].some((id) => !this.resident.has(id)))
307
+ waits.push(item.poke.promise);
308
+ continue;
309
+ }
310
+ const inbox = item.handle.agent.inbox;
311
+ if (
312
+ item.failureAt === undefined &&
313
+ (inbox.nextTurn.length > 0 || inbox.nextStep.length > 0)
314
+ )
315
+ continue;
316
+ waits.push(Promise.race([item.result.promise.catch(() => undefined), item.poke.promise]));
317
+ }
318
+ if (waits.length > 0) {
319
+ observed = true;
320
+ await Promise.all(waits);
321
+ continue;
322
+ }
323
+ // A descendant can be published while whenIdle follows another child.
324
+ if (
325
+ [...this.materializations].some(
326
+ (item) => item.lineage.includes(parent) && !pending.includes(item),
327
+ ) ||
328
+ [...this.resident.values()].some(
329
+ (item) =>
330
+ activationAgent(item) !== parent &&
331
+ item.ancestry.has(parent) &&
332
+ !children.includes(item),
333
+ )
334
+ )
335
+ continue;
336
+ return observed;
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Stop the managed descendants of exact live host-owned parents.
342
+ * @param parents - exact live roots whose managed descendants must stop.
343
+ */
344
+ async drainDescendants(parents: readonly Agent[]): Promise<void> {
345
+ const roots = new Set(parents.filter((parent) => this.ctx.agents.get(parent.id) === parent));
346
+ if (roots.size === 0) return;
347
+
348
+ for (const root of roots) {
349
+ this.closingMembers(root).add(root);
350
+ }
351
+
352
+ const targets: Activation[] = [];
353
+ for (const activation of this.resident.values()) {
354
+ const child = activationAgent(activation);
355
+ const lineage = this.liveLineage(child ?? activation.parent);
356
+ const owners = [...roots].filter((root) => child !== root && activation.ancestry.has(root));
357
+ if (owners.length === 0) continue;
358
+ targets.push(activation);
359
+ for (const owner of owners) {
360
+ const members = this.closingMembers(owner);
361
+ if (child !== undefined) members.add(child);
362
+ for (const agent of lineage) members.add(agent);
363
+ }
364
+ }
365
+ const materializations = [...this.materializations].filter((materialization) => {
366
+ const owners = [...roots].filter((root) => materialization.lineage.includes(root));
367
+ for (const owner of owners) {
368
+ const members = this.closingMembers(owner);
369
+ for (const agent of materialization.lineage) members.add(agent);
370
+ }
371
+ if (owners.length > 0) materialization.controller.abort();
372
+ return owners.length > 0;
373
+ });
374
+
375
+ const ownedTargets = new Set<SessionId>();
376
+ for (const activation of targets) {
377
+ for (const child of activation.ownedChildren) ownedTargets.add(child);
378
+ }
379
+ const targetRoots = targets.filter((activation) => !ownedTargets.has(activation.childId));
380
+
381
+ for (const activation of targets) {
382
+ const disposal = this.dispose(activation);
383
+ void disposal.catch(() => undefined);
384
+ }
385
+
386
+ await Promise.all(materializations.map((materialization) => materialization.settled));
387
+ await this.disposeRoots(targetRoots, "scoped activation(s)");
388
+ }
389
+
390
+ /**
391
+ * Release selected resident direct children of one exact live parent.
392
+ * @param parent - exact live direct parent authorizing the selected release.
393
+ * @param childIds - durable direct-child ids to release when resident.
394
+ */
395
+ async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void> {
396
+ if (this.ctx.agents.get(parent.id) !== parent) {
397
+ throw new SubagentError(
398
+ "selected child teardown requires the exact live parent agent",
399
+ "UNAUTHORIZED",
400
+ );
401
+ }
402
+ const targets: Activation[] = [];
403
+ for (const childId of new Set(childIds)) {
404
+ const activation = this.resident.get(childId);
405
+ if (activation === undefined) continue;
406
+ if (activation.parent.id !== parent.id || !activation.ancestry.has(parent)) {
407
+ throw new SubagentError(
408
+ `subagent "${childId}" is not a direct child of agent "${parent.id}"`,
409
+ "UNAUTHORIZED",
410
+ );
411
+ }
412
+ targets.push(activation);
413
+ }
414
+
415
+ for (const activation of targets) {
416
+ const disposal = this.dispose(activation);
417
+ void disposal.catch(() => undefined);
418
+ }
419
+ await this.disposeRoots(targets, "selected activation(s)");
420
+ }
421
+
422
+ /**
423
+ * Start one continuable background child and resolve at initial inbox acceptance.
424
+ * Every earlier failure disposes any created handle and rolls back Activation
425
+ * and parent ownership without returning either id.
426
+ * @param spec - provider, delegation request, and caller cancellation.
427
+ * @returns the durable child id and accepted initial prompt message id.
428
+ */
429
+ async startLocal(
430
+ spec: SubagentActivationSpec,
431
+ ): Promise<SubagentActivation & { messageId: MessageId }> {
432
+ const request = spec.request;
433
+ const parent = request.parent;
434
+ this.assertAdmitting(parent);
435
+ const persistence =
436
+ spec.delivery === "caller" ? this.ctx.get("sessionPersistence") : this.requirePersistence();
437
+ assertSubagentMaxDepth(request.maxDepth);
438
+ const childId = spec.childId ?? brandString<SessionId>(randomUUID());
439
+ this.assertChildIdAvailable(childId);
440
+ const childDepth = resolveChildDepth(parent, request.maxDepth);
441
+ // Snapshot before any await: invalid descriptor JSON rejects the call
442
+ // before a child exists, and the detached value is what reaches the log.
443
+ const agentOptions = resolveChildAgentOptions(parent, request.agentOptions, childDepth);
444
+ const agentProvider = agentOptions.provider;
445
+ const agentModel = agentOptions.model;
446
+ const agentReasoningEffort = agentOptions.reasoningEffort;
447
+ const descriptor = snapshotSubagentDescriptor({
448
+ mode: "continuable",
449
+ provider: spec.provider,
450
+ label: spec.label,
451
+ ...(agentProvider !== undefined ? { agentProvider } : {}),
452
+ ...(agentModel !== undefined ? { agentModel } : {}),
453
+ ...(agentReasoningEffort !== undefined ? { agentReasoningEffort } : {}),
454
+ });
455
+ // Capture before the first await: a later parent switch belongs to the
456
+ // parent's future, not to this child.
457
+ const delegatedPolicies = captureDelegatedPolicyOverrides(parent);
458
+
459
+ // An idle continuation-managed parent must not settle while a caller is
460
+ // still creating its child. A turn-scoped delegation does not need this,
461
+ // but the service is also callable outside a turn.
462
+ const releaseHold = this.holdOwnership(parent, childId);
463
+ try {
464
+ const cwd = await this.resolveDirectory(request, spec.signal);
465
+ const prepared = await this.host.prepareContinuable(spec.provider, {
466
+ sessionId: childId,
467
+ cwd,
468
+ parent,
469
+ signal: spec.signal,
470
+ });
471
+ spec.signal.throwIfAborted();
472
+ this.assertAdmitting(parent);
473
+
474
+ const inheritedEventCount = SessionLogOffset(prepared.seed?.length ?? 0);
475
+ const seed = prepared.seed;
476
+ let established!: Activation;
477
+ const messageId = await this.locks.run(childId, async () => {
478
+ spec.signal.throwIfAborted();
479
+ this.assertAdmitting(parent);
480
+ this.assertChildIdAvailable(childId);
481
+ if (spec.childId !== undefined) {
482
+ const persisted = await persistence?.stat(childId, { signal: spec.signal });
483
+ spec.signal.throwIfAborted();
484
+ this.assertAdmitting(parent);
485
+ this.assertChildIdAvailable(childId);
486
+ if (persisted !== undefined) {
487
+ throw new SubagentError(`subagent "${childId}" already exists`, "DUPLICATE_CHILD");
488
+ }
489
+ }
490
+ const activation = await this.materialize({
491
+ kind: "local",
492
+ childId,
493
+ provider: spec.provider,
494
+ parent,
495
+ create: {
496
+ cwd,
497
+ seed,
498
+ meta: childSessionMeta(
499
+ parent,
500
+ childDepth,
501
+ prepared.seed !== undefined,
502
+ request.agentPreset,
503
+ ),
504
+ inheritedEventCount,
505
+ delegatedPolicies,
506
+ descriptor,
507
+ },
508
+ agentOptions,
509
+ composition: { agentPreset: request.agentPreset },
510
+ signal: spec.signal,
511
+ delivery: spec.delivery,
512
+ outputSchema: spec.request.outputSchema,
513
+ });
514
+ established = activation;
515
+ const child = requireLocalActivation(activation).handle.agent;
516
+ const childHeader = child.session.header;
517
+ return await this.submitMaterialized(
518
+ activation,
519
+ spec.delivery !== "caller" &&
520
+ isAdjacentAgentSendMessageTool(this.ctx.get("tools")?.get("send_message", child))
521
+ ? withContinuableReturnGuidance(parent.id, request.prompt)
522
+ : request.prompt,
523
+ { source: { kind: "user" }, signal: spec.signal, delivery: "queue" },
524
+ parent,
525
+ () => {
526
+ establishCatalogChild(parent.session, childHeader, descriptor);
527
+ },
528
+ );
529
+ });
530
+ return { ...this.receipt(established), childId, messageId };
531
+ } catch (error: unknown) {
532
+ releaseHold();
533
+ throw error;
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Start an external backend under activation ownership.
539
+ * @param spec - task, provider, cancellation, and delivery policy.
540
+ * @returns the accepted activation and its result/disposal capabilities.
541
+ */
542
+ async startExternal(spec: SubagentActivationSpec): Promise<SubagentActivation> {
543
+ const parent = spec.request.parent;
544
+ this.assertAdmitting(parent);
545
+ const pendingId = brandString<SessionId>(randomUUID());
546
+ const releaseHold = this.holdOwnership(parent, pendingId);
547
+ let activation: Activation | undefined;
548
+ try {
549
+ const cwd = await this.resolveDirectory(spec.request, spec.signal);
550
+ activation = await this.materialize({
551
+ kind: "external",
552
+ childId: pendingId,
553
+ provider: spec.provider,
554
+ parent,
555
+ signal: spec.signal,
556
+ delivery: spec.delivery,
557
+ start: (signal) =>
558
+ this.host.startExternal(spec.provider, {
559
+ ...spec.request,
560
+ cwd,
561
+ label: spec.label,
562
+ signal,
563
+ }),
564
+ });
565
+ this.assertAdmitting(parent);
566
+ this.assertLiveParent(parent, activation.childId);
567
+ spec.signal.throwIfAborted();
568
+ if (spec.delivery === "parent") {
569
+ establishExternalCatalogChild(parent.session, activation.childId, spec.label);
570
+ }
571
+ this.announce(activation);
572
+ return this.receipt(activation);
573
+ } catch (error: unknown) {
574
+ if (activation !== undefined) {
575
+ try {
576
+ await this.dispose(activation);
577
+ } catch (cleanupError: unknown) {
578
+ this.ctx.logger.warn(
579
+ `subagent "${activation.childId}" admission rollback failed: ${failureMessage(cleanupError)}`,
580
+ );
581
+ }
582
+ }
583
+ throw error;
584
+ } finally {
585
+ releaseHold();
586
+ }
587
+ }
588
+
589
+ /** Capture the explicit or inherited directory while the parent still owns startup. */
590
+ private async resolveDirectory(
591
+ request: Pick<SubagentStartRequest, "parent" | "cwd">,
592
+ signal: AbortSignal,
593
+ ): Promise<string> {
594
+ const cwd =
595
+ request.cwd !== undefined && isAbsolute(request.cwd)
596
+ ? request.cwd
597
+ : resolve(
598
+ await this.ctx.workingDirectory.ensure(request.parent, signal),
599
+ request.cwd ?? ".",
600
+ );
601
+ signal.throwIfAborted();
602
+ return cwd;
603
+ }
604
+
605
+ /**
606
+ * Deliver one model-authored message to a direct continuable child or to the
607
+ * sender's direct parent. A missing direct child cold-resumes through the
608
+ * ordinary continuation lifecycle.
609
+ * @param sender - exact live Agent authorizing and originating the message.
610
+ * @param targetId - durable direct-parent or direct-child session id.
611
+ * @param content - model-authored content to deliver.
612
+ * @param options - caller cancellation before acceptance.
613
+ * @returns the accepted message's inbox id.
614
+ */
615
+ async sendMessage(
616
+ sender: Agent,
617
+ targetId: SessionId,
618
+ content: ContentBlock[],
619
+ options: SubagentSendMessageOptions,
620
+ ): Promise<MessageId> {
621
+ if (this.ctx.agents.get(sender.id) !== sender) {
622
+ throw new SubagentError(
623
+ "message delivery requires the exact live sender agent",
624
+ "UNAUTHORIZED",
625
+ );
626
+ }
627
+ this.assertAdmitting(sender);
628
+ const senderActivation = this.resident.get(sender.id);
629
+ if (
630
+ senderActivation !== undefined &&
631
+ senderActivation.kind === "local" &&
632
+ senderActivation.handle.agent === sender &&
633
+ senderActivation.parent.id === targetId
634
+ ) {
635
+ options.signal.throwIfAborted();
636
+ return this.sendToParent(senderActivation, sender, content);
637
+ }
638
+ if (sender.session.header.parentSession === targetId) {
639
+ throw new SubagentError(
640
+ `agent "${sender.id}" is not a resident continuable child and cannot send to parent "${targetId}"`,
641
+ "UNAUTHORIZED",
642
+ );
643
+ }
644
+ return this.deliverToChild(sender, targetId, content, {
645
+ signal: options.signal,
646
+ delivery: "steer",
647
+ });
648
+ }
649
+
650
+ /**
651
+ * Queue one human-authored prompt as a distinct direct-child turn.
652
+ * @param parent - exact live direct parent authorizing delivery.
653
+ * @param childId - durable direct-child session id.
654
+ * @param content - model-visible prompt blocks.
655
+ * @param source - durable attribution for the human prompt.
656
+ * @param signal - caller cancellation before inbox acceptance.
657
+ * @returns the accepted durable message id.
658
+ */
659
+ async queuePrompt(
660
+ parent: Agent,
661
+ childId: SessionId,
662
+ content: ContentBlock[],
663
+ source: MessageSource,
664
+ signal: AbortSignal,
665
+ ): Promise<MessageId> {
666
+ return this.deliverToChild(parent, childId, content, { source, signal, delivery: "queue" });
667
+ }
668
+
669
+ /**
670
+ * Steer one host-authored prompt to a direct continuable child.
671
+ * @param parent - exact live direct parent authorizing delivery.
672
+ * @param childId - durable direct-child session id.
673
+ * @param content - model-visible prompt blocks.
674
+ * @param source - durable attribution for the host prompt.
675
+ * @param signal - caller cancellation before inbox acceptance.
676
+ * @returns the accepted durable message id.
677
+ */
678
+ async steerPrompt(
679
+ parent: Agent,
680
+ childId: SessionId,
681
+ content: ContentBlock[],
682
+ source: MessageSource,
683
+ signal: AbortSignal,
684
+ ): Promise<MessageId> {
685
+ return this.deliverToChild(parent, childId, content, { source, signal, delivery: "steer" });
686
+ }
687
+
688
+ /** Admit local input synchronously before any teardown can begin. */
689
+ private deliver(activation: Activation, message: UserMessage, delivery: SubagentDelivery): void {
690
+ if (activation.closing !== undefined) {
691
+ throw new SubagentError(
692
+ "subagent activation is being disposed; the message was not accepted",
693
+ "ACTIVATION_CLOSING",
694
+ );
695
+ }
696
+ const local = requireLocalActivation(activation);
697
+ if (local.structured?.acceptsInput() === false) {
698
+ throw new SubagentError(
699
+ "subagent is submitting or has submitted its structured result; the message was not accepted",
700
+ "INPUT_CLOSED",
701
+ );
702
+ }
703
+ if (delivery === "steer") local.handle.agent.steer(message);
704
+ else local.handle.agent.followup(message);
705
+ }
706
+
707
+ /**
708
+ * Reject one child identity already owned by a live Agent or Session.
709
+ * @param childId - proposed durable child session id.
710
+ */
711
+ private assertChildIdAvailable(childId: SessionId): void {
712
+ if (
713
+ this.resident.has(childId) ||
714
+ this.ctx.agents.get(childId) !== undefined ||
715
+ this.ctx.get("sessions")?.get(childId) !== undefined
716
+ ) {
717
+ throw new SubagentError(`subagent "${childId}" already exists`, "DUPLICATE_CHILD");
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Pre-register `childId` in a continuation-managed parent's owned set so the
723
+ * parent cannot settle while a caller is still establishing or resuming that
724
+ * child. Returns a releaser for the failure path; it removes only a hold
725
+ * this call added, and leaves ownership in place once a live Activation for
726
+ * the child exists.
727
+ * @param parent - the live direct parent the operation is admitted under.
728
+ * @param childId - the durable child the operation addresses.
729
+ * @returns the failure-path releaser; a no-op when nothing was added.
730
+ */
731
+ private holdOwnership(parent: Agent, childId: SessionId): () => void {
732
+ const parentActivation = this.resident.get(parent.id);
733
+ if (parentActivation === undefined || activationAgent(parentActivation) !== parent)
734
+ return () => {};
735
+ if (parentActivation.closing !== undefined) {
736
+ throw new SubagentError(
737
+ `subagent parent "${parent.id}" is being disposed; the child was not established`,
738
+ "ACTIVATION_CLOSING",
739
+ );
740
+ }
741
+ if (parentActivation.ownedChildren.has(childId)) return () => {};
742
+ parentActivation.ownedChildren.add(childId);
743
+ return () => {
744
+ const live = this.resident.get(childId);
745
+ /* v8 ignore next 4 -- reaching this arm needs another delivery to establish the child
746
+ * between this operation's failure and its releaser running, which no test can schedule
747
+ * deterministically: the ownership edge then belongs to that live Activation, so the
748
+ * conservative keep leaves it for finishDisposal's releaseOwnership. */
749
+ if (live !== undefined && live.closing === undefined) return;
750
+ if (parentActivation.ownedChildren.delete(childId)) this.wake(parentActivation);
751
+ };
752
+ }
753
+
754
+ /**
755
+ * Apply activation admission to a resident parent before delivering input.
756
+ * @param parent - exact live Agent receiving the message.
757
+ * @param message - durable user message to deliver.
758
+ * @param delivery - receiving inbox destination.
759
+ */
760
+ private sendWaking(parent: Agent, message: UserMessage, delivery: SubagentDelivery): void {
761
+ const parentActivation = this.resident.get(parent.id);
762
+ if (parentActivation !== undefined && activationAgent(parentActivation) === parent) {
763
+ try {
764
+ this.deliver(parentActivation, message, delivery);
765
+ } finally {
766
+ this.wake(parentActivation);
767
+ }
768
+ return;
769
+ }
770
+ if (delivery === "steer") parent.steer(message);
771
+ else parent.followup(message);
772
+ }
773
+
774
+ /**
775
+ * Reject new admission once the manager or this exact parent tree began draining.
776
+ * @param agent - exact live Agent whose lineage determines admission.
777
+ */
778
+ private assertAdmitting(agent: Agent): void {
779
+ const closing = this.closingTeardownFor(agent);
780
+ if (closing === undefined) return;
781
+ throw new SubagentError(
782
+ closing === "manager"
783
+ ? "subagents are draining; the operation was not admitted"
784
+ : `subagents below parent "${closing.id}" are draining; the operation was not admitted`,
785
+ "DRAINING",
786
+ );
787
+ }
788
+
789
+ /**
790
+ * Authorize one operation against the durable direct-parent lineage.
791
+ * @param parent - exact live Agent claiming direct-parent authority.
792
+ * @param childId - durable child session id addressed by the operation.
793
+ * @param parentSession - durable direct-parent id recorded by the child.
794
+ */
795
+ private authorizeLineage(
796
+ parent: Agent,
797
+ childId: SessionId,
798
+ parentSession: SessionId | undefined,
799
+ ): void {
800
+ this.assertLiveParent(parent, childId);
801
+ if (parentSession !== parent.id) {
802
+ throw new SubagentError(
803
+ `subagent "${childId}" belongs to another parent session`,
804
+ "UNAUTHORIZED",
805
+ );
806
+ }
807
+ }
808
+
809
+ /** Reject parent authority after the live registry entry changes. */
810
+ private assertLiveParent(parent: Agent, childId: SessionId): void {
811
+ if (this.ctx.agents.get(parent.id) !== parent) {
812
+ throw new SubagentError(
813
+ `subagent "${childId}" delivery requires the exact live parent agent`,
814
+ "UNAUTHORIZED",
815
+ );
816
+ }
817
+ }
818
+
819
+ /**
820
+ * Create or resume one child Agent and publish its Activation.
821
+ * @param inputs - reconstruction and admission inputs for the residency epoch.
822
+ * @returns the published process-local Activation.
823
+ */
824
+ private materialize(inputs: MaterializeInputs): Promise<Activation> {
825
+ this.assertAdmitting(inputs.parent);
826
+ inputs.signal.throwIfAborted();
827
+ const lineage = this.liveLineage(inputs.parent);
828
+ const pool = this.resident.get(inputs.parent.id)?.pool ?? this.rootPool(inputs.parent);
829
+ const releaseSlot = pool.reserve(this.maxActiveSubagents());
830
+ const settled = Promise.withResolvers<void>();
831
+ const controller = new AbortController();
832
+ const materialization: Materialization = {
833
+ lineage,
834
+ settled: settled.promise,
835
+ controller,
836
+ };
837
+ this.materializations.add(materialization);
838
+ return this.materializeTracked(
839
+ { ...inputs, signal: AbortSignal.any([inputs.signal, controller.signal]) },
840
+ lineage,
841
+ pool,
842
+ releaseSlot,
843
+ )
844
+ .catch((error: unknown) => {
845
+ releaseSlot();
846
+ if (error instanceof Error && error.name === "AbortError")
847
+ this.assertAdmitting(inputs.parent);
848
+ throw error;
849
+ })
850
+ .finally(() => {
851
+ this.materializations.delete(materialization);
852
+ settled.resolve();
853
+ });
854
+ }
855
+
856
+ /**
857
+ * Stop and release one Activation through its memoized close transaction.
858
+ * @param activation - exact residency epoch to close.
859
+ * @returns the shared close transaction.
860
+ */
861
+ private dispose(activation: Activation): Promise<void> {
862
+ return this.close(activation, () => this.finishDisposal(activation, true));
863
+ }
864
+
865
+ /** Close admission synchronously and share one release, including startup rollback. */
866
+ private close(activation: Activation, release: () => Promise<void>): Promise<void> {
867
+ if (activation.closing !== undefined) return activation.closing;
868
+ const completion = Promise.withResolvers<void>();
869
+ activation.closing = completion.promise;
870
+ void release().then(completion.resolve, completion.reject);
871
+ return completion.promise;
872
+ }
873
+
874
+ /** Dispose independent roots and report every branch failure after all settle. */
875
+ private async disposeRoots(
876
+ roots: readonly Activation[],
877
+ failureSubject: "activation(s)" | "scoped activation(s)" | "selected activation(s)",
878
+ ): Promise<void> {
879
+ const failures = await Promise.all(
880
+ roots.map(async (activation) => {
881
+ try {
882
+ await this.dispose(activation);
883
+ return undefined;
884
+ } catch (error: unknown) {
885
+ return error;
886
+ }
887
+ }),
888
+ );
889
+ const reasons = failures.filter((failure) => failure !== undefined);
890
+ if (reasons.length > 0) {
891
+ throw new SubagentError(
892
+ `subagent teardown failed for ${reasons.length} ${failureSubject}: ` +
893
+ reasons.map((reason) => failureMessage(reason)).join("; "),
894
+ "ACTIVATION_TEARDOWN_FAILED",
895
+ );
896
+ }
897
+ }
898
+
899
+ /** Return the retained member set for one exact scoped-teardown root. */
900
+ private closingMembers(root: Agent): Set<Agent> {
901
+ const existing = this.closingScopes.get(root);
902
+ if (existing !== undefined) return existing;
903
+ const members = new Set<Agent>();
904
+ this.closingScopes.set(root, members);
905
+ return members;
906
+ }
907
+
908
+ /** Return the exact currently resolvable ancestry from `agent` upward. */
909
+ private liveLineage(agent: Agent): Agent[] {
910
+ const lineage = [agent];
911
+ const seen = new Set<SessionId>([agent.id]);
912
+ let parentSession = agent.session.header.parentSession;
913
+ while (parentSession !== undefined) {
914
+ const parent = this.ctx.agents.get(parentSession);
915
+ if (parent === undefined || seen.has(parent.id)) break;
916
+ lineage.push(parent);
917
+ seen.add(parent.id);
918
+ parentSession = parent.session.header.parentSession;
919
+ }
920
+ return lineage;
921
+ }
922
+
923
+ /** Return the teardown that closed continuable admission for this agent's lineage. */
924
+ private closingTeardownFor(agent: Agent): Agent | "manager" | undefined {
925
+ if (this.draining) return "manager";
926
+ const lineage = this.liveLineage(agent);
927
+ for (const [root, members] of this.closingScopes) {
928
+ if (members.has(agent) || lineage.includes(root)) return root;
929
+ }
930
+ return undefined;
931
+ }
932
+
933
+ /** Resolve a root's pool once; descendants inherit their resident parent's pool directly. */
934
+ private rootPool(parent: Agent): ActivationPool {
935
+ let pool = this.rootPools.get(parent);
936
+ if (pool === undefined) {
937
+ pool = new ActivationPool();
938
+ this.rootPools.set(parent, pool);
939
+ }
940
+ return pool;
941
+ }
942
+
943
+ /** Perform one tracked materialization through publication or rollback. */
944
+ private async materializeTracked(
945
+ inputs: MaterializeInputs,
946
+ parentLineage: readonly Agent[],
947
+ pool: ActivationPool,
948
+ releaseSlot: () => void,
949
+ ): Promise<Activation> {
950
+ const { provider, parent } = inputs;
951
+ let childId = inputs.childId;
952
+ inputs.signal.throwIfAborted();
953
+ let resource: ActivationResource;
954
+ if (inputs.kind === "external") {
955
+ const controller = new AbortController();
956
+ const cancelStartup = (): void => {
957
+ controller.abort(inputs.signal.reason);
958
+ };
959
+ inputs.signal.addEventListener("abort", cancelStartup, { once: true });
960
+ try {
961
+ const run = await inputs.start(controller.signal);
962
+ resource = { kind: "external", run, controller };
963
+ void run.result.catch(() => undefined);
964
+ childId = run.id;
965
+ try {
966
+ this.assertChildIdAvailable(childId);
967
+ } catch (error: unknown) {
968
+ await run.dispose();
969
+ throw error;
970
+ }
971
+ } finally {
972
+ inputs.signal.removeEventListener("abort", cancelStartup);
973
+ }
974
+ } else {
975
+ const { create } = inputs;
976
+ let structured: StructuredAttachment | undefined;
977
+ const setup = async (childCtx: Context, child: Agent): Promise<void> => {
978
+ // Only fresh creation appends the descriptor and delegated policy after
979
+ // the inherited marker; a cold resume replays those persisted events.
980
+ if (inputs.composition.agentPreset !== undefined) {
981
+ await applyChildComposition(childCtx, parent, inputs.composition);
982
+ }
983
+ if (create !== undefined) {
984
+ if (inputs.composition.agentPreset !== undefined) {
985
+ child.session.append("agent-preset/selected", {
986
+ agentPreset: inputs.composition.agentPreset,
987
+ });
988
+ }
989
+ child.session.append("subagent/descriptor", create.descriptor);
990
+ appendDelegatedPolicyOverrides(child.session, create.delegatedPolicies);
991
+ const workingDirectory = childCtx.get("workingDirectory");
992
+ if (workingDirectory === undefined)
993
+ throw new Error("local subagents require the working-directory service");
994
+ await workingDirectory.set(child, create.cwd, inputs.signal);
995
+ }
996
+ if (inputs.composition.agentPreset === undefined)
997
+ await applyChildComposition(childCtx, parent, inputs.composition);
998
+ if (inputs.outputSchema !== undefined) {
999
+ structured = attachStructuredRuntime(
1000
+ childCtx,
1001
+ inputs.outputSchema,
1002
+ () =>
1003
+ this.resident.get(childId)?.ownedChildren.size === 0 &&
1004
+ child.inbox.nextStep.length === 0 &&
1005
+ child.inbox.nextTurn.length === 0,
1006
+ );
1007
+ }
1008
+ };
1009
+ const handle =
1010
+ create === undefined
1011
+ ? await this.ownerCtx.agents.resume({
1012
+ resumeSessionId: childId,
1013
+ parentAgent: parent,
1014
+ agentOptions: inputs.agentOptions,
1015
+ signal: inputs.signal,
1016
+ setup,
1017
+ })
1018
+ : await this.ownerCtx.agents.create({
1019
+ sessionId: childId,
1020
+ parentAgent: parent,
1021
+ meta: create.meta,
1022
+ ...(create.seed === undefined ? {} : { seed: create.seed }),
1023
+ inheritedEventCount: create.inheritedEventCount,
1024
+ agentOptions: inputs.agentOptions,
1025
+ signal: inputs.signal,
1026
+ setup,
1027
+ });
1028
+
1029
+ resource = {
1030
+ kind: "local",
1031
+ handle,
1032
+ outputStart: handle.agent.session.seq,
1033
+ structured,
1034
+ failureAt: undefined,
1035
+ poke: Promise.withResolvers<void>(),
1036
+ };
1037
+ }
1038
+ const observer = this.host.observeActivation(provider, childId, parent);
1039
+
1040
+ const activation: Activation = {
1041
+ pool,
1042
+ releaseSlot,
1043
+ childId,
1044
+ ...resource,
1045
+ parent,
1046
+ delivery: inputs.delivery ?? "parent",
1047
+ result: Promise.withResolvers<SubagentResult>(),
1048
+ closing: undefined,
1049
+ ancestry: new WeakSet(
1050
+ resource.kind === "external" ? parentLineage : [resource.handle.agent, ...parentLineage],
1051
+ ),
1052
+ ownedChildren: new Set(),
1053
+ observer,
1054
+ announced: false,
1055
+ };
1056
+ void activation.result.promise.catch(() => undefined);
1057
+ this.resident.set(childId, activation);
1058
+ try {
1059
+ this.assertAdmitting(parent);
1060
+ inputs.signal.throwIfAborted();
1061
+ if (this.ctx.agents.get(parent.id) !== parent)
1062
+ throw new SubagentError("subagent parent is no longer live", "UNAUTHORIZED");
1063
+ this.acquireOwnership(parent, childId);
1064
+ const wakeSettlement = (): void => {
1065
+ this.wake(activation);
1066
+ };
1067
+ if (activation.kind === "local") {
1068
+ observeLocalFailure(activation, wakeSettlement);
1069
+ activation.handle.agent.ctx.on("agent/inbox/inserted", wakeSettlement);
1070
+ activation.handle.agent.ctx.on("agent/inbox/claimed", wakeSettlement);
1071
+ activation.handle.agent.ctx.on("agent/inbox/discarded", wakeSettlement);
1072
+ }
1073
+ observer.start(activationAgent(activation));
1074
+ } catch (error: unknown) {
1075
+ /* v8 ignore next -- rollback failure must not mask the admission failure
1076
+ * that prevented this operation from returning an accepted message id. */
1077
+ await this.rollbackUnpublished(activation, error).catch(() => undefined);
1078
+ throw error;
1079
+ }
1080
+ return activation;
1081
+ }
1082
+
1083
+ /**
1084
+ * Begin settlement observation after initial acceptance is recorded.
1085
+ * @param activation - accepted activation to observe.
1086
+ */
1087
+ private announce(activation: Activation): void {
1088
+ if (activation.announced) return;
1089
+ activation.announced = true;
1090
+ if (activation.kind === "local") {
1091
+ this.watchLocalSettlement(activation);
1092
+ } else {
1093
+ const finish = (): Promise<void> =>
1094
+ this.close(activation, () => this.finishDisposal(activation, false));
1095
+ void activation.run.result.then(finish, finish).catch((error: unknown) => {
1096
+ this.reportTeardownFailure(activation, error);
1097
+ });
1098
+ }
1099
+ }
1100
+
1101
+ /** Release an Activation whose start edge was not published. */
1102
+ private rollbackUnpublished(activation: Activation, error: unknown): Promise<void> {
1103
+ return this.close(activation, async () => {
1104
+ try {
1105
+ await (activation.kind === "local"
1106
+ ? activation.handle.dispose()
1107
+ : activation.run.dispose());
1108
+ } finally {
1109
+ this.resident.delete(activation.childId);
1110
+ activation.releaseSlot();
1111
+ this.releaseOwnership(activation.childId);
1112
+ activation.result.reject(error);
1113
+ }
1114
+ });
1115
+ }
1116
+
1117
+ /** Register the child in a continuation-managed parent's owned set. */
1118
+ private acquireOwnership(parent: Agent, childId: SessionId): void {
1119
+ const parentActivation = this.resident.get(parent.id);
1120
+ if (parentActivation === undefined) return;
1121
+ if (parentActivation.closing !== undefined) {
1122
+ throw new SubagentError(
1123
+ `subagent parent "${parent.id}" is being disposed; the child was not established`,
1124
+ "ACTIVATION_CLOSING",
1125
+ );
1126
+ }
1127
+ parentActivation.ownedChildren.add(childId);
1128
+ this.wake(parentActivation);
1129
+ }
1130
+
1131
+ /** Remove one child from its live owner's set and let that owner re-check settlement. */
1132
+ private releaseOwnership(childId: SessionId): void {
1133
+ for (const candidate of this.resident.values()) {
1134
+ if (candidate.ownedChildren.delete(childId)) this.wake(candidate);
1135
+ }
1136
+ }
1137
+
1138
+ /** Let a settlement watcher re-check residency after relevant state changes. */
1139
+ private wake(activation: Activation): void {
1140
+ if (activation.kind === "external") return;
1141
+ activation.poke.resolve();
1142
+ activation.poke = Promise.withResolvers<void>();
1143
+ }
1144
+
1145
+ /** Follow one Activation to natural settlement. */
1146
+ private watchLocalSettlement(activation: LocalActivation): void {
1147
+ void (async () => {
1148
+ while (true) {
1149
+ const idleObservation = activation.poke;
1150
+ await activation.handle.agent.whenIdle();
1151
+ if (activation.closing !== undefined) return;
1152
+ const readiness = await this.locks.run(activation.childId, () =>
1153
+ Promise.resolve(this.settlementState(activation, idleObservation)),
1154
+ );
1155
+ if (readiness === "closed") return;
1156
+ if (readiness === "retry") continue;
1157
+ if (readiness === "wait") {
1158
+ await idleObservation.promise;
1159
+ continue;
1160
+ }
1161
+
1162
+ const finalSeq = activation.handle.agent.session.seq;
1163
+ await this.flushFinalState(activation);
1164
+ const attempt = await this.locks.run<SettlementAttempt>(activation.childId, () => {
1165
+ const state = this.settlementState(activation, idleObservation);
1166
+ if (state !== "ready") return Promise.resolve(state);
1167
+ if (activation.handle.agent.session.seq !== finalSeq) {
1168
+ return Promise.resolve("retry");
1169
+ }
1170
+ let done!: Promise<void>;
1171
+ try {
1172
+ void activation.handle.agent.runMaintenance(() => {
1173
+ done = this.close(activation, () => this.finishDisposal(activation, false));
1174
+ return Promise.resolve();
1175
+ });
1176
+ } catch (_error: unknown) {
1177
+ // Another activity already owns the Agent's idle phase.
1178
+ return Promise.resolve("retry");
1179
+ }
1180
+ return Promise.resolve({ done });
1181
+ });
1182
+
1183
+ if (typeof attempt === "string") {
1184
+ if (attempt === "closed") return;
1185
+ continue;
1186
+ }
1187
+ try {
1188
+ await attempt.done;
1189
+ } catch (error: unknown) {
1190
+ this.reportTeardownFailure(activation, error);
1191
+ }
1192
+ return;
1193
+ }
1194
+ })();
1195
+ }
1196
+
1197
+ /** Report disposal failure once after the natural settlement observer finishes. */
1198
+ private reportTeardownFailure(activation: Activation, error: unknown): void {
1199
+ this.ctx.logger.warn(
1200
+ `subagent "${activation.childId}" activation teardown failed: ${failureMessage(error)}`,
1201
+ );
1202
+ }
1203
+
1204
+ /** Check pending input and owned children before closing admission. */
1205
+ private settlementState(
1206
+ activation: LocalActivation,
1207
+ observation: PromiseWithResolvers<void>,
1208
+ ): SettlementState {
1209
+ if (activation.closing !== undefined) return "closed";
1210
+ if (activation.poke !== observation) return "retry";
1211
+ const inbox = activation.handle.agent.inbox;
1212
+ if (activation.ownedChildren.size > 0) return "wait";
1213
+ if (
1214
+ activation.failureAt === undefined &&
1215
+ (inbox.nextTurn.length > 0 || inbox.nextStep.length > 0)
1216
+ )
1217
+ return "wait";
1218
+ return "ready";
1219
+ }
1220
+
1221
+ /** Propagate stop synchronously and await descendant startup rollback before release. */
1222
+ private async finishDisposal(activation: Activation, stop: boolean): Promise<void> {
1223
+ this.wake(activation);
1224
+ const { childId } = activation;
1225
+ const failures: SubagentError[] = [];
1226
+ let result: SubagentResult = { output: [], stopReason: "error" };
1227
+ let resultFailure: { error: unknown } | undefined;
1228
+ let externalDisposal: Promise<void> | undefined;
1229
+ const child = activationAgent(activation);
1230
+ const detail = (error: unknown): string =>
1231
+ child === undefined ? failureMessage(error) : errorChain(error);
1232
+ try {
1233
+ if (activation.kind === "external") {
1234
+ if (stop) {
1235
+ activation.controller.abort({ kind: "parent" });
1236
+ // Some backends settle their result only when disposal begins.
1237
+ externalDisposal = Promise.resolve().then(() => activation.run.dispose());
1238
+ void externalDisposal.catch(() => undefined);
1239
+ }
1240
+ result = await activation.run.result;
1241
+ } else {
1242
+ if (stop) {
1243
+ activation.handle.agent.cancel({ kind: "parent" });
1244
+ const idle = activation.handle.agent.whenIdle();
1245
+ const children = [...activation.ownedChildren]
1246
+ .map((child) => this.resident.get(child))
1247
+ .filter((child): child is Activation => child !== undefined);
1248
+ const pending = [...this.materializations].filter((item) =>
1249
+ item.lineage.includes(activation.handle.agent),
1250
+ );
1251
+ const childDisposals = children.map((child) => this.dispose(child));
1252
+ for (const item of pending) item.controller.abort();
1253
+ childDisposals.push(...pending.map((item) => item.settled));
1254
+ const childFailures = await Promise.all(
1255
+ childDisposals.map(async (disposal) => {
1256
+ try {
1257
+ await disposal;
1258
+ return undefined;
1259
+ } catch (error: unknown) {
1260
+ return error;
1261
+ }
1262
+ }),
1263
+ );
1264
+ const reasons = childFailures.filter((reason) => reason !== undefined);
1265
+ if (reasons.length > 0) {
1266
+ failures.push(
1267
+ new SubagentError(
1268
+ `subagent "${childId}" child teardown failed: ${reasons.map((reason) => failureMessage(reason)).join("; ")}`,
1269
+ "ACTIVATION_TEARDOWN_FAILED",
1270
+ ),
1271
+ );
1272
+ }
1273
+ await idle;
1274
+ await this.flushFinalState(activation);
1275
+ }
1276
+ result = captureLocalResult(activation);
1277
+ }
1278
+ } catch (error: unknown) {
1279
+ resultFailure = { error };
1280
+ failures.push(
1281
+ new SubagentError(
1282
+ `subagent "${childId}" activation teardown failed: ${detail(error)}`,
1283
+ "ACTIVATION_TEARDOWN_FAILED",
1284
+ { cause: error },
1285
+ ),
1286
+ );
1287
+ }
1288
+ try {
1289
+ if (activation.kind === "local") await activation.handle.dispose();
1290
+ else await (externalDisposal ?? activation.run.dispose());
1291
+ } catch (error: unknown) {
1292
+ failures.push(
1293
+ new SubagentError(
1294
+ `subagent "${childId}" activation handle disposal failed: ${detail(error)}`,
1295
+ "ACTIVATION_TEARDOWN_FAILED",
1296
+ { cause: error },
1297
+ ),
1298
+ );
1299
+ }
1300
+
1301
+ let failure: SubagentError | undefined;
1302
+ if (failures.length === 1) {
1303
+ failure = failures[0];
1304
+ } else if (failures.length > 1) {
1305
+ failure = new SubagentError(
1306
+ `subagent "${childId}" activation teardown failed at ${failures.length} boundaries: ` +
1307
+ failures.map((item) => detail(item)).join("; "),
1308
+ "ACTIVATION_TEARDOWN_FAILED",
1309
+ { cause: new AggregateError(failures) },
1310
+ );
1311
+ }
1312
+ this.resident.delete(childId);
1313
+ activation.releaseSlot();
1314
+ const terminal: SubagentResult =
1315
+ failure === undefined ? result : { output: [], stopReason: "error" };
1316
+ this.notifySettlement(activation, result);
1317
+ this.releaseOwnership(childId);
1318
+ activation.observer.settle(terminal);
1319
+ if (resultFailure === undefined) activation.result.resolve(result);
1320
+ else activation.result.reject(resultFailure.error);
1321
+ if (failure !== undefined) throw failure;
1322
+ }
1323
+
1324
+ /** Deliver the captured execution result to the durable direct parent after cleanup. */
1325
+ private notifySettlement(activation: Activation, terminal: SubagentResult): void {
1326
+ if (!activation.announced || activation.delivery === "caller") return;
1327
+ try {
1328
+ const parent = this.ctx.agents.get(activation.parent.id);
1329
+ if (parent !== activation.parent) return;
1330
+ const message = createSettlementMessage(
1331
+ activation.childId,
1332
+ terminal,
1333
+ activationAgent(activation) !== undefined,
1334
+ );
1335
+ if (this.closingTeardownFor(parent) !== undefined) {
1336
+ parent.inject(message);
1337
+ return;
1338
+ }
1339
+ this.sendWaking(parent, message, parent.status === "idle" ? "queue" : "steer");
1340
+ } catch (error: unknown) {
1341
+ this.ctx.logger.warn(
1342
+ `subagent "${activation.childId}" settlement notice was not delivered to its parent: ` +
1343
+ errorChain(error),
1344
+ );
1345
+ }
1346
+ }
1347
+
1348
+ /** Request a best-effort final session flush before closing natural-settlement admission. */
1349
+ private async flushFinalState(activation: LocalActivation): Promise<void> {
1350
+ try {
1351
+ await activation.handle.agent.ctx.sessions.flush(activation.handle.agent.session);
1352
+ } catch (error: unknown) {
1353
+ this.ctx.logger.warn(
1354
+ `subagent "${activation.childId}" best-effort final session flush failed; ` +
1355
+ `the persisted state may be unavailable or stale on resume: ${errorChain(error)}`,
1356
+ );
1357
+ }
1358
+ }
1359
+
1360
+ /** Return capabilities bound to an exact activation instead of a future same-id instance. */
1361
+ private receipt(activation: Activation): SubagentActivation {
1362
+ return {
1363
+ childId: activation.childId,
1364
+ result: activation.result.promise,
1365
+ dispose: () => this.dispose(activation),
1366
+ };
1367
+ }
1368
+
1369
+ /** Route one parent-originated delivery through residency and cold resume. */
1370
+ private async deliverToChild(
1371
+ parent: Agent,
1372
+ childId: SessionId,
1373
+ content: ContentBlock[],
1374
+ options: ChildDeliveryOptions,
1375
+ ): Promise<MessageId> {
1376
+ this.assertAdmitting(parent);
1377
+ const releaseHold = this.holdOwnership(parent, childId);
1378
+ try {
1379
+ return await this.deliverFollowup(parent, childId, content, options);
1380
+ } catch (error: unknown) {
1381
+ releaseHold();
1382
+ throw error;
1383
+ }
1384
+ }
1385
+
1386
+ /** The delivery loop behind {@link deliverToChild}, run under the parent hold. */
1387
+ private async deliverFollowup(
1388
+ parent: Agent,
1389
+ childId: SessionId,
1390
+ content: ContentBlock[],
1391
+ options: ChildDeliveryOptions,
1392
+ ): Promise<MessageId> {
1393
+ while (true) {
1394
+ const live = await this.locks.run(childId, async () => {
1395
+ const activation = this.resident.get(childId);
1396
+ if (activation === undefined) return this.coldResume(parent, childId, content, options);
1397
+ requireLocalActivation(activation);
1398
+ const disposal = activation.closing;
1399
+ /* v8 ignore next 3 -- the send-versus-dispose cutoff needs a delivery to
1400
+ * observe the transaction inside the same critical section that opened it. */
1401
+ if (disposal !== undefined) {
1402
+ return disposal.then(
1403
+ () => undefined,
1404
+ () => undefined,
1405
+ );
1406
+ }
1407
+ if (contentHasImage(content)) {
1408
+ await this.assertImageCapable(
1409
+ requireLocalActivation(activation).handle.agent,
1410
+ options.signal,
1411
+ );
1412
+ if (activation.closing !== undefined) {
1413
+ await Promise.allSettled([activation.closing]);
1414
+ return undefined;
1415
+ }
1416
+ }
1417
+ const messageId = this.submitAdmitted(activation, content, options, parent);
1418
+ this.announce(activation);
1419
+ return messageId;
1420
+ });
1421
+ /* v8 ignore start -- only a delivery that lost the disposal cutoff retries. */
1422
+ if (live !== undefined) return live;
1423
+ this.assertAdmitting(parent);
1424
+ options.signal.throwIfAborted();
1425
+ /* v8 ignore stop */
1426
+ }
1427
+ }
1428
+
1429
+ /** Deliver one resident continuable child's message to its live direct parent. */
1430
+ private sendToParent(activation: Activation, sender: Agent, content: ContentBlock[]): MessageId {
1431
+ /* v8 ignore next 6 -- only synchronous re-entrant teardown can open this
1432
+ * transaction between exact-agent authorization and this no-await span. */
1433
+ if (activation.closing !== undefined) {
1434
+ throw new SubagentError(
1435
+ `subagent "${sender.id}" activation is being disposed; the message was not delivered`,
1436
+ "ACTIVATION_CLOSING",
1437
+ );
1438
+ }
1439
+ const parent = this.ctx.agents.get(activation.parent.id);
1440
+ if (parent !== activation.parent) {
1441
+ throw new SubagentError(
1442
+ "direct parent is not live; the message was not delivered",
1443
+ "PARENT_UNAVAILABLE",
1444
+ );
1445
+ }
1446
+ const message = createAgentMessage(sender, content);
1447
+ this.sendAgentMessage(parent, message);
1448
+ return message.id;
1449
+ }
1450
+
1451
+ /** Send one Agent message while translating only the target's own rejection. */
1452
+ private sendAgentMessage(parent: Agent, message: ReturnType<typeof createUserMessage>): void {
1453
+ try {
1454
+ this.sendWaking(parent, message, "steer");
1455
+ } catch (error: unknown) {
1456
+ throw new SubagentError(
1457
+ "direct parent is not live; the message was not delivered",
1458
+ "PARENT_UNAVAILABLE",
1459
+ { cause: error },
1460
+ );
1461
+ }
1462
+ }
1463
+
1464
+ /**
1465
+ * Cold-resume a persisted child and submit the waiting turn. The descriptor
1466
+ * supplies every reconstruction input; no subagent provider is dispatched.
1467
+ */
1468
+ private async coldResume(
1469
+ parent: Agent,
1470
+ childId: SessionId,
1471
+ content: ContentBlock[],
1472
+ options: ChildDeliveryOptions,
1473
+ ): Promise<MessageId> {
1474
+ const query = this.requireSessionQuery();
1475
+ let observation: SessionObservation;
1476
+ try {
1477
+ observation = await query.observeSession(childId, {
1478
+ signal: options.signal,
1479
+ });
1480
+ } catch (error: unknown) {
1481
+ options.signal.throwIfAborted();
1482
+ throw new SubagentError(`subagent "${childId}" is unavailable`, "NOT_RESUMABLE", {
1483
+ cause: error,
1484
+ });
1485
+ }
1486
+ using source = observation;
1487
+ this.assertAdmitting(parent);
1488
+ this.authorizeLineage(parent, childId, source.header.parentSession);
1489
+ const ownEvents = source.events.slice(source.inheritedEventCount);
1490
+ const descriptor = foldSubagentDescriptor(ownEvents);
1491
+ if (descriptor === undefined || descriptor.mode !== "continuable") {
1492
+ throw new SubagentError(
1493
+ `subagent "${childId}" has no supported continuation state and cannot be resumed; choose a different target`,
1494
+ "NOT_RESUMABLE",
1495
+ );
1496
+ }
1497
+ let activation: Activation;
1498
+ try {
1499
+ const independentlySelected = hasIndependentPresetSelection(ownEvents);
1500
+ const agentPreset = independentlySelected
1501
+ ? source.projections?.values.agentPreset
1502
+ : undefined;
1503
+ if (independentlySelected && typeof agentPreset !== "string") {
1504
+ throw new SubagentError(
1505
+ `subagent "${childId}" has no agent preset projection for restoration`,
1506
+ "NOT_RESUMABLE",
1507
+ );
1508
+ }
1509
+ activation = await this.materialize({
1510
+ kind: "local",
1511
+ childId,
1512
+ provider: descriptor.provider,
1513
+ parent,
1514
+ agentOptions: {
1515
+ ...(descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}),
1516
+ ...(descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}),
1517
+ ...(descriptor.agentReasoningEffort !== undefined
1518
+ ? { reasoningEffort: ReasoningEffortId(descriptor.agentReasoningEffort) }
1519
+ : {}),
1520
+ },
1521
+ composition: {
1522
+ agentPreset: agentPreset ?? undefined,
1523
+ persona: descriptor.persona,
1524
+ toolFilter: descriptor.toolFilter,
1525
+ },
1526
+ signal: options.signal,
1527
+ });
1528
+ } catch (error: unknown) {
1529
+ options.signal.throwIfAborted();
1530
+ if (error instanceof SubagentError) throw error;
1531
+ throw new SubagentError(`subagent "${childId}" is unavailable`, "NOT_RESUMABLE", {
1532
+ cause: error,
1533
+ });
1534
+ }
1535
+ return await this.submitMaterialized(activation, content, options, parent);
1536
+ }
1537
+
1538
+ /** Admit a materialized child, commit its creation fact, and release it on failure. */
1539
+ private async submitMaterialized(
1540
+ activation: Activation,
1541
+ content: ContentBlock[],
1542
+ options: ChildDeliveryOptions,
1543
+ parent: Agent,
1544
+ commit?: () => void,
1545
+ ): Promise<MessageId> {
1546
+ try {
1547
+ if (contentHasImage(content)) {
1548
+ await this.assertImageCapable(
1549
+ requireLocalActivation(activation).handle.agent,
1550
+ options.signal,
1551
+ );
1552
+ if (activation.closing !== undefined) {
1553
+ throw new SubagentError(
1554
+ `subagent "${activation.childId}" is closing`,
1555
+ "ACTIVATION_CLOSING",
1556
+ );
1557
+ }
1558
+ }
1559
+ const messageId = this.submitAdmitted(activation, content, options, parent);
1560
+ commit?.();
1561
+ this.announce(activation);
1562
+ return messageId;
1563
+ } catch (error: unknown) {
1564
+ try {
1565
+ await this.dispose(activation);
1566
+ } catch (cleanupError: unknown) {
1567
+ this.ctx.logger.warn(
1568
+ `subagent continuation: disposal after admission or catalog append failure also failed: ${String(cleanupError)}`,
1569
+ );
1570
+ }
1571
+ throw error;
1572
+ }
1573
+ }
1574
+
1575
+ /** Build and submit one message across the final synchronous admission cutoff. */
1576
+ private submitAdmitted(
1577
+ activation: Activation,
1578
+ content: ContentBlock[],
1579
+ options: ChildDeliveryOptions,
1580
+ parent: Agent,
1581
+ ): MessageId {
1582
+ const message =
1583
+ options.source === undefined
1584
+ ? createAgentMessage(parent, content)
1585
+ : createUserMessage({ content, source: options.source });
1586
+ options.signal.throwIfAborted();
1587
+ this.assertAdmitting(parent);
1588
+ this.authorizeLineage(parent, activation.childId, activation.parent.id);
1589
+ this.acquireOwnership(parent, activation.childId);
1590
+ try {
1591
+ this.deliver(activation, message, options.delivery);
1592
+ } finally {
1593
+ this.wake(activation);
1594
+ }
1595
+ return message.id;
1596
+ }
1597
+
1598
+ /** Refuse image content for a child whose fixed model accepts text only. */
1599
+ private async assertImageCapable(agent: Agent, signal: AbortSignal): Promise<void> {
1600
+ const { provider, model } = agent.options;
1601
+ if (provider === undefined || model === undefined) return;
1602
+ const llm = this.ctx.get("llm");
1603
+ /* v8 ignore next -- without an LLM registry, delivery defers to projection. */
1604
+ if (llm === undefined) return;
1605
+ const info = await llm.resolveModelInfo(provider, model, signal);
1606
+ if (info.inputModalities !== undefined && !info.inputModalities.includes("image")) {
1607
+ throw new SubagentError(
1608
+ `Model "${model}" does not support image input.`,
1609
+ "MODEL_DOES_NOT_SUPPORT_IMAGES",
1610
+ );
1611
+ }
1612
+ }
1613
+
1614
+ /** Resolve the persistence service continuable children require, or fail loud. */
1615
+ private requirePersistence(): SessionPersistence {
1616
+ const persistence = this.ctx.get("sessionPersistence");
1617
+ if (persistence === undefined) {
1618
+ throw new SubagentError(
1619
+ "continuable subagents require session persistence (load a dsh-session-persistence backend)",
1620
+ "PERSISTENCE_UNAVAILABLE",
1621
+ );
1622
+ }
1623
+ return persistence;
1624
+ }
1625
+
1626
+ /** Resolve the Session query service used for cold child observations. */
1627
+ private requireSessionQuery(): SessionQueryEngine {
1628
+ const query = this.ctx.get("sessionQuery");
1629
+ if (query === undefined) {
1630
+ throw new SubagentError(
1631
+ "continuable subagents require session query (load @deepseek-ai/dsh-session-query)",
1632
+ "CONTINUATION_UNAVAILABLE",
1633
+ );
1634
+ }
1635
+ return query;
1636
+ }
1637
+ }
1638
+
1639
+ export default SubagentManager;
1640
+
1641
+ /** Creation-time selection precedes the own descriptor; historical forks can select after it. */
1642
+ function hasIndependentPresetSelection(events: readonly SessionEvent[]): boolean {
1643
+ for (const event of events) {
1644
+ if (event.type === "subagent/descriptor") return false;
1645
+ if (event.type === "agent-preset/selected") return true;
1646
+ }
1647
+ /* v8 ignore next -- coldResume rejects logs without a supported own descriptor before this lookup. */
1648
+ return false;
1649
+ }