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