index.ts 24 KB

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