index.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. /**
  2. * Concrete agent-loop plugin: creates scoped ReactLoopAgents, publishes them
  3. * through the agent/session registries, and owns their ordered teardown.
  4. *
  5. * @module @deepseek-ai/dsh-agent-loop
  6. */
  7. import { Context, FiberState, Service } from 'cordis'
  8. import { randomUUID } from 'node:crypto'
  9. import z from 'schemastery'
  10. import { emitAgentEvent } from '@deepseek-ai/dsh-agent'
  11. import type {
  12. Agent,
  13. AgentFactory,
  14. AgentHandle,
  15. AgentOptions,
  16. AgentSetup,
  17. CreateAgentOptions,
  18. ResumeAgentOptions,
  19. SessionStartSource,
  20. } from '@deepseek-ai/dsh-agent'
  21. import { errorChain } from '@deepseek-ai/dsh-llm'
  22. import { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session'
  23. import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
  24. import type {} from '@deepseek-ai/dsh-system-prompt'
  25. import type {} from '@deepseek-ai/dsh-tools'
  26. import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
  27. import { ReactLoopAgent } from './agent.ts'
  28. import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
  29. /** Fiber states that cannot own or serve a new lifecycle. */
  30. const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
  31. FiberState.UNLOADING,
  32. FiberState.DISPOSED,
  33. FiberState.FAILED,
  34. ])
  35. /** Factory-level ownership: live agent teardowns plus config startup work. */
  36. class FactoryOwnership {
  37. private accepting = true
  38. private readonly teardown = new AbortController()
  39. private readonly inactive = Promise.withResolvers<void>()
  40. private readonly liveAgents = new Set<() => Promise<void>>()
  41. private startupTasks = new Set<Promise<void>>()
  42. constructor(private readonly fiber: Context['fiber']) {}
  43. /** Aborts (reason: `agent loop is not active` error) when factory teardown begins. */
  44. get signal(): AbortSignal {
  45. return this.teardown.signal
  46. }
  47. isActive(): boolean {
  48. return this.accepting && !INACTIVE_STATES.has(this.fiber.state)
  49. }
  50. /** Track one live agent's shared teardown until it has run. */
  51. track(dispose: () => Promise<void>): () => void {
  52. this.liveAgents.add(dispose)
  53. return () => { this.liveAgents.delete(dispose) }
  54. }
  55. /** Join config startup work that begins before an agent exists. */
  56. trackStartup(task: Promise<void>): void {
  57. this.startupTasks.add(task)
  58. const forget = () => { this.startupTasks.delete(task) }
  59. void task.then(forget, forget)
  60. }
  61. /** Join one public create/resume continuation; factory dispose awaits its settlement. */
  62. trackWrapper(task: Promise<unknown>): void {
  63. this.trackStartup(task.then(() => undefined, () => undefined))
  64. }
  65. /** Resolve `task`, or stop waiting when factory teardown begins. */
  66. async waitWhileActive(task: Promise<void>): Promise<void> {
  67. await Promise.race([task, this.inactive.promise])
  68. }
  69. async dispose(): Promise<void> {
  70. this.accepting = false
  71. this.teardown.abort(new Error('agent loop is not active'))
  72. this.inactive.resolve()
  73. await Promise.all([
  74. ...[...this.liveAgents].map(dispose => dispose()),
  75. ...this.startupTasks,
  76. ])
  77. }
  78. }
  79. /** Await `operation`, or throw the signal's reason as soon as it aborts. */
  80. async function raceAbort<T>(operation: PromiseLike<T> | T, signal: AbortSignal, id: SessionId): Promise<T> {
  81. const toAbortError = (): Error => signal.reason instanceof Error
  82. ? signal.reason
  83. : new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
  84. if (signal.aborted) throw toAbortError()
  85. const aborted = Promise.withResolvers<never>()
  86. const listener = (): void => { aborted.reject(toAbortError()) }
  87. signal.addEventListener('abort', listener, { once: true })
  88. try {
  89. return await Promise.race([Promise.resolve(operation), aborted.promise])
  90. } finally {
  91. signal.removeEventListener('abort', listener)
  92. }
  93. }
  94. /** Start an abortable operation and release a value that arrives after cancellation. */
  95. async function raceAbortCall<T>(
  96. operation: () => PromiseLike<T> | T,
  97. signal: AbortSignal,
  98. id: SessionId,
  99. releaseAbandoned?: (value: T) => void,
  100. ): Promise<T> {
  101. if (signal.aborted) {
  102. throw signal.reason instanceof Error
  103. ? signal.reason
  104. : new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
  105. }
  106. const pending = Promise.resolve().then(operation)
  107. try {
  108. return await raceAbort(pending, signal, id)
  109. } catch (error: unknown) {
  110. // oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while the operation is awaited.
  111. if (signal.aborted && releaseAbandoned !== undefined) {
  112. void pending.then(releaseAbandoned, () => undefined)
  113. }
  114. throw error
  115. }
  116. }
  117. /** Resolve the deployment-wide scheduler cap at the owning config boundary. */
  118. function resolveMaxParallelToolCalls(value: number | undefined): number {
  119. const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
  120. if (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1) {
  121. throw new Error('maxParallelToolCalls must be a positive integer')
  122. }
  123. return maxParallelToolCalls
  124. }
  125. /** Reject an output-token cap that cannot be represented exactly on the request wire. */
  126. function assertAgentOptions(options: AgentOptions): void {
  127. if (options.maxTokens !== undefined
  128. && (!Number.isSafeInteger(options.maxTokens) || options.maxTokens <= 0)) {
  129. throw new TypeError('agent maxTokens must be a positive safe integer')
  130. }
  131. }
  132. /** Prepared-but-unpublished agent resources sharing one memoized teardown. */
  133. interface PreparedAgent {
  134. agent: ReactLoopAgent
  135. /** Aborts when the factory unloads, the caller cancels, or teardown begins — ends any setup await. */
  136. signal: AbortSignal
  137. /** Enter registries, announce, notify session-start, and start the machine. */
  138. publish(source: SessionStartSource): AgentHandle
  139. /** Reverse teardown: stop the machine, unregister, unwind the scope. Memoized. */
  140. dispose(): Promise<void>
  141. }
  142. declare module 'cordis' {
  143. interface Context {
  144. agentLoop: AgentLoop
  145. /**
  146. * Launcher-owned exact session identities for configured agents, keyed by
  147. * the agent's config `id` and set with `ctx.provide()` before any Loader
  148. * entry mounts (see {@link CONFIGURED_AGENT_IDENTITIES_KEY}). A launcher
  149. * owns identity because only it knows whether the session already exists,
  150. * while the `cordis.yml` row keeps the model route as ordinary patchable
  151. * config. An entry with no matching key keeps its configured identity.
  152. */
  153. configuredAgentIdentities?: ConfiguredAgentIdentities
  154. }
  155. interface Events {
  156. /**
  157. * A declarative agent entry failed before it could publish a live agent.
  158. * Consumers that buffer work for the configured identity use this
  159. * transient signal to reject that work instead of waiting forever. Normal
  160. * factory teardown suppresses failures from the cancelled startup attempt.
  161. * @param payload.sessionId - exact shared agent/session identity that failed startup.
  162. * @param payload.error - persistence, setup, or publication failure.
  163. * @mode emit
  164. */
  165. 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void
  166. }
  167. }
  168. export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
  169. /**
  170. * One launcher-selected session identity for a configured agent. `resume`
  171. * distinguishes rehydrating existing persisted history from creating the
  172. * session fresh under that exact id, which the two config keys express as
  173. * `resumeSessionId` and `sessionId`.
  174. */
  175. export interface LauncherAgentIdentity {
  176. /** Exact session id to create fresh or resume. */
  177. id: SessionId
  178. /** Resume existing persisted history instead of creating the session fresh. */
  179. resume: boolean
  180. }
  181. /** Launcher-selected identities keyed by the configured agent's `id`. */
  182. export interface ConfiguredAgentIdentities extends Readonly<Record<string, LauncherAgentIdentity>> {}
  183. /**
  184. * Context key a launcher sets before any Loader entry mounts
  185. * (`ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, identities)`) to fix
  186. * configured agents' session identities without a config key, so an overlay
  187. * repointing the row's model route cannot drop them.
  188. */
  189. export const CONFIGURED_AGENT_IDENTITIES_KEY = 'configuredAgentIdentities'
  190. /**
  191. * Apply launcher-owned identities over the configured agents, replacing both
  192. * identity keys for every entry the launcher named so a config-supplied
  193. * identity can never survive alongside a launcher-supplied one.
  194. * @param agents - the configured agent entries.
  195. * @param identities - launcher identities keyed by configured agent `id`, or `undefined`.
  196. * @returns the entries with launcher-owned identities applied.
  197. */
  198. function applyLauncherIdentities(
  199. agents: Config['agents'],
  200. identities: ConfiguredAgentIdentities | undefined,
  201. ): Config['agents'] {
  202. if (identities === undefined) return agents
  203. return agents.map((agent) => {
  204. const identity = identities[agent.id]
  205. if (identity === undefined) return agent
  206. const { sessionId: _sessionId, resumeSessionId: _resumeSessionId, ...rest } = agent
  207. return identity.resume
  208. ? { ...rest, resumeSessionId: identity.id }
  209. : { ...rest, sessionId: identity.id }
  210. })
  211. }
  212. /** Agent-loop plugin configuration. */
  213. export interface Config {
  214. /**
  215. * Maximum parallel-safe calls in flight per agent step. `1` is serial;
  216. * omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
  217. */
  218. maxParallelToolCalls?: number
  219. /** Agents created or resumed at plugin startup. */
  220. agents: (AgentOptions & {
  221. /** Stable config label used in logs and as the fresh combined-id prefix. */
  222. id: string
  223. /** Optional stable identity; remounts resume its materialized history, while first use creates it fresh. */
  224. sessionId?: SessionId
  225. /** Optional workspace for a fresh session. */
  226. cwd?: string
  227. /** Persisted session to resume instead of creating a fresh session. */
  228. resumeSessionId?: SessionId
  229. })[]
  230. }
  231. /** Agent-loop configuration after defaults and load-time validation. */
  232. type ResolvedConfig = Config & { maxParallelToolCalls: number }
  233. /** Reject self-contained identity conflicts before any configured agent starts. */
  234. function validateConfiguredAgents(agents: Config['agents']): void {
  235. const exactIdentities = new Map<SessionId, string>()
  236. for (const { id, sessionId, resumeSessionId } of agents) {
  237. const hasResumeId = resumeSessionId !== undefined && resumeSessionId !== ''
  238. if (sessionId !== undefined && hasResumeId) {
  239. throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`)
  240. }
  241. const exactIdentity = hasResumeId ? resumeSessionId : sessionId
  242. if (exactIdentity === undefined) continue
  243. const firstId = exactIdentities.get(exactIdentity)
  244. if (firstId !== undefined) {
  245. throw new Error(`agents "${firstId}" and "${id}" use duplicate exact session identity "${exactIdentity}"`)
  246. }
  247. exactIdentities.set(exactIdentity, id)
  248. }
  249. }
  250. /** Concrete agent factory and driver service. */
  251. export class AgentLoop extends Service implements AgentFactory {
  252. static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
  253. /** Runtime schema for declarative agents. */
  254. static Config = z.object({
  255. maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
  256. agents: z.array(z.object({
  257. id: z.string().required(),
  258. sessionId: z.string().min(1),
  259. provider: z.string(),
  260. model: z.string(),
  261. maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
  262. cwd: z.string(),
  263. resumeSessionId: z.string(),
  264. })).default([]),
  265. }) as z<Config>
  266. /** Validated configuration owned by the agent-loop service. */
  267. readonly config: ResolvedConfig
  268. private readonly ownership: FactoryOwnership
  269. /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
  270. private readonly runtime: { ctx: Context }
  271. constructor(ctx: Context, config: Config) {
  272. super(ctx, 'agentLoop')
  273. this.config = {
  274. ...config,
  275. agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)),
  276. maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls),
  277. }
  278. validateConfiguredAgents(this.config.agents)
  279. this.ownership = new FactoryOwnership(ctx.fiber)
  280. this.runtime = { ctx }
  281. ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
  282. ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
  283. ctx.systemPrompt.variable('provider', context => context.agent?.options.provider)
  284. ctx.systemPrompt.variable('model', context => context.agent?.options.model)
  285. ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
  286. for (const { id, sessionId, cwd, resumeSessionId, ...options } of this.config.agents) {
  287. const meta = cwd === undefined ? {} : { cwd }
  288. if (resumeSessionId === undefined || resumeSessionId === '') {
  289. const configuredId = sessionId ?? SessionId(`${id}-session-${randomUUID()}`)
  290. const persistence = sessionId === undefined ? undefined : ctx.get('sessionPersistence')
  291. if (persistence === undefined) {
  292. this.create(configuredId, options, meta)
  293. } else {
  294. const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => {
  295. this.reportConfiguredStartupFailure(id, 'restore', configuredId, error)
  296. })
  297. this.ownership.trackStartup(startup)
  298. }
  299. continue
  300. }
  301. ctx.effect(() => {
  302. const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
  303. void this.resumeWith(ctx, childCtx.sessionPersistence, {
  304. resumeSessionId,
  305. agentOptions: options,
  306. }).catch((error: unknown) => {
  307. this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error)
  308. })
  309. })
  310. return fiber.dispose
  311. }, `agentLoop.resume(${id})`)
  312. }
  313. }
  314. /** Report a contained declarative-start failure to identity-bound consumers. */
  315. private reportConfiguredStartupFailure(
  316. configId: string,
  317. action: 'restore' | 'resume',
  318. sessionId: SessionId,
  319. error: unknown,
  320. ): void {
  321. if (!this.ownership.isActive()) return
  322. this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`)
  323. const args: unknown[] = ['agent-loop/config-start-failed', { sessionId, error }]
  324. for (const callback of this.ctx.events.dispatch('emit', args)) {
  325. try {
  326. const returned: unknown = callback(...args)
  327. void Promise.resolve(returned).catch((listenerError: unknown) => {
  328. this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${errorChain(listenerError)}`)
  329. })
  330. } catch (listenerError: unknown) {
  331. this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${errorChain(listenerError)}`)
  332. }
  333. }
  334. }
  335. /** Restore a materialized exact config identity on remount, or create it on first use. */
  336. private async restoreOrCreateConfigured(
  337. ownerCtx: Context,
  338. persistence: SessionPersistence,
  339. sessionId: SessionId,
  340. agentOptions: AgentOptions,
  341. meta: Pick<SessionHeader, 'cwd'>,
  342. ): Promise<void> {
  343. await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId)
  344. if (!this.ownership.isActive()) return
  345. try {
  346. await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions })
  347. return
  348. } catch (error: unknown) {
  349. if (!this.ownership.isActive()) return
  350. // A load is the per-id serialization barrier for eager write-behind and
  351. // lifecycle retirement. Only a genuinely absent artifact falls back to
  352. // first creation; corruption and backend failures stay loud.
  353. const exists = (await persistence.list()).some(header => header.id === sessionId)
  354. if (exists) throw error
  355. }
  356. this.create(sessionId, agentOptions, meta)
  357. }
  358. /** Wait for a draining same-id lifecycle to finish registry teardown. */
  359. private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise<void> {
  360. // Only an id still occupying a registry needs waiting for; a live healthy
  361. // occupant is a collision the create/resume below will surface itself.
  362. if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) return
  363. const released = Promise.withResolvers<void>()
  364. const checkReleased = (): void => {
  365. if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) {
  366. released.resolve()
  367. }
  368. }
  369. const disposeAgentListener = ownerCtx.on('agent/disposed', () => { checkReleased() })
  370. const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased)
  371. try {
  372. checkReleased()
  373. await this.ownership.waitWhileActive(released.promise)
  374. } finally {
  375. disposeAgentListener()
  376. disposeSessionListener()
  377. }
  378. }
  379. /**
  380. * Construct the driver, scope, and one memoized reverse teardown for a new
  381. * agent. The teardown is registered with the factory and the owner fiber
  382. * BEFORE publication, so a mid-setup unload rolls everything back; `signal`
  383. * fuses caller cancellation with lifecycle teardown for setup awaits.
  384. */
  385. private prepare(ownerCtx: Context, id: SessionId, options: AgentOptions, session: Session, callerSignal?: AbortSignal): PreparedAgent {
  386. assertAgentOptions(options)
  387. ownerCtx.fiber.assertActive()
  388. // Every caller reaches prepare() synchronously from a service method
  389. // whose Cordis dispatch already requires the live factory fiber, or
  390. // re-checks ownership itself after its awaits (resume's load barrier).
  391. /* v8 ignore next -- unreachable backstop, see above */
  392. if (!this.ownership.isActive()) throw new Error('agent loop is not active')
  393. if (callerSignal?.aborted) {
  394. throw callerSignal.reason instanceof Error
  395. ? callerSignal.reason
  396. : new Error(`agent "${id}" creation aborted`, { cause: callerSignal.reason })
  397. }
  398. const loopCtx = this.runtime.ctx
  399. // Deactivation fuses three owners, each with its own reason: the caller's
  400. // cancellation signal, the owner fiber's unload, and factory teardown.
  401. // It is registered BEFORE any resource exists, over mutable slots, so an
  402. // unload arriving while the scope is still minting finds a working
  403. // disposer instead of a leak.
  404. const abort = new AbortController()
  405. const onCallerAbort = (): void => {
  406. abort.abort(callerSignal?.reason instanceof Error
  407. ? callerSignal.reason
  408. : new Error(`agent "${id}" creation aborted`, { cause: callerSignal?.reason }))
  409. }
  410. const onFactoryTeardown = (): void => { abort.abort(this.ownership.signal.reason) }
  411. callerSignal?.addEventListener('abort', onCallerAbort, { once: true })
  412. this.ownership.signal.addEventListener('abort', onFactoryTeardown, { once: true })
  413. let machine: ReactLoopAgent | undefined
  414. let detachSession: (() => void) | undefined
  415. let detachAgent: (() => void) | undefined
  416. let disposing: Promise<void> | undefined
  417. const machineReady = Promise.withResolvers<void>()
  418. // Reverse teardown, memoized so every racing owner awaits one quiescence:
  419. // stop the machine, leave the registries, unwind the scope, release
  420. // bookkeeping.
  421. const dispose = (ownerTriggered = false): Promise<void> => (disposing ??= (async () => {
  422. abort.abort(new Error(`agent "${id}" lifecycle disposed`))
  423. callerSignal?.removeEventListener('abort', onCallerAbort)
  424. this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
  425. try {
  426. // Disposal IS a disposed-cause cancel followed by quiescence. New work
  427. // sent after this point is the sender's bug — the registries are about
  428. // to drop the agent, so nothing should still hold it.
  429. if (machine === undefined) await machineReady.promise
  430. if (machine !== undefined) {
  431. machine.cancel({ kind: 'disposed' })
  432. await machine.whenIdle()
  433. await machine.scope.dispose()
  434. }
  435. } finally {
  436. try {
  437. detachAgent?.()
  438. detachSession?.()
  439. } finally {
  440. untrack()
  441. if (!ownerTriggered) await unfollowOwner()
  442. }
  443. }
  444. })())
  445. const untrack = this.ownership.track(dispose)
  446. let unfollowOwner: () => Promise<void> | void
  447. try {
  448. unfollowOwner = ownerCtx.effect(() => () => {
  449. // Owner disposal owns the same quiescence boundary. Its teardown skips
  450. // unregistering this already-running owner effect from inside itself.
  451. if (disposing !== undefined) return
  452. abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
  453. return dispose(true)
  454. }, `agentLoop.lifecycle(${id})`)
  455. /* v8 ignore start -- ctx.effect throws only on an inactive fiber, which assertActive() above already rejected */
  456. } catch (error: unknown) {
  457. untrack()
  458. callerSignal?.removeEventListener('abort', onCallerAbort)
  459. this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
  460. throw error
  461. }
  462. /* v8 ignore stop */
  463. const assertLive = (): void => {
  464. if (!abort.signal.aborted) return
  465. // Every fused abort source carries an Error reason: onCallerAbort and
  466. // raceAbort wrap non-Error caller reasons, and the factory/lifecycle
  467. // owners abort with constructed Errors.
  468. /* v8 ignore next -- unreachable String() arm, see above */
  469. throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason))
  470. }
  471. try {
  472. const agent = machine = new ReactLoopAgent(loopCtx, id, options, session)
  473. machineReady.resolve()
  474. assertLive()
  475. return {
  476. agent,
  477. signal: abort.signal,
  478. publish: (source) => {
  479. assertLive()
  480. detachSession = agent.ctx.sessions.enter(session)
  481. detachAgent = loopCtx.agents.enter(agent, ownerCtx.agent)
  482. agent.ctx.sessions.announce(session)
  483. assertLive()
  484. loopCtx.agents.announce(agent)
  485. assertLive()
  486. // A synchronous announce/session-start listener may have started
  487. // teardown; the machine is already live (delivery works from the
  488. // session-start seam), so only the liveness recheck is owed.
  489. emitAgentEvent(loopCtx, agent, 'agent/session-start', { source })
  490. assertLive()
  491. return { agent, dispose }
  492. },
  493. dispose,
  494. }
  495. } catch (error: unknown) {
  496. machineReady.resolve()
  497. void dispose()
  498. throw error
  499. }
  500. }
  501. /**
  502. * Create an agent and session under one caller-supplied identity, owned by
  503. * the accessing fiber. Constructor-driven config calls mint a fresh combined
  504. * id before entering this boundary.
  505. * @param id - shared agent/session identity.
  506. * @param options - concrete loop options.
  507. * @param meta - optional fresh-session workspace metadata.
  508. * @returns the published running agent.
  509. */
  510. create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent {
  511. using preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, { meta }))
  512. const prepared = this.prepare(this.ctx, id, options, preparation.session)
  513. try {
  514. return prepared.publish('startup').agent
  515. } catch (error: unknown) {
  516. void prepared.dispose()
  517. throw error
  518. }
  519. }
  520. /**
  521. * Create an owned agent on a caller-supplied session id.
  522. * @param ownerCtx - caller context that structurally owns the lifecycle.
  523. * @param options - identities, session seed/metadata, loop options, setup, and cancellation.
  524. * @returns the published handle.
  525. */
  526. async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
  527. const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, {
  528. ...options.seed === undefined ? {} : { seed: options.seed },
  529. ...options.meta === undefined ? {} : { meta: options.meta },
  530. }))
  531. const published = this.setupAndPublish(
  532. ownerCtx,
  533. options.sessionId,
  534. preparation,
  535. options.agentOptions ?? {},
  536. options.setup,
  537. options.signal,
  538. 'startup',
  539. )
  540. this.ownership.trackWrapper(published)
  541. return published
  542. }
  543. /** Prepare one Agent around an acquired Session, run setup, and publish it. */
  544. private async setupAndPublish(
  545. ownerCtx: Context,
  546. id: SessionId,
  547. preparation: SessionPreparation,
  548. agentOptions: AgentOptions,
  549. setup: AgentSetup | undefined,
  550. signal: AbortSignal | undefined,
  551. source: SessionStartSource,
  552. ): Promise<AgentHandle> {
  553. using ownedPreparation = preparation
  554. const session = ownedPreparation.session
  555. const prepared = this.prepare(ownerCtx, id, agentOptions, session, signal)
  556. try {
  557. const setupCommit = await raceAbort(setup?.(prepared.agent.ctx), prepared.signal, id)
  558. setupCommit?.commit()
  559. return prepared.publish(source)
  560. } catch (error: unknown) {
  561. await prepared.dispose()
  562. throw error
  563. }
  564. }
  565. /**
  566. * Resume an owned agent from the configured persistence service.
  567. * @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
  568. * @param options - persisted identity, loop options, setup, and cancellation.
  569. * @returns the published handle.
  570. */
  571. async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle> {
  572. const persistence = this.runtime.ctx.get('sessionPersistence')
  573. if (persistence === undefined) {
  574. throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
  575. }
  576. return this.resumeWith(ownerCtx, persistence, options)
  577. }
  578. /** Resume through an explicit persistence handle used by the deferred config path. */
  579. private resumeWith(
  580. ownerCtx: Context,
  581. persistence: SessionPersistence,
  582. options: ResumeAgentOptions,
  583. ): Promise<AgentHandle> {
  584. const id = options.resumeSessionId
  585. const published = (async () => {
  586. // The load may outlive its owner: race it against caller cancellation,
  587. // owner-fiber unload, and factory teardown so a never-settling backend
  588. // cannot pin the identity.
  589. const ownerAbort = new AbortController()
  590. const unfollowOwner = ownerCtx.effect(() => () => {
  591. ownerAbort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
  592. }, `agentLoop.resume-load(${id})`)
  593. const fused = AbortSignal.any([
  594. ...options.signal === undefined ? [] : [options.signal],
  595. ownerAbort.signal,
  596. this.ownership.signal,
  597. ])
  598. let preparation: SessionPreparation | undefined
  599. try {
  600. try {
  601. preparation = await raceAbortCall(
  602. () => persistence.prepare(id, fused),
  603. fused,
  604. id,
  605. (abandoned) => { abandoned[Symbol.dispose]() },
  606. )
  607. } finally {
  608. await unfollowOwner()
  609. }
  610. ownerCtx.fiber.assertActive()
  611. if (!this.ownership.isActive()) throw new Error('agent loop is not active')
  612. return await this.setupAndPublish(
  613. ownerCtx,
  614. id,
  615. preparation,
  616. options.agentOptions ?? {},
  617. options.setup,
  618. options.signal,
  619. 'resume',
  620. )
  621. } finally {
  622. preparation?.[Symbol.dispose]()
  623. }
  624. })()
  625. this.ownership.trackWrapper(published)
  626. return published
  627. }
  628. }
  629. export default AgentLoop