| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483 |
- /**
- * Internal continuable-subagent manager: stable child ids, descriptor
- * persistence, activation admission, the live ownership graph, cold resume,
- * child-first disposal, and settlement delivery to the parent, behind
- * `ctx.subagents`.
- *
- * A continuable child has one durable Session and at most one process-local
- * {@link Activation} — one residency epoch for a reconstructed child Agent. An
- * Activation is not a request, result, cancellation, or Task boundary: it may
- * execute many FIFO turns and stays resident while descendants it created are
- * still running. The Agent inbox is the only turn queue, so this manager owns
- * residency while the Agent loop owns all turn ordering and execution. No
- * continuable path creates a Task or an intermediate result-bearing wrapper.
- *
- * Because residency is this manager's alone to end, telling the parent that a
- * child settled is its job too. An external `subagent/end` listener cannot do
- * it correctly: that payload names no parent, the child handle is already
- * disposed by then, and the release that wakes the parent's own settlement
- * watcher has already run. See {@link SubagentContinuationManager.notifySettlement}.
- *
- * @module @deepseek-ai/dsh-subagent
- */
- import { randomUUID } from 'node:crypto'
- import type { Context } from '@deepseek-ai/cordis'
- import type {
- Agent,
- AgentHandle,
- AgentOptions,
- AgentSetupCommit,
- CreateAgentOptions,
- } from '@deepseek-ai/dsh-agent'
- import { boundContextSummary, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
- import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
- import { SessionId } from '@deepseek-ai/dsh-session'
- import type { SessionEvent } from '@deepseek-ai/dsh-session'
- import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
- import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
- import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts'
- import type { SubagentDescriptorData } from './descriptor.ts'
- import {
- appendDelegatedPolicyOverrides,
- applyChildComposition,
- captureDelegatedPolicyOverrides,
- childSessionMeta,
- resolveChildAgentOptions,
- resolveChildDepth,
- } from './child-agent.ts'
- import type { DelegatedPolicyOverrides } from './child-agent.ts'
- import { assertSubagentMaxDepth } from './depth.ts'
- import { seedDescriptorTurn } from './descriptor-seed.ts'
- import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentResult, SubagentStartRequest } from './types.ts'
- import type { ActivationObserver, ActivationTerminal } from './lifecycle.ts'
- import { SubagentError } from './error.ts'
- import type SubagentActivationSetupRegistry from './activation-setup-registry.ts'
- /** Attribution for a model coordinator's follow-up to one of its children. */
- export interface CoordinatorMessageSource {
- readonly kind: 'coordinator'
- /** A message another agent addressed to this one (`relay` context form). */
- readonly form: 'relay'
- /** Session id of the agent whose tool call produced the follow-up. */
- readonly senderSessionId: SessionId
- }
- /** Durable attribution for a continuable child's explicit parent report. */
- export interface SubagentReportMessageSource {
- readonly kind: 'subagent-report'
- /** A message another agent addressed to this one (`relay` context form). */
- readonly form: 'relay'
- /** Session id of the reporting child. */
- readonly senderSessionId: SessionId
- }
- /**
- * Durable attribution for the runtime's own account of a continuable child
- * settling. Deliberately a different kind from
- * {@link SubagentReportMessageSource}: a report is content the child chose,
- * while this message is the manager stating what became of the child, and a
- * transcript that merged them would credit the child with words it never wrote.
- */
- export interface SubagentSettledMessageSource {
- readonly kind: 'subagent-settled'
- /** A runtime account shown without expanding the row (`notice` context form). */
- readonly form: 'notice'
- /** One-line account of how the child ended. */
- readonly summary: string
- /** Session id of the child that settled. */
- readonly senderSessionId: SessionId
- }
- declare module '@deepseek-ai/dsh-llm' {
- interface MessageSourceMap {
- coordinator: CoordinatorMessageSource
- 'subagent-report': SubagentReportMessageSource
- 'subagent-settled': SubagentSettledMessageSource
- }
- }
- /** Deployment scheduling policy for accepted child reports. */
- export type SubagentReportDelivery = 'quiet' | 'wakeup'
- /** Options for one continuable child's report to its direct parent. */
- export interface SubagentReportOptions {
- /** Already-resolved parent scheduling policy. */
- readonly delivery: SubagentReportDelivery
- /** Caller cancellation, owning authorization and admission until acceptance. */
- readonly signal: AbortSignal
- }
- /** What a caller asks for when starting a continuable background child. */
- export interface ContinuableStartSpec {
- /** The `ctx.subagents` provider whose continuable-creation capability establishes the child. */
- readonly provider: string
- /** The initial delegation's short `description`, persisted as the child's creation label. */
- readonly label: string
- /**
- * The delegation request. The manager reserves the stable child id, resolves
- * the durable descriptor, and composes the child itself.
- */
- readonly request: Omit<SubagentStartRequest, 'label' | 'signal' | 'outputSchema'>
- /** Caller cancellation, owning the operation only until inbox acceptance. */
- readonly signal: AbortSignal
- }
- /** Identities returned once a continuable child accepted its initial prompt. */
- export interface ContinuableStart {
- /** The durable child session id, stable across activations. */
- readonly childId: SessionId
- /** The accepted initial prompt's inbox message id. */
- readonly messageId: MessageId
- }
- /**
- * Authority under which one interrupt request is admitted. `user` carries the
- * durable direct-parent address a human client presented; `ancestor` carries
- * the exact live Agent object whose recorded lineage must contain the caller.
- */
- export type SubagentInterruptAuthority =
- | { readonly kind: 'user'; readonly parentSessionId: SessionId }
- | { readonly kind: 'ancestor'; readonly agent: Agent }
- /** Options for following up with one continuable child. */
- export interface SubagentFollowupOptions {
- /** Durable attribution retained on the delivered message; it grants no authority. */
- readonly source: MessageSource
- /** Caller cancellation, owning the operation only until inbox acceptance. */
- readonly signal: AbortSignal
- }
- /**
- * The residency state of one continuable child, derived from Agent quiescence
- * and the owned-child set rather than a second state machine:
- * `running` — the Agent has an active admission or turn, or waking inbox work;
- * `waiting` — the Agent is quiescent but still owns undisposed children;
- * `settled` — quiescent with every owned child disposed, so the manager
- * disposes the `AgentHandle` and removes the Activation.
- */
- type ActivationState = 'running' | 'waiting' | 'settled'
- /**
- * Hooks the manager needs from the owning service. Declared here, by the
- * dependent, so the manager states exactly what it requires instead of
- * depending back on the whole {@link SubagentService}. Package-private: no
- * consumer outside this package supplies a host.
- */
- interface ContinuationHost {
- /**
- * Resolve one provider's continuable-creation contribution, or reject when
- * the provider is unknown or lacks the capability.
- * @param name - the configured provider name.
- * @param request - the reserved identity, delegating parent, and cancellation.
- * @returns the provider's detached creation spec.
- */
- prepareContinuable(name: string, request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>
- /**
- * Build the lifecycle observer for one Activation's residency epoch.
- * @param provider - the provider name recorded in the durable descriptor.
- * @param childId - the durable child session id.
- * @param parent - the exact live direct parent for scoped dispatch.
- * @returns the observer whose edges this epoch publishes.
- */
- observeActivation(provider: string, childId: SessionId, parent: Agent): ActivationObserver
- }
- /**
- * One residency epoch for a reconstructed continuable child Agent. It directly
- * owns the published `AgentHandle`; the manager's private activation-owner
- * scope is its structural Cordis owner.
- */
- interface Activation {
- /** The durable child this Activation is an epoch of. */
- readonly childId: SessionId
- /**
- * The durable direct parent, stored because settlement delivery must resolve
- * that parent after the child handle is gone. {@link ancestry} cannot answer
- * it: a `WeakSet` is not enumerable, and the child's own header is only
- * reachable through a handle disposal has already released.
- */
- readonly parentSession: SessionId
- /** The provider name recorded in the durable descriptor. */
- readonly provider: string
- /** The retained live Agent handle, disposed exactly once at settlement. */
- readonly handle: AgentHandle
- /**
- * Exact live Agent ancestry observed when this Activation materialized.
- * Weak membership preserves host-scope identity across an intermediate
- * ancestor leaving the registry without retaining that ancestor's runtime.
- */
- readonly ancestry: WeakSet<Agent>
- /**
- * Session ids of the child Activations this one owns. Because one Session has
- * at most one live Activation, the id identifies the live child without
- * another runtime-incarnation reference. Non-empty blocks settlement.
- */
- readonly ownedChildren: Set<SessionId>
- /** The lifecycle observer that emits this epoch's start and terminal edges. */
- readonly observer: ActivationObserver
- /**
- * The memoized disposal transaction. Presence IS the admission cutoff: it is
- * assigned synchronously when disposal begins, so no delivery can join a
- * handle being torn down, and a racing delivery awaits it before cold-resuming
- * a new Activation. Every converging releaser shares this one teardown.
- */
- disposal: Promise<void> | undefined
- /**
- * Accepted waking message ids this manager has not yet seen leave the inbox.
- * `Agent.status` is still `idle` in the window between `followup()` and the
- * microtask that admits it, so settlement must not treat that gap as quiet.
- */
- readonly accepted: Set<MessageId>
- /**
- * Whether any delivery to this child was ever accepted. A materialization
- * rolled back before its first acceptance is a child the caller was told does
- * not exist, so its teardown owes the parent no settlement account.
- */
- announced: boolean
- /** Renewed whenever a settlement watcher must re-observe quiescence. */
- poke: PromiseWithResolvers<void>
- }
- /** Inputs shared by fresh and resumed Activation materialization. */
- interface MaterializeInputs {
- childId: SessionId
- provider: string
- parent: Agent
- /**
- * Creation inputs; absent for a cold resume, which loads the persisted
- * session — including the delegation policy events a fresh creation seeded,
- * so a resume never re-captures the parent's policy.
- */
- create?: {
- seed: readonly SessionEvent[]
- meta: NonNullable<CreateAgentOptions['meta']>
- /** Policy captured at the delegation boundary: the parent's sandbox override plus the approval pin. */
- delegatedPolicies: DelegatedPolicyOverrides
- }
- agentOptions: AgentOptions
- composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined }
- signal: AbortSignal
- }
- /**
- * One admitted materialization and the exact live ancestry observed at its
- * synchronous admission boundary. Retaining identities lets a scoped teardown
- * keep waiting even if an intermediate Agent leaves the registry meanwhile.
- */
- interface Materialization {
- readonly lineage: readonly Agent[]
- readonly settled: Promise<void>
- }
- /**
- * Read one Activation's current disposal transaction. This indirection exists
- * because TypeScript would otherwise narrow repeated reads of the mutable field
- * inside a long-lived closure to constants instead of re-reading runtime state.
- * @param activation - the Activation to inspect.
- * @returns the in-flight or settled disposal, or `undefined` while resident.
- */
- function disposalOf(activation: Activation): Promise<void> | undefined {
- return activation.disposal
- }
- /**
- * One line telling a parent that a background child is finished and why, in
- * the parent's own task vocabulary.
- * @param childId - the durable child the parent knows by id.
- * @param stopReason - how the child's last ordinary turn ended.
- * @returns the model-facing opening line of the settlement notice.
- */
- function settlementSummary(childId: SessionId, stopReason: SubagentResult['stopReason']): string {
- const subject = `Background subagent ${childId}`
- switch (stopReason) {
- case 'completed':
- return `${subject} finished and will do no further work unless you send it more.`
- case 'aborted':
- return `${subject} was stopped before it finished.`
- case 'max-tokens':
- return `${subject} ran out of room before it finished.`
- // A pre-step rejection — a hook deny, a policy plugin — discarded input
- // the child had claimed, so the parent must not treat the task as done.
- case 'refusal':
- return `${subject} declined the task.`
- case 'error':
- return `${subject} failed before it finished.`
- /* v8 ignore next 4 -- `SubagentResult['stopReason']` is merge-extensible, so this arm
- * needs a backend that adds a variant; an unnameable ending is reported as unfinished
- * rather than silently as success. */
- default:
- return `${subject} ended abnormally (${String(stopReason)}) before it finished.`
- }
- }
- /** Whether one settlement attempt opened the disposal transaction. */
- type SettlementAttempt =
- | { readonly settling: false }
- | { readonly settling: true; readonly done: Promise<void> }
- /** Serialize each durable child's delivery, release, and disposal. */
- class ChildLock {
- private tails = new Map<SessionId, Promise<unknown>>()
- /**
- * Run `operation` after every previously queued operation for `childId`.
- * @param childId - the durable child whose operations are linearized.
- * @param operation - the critical section to run in order.
- * @returns the operation's own settlement.
- */
- run<T>(childId: SessionId, operation: () => Promise<T>): Promise<T> {
- const previous = this.tails.get(childId) ?? Promise.resolve()
- const result = previous.then(operation, operation)
- // Absorb rejections in the chaining tail so one failed critical section
- // cannot reject an unrelated later caller.
- const tail = result.then(() => undefined, () => undefined)
- this.tails.set(childId, tail)
- void tail.then(() => {
- if (this.tails.get(childId) === tail) this.tails.delete(childId)
- })
- return result
- }
- }
- /**
- * The continuable-subagent orchestration service behind `ctx.subagents`. Tool
- * schema and host adapters are consumers of this one contract; foreground
- * one-shot delegation keeps calling `ctx.subagents.start()` and never enters
- * this lifecycle.
- */
- export class SubagentContinuationManager {
- /** Child session id → its live Activation. Process-local, never durable. */
- private activations = new Map<SessionId, Activation>()
- /** Materializations admitted before drain, tracked through publication or rollback. */
- private readonly materializations = new Set<Materialization>()
- private readonly locks = new ChildLock()
- /** Structural Cordis owner of every Activation handle. */
- private readonly ownerCtx: Context
- /**
- * Exact roots whose host teardown has begun, with the live lineage members
- * observed under each root. Entries remain until that exact root leaves the
- * Agent registry, closing admission throughout its host's teardown without
- * poisoning a later same-id replacement.
- */
- private readonly closingScopes = new Map<Agent, Set<Agent>>()
- private draining = false
- constructor(
- private readonly ctx: Context,
- private readonly host: ContinuationHost,
- private readonly setupRegistry: SubagentActivationSetupRegistry,
- ) {
- // Ordinary Cordis owner effects unwind in reverse registration order, which
- // cannot express the dynamic child graph. Register the private scope's
- // structural disposer FIRST and the drain SECOND, so reverse unwind invokes
- // the drain before releasing the scope; a cleanup effect on the same scope
- // as the Agent handles would let structural handle disposal bypass
- // child-first ordering.
- const scope = ctx.plugin(function activationOwner() {})
- this.ownerCtx = scope.ctx
- ctx.on('agent/disposed', ({ agent }) => {
- this.closingScopes.delete(agent)
- })
- ctx.effect(function* (this: SubagentContinuationManager) {
- yield scope.dispose
- yield () => this.drain()
- }.bind(this), 'subagents.continuations()')
- }
- /**
- * Start one continuable background child: reserve its durable identity,
- * resolve the provider's detached creation spec, create the child Agent
- * through the private activation-owner scope, establish any continuable-parent
- * ownership, and submit the initial prompt. Resolves when inbox acceptance
- * yields the message id — without waiting for the turn to start or for the
- * message to reach the Session log.
- *
- * Every failure before that acceptance rejects without either id, disposing
- * any created handle and rolling back the Activation and parent ownership.
- * The caller signal owns lookup, materialization, and admission only until
- * acceptance; afterwards the manager owns the Activation independently.
- * @param spec - provider, delegation request, and caller cancellation.
- * @returns the durable child id and the accepted initial prompt's message id.
- */
- async startContinuable(spec: ContinuableStartSpec): Promise<ContinuableStart> {
- const request = spec.request
- const parent = request.parent
- this.assertAdmitting(parent)
- this.requirePersistence()
- assertSubagentMaxDepth(request.maxDepth)
- const childId = SessionId(randomUUID())
- const childDepth = resolveChildDepth(parent, request.maxDepth)
- // Snapshot before any await: invalid descriptor JSON rejects the call
- // before a child exists, and the detached value is what reaches the log.
- const agentProvider = request.agentOptions?.provider ?? parent.options.provider
- const agentModel = request.agentOptions?.model ?? parent.options.model
- const descriptor = snapshotSubagentDescriptor({
- mode: 'continuable',
- provider: spec.provider,
- label: spec.label,
- ...agentProvider !== undefined ? { agentProvider } : {},
- ...agentModel !== undefined ? { agentModel } : {},
- ...request.persona !== undefined ? { persona: request.persona } : {},
- ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {},
- })
- // Capture before the first await: a later parent switch belongs to the
- // parent's future, not to this child.
- const delegatedPolicies = captureDelegatedPolicyOverrides(parent)
- const prepared = await this.host.prepareContinuable(spec.provider, {
- sessionId: childId,
- parent,
- signal: spec.signal,
- })
- spec.signal.throwIfAborted()
- this.assertAdmitting(parent)
- const lineageSeedLength = prepared.seed?.length ?? 0
- const seed = seedDescriptorTurn(childId, prepared.seed, descriptor)
- const messageId = await this.locks.run(childId, async () => {
- const activation = await this.materialize({
- childId,
- provider: spec.provider,
- parent,
- create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), delegatedPolicies },
- agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth),
- composition: { persona: request.persona, toolFilter: request.toolFilter },
- signal: spec.signal,
- })
- return this.submitMaterialized(
- activation,
- request.prompt,
- { kind: 'user' },
- parent,
- spec.signal,
- )
- })
- return { childId, messageId }
- }
- /**
- * Deliver one later message to a known continuable child as its next FIFO
- * turn. Routing depends only on Activation residency: a `running` Activation
- * enqueues, a `waiting` one wakes the same Agent, and an absent one
- * cold-resumes a new Activation from the persisted Session. The Agent inbox
- * is the only queue, so every accepted message has one observable order.
- *
- * The caller signal owns lookup, materialization, and admission only until
- * inbox acceptance; afterwards the accepted turn cannot be cancelled through
- * this service.
- * @param parent - the exact live direct parent authorizing this delivery.
- * @param childId - the durable child session id.
- * @param content - the user-role content to deliver.
- * @param options - the message source fields and caller cancellation.
- * @returns the accepted message's inbox id.
- * @throws when parent authority, availability, or admission rejects the delivery.
- */
- async followup(
- parent: Agent,
- childId: SessionId,
- content: ContentBlock[],
- options: SubagentFollowupOptions,
- ): Promise<MessageId> {
- this.assertAdmitting(parent)
- while (true) {
- const live = await this.locks.run(childId, async () => {
- const activation = this.activations.get(childId)
- if (activation === undefined) return this.coldResume(parent, childId, content, options)
- // A delivery that arrives after the disposal transaction began must not
- // reach a handle being torn down; wait for release, then cold-resume.
- /* v8 ignore next 3 -- the send-versus-dispose cutoff: reaching this arm needs a
- * delivery to observe the transaction inside the same critical section that opened it,
- * which no test can schedule deterministically. The behavior is covered end-to-end by
- * "cold-resumes a delivery that lost the race with final disposal". */
- if (activation.disposal !== undefined) {
- return activation.disposal.then(() => undefined, () => undefined)
- }
- return this.submitAdmitted(activation, content, options.source, parent, options.signal)
- })
- /* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that
- * race reaches the retry below, which then cold-resumes a new Activation. */
- if (live !== undefined) return live
- this.assertAdmitting(parent)
- options.signal.throwIfAborted()
- /* v8 ignore stop */
- }
- }
- /**
- * Interrupt one live continuable child's current turn. Admission is
- * synchronous and the effect is asynchronous: this authorizes the caller,
- * requests `Agent.cancel(cause, { keepInbox: true })` on the target, and
- * returns without waiting for the target to observe the signal or reach
- * quiescence. The Activation, its handle, accepted unclaimed inbox work, and
- * already-published descendants are untouched; work already claimed into the
- * interrupted turn is not requeued. Once the interrupted driver is idle, a
- * waking send resumes the parked queue.
- *
- * An absent target is an accepted no-op, which uniformly covers natural
- * completion races, repeated requests, one-shot ids, and unknown ids without
- * consulting the durable catalog. A target whose disposal transaction is
- * already open is likewise an accepted no-op after authorization.
- * @param targetSessionId - the durable child session id to interrupt.
- * @param authority - the human parent address or exact live ancestor Agent.
- * @throws {SubagentError} `UNAUTHORIZED` when the presented authority does
- * not own the live target: a stale or self-targeting ancestor caller, a
- * parent address that is not the live target's durable direct parent, or
- * an ancestor outside the target's recorded live lineage.
- */
- interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void {
- if (authority.kind === 'ancestor') {
- const caller = authority.agent
- // A stale caller is rejected even when the target is absent, so a
- // replaced same-id Agent can never probe this manager's state.
- if (this.ctx.agents.get(caller.id) !== caller) {
- throw new SubagentError(
- `interrupting "${targetSessionId}" requires the exact live ancestor agent`,
- 'UNAUTHORIZED',
- )
- }
- if (caller.id === targetSessionId) {
- throw new SubagentError(
- `agent "${caller.id}" cannot interrupt itself`,
- 'UNAUTHORIZED',
- )
- }
- }
- const activation = this.activations.get(targetSessionId)
- if (activation === undefined) return
- if (authority.kind === 'user') {
- if (activation.handle.agent.session.header.parentSession !== authority.parentSessionId) {
- throw new SubagentError(
- `subagent "${targetSessionId}" belongs to another parent session`,
- 'UNAUTHORIZED',
- )
- }
- } else if (!activation.ancestry.has(authority.agent)) {
- throw new SubagentError(
- `subagent "${targetSessionId}" is not a live descendant of agent "${authority.agent.id}"`,
- 'UNAUTHORIZED',
- )
- }
- // Disposal already stopped the target with a whole-Activation teardown;
- // a second cancel would be a redundant signal on a closing handle.
- if (activation.disposal !== undefined) return
- activation.handle.agent.cancel(
- authority.kind === 'user' ? { kind: 'user' } : { kind: 'parent' },
- { keepInbox: true },
- )
- }
- /**
- * Deliver explicitly selected content from one resident continuable child to
- * its durable direct parent. Sender authorization, parent resolution, and
- * send acceptance share one no-await span. Reporting neither concludes the
- * child's turn nor changes its Activation lifetime.
- * @param child - exact live reporting child; this is the authority credential.
- * @param content - selected model-facing content.
- * @param options - scheduling policy and pre-acceptance cancellation.
- * @returns the stable identity of the message accepted by the parent.
- * @throws {SubagentError} when the sender is unauthorized, the parent is not
- * live, or continuation admission is closing.
- */
- // oxlint-disable-next-line typescript/require-await -- keep rejection semantics without yielding during admission
- async reportFrom(
- child: Agent,
- content: ContentBlock[],
- options: SubagentReportOptions,
- ): Promise<MessageId> {
- options.signal.throwIfAborted()
- this.assertAdmitting(child)
- const activation = this.authorizeReporter(child)
- const parent = this.resolveReportParent(child)
- return this.deliverReport(activation, parent, content, options.delivery)
- }
- /** Authorize only the exact Agent of one resident Activation. */
- private authorizeReporter(child: Agent): Activation {
- const activation = this.activations.get(child.id)
- if (activation === undefined || activation.handle.agent !== child) {
- throw new SubagentError(
- `agent "${child.id}" is not a live continuable subagent and cannot report`,
- 'UNAUTHORIZED',
- )
- }
- /* v8 ignore next 6 -- only a synchronous re-entrant disposer can open this
- * transaction between exact-agent authorization and this no-await cutoff. */
- if (activation.disposal !== undefined) {
- throw new SubagentError(
- `subagent "${child.id}" activation is being disposed; the report was not delivered`,
- 'ACTIVATION_CLOSING',
- )
- }
- return activation
- }
- /** Resolve the reporting child's live direct parent from durable lineage. */
- private resolveReportParent(child: Agent): Agent {
- const parentId = child.session.header.parentSession
- /* v8 ignore next -- every continuation-managed child has direct-parent metadata. */
- const parent = parentId === undefined ? undefined : this.ctx.agents.get(parentId)
- if (parent === undefined) {
- throw new SubagentError(
- 'direct parent is not live; report was not delivered',
- 'PARENT_UNAVAILABLE',
- )
- }
- return parent
- }
- /** Deliver one framed report through the selected parent scheduling preset. */
- private deliverReport(
- activation: Activation,
- parent: Agent,
- content: ContentBlock[],
- delivery: SubagentReportDelivery,
- ): MessageId {
- const message = createUserMessage({
- content: [
- { type: 'text' as const, text: `Background subagent ${activation.childId} reported:` },
- ...content,
- ],
- source: {
- kind: 'subagent-report' as const,
- form: 'relay' as const,
- senderSessionId: activation.childId,
- },
- })
- if (delivery === 'wakeup') {
- this.sendWaking(parent, message, () => { this.sendReport(parent, message, delivery) })
- } else {
- this.sendReport(parent, message, delivery)
- }
- return message.id
- }
- /**
- * Perform one waking send to a parent, accounted against that parent's own
- * Activation when it has one. Registering the id before the send is what
- * keeps a continuation-managed parent from being judged quiescent in the
- * window between `followup()` and the microtask that admits it.
- * @param parent - the exact live parent receiving the waking message.
- * @param message - the message whose id is accounted.
- * @param send - the synchronous waking send to perform.
- */
- private sendWaking(
- parent: Agent,
- message: ReturnType<typeof createUserMessage>,
- send: () => void,
- ): void {
- const parentActivation = this.activations.get(parent.id)
- if (parentActivation !== undefined && parentActivation.handle.agent === parent) {
- this.admitWaking(parentActivation, message.id, send)
- } else {
- send()
- }
- }
- /** Send one report while translating only the parent's own rejection. */
- private sendReport(
- parent: Agent,
- message: ReturnType<typeof createUserMessage>,
- delivery: SubagentReportDelivery,
- ): void {
- try {
- if (delivery === 'wakeup') parent.followup(message)
- else parent.inject(message)
- } catch (error: unknown) {
- throw new SubagentError(
- 'direct parent is not live; report was not delivered',
- 'PARENT_UNAVAILABLE',
- { cause: error },
- )
- }
- }
- /**
- * Close admission, await every already-admitted materialization through
- * publication or rollback, then dispose the stable live Activation forest
- * child-first. Sibling branches drain independently: one failure is recorded
- * but never prevents the remaining handles from being attempted, and the
- * aggregate rejects only after every branch settles.
- * @returns once materialization is quiescent and every live Activation released its handle.
- * @throws an aggregate error when any branch failed to release.
- */
- async drain(): Promise<void> {
- // Close admission synchronously before the first await. Materializations
- // already past that cutoff remain tracked until their handle is installed
- // or rollback completes, producing a stable forest for the later snapshot.
- this.draining = true
- await Promise.all([...this.materializations].map(materialization => materialization.settled))
- // Snapshot roots after closing admission: a root is an Activation no live
- // Activation owns, so disposing roots recurses child-first into the forest.
- const owned = new Set<SessionId>()
- for (const activation of this.activations.values()) {
- for (const child of activation.ownedChildren) owned.add(child)
- }
- const roots = [...this.activations.values()].filter(activation => !owned.has(activation.childId))
- await this.disposeRoots(roots, 'activation(s)')
- }
- /**
- * Stop only the continuable descendants of exact live host-owned parents.
- * Admission stays closed for those parent trees until each exact parent
- * leaves the Agent registry; unrelated trees and manager-wide admission stay
- * live.
- * @param parents - exact live roots whose continuable descendants must stop.
- * @returns once every retained descendant Activation released its handle.
- * @throws an aggregate error after all scoped branches settle when any failed.
- */
- async drainDescendants(parents: readonly Agent[]): Promise<void> {
- const roots = new Set(parents.filter(parent => this.ctx.agents.get(parent.id) === parent))
- if (roots.size === 0) return
- // Publish the scoped admission cutoff before the first await. Merge with an
- // earlier call for the same exact root so a converging drain cannot forget
- // descendants whose release is already in flight.
- for (const root of roots) {
- this.closingMembers(root).add(root)
- }
- const targets: Activation[] = []
- for (const activation of this.activations.values()) {
- const lineage = this.liveLineage(activation.handle.agent)
- // Strict descendants only: a continuable Agent may itself be a
- // host-owned root, and its host remains responsible for that root handle.
- const owners = [...roots].filter(root => activation.handle.agent !== root
- && activation.ancestry.has(root))
- if (owners.length === 0) continue
- targets.push(activation)
- for (const owner of owners) {
- const members = this.closingMembers(owner)
- members.add(activation.handle.agent)
- for (const agent of lineage) members.add(agent)
- }
- }
- const materializations = [...this.materializations].filter((materialization) => {
- const owners = [...roots].filter(root => materialization.lineage.includes(root))
- for (const owner of owners) {
- const members = this.closingMembers(owner)
- for (const agent of materialization.lineage) members.add(agent)
- }
- return owners.length > 0
- })
- const ownedTargets = new Set<SessionId>()
- for (const activation of targets) {
- for (const child of activation.ownedChildren) ownedTargets.add(child)
- }
- const targetRoots = targets.filter(activation => !ownedTargets.has(activation.childId))
- // Open every selected transaction before the materialization barrier.
- // Disposal propagates cancellation top-down in the same synchronous span;
- // handle release remains child-first.
- for (const activation of targets) {
- const disposal = this.dispose(activation)
- void disposal.catch(() => undefined)
- }
- await Promise.all(materializations.map(materialization => materialization.settled))
- await this.disposeRoots(targetRoots, 'scoped activation(s)')
- }
- /** Dispose independent roots and report every branch failure after all settle. */
- private async disposeRoots(
- roots: readonly Activation[],
- failureSubject: 'activation(s)' | 'scoped activation(s)',
- ): Promise<void> {
- const failures = await Promise.all(roots.map(async (activation) => {
- try {
- await this.dispose(activation)
- return undefined
- } catch (error: unknown) {
- return error
- }
- }))
- const reasons = failures.filter(failure => failure !== undefined)
- if (reasons.length > 0) {
- throw new SubagentError(
- `continuable subagent teardown failed for ${reasons.length} ${failureSubject}: `
- + reasons.map(reason => errorChain(reason)).join('; '),
- 'ACTIVATION_TEARDOWN_FAILED',
- )
- }
- }
- /** Return the retained member set for one exact scoped-teardown root. */
- private closingMembers(root: Agent): Set<Agent> {
- const existing = this.closingScopes.get(root)
- if (existing !== undefined) return existing
- const members = new Set<Agent>()
- this.closingScopes.set(root, members)
- return members
- }
- /**
- * Return the exact currently resolvable ancestry from `agent` upward. The
- * first element is always the supplied identity, even when it is already
- * stale; each ancestor after it must be the registry's current exact entry.
- */
- private liveLineage(agent: Agent): Agent[] {
- const lineage = [agent]
- const seen = new Set<SessionId>([agent.id])
- let parentSession = agent.session.header.parentSession
- while (parentSession !== undefined) {
- const parent = this.ctx.agents.get(parentSession)
- if (parent === undefined || seen.has(parent.id)) break
- lineage.push(parent)
- seen.add(parent.id)
- parentSession = parent.session.header.parentSession
- }
- return lineage
- }
- /**
- * The teardown that closed continuable admission for this agent's lineage.
- * `'manager'` is the whole manager draining; an Agent is the exact scoped root
- * whose forest is closing.
- * @param agent - the agent whose lineage is tested.
- * @returns the closing teardown, or `undefined` while admission is open.
- */
- private closingTeardownFor(agent: Agent): Agent | 'manager' | undefined {
- if (this.draining) return 'manager'
- const lineage = this.liveLineage(agent)
- for (const [root, members] of this.closingScopes) {
- if (members.has(agent) || lineage.includes(root)) return root
- }
- return undefined
- }
- /** Reject new admission once the manager or this exact parent tree began draining. */
- private assertAdmitting(agent: Agent): void {
- const closing = this.closingTeardownFor(agent)
- if (closing === undefined) return
- throw new SubagentError(
- closing === 'manager'
- ? 'continuable subagents are draining; the operation was not admitted'
- : `continuable subagents below parent "${closing.id}" are draining; the operation was not admitted`,
- 'DRAINING',
- )
- }
- /**
- * Derive residency from Agent quiescence and the owned-child set. `running`
- * covers an active admission, an open turn, or accepted waking inbox work.
- *
- * `Agent.status` alone is insufficient: it stays `idle` between an accepted
- * waking send and the microtask that admits it, so a synchronous inbox
- * observer would see `settled` while a turn is already queued. `accepted`
- * holds the ids this manager admitted but has not yet seen drained.
- */
- private stateOf(activation: Activation): ActivationState {
- if (activation.handle.agent.status === 'running' || activation.accepted.size > 0) return 'running'
- if (activation.ownedChildren.size > 0) return 'waiting'
- return 'settled'
- }
- /**
- * Cold-resume a persisted child: inspect and authorize its Session, fold the
- * generic descriptor, create the Activation through `ctx.agents.resume()`,
- * and submit the waiting turn. This never dispatches through a subagent
- * provider — the persisted Session already holds the initial prefix and the
- * descriptor is the whole reconstruction input.
- */
- private async coldResume(
- parent: Agent,
- childId: SessionId,
- content: ContentBlock[],
- options: SubagentFollowupOptions,
- ): Promise<MessageId> {
- const persistence = this.requirePersistence()
- let loaded: Awaited<ReturnType<typeof persistence.inspect>>
- try {
- loaded = await persistence.inspect(childId, options.signal)
- } catch (error: unknown) {
- options.signal.throwIfAborted()
- throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error })
- }
- options.signal.throwIfAborted()
- this.assertAdmitting(parent)
- // Authorize the persisted header before folding: only the durable child's
- // exact live direct parent may continue it.
- this.authorizeLineage(parent, childId, loaded.meta.parentSession)
- // Fold only the child's own suffix: a fork seed replays the parent's log,
- // which may carry an ANCESTOR's descriptor when the parent is itself a
- // continuable child.
- const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0))
- if (descriptor === undefined || descriptor.mode !== 'continuable') {
- throw new SubagentError(
- `subagent "${childId}" has no supported continuation state and cannot be resumed; `
- + 'do not retry send_message with this id',
- 'NOT_RESUMABLE',
- )
- }
- let activation: Activation
- try {
- activation = await this.materialize({
- childId,
- provider: descriptor.provider,
- parent,
- agentOptions: {
- ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {},
- ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {},
- },
- composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter },
- signal: options.signal,
- })
- } catch (error: unknown) {
- options.signal.throwIfAborted()
- if (error instanceof SubagentError) throw error
- throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error })
- }
- return this.submitMaterialized(activation, content, options.source, parent, options.signal)
- }
- /**
- * Submit to a freshly materialized Activation or roll it back completely.
- * @param activation - the just-published Activation to admit or release.
- * @param content - the initial or resumed message content.
- * @param source - durable fields naming who supplied the accepted message.
- * @param parent - the live direct parent authorizing admission.
- * @param signal - caller cancellation owning admission until acceptance.
- * @returns the accepted inbox message id.
- */
- private async submitMaterialized(
- activation: Activation,
- content: ContentBlock[],
- source: MessageSource,
- parent: Agent,
- signal: AbortSignal,
- ): Promise<MessageId> {
- try {
- return this.submitAdmitted(activation, content, source, parent, signal)
- } catch (error: unknown) {
- /* v8 ignore next -- rollback disposal failures must not mask the
- * pre-acceptance signal, drain, or lifecycle failure. */
- await this.dispose(activation).catch(() => undefined)
- throw error
- }
- }
- /**
- * Create or resume the child Agent through the private activation-owner
- * scope, install the handle in a fresh Activation, and register ownership on
- * a continuation-managed parent. Rejection leaves no Activation, no handle,
- * and no ownership membership.
- */
- private materialize(inputs: MaterializeInputs): Promise<Activation> {
- this.assertAdmitting(inputs.parent)
- const settled = Promise.withResolvers<void>()
- const lineage = this.liveLineage(inputs.parent)
- const materialization: Materialization = {
- lineage,
- settled: settled.promise,
- }
- this.materializations.add(materialization)
- return this.materializeTracked(inputs, lineage).finally(() => {
- this.materializations.delete(materialization)
- settled.resolve()
- })
- }
- /**
- * Perform one tracked materialization. The caller keeps the drain barrier
- * registered until this either returns a resident Activation or finishes
- * rollback.
- */
- private async materializeTracked(
- inputs: MaterializeInputs,
- parentLineage: readonly Agent[],
- ): Promise<Activation> {
- const { childId, provider, parent, create } = inputs
- // No id pre-check here: the child lock serializes each durable child, both
- // callers reach this only after confirming no Activation exists, and
- // `AgentRegistry.enter()` is the authoritative collision boundary for an id
- // some other owner holds — a duplicate would reject there with rollback.
- inputs.signal.throwIfAborted()
- const setup = (childCtx: Context): AgentSetupCommit => {
- // Only fresh creation seeds the delegation policy onto the child's own
- // log (after any fork seed, so fresh policy wins stale seed state); a
- // cold resume replays those persisted events instead.
- if (create !== undefined) {
- appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, create.delegatedPolicies)
- }
- applyChildComposition(childCtx, parent, inputs.composition)
- return this.setupRegistry.apply(childCtx)
- }
- const observer = this.host.observeActivation(provider, childId, parent)
- // Agent creation owns rollback before handle transfer. A rejection leaves
- // no resident Activation and therefore publishes no lifecycle edge.
- const handle: AgentHandle = create === undefined
- ? await this.ownerCtx.agents.resume({
- resumeSessionId: childId,
- agentOptions: inputs.agentOptions,
- signal: inputs.signal,
- setup,
- })
- : await this.ownerCtx.agents.create({
- sessionId: childId,
- meta: create.meta,
- seed: create.seed,
- agentOptions: inputs.agentOptions,
- signal: inputs.signal,
- setup,
- })
- const activation: Activation = {
- childId,
- // The durable lineage, not merely the caller: creation stamps this same
- // agent into the child's header, and cold resume authorized it against
- // the persisted header before materializing.
- parentSession: parent.id,
- provider,
- handle,
- ancestry: new WeakSet([handle.agent, ...parentLineage]),
- ownedChildren: new Set(),
- observer,
- disposal: undefined,
- accepted: new Set(),
- announced: false,
- poke: Promise.withResolvers<void>(),
- }
- // After transfer, any failure must dispose the created handle, remove the
- // Activation, and roll back parent ownership before rejecting.
- this.activations.set(childId, activation)
- try {
- inputs.signal.throwIfAborted()
- this.assertAdmitting(parent)
- this.acquireOwnership(parent, childId)
- // Every accepted id leaves the inbox exactly once, through dequeue or
- // discard. Clearing it there is what lets `stateOf()` distinguish a truly
- // quiet Agent from one whose accepted turn has not been admitted yet.
- // Registered through the child's own scoped context, so scope filtering
- // already restricts both listeners to this exact agent.
- handle.agent.ctx.on('agent/inbox/claimed', ({ message }) => {
- /* v8 ignore next -- a claim of an id this manager never admitted needs
- * another sender on the same child, which no current path allows. */
- if (activation.accepted.delete(message.id)) this.wake(activation)
- })
- handle.agent.ctx.on('agent/inbox/discarded', ({ message }) => {
- if (activation.accepted.delete(message.id)) this.wake(activation)
- })
- // Agent creation committed setup at its publication boundary;
- // revocations from here on are immediate live revocation.
- // Publish the start edge before any turn can run, so observers see this
- // epoch before its first request.
- observer.start(handle.agent)
- } catch (error: unknown) {
- // Listener exceptions are contained by the lifecycle emitter; a start
- // publication throw therefore leaves no residency edge to pair.
- /* v8 ignore next -- rollback failure must not mask the admission failure
- * that prevented this operation from returning an accepted message id. */
- await this.rollbackUnpublished(activation).catch(() => undefined)
- throw error
- }
- this.watchSettlement(activation)
- return activation
- }
- /**
- * Release an Activation whose start edge was not published. The memoized
- * transaction remains in the live map until handle disposal settles, so a
- * concurrent drain or delivery observes the same closing boundary.
- */
- private rollbackUnpublished(activation: Activation): Promise<void> {
- return (activation.disposal ??= (async () => {
- try {
- await activation.handle.dispose()
- } finally {
- this.activations.delete(activation.childId)
- this.releaseOwnership(activation.childId)
- }
- })())
- }
- /**
- * Register the child in a continuation-managed parent's owned set before the
- * child can run, so that parent cannot settle while the child is live. A
- * top-level or other non-continuation Agent has no Activation and stays
- * outside the waiting graph.
- */
- private acquireOwnership(parent: Agent, childId: SessionId): void {
- const parentActivation = this.activations.get(parent.id)
- if (parentActivation === undefined) return
- if (parentActivation.disposal !== undefined) {
- throw new SubagentError(
- `subagent parent "${parent.id}" is being disposed; the child was not established`,
- 'ACTIVATION_CLOSING',
- )
- }
- parentActivation.ownedChildren.add(childId)
- }
- /** Remove one child from its live owner's set and let that owner re-check settlement. */
- private releaseOwnership(childId: SessionId): void {
- for (const candidate of this.activations.values()) {
- if (candidate.ownedChildren.delete(childId)) this.wake(candidate)
- }
- }
- /** Let a settlement watcher re-observe quiescence after ownership or inbox changes. */
- private wake(activation: Activation): void {
- activation.poke.resolve()
- activation.poke = Promise.withResolvers<void>()
- }
- /**
- * Submit one message as the child's next FIFO turn and return its accepted
- * inbox id. Acceptance is the operation's success boundary; the manager owns
- * the Activation independently afterwards.
- */
- private submit(
- activation: Activation,
- content: ContentBlock[],
- source: MessageSource,
- parent: Agent,
- ): MessageId {
- // Parent-originated delivery keeps the parent live through ownership, so
- // establish it before the message can enter the child's inbox.
- this.acquireOwnership(parent, activation.childId)
- const message = createUserMessage({ content, source })
- const accepted = this.admitWaking(activation, message.id, () => {
- activation.handle.agent.followup(message)
- })
- // Past this point the caller has an id for this child, so its eventual
- // settlement is something the parent is owed an account of.
- activation.announced = true
- return accepted
- }
- /**
- * Account one waking send across a resident Activation's settlement window.
- * @param activation - Activation receiving waking inbox work.
- * @param messageId - stable identity of the message about to be sent.
- * @param send - synchronous send that publishes one enqueue occurrence.
- * @returns the accepted message id.
- */
- private admitWaking(
- activation: Activation,
- messageId: MessageId,
- send: () => void,
- ): MessageId {
- // `Agent.followup()` publishes inbox events synchronously, so observers must
- // see this Activation as busy before the call begins.
- activation.accepted.add(messageId)
- try {
- send()
- } catch (error: unknown) {
- activation.accepted.delete(messageId)
- throw error
- }
- // Accepted waking work keeps this Activation live until whenIdle() observes
- // the complete waking suffix.
- this.wake(activation)
- return messageId
- }
- /**
- * Cross the final admission cutoff and submit without yielding. Signal abort,
- * manager drain, or Activation disposal that wins before this synchronous
- * span rejects without inbox acceptance.
- */
- private submitAdmitted(
- activation: Activation,
- content: ContentBlock[],
- source: MessageSource,
- parent: Agent,
- signal: AbortSignal,
- ): MessageId {
- signal.throwIfAborted()
- this.assertAdmitting(parent)
- /* v8 ignore next 6 -- only a synchronous re-entrant disposer can change
- * this field between the caller's live check and this no-await boundary. */
- if (disposalOf(activation) !== undefined) {
- throw new SubagentError(
- `subagent "${activation.childId}" activation is being disposed; the message was not accepted`,
- 'ACTIVATION_CLOSING',
- )
- }
- this.authorizeLineage(
- parent,
- activation.childId,
- activation.handle.agent.session.header.parentSession,
- )
- return this.submit(activation, content, source, parent)
- }
- /**
- * Authorize one operation against the durable direct-parent lineage. Other
- * agents, ancestors, teams, workflows, and hosts remain rejected until an
- * explicit authority protocol has a production consumer.
- */
- private authorizeLineage(
- parent: Agent,
- childId: SessionId,
- parentSession: SessionId | undefined,
- ): void {
- if (this.ctx.agents.get(parent.id) !== parent) {
- throw new SubagentError(
- `subagent "${childId}" delivery requires the exact live parent agent`,
- 'UNAUTHORIZED',
- )
- }
- if (parentSession !== parent.id) {
- throw new SubagentError(`subagent "${childId}" belongs to another parent session`, 'UNAUTHORIZED')
- }
- }
- /**
- * Follow one Activation to settlement: wait for Agent quiescence, then for
- * every owned child to complete disposal, and dispose the handle once both
- * hold. A `next-turn` delivered while `waiting` wakes the same Agent and
- * returns it to `running`, so this re-observes rather than settling early.
- */
- private watchSettlement(activation: Activation): void {
- void (async () => {
- while (disposalOf(activation) === undefined) {
- const poked = activation.poke.promise
- await Promise.race([activation.handle.agent.whenIdle(), poked])
- if (disposalOf(activation) !== undefined) return
- // Re-check settlement INSIDE the child lock and begin disposal in the
- // same critical section, so a concurrent delivery either wins admission
- // before the transaction opens or waits for release and cold-resumes.
- // Deciding outside the lock would let a delivery observe a not-yet
- // resident handle that this watcher is already about to tear down.
- const settling = await this.locks.run<SettlementAttempt>(activation.childId, () => {
- if (disposalOf(activation) !== undefined || this.stateOf(activation) !== 'settled') {
- return Promise.resolve({ settling: false })
- }
- // `dispose()` assigns its memoized transaction synchronously, so
- // admission is closed before this critical section releases.
- return Promise.resolve({ settling: true, done: this.dispose(activation) })
- })
- if (!settling.settling) {
- // Still running, or waiting on descendants: re-observe after the next
- // accepted message or ownership release.
- if (activation.handle.agent.status !== 'running') await poked
- continue
- }
- try {
- await settling.done
- } catch (error: unknown) {
- this.ctx.logger.warn(
- `subagent "${activation.childId}" activation teardown failed: ${errorChain(error)}`,
- )
- }
- return
- }
- })()
- }
- /**
- * Stop one Activation immediately, then release it child-first. The memoized
- * transaction is installed before cancellation or recursive callbacks, so
- * admission and reentrant teardown converge on the same owner.
- *
- * The final session flush is best effort and never prevents handle disposal
- * or ownership release, because retaining a child would permanently pin its
- * ancestors in `waiting`.
- * @param activation - the residency epoch to stop and release.
- * @returns the one disposal transaction owned by this Activation.
- */
- private dispose(activation: Activation): Promise<void> {
- const existing = activation.disposal
- if (existing !== undefined) return existing
- const completion = Promise.withResolvers<void>()
- // Presence is the admission cutoff. Assign it before the async helper starts
- // because that helper cancels Agents and may synchronously re-enter callers.
- activation.disposal = completion.promise
- void this.finishDisposal(activation).then(completion.resolve, completion.reject)
- return completion.promise
- }
- /**
- * Propagate stop synchronously, then finish the child-first release.
- * @param activation - the Activation whose disposal transaction is installed.
- * @returns once the handle and ownership edge are released.
- */
- private async finishDisposal(activation: Activation): Promise<void> {
- this.wake(activation)
- const { childId } = activation
- // Stop top-down before the first await. Slow descendant cleanup may delay
- // release, but it cannot let this ancestor continue model or tool work.
- activation.handle.agent.cancel({ kind: 'parent' })
- const idle = activation.handle.agent.whenIdle()
- const children = [...activation.ownedChildren]
- .map(child => this.activations.get(child))
- .filter((child): child is Activation => child !== undefined)
- const childDisposals = children.map(child => this.dispose(child))
- const failures: SubagentError[] = []
- try {
- // Release remains child-first even though cancellation propagated
- // top-down: every owned child completes before this handle is removed.
- const childFailures = await Promise.all(childDisposals.map(async (disposal) => {
- try {
- await disposal
- return undefined
- } catch (error: unknown) {
- return error
- }
- }))
- const reasons = childFailures.filter(reason => reason !== undefined)
- if (reasons.length > 0) {
- failures.push(new SubagentError(
- `subagent "${childId}" child teardown failed: ${reasons.map(reason => errorChain(reason)).join('; ')}`,
- 'ACTIVATION_TEARDOWN_FAILED',
- ))
- }
- // Quiesce before the flush: a turn still running would keep
- // appending events the flush cannot cover.
- await idle
- await this.flushFinalState(activation)
- // Capture the child-dependent edge data while the child is still live:
- // handle disposal unregisters it, and consumers read its log and scope.
- activation.observer.capture(activation.handle.agent)
- } catch (error: unknown) {
- failures.push(new SubagentError(
- `subagent "${childId}" activation teardown failed: ${errorChain(error)}`,
- 'ACTIVATION_TEARDOWN_FAILED',
- { cause: error },
- ))
- }
- try {
- await activation.handle.dispose()
- } catch (error: unknown) {
- failures.push(new SubagentError(
- `subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`,
- 'ACTIVATION_TEARDOWN_FAILED',
- { cause: error },
- ))
- }
- let failure: SubagentError | undefined
- if (failures.length === 1) {
- failure = failures[0]
- } else if (failures.length > 1) {
- failure = new SubagentError(
- `subagent "${childId}" activation teardown failed at ${failures.length} boundaries: `
- + failures.map(item => errorChain(item)).join('; '),
- 'ACTIVATION_TEARDOWN_FAILED',
- { cause: new AggregateError(failures) },
- )
- }
- // Only now is the Activation gone: keeping the entry until disposal settles
- // makes a racing delivery wait for release rather than cold-resume into the
- // still-registered agent.
- this.activations.delete(childId)
- // BEFORE releasing ownership, while the parent still counts this child and
- // therefore cannot be judged settled. Delivering after the release would
- // race a parent watcher that resumes one microtask later, finds itself
- // childless and quiet, and disposes an Agent whose `cancel()` clears the
- // inbox this notice is sitting in.
- this.notifySettlement(activation, activation.observer.terminal(failure))
- // Release ownership even on failure: a retained failed child would pin its
- // ancestors in `waiting` forever.
- this.releaseOwnership(childId)
- // Emit once the disposal outcome is known, so a rejecting scoped cleanup
- // cannot be reported as a successful epoch.
- activation.observer.settle(failure)
- if (failure !== undefined) throw failure
- }
- /**
- * Tell the durable direct parent that this child produced everything it is
- * going to. Unconditional for every child the caller received an id for: it
- * does not consider whether the child reported, because the cases that most
- * need it — a token ceiling, a model failure, cancellation, teardown — are
- * exactly the ones where the child never got to choose. A materialization
- * rolled back before its first acceptance stays silent, since the caller was
- * told that child was not established. A parent that is no longer live is not
- * an error; the child's own Session remains the durable record either way.
- * A parent whose own lineage is already closing receives the notice without a
- * wake, because teardown is not a reason to start a turn.
- *
- * Never blocks disposal. A delivery failure is logged and dropped, because
- * retaining a child to retry a notice would pin its whole ancestry in
- * `waiting` forever.
- * @param activation - the settling Activation, still owned by its parent.
- * @param terminal - how this epoch ended, as the terminal edge will report it.
- */
- private notifySettlement(activation: Activation, terminal: ActivationTerminal): void {
- if (!activation.announced) return
- try {
- const parent = this.ctx.agents.get(activation.parentSession)
- if (parent === undefined) return
- const summary = settlementSummary(activation.childId, terminal.stopReason)
- const message = createUserMessage({
- content: [
- { type: 'text' as const, text: summary },
- ...terminal.output === undefined
- ? [{ type: 'text' as const, text: 'It left no closing message.' }]
- : [{ type: 'text' as const, text: 'Its closing message:' }, ...terminal.output],
- ],
- source: {
- kind: 'subagent-settled' as const,
- form: 'notice' as const,
- summary: boundContextSummary(summary),
- senderSessionId: activation.childId,
- },
- })
- // A parent whose own teardown already began must not be woken. Waking is
- // not a queue operation: `followup()` on a quiescent Agent starts a turn,
- // and `cancel()` does not arm against a later one, so a notice arriving
- // during teardown would spend a model request on an Agent its host is
- // about to dispose — once per tree layer, since each layer's own notice
- // then wakes the layer above it. Injecting delivers to a parent still
- // reading its inbox and records the account in the log either way; it
- // does NOT survive that parent's own disposal, whose `keepInbox: false`
- // cancel durably clears whatever it never claimed.
- if (this.closingTeardownFor(parent) !== undefined) {
- parent.inject(message)
- return
- }
- // An idle parent has nothing else to look at, so it gets one ordinary
- // turn. A busy parent is steered instead of woken: `Inbox.claim()` takes
- // the whole next-step batch at one boundary, so several children settling
- // together cost one step rather than one turn each. Steering rather than
- // injecting closes the window where a driver retires between this status
- // read and the send, which would strand the notice unclaimed.
- this.sendWaking(parent, message, () => {
- if (parent.status === 'idle') parent.followup(message)
- else parent.steer(message)
- })
- } catch (error: unknown) {
- this.ctx.logger.warn(
- `subagent "${activation.childId}" settlement notice was not delivered to its parent: `
- + errorChain(error),
- )
- }
- }
- /**
- * Request a best-effort final session flush after the child is quiescent.
- * Listener failure is logged because flush participation cannot identify a
- * particular persistence backend, and teardown must still release ownership.
- * @param activation - the Activation whose final events should be flushed.
- */
- private async flushFinalState(activation: Activation): Promise<void> {
- const child = activation.handle.agent
- try {
- await child.ctx.sessions.flush(child.session)
- } catch (error: unknown) {
- this.ctx.logger.warn(
- `subagent "${activation.childId}" best-effort final session flush failed; `
- + `the persisted state may be unavailable or stale on resume: ${errorChain(error)}`,
- )
- }
- }
- /** Resolve the persistence service continuable children require, or fail loud. */
- private requirePersistence(): SessionPersistence {
- const persistence = this.ctx.get('sessionPersistence')
- if (persistence === undefined) {
- throw new SubagentError(
- 'continuable subagents require session persistence (load a dsh-session-persistence backend)',
- 'PERSISTENCE_UNAVAILABLE',
- )
- }
- return persistence
- }
- }
- export type { SubagentDescriptorData }
- export default SubagentContinuationManager
|