index.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687
  1. /**
  2. * Agent service: live registry, factory delegation, and process-local
  3. * initiator scope. Concrete creation and driving belong to the loop.
  4. *
  5. * @module @deepseek-ai/dsh-agent
  6. */
  7. import { Context, FiberState, getTraceable, Service, symbols } from '@deepseek-ai/cordis'
  8. import type { Fiber } from '@deepseek-ai/cordis'
  9. import { AsyncLocalStorage } from 'node:async_hooks'
  10. import { isPromise } from 'node:util/types'
  11. import { scopeTarget } from '@deepseek-ai/dsh-scope'
  12. import type { Scoped } from '@deepseek-ai/dsh-scope'
  13. import type { SessionEvent, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
  14. import type { Agent } from './types.ts'
  15. import type { AgentOptions, SessionStartSource } from './runtime-types.ts'
  16. export * from './runtime-types.ts'
  17. export * from './types.ts'
  18. export type * from './projection.ts'
  19. export * from './consumed-work.ts'
  20. export * from './model-selection.ts'
  21. export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
  22. export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
  23. declare module '@deepseek-ai/cordis' {
  24. interface Context {
  25. agents: AgentRegistry
  26. }
  27. }
  28. /**
  29. * Synchronous finalizer returned by unpublished Agent setup when its
  30. * contributions need validation at the exact publication commit point.
  31. */
  32. export interface AgentSetupCommit {
  33. /**
  34. * Validate and commit the prepared setup immediately before publication.
  35. * @throws when publication must roll the unpublished Agent back.
  36. */
  37. commit(): void
  38. }
  39. /**
  40. * Compose an unpublished Agent scope and optionally return its publication commit.
  41. * @param agentCtx - unpublished Agent scope.
  42. * @param agent - unpublished Agent being composed.
  43. * @returns an optional synchronous commit invoked after setup awaits settle and immediately before publication.
  44. */
  45. export type AgentSetup = (
  46. agentCtx: Context,
  47. agent: Agent,
  48. ) => AgentSetupCommit | Promise<AgentSetupCommit | void> | void
  49. /**
  50. * Options for programmatically creating an agent through the registry factory
  51. * ({@link AgentRegistry.create}). The caller supplies the single live
  52. * `sessionId` shared by the agent registry and session log (e.g. an
  53. * ACP-generated id), plus optional session metadata (the validated `cwd`, fork
  54. * lineage); the factory creates the session and agent under that identity.
  55. */
  56. export interface CreateAgentOptions {
  57. /** The live agent/session identity. */
  58. readonly sessionId: SessionId
  59. /** Live parent Agent for runtime ownership; omit for a root Agent. */
  60. readonly parentAgent?: Agent
  61. /**
  62. * Session creation metadata: validated absolute `cwd`, `parentSession`
  63. * fork lineage, the `isSeeded` fork marker, the coarse `origin`
  64. * classification, and the `delegationDepth` recursion budget. Mirrors the
  65. * `cwd`/`parentSession`/`isSeeded`/`origin`/`delegationDepth` fields of
  66. * {@link CreateSessionOptions.meta} in dsh-session (the internal-only
  67. * `createdAt`, used when reconstructing a persisted session, is deliberately
  68. * excluded — a factory caller never sets it). This is durable session data,
  69. * so the session boundary validates and snapshots it before asynchronous
  70. * setup begins.
  71. */
  72. readonly meta?: {
  73. readonly cwd?: string
  74. readonly parentSession?: SessionId
  75. readonly isSeeded?: boolean
  76. readonly origin?: 'subagent'
  77. readonly delegationDepth?: number
  78. readonly agentPreset?: string
  79. }
  80. /** Exact fork-inherited prefix length when the session metadata sets `isSeeded`. */
  81. readonly inheritedEventCount?: SessionLogOffset
  82. /**
  83. * Initial replay/fork history. A fork supplies a balanced completed-turn
  84. * prefix of the parent's log. The complete seed must be contiguous from seq
  85. * 0, carry only lossless-JSON data, and contain no open turn/step or dangling
  86. * tool call. The factory passes it to the session's durable
  87. * validator/snapshot boundary before publication.
  88. */
  89. readonly seed?: readonly SessionEvent[]
  90. /** Per-agent options (model, …). */
  91. readonly agentOptions?: AgentOptions
  92. /** Optional creation-only cancellation signal; detached before the returned handle becomes visible. */
  93. readonly signal?: AbortSignal
  94. /**
  95. * Creation-time composition of the agent's scoped world. The factory awaits
  96. * setup after minting `agentCtx` but BEFORE inserting or announcing either
  97. * the session or agent, so observers can never see a partially configured
  98. * world. Setup may return an {@link AgentSetupCommit}; the factory invokes its
  99. * synchronous `commit()` after every setup await settles and immediately
  100. * before registry publication. This lets mutable provisioning revalidate at
  101. * the exact publication boundary. Everything registered through `agentCtx`
  102. * (scoped tools, prompt sections/variables, `restrict()`, listeners, awaited
  103. * child plugins) exists before `session/created`, `agent/created`,
  104. * and the first prompt assembly. A setup
  105. * throw/rejection, commit throw, or owner disposal rolls the scope back
  106. * without publishing either id.
  107. *
  108. * **Setup composes, it never drives**: the callback is trusted same-process
  109. * code and receives the full scoped context, so this is a contract rather
  110. * than a runtime restriction. Drive the agent only after creation resolves.
  111. */
  112. readonly setup?: AgentSetup
  113. }
  114. /**
  115. * Options for resuming an agent on a persisted session
  116. * ({@link AgentRegistry.resume}).
  117. */
  118. export interface ResumeAgentOptions {
  119. /** The persisted session id to load and use as the live agent/session identity. */
  120. readonly resumeSessionId: SessionId
  121. /** Live parent Agent for runtime ownership; omit for a root Agent. */
  122. readonly parentAgent?: Agent
  123. /** Per-agent options (model, …). */
  124. readonly agentOptions?: AgentOptions
  125. /** Optional creation-only cancellation signal for persistence load/setup; detached before return. */
  126. readonly signal?: AbortSignal
  127. /**
  128. * Resume-time composition of the agent's fresh scoped world. Persistence is
  129. * loaded first; the factory then mints `agentCtx` and awaits setup while the
  130. * reconstructed session and agent remain unpublished. The callback has the
  131. * same trusted composition-only contract and optional synchronous
  132. * publication commit as {@link CreateAgentOptions.setup}: all registrations
  133. * exist before either creation announcement, and rejection, commit failure,
  134. * or owner disposal rolls the transaction back without publishing either id.
  135. */
  136. readonly setup?: AgentSetup
  137. }
  138. /**
  139. * An owned agent plus its disposer, returned by {@link AgentRegistry.create} /
  140. * {@link AgentRegistry.resume}. The disposer is a CAPABILITY: among consumers,
  141. * only the holder can tear this agent down. The registered factory provider is
  142. * also a structural owner because the scoped agent depends on that provider's
  143. * service API; provider unload stops and drains every live handle it made.
  144. * `dispose()` stops the loop, awaits its exit, unregisters the agent, removes
  145. * its session from the store, and finally unwinds its scoped world.
  146. *
  147. * `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is
  148. * exposed only to the consumer owner that created it; the structural provider
  149. * reaches the same teardown internally. Config-created agents (the loop's own
  150. * startup) are owned by the loop fiber and never need a handle.
  151. */
  152. export interface AgentHandle {
  153. agent: Agent
  154. dispose(): Promise<void>
  155. }
  156. /**
  157. * The agent-creation factory the loop implementation provides to the registry
  158. * via {@link AgentRegistry.setFactory}. Kept on the `dsh-agent` interface so
  159. * consumers (e.g. the ACP bridge) program against `ctx.agents` without
  160. * depending on the concrete `dsh-agent-loop` package.
  161. */
  162. export interface AgentFactory {
  163. /**
  164. * Create a new agent on a caller-supplied session id. Async because creation
  165. * awaits unpublished setup, invokes its optional synchronous commit, inserts
  166. * both session and agent, announces session creation, and awaits serial
  167. * `agent/created` listeners before releasing queued work. The sequence is
  168. * rollback-covered, but notifications delivered before a later listener
  169. * failure remain observable; every agent or session creation announcement
  170. * that began is paired by `agent/disposed` or `session/disposed` during
  171. * rollback. The owner disposes the resolved handle to stop/drain,
  172. * unregister, remove the session, and unwind the scope.
  173. * The registry passes a context carrying the `create()` caller's fiber and
  174. * scope as `ownerCtx`. The implementation attaches the unpublished
  175. * transaction and resulting lifecycle to that owner; it must not infer
  176. * ownership from the factory object's registration context.
  177. * @param ownerCtx - caller-bound context that owns the transaction and live handle.
  178. * @param options - agent/session identity, configuration, optional live parent, and setup.
  179. * @returns the owned handle after setup and both creation announcements complete.
  180. */
  181. createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
  182. /**
  183. * Resume an agent on a persisted session. Async because it opens the
  184. * persisted session for write, reads and repairs the log, publishes it, and
  185. * awaits the optional unpublished setup transaction; must be called after
  186. * `ctx.sessionPersistence` exists (consumers inject `sessionPersistence`).
  187. * Publication follows the same setup-commit and ordered boundary as
  188. * {@link createAgent}.
  189. * @param ownerCtx - caller-bound context that owns load, setup, and the live handle.
  190. * @param options - persisted identity, configuration, optional live parent, and setup.
  191. * @returns the owned handle after setup, both announcements, and loop start complete.
  192. */
  193. resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
  194. }
  195. /** Thrown when create/resume is called before an agent factory is registered. */
  196. const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plugin)'
  197. const NO_INITIATOR_MESSAGE = 'no initiating agent is active'
  198. const DISPOSED_INITIATOR_MESSAGE = 'agent initiator scope is disposed'
  199. /** All mutable lifecycle state for one exact registry entry. */
  200. interface AgentEntry {
  201. readonly id: SessionId
  202. readonly agent: Agent
  203. /** Runtime creator-agent ownership; independent of durable session lineage. */
  204. readonly owner: Agent | undefined
  205. readonly carrier: Scoped<Agent>
  206. announced: boolean
  207. announcing: boolean
  208. detachRequested: boolean
  209. }
  210. /** One tracked boundary plus its inherited nesting chain. */
  211. interface InitiatorRun {
  212. active: boolean
  213. readonly parent: InitiatorRun | undefined
  214. }
  215. /** Plain holder prevents Cordis from tracing the factory field before the caller context is known. */
  216. interface FactorySlot {
  217. readonly target: AgentFactory
  218. }
  219. /**
  220. * Agent service (`ctx.agents`): tracks live agents and carries the initiating
  221. * Agent through one process-local asynchronous driver chain. Agent *creation*
  222. * is provided by whichever plugin implements the {@link AgentFactory}
  223. * (`@deepseek-ai/dsh-agent-loop`), registered via {@link setFactory}.
  224. *
  225. * Initiator methods provide same-process causal attribution only. Ambient
  226. * presence is neither liveness proof nor authorization; subjects and owners
  227. * remain explicit, as does identity at worker, process, persistence, and wire
  228. * boundaries. Returned Promise boundaries drain during teardown, except a
  229. * nested lineage that starts an owning-fiber unload is excluded from its own drain.
  230. */
  231. export class AgentRegistry extends Service {
  232. private store = new Map<SessionId, AgentEntry>()
  233. private factory: FactorySlot | undefined
  234. private readonly initiators = new AsyncLocalStorage<Agent | undefined>()
  235. private readonly initiatorRuns = new AsyncLocalStorage<InitiatorRun>()
  236. private initiatorState: 'active' | 'closing' | 'disposed' = 'active'
  237. private activeInitiatorRuns = 0
  238. private initiatorDrain: PromiseWithResolvers<void> | undefined
  239. private initiatorDisposal: Promise<void> | undefined
  240. constructor(ctx: Context) {
  241. super(ctx, 'agents')
  242. ctx.inject(['typert'], (typeCtx) => {
  243. typeCtx.typert.lookups.register('agent', {
  244. parameter: 'agent',
  245. wire: 'agentId',
  246. hostTypeSymbol: '@deepseek-ai/dsh-agent#Agent',
  247. wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
  248. resolve: sessionId => this.get(sessionId),
  249. })
  250. typeCtx.typert.contexts.registerHost('agent', {
  251. wire: 'agentId',
  252. wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
  253. resolve: sessionId => this.get(sessionId)?.ctx,
  254. })
  255. })
  256. ctx.on('internal/status', (fiber) => {
  257. if (fiber.state === FiberState.UNLOADING && this.hasLifecycleAncestor(fiber)) {
  258. this.closeInitiators()
  259. }
  260. })
  261. ctx.effect(function* (this: AgentRegistry) {
  262. yield () => this.disposeInitiators()
  263. yield () => { this.closeInitiators() }
  264. }.bind(this), 'agents.initiatorLifecycle()')
  265. }
  266. /**
  267. * Read the Agent that initiated the inherited asynchronous driver chain.
  268. * Use this optional form for logging, tracing, metrics, or host attribution
  269. * that also supports agentless calls. When a parent creates a child, setup
  270. * reports the causal parent while the setup callback's Agent parameter
  271. * identifies the child.
  272. * @returns the inherited Agent, or `undefined` outside an initiator boundary
  273. * and inside an explicit clearing boundary.
  274. * @throws when this service instance has been disposed.
  275. */
  276. currentInitiator(): Agent | undefined {
  277. this.assertInitiatorsReadable()
  278. return this.initiators.getStore()
  279. }
  280. /**
  281. * Read the initiating Agent and fail when no initiator boundary is active.
  282. * Use this for private helpers contractually below a driver, or for a
  283. * deployment-owned outbound request whose contract forbids agentless calls.
  284. * Generic or direct-call paths use optional lookup or explicit request fields.
  285. * @returns the inherited Agent.
  286. * @throws when no initiator is active or this service instance has been disposed.
  287. */
  288. requireInitiator(): Agent {
  289. const agent = this.currentInitiator()
  290. if (agent === undefined) throw new Error(NO_INITIATOR_MESSAGE)
  291. return agent
  292. }
  293. /**
  294. * Run an operation with one exact Agent as its process-local initiator. The
  295. * exact synchronous value or Promise returned by the operation is preserved.
  296. * Custom drivers and test harnesses wrap their complete returned foreground
  297. * lifetime.
  298. * A queue or wire receiver may establish this boundary only after validating
  299. * explicit identity and resolving the exact live Agent; this method does neither.
  300. * Detached work remains owned by the subsystem that starts it.
  301. * @param agent - initiating Agent to inherit; presence is neither liveness proof nor authorization.
  302. * @param operation - synchronous or asynchronous operation to invoke.
  303. * @returns the exact value returned by `operation`.
  304. * @throws when the initiator scope is closing/disposed, or when `operation` throws.
  305. */
  306. withInitiator<T>(agent: Agent, operation: () => T): T {
  307. return this.runWithInitiator(agent, operation)
  308. }
  309. /**
  310. * Run an operation inside a boundary that hides any inherited initiating
  311. * Agent. The exact synchronous value or Promise is preserved.
  312. * Use this while creating lazy shared timers, queue pumps, pool maintenance,
  313. * watchers, or exporters so they do not inherit the first Agent that happens
  314. * to initialize them. It clears only initiator attribution, not explicit
  315. * fields, and does not own or drain detached resources.
  316. * @param operation - synchronous or asynchronous operation to invoke without an initiator.
  317. * @returns the exact value returned by `operation`.
  318. * @throws when the initiator scope is closing/disposed, or when `operation` throws.
  319. */
  320. withoutInitiator<T>(operation: () => T): T {
  321. return this.runWithInitiator(undefined, operation)
  322. }
  323. /**
  324. * Register the agent-creation factory (the loop calls this on construction,
  325. * effect-scoped). A traced Cordis service is canonicalized to its concrete
  326. * target; each create/resume call is then traced through that caller's
  327. * context so ownership follows the caller without stacking proxy layers.
  328. * Throws if a factory is already registered. Returns the disposer; on
  329. * dispose the factory slot is cleared.
  330. * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to.
  331. * @returns the disposer that clears the factory slot. The exact
  332. * Cordis effect disposer (single-shot): composite (generator) effects may
  333. * yield it directly — exact identity nests the teardown in order.
  334. */
  335. setFactory(factory: AgentFactory): () => void {
  336. const dispose = this.ctx.effect(() => {
  337. if (this.factory !== undefined) throw new Error('an agent factory is already registered')
  338. // Avoid stacking two Cordis shadow layers when a caller passes a Service
  339. // already read through a context. Calls are re-traced through their
  340. // actual owner context below.
  341. const target = (factory as AgentFactory & { [symbols.original]?: AgentFactory })[symbols.original] ?? factory
  342. this.factory = { target }
  343. return () => { this.factory = undefined }
  344. }, 'agents.setFactory()')
  345. // The exact cordis effect disposer (the agents.register() convention): a
  346. // caller's composite effect can yield it for in-order teardown; the
  347. // loop's constructor effect returns it directly, identity-nesting the
  348. // registration under that effect.
  349. // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
  350. return dispose
  351. }
  352. /** Return the active creation factory. */
  353. private requireFactory(): FactorySlot {
  354. if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
  355. return this.factory
  356. }
  357. /**
  358. * Create and publish a new agent through the registered factory.
  359. * Distinct from {@link register} (which records an already-constructed
  360. * agent): this constructs the agent and its session. Rejects if no factory is
  361. * registered or creation/setup fails. The resolved {@link AgentHandle} lets
  362. * the owner tear down exactly this agent.
  363. * @param options - shared identity, optional live parent, session seed/metadata, and agent options.
  364. * @returns the handle after setup, rollback-covered publication, and loop start complete.
  365. */
  366. async create(options: CreateAgentOptions): Promise<AgentHandle> {
  367. const ownerCtx = this.ctx
  368. // Re-trace a Service-backed factory through the accessing context
  369. // explicitly. This preserves AgentLoop's dependency origin while binding
  370. // its effects to ownerCtx; plain factories receive ownerCtx as an explicit
  371. // capability and need no Cordis tracker magic.
  372. const { target } = this.requireFactory()
  373. const receiver = getTraceable(ownerCtx, target)
  374. // oxlint-disable-next-line typescript/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver
  375. return Reflect.apply(target.createAgent, receiver, [ownerCtx, options])
  376. }
  377. /**
  378. * Load a persisted session and resume an agent on it through the registered
  379. * factory. Rejects if no factory is registered; the factory rejects if
  380. * session persistence is not configured or persistence/setup fails.
  381. * @param options - persisted identity, optional live parent, configuration, and setup.
  382. * @returns the handle after setup, rollback-covered publication, and loop start complete.
  383. */
  384. async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
  385. const ownerCtx = this.ctx
  386. const { target } = this.requireFactory()
  387. const receiver = getTraceable(ownerCtx, target)
  388. // oxlint-disable-next-line typescript/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver
  389. return Reflect.apply(target.resume, receiver, [ownerCtx, options])
  390. }
  391. /**
  392. * Register a live agent. Rejects if the id is already registered or a
  393. * serial `agent/created` listener fails. Emits `agent/disposed`
  394. * when the calling fiber is disposed — both with the agent's scope carrier
  395. * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the
  396. * emits are scope-filtered regardless of which context invoked `register`
  397. * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always
  398. * requires passing the carrier). The entry is a runtime root; factory-backed
  399. * creation uses `options.parentAgent` for child ownership. Await the registration before using the agent.
  400. * @param agent - the already-constructed agent to record in the store.
  401. * @param source - creation source; defaults to fresh startup.
  402. * @returns the awaitable Cordis effect disposer (single-shot; a repeat call
  403. * returns undefined without awaiting an in-flight teardown). Exact
  404. * identity is load-bearing: a composite (generator) effect that owns a
  405. * teardown ORDER — the agent factory's lifecycle chain — must yield THIS
  406. * function so Cordis nests the unregistration at that yield position;
  407. * yielding a wrapper would leave it disposing as a concurrent sibling on
  408. * owner unload, unregistering the agent (and emitting `agent/disposed`)
  409. * while its final turn is still draining.
  410. */
  411. register(agent: Agent, source: SessionStartSource = 'startup'): ReturnType<Context['effect']> {
  412. return this.ctx.effect(async function* (this: AgentRegistry) {
  413. yield this.enter(agent, undefined)
  414. await this.announce(agent, source)
  415. }.bind(this), 'agents.register()')
  416. }
  417. /**
  418. * Insert an already-constructed agent without announcing it. This is the
  419. * advanced ordered-lifecycle primitive used by the async agent factory: it
  420. * first completes setup while the agent is unpublished, then assigns the
  421. * returned detach closure into its pre-installed composite teardown before
  422. * calling {@link announce}. Ordinary callers use {@link register}.
  423. * @param agent - the prepared, unpublished agent.
  424. * @param owner - explicitly supplied live runtime owner, or
  425. * undefined for a top-level runtime root. This is runtime ownership, not
  426. * the resumed session's durable parent lineage.
  427. * @returns an idempotent closure that removes this exact entry and emits
  428. * `agent/disposed` with listener failures contained. When called from a
  429. * `agent/created` listener, removal and disposal wait until the serial
  430. * creation dispatch settles.
  431. */
  432. enter(agent: Agent, owner: Agent | undefined): () => void {
  433. const id = agent.id
  434. if (id !== agent.session.id) {
  435. throw new Error(`agent id "${id}" does not match session id "${agent.session.id}"`)
  436. }
  437. const carrier = scopeTarget(agent, agent)
  438. // This is the authoritative collision boundary. Concurrent create/resume
  439. // operations may both prepare, but only one exact entry can publish.
  440. if (this.store.has(id)) throw new Error(`agent "${id}" is already registered`)
  441. const entry: AgentEntry = {
  442. id,
  443. agent,
  444. owner,
  445. carrier,
  446. announced: false,
  447. announcing: false,
  448. detachRequested: false,
  449. }
  450. this.store.set(id, entry)
  451. let entered = true
  452. const detach = (): void => {
  453. if (!entered) return
  454. entered = false
  455. // Every callback reached by this creation dispatch must observe the same
  456. // live entry, and disposal must follow creation. A listener may own
  457. // the advanced detach capability, so make that ordering structural:
  458. // visibility and the paired disposal are deferred until announce()'s
  459. // serial dispatch has settled.
  460. if (entry.announcing) {
  461. entry.detachRequested = true
  462. return
  463. }
  464. this.detachEntered(entry)
  465. }
  466. return detach
  467. }
  468. /** Remove one exact entered agent and emit its paired disposal when announced. */
  469. private detachEntered(entry: AgentEntry): void {
  470. entry.detachRequested = false
  471. // A stale capability can never delete a later same-id lifecycle. The
  472. // captured entry identity is the final boundary.
  473. /* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */
  474. if (this.store.get(entry.id) !== entry) return
  475. this.store.delete(entry.id)
  476. // An insertion rolled back before announce was never externally created,
  477. // so emitting disposed would invent an impossible lifecycle edge. Marking
  478. // happens before the created emit: if a later created listener throws,
  479. // earlier listeners may already have observed it and must see disposal.
  480. if (!entry.announced) return
  481. this.emitDisposed(entry)
  482. }
  483. /** Emit the paired disposal edge through the entry's stable carrier. */
  484. private emitDisposed(entry: AgentEntry): void {
  485. const args: unknown[] = [entry.carrier, 'agent/disposed', { agent: entry.agent }]
  486. for (const callback of this.ctx.events.dispatch('emit', args)) {
  487. try {
  488. const returned: unknown = callback(...args)
  489. void Promise.resolve(returned).catch((error: unknown) => {
  490. this.ctx.logger.warn(`agent "${entry.id}": agent/disposed listener rejected: ${String(error)}`)
  491. })
  492. } catch (error: unknown) {
  493. this.ctx.logger.warn(`agent "${entry.id}": agent/disposed listener threw: ${String(error)}`)
  494. }
  495. }
  496. }
  497. /**
  498. * Announce an agent previously inserted with {@link enter}.
  499. * @param agent - the live inserted agent to announce.
  500. * @param source - fresh creation, resume, clear, or compaction source.
  501. * @param signal - optional factory initialization cancellation signal passed to listeners.
  502. * @returns completion of the serial creation listeners; a listener failure rejects.
  503. * @throws if `agent` is not the exact live registry entry for its id, or its
  504. * creation announcement already began (including a reentrant call from a
  505. * creation listener).
  506. */
  507. async announce(agent: Agent, source: SessionStartSource, signal?: AbortSignal): Promise<void> {
  508. const entry = this.store.get(agent.id)
  509. if (entry === undefined || entry.agent !== agent) {
  510. throw new Error(`agent "${agent.id}" is not live in this registry`)
  511. }
  512. if (entry.announced || entry.announcing) {
  513. throw new Error(`agent "${entry.id}" was already announced`)
  514. }
  515. // Mark before dispatch so a listener cannot recursively create a second
  516. // lifecycle edge; detach still pairs a partially delivered first edge.
  517. entry.announcing = true
  518. entry.announced = true
  519. try {
  520. await this.ctx.serial(entry.carrier, 'agent/created', {
  521. agent: entry.agent,
  522. source,
  523. ...signal === undefined ? {} : { signal },
  524. })
  525. } finally {
  526. entry.announcing = false
  527. if (entry.detachRequested) this.detachEntered(entry)
  528. }
  529. }
  530. /**
  531. * Look up a live agent.
  532. * @param id - the shared agent/session id to look up.
  533. * @returns the agent, or undefined when no live agent has that id.
  534. */
  535. get(id: SessionId): Agent | undefined {
  536. return this.store.get(id)?.agent
  537. }
  538. /**
  539. * Test whether a live agent was created through one exact parent agent's
  540. * scoped context. Runtime ownership is independent of durable session
  541. * lineage and remains unambiguous when unrelated providers reuse an id.
  542. * @param id - the candidate child agent's shared agent/session id.
  543. * @param owner - the expected runtime creator agent.
  544. * @returns true only while the exact child entry is live under that owner.
  545. */
  546. isOwnedBy(id: SessionId, owner: Agent): boolean {
  547. return this.store.get(id)?.owner === owner
  548. }
  549. /**
  550. * All live agents, in registration order.
  551. * @returns a fresh array; mutating it does not affect the registry.
  552. */
  553. list(): Agent[] {
  554. return [...this.store.values()].map(entry => entry.agent)
  555. }
  556. /**
  557. * All live top-level agents in registration order. A top-level agent was
  558. * created without an owning agent context; durable session lineage does not
  559. * affect this runtime relation, so a resumed fork may still be a root.
  560. * @returns a fresh array; mutating it does not affect the registry.
  561. */
  562. roots(): Agent[] {
  563. return [...this.store.values()]
  564. .filter(entry => entry.owner === undefined)
  565. .map(entry => entry.agent)
  566. }
  567. /** Reject new initiator boundaries while inherited continuations drain. */
  568. private closeInitiators(): void {
  569. if (this.initiatorState === 'active') this.initiatorState = 'closing'
  570. }
  571. /** Wait for returned-Promise boundaries, then invalidate retained references. */
  572. private disposeInitiators(): Promise<void> {
  573. return (this.initiatorDisposal ??= (async () => {
  574. this.closeInitiators()
  575. this.releaseReentrantInitiatorRuns()
  576. if (this.activeInitiatorRuns !== 0) {
  577. this.initiatorDrain ??= Promise.withResolvers<void>()
  578. await this.initiatorDrain.promise
  579. }
  580. this.initiatorState = 'disposed'
  581. this.initiators.disable()
  582. this.initiatorRuns.disable()
  583. })())
  584. }
  585. /** Establish one tracked initiator or clearing boundary. */
  586. private runWithInitiator<T>(agent: Agent | undefined, operation: () => T): T {
  587. if (this.initiatorState !== 'active') throw new Error(DISPOSED_INITIATOR_MESSAGE)
  588. const run: InitiatorRun = {
  589. active: true,
  590. parent: this.initiatorRuns.getStore(),
  591. }
  592. this.activeInitiatorRuns += 1
  593. let result: T
  594. try {
  595. result = this.initiatorRuns.run(run, () => this.initiators.run(agent, operation))
  596. } catch (error: unknown) {
  597. this.releaseInitiatorRun(run)
  598. throw error
  599. }
  600. if (isPromise(result)) {
  601. try {
  602. void Promise.prototype.then.call(
  603. result,
  604. () => { this.releaseInitiatorRun(run) },
  605. () => { this.releaseInitiatorRun(run) },
  606. )
  607. } catch {
  608. // A branded Promise may expose a failing @@species. Observer setup did
  609. // not attach, so preserve the exact return without leaking the run.
  610. this.releaseInitiatorRun(run)
  611. }
  612. } else {
  613. this.releaseInitiatorRun(run)
  614. }
  615. return result
  616. }
  617. /** Whether one unloading fiber owns this service's lifecycle. */
  618. private hasLifecycleAncestor(candidate: Fiber): boolean {
  619. let fiber = this.ctx.fiber
  620. while (true) {
  621. if (fiber === candidate) return true
  622. const parent = fiber.parent.fiber
  623. if (parent === fiber) return false
  624. fiber = parent
  625. }
  626. }
  627. private assertInitiatorsReadable(): void {
  628. if (this.initiatorState === 'disposed') throw new Error(DISPOSED_INITIATOR_MESSAGE)
  629. }
  630. /** Exclude the boundary chain that initiated this teardown from its own drain. */
  631. private releaseReentrantInitiatorRuns(): void {
  632. let run = this.initiatorRuns.getStore()
  633. while (run !== undefined) {
  634. this.releaseInitiatorRun(run)
  635. run = run.parent
  636. }
  637. }
  638. private releaseInitiatorRun(run: InitiatorRun): void {
  639. if (!run.active) return
  640. run.active = false
  641. this.activeInitiatorRuns -= 1
  642. if (this.activeInitiatorRuns !== 0) return
  643. this.initiatorDrain?.resolve()
  644. this.initiatorDrain = undefined
  645. }
  646. }
  647. export default AgentRegistry