index.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  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. AgentFactory,
  15. AgentHandle,
  16. AgentId,
  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. export { ReactLoopAgent } from './agent.ts'
  36. /** Fiber states that cannot own or serve a new lifecycle. */
  37. const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
  38. FiberState.UNLOADING,
  39. FiberState.DISPOSED,
  40. FiberState.FAILED,
  41. ])
  42. /** Factory-level ownership of every preparing or live transaction. */
  43. class FactoryOwnership {
  44. private accepting = true
  45. private transactions = new Set<AgentCreationTransaction>()
  46. constructor(private readonly fiber: Context['fiber']) {}
  47. isActive(): boolean {
  48. return this.accepting && !INACTIVE_STATES.has(this.fiber.state)
  49. }
  50. track(transaction: AgentCreationTransaction): () => void {
  51. this.transactions.add(transaction)
  52. return () => { this.transactions.delete(transaction) }
  53. }
  54. async dispose(): Promise<void> {
  55. this.accepting = false
  56. const reason = new Error('agent loop is not active')
  57. await Promise.all(
  58. [...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
  59. )
  60. }
  61. }
  62. /** Build the public cancellation error while preserving a caller-supplied cause. */
  63. function signalAbortError(id: AgentId, signal: AbortSignal): Error {
  64. if (signal.reason instanceof Error) return signal.reason
  65. return new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
  66. }
  67. /** Resolve the deployment-wide scheduler cap at the owning config boundary. */
  68. function resolveMaxParallelToolCalls(value: number | undefined): number {
  69. const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
  70. if (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1) {
  71. throw new Error('maxParallelToolCalls must be a positive integer')
  72. }
  73. return maxParallelToolCalls
  74. }
  75. /**
  76. * Caller-owned create/resume transaction through rollback-covered publication
  77. * and quiescent teardown. Resources remain private until the final registry
  78. * entry arbitrates identity.
  79. */
  80. class AgentCreationTransaction {
  81. private active = true
  82. private failure: Error | undefined
  83. private readonly deactivation = Promise.withResolvers<void>()
  84. private readonly publication = Promise.withResolvers<void>()
  85. private readonly torndown = Promise.withResolvers<void>()
  86. private readonly wrapperCompletion = Promise.withResolvers<void>()
  87. private preparing: Promise<void> | undefined
  88. private driver: PreparedReactLoopAgent | undefined
  89. private scope: Scope | undefined
  90. private session: Session | undefined
  91. private lifecycleDispose: (() => Promise<void> | void) | undefined
  92. private detachSession: (() => void) | undefined
  93. private detachAgent: (() => void) | undefined
  94. private publishing = false
  95. private cleanupTask: Promise<void> | undefined
  96. private ownerFollowing = true
  97. private readonly ownerDispose: () => Promise<void> | void
  98. private readonly untrackFactory: () => void
  99. private readonly abortListener: (() => void) | undefined
  100. readonly ownerAgent: Context['agent']
  101. readonly ownerFiber: Context['fiber']
  102. constructor(
  103. private readonly loopCtx: Context,
  104. private readonly ownerCtx: Context,
  105. private readonly ownership: FactoryOwnership,
  106. readonly id: AgentId,
  107. signal?: AbortSignal,
  108. ) {
  109. ownerCtx.fiber.assertActive()
  110. this.ownerAgent = ownerCtx.agent
  111. this.ownerFiber = ownerCtx.fiber
  112. if (!ownership.isActive()) throw new Error('agent loop is not active')
  113. this.ownerDispose = ownerCtx.effect(() => () => {
  114. if (!this.ownerFollowing) return
  115. return this.dispose(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
  116. }, `agentLoop.owner(${id})`)
  117. this.untrackFactory = ownership.track(this)
  118. if (signal === undefined) {
  119. this.abortListener = undefined
  120. } else {
  121. this.abortListener = () => {
  122. /* v8 ignore next 3 -- transaction teardown contains callback/driver failures; rejection is a future-drift backstop. */
  123. void this.dispose(signalAbortError(id, signal)).catch((error: unknown) => {
  124. this.loopCtx.logger.error(error)
  125. })
  126. }
  127. signal.addEventListener('abort', this.abortListener, { once: true })
  128. if (signal.aborted) this.deactivate(signalAbortError(id, signal))
  129. }
  130. this.signal = signal
  131. }
  132. private readonly signal: AbortSignal | undefined
  133. /** Whether caller, provider, and optional parent-agent ownership remain live. */
  134. isActive(): boolean {
  135. return this.active
  136. && this.ownership.isActive()
  137. && this.ownerFiber.uid !== null
  138. && !INACTIVE_STATES.has(this.ownerFiber.state)
  139. && this.ownerAgent?.status !== 'disposed'
  140. }
  141. /** Fail synchronously at every real lifecycle boundary after deactivation. */
  142. assertActive(): void {
  143. if (this.isActive()) return
  144. if (!this.ownership.isActive()) throw new Error('agent loop is not active')
  145. throw this.failure ?? new Error(`agent "${this.id}" setup aborted: owner disposed during setup`)
  146. }
  147. /** Race an external async operation against structural/signal deactivation. */
  148. async waitFor<T>(operation: PromiseLike<T> | T): Promise<T> {
  149. this.assertActive()
  150. return await Promise.race([
  151. Promise.resolve(operation),
  152. this.deactivation.promise.then(() => {
  153. /* v8 ignore next -- deactivate() assigns failure before resolving deactivation. */
  154. throw this.failure ?? new Error(`agent "${this.id}" creation deactivated`)
  155. }),
  156. ])
  157. }
  158. /** Construct the driver and scope, then install their complete ordered lifecycle. */
  159. prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent {
  160. this.assertActive()
  161. const gate = Promise.withResolvers<void>()
  162. this.preparing = gate.promise
  163. try {
  164. this.session = session
  165. const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session, maxParallelToolCalls)
  166. this.driver = driver
  167. const agent = driver.agent
  168. const scope = createScope(this.loopCtx, agent)
  169. this.scope = scope
  170. bindReactLoopAgentContext(agent, scope.ctx.extend({ agent }))
  171. this.installLifecycle(scope, driver)
  172. this.assertActive()
  173. return agent
  174. } catch (error: unknown) {
  175. if (!this.isActive() && error instanceof Error && /inactive context/.test(error.message)) {
  176. throw this.failure ?? this.disposalReason()
  177. }
  178. throw error
  179. } finally {
  180. gate.resolve()
  181. this.preparing = undefined
  182. }
  183. }
  184. /** Register the exact scope disposer inside the ordered transaction effect. */
  185. private installLifecycle(scope: Scope, driver: PreparedReactLoopAgent): void {
  186. this.lifecycleDispose = this.ownerCtx.effect(function* (this: AgentCreationTransaction) {
  187. // First yielded, disposed last.
  188. yield () => { this.finish() }
  189. yield scope.rawDispose
  190. yield () => {
  191. this.detachSession?.()
  192. this.detachSession = undefined
  193. }
  194. yield () => {
  195. this.detachAgent?.()
  196. this.detachAgent = undefined
  197. }
  198. // Last yielded, disposed first.
  199. yield () => {
  200. this.deactivate(this.disposalReason())
  201. if (this.publishing) {
  202. return this.publication.promise.then(() => driver.dispose())
  203. }
  204. return driver.dispose()
  205. }
  206. }.bind(this), `agentLoop.lifecycle(${this.id})`)
  207. }
  208. /** Publish the exact prepared objects and start the driver. */
  209. publish(source: SessionStartSource): AgentHandle {
  210. this.assertActive()
  211. const driver = this.driver
  212. /* v8 ignore next -- publish() is private and every caller invokes prepare() first. */
  213. if (driver === undefined) throw new Error(`agent "${this.id}" is not prepared`)
  214. const agent = driver.agent
  215. const session = this.session
  216. /* v8 ignore next -- prepare() assigns the session before it can produce the driver above. */
  217. if (session === undefined) throw new Error(`agent "${this.id}" has no prepared session`)
  218. this.publishing = true
  219. try {
  220. this.detachSession = agent.ctx.sessions.enter(session)
  221. this.detachAgent = this.loopCtx.agents.enter(agent)
  222. agent.ctx.sessions.announce(session)
  223. this.assertActive()
  224. this.loopCtx.agents.announce(agent)
  225. this.assertActive()
  226. driver.markPublished()
  227. agentEvents(this.loopCtx, agent).emit('agent/session-start', source)
  228. this.assertActive()
  229. driver.startDriver()
  230. return { agent, dispose: () => this.dispose() }
  231. } finally {
  232. this.publishing = false
  233. this.publication.resolve()
  234. }
  235. }
  236. /** Mark the transaction inactive exactly once and wake load/setup races. */
  237. private deactivate(reason: Error): void {
  238. if (!this.active) return
  239. this.active = false
  240. this.failure = reason
  241. this.deactivation.resolve()
  242. }
  243. /** Choose the structural cause when an owner/factory effect starts teardown first. */
  244. private disposalReason(): Error {
  245. if (this.failure !== undefined) return this.failure
  246. if (!this.ownership.isActive()) return new Error('agent loop is not active')
  247. if (this.ownerFiber.uid === null || INACTIVE_STATES.has(this.ownerFiber.state) || this.ownerAgent?.status === 'disposed') {
  248. return new Error(`agent "${this.id}" setup aborted: owner disposed during setup`)
  249. }
  250. return new Error(`agent "${this.id}" lifecycle disposed`)
  251. }
  252. /** Complete ownership bookkeeping after every resource reached quiescence. */
  253. private finish(): void {
  254. this.untrackFactory()
  255. this.ownerFollowing = false
  256. void this.ownerDispose()
  257. this.torndown.resolve()
  258. }
  259. /**
  260. * Deactivate and quiesce this transaction. The promise is memoized because
  261. * Cordis effect disposers are single-shot while handles promise shared
  262. * quiescence to every racing owner.
  263. */
  264. dispose(reason = new Error(`agent "${this.id}" lifecycle disposed`)): Promise<void> {
  265. this.deactivate(reason)
  266. return (this.cleanupTask ??= (async () => {
  267. if (this.preparing !== undefined) await this.preparing
  268. if (this.lifecycleDispose !== undefined) {
  269. await this.lifecycleDispose()
  270. await this.torndown.promise
  271. return
  272. }
  273. try {
  274. await this.driver?.dispose()
  275. } finally {
  276. try {
  277. await this.scope?.dispose()
  278. } finally {
  279. this.finish()
  280. }
  281. }
  282. })())
  283. }
  284. /** Mark the public create/resume continuation settled and detach its creation-only signal. */
  285. finishWrapper(): void {
  286. if (this.signal !== undefined && this.abortListener !== undefined) {
  287. this.signal.removeEventListener('abort', this.abortListener)
  288. }
  289. this.wrapperCompletion.resolve()
  290. }
  291. /** Factory shutdown joins both resource teardown and the public wrapper's deactivation continuation. */
  292. async disposeForFactory(reason: Error): Promise<void> {
  293. await this.dispose(reason)
  294. await this.wrapperCompletion.promise
  295. }
  296. }
  297. declare module 'cordis' {
  298. interface Context {
  299. agentLoop: AgentLoop
  300. }
  301. }
  302. export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
  303. /** Agent-loop plugin configuration. */
  304. export interface Config {
  305. /**
  306. * Maximum parallel-safe calls in flight per agent step. `1` is serial;
  307. * omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
  308. */
  309. maxParallelToolCalls?: number
  310. /** Agents created or resumed at plugin startup. */
  311. agents: (AgentOptions & {
  312. /** Registry identity for the live agent. */
  313. id: AgentId
  314. /** Optional workspace for a fresh session. */
  315. cwd?: string
  316. /** Persisted session to resume instead of creating a fresh session. */
  317. resumeSessionId?: SessionId
  318. })[]
  319. }
  320. /** Concrete ReactLoopAgent factory and driver service. */
  321. export class AgentLoop extends Service implements AgentFactory {
  322. static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
  323. /** Runtime schema for declarative agents. */
  324. static Config = z.object({
  325. maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
  326. agents: z.array(z.object({
  327. id: z.string().required(),
  328. provider: z.string(),
  329. model: z.string(),
  330. cwd: z.string(),
  331. resumeSessionId: z.string(),
  332. })).default([]),
  333. }) as unknown as z<Config>
  334. private readonly ownership: FactoryOwnership
  335. /** Resolved concurrency cap for every driver created by this factory. */
  336. private readonly maxParallelToolCalls: number
  337. /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
  338. private readonly runtime: { ctx: Context }
  339. constructor(ctx: Context, public config: Config) {
  340. super(ctx, 'agentLoop')
  341. this.maxParallelToolCalls = resolveMaxParallelToolCalls(config.maxParallelToolCalls)
  342. this.ownership = new FactoryOwnership(ctx.fiber)
  343. this.runtime = { ctx }
  344. ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
  345. ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
  346. ctx.systemPrompt.variable('provider', context => context.agent?.options.provider)
  347. ctx.systemPrompt.variable('model', context => context.agent?.options.model)
  348. ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
  349. for (const { id, cwd, resumeSessionId, ...options } of config.agents) {
  350. if (resumeSessionId === undefined || resumeSessionId === '') {
  351. this.create(id, options, cwd === undefined ? {} : { cwd })
  352. continue
  353. }
  354. ctx.effect(() => {
  355. const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
  356. void this.resumeWith(ctx, childCtx.sessionPersistence, {
  357. agentId: id,
  358. resumeSessionId,
  359. agentOptions: options,
  360. }).catch((error: unknown) => {
  361. ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
  362. })
  363. })
  364. return fiber.dispose
  365. }, `agentLoop.resume(${id})`)
  366. }
  367. }
  368. /**
  369. * Create an agent on a fresh per-run session, owned by the accessing fiber.
  370. * Constructor-driven config calls use the loop fiber itself.
  371. * @param id - agent registry id.
  372. * @param options - concrete loop options.
  373. * @param meta - optional fresh-session workspace metadata.
  374. * @returns the published running agent.
  375. */
  376. create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
  377. const loopCtx = this.runtime.ctx
  378. const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
  379. try {
  380. const sessionId = SessionId(`${id}-session-${randomUUID()}`)
  381. const session = loopCtx.sessions.prepare(sessionId, { meta })
  382. const agent = transaction.prepare(options, session, this.maxParallelToolCalls)
  383. transaction.publish('startup')
  384. return agent
  385. } catch (error: unknown) {
  386. void transaction.dispose(error instanceof Error ? error : new Error(String(error)))
  387. throw error
  388. } finally {
  389. transaction.finishWrapper()
  390. }
  391. }
  392. /**
  393. * Create an owned agent on a caller-supplied session id.
  394. * @param ownerCtx - caller context that structurally owns the transaction.
  395. * @param options - identities, session seed/metadata, loop options, setup, and cancellation.
  396. * @returns the published handle.
  397. */
  398. async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
  399. const agentOptions = options.agentOptions ?? {}
  400. const transaction = new AgentCreationTransaction(
  401. this.runtime.ctx,
  402. ownerCtx,
  403. this.ownership,
  404. options.agentId,
  405. options.signal,
  406. )
  407. try {
  408. const session = this.runtime.ctx.sessions.prepare(options.sessionId, {
  409. ...options.seed === undefined ? {} : { seed: options.seed },
  410. ...options.meta === undefined ? {} : { meta: options.meta },
  411. })
  412. const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
  413. await transaction.waitFor(options.setup?.(agent.ctx))
  414. transaction.assertActive()
  415. return transaction.publish('startup')
  416. } catch (error: unknown) {
  417. await transaction.dispose(error instanceof Error ? error : new Error(String(error)))
  418. throw error
  419. } finally {
  420. transaction.finishWrapper()
  421. }
  422. }
  423. /**
  424. * Resume an owned agent from the configured persistence service.
  425. * @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
  426. * @param options - persisted identity, loop options, setup, and cancellation.
  427. * @returns the published handle.
  428. */
  429. async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle> {
  430. const persistence = this.runtime.ctx.get('sessionPersistence')
  431. if (persistence === undefined) {
  432. throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
  433. }
  434. return this.resumeWith(ownerCtx, persistence, options)
  435. }
  436. /** Resume through an explicit persistence handle used by the deferred config path. */
  437. private async resumeWith(
  438. ownerCtx: Context,
  439. persistence: SessionPersistence,
  440. options: ResumeAgentOptions,
  441. ): Promise<AgentHandle> {
  442. const agentOptions = options.agentOptions ?? {}
  443. const transaction = new AgentCreationTransaction(
  444. this.runtime.ctx,
  445. ownerCtx,
  446. this.ownership,
  447. options.agentId,
  448. options.signal,
  449. )
  450. try {
  451. const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId))
  452. transaction.assertActive()
  453. const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, {
  454. seed: loaded.events,
  455. meta: {
  456. createdAt: loaded.meta.createdAt,
  457. ...loaded.meta.cwd === undefined ? {} : { cwd: loaded.meta.cwd },
  458. ...loaded.meta.parentSession === undefined ? {} : { parentSession: loaded.meta.parentSession },
  459. ...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength },
  460. },
  461. })
  462. const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
  463. await transaction.waitFor(options.setup?.(agent.ctx))
  464. transaction.assertActive()
  465. return transaction.publish('resume')
  466. } catch (error: unknown) {
  467. await transaction.dispose(error instanceof Error ? error : new Error(String(error)))
  468. throw error
  469. } finally {
  470. transaction.finishWrapper()
  471. }
  472. }
  473. }
  474. export default AgentLoop