@deepseek-ai/dsh-subagent 0.1.2-alpha.5 → 0.1.3-alpha.2

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.
@@ -1,22 +1,14 @@
1
1
  /**
2
- * Internal continuable-subagent manager: stable child ids, descriptor
3
- * persistence, activation admission, the live ownership graph, cold resume,
4
- * child-first disposal, and settlement delivery to the parent, behind
5
- * `ctx.subagents`.
2
+ * Continuable-subagent orchestration behind `ctx.subagents`: stable child ids,
3
+ * descriptor persistence, provider preparation, cold resume, authorization,
4
+ * and message routing. {@link ContinuableActivationRegistry} owns the mutable
5
+ * process-local Activation graph and its settlement and disposal lifecycle.
6
6
  *
7
7
  * A continuable child has one durable Session and at most one process-local
8
- * {@link Activation} — one residency epoch for a reconstructed child Agent. An
9
- * Activation is not a request, result, cancellation, or Task boundary: it may
10
- * execute many FIFO turns and stays resident while descendants it created are
11
- * still running. The Agent inbox is the only turn queue, so this manager owns
12
- * residency while the Agent loop owns all turn ordering and execution. No
13
- * continuable path creates a Task or an intermediate result-bearing wrapper.
14
- *
15
- * Because residency is this manager's alone to end, telling the parent that a
16
- * child settled is its job too. An external `subagent/end` listener cannot do
17
- * it correctly: that payload names no parent, the child handle is already
18
- * disposed by then, and the release that wakes the parent's own settlement
19
- * watcher has already run. See {@link SubagentContinuationManager.notifySettlement}.
8
+ * Activation. The Agent inbox is the only turn queue, so this manager owns
9
+ * durable orchestration while the Agent loop owns all turn ordering and
10
+ * execution. No continuable path creates a Task or an intermediate
11
+ * result-bearing wrapper.
20
12
  *
21
13
  * @module @deepseek-ai/dsh-subagent
22
14
  */
@@ -74,109 +66,15 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
74
66
  });
75
67
  import { randomUUID } from 'node:crypto';
76
68
  import { brandString } from '@deepseek-ai/dsh-brand';
77
- import { ReasoningEffortId, boundContextSummary, contentHasImage, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm';
69
+ import { ReasoningEffortId, contentHasImage, createUserMessage } from '@deepseek-ai/dsh-llm';
78
70
  import { SessionLogOffset } from '@deepseek-ai/dsh-session';
79
- import { foldSubagentDescriptor, snapshotSubagentDescriptor } from "./descriptor.js";
80
- import { appendDelegatedPolicyOverrides, applyChildComposition, captureDelegatedPolicyOverrides, childSessionMeta, resolveChildAgentOptions, resolveChildDepth, } from "./child-agent.js";
71
+ import { childSessionMeta, captureDelegatedPolicyOverrides, resolveChildAgentOptions, resolveChildDepth, } from "./child-agent.js";
72
+ import { ContinuableActivationRegistry, } from "./continuation-activation.js";
73
+ import { createAgentMessage, withContinuableReturnGuidance, } from "./continuation-messages.js";
81
74
  import { assertSubagentMaxDepth } from "./depth.js";
82
- import { seedDescriptorTurn } from "./descriptor-seed.js";
75
+ import { foldSubagentDescriptor, snapshotSubagentDescriptor } from "./descriptor.js";
83
76
  import { SubagentError } from "./error.js";
84
77
  import { isAdjacentAgentSendMessageTool } from "./internal.js";
85
- /**
86
- * Read one Activation's current disposal transaction. This indirection exists
87
- * because TypeScript would otherwise narrow repeated reads of the mutable field
88
- * inside a long-lived closure to constants instead of re-reading runtime state.
89
- * @param activation - the Activation to inspect.
90
- * @returns the in-flight or settled disposal, or `undefined` while resident.
91
- */
92
- function disposalOf(activation) {
93
- return activation.disposal;
94
- }
95
- /** Build durable attribution for one adjacent-Agent message. */
96
- function agentMessageSource(sender) {
97
- return {
98
- kind: 'agent-message',
99
- form: 'relay',
100
- senderSessionId: sender.id,
101
- };
102
- }
103
- /** Build the model-visible and durable representation of one adjacent-Agent message. */
104
- function agentMessage(sender, content) {
105
- return createUserMessage({
106
- content: [
107
- { type: 'text', text: `Agent ${sender.id} sent a message:` },
108
- ...content,
109
- ],
110
- source: agentMessageSource(sender),
111
- });
112
- }
113
- /** Append adjacent-Agent return guidance to a continuable child's initial task. */
114
- function continuableInitialPrompt(parentId, prompt) {
115
- const encodedParentId = JSON.stringify(parentId);
116
- return [
117
- ...prompt,
118
- {
119
- type: 'text',
120
- text: `Your parent agent id is ${encodedParentId}. Before you finish, send your result to that agent with `
121
- + `send_message({ agent_id: ${encodedParentId}, message: "<self-contained result>" }). The parent shares `
122
- + 'your workspace but does not automatically receive your transcript, tool output, or reasoning. Send '
123
- + 'earlier messages as well when a finding changes what the parent should do next; sending a message '
124
- + 'does not end your turn.',
125
- },
126
- ];
127
- }
128
- /**
129
- * One line telling a parent that a background child is finished and why, in
130
- * the parent's own task vocabulary.
131
- * @param childId - the durable child the parent knows by id.
132
- * @param stopReason - how the child's last ordinary turn ended.
133
- * @returns the model-facing opening line of the settlement notice.
134
- */
135
- function settlementSummary(childId, stopReason) {
136
- const subject = `Background subagent ${childId}`;
137
- switch (stopReason) {
138
- case 'completed':
139
- return `${subject} finished and will do no further work unless you send it more.`;
140
- case 'aborted':
141
- return `${subject} was stopped before it finished.`;
142
- case 'max-tokens':
143
- return `${subject} ran out of room before it finished.`;
144
- // A pre-step rejection — a hook deny, a policy plugin — discarded input
145
- // the child had claimed, so the parent must not treat the task as done.
146
- case 'refusal':
147
- return `${subject} declined the task.`;
148
- case 'error':
149
- return `${subject} failed before it finished.`;
150
- /* v8 ignore next 4 -- `SubagentResult['stopReason']` is merge-extensible, so this arm
151
- * needs a backend that adds a variant; an unnameable ending is reported as unfinished
152
- * rather than silently as success. */
153
- default:
154
- return `${subject} ended abnormally (${String(stopReason)}) before it finished.`;
155
- }
156
- }
157
- /** Serialize each durable child's delivery, release, and disposal. */
158
- class ChildLock {
159
- tails = new Map();
160
- /**
161
- * Run `operation` after every previously queued operation for `childId`.
162
- * @param childId - the durable child whose operations are linearized.
163
- * @param operation - the critical section to run in order.
164
- * @returns the operation's own settlement.
165
- */
166
- run(childId, operation) {
167
- const previous = this.tails.get(childId) ?? Promise.resolve();
168
- const result = previous.then(operation, operation);
169
- // Absorb rejections in the chaining tail so one failed critical section
170
- // cannot reject an unrelated later caller.
171
- const tail = result.then(() => undefined, () => undefined);
172
- this.tails.set(childId, tail);
173
- void tail.then(() => {
174
- if (this.tails.get(childId) === tail)
175
- this.tails.delete(childId);
176
- });
177
- return result;
178
- }
179
- }
180
78
  /**
181
79
  * The continuable-subagent orchestration service behind `ctx.subagents`. Tool
182
80
  * schema and host adapters are consumers of this one contract; foreground
@@ -186,63 +84,27 @@ class ChildLock {
186
84
  export class SubagentContinuationManager {
187
85
  ctx;
188
86
  host;
189
- /** Child session id → its live Activation. Process-local, never durable. */
190
- activations = new Map();
191
- /** Materializations admitted before drain, tracked through publication or rollback. */
192
- materializations = new Set();
193
- locks = new ChildLock();
194
- /** Structural Cordis owner of every Activation handle. */
195
- ownerCtx;
196
- /**
197
- * Exact roots whose host teardown has begun, with the live lineage members
198
- * observed under each root. Entries remain until that exact root leaves the
199
- * Agent registry, closing admission throughout its host's teardown without
200
- * poisoning a later same-id replacement.
201
- */
202
- closingScopes = new Map();
203
- draining = false;
87
+ activations;
204
88
  constructor(ctx, host) {
205
89
  this.ctx = ctx;
206
90
  this.host = host;
207
- // Ordinary Cordis owner effects unwind in reverse registration order, which
208
- // cannot express the dynamic child graph. Register the private scope's
209
- // structural disposer FIRST and the drain SECOND, so reverse unwind invokes
210
- // the drain before releasing the scope; a cleanup effect on the same scope
211
- // as the Agent handles would let structural handle disposal bypass
212
- // child-first ordering.
213
- const scope = ctx.plugin(function activationOwner() { });
214
- this.ownerCtx = scope.ctx;
215
- ctx.on('agent/disposed', ({ agent }) => {
216
- this.closingScopes.delete(agent);
217
- });
218
- ctx.effect(function* () {
219
- yield scope.dispose;
220
- yield () => this.drain();
221
- }.bind(this), 'subagents.continuations()');
91
+ this.activations = new ContinuableActivationRegistry(ctx, (provider, childId, parent) => host.observeActivation(provider, childId, parent));
222
92
  }
223
93
  /**
224
- * Start one continuable background child: reserve its durable identity,
225
- * resolve the provider's detached creation spec, create the child Agent
226
- * through the private activation-owner scope, establish any continuable-parent
227
- * ownership, and submit the initial prompt. Resolves when inbox acceptance
228
- * yields the message id — without waiting for the turn to start or for the
229
- * message to reach the Session log.
230
- *
231
- * Every failure before that acceptance rejects without either id, disposing
232
- * any created handle and rolling back the Activation and parent ownership.
233
- * The caller signal owns lookup, materialization, and admission only until
234
- * acceptance; afterwards the manager owns the Activation independently.
94
+ * Start one continuable background child and resolve at initial inbox acceptance.
95
+ * Every earlier failure disposes any created handle and rolls back Activation
96
+ * and parent ownership without returning either id.
235
97
  * @param spec - provider, delegation request, and caller cancellation.
236
- * @returns the durable child id and the accepted initial prompt's message id.
98
+ * @returns the durable child id and accepted initial prompt message id.
237
99
  */
238
100
  async startContinuable(spec) {
239
101
  const request = spec.request;
240
102
  const parent = request.parent;
241
- this.assertAdmitting(parent);
103
+ this.activations.assertAdmitting(parent);
242
104
  const persistence = this.requirePersistence();
243
105
  assertSubagentMaxDepth(request.maxDepth);
244
106
  const childId = spec.childId ?? brandString(randomUUID());
245
- this.assertChildIdAvailable(childId);
107
+ this.activations.assertChildIdAvailable(childId);
246
108
  const childDepth = resolveChildDepth(parent, request.maxDepth);
247
109
  // Snapshot before any await: invalid descriptor JSON rejects the call
248
110
  // before a child exists, and the detached value is what reaches the log.
@@ -263,72 +125,74 @@ export class SubagentContinuationManager {
263
125
  // Capture before the first await: a later parent switch belongs to the
264
126
  // parent's future, not to this child.
265
127
  const delegatedPolicies = captureDelegatedPolicyOverrides(parent);
266
- const prepared = await this.host.prepareContinuable(spec.provider, {
267
- sessionId: childId,
268
- parent,
269
- signal: spec.signal,
270
- });
271
- spec.signal.throwIfAborted();
272
- this.assertAdmitting(parent);
273
- const inheritedEventCount = SessionLogOffset(prepared.seed?.length ?? 0);
274
- const seed = seedDescriptorTurn(childId, prepared.seed, descriptor);
275
- const messageId = await this.locks.run(childId, async () => {
128
+ // An idle continuation-managed parent must not settle while a caller is
129
+ // still creating its child. A turn-scoped delegation does not need this,
130
+ // but the service is also callable outside a turn.
131
+ const releaseHold = this.activations.holdOwnership(parent, childId);
132
+ try {
133
+ const prepared = await this.host.prepareContinuable(spec.provider, {
134
+ sessionId: childId,
135
+ parent,
136
+ signal: spec.signal,
137
+ });
276
138
  spec.signal.throwIfAborted();
277
- this.assertAdmitting(parent);
278
- this.assertChildIdAvailable(childId);
279
- if (spec.childId !== undefined) {
280
- const persisted = await persistence.listSnapshots(spec.signal);
139
+ this.activations.assertAdmitting(parent);
140
+ const inheritedEventCount = SessionLogOffset(prepared.seed?.length ?? 0);
141
+ const seed = prepared.seed;
142
+ const messageId = await this.activations.locks.run(childId, async () => {
281
143
  spec.signal.throwIfAborted();
282
- this.assertAdmitting(parent);
283
- this.assertChildIdAvailable(childId);
284
- if (persisted.some(snapshot => snapshot.header.id === childId)) {
285
- throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD');
144
+ this.activations.assertAdmitting(parent);
145
+ this.activations.assertChildIdAvailable(childId);
146
+ if (spec.childId !== undefined) {
147
+ const persisted = await persistence.stat(childId, { signal: spec.signal });
148
+ spec.signal.throwIfAborted();
149
+ this.activations.assertAdmitting(parent);
150
+ this.activations.assertChildIdAvailable(childId);
151
+ if (persisted !== undefined) {
152
+ throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD');
153
+ }
286
154
  }
287
- }
288
- const activation = await this.materialize({
289
- childId,
290
- provider: spec.provider,
291
- parent,
292
- create: {
293
- seed,
294
- meta: childSessionMeta(parent, childDepth, prepared.seed !== undefined),
295
- inheritedEventCount,
296
- delegatedPolicies,
297
- },
298
- agentOptions,
299
- composition: { persona: request.persona, toolFilter: request.toolFilter },
300
- signal: spec.signal,
155
+ const activation = await this.activations.materialize({
156
+ childId,
157
+ provider: spec.provider,
158
+ parent,
159
+ create: {
160
+ seed,
161
+ meta: childSessionMeta(parent, childDepth, prepared.seed !== undefined),
162
+ inheritedEventCount,
163
+ delegatedPolicies,
164
+ descriptor,
165
+ },
166
+ agentOptions,
167
+ composition: { persona: request.persona, toolFilter: request.toolFilter },
168
+ signal: spec.signal,
169
+ });
170
+ return this.submitMaterialized(activation, isAdjacentAgentSendMessageTool(this.ctx.get('tools')?.get('send_message', activation.handle.agent))
171
+ ? withContinuableReturnGuidance(parent.id, request.prompt)
172
+ : request.prompt, { source: { kind: 'user' }, signal: spec.signal, delivery: 'queue' }, parent);
301
173
  });
302
- return this.submitMaterialized(activation, isAdjacentAgentSendMessageTool(this.ctx.get('tools')?.get('send_message', activation.handle.agent))
303
- ? continuableInitialPrompt(parent.id, request.prompt)
304
- : request.prompt, { source: { kind: 'user' }, signal: spec.signal, delivery: 'queue' }, parent);
305
- });
306
- return { childId, messageId };
307
- }
308
- /** Reject one child identity already owned by a live Agent or Session. */
309
- assertChildIdAvailable(childId) {
310
- if (this.ctx.agents.get(childId) !== undefined || this.ctx.get('sessions')?.get(childId) !== undefined) {
311
- throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD');
174
+ return { childId, messageId };
175
+ }
176
+ catch (error) {
177
+ releaseHold();
178
+ throw error;
312
179
  }
313
180
  }
314
181
  /**
315
182
  * Deliver one model-authored message to a direct continuable child or to the
316
- * sender's direct parent. Both directions use Steer: a running target admits
317
- * the message at its nearest step boundary, while an idle target starts a
318
- * turn. A missing direct child cold-resumes through the ordinary continuation
319
- * lifecycle. The caller signal owns the operation only until inbox acceptance.
183
+ * sender's direct parent. A missing direct child cold-resumes through the
184
+ * ordinary continuation lifecycle.
320
185
  * @param sender - exact live Agent authorizing and originating the message.
321
186
  * @param targetId - durable direct-parent or direct-child session id.
322
187
  * @param content - model-authored content to deliver.
323
188
  * @param options - caller cancellation before acceptance.
324
189
  * @returns the accepted message's inbox id.
325
- * @throws when adjacency, availability, or admission rejects delivery.
326
190
  */
327
191
  async sendMessage(sender, targetId, content, options) {
328
192
  if (this.ctx.agents.get(sender.id) !== sender) {
329
193
  throw new SubagentError('message delivery requires the exact live sender agent', 'UNAUTHORIZED');
330
194
  }
331
- this.assertAdmitting(sender);
195
+ this.activations.assertAdmitting(sender);
332
196
  const senderActivation = this.activations.get(sender.id);
333
197
  if (senderActivation !== undefined
334
198
  && senderActivation.handle.agent === sender
@@ -348,358 +212,124 @@ export class SubagentContinuationManager {
348
212
  * Queue one human-authored prompt as a distinct direct-child turn.
349
213
  * @param parent - exact live direct parent authorizing delivery.
350
214
  * @param childId - durable direct-child session id.
351
- * @param content - human-authored content to deliver.
352
- * @param source - durable host-protocol provenance.
215
+ * @param content - model-visible prompt blocks.
216
+ * @param source - durable attribution for the human prompt.
353
217
  * @param signal - caller cancellation before inbox acceptance.
354
- * @returns the accepted message's inbox id.
218
+ * @returns the accepted durable message id.
355
219
  */
356
220
  async queuePrompt(parent, childId, content, source, signal) {
357
221
  return this.deliverToChild(parent, childId, content, { source, signal, delivery: 'queue' });
358
222
  }
223
+ /**
224
+ * Steer one host-authored prompt to a direct continuable child.
225
+ * @param parent - exact live direct parent authorizing delivery.
226
+ * @param childId - durable direct-child session id.
227
+ * @param content - model-visible prompt blocks.
228
+ * @param source - durable attribution for the host prompt.
229
+ * @param signal - caller cancellation before inbox acceptance.
230
+ * @returns the accepted durable message id.
231
+ */
232
+ async steerPrompt(parent, childId, content, source, signal) {
233
+ return this.deliverToChild(parent, childId, content, { source, signal, delivery: 'steer' });
234
+ }
359
235
  /** Route one parent-originated delivery through residency and cold resume. */
360
236
  async deliverToChild(parent, childId, content, options) {
361
- this.assertAdmitting(parent);
237
+ this.activations.assertAdmitting(parent);
238
+ const releaseHold = this.activations.holdOwnership(parent, childId);
239
+ try {
240
+ return await this.deliverFollowup(parent, childId, content, options);
241
+ }
242
+ catch (error) {
243
+ releaseHold();
244
+ throw error;
245
+ }
246
+ }
247
+ /** The delivery loop behind {@link deliverToChild}, run under the parent hold. */
248
+ async deliverFollowup(parent, childId, content, options) {
362
249
  while (true) {
363
- const live = await this.locks.run(childId, async () => {
250
+ const live = await this.activations.locks.run(childId, async () => {
364
251
  const activation = this.activations.get(childId);
365
252
  if (activation === undefined)
366
253
  return this.coldResume(parent, childId, content, options);
367
- // A delivery that arrives after the disposal transaction began must not
368
- // reach a handle being torn down; wait for release, then cold-resume.
369
- const disposal = activation.disposal;
370
- /* v8 ignore next 3 -- the send-versus-dispose cutoff: reaching this arm needs a
371
- * delivery to observe the transaction inside the same critical section that opened it,
372
- * which no test can schedule deterministically. The behavior is covered end-to-end by
373
- * "cold-resumes a delivery that lost the race with final disposal". */
254
+ const disposal = activation.inbox.closing;
255
+ /* v8 ignore next 3 -- the send-versus-dispose cutoff needs a delivery to
256
+ * observe the transaction inside the same critical section that opened it. */
374
257
  if (disposal !== undefined) {
375
258
  return disposal.then(() => undefined, () => undefined);
376
259
  }
377
- // Text-only delivery stays await-free, so the disposal-cutoff check
378
- // above and the submit share one critical window. The image path
379
- // awaits a capability read, so it re-checks the cutoff afterwards; a
380
- // disposal that began during the read is waited out and retried like
381
- // one observed on entry.
382
260
  if (contentHasImage(content)) {
383
261
  await this.assertImageCapable(activation.handle.agent, options.signal);
384
- if (activation.disposal !== undefined) {
385
- await Promise.allSettled([activation.disposal]);
262
+ if (activation.inbox.closing !== undefined) {
263
+ await Promise.allSettled([activation.inbox.closing]);
386
264
  return undefined;
387
265
  }
388
266
  }
389
267
  return this.submitAdmitted(activation, content, options, parent);
390
268
  });
391
- /* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that
392
- * race reaches the retry below, which then cold-resumes a new Activation. */
269
+ /* v8 ignore start -- only a delivery that lost the disposal cutoff retries. */
393
270
  if (live !== undefined)
394
271
  return live;
395
- this.assertAdmitting(parent);
272
+ this.activations.assertAdmitting(parent);
396
273
  options.signal.throwIfAborted();
397
274
  /* v8 ignore stop */
398
275
  }
399
276
  }
400
277
  /**
401
278
  * Interrupt one live continuable child's current turn. Admission is
402
- * synchronous and the effect is asynchronous: this authorizes the caller,
403
- * requests `Agent.cancel(cause, { keepInbox: true })` on the target, and
404
- * returns without waiting for the target to observe the signal or reach
405
- * quiescence. The Activation, its handle, accepted unclaimed inbox work, and
406
- * already-published descendants are untouched; work already claimed into the
407
- * interrupted turn is not requeued. Once the interrupted driver is idle, a
408
- * waking send resumes the parked queue.
409
- *
410
- * An absent target is an accepted no-op, which uniformly covers natural
411
- * completion races, repeated requests, one-shot ids, and unknown ids without
412
- * consulting the durable catalog. A target whose disposal transaction is
413
- * already open is likewise an accepted no-op after authorization.
279
+ * synchronous and the cancellation effect is asynchronous. An absent or
280
+ * already-closing target is an accepted no-op after authority checks.
414
281
  * @param targetSessionId - the durable child session id to interrupt.
415
282
  * @param authority - the human parent address or exact live ancestor Agent.
416
- * @throws {SubagentError} `UNAUTHORIZED` when the presented authority does
417
- * not own the live target: a stale or self-targeting ancestor caller, a
418
- * parent address that is not the live target's durable direct parent, or
419
- * an ancestor outside the target's recorded live lineage.
420
283
  */
421
284
  interrupt(targetSessionId, authority) {
422
- if (authority.kind === 'ancestor') {
423
- const caller = authority.agent;
424
- // A stale caller is rejected even when the target is absent, so a
425
- // replaced same-id Agent can never probe this manager's state.
426
- if (this.ctx.agents.get(caller.id) !== caller) {
427
- throw new SubagentError(`interrupting "${targetSessionId}" requires the exact live ancestor agent`, 'UNAUTHORIZED');
428
- }
429
- if (caller.id === targetSessionId) {
430
- throw new SubagentError(`agent "${caller.id}" cannot interrupt itself`, 'UNAUTHORIZED');
431
- }
432
- }
433
- const activation = this.activations.get(targetSessionId);
434
- if (activation === undefined)
435
- return;
436
- if (authority.kind === 'user') {
437
- if (activation.handle.agent.session.header.parentSession !== authority.parentSessionId) {
438
- throw new SubagentError(`subagent "${targetSessionId}" belongs to another parent session`, 'UNAUTHORIZED');
439
- }
440
- }
441
- else if (!activation.ancestry.has(authority.agent)) {
442
- throw new SubagentError(`subagent "${targetSessionId}" is not a live descendant of agent "${authority.agent.id}"`, 'UNAUTHORIZED');
443
- }
444
- // Disposal already stopped the target with a whole-Activation teardown;
445
- // a second cancel would be a redundant signal on a closing handle.
446
- if (activation.disposal !== undefined)
447
- return;
448
- activation.handle.agent.cancel(authority.kind === 'user' ? { kind: 'user' } : { kind: 'parent' }, { keepInbox: true });
285
+ this.activations.interrupt(targetSessionId, authority);
449
286
  }
450
287
  /** Deliver one resident continuable child's message to its live direct parent. */
451
288
  sendToParent(activation, sender, content) {
452
289
  /* v8 ignore next 6 -- only synchronous re-entrant teardown can open this
453
290
  * transaction between exact-agent authorization and this no-await span. */
454
- if (activation.disposal !== undefined) {
291
+ if (activation.inbox.closing !== undefined) {
455
292
  throw new SubagentError(`subagent "${sender.id}" activation is being disposed; the message was not delivered`, 'ACTIVATION_CLOSING');
456
293
  }
457
294
  const parent = this.ctx.agents.get(activation.parentSession);
458
295
  if (parent === undefined) {
459
296
  throw new SubagentError('direct parent is not live; the message was not delivered', 'PARENT_UNAVAILABLE');
460
297
  }
461
- const message = agentMessage(sender, content);
462
- this.sendWaking(parent, message, () => { this.sendAgentMessage(parent, message); });
298
+ const message = createAgentMessage(sender, content);
299
+ this.sendAgentMessage(parent, message);
463
300
  return message.id;
464
301
  }
465
- /**
466
- * Perform one waking send to a parent, accounted against that parent's own
467
- * Activation when it has one. Registering the id before the send is what
468
- * keeps a continuation-managed parent from being judged quiescent in the
469
- * window between a waking send and the microtask that admits it.
470
- * @param parent - the exact live parent receiving the waking message.
471
- * @param message - the message whose id is accounted.
472
- * @param send - the synchronous waking send to perform.
473
- */
474
- sendWaking(parent, message, send) {
475
- const parentActivation = this.activations.get(parent.id);
476
- if (parentActivation !== undefined && parentActivation.handle.agent === parent) {
477
- this.admitWaking(parentActivation, message.id, send);
478
- }
479
- else {
480
- send();
481
- }
482
- }
483
302
  /** Send one Agent message while translating only the target's own rejection. */
484
303
  sendAgentMessage(parent, message) {
485
304
  try {
486
- parent.steer(message);
305
+ this.activations.sendWaking(parent, message, 'steer');
487
306
  }
488
307
  catch (error) {
489
308
  throw new SubagentError('direct parent is not live; the message was not delivered', 'PARENT_UNAVAILABLE', { cause: error });
490
309
  }
491
310
  }
492
- /**
493
- * Close admission, await every already-admitted materialization through
494
- * publication or rollback, then dispose the stable live Activation forest
495
- * child-first. Sibling branches drain independently: one failure is recorded
496
- * but never prevents the remaining handles from being attempted, and the
497
- * aggregate rejects only after every branch settles.
498
- * @returns once materialization is quiescent and every live Activation released its handle.
499
- * @throws an aggregate error when any branch failed to release.
500
- */
311
+ /** Close manager-wide admission and release every live Activation. */
501
312
  async drain() {
502
- // Close admission synchronously before the first await. Materializations
503
- // already past that cutoff remain tracked until their handle is installed
504
- // or rollback completes, producing a stable forest for the later snapshot.
505
- this.draining = true;
506
- await Promise.all([...this.materializations].map(materialization => materialization.settled));
507
- // Snapshot roots after closing admission: a root is an Activation no live
508
- // Activation owns, so disposing roots recurses child-first into the forest.
509
- const owned = new Set();
510
- for (const activation of this.activations.values()) {
511
- for (const child of activation.ownedChildren)
512
- owned.add(child);
513
- }
514
- const roots = [...this.activations.values()].filter(activation => !owned.has(activation.childId));
515
- await this.disposeRoots(roots, 'activation(s)');
313
+ await this.activations.drain();
516
314
  }
517
315
  /**
518
316
  * Stop only the continuable descendants of exact live host-owned parents.
519
- * Admission stays closed for those parent trees until each exact parent
520
- * leaves the Agent registry; unrelated trees and manager-wide admission stay
521
- * live.
522
317
  * @param parents - exact live roots whose continuable descendants must stop.
523
- * @returns once every retained descendant Activation released its handle.
524
- * @throws an aggregate error after all scoped branches settle when any failed.
525
318
  */
526
319
  async drainDescendants(parents) {
527
- const roots = new Set(parents.filter(parent => this.ctx.agents.get(parent.id) === parent));
528
- if (roots.size === 0)
529
- return;
530
- // Publish the scoped admission cutoff before the first await. Merge with an
531
- // earlier call for the same exact root so a converging drain cannot forget
532
- // descendants whose release is already in flight.
533
- for (const root of roots) {
534
- this.closingMembers(root).add(root);
535
- }
536
- const targets = [];
537
- for (const activation of this.activations.values()) {
538
- const lineage = this.liveLineage(activation.handle.agent);
539
- // Strict descendants only: a continuable Agent may itself be a
540
- // host-owned root, and its host remains responsible for that root handle.
541
- const owners = [...roots].filter(root => activation.handle.agent !== root
542
- && activation.ancestry.has(root));
543
- if (owners.length === 0)
544
- continue;
545
- targets.push(activation);
546
- for (const owner of owners) {
547
- const members = this.closingMembers(owner);
548
- members.add(activation.handle.agent);
549
- for (const agent of lineage)
550
- members.add(agent);
551
- }
552
- }
553
- const materializations = [...this.materializations].filter((materialization) => {
554
- const owners = [...roots].filter(root => materialization.lineage.includes(root));
555
- for (const owner of owners) {
556
- const members = this.closingMembers(owner);
557
- for (const agent of materialization.lineage)
558
- members.add(agent);
559
- }
560
- return owners.length > 0;
561
- });
562
- const ownedTargets = new Set();
563
- for (const activation of targets) {
564
- for (const child of activation.ownedChildren)
565
- ownedTargets.add(child);
566
- }
567
- const targetRoots = targets.filter(activation => !ownedTargets.has(activation.childId));
568
- // Open every selected transaction before the materialization barrier.
569
- // Disposal propagates cancellation top-down in the same synchronous span;
570
- // handle release remains child-first.
571
- for (const activation of targets) {
572
- const disposal = this.dispose(activation);
573
- void disposal.catch(() => undefined);
574
- }
575
- await Promise.all(materializations.map(materialization => materialization.settled));
576
- await this.disposeRoots(targetRoots, 'scoped activation(s)');
320
+ await this.activations.drainDescendants(parents);
577
321
  }
578
322
  /**
579
- * Release selected resident direct children of one exact live parent without
580
- * closing admission for the parent's other continuable children. Owned
581
- * descendants are released recursively through the same lifecycle.
323
+ * Release selected resident direct children of one exact live parent.
582
324
  * @param parent - exact live direct parent authorizing the selected release.
583
325
  * @param childIds - durable direct-child ids to release when resident.
584
- * @returns once every selected Activation released its handle.
585
- * @throws {SubagentError} `UNAUTHORIZED` when a resident target is not the
586
- * parent's direct continuable child or the parent identity is stale.
587
326
  */
588
327
  async drainChildren(parent, childIds) {
589
- if (this.ctx.agents.get(parent.id) !== parent) {
590
- throw new SubagentError('selected child teardown requires the exact live parent agent', 'UNAUTHORIZED');
591
- }
592
- const targets = [];
593
- for (const childId of new Set(childIds)) {
594
- const activation = this.activations.get(childId);
595
- if (activation === undefined)
596
- continue;
597
- if (activation.parentSession !== parent.id || !activation.ancestry.has(parent)) {
598
- throw new SubagentError(`subagent "${childId}" is not a direct child of agent "${parent.id}"`, 'UNAUTHORIZED');
599
- }
600
- targets.push(activation);
601
- }
602
- // Open every transaction before the first await so cancellation propagates
603
- // across the selected roots in one synchronous span.
604
- for (const activation of targets) {
605
- const disposal = this.dispose(activation);
606
- void disposal.catch(() => undefined);
607
- }
608
- await this.disposeRoots(targets, 'selected activation(s)');
609
- }
610
- /** Dispose independent roots and report every branch failure after all settle. */
611
- async disposeRoots(roots, failureSubject) {
612
- const failures = await Promise.all(roots.map(async (activation) => {
613
- try {
614
- await this.dispose(activation);
615
- return undefined;
616
- }
617
- catch (error) {
618
- return error;
619
- }
620
- }));
621
- const reasons = failures.filter(failure => failure !== undefined);
622
- if (reasons.length > 0) {
623
- throw new SubagentError(`continuable subagent teardown failed for ${reasons.length} ${failureSubject}: `
624
- + reasons.map(reason => errorChain(reason)).join('; '), 'ACTIVATION_TEARDOWN_FAILED');
625
- }
626
- }
627
- /** Return the retained member set for one exact scoped-teardown root. */
628
- closingMembers(root) {
629
- const existing = this.closingScopes.get(root);
630
- if (existing !== undefined)
631
- return existing;
632
- const members = new Set();
633
- this.closingScopes.set(root, members);
634
- return members;
635
- }
636
- /**
637
- * Return the exact currently resolvable ancestry from `agent` upward. The
638
- * first element is always the supplied identity, even when it is already
639
- * stale; each ancestor after it must be the registry's current exact entry.
640
- */
641
- liveLineage(agent) {
642
- const lineage = [agent];
643
- const seen = new Set([agent.id]);
644
- let parentSession = agent.session.header.parentSession;
645
- while (parentSession !== undefined) {
646
- const parent = this.ctx.agents.get(parentSession);
647
- if (parent === undefined || seen.has(parent.id))
648
- break;
649
- lineage.push(parent);
650
- seen.add(parent.id);
651
- parentSession = parent.session.header.parentSession;
652
- }
653
- return lineage;
328
+ await this.activations.drainChildren(parent, childIds);
654
329
  }
655
330
  /**
656
- * The teardown that closed continuable admission for this agent's lineage.
657
- * `'manager'` is the whole manager draining; an Agent is the exact scoped root
658
- * whose forest is closing.
659
- * @param agent - the agent whose lineage is tested.
660
- * @returns the closing teardown, or `undefined` while admission is open.
661
- */
662
- closingTeardownFor(agent) {
663
- if (this.draining)
664
- return 'manager';
665
- const lineage = this.liveLineage(agent);
666
- for (const [root, members] of this.closingScopes) {
667
- if (members.has(agent) || lineage.includes(root))
668
- return root;
669
- }
670
- return undefined;
671
- }
672
- /** Reject new admission once the manager or this exact parent tree began draining. */
673
- assertAdmitting(agent) {
674
- const closing = this.closingTeardownFor(agent);
675
- if (closing === undefined)
676
- return;
677
- throw new SubagentError(closing === 'manager'
678
- ? 'continuable subagents are draining; the operation was not admitted'
679
- : `continuable subagents below parent "${closing.id}" are draining; the operation was not admitted`, 'DRAINING');
680
- }
681
- /**
682
- * Derive residency from Agent quiescence and the owned-child set. `running`
683
- * covers an active admission, an open turn, or accepted waking inbox work.
684
- *
685
- * `Agent.status` alone is insufficient: it stays `idle` between an accepted
686
- * waking send and the microtask that admits it, so a synchronous inbox
687
- * observer would see `settled` while a turn is already queued. `accepted`
688
- * holds the ids this manager admitted but has not yet seen drained.
689
- */
690
- stateOf(activation) {
691
- if (activation.handle.agent.status === 'running' || activation.accepted.size > 0)
692
- return 'running';
693
- if (activation.ownedChildren.size > 0)
694
- return 'waiting';
695
- return 'settled';
696
- }
697
- /**
698
- * Cold-resume a persisted child: retain and authorize its prepared Session, fold the
699
- * generic descriptor, create the Activation through `ctx.agents.resume()`,
700
- * and submit the waiting turn. This never dispatches through a subagent
701
- * provider — the persisted Session already holds the initial prefix and the
702
- * descriptor is the whole reconstruction input.
331
+ * Cold-resume a persisted child and submit the waiting turn. The descriptor
332
+ * supplies every reconstruction input; no subagent provider is dispatched.
703
333
  */
704
334
  async coldResume(parent, childId, content, options) {
705
335
  const env_1 = { stack: [], error: void 0, hasError: false };
@@ -716,20 +346,15 @@ export class SubagentContinuationManager {
716
346
  throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error });
717
347
  }
718
348
  const source = __addDisposableResource(env_1, observation, false);
719
- this.assertAdmitting(parent);
720
- // Authorize the persisted header before folding: only the durable child's
721
- // exact live direct parent may continue it.
722
- this.authorizeLineage(parent, childId, source.header.parentSession);
723
- // Fold only the child's own suffix: a fork seed replays the parent's log,
724
- // which may carry an ANCESTOR's descriptor when the parent is itself a
725
- // continuable child.
349
+ this.activations.assertAdmitting(parent);
350
+ this.activations.authorizeLineage(parent, childId, source.header.parentSession);
726
351
  const descriptor = foldSubagentDescriptor(source.events.slice(source.inheritedEventCount));
727
352
  if (descriptor === undefined || descriptor.mode !== 'continuable') {
728
353
  throw new SubagentError(`subagent "${childId}" has no supported continuation state and cannot be resumed; choose a different target`, 'NOT_RESUMABLE');
729
354
  }
730
355
  let activation;
731
356
  try {
732
- activation = await this.materialize({
357
+ activation = await this.activations.materialize({
733
358
  childId,
734
359
  provider: descriptor.provider,
735
360
  parent,
@@ -760,22 +385,12 @@ export class SubagentContinuationManager {
760
385
  __disposeResources(env_1);
761
386
  }
762
387
  }
763
- /**
764
- * Submit to a freshly materialized Activation or roll it back completely.
765
- * @param activation - the just-published Activation to admit or release.
766
- * @param content - the initial or resumed message content.
767
- * @param options - durable source, scheduling, and pre-acceptance cancellation.
768
- * @param parent - the live direct parent authorizing admission.
769
- * @returns the accepted inbox message id.
770
- */
388
+ /** Submit to a freshly materialized Activation or roll it back completely. */
771
389
  async submitMaterialized(activation, content, options, parent) {
772
390
  try {
773
391
  if (contentHasImage(content)) {
774
- // The capability read awaits with the activation already published, so
775
- // the disposal cutoff is re-checked before the submit; a drain that
776
- // began during the read turns into a clean closing rejection.
777
392
  await this.assertImageCapable(activation.handle.agent, options.signal);
778
- if (activation.disposal !== undefined) {
393
+ if (activation.inbox.closing !== undefined) {
779
394
  throw new SubagentError(`subagent "${activation.childId}" is closing`, 'ACTIVATION_CLOSING');
780
395
  }
781
396
  }
@@ -784,29 +399,24 @@ export class SubagentContinuationManager {
784
399
  catch (error) {
785
400
  /* v8 ignore next -- rollback disposal failures must not mask the
786
401
  * pre-acceptance signal, drain, or lifecycle failure. */
787
- await this.dispose(activation).catch(() => undefined);
402
+ await this.activations.dispose(activation).catch(() => undefined);
788
403
  throw error;
789
404
  }
790
405
  }
791
- /**
792
- * Refuse image content addressed to a child whose model accepts text only.
793
- * Callers guard with `contentHasImage`, so text-only delivery never awaits.
794
- * The check runs inside the per-child delivery lock, before the message
795
- * exists, so a rejection leaves no partial user message. When the child's
796
- * route is not fixed by its options (a request-waterfall listener owns it)
797
- * or no LLM registry is composed, delivery proceeds and the LLM layer's
798
- * text-only projection replaces each image with its stable placeholder.
799
- * @param agent - the live or freshly materialized child agent.
800
- * @param signal - caller cancellation bounding the model-info read.
801
- * @throws {SubagentError} `MODEL_DOES_NOT_SUPPORT_IMAGES` when the child's resolved model declines image input.
802
- */
406
+ /** Build and submit one message across the final synchronous admission cutoff. */
407
+ submitAdmitted(activation, content, options, parent) {
408
+ const message = options.source === undefined
409
+ ? createAgentMessage(parent, content)
410
+ : createUserMessage({ content, source: options.source });
411
+ return this.activations.submitAdmitted(activation, message, options.delivery, parent, options.signal);
412
+ }
413
+ /** Refuse image content for a child whose fixed model accepts text only. */
803
414
  async assertImageCapable(agent, signal) {
804
415
  const { provider, model } = agent.options;
805
416
  if (provider === undefined || model === undefined)
806
417
  return;
807
418
  const llm = this.ctx.get('llm');
808
- /* v8 ignore next -- a deployment without the LLM registry serves no model
809
- * to refuse against; delivery then defers to the text-only projection. */
419
+ /* v8 ignore next -- without an LLM registry, delivery defers to projection. */
810
420
  if (llm === undefined)
811
421
  return;
812
422
  const info = await llm.resolveModelInfo(provider, model, signal);
@@ -814,468 +424,6 @@ export class SubagentContinuationManager {
814
424
  throw new SubagentError(`Model "${model}" does not support image input.`, 'MODEL_DOES_NOT_SUPPORT_IMAGES');
815
425
  }
816
426
  }
817
- /**
818
- * Create or resume the child Agent through the private activation-owner
819
- * scope, install the handle in a fresh Activation, and register ownership on
820
- * a continuation-managed parent. Rejection leaves no Activation, no handle,
821
- * and no ownership membership.
822
- */
823
- materialize(inputs) {
824
- this.assertAdmitting(inputs.parent);
825
- const settled = Promise.withResolvers();
826
- const lineage = this.liveLineage(inputs.parent);
827
- const materialization = {
828
- lineage,
829
- settled: settled.promise,
830
- };
831
- this.materializations.add(materialization);
832
- return this.materializeTracked(inputs, lineage).finally(() => {
833
- this.materializations.delete(materialization);
834
- settled.resolve();
835
- });
836
- }
837
- /**
838
- * Perform one tracked materialization. The caller keeps the drain barrier
839
- * registered until this either returns a resident Activation or finishes
840
- * rollback.
841
- */
842
- async materializeTracked(inputs, parentLineage) {
843
- const { childId, provider, parent, create } = inputs;
844
- // No id pre-check here: the child lock serializes each durable child, both
845
- // callers reach this only after confirming no Activation exists, and
846
- // `AgentRegistry.enter()` is the authoritative collision boundary for an id
847
- // some other owner holds — a duplicate would reject there with rollback.
848
- inputs.signal.throwIfAborted();
849
- const setup = (childCtx) => {
850
- // Only fresh creation seeds the delegation policy onto the child's own
851
- // log (after any fork seed, so fresh policy wins stale seed state); a
852
- // cold resume replays those persisted events instead.
853
- if (create !== undefined) {
854
- appendDelegatedPolicyOverrides(childCtx.agent.session, create.delegatedPolicies);
855
- }
856
- applyChildComposition(childCtx, parent, inputs.composition);
857
- };
858
- const observer = this.host.observeActivation(provider, childId, parent);
859
- // Agent creation owns rollback before handle transfer. A rejection leaves
860
- // no resident Activation and therefore publishes no lifecycle edge.
861
- const handle = create === undefined
862
- ? await this.ownerCtx.agents.resume({
863
- resumeSessionId: childId,
864
- agentOptions: inputs.agentOptions,
865
- signal: inputs.signal,
866
- setup,
867
- })
868
- : await this.ownerCtx.agents.create({
869
- sessionId: childId,
870
- meta: create.meta,
871
- seed: create.seed,
872
- inheritedEventCount: create.inheritedEventCount,
873
- agentOptions: inputs.agentOptions,
874
- signal: inputs.signal,
875
- setup,
876
- });
877
- const activation = {
878
- childId,
879
- // The durable lineage, not merely the caller: creation stamps this same
880
- // agent into the child's header, and cold resume authorized it against
881
- // the persisted header before materializing.
882
- parentSession: parent.id,
883
- provider,
884
- handle,
885
- ancestry: new WeakSet([handle.agent, ...parentLineage]),
886
- ownedChildren: new Set(),
887
- observer,
888
- disposal: undefined,
889
- accepted: new Set(),
890
- announced: false,
891
- poke: Promise.withResolvers(),
892
- };
893
- // After transfer, any failure must dispose the created handle, remove the
894
- // Activation, and roll back parent ownership before rejecting.
895
- this.activations.set(childId, activation);
896
- try {
897
- inputs.signal.throwIfAborted();
898
- this.assertAdmitting(parent);
899
- this.acquireOwnership(parent, childId);
900
- // Every accepted id leaves the inbox exactly once, through dequeue or
901
- // discard. Clearing it there is what lets `stateOf()` distinguish a truly
902
- // quiet Agent from one whose accepted turn has not been admitted yet.
903
- // Registered through the child's own scoped context, so scope filtering
904
- // already restricts both listeners to this exact agent.
905
- handle.agent.ctx.on('agent/inbox/claimed', ({ message }) => {
906
- /* v8 ignore next -- a claim of an id this manager never admitted needs
907
- * another sender on the same child, which no current path allows. */
908
- if (activation.accepted.delete(message.id))
909
- this.wake(activation);
910
- });
911
- handle.agent.ctx.on('agent/inbox/discarded', ({ message }) => {
912
- if (activation.accepted.delete(message.id))
913
- this.wake(activation);
914
- });
915
- // Agent creation committed setup at its publication boundary;
916
- // revocations from here on are immediate live revocation.
917
- // Publish the start edge before any turn can run, so observers see this
918
- // epoch before its first request.
919
- observer.start(handle.agent);
920
- }
921
- catch (error) {
922
- // Listener exceptions are contained by the lifecycle emitter; a start
923
- // publication throw therefore leaves no residency edge to pair.
924
- /* v8 ignore next -- rollback failure must not mask the admission failure
925
- * that prevented this operation from returning an accepted message id. */
926
- await this.rollbackUnpublished(activation).catch(() => undefined);
927
- throw error;
928
- }
929
- this.watchSettlement(activation);
930
- return activation;
931
- }
932
- /**
933
- * Release an Activation whose start edge was not published. The memoized
934
- * transaction remains in the live map until handle disposal settles, so a
935
- * concurrent drain or delivery observes the same closing boundary.
936
- */
937
- rollbackUnpublished(activation) {
938
- return (activation.disposal ??= (async () => {
939
- try {
940
- await activation.handle.dispose();
941
- }
942
- finally {
943
- this.activations.delete(activation.childId);
944
- this.releaseOwnership(activation.childId);
945
- }
946
- })());
947
- }
948
- /**
949
- * Register the child in a continuation-managed parent's owned set before the
950
- * child can run, so that parent cannot settle while the child is live. A
951
- * top-level or other non-continuation Agent has no Activation and stays
952
- * outside the waiting graph.
953
- */
954
- acquireOwnership(parent, childId) {
955
- const parentActivation = this.activations.get(parent.id);
956
- if (parentActivation === undefined)
957
- return;
958
- if (parentActivation.disposal !== undefined) {
959
- throw new SubagentError(`subagent parent "${parent.id}" is being disposed; the child was not established`, 'ACTIVATION_CLOSING');
960
- }
961
- parentActivation.ownedChildren.add(childId);
962
- }
963
- /** Remove one child from its live owner's set and let that owner re-check settlement. */
964
- releaseOwnership(childId) {
965
- for (const candidate of this.activations.values()) {
966
- if (candidate.ownedChildren.delete(childId))
967
- this.wake(candidate);
968
- }
969
- }
970
- /** Let a settlement watcher re-observe quiescence after ownership or inbox changes. */
971
- wake(activation) {
972
- activation.poke.resolve();
973
- activation.poke = Promise.withResolvers();
974
- }
975
- /**
976
- * Submit one message as the child's next FIFO turn and return its accepted
977
- * inbox id. Acceptance is the operation's success boundary; the manager owns
978
- * the Activation independently afterwards.
979
- */
980
- submit(activation, content, options, parent) {
981
- // Parent-originated delivery keeps the parent live through ownership, so
982
- // establish it before the message can enter the child's inbox.
983
- this.acquireOwnership(parent, activation.childId);
984
- const message = options.delivery === 'steer'
985
- ? agentMessage(parent, content)
986
- : createUserMessage({ content, source: options.source });
987
- const accepted = this.admitWaking(activation, message.id, () => {
988
- if (options.delivery === 'steer')
989
- activation.handle.agent.steer(message);
990
- else
991
- activation.handle.agent.followup(message);
992
- });
993
- // Past this point the caller has an id for this child, so its eventual
994
- // settlement is something the parent is owed an account of.
995
- activation.announced = true;
996
- return accepted;
997
- }
998
- /**
999
- * Account one waking send across a resident Activation's settlement window.
1000
- * @param activation - Activation receiving waking inbox work.
1001
- * @param messageId - stable identity of the message about to be sent.
1002
- * @param send - synchronous send that publishes one enqueue occurrence.
1003
- * @returns the accepted message id.
1004
- */
1005
- admitWaking(activation, messageId, send) {
1006
- // Waking Agent sends publish inbox events synchronously, so observers must
1007
- // see this Activation as busy before the call begins.
1008
- activation.accepted.add(messageId);
1009
- try {
1010
- send();
1011
- }
1012
- catch (error) {
1013
- activation.accepted.delete(messageId);
1014
- throw error;
1015
- }
1016
- // Accepted waking work keeps this Activation live until whenIdle() observes
1017
- // the complete waking suffix.
1018
- this.wake(activation);
1019
- return messageId;
1020
- }
1021
- /**
1022
- * Cross the final admission cutoff and submit without yielding. Signal abort,
1023
- * manager drain, or Activation disposal that wins before this synchronous
1024
- * span rejects without inbox acceptance.
1025
- */
1026
- submitAdmitted(activation, content, options, parent) {
1027
- options.signal.throwIfAborted();
1028
- this.assertAdmitting(parent);
1029
- /* v8 ignore next 6 -- only a synchronous re-entrant disposer can change
1030
- * this field between the caller's live check and this no-await boundary. */
1031
- if (disposalOf(activation) !== undefined) {
1032
- throw new SubagentError(`subagent "${activation.childId}" activation is being disposed; the message was not accepted`, 'ACTIVATION_CLOSING');
1033
- }
1034
- this.authorizeLineage(parent, activation.childId, activation.handle.agent.session.header.parentSession);
1035
- return this.submit(activation, content, options, parent);
1036
- }
1037
- /**
1038
- * Authorize one operation against the durable direct-parent lineage. Other
1039
- * agents, ancestors, teams, workflows, and hosts remain rejected until an
1040
- * explicit authority protocol has a production consumer.
1041
- */
1042
- authorizeLineage(parent, childId, parentSession) {
1043
- if (this.ctx.agents.get(parent.id) !== parent) {
1044
- throw new SubagentError(`subagent "${childId}" delivery requires the exact live parent agent`, 'UNAUTHORIZED');
1045
- }
1046
- if (parentSession !== parent.id) {
1047
- throw new SubagentError(`subagent "${childId}" belongs to another parent session`, 'UNAUTHORIZED');
1048
- }
1049
- }
1050
- /**
1051
- * Follow one Activation to settlement: wait for Agent quiescence, then for
1052
- * every owned child to complete disposal, and dispose the handle once both
1053
- * hold. A `next-turn` delivered while `waiting` wakes the same Agent and
1054
- * returns it to `running`, so this re-observes rather than settling early.
1055
- */
1056
- watchSettlement(activation) {
1057
- void (async () => {
1058
- while (disposalOf(activation) === undefined) {
1059
- const poked = activation.poke.promise;
1060
- await Promise.race([activation.handle.agent.whenIdle(), poked]);
1061
- if (disposalOf(activation) !== undefined)
1062
- return;
1063
- // Re-check settlement INSIDE the child lock and begin disposal in the
1064
- // same critical section, so a concurrent delivery either wins admission
1065
- // before the transaction opens or waits for release and cold-resumes.
1066
- // Deciding outside the lock would let a delivery observe a not-yet
1067
- // resident handle that this watcher is already about to tear down.
1068
- const settling = await this.locks.run(activation.childId, () => {
1069
- if (disposalOf(activation) !== undefined || this.stateOf(activation) !== 'settled') {
1070
- return Promise.resolve({ settling: false });
1071
- }
1072
- // `dispose()` assigns its memoized transaction synchronously, so
1073
- // admission is closed before this critical section releases.
1074
- return Promise.resolve({ settling: true, done: this.dispose(activation) });
1075
- });
1076
- if (!settling.settling) {
1077
- // Still running, or waiting on descendants: re-observe after the next
1078
- // accepted message or ownership release.
1079
- if (activation.handle.agent.status !== 'running')
1080
- await poked;
1081
- continue;
1082
- }
1083
- try {
1084
- await settling.done;
1085
- }
1086
- catch (error) {
1087
- this.ctx.logger.warn(`subagent "${activation.childId}" activation teardown failed: ${errorChain(error)}`);
1088
- }
1089
- return;
1090
- }
1091
- })();
1092
- }
1093
- /**
1094
- * Stop one Activation immediately, then release it child-first. The memoized
1095
- * transaction is installed before cancellation or recursive callbacks, so
1096
- * admission and reentrant teardown converge on the same owner.
1097
- *
1098
- * The final session flush is best effort and never prevents handle disposal
1099
- * or ownership release, because retaining a child would permanently pin its
1100
- * ancestors in `waiting`.
1101
- * @param activation - the residency epoch to stop and release.
1102
- * @returns the one disposal transaction owned by this Activation.
1103
- */
1104
- dispose(activation) {
1105
- const existing = activation.disposal;
1106
- if (existing !== undefined)
1107
- return existing;
1108
- const completion = Promise.withResolvers();
1109
- // Presence is the admission cutoff. Assign it before the async helper starts
1110
- // because that helper cancels Agents and may synchronously re-enter callers.
1111
- activation.disposal = completion.promise;
1112
- void this.finishDisposal(activation).then(completion.resolve, completion.reject);
1113
- return completion.promise;
1114
- }
1115
- /**
1116
- * Propagate stop synchronously, then finish the child-first release.
1117
- * @param activation - the Activation whose disposal transaction is installed.
1118
- * @returns once the handle and ownership edge are released.
1119
- */
1120
- async finishDisposal(activation) {
1121
- this.wake(activation);
1122
- const { childId } = activation;
1123
- // Stop top-down before the first await. Slow descendant cleanup may delay
1124
- // release, but it cannot let this ancestor continue model or tool work.
1125
- activation.handle.agent.cancel({ kind: 'parent' });
1126
- const idle = activation.handle.agent.whenIdle();
1127
- const children = [...activation.ownedChildren]
1128
- .map(child => this.activations.get(child))
1129
- .filter((child) => child !== undefined);
1130
- const childDisposals = children.map(child => this.dispose(child));
1131
- const failures = [];
1132
- try {
1133
- // Release remains child-first even though cancellation propagated
1134
- // top-down: every owned child completes before this handle is removed.
1135
- const childFailures = await Promise.all(childDisposals.map(async (disposal) => {
1136
- try {
1137
- await disposal;
1138
- return undefined;
1139
- }
1140
- catch (error) {
1141
- return error;
1142
- }
1143
- }));
1144
- const reasons = childFailures.filter(reason => reason !== undefined);
1145
- if (reasons.length > 0) {
1146
- failures.push(new SubagentError(`subagent "${childId}" child teardown failed: ${reasons.map(reason => errorChain(reason)).join('; ')}`, 'ACTIVATION_TEARDOWN_FAILED'));
1147
- }
1148
- // Quiesce before the flush: a turn still running would keep
1149
- // appending events the flush cannot cover.
1150
- await idle;
1151
- await this.flushFinalState(activation);
1152
- // Capture the child-dependent edge data while the child is still live:
1153
- // handle disposal unregisters it, and consumers read its log and scope.
1154
- activation.observer.capture(activation.handle.agent);
1155
- }
1156
- catch (error) {
1157
- failures.push(new SubagentError(`subagent "${childId}" activation teardown failed: ${errorChain(error)}`, 'ACTIVATION_TEARDOWN_FAILED', { cause: error }));
1158
- }
1159
- try {
1160
- await activation.handle.dispose();
1161
- }
1162
- catch (error) {
1163
- failures.push(new SubagentError(`subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`, 'ACTIVATION_TEARDOWN_FAILED', { cause: error }));
1164
- }
1165
- let failure;
1166
- if (failures.length === 1) {
1167
- failure = failures[0];
1168
- }
1169
- else if (failures.length > 1) {
1170
- failure = new SubagentError(`subagent "${childId}" activation teardown failed at ${failures.length} boundaries: `
1171
- + failures.map(item => errorChain(item)).join('; '), 'ACTIVATION_TEARDOWN_FAILED', { cause: new AggregateError(failures) });
1172
- }
1173
- // Only now is the Activation gone: keeping the entry until disposal settles
1174
- // makes a racing delivery wait for release rather than cold-resume into the
1175
- // still-registered agent.
1176
- this.activations.delete(childId);
1177
- // BEFORE releasing ownership, while the parent still counts this child and
1178
- // therefore cannot be judged settled. Delivering after the release would
1179
- // race a parent watcher that resumes one microtask later, finds itself
1180
- // childless and quiet, and disposes an Agent whose `cancel()` clears the
1181
- // inbox this notice is sitting in.
1182
- this.notifySettlement(activation, activation.observer.terminal(failure));
1183
- // Release ownership even on failure: a retained failed child would pin its
1184
- // ancestors in `waiting` forever.
1185
- this.releaseOwnership(childId);
1186
- // Emit once the disposal outcome is known, so a rejecting scoped cleanup
1187
- // cannot be reported as a successful epoch.
1188
- activation.observer.settle(failure);
1189
- if (failure !== undefined)
1190
- throw failure;
1191
- }
1192
- /**
1193
- * Tell the durable direct parent that this child produced everything it is
1194
- * going to. Unconditional for every child the caller received an id for: it
1195
- * does not consider whether the child reported, because the cases that most
1196
- * need it — a token ceiling, a model failure, cancellation, teardown — are
1197
- * exactly the ones where the child never got to choose. A materialization
1198
- * rolled back before its first acceptance stays silent, since the caller was
1199
- * told that child was not established. A parent that is no longer live is not
1200
- * an error; the child's own Session remains the durable record either way.
1201
- * A parent whose own lineage is already closing receives the notice without a
1202
- * wake, because teardown is not a reason to start a turn.
1203
- *
1204
- * Never blocks disposal. A delivery failure is logged and dropped, because
1205
- * retaining a child to retry a notice would pin its whole ancestry in
1206
- * `waiting` forever.
1207
- * @param activation - the settling Activation, still owned by its parent.
1208
- * @param terminal - how this epoch ended, as the terminal edge will report it.
1209
- */
1210
- notifySettlement(activation, terminal) {
1211
- if (!activation.announced)
1212
- return;
1213
- try {
1214
- const parent = this.ctx.agents.get(activation.parentSession);
1215
- if (parent === undefined)
1216
- return;
1217
- const summary = settlementSummary(activation.childId, terminal.stopReason);
1218
- const message = createUserMessage({
1219
- content: [
1220
- { type: 'text', text: summary },
1221
- ...terminal.output === undefined
1222
- ? [{ type: 'text', text: 'It left no closing message.' }]
1223
- : [{ type: 'text', text: 'Its closing message:' }, ...terminal.output],
1224
- ],
1225
- source: {
1226
- kind: 'subagent-settled',
1227
- form: 'notice',
1228
- summary: boundContextSummary(summary),
1229
- senderSessionId: activation.childId,
1230
- },
1231
- });
1232
- // A parent whose own teardown already began must not be woken. Waking is
1233
- // not a queue operation: `followup()` on a quiescent Agent starts a turn,
1234
- // and `cancel()` does not arm against a later one, so a notice arriving
1235
- // during teardown would spend a model request on an Agent its host is
1236
- // about to dispose — once per tree layer, since each layer's own notice
1237
- // then wakes the layer above it. Injecting delivers to a parent still
1238
- // reading its inbox and records the account in the log either way; it
1239
- // does NOT survive that parent's own disposal, whose `keepInbox: false`
1240
- // cancel durably clears whatever it never claimed.
1241
- if (this.closingTeardownFor(parent) !== undefined) {
1242
- parent.inject(message);
1243
- return;
1244
- }
1245
- // An idle parent has nothing else to look at, so it gets one ordinary
1246
- // turn. A busy parent is steered instead of woken: `Inbox.claim()` takes
1247
- // the whole next-step batch at one boundary, so several children settling
1248
- // together cost one step rather than one turn each. Steering rather than
1249
- // injecting closes the window where a driver retires between this status
1250
- // read and the send, which would strand the notice unclaimed.
1251
- this.sendWaking(parent, message, () => {
1252
- if (parent.status === 'idle')
1253
- parent.followup(message);
1254
- else
1255
- parent.steer(message);
1256
- });
1257
- }
1258
- catch (error) {
1259
- this.ctx.logger.warn(`subagent "${activation.childId}" settlement notice was not delivered to its parent: `
1260
- + errorChain(error));
1261
- }
1262
- }
1263
- /**
1264
- * Request a best-effort final session flush after the child is quiescent.
1265
- * Listener failure is logged because flush participation cannot identify a
1266
- * particular persistence backend, and teardown must still release ownership.
1267
- * @param activation - the Activation whose final events should be flushed.
1268
- */
1269
- async flushFinalState(activation) {
1270
- const child = activation.handle.agent;
1271
- try {
1272
- await child.ctx.sessions.flush(child.session);
1273
- }
1274
- catch (error) {
1275
- this.ctx.logger.warn(`subagent "${activation.childId}" best-effort final session flush failed; `
1276
- + `the persisted state may be unavailable or stale on resume: ${errorChain(error)}`);
1277
- }
1278
- }
1279
427
  /** Resolve the persistence service continuable children require, or fail loud. */
1280
428
  requirePersistence() {
1281
429
  const persistence = this.ctx.get('sessionPersistence');