continuation.ts 63 KB

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