index.ts 29 KB

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