index.ts 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945
  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 { brandString } from '@deepseek-ai/dsh-brand'
  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 type {} from '@deepseek-ai/dsh-settings'
  25. import { interruptedTurnClosers, SessionLogOffset, SessionPreparation, SessionSeq } from '@deepseek-ai/dsh-session'
  26. import type { Session, SessionHeader, SessionId } 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 { SessionPersistenceNotFoundError } from '@deepseek-ai/dsh-session-persistence'
  32. import type { SessionHandle, SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
  33. import { ReactLoopAgent } from './agent.ts'
  34. import { inboxProjectionDefinition } from './inbox.ts'
  35. import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.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. const turnBoundaryProjectionSchema: zod.ZodType<TurnBoundaryProjection> = zod.object({
  43. openTurnStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(),
  44. lastStepStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(),
  45. lastStepBoundary: zod.object({
  46. kind: zod.union([zod.literal('start'), zod.literal('end')]),
  47. seq: zod.number().int().nonnegative().transform(SessionSeq),
  48. }).nullable(),
  49. lastTurn: zod.number().int().nonnegative(),
  50. })
  51. /** Host projection of agent turn and step boundaries. */
  52. export const turnBoundaryProjectionDefinition = {
  53. key: 'turnBoundary',
  54. stateVersion: 2,
  55. stateSchema: turnBoundaryProjectionSchema,
  56. init: () => ({
  57. openTurnStartSeq: null,
  58. lastStepStartSeq: null,
  59. lastStepBoundary: null,
  60. lastTurn: 0,
  61. }),
  62. apply: (state, event) => {
  63. switch (event.type) {
  64. case 'turn/start':
  65. return {
  66. ...state,
  67. openTurnStartSeq: event.seq,
  68. lastTurn: event.data.turn,
  69. }
  70. case 'turn/end':
  71. return {
  72. ...state,
  73. openTurnStartSeq: null,
  74. }
  75. case 'step/start':
  76. return {
  77. ...state,
  78. lastStepStartSeq: event.seq,
  79. lastStepBoundary: { kind: 'start', seq: event.seq },
  80. }
  81. case 'step/end':
  82. return {
  83. ...state,
  84. lastStepBoundary: { kind: 'end', seq: event.seq },
  85. }
  86. default:
  87. return state
  88. }
  89. },
  90. } satisfies ProjectionDefinition<'turnBoundary', TurnBoundaryProjection>
  91. /** Factory-level ownership: live agent teardowns plus config startup work. */
  92. class FactoryOwnership {
  93. private accepting = true
  94. private readonly teardown = new AbortController()
  95. private readonly inactive = Promise.withResolvers<void>()
  96. private readonly liveAgents = new Set<() => Promise<void>>()
  97. private startupTasks = new Set<Promise<void>>()
  98. constructor(private readonly fiber: Context['fiber']) {}
  99. /** Aborts (reason: `agent loop is not active` error) when factory teardown begins. */
  100. get signal(): AbortSignal {
  101. return this.teardown.signal
  102. }
  103. isActive(): boolean {
  104. return this.accepting && !INACTIVE_STATES.has(this.fiber.state)
  105. }
  106. /** Track one live agent's shared teardown until it has run. */
  107. track(dispose: () => Promise<void>): () => void {
  108. this.liveAgents.add(dispose)
  109. return () => { this.liveAgents.delete(dispose) }
  110. }
  111. /** Join config startup work that begins before an agent exists. */
  112. trackStartup(job: Promise<void>): void {
  113. this.startupTasks.add(job)
  114. const forget = () => { this.startupTasks.delete(job) }
  115. void job.then(forget, forget)
  116. }
  117. /** Join one public create/resume continuation; factory dispose awaits its settlement. */
  118. trackWrapper(job: Promise<unknown>): void {
  119. this.trackStartup(job.then(() => undefined, () => undefined))
  120. }
  121. /** Resolve `task`, or stop waiting when factory teardown begins. */
  122. async waitWhileActive(job: Promise<void>): Promise<void> {
  123. await Promise.race([job, this.inactive.promise])
  124. }
  125. async dispose(): Promise<void> {
  126. this.accepting = false
  127. this.teardown.abort(new Error('agent loop is not active'))
  128. this.inactive.resolve()
  129. await Promise.all([
  130. ...[...this.liveAgents].map(dispose => dispose()),
  131. ...this.startupTasks,
  132. ])
  133. }
  134. }
  135. /** Await `operation`, or throw the signal's reason as soon as it aborts. */
  136. async function raceAbort<T>(operation: PromiseLike<T> | T, signal: AbortSignal, id: SessionId): Promise<T> {
  137. const toAbortError = (): Error => signal.reason instanceof Error
  138. ? signal.reason
  139. : new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
  140. if (signal.aborted) throw toAbortError()
  141. const aborted = Promise.withResolvers<never>()
  142. const listener = (): void => { aborted.reject(toAbortError()) }
  143. signal.addEventListener('abort', listener, { once: true })
  144. try {
  145. return await Promise.race([Promise.resolve(operation), aborted.promise])
  146. } finally {
  147. signal.removeEventListener('abort', listener)
  148. }
  149. }
  150. /** Start an abortable operation and release a value that arrives after cancellation. */
  151. async function raceAbortCall<T>(
  152. operation: () => PromiseLike<T> | T,
  153. signal: AbortSignal,
  154. id: SessionId,
  155. releaseAbandoned?: (value: T) => void,
  156. ): Promise<T> {
  157. if (signal.aborted) {
  158. throw signal.reason instanceof Error
  159. ? signal.reason
  160. : new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
  161. }
  162. const pending = Promise.resolve().then(operation)
  163. try {
  164. return await raceAbort(pending, signal, id)
  165. } catch (error: unknown) {
  166. // oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while the operation is awaited.
  167. if (signal.aborted && releaseAbandoned !== undefined) {
  168. void pending.then(releaseAbandoned, () => undefined)
  169. }
  170. throw error
  171. }
  172. }
  173. /** Resolve the deployment-wide scheduler cap at the owning config boundary. */
  174. function resolveMaxParallelToolCalls(value: number | undefined): number {
  175. const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
  176. if (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1) {
  177. throw new Error('maxParallelToolCalls must be a positive integer')
  178. }
  179. return maxParallelToolCalls
  180. }
  181. /** Reject an output-token cap that cannot be represented exactly on the request wire. */
  182. function assertAgentOptions(options: AgentOptions): void {
  183. if (options.maxTokens !== undefined
  184. && (!Number.isSafeInteger(options.maxTokens) || options.maxTokens <= 0)) {
  185. throw new TypeError('agent maxTokens must be a positive safe integer')
  186. }
  187. }
  188. /** One session's owned write handle plus the count of events already stored through it. */
  189. interface StoredSession {
  190. readonly handle: SessionHandle
  191. storedCount: number
  192. }
  193. /** Prepared-but-unpublished agent resources sharing one memoized teardown. */
  194. interface PreparedAgent {
  195. agent: ReactLoopAgent
  196. /** Aborts when the factory unloads, the caller cancels, or teardown begins — ends any setup await. */
  197. signal: AbortSignal
  198. /** Enter both registries and await creation listeners. */
  199. publish(source: SessionStartSource): Promise<AgentHandle>
  200. /** Reverse teardown: stop the machine, unregister, unwind the scope. Memoized. */
  201. dispose(): Promise<void>
  202. }
  203. declare module '@deepseek-ai/cordis' {
  204. interface Context {
  205. agentLoop: AgentLoop
  206. /**
  207. * Launcher-owned exact session identities for configured agents, keyed by
  208. * the agent's config `id` and set with `ctx.provide()` before any Loader
  209. * entry mounts (see {@link CONFIGURED_AGENT_IDENTITIES_KEY}). A launcher
  210. * owns identity because only it knows whether the session already exists,
  211. * while the `cordis.yml` row keeps the model route as ordinary patchable
  212. * config. An entry with no matching key keeps its configured identity.
  213. */
  214. configuredAgentIdentities?: ConfiguredAgentIdentities
  215. }
  216. interface Events {
  217. /**
  218. * A declarative agent entry failed before it could publish a live agent.
  219. * Consumers that buffer work for the configured identity use this
  220. * transient signal to reject that work instead of waiting forever. Normal
  221. * factory teardown suppresses failures from the cancelled startup attempt.
  222. * @param payload.sessionId - exact shared agent/session identity that failed startup.
  223. * @param payload.error - persistence, setup, or publication failure.
  224. * @mode emit
  225. */
  226. 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void
  227. }
  228. }
  229. export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
  230. /**
  231. * One launcher-selected session identity for a configured agent. `resume`
  232. * distinguishes rehydrating existing persisted history from creating the
  233. * session fresh under that exact id, which the two config keys express as
  234. * `resumeSessionId` and `sessionId`.
  235. */
  236. export interface LauncherAgentIdentity {
  237. /** Exact session id to create fresh or resume. */
  238. id: SessionId
  239. /** Resume existing persisted history instead of creating the session fresh. */
  240. resume: boolean
  241. }
  242. /** Launcher-selected identities keyed by the configured agent's `id`. */
  243. export interface ConfiguredAgentIdentities extends Readonly<Record<string, LauncherAgentIdentity>> {}
  244. /**
  245. * Context key a launcher sets before any Loader entry mounts
  246. * (`ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, identities)`) to fix
  247. * configured agents' session identities without a config key, so an overlay
  248. * repointing the row's model route cannot drop them.
  249. */
  250. export const CONFIGURED_AGENT_IDENTITIES_KEY = 'configuredAgentIdentities'
  251. /**
  252. * Apply launcher-owned identities over the configured agents, replacing both
  253. * identity keys for every entry the launcher named so a config-supplied
  254. * identity can never survive alongside a launcher-supplied one.
  255. * @param agents - the configured agent entries.
  256. * @param identities - launcher identities keyed by configured agent `id`, or `undefined`.
  257. * @returns the entries with launcher-owned identities applied.
  258. */
  259. function applyLauncherIdentities(
  260. agents: Config['agents'],
  261. identities: ConfiguredAgentIdentities | undefined,
  262. ): Config['agents'] {
  263. if (identities === undefined) return agents
  264. return agents.map((agent) => {
  265. const identity = identities[agent.id]
  266. if (identity === undefined) return agent
  267. const { sessionId: _sessionId, resumeSessionId: _resumeSessionId, ...rest } = agent
  268. return identity.resume
  269. ? { ...rest, resumeSessionId: identity.id }
  270. : { ...rest, sessionId: identity.id }
  271. })
  272. }
  273. /** Settings namespace carrying the tool-call parallelism a user owns. */
  274. export const AGENT_LOOP_SETTINGS_NAMESPACE = 'agent-loop'
  275. /**
  276. * The agent-loop fields a user owns. Deliberately a strict subset of
  277. * {@link Config}: `agents` is a boot-time composition array consumed once when
  278. * the service starts, so a stored change could only look like it had an effect.
  279. */
  280. export interface AgentLoopSettings {
  281. /** Maximum parallel-safe calls in flight per agent step. */
  282. maxParallelToolCalls: number
  283. }
  284. /** Schema of the agent-loop settings section. */
  285. export const AGENT_LOOP_SETTINGS_SCHEMA: z<AgentLoopSettings> = z.object({
  286. maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
  287. })
  288. /** Agent-loop plugin configuration. */
  289. export interface Config {
  290. /**
  291. * Maximum parallel-safe calls in flight per agent step. `1` is serial;
  292. * omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
  293. */
  294. maxParallelToolCalls?: number
  295. /** Agents created or resumed at plugin startup. */
  296. agents: (AgentOptions & {
  297. /** Stable config label used in logs and as the fresh combined-id prefix. */
  298. id: string
  299. /** Optional stable identity; remounts resume its materialized history, while first use creates it fresh. */
  300. sessionId?: SessionId
  301. /** Optional workspace for a fresh session. */
  302. cwd?: string
  303. /** Persisted session to resume instead of creating a fresh session. */
  304. resumeSessionId?: SessionId
  305. })[]
  306. }
  307. /** Agent-loop configuration after defaults and load-time validation. */
  308. type ResolvedConfig = Config & { maxParallelToolCalls: number }
  309. /** Reject self-contained identity conflicts before any configured agent starts. */
  310. function validateConfiguredAgents(agents: Config['agents']): void {
  311. const exactIdentities = new Map<SessionId, string>()
  312. for (const { id, sessionId, resumeSessionId } of agents) {
  313. const hasResumeId = resumeSessionId !== undefined && resumeSessionId !== ''
  314. if (sessionId !== undefined && hasResumeId) {
  315. throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`)
  316. }
  317. const exactIdentity = hasResumeId ? resumeSessionId : sessionId
  318. if (exactIdentity === undefined) continue
  319. const firstId = exactIdentities.get(exactIdentity)
  320. if (firstId !== undefined) {
  321. throw new Error(`agents "${firstId}" and "${id}" use duplicate exact session identity "${exactIdentity}"`)
  322. }
  323. exactIdentities.set(exactIdentity, id)
  324. }
  325. }
  326. /** Concrete agent factory and driver service. */
  327. export class AgentLoop extends Service implements AgentFactory {
  328. static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt', 'sessionProjections']
  329. /** Runtime schema for declarative agents. */
  330. static Config = z.object({
  331. maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
  332. agents: z.array(z.object({
  333. id: z.string().required(),
  334. sessionId: z.string().min(1),
  335. provider: z.string(),
  336. model: z.string(),
  337. reasoningEffort: z.string().min(1) as z<ReturnType<typeof ReasoningEffortId>>,
  338. maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
  339. cwd: z.string(),
  340. resumeSessionId: z.string(),
  341. })).default([]),
  342. }) as z<Config>
  343. /** Validated configuration owned by the agent-loop service. */
  344. readonly config: ResolvedConfig
  345. private readonly ownership: FactoryOwnership
  346. /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
  347. private readonly runtime: { ctx: Context }
  348. constructor(ctx: Context, config: Config) {
  349. super(ctx, 'agentLoop')
  350. const entry: AgentLoopSettings = {
  351. maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls),
  352. }
  353. let source: () => AgentLoopSettings = () => entry
  354. this.config = {
  355. ...config,
  356. agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)),
  357. // Read through on every scheduler decision: `tool-calls.ts` destructures
  358. // this at the start of each group, so a committed change caps the next
  359. // group without disturbing the one in flight.
  360. get maxParallelToolCalls() {
  361. return source().maxParallelToolCalls
  362. },
  363. }
  364. ctx.inject(['settings'], (settingsCtx) => {
  365. settingsCtx.settings.installSection(ctx, AGENT_LOOP_SETTINGS_NAMESPACE, AGENT_LOOP_SETTINGS_SCHEMA, entry, {
  366. // The schema admits any integer above zero; `resolveMaxParallelToolCalls`
  367. // owns the whole rule, so refusing here keeps the running scheduler on
  368. // its last good cap instead of failing at the next tool group.
  369. validate: value => void resolveMaxParallelToolCalls(value.maxParallelToolCalls),
  370. setSource: (current) => {
  371. source = current
  372. },
  373. // Nothing is derived from the cap: the getter above is the only reader.
  374. onChange: () => {},
  375. })
  376. })
  377. validateConfiguredAgents(this.config.agents)
  378. // Register only after every config validation above has passed, so a
  379. // rejected constructor leaves no projection unit behind.
  380. ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
  381. ctx.sessionProjections.register(inboxProjectionDefinition)
  382. this.ownership = new FactoryOwnership(ctx.fiber)
  383. this.runtime = { ctx }
  384. ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
  385. ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
  386. ctx.systemPrompt.variable('provider', context => context.agent?.options.provider)
  387. ctx.systemPrompt.variable('model', context => context.agent?.options.model)
  388. ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
  389. for (const { id, sessionId, cwd, resumeSessionId, ...options } of this.config.agents) {
  390. const meta = cwd === undefined ? {} : { cwd }
  391. if (resumeSessionId === undefined || resumeSessionId === '') {
  392. const configuredId = sessionId ?? brandString<SessionId>(`${id}-session-${randomUUID()}`)
  393. const persistence = sessionId === undefined ? undefined : ctx.get('sessionPersistence')
  394. if (persistence === undefined) {
  395. const startup = this.create(configuredId, options, meta).then(() => undefined, (error: unknown) => {
  396. this.reportConfiguredStartupFailure(id, 'restore', configuredId, error)
  397. })
  398. this.ownership.trackStartup(startup)
  399. } else {
  400. const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => {
  401. this.reportConfiguredStartupFailure(id, 'restore', configuredId, error)
  402. })
  403. this.ownership.trackStartup(startup)
  404. }
  405. continue
  406. }
  407. ctx.effect(() => {
  408. const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
  409. void this.resumeWith(ctx, childCtx.sessionPersistence, {
  410. resumeSessionId,
  411. agentOptions: options,
  412. }).catch((error: unknown) => {
  413. this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error)
  414. })
  415. })
  416. return fiber.dispose
  417. }, `agentLoop.resume(${id})`)
  418. }
  419. }
  420. /** Report a contained declarative-start failure to identity-bound consumers. */
  421. private reportConfiguredStartupFailure(
  422. configId: string,
  423. action: 'restore' | 'resume',
  424. sessionId: SessionId,
  425. error: unknown,
  426. ): void {
  427. if (!this.ownership.isActive()) return
  428. this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`)
  429. const args: unknown[] = ['agent-loop/config-start-failed', { sessionId, error }]
  430. for (const callback of this.ctx.events.dispatch('emit', args)) {
  431. try {
  432. const returned: unknown = callback(...args)
  433. void Promise.resolve(returned).catch((listenerError: unknown) => {
  434. this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${errorChain(listenerError)}`)
  435. })
  436. } catch (listenerError: unknown) {
  437. this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${errorChain(listenerError)}`)
  438. }
  439. }
  440. }
  441. /** Restore a materialized exact config identity on remount, or create it on first use. */
  442. private async restoreOrCreateConfigured(
  443. ownerCtx: Context,
  444. persistence: SessionPersistence,
  445. sessionId: SessionId,
  446. agentOptions: AgentOptions,
  447. meta: Pick<SessionHeader, 'cwd'>,
  448. ): Promise<void> {
  449. await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId)
  450. if (!this.ownership.isActive()) return
  451. try {
  452. await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions })
  453. return
  454. } catch (error: unknown) {
  455. if (!this.ownership.isActive()) return
  456. // Only a genuinely absent stored session falls back to first creation;
  457. // corruption, ownership conflicts, and backend failures stay loud.
  458. if (!(error instanceof SessionPersistenceNotFoundError)) throw error
  459. }
  460. await this.create(sessionId, agentOptions, meta)
  461. }
  462. /** Wait for a draining same-id lifecycle to finish registry teardown. */
  463. private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise<void> {
  464. // Only an id still occupying a registry needs waiting for; a live healthy
  465. // occupant is a collision the create/resume below will surface itself.
  466. if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) return
  467. const released = Promise.withResolvers<void>()
  468. const checkReleased = (): void => {
  469. if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) {
  470. released.resolve()
  471. }
  472. }
  473. const disposeAgentListener = ownerCtx.on('agent/disposed', () => { checkReleased() })
  474. const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased)
  475. try {
  476. checkReleased()
  477. await this.ownership.waitWhileActive(released.promise)
  478. } finally {
  479. disposeAgentListener()
  480. disposeSessionListener()
  481. }
  482. }
  483. /**
  484. * Construct the driver, scope, and one memoized reverse teardown for a new
  485. * agent. The teardown is registered with the factory and the owner fiber
  486. * BEFORE publication, so a mid-setup unload rolls everything back; `signal`
  487. * fuses caller cancellation with lifecycle teardown for setup awaits.
  488. */
  489. private prepare(
  490. ownerCtx: Context,
  491. id: SessionId,
  492. options: AgentOptions,
  493. session: Session,
  494. callerSignal?: AbortSignal,
  495. handle?: SessionHandle,
  496. parentAgent?: Agent,
  497. ): PreparedAgent {
  498. assertAgentOptions(options)
  499. ownerCtx.fiber.assertActive()
  500. // Every caller reaches prepare() synchronously from a service method
  501. // whose Cordis dispatch already requires the live factory fiber, or
  502. // re-checks ownership itself after its awaits (resume's load barrier).
  503. /* v8 ignore next -- unreachable backstop, see above */
  504. if (!this.ownership.isActive()) throw new Error('agent loop is not active')
  505. if (callerSignal?.aborted) {
  506. throw callerSignal.reason instanceof Error
  507. ? callerSignal.reason
  508. : new Error(`agent "${id}" creation aborted`, { cause: callerSignal.reason })
  509. }
  510. const loopCtx = this.runtime.ctx
  511. // Deactivation fuses three owners, each with its own reason: the caller's
  512. // cancellation signal, the owner fiber's unload, and factory teardown.
  513. // It is registered BEFORE any resource exists, over mutable slots, so an
  514. // unload arriving while the scope is still minting finds a working
  515. // disposer instead of a leak.
  516. const abort = new AbortController()
  517. const onCallerAbort = (): void => {
  518. abort.abort(callerSignal?.reason instanceof Error
  519. ? callerSignal.reason
  520. : new Error(`agent "${id}" creation aborted`, { cause: callerSignal?.reason }))
  521. }
  522. const onFactoryTeardown = (): void => { abort.abort(this.ownership.signal.reason) }
  523. callerSignal?.addEventListener('abort', onCallerAbort, { once: true })
  524. this.ownership.signal.addEventListener('abort', onFactoryTeardown, { once: true })
  525. let machine: ReactLoopAgent | undefined
  526. let detachSession: (() => void) | undefined
  527. let detachAgent: (() => void) | undefined
  528. let disposing: Promise<void> | undefined
  529. let publication: ReturnType<typeof Promise.withResolvers<void>> | undefined
  530. const machineReady = Promise.withResolvers<void>()
  531. // Reverse teardown, memoized so every racing owner awaits one quiescence:
  532. // stop the machine, drain and close the session's write path, leave the
  533. // registries, unwind the scope, release bookkeeping.
  534. const dispose = (ownerTriggered = false): Promise<void> => (disposing ??= (async () => {
  535. abort.abort(new Error(`agent "${id}" lifecycle disposed`))
  536. callerSignal?.removeEventListener('abort', onCallerAbort)
  537. this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
  538. // Teardown failures are collected, never swallowed: registry, scope,
  539. // and ownership cleanup always run to quiescence, then the memoized
  540. // disposal rejects with what failed so every racing owner observes it.
  541. const failures: unknown[] = []
  542. try {
  543. // Creation listeners retain the session and scope through their awaits.
  544. if (publication !== undefined) await publication.promise
  545. // Disposal IS a disposed-cause cancel followed by quiescence. New work
  546. // sent after this point is the sender's bug — the registries are about
  547. // to drop the agent, so nothing should still hold it.
  548. /* v8 ignore next -- Cordis effect teardown waits for synchronous setup before observing the machine slot. */
  549. if (machine === undefined) await machineReady.promise
  550. /* v8 ignore next -- setup failure untracks this disposer before resolving without a machine. */
  551. if (machine !== undefined) {
  552. machine.cancel({ kind: 'disposed' })
  553. await machine.whenIdle()
  554. await machine.scope.dispose()
  555. }
  556. } catch (error: unknown) {
  557. failures.push(error)
  558. }
  559. // The loop above committed its closing events synchronously into the
  560. // session; handle close drains them durably before releasing the write
  561. // path. The close drain can be the first operation that surfaces a
  562. // durability failure, so its error is retained, not logged away.
  563. try {
  564. await handle?.close()
  565. } catch (error: unknown) {
  566. failures.push(error)
  567. }
  568. try {
  569. detachAgent?.()
  570. detachSession?.()
  571. } finally {
  572. untrack()
  573. if (!ownerTriggered) await unfollowOwner()
  574. }
  575. if (failures.length === 1) throw failures[0]
  576. if (failures.length > 1) {
  577. throw new AggregateError(failures, `agent "${id}" disposal failed`)
  578. }
  579. })())
  580. const untrack = this.ownership.track(dispose)
  581. let unfollowOwner: () => Promise<void> | void
  582. try {
  583. unfollowOwner = ownerCtx.effect(function* () {
  584. machine = new ReactLoopAgent(loopCtx, id, options, session)
  585. machineReady.resolve()
  586. yield machine.scope.rawDispose
  587. yield () => {
  588. // Owner disposal owns the same quiescence boundary. Its teardown skips
  589. // unregistering this already-running owner effect from inside itself.
  590. if (disposing !== undefined) return
  591. abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
  592. return dispose(true)
  593. }
  594. }, `agentLoop.lifecycle(${id})`)
  595. /* v8 ignore start -- ctx.effect throws only on an inactive fiber, which assertActive() above already rejected */
  596. } catch (error: unknown) {
  597. machineReady.resolve()
  598. untrack()
  599. callerSignal?.removeEventListener('abort', onCallerAbort)
  600. this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
  601. throw error
  602. }
  603. /* v8 ignore stop */
  604. const assertLive = (): void => {
  605. if (!abort.signal.aborted) return
  606. // Every fused abort source carries an Error reason: onCallerAbort and
  607. // raceAbort wrap non-Error caller reasons, and the factory/lifecycle
  608. // owners abort with constructed Errors.
  609. /* v8 ignore next -- unreachable String() arm, see above */
  610. throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason))
  611. }
  612. try {
  613. /* v8 ignore next -- a synchronous effect exhausts the generator before returning */
  614. if (machine === undefined) throw new Error(`agent "${id}" lifecycle did not construct its driver`)
  615. const agent = machine
  616. assertLive()
  617. return {
  618. agent,
  619. signal: abort.signal,
  620. publish: async (source) => {
  621. publication = Promise.withResolvers<void>()
  622. try {
  623. assertLive()
  624. detachSession = agent.ctx.sessions.enter(session)
  625. // The mounted backend routes announced live events into the active
  626. // write handle by session id; the loop only owns the handle itself.
  627. detachAgent = loopCtx.agents.enter(agent, parentAgent)
  628. agent.ctx.sessions.announce(session)
  629. assertLive()
  630. await loopCtx.agents.announce(agent, source, abort.signal)
  631. assertLive()
  632. return { agent, dispose }
  633. } finally {
  634. publication.resolve()
  635. publication = undefined
  636. }
  637. },
  638. dispose,
  639. }
  640. } catch (error: unknown) {
  641. machineReady.resolve()
  642. // Rollback swallows a disposal rejection: the setup failure is primary.
  643. void dispose().catch(() => {})
  644. throw error
  645. }
  646. }
  647. /**
  648. * Create an agent and session under one caller-supplied identity, owned by
  649. * the accessing fiber. Constructor-driven config calls mint a fresh combined
  650. * id before entering this boundary. When a persistence backend is mounted,
  651. * the session's durable identity and any seed are stored before publication.
  652. * @param id - shared agent/session identity.
  653. * @param options - concrete loop options.
  654. * @param meta - optional fresh-session workspace metadata.
  655. * @returns the published running agent.
  656. */
  657. async create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Promise<Agent> {
  658. using preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, { meta }))
  659. const stored = await this.createStoredSession(preparation.session)
  660. let prepared: PreparedAgent
  661. try {
  662. prepared = this.prepare(this.ctx, id, options, preparation.session, undefined, stored?.handle)
  663. } catch (error: unknown) {
  664. await stored?.handle.close().catch(() => {})
  665. throw error
  666. }
  667. return (await this.initializeAgent(prepared, async () => {
  668. await this.appendUnstoredSuffix(stored, preparation.session)
  669. return await prepared.publish('startup')
  670. })).agent
  671. }
  672. /**
  673. * Take a fresh session's write ownership when persistence is mounted.
  674. * Nothing is appended here: the constructor seed (which never re-emits
  675. * through `session/event`) is stored by `appendUnstoredSuffix` at the
  676. * publication commit point, so a failed or cancelled validation or setup
  677. * closes an unmaterialized handle and leaves no stored residue — the same
  678. * id can be created again.
  679. * @param session - the unpublished session to store.
  680. * @param signal - optional cancellation forwarded to the backend create.
  681. * @returns the owned handle and stored cursor, or `undefined` without a backend.
  682. */
  683. private async createStoredSession(session: Session, signal?: AbortSignal): Promise<StoredSession | undefined> {
  684. const persistence = this.runtime.ctx.get('sessionPersistence')
  685. if (persistence === undefined) return undefined
  686. const handle = await persistence.create(session.header, {
  687. inheritedEventCount: session.inheritedEventCount,
  688. ...signal === undefined ? {} : { signal },
  689. })
  690. return { handle, storedCount: 0 }
  691. }
  692. /**
  693. * Durably store the session events appended since the last stored cursor.
  694. * Pre-publication appends (constructor seed markers, setup-window events
  695. * such as delegation policy records) never re-emit through `session/event`,
  696. * so publication must flush them through the handle before live events
  697. * start routing into it.
  698. * @param stored - the session's owned handle and stored cursor, if any.
  699. * @param session - the unpublished session whose suffix is stored.
  700. */
  701. private async appendUnstoredSuffix(stored: StoredSession | undefined, session: Session): Promise<void> {
  702. if (stored === undefined) return
  703. // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
  704. const suffix = session.snapshotEvents(SessionLogOffset(stored.storedCount))
  705. if (suffix.length > 0) await stored.handle.append(suffix)
  706. // Advance by what was stored, not to `session.seq`: an event appended
  707. // during the await must stay unstored for the next flush.
  708. stored.storedCount += suffix.length
  709. }
  710. /**
  711. * Create an owned agent on a caller-supplied session id.
  712. * @param ownerCtx - caller context that structurally owns the lifecycle.
  713. * @param options - identities, optional live parent, session seed/metadata, loop options, setup, and cancellation.
  714. * @returns the published handle.
  715. */
  716. async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
  717. const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, {
  718. ...options.seed === undefined ? {} : { seed: options.seed },
  719. ...options.meta === undefined ? {} : { meta: options.meta },
  720. ...options.inheritedEventCount === undefined ? {} : { inheritedEventCount: options.inheritedEventCount },
  721. }))
  722. const published = (async () => {
  723. let stored: StoredSession | undefined
  724. try {
  725. // raceAbortCall normalizes a pre-aborted or mid-create abort and
  726. // closes a handle that finishes creating after abandonment.
  727. stored = options.signal === undefined
  728. ? await this.createStoredSession(preparation.session)
  729. : await raceAbortCall(
  730. () => this.createStoredSession(preparation.session, options.signal),
  731. options.signal,
  732. options.sessionId,
  733. (abandoned) => { void abandoned?.handle.close().catch(() => {}) },
  734. )
  735. } catch (error: unknown) {
  736. preparation[Symbol.dispose]()
  737. throw error
  738. }
  739. return this.setupAndPublish(
  740. ownerCtx,
  741. options.sessionId,
  742. preparation,
  743. options.agentOptions ?? {},
  744. options.setup,
  745. options.signal,
  746. 'startup',
  747. stored,
  748. options.parentAgent,
  749. )
  750. })()
  751. this.ownership.trackWrapper(published)
  752. return published
  753. }
  754. /** Prepare one Agent around an acquired Session, run setup, and publish it. */
  755. private async setupAndPublish(
  756. ownerCtx: Context,
  757. id: SessionId,
  758. preparation: SessionPreparation,
  759. agentOptions: AgentOptions,
  760. setup: AgentSetup | undefined,
  761. signal: AbortSignal | undefined,
  762. source: SessionStartSource,
  763. stored?: StoredSession,
  764. parentAgent?: Agent,
  765. ): Promise<AgentHandle> {
  766. using ownedPreparation = preparation
  767. const session = ownedPreparation.session
  768. let prepared: PreparedAgent
  769. try {
  770. prepared = this.prepare(ownerCtx, id, agentOptions, session, signal, stored?.handle, parentAgent)
  771. } catch (error: unknown) {
  772. await stored?.handle.close().catch(() => {})
  773. throw error
  774. }
  775. return await this.initializeAgent(prepared, async () => {
  776. const setupCommit = await raceAbort(setup?.(prepared.agent.ctx, prepared.agent), prepared.signal, id)
  777. setupCommit?.commit()
  778. await this.appendUnstoredSuffix(stored, session)
  779. return await prepared.publish(source)
  780. })
  781. }
  782. private async initializeAgent(prepared: PreparedAgent, initialize: () => Promise<AgentHandle>): Promise<AgentHandle> {
  783. try {
  784. return await prepared.agent.runMaintenance(async () => {
  785. try {
  786. return await initialize()
  787. } catch (error: unknown) {
  788. // Teardown owns inbox cleanup and may already have removed its projection.
  789. prepared.agent.cancel({ kind: 'disposed' }, { keepInbox: true })
  790. throw error
  791. }
  792. })
  793. } catch (error: unknown) {
  794. // Rollback swallows a disposal rejection (a failing final handle close):
  795. // the setup failure is the primary error the caller must see.
  796. await prepared.dispose().catch(() => {})
  797. throw error
  798. }
  799. }
  800. /**
  801. * Resume an owned agent from the configured persistence service.
  802. * @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
  803. * @param options - persisted identity, optional live parent, loop options, setup, and cancellation.
  804. * @returns the published handle.
  805. */
  806. async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle> {
  807. const persistence = this.runtime.ctx.get('sessionPersistence')
  808. if (persistence === undefined) {
  809. throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
  810. }
  811. return this.resumeWith(ownerCtx, persistence, options)
  812. }
  813. /** Resume through an explicit persistence handle used by the deferred config path. */
  814. private resumeWith(
  815. ownerCtx: Context,
  816. persistence: SessionPersistence,
  817. options: ResumeAgentOptions,
  818. ): Promise<AgentHandle> {
  819. const id = options.resumeSessionId
  820. const published = (async () => {
  821. // The open and read may outlive their owner: race them against caller
  822. // cancellation, owner-fiber unload, and factory teardown so a
  823. // never-settling backend cannot pin the identity.
  824. const ownerAbort = new AbortController()
  825. const unfollowOwner = ownerCtx.effect(() => () => {
  826. ownerAbort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
  827. }, `agentLoop.resume-load(${id})`)
  828. const fused = AbortSignal.any([
  829. ...options.signal === undefined ? [] : [options.signal],
  830. ownerAbort.signal,
  831. this.ownership.signal,
  832. ])
  833. let handle: SessionHandle | undefined
  834. let stored: StoredSession | undefined
  835. let preparation: SessionPreparation | undefined
  836. try {
  837. try {
  838. // Taking write ownership FIRST excludes a concurrent resume of the
  839. // same id (in this process, a live agent's handle holds the claim).
  840. handle = await raceAbortCall(
  841. () => persistence.open(id, 'write', { signal: fused }),
  842. fused,
  843. id,
  844. (abandoned) => { void abandoned.close() },
  845. )
  846. // Semantic crash repair is the agent layer's job: persistence hands
  847. // back the physically valid log; an interrupted final turn receives
  848. // synthetic closers (missing tool errors, step/end, turn/end) that
  849. // are appended through the same handle as an ordinary batch.
  850. const coldRead = await handle.read(0, undefined, { signal: fused })
  851. fused.throwIfAborted()
  852. const persisted = coldRead.events
  853. const closers = interruptedTurnClosers(persisted)
  854. if (closers.length > 0) await handle.append(closers)
  855. preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, {
  856. seed: [...persisted, ...closers],
  857. meta: structuredClone(handle.header),
  858. inheritedEventCount: handle.inheritedEventCount,
  859. eventState: coldRead.eventState,
  860. }))
  861. stored = { handle, storedCount: persisted.length + closers.length }
  862. await this.appendUnstoredSuffix(stored, preparation.session)
  863. } finally {
  864. await unfollowOwner()
  865. }
  866. ownerCtx.fiber.assertActive()
  867. if (!this.ownership.isActive()) throw new Error('agent loop is not active')
  868. const owned = stored
  869. handle = undefined // ownership passes to setupAndPublish/prepare
  870. return await this.setupAndPublish(
  871. ownerCtx,
  872. id,
  873. preparation,
  874. options.agentOptions ?? {},
  875. options.setup,
  876. options.signal,
  877. 'resume',
  878. owned,
  879. options.parentAgent,
  880. )
  881. } finally {
  882. preparation?.[Symbol.dispose]()
  883. await handle?.close().catch(() => {})
  884. }
  885. })()
  886. this.ownership.trackWrapper(published)
  887. return published
  888. }
  889. }
  890. export default AgentLoop