index.ts 31 KB

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