index.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  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 { createScope } from '@deepseek-ai/dsh-scope'
  11. import type { Scope } from '@deepseek-ai/dsh-scope'
  12. import { agentEvents } from '@deepseek-ai/dsh-agent'
  13. import type {
  14. Agent,
  15. AgentFactory,
  16. AgentHandle,
  17. AgentOptions,
  18. CreateAgentOptions,
  19. ResumeAgentOptions,
  20. SessionStartSource,
  21. } from '@deepseek-ai/dsh-agent'
  22. import type {} from '@deepseek-ai/dsh-llm'
  23. import { SessionId } from '@deepseek-ai/dsh-session'
  24. import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
  25. import type {} from '@deepseek-ai/dsh-system-prompt'
  26. import type {} from '@deepseek-ai/dsh-tools'
  27. import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
  28. import {
  29. bindReactLoopAgentContext,
  30. prepareReactLoopAgent,
  31. ReactLoopAgent,
  32. } from './agent.ts'
  33. import type { PreparedReactLoopAgent } from './agent.ts'
  34. import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
  35. /** Fiber states that cannot own or serve a new lifecycle. */
  36. const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
  37. FiberState.UNLOADING,
  38. FiberState.DISPOSED,
  39. FiberState.FAILED,
  40. ])
  41. /** Render an arbitrary thrown value without letting coercion escape containment. */
  42. function renderThrown(value: unknown): string {
  43. try {
  44. return String(value)
  45. } catch {
  46. return '<unrenderable thrown value>'
  47. }
  48. }
  49. /** Factory-level ownership of every preparing or live transaction. */
  50. class FactoryOwnership {
  51. private accepting = true
  52. private readonly inactive = Promise.withResolvers<void>()
  53. private transactions = new Set<AgentCreationTransaction>()
  54. private startupTasks = new Set<Promise<void>>()
  55. constructor(private readonly fiber: Context['fiber']) {}
  56. isActive(): boolean {
  57. return this.accepting && !INACTIVE_STATES.has(this.fiber.state)
  58. }
  59. track(transaction: AgentCreationTransaction): () => void {
  60. this.transactions.add(transaction)
  61. return () => { this.transactions.delete(transaction) }
  62. }
  63. /** Join config startup work that begins before an agent transaction exists. */
  64. trackStartup(task: Promise<void>): void {
  65. this.startupTasks.add(task)
  66. const forget = () => { this.startupTasks.delete(task) }
  67. void task.then(forget, forget)
  68. }
  69. /** Resolve `task`, or stop waiting when factory teardown begins. */
  70. async waitWhileActive(task: Promise<void>): Promise<void> {
  71. await Promise.race([task, this.inactive.promise])
  72. }
  73. async dispose(): Promise<void> {
  74. this.accepting = false
  75. this.inactive.resolve()
  76. const reason = new Error('agent loop is not active')
  77. await Promise.all([
  78. ...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
  79. ...this.startupTasks,
  80. ])
  81. }
  82. }
  83. /** Build the public cancellation error while preserving a caller-supplied cause. */
  84. function signalAbortError(id: SessionId, signal: AbortSignal): Error {
  85. if (signal.reason instanceof Error) return signal.reason
  86. return new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
  87. }
  88. /** Resolve the deployment-wide scheduler cap at the owning config boundary. */
  89. function resolveMaxParallelToolCalls(value: number | undefined): number {
  90. const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
  91. if (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1) {
  92. throw new Error('maxParallelToolCalls must be a positive integer')
  93. }
  94. return maxParallelToolCalls
  95. }
  96. /**
  97. * Caller-owned create/resume transaction through rollback-covered publication
  98. * and quiescent teardown. Resources remain private until the final registry
  99. * entry arbitrates identity.
  100. */
  101. class AgentCreationTransaction {
  102. private active = true
  103. private failure: Error | undefined
  104. private readonly deactivation = Promise.withResolvers<void>()
  105. private readonly publication = Promise.withResolvers<void>()
  106. private readonly torndown = Promise.withResolvers<void>()
  107. private readonly wrapperCompletion = Promise.withResolvers<void>()
  108. private preparing: Promise<void> | undefined
  109. private driver: PreparedReactLoopAgent | undefined
  110. private scope: Scope | undefined
  111. private session: Session | undefined
  112. private lifecycleDispose: (() => Promise<void> | void) | undefined
  113. private detachSession: (() => void) | undefined
  114. private detachAgent: (() => void) | undefined
  115. private publishing = false
  116. private cleanupTask: Promise<void> | undefined
  117. private ownerFollowing = true
  118. private readonly ownerDispose: () => Promise<void> | void
  119. private readonly untrackFactory: () => void
  120. private readonly abortListener: (() => void) | undefined
  121. readonly ownerAgent: Context['agent']
  122. readonly ownerFiber: Context['fiber']
  123. constructor(
  124. private readonly loopCtx: Context,
  125. private readonly ownerCtx: Context,
  126. private readonly ownership: FactoryOwnership,
  127. readonly id: SessionId,
  128. signal?: AbortSignal,
  129. ) {
  130. ownerCtx.fiber.assertActive()
  131. this.ownerAgent = ownerCtx.agent
  132. this.ownerFiber = ownerCtx.fiber
  133. if (!ownership.isActive()) throw new Error('agent loop is not active')
  134. this.ownerDispose = ownerCtx.effect(() => () => {
  135. if (!this.ownerFollowing) return
  136. return this.dispose(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
  137. }, `agentLoop.owner(${id})`)
  138. this.untrackFactory = ownership.track(this)
  139. if (signal === undefined) {
  140. this.abortListener = undefined
  141. } else {
  142. this.abortListener = () => {
  143. /* v8 ignore next 3 -- transaction teardown contains callback/driver failures; rejection is a future-drift backstop. */
  144. void this.dispose(signalAbortError(id, signal)).catch((error: unknown) => {
  145. this.loopCtx.logger.error(error)
  146. })
  147. }
  148. signal.addEventListener('abort', this.abortListener, { once: true })
  149. if (signal.aborted) this.deactivate(signalAbortError(id, signal))
  150. }
  151. this.signal = signal
  152. }
  153. private readonly signal: AbortSignal | undefined
  154. /** Whether caller, provider, and optional parent-agent ownership remain live. */
  155. isActive(): boolean {
  156. return this.active
  157. && this.ownership.isActive()
  158. && this.ownerFiber.uid !== null
  159. && !INACTIVE_STATES.has(this.ownerFiber.state)
  160. && this.ownerAgent?.status !== 'disposed'
  161. }
  162. /** Fail synchronously at every real lifecycle boundary after deactivation. */
  163. assertActive(): void {
  164. if (this.isActive()) return
  165. if (!this.ownership.isActive()) throw new Error('agent loop is not active')
  166. throw this.failure ?? new Error(`agent "${this.id}" setup aborted: owner disposed during setup`)
  167. }
  168. /** Race an external async operation against structural/signal deactivation. */
  169. async waitFor<T>(operation: PromiseLike<T> | T): Promise<T> {
  170. this.assertActive()
  171. return await Promise.race([
  172. Promise.resolve(operation),
  173. this.deactivation.promise.then(() => {
  174. /* v8 ignore next -- deactivate() assigns failure before resolving deactivation. */
  175. throw this.failure ?? new Error(`agent "${this.id}" creation deactivated`)
  176. }),
  177. ])
  178. }
  179. /** Construct the driver and scope, then install their complete ordered lifecycle. */
  180. prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent {
  181. this.assertActive()
  182. const gate = Promise.withResolvers<void>()
  183. this.preparing = gate.promise
  184. try {
  185. this.session = session
  186. const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session, maxParallelToolCalls)
  187. this.driver = driver
  188. const agent = driver.agent
  189. const scope = createScope(this.loopCtx, agent)
  190. this.scope = scope
  191. bindReactLoopAgentContext(agent, scope.ctx.extend({ agent }))
  192. this.installLifecycle(scope, driver)
  193. this.assertActive()
  194. return agent
  195. } catch (error: unknown) {
  196. if (!this.isActive() && error instanceof Error && /inactive context/.test(error.message)) {
  197. throw this.failure ?? this.disposalReason()
  198. }
  199. throw error
  200. } finally {
  201. gate.resolve()
  202. this.preparing = undefined
  203. }
  204. }
  205. /** Register the exact scope disposer inside the ordered transaction effect. */
  206. private installLifecycle(scope: Scope, driver: PreparedReactLoopAgent): void {
  207. this.lifecycleDispose = this.ownerCtx.effect(function* (this: AgentCreationTransaction) {
  208. // First yielded, disposed last.
  209. yield () => { this.finish() }
  210. yield scope.rawDispose
  211. yield () => {
  212. this.detachSession?.()
  213. this.detachSession = undefined
  214. }
  215. yield () => {
  216. this.detachAgent?.()
  217. this.detachAgent = undefined
  218. }
  219. // Last yielded, disposed first.
  220. yield () => {
  221. this.deactivate(this.disposalReason())
  222. if (this.publishing) {
  223. return this.publication.promise.then(() => driver.dispose())
  224. }
  225. return driver.dispose()
  226. }
  227. }.bind(this), `agentLoop.lifecycle(${this.id})`)
  228. }
  229. /** Publish the exact prepared objects and start the driver. */
  230. publish(source: SessionStartSource): AgentHandle {
  231. this.assertActive()
  232. const driver = this.driver
  233. /* v8 ignore next -- publish() is private and every caller invokes prepare() first. */
  234. if (driver === undefined) throw new Error(`agent "${this.id}" is not prepared`)
  235. const agent = driver.agent
  236. const session = this.session
  237. /* v8 ignore next -- prepare() assigns the session before it can produce the driver above. */
  238. if (session === undefined) throw new Error(`agent "${this.id}" has no prepared session`)
  239. this.publishing = true
  240. try {
  241. this.detachSession = agent.ctx.sessions.enter(session)
  242. this.detachAgent = this.loopCtx.agents.enter(agent, this.ownerAgent)
  243. agent.ctx.sessions.announce(session)
  244. this.assertActive()
  245. this.loopCtx.agents.announce(agent)
  246. this.assertActive()
  247. driver.markPublished()
  248. agentEvents(this.loopCtx, agent).emit('agent/session-start', source)
  249. this.assertActive()
  250. driver.startDriver()
  251. return { agent, dispose: () => this.dispose() }
  252. } finally {
  253. this.publishing = false
  254. this.publication.resolve()
  255. }
  256. }
  257. /** Mark the transaction inactive exactly once and wake load/setup races. */
  258. private deactivate(reason: Error): void {
  259. if (!this.active) return
  260. this.active = false
  261. this.failure = reason
  262. this.deactivation.resolve()
  263. }
  264. /** Choose the structural cause when an owner/factory effect starts teardown first. */
  265. private disposalReason(): Error {
  266. if (this.failure !== undefined) return this.failure
  267. if (!this.ownership.isActive()) return new Error('agent loop is not active')
  268. if (this.ownerFiber.uid === null || INACTIVE_STATES.has(this.ownerFiber.state) || this.ownerAgent?.status === 'disposed') {
  269. return new Error(`agent "${this.id}" setup aborted: owner disposed during setup`)
  270. }
  271. return new Error(`agent "${this.id}" lifecycle disposed`)
  272. }
  273. /** Complete ownership bookkeeping after every resource reached quiescence. */
  274. private finish(): void {
  275. this.untrackFactory()
  276. this.ownerFollowing = false
  277. void this.ownerDispose()
  278. this.torndown.resolve()
  279. }
  280. /**
  281. * Deactivate and quiesce this transaction. The promise is memoized because
  282. * Cordis effect disposers are single-shot while handles promise shared
  283. * quiescence to every racing owner.
  284. */
  285. dispose(reason = new Error(`agent "${this.id}" lifecycle disposed`)): Promise<void> {
  286. this.deactivate(reason)
  287. return (this.cleanupTask ??= (async () => {
  288. if (this.preparing !== undefined) await this.preparing
  289. if (this.lifecycleDispose !== undefined) {
  290. await this.lifecycleDispose()
  291. await this.torndown.promise
  292. return
  293. }
  294. try {
  295. await this.driver?.dispose()
  296. } finally {
  297. try {
  298. await this.scope?.dispose()
  299. } finally {
  300. this.finish()
  301. }
  302. }
  303. })())
  304. }
  305. /** Mark the public create/resume continuation settled and detach its creation-only signal. */
  306. finishWrapper(): void {
  307. if (this.signal !== undefined && this.abortListener !== undefined) {
  308. this.signal.removeEventListener('abort', this.abortListener)
  309. }
  310. this.wrapperCompletion.resolve()
  311. }
  312. /** Factory shutdown joins both resource teardown and the public wrapper's deactivation continuation. */
  313. async disposeForFactory(reason: Error): Promise<void> {
  314. await this.dispose(reason)
  315. await this.wrapperCompletion.promise
  316. }
  317. }
  318. declare module 'cordis' {
  319. interface Context {
  320. agentLoop: AgentLoop
  321. }
  322. interface Events {
  323. /**
  324. * A declarative agent entry failed before it could publish a live agent.
  325. * Consumers that buffer work for the configured identity use this
  326. * transient signal to reject that work instead of waiting forever. Normal
  327. * factory teardown suppresses failures from the cancelled startup attempt.
  328. * @param sessionId - exact shared agent/session identity that failed startup.
  329. * @param error - persistence, setup, or publication failure.
  330. * @mode emit
  331. */
  332. 'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
  333. }
  334. }
  335. export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
  336. /** Agent-loop plugin configuration. */
  337. export interface Config {
  338. /**
  339. * Maximum parallel-safe calls in flight per agent step. `1` is serial;
  340. * omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
  341. */
  342. maxParallelToolCalls?: number
  343. /** Agents created or resumed at plugin startup. */
  344. agents: (AgentOptions & {
  345. /** Stable config label used in logs and as the fresh combined-id prefix. */
  346. id: string
  347. /** Optional stable identity; remounts resume its materialized history, while first use creates it fresh. */
  348. sessionId?: SessionId
  349. /** Optional workspace for a fresh session. */
  350. cwd?: string
  351. /** Persisted session to resume instead of creating a fresh session. */
  352. resumeSessionId?: SessionId
  353. })[]
  354. }
  355. /** Reject self-contained identity conflicts before any configured agent starts. */
  356. function validateConfiguredAgents(agents: Config['agents']): void {
  357. const exactIdentities = new Map<SessionId, string>()
  358. for (const { id, sessionId, resumeSessionId } of agents) {
  359. const hasResumeId = resumeSessionId !== undefined && resumeSessionId !== ''
  360. if (sessionId !== undefined && hasResumeId) {
  361. throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`)
  362. }
  363. const exactIdentity = hasResumeId ? resumeSessionId : sessionId
  364. if (exactIdentity === undefined) continue
  365. const firstId = exactIdentities.get(exactIdentity)
  366. if (firstId !== undefined) {
  367. throw new Error(`agents "${firstId}" and "${id}" use duplicate exact session identity "${exactIdentity}"`)
  368. }
  369. exactIdentities.set(exactIdentity, id)
  370. }
  371. }
  372. /** Concrete agent factory and driver service. */
  373. export class AgentLoop extends Service implements AgentFactory {
  374. static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
  375. /** Runtime schema for declarative agents. */
  376. static Config = z.object({
  377. maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
  378. agents: z.array(z.object({
  379. id: z.string().required(),
  380. sessionId: z.string().min(1),
  381. provider: z.string(),
  382. model: z.string(),
  383. cwd: z.string(),
  384. resumeSessionId: z.string(),
  385. })).default([]),
  386. }) as unknown as z<Config>
  387. private readonly ownership: FactoryOwnership
  388. /** Resolved concurrency cap for every driver created by this factory. */
  389. private readonly maxParallelToolCalls: number
  390. /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
  391. private readonly runtime: { ctx: Context }
  392. constructor(ctx: Context, public config: Config) {
  393. super(ctx, 'agentLoop')
  394. validateConfiguredAgents(config.agents)
  395. this.maxParallelToolCalls = resolveMaxParallelToolCalls(config.maxParallelToolCalls)
  396. this.ownership = new FactoryOwnership(ctx.fiber)
  397. this.runtime = { ctx }
  398. ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
  399. ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
  400. ctx.systemPrompt.variable('provider', context => context.agent?.options.provider)
  401. ctx.systemPrompt.variable('model', context => context.agent?.options.model)
  402. ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
  403. for (const { id, sessionId, cwd, resumeSessionId, ...options } of config.agents) {
  404. const meta = cwd === undefined ? {} : { cwd }
  405. if (resumeSessionId === undefined || resumeSessionId === '') {
  406. const configuredId = sessionId ?? SessionId(`${id}-session-${randomUUID()}`)
  407. const persistence = sessionId === undefined ? undefined : ctx.get('sessionPersistence')
  408. if (persistence === undefined) {
  409. this.create(configuredId, options, meta)
  410. } else {
  411. const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => {
  412. this.reportConfiguredStartupFailure(id, 'restore', configuredId, error)
  413. })
  414. this.ownership.trackStartup(startup)
  415. }
  416. continue
  417. }
  418. ctx.effect(() => {
  419. const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
  420. void this.resumeWith(ctx, childCtx.sessionPersistence, {
  421. resumeSessionId,
  422. agentOptions: options,
  423. }).catch((error: unknown) => {
  424. this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error)
  425. })
  426. })
  427. return fiber.dispose
  428. }, `agentLoop.resume(${id})`)
  429. }
  430. }
  431. /** Report a contained declarative-start failure to identity-bound consumers. */
  432. private reportConfiguredStartupFailure(
  433. configId: string,
  434. action: 'restore' | 'resume',
  435. sessionId: SessionId,
  436. error: unknown,
  437. ): void {
  438. if (!this.ownership.isActive()) return
  439. this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${renderThrown(error)}`)
  440. const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error]
  441. for (const callback of this.ctx.events.dispatch('emit', args)) {
  442. try {
  443. const returned: unknown = callback(...args)
  444. void Promise.resolve(returned).catch((listenerError: unknown) => {
  445. this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${renderThrown(listenerError)}`)
  446. })
  447. } catch (listenerError: unknown) {
  448. this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${renderThrown(listenerError)}`)
  449. }
  450. }
  451. }
  452. /** Restore a materialized exact config identity on remount, or create it on first use. */
  453. private async restoreOrCreateConfigured(
  454. ownerCtx: Context,
  455. persistence: SessionPersistence,
  456. sessionId: SessionId,
  457. agentOptions: AgentOptions,
  458. meta: Pick<SessionHeader, 'cwd'>,
  459. ): Promise<void> {
  460. await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId)
  461. if (!this.ownership.isActive()) return
  462. const exists = (await persistence.list()).some(header => header.id === sessionId)
  463. if (!this.ownership.isActive()) return
  464. if (exists) {
  465. await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions })
  466. return
  467. }
  468. this.create(sessionId, agentOptions, meta)
  469. }
  470. /** Wait for an already-disposed same-id lifecycle to finish registry teardown. */
  471. private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise<void> {
  472. const current = ownerCtx.agents.get(sessionId)
  473. if (current?.status !== 'disposed') return
  474. const released = Promise.withResolvers<void>()
  475. const checkReleased = (): void => {
  476. if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) {
  477. released.resolve()
  478. }
  479. }
  480. const disposeAgentListener = ownerCtx.on('agent/disposed', checkReleased)
  481. const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased)
  482. try {
  483. checkReleased()
  484. await this.ownership.waitWhileActive(released.promise)
  485. } finally {
  486. disposeAgentListener()
  487. disposeSessionListener()
  488. }
  489. }
  490. /**
  491. * Create an agent and session under one caller-supplied identity, owned by
  492. * the accessing fiber. Constructor-driven config calls mint a fresh combined
  493. * id before entering this boundary.
  494. * @param id - shared agent/session identity.
  495. * @param options - concrete loop options.
  496. * @param meta - optional fresh-session workspace metadata.
  497. * @returns the published running agent.
  498. */
  499. create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent {
  500. const loopCtx = this.runtime.ctx
  501. const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
  502. try {
  503. const session = loopCtx.sessions.prepare(id, { meta })
  504. const agent = transaction.prepare(options, session, this.maxParallelToolCalls)
  505. transaction.publish('startup')
  506. return agent
  507. } catch (error: unknown) {
  508. void transaction.dispose(error instanceof Error ? error : new Error(String(error)))
  509. throw error
  510. } finally {
  511. transaction.finishWrapper()
  512. }
  513. }
  514. /**
  515. * Create an owned agent on a caller-supplied session id.
  516. * @param ownerCtx - caller context that structurally owns the transaction.
  517. * @param options - identities, session seed/metadata, loop options, setup, and cancellation.
  518. * @returns the published handle.
  519. */
  520. async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
  521. const agentOptions = options.agentOptions ?? {}
  522. const transaction = new AgentCreationTransaction(
  523. this.runtime.ctx,
  524. ownerCtx,
  525. this.ownership,
  526. options.sessionId,
  527. options.signal,
  528. )
  529. try {
  530. const session = this.runtime.ctx.sessions.prepare(options.sessionId, {
  531. ...options.seed === undefined ? {} : { seed: options.seed },
  532. ...options.meta === undefined ? {} : { meta: options.meta },
  533. })
  534. const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
  535. await transaction.waitFor(options.setup?.(agent.ctx))
  536. transaction.assertActive()
  537. return transaction.publish('startup')
  538. } catch (error: unknown) {
  539. await transaction.dispose(error instanceof Error ? error : new Error(String(error)))
  540. throw error
  541. } finally {
  542. transaction.finishWrapper()
  543. }
  544. }
  545. /**
  546. * Resume an owned agent from the configured persistence service.
  547. * @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
  548. * @param options - persisted identity, loop options, setup, and cancellation.
  549. * @returns the published handle.
  550. */
  551. async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle> {
  552. const persistence = this.runtime.ctx.get('sessionPersistence')
  553. if (persistence === undefined) {
  554. throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
  555. }
  556. return this.resumeWith(ownerCtx, persistence, options)
  557. }
  558. /** Resume through an explicit persistence handle used by the deferred config path. */
  559. private async resumeWith(
  560. ownerCtx: Context,
  561. persistence: SessionPersistence,
  562. options: ResumeAgentOptions,
  563. ): Promise<AgentHandle> {
  564. const agentOptions = options.agentOptions ?? {}
  565. const transaction = new AgentCreationTransaction(
  566. this.runtime.ctx,
  567. ownerCtx,
  568. this.ownership,
  569. options.resumeSessionId,
  570. options.signal,
  571. )
  572. try {
  573. const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId))
  574. transaction.assertActive()
  575. const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, {
  576. seed: loaded.events,
  577. meta: {
  578. createdAt: loaded.meta.createdAt,
  579. ...loaded.meta.cwd === undefined ? {} : { cwd: loaded.meta.cwd },
  580. ...loaded.meta.parentSession === undefined ? {} : { parentSession: loaded.meta.parentSession },
  581. ...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength },
  582. },
  583. })
  584. const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
  585. await transaction.waitFor(options.setup?.(agent.ctx))
  586. transaction.assertActive()
  587. return transaction.publish('resume')
  588. } catch (error: unknown) {
  589. await transaction.dispose(error instanceof Error ? error : new Error(String(error)))
  590. throw error
  591. } finally {
  592. transaction.finishWrapper()
  593. }
  594. }
  595. }
  596. export default AgentLoop