| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916 |
- /**
- * Concrete agent-loop plugin: creates scoped ReactLoopAgents, publishes them
- * through the agent/session registries, and owns their ordered teardown.
- *
- * @module @deepseek-ai/dsh-agent-loop
- */
- import { Context, FiberState, Service } from '@deepseek-ai/cordis'
- import { randomUUID } from 'node:crypto'
- import z from '@deepseek-ai/schemastery'
- import { z as zod } from 'zod'
- import { brandString } from '@deepseek-ai/dsh-brand'
- import { emitAgentEvent } from '@deepseek-ai/dsh-agent'
- import type {
- Agent,
- AgentFactory,
- AgentHandle,
- AgentOptions,
- AgentSetup,
- CreateAgentOptions,
- ResumeAgentOptions,
- SessionStartSource,
- TurnBoundaryProjection,
- } from '@deepseek-ai/dsh-agent'
- import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
- import type {} from '@deepseek-ai/dsh-settings'
- import { interruptedTurnClosers, SessionLogOffset, SessionPreparation, SessionSeq } from '@deepseek-ai/dsh-session'
- import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
- import type {} from '@deepseek-ai/dsh-system-prompt'
- import type {} from '@deepseek-ai/dsh-tools'
- import type {} from '@deepseek-ai/dsh-session-projection'
- import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
- import { SessionPersistenceNotFoundError } from '@deepseek-ai/dsh-session-persistence'
- import type { SessionHandle, SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
- import { ReactLoopAgent } from './agent.ts'
- import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
- /** Fiber states that cannot own or serve a new lifecycle. */
- const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
- FiberState.UNLOADING,
- FiberState.DISPOSED,
- FiberState.FAILED,
- ])
- const turnBoundaryProjectionSchema: zod.ZodType<TurnBoundaryProjection> = zod.object({
- openTurnStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(),
- lastStepStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(),
- lastStepBoundary: zod.object({
- kind: zod.union([zod.literal('start'), zod.literal('end')]),
- seq: zod.number().int().nonnegative().transform(SessionSeq),
- }).nullable(),
- lastTurn: zod.number().int().nonnegative(),
- })
- /** Host projection of agent turn and step boundaries. */
- export const turnBoundaryProjectionDefinition = {
- key: 'turnBoundary',
- stateVersion: 2,
- stateSchema: turnBoundaryProjectionSchema,
- init: () => ({
- openTurnStartSeq: null,
- lastStepStartSeq: null,
- lastStepBoundary: null,
- lastTurn: 0,
- }),
- apply: (state, event) => {
- switch (event.type) {
- case 'turn/start':
- return {
- ...state,
- openTurnStartSeq: event.seq,
- lastTurn: event.data.turn,
- }
- case 'turn/end':
- return {
- ...state,
- openTurnStartSeq: null,
- }
- case 'step/start':
- return {
- ...state,
- lastStepStartSeq: event.seq,
- lastStepBoundary: { kind: 'start', seq: event.seq },
- }
- case 'step/end':
- return {
- ...state,
- lastStepBoundary: { kind: 'end', seq: event.seq },
- }
- default:
- return state
- }
- },
- } satisfies ProjectionDefinition<'turnBoundary', TurnBoundaryProjection>
- /** Factory-level ownership: live agent teardowns plus config startup work. */
- class FactoryOwnership {
- private accepting = true
- private readonly teardown = new AbortController()
- private readonly inactive = Promise.withResolvers<void>()
- private readonly liveAgents = new Set<() => Promise<void>>()
- private startupTasks = new Set<Promise<void>>()
- constructor(private readonly fiber: Context['fiber']) {}
- /** Aborts (reason: `agent loop is not active` error) when factory teardown begins. */
- get signal(): AbortSignal {
- return this.teardown.signal
- }
- isActive(): boolean {
- return this.accepting && !INACTIVE_STATES.has(this.fiber.state)
- }
- /** Track one live agent's shared teardown until it has run. */
- track(dispose: () => Promise<void>): () => void {
- this.liveAgents.add(dispose)
- return () => { this.liveAgents.delete(dispose) }
- }
- /** Join config startup work that begins before an agent exists. */
- trackStartup(job: Promise<void>): void {
- this.startupTasks.add(job)
- const forget = () => { this.startupTasks.delete(job) }
- void job.then(forget, forget)
- }
- /** Join one public create/resume continuation; factory dispose awaits its settlement. */
- trackWrapper(job: Promise<unknown>): void {
- this.trackStartup(job.then(() => undefined, () => undefined))
- }
- /** Resolve `task`, or stop waiting when factory teardown begins. */
- async waitWhileActive(job: Promise<void>): Promise<void> {
- await Promise.race([job, this.inactive.promise])
- }
- async dispose(): Promise<void> {
- this.accepting = false
- this.teardown.abort(new Error('agent loop is not active'))
- this.inactive.resolve()
- await Promise.all([
- ...[...this.liveAgents].map(dispose => dispose()),
- ...this.startupTasks,
- ])
- }
- }
- /** Await `operation`, or throw the signal's reason as soon as it aborts. */
- async function raceAbort<T>(operation: PromiseLike<T> | T, signal: AbortSignal, id: SessionId): Promise<T> {
- const toAbortError = (): Error => signal.reason instanceof Error
- ? signal.reason
- : new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
- if (signal.aborted) throw toAbortError()
- const aborted = Promise.withResolvers<never>()
- const listener = (): void => { aborted.reject(toAbortError()) }
- signal.addEventListener('abort', listener, { once: true })
- try {
- return await Promise.race([Promise.resolve(operation), aborted.promise])
- } finally {
- signal.removeEventListener('abort', listener)
- }
- }
- /** Start an abortable operation and release a value that arrives after cancellation. */
- async function raceAbortCall<T>(
- operation: () => PromiseLike<T> | T,
- signal: AbortSignal,
- id: SessionId,
- releaseAbandoned?: (value: T) => void,
- ): Promise<T> {
- if (signal.aborted) {
- throw signal.reason instanceof Error
- ? signal.reason
- : new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
- }
- const pending = Promise.resolve().then(operation)
- try {
- return await raceAbort(pending, signal, id)
- } catch (error: unknown) {
- // oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while the operation is awaited.
- if (signal.aborted && releaseAbandoned !== undefined) {
- void pending.then(releaseAbandoned, () => undefined)
- }
- throw error
- }
- }
- /** Resolve the deployment-wide scheduler cap at the owning config boundary. */
- function resolveMaxParallelToolCalls(value: number | undefined): number {
- const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
- if (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1) {
- throw new Error('maxParallelToolCalls must be a positive integer')
- }
- return maxParallelToolCalls
- }
- /** Reject an output-token cap that cannot be represented exactly on the request wire. */
- function assertAgentOptions(options: AgentOptions): void {
- if (options.maxTokens !== undefined
- && (!Number.isSafeInteger(options.maxTokens) || options.maxTokens <= 0)) {
- throw new TypeError('agent maxTokens must be a positive safe integer')
- }
- }
- /** One session's owned write handle plus the count of events already stored through it. */
- interface StoredSession {
- readonly handle: SessionHandle
- storedCount: number
- }
- /** Prepared-but-unpublished agent resources sharing one memoized teardown. */
- interface PreparedAgent {
- agent: ReactLoopAgent
- /** Aborts when the factory unloads, the caller cancels, or teardown begins — ends any setup await. */
- signal: AbortSignal
- /** Enter registries, announce, notify session-start, and start the machine. */
- publish(source: SessionStartSource): AgentHandle
- /** Reverse teardown: stop the machine, unregister, unwind the scope. Memoized. */
- dispose(): Promise<void>
- }
- declare module '@deepseek-ai/cordis' {
- interface Context {
- agentLoop: AgentLoop
- /**
- * Launcher-owned exact session identities for configured agents, keyed by
- * the agent's config `id` and set with `ctx.provide()` before any Loader
- * entry mounts (see {@link CONFIGURED_AGENT_IDENTITIES_KEY}). A launcher
- * owns identity because only it knows whether the session already exists,
- * while the `cordis.yml` row keeps the model route as ordinary patchable
- * config. An entry with no matching key keeps its configured identity.
- */
- configuredAgentIdentities?: ConfiguredAgentIdentities
- }
- interface Events {
- /**
- * A declarative agent entry failed before it could publish a live agent.
- * Consumers that buffer work for the configured identity use this
- * transient signal to reject that work instead of waiting forever. Normal
- * factory teardown suppresses failures from the cancelled startup attempt.
- * @param payload.sessionId - exact shared agent/session identity that failed startup.
- * @param payload.error - persistence, setup, or publication failure.
- * @mode emit
- */
- 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void
- }
- }
- export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
- /**
- * One launcher-selected session identity for a configured agent. `resume`
- * distinguishes rehydrating existing persisted history from creating the
- * session fresh under that exact id, which the two config keys express as
- * `resumeSessionId` and `sessionId`.
- */
- export interface LauncherAgentIdentity {
- /** Exact session id to create fresh or resume. */
- id: SessionId
- /** Resume existing persisted history instead of creating the session fresh. */
- resume: boolean
- }
- /** Launcher-selected identities keyed by the configured agent's `id`. */
- export interface ConfiguredAgentIdentities extends Readonly<Record<string, LauncherAgentIdentity>> {}
- /**
- * Context key a launcher sets before any Loader entry mounts
- * (`ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, identities)`) to fix
- * configured agents' session identities without a config key, so an overlay
- * repointing the row's model route cannot drop them.
- */
- export const CONFIGURED_AGENT_IDENTITIES_KEY = 'configuredAgentIdentities'
- /**
- * Apply launcher-owned identities over the configured agents, replacing both
- * identity keys for every entry the launcher named so a config-supplied
- * identity can never survive alongside a launcher-supplied one.
- * @param agents - the configured agent entries.
- * @param identities - launcher identities keyed by configured agent `id`, or `undefined`.
- * @returns the entries with launcher-owned identities applied.
- */
- function applyLauncherIdentities(
- agents: Config['agents'],
- identities: ConfiguredAgentIdentities | undefined,
- ): Config['agents'] {
- if (identities === undefined) return agents
- return agents.map((agent) => {
- const identity = identities[agent.id]
- if (identity === undefined) return agent
- const { sessionId: _sessionId, resumeSessionId: _resumeSessionId, ...rest } = agent
- return identity.resume
- ? { ...rest, resumeSessionId: identity.id }
- : { ...rest, sessionId: identity.id }
- })
- }
- /** Settings namespace carrying the tool-call parallelism a user owns. */
- export const AGENT_LOOP_SETTINGS_NAMESPACE = 'agent-loop'
- /**
- * The agent-loop fields a user owns. Deliberately a strict subset of
- * {@link Config}: `agents` is a boot-time composition array consumed once when
- * the service starts, so a stored change could only look like it had an effect.
- */
- export interface AgentLoopSettings {
- /** Maximum parallel-safe calls in flight per agent step. */
- maxParallelToolCalls: number
- }
- /** Schema of the agent-loop settings section. */
- export const AGENT_LOOP_SETTINGS_SCHEMA: z<AgentLoopSettings> = z.object({
- maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
- })
- /** Agent-loop plugin configuration. */
- export interface Config {
- /**
- * Maximum parallel-safe calls in flight per agent step. `1` is serial;
- * omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
- */
- maxParallelToolCalls?: number
- /** Agents created or resumed at plugin startup. */
- agents: (AgentOptions & {
- /** Stable config label used in logs and as the fresh combined-id prefix. */
- id: string
- /** Optional stable identity; remounts resume its materialized history, while first use creates it fresh. */
- sessionId?: SessionId
- /** Optional workspace for a fresh session. */
- cwd?: string
- /** Persisted session to resume instead of creating a fresh session. */
- resumeSessionId?: SessionId
- })[]
- }
- /** Agent-loop configuration after defaults and load-time validation. */
- type ResolvedConfig = Config & { maxParallelToolCalls: number }
- /** Reject self-contained identity conflicts before any configured agent starts. */
- function validateConfiguredAgents(agents: Config['agents']): void {
- const exactIdentities = new Map<SessionId, string>()
- for (const { id, sessionId, resumeSessionId } of agents) {
- const hasResumeId = resumeSessionId !== undefined && resumeSessionId !== ''
- if (sessionId !== undefined && hasResumeId) {
- throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`)
- }
- const exactIdentity = hasResumeId ? resumeSessionId : sessionId
- if (exactIdentity === undefined) continue
- const firstId = exactIdentities.get(exactIdentity)
- if (firstId !== undefined) {
- throw new Error(`agents "${firstId}" and "${id}" use duplicate exact session identity "${exactIdentity}"`)
- }
- exactIdentities.set(exactIdentity, id)
- }
- }
- /** Concrete agent factory and driver service. */
- export class AgentLoop extends Service implements AgentFactory {
- static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt', 'sessionProjections']
- /** Runtime schema for declarative agents. */
- static Config = z.object({
- maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
- agents: z.array(z.object({
- id: z.string().required(),
- sessionId: z.string().min(1),
- provider: z.string(),
- model: z.string(),
- reasoningEffort: z.string().min(1) as z<ReturnType<typeof ReasoningEffortId>>,
- maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
- cwd: z.string(),
- resumeSessionId: z.string(),
- })).default([]),
- }) as z<Config>
- /** Validated configuration owned by the agent-loop service. */
- readonly config: ResolvedConfig
- private readonly ownership: FactoryOwnership
- /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
- private readonly runtime: { ctx: Context }
- constructor(ctx: Context, config: Config) {
- super(ctx, 'agentLoop')
- const entry: AgentLoopSettings = {
- maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls),
- }
- let source: () => AgentLoopSettings = () => entry
- this.config = {
- ...config,
- agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)),
- // Read through on every scheduler decision: `tool-calls.ts` destructures
- // this at the start of each group, so a committed change caps the next
- // group without disturbing the one in flight.
- get maxParallelToolCalls() {
- return source().maxParallelToolCalls
- },
- }
- ctx.inject(['settings'], (settingsCtx) => {
- settingsCtx.settings.installSection(ctx, AGENT_LOOP_SETTINGS_NAMESPACE, AGENT_LOOP_SETTINGS_SCHEMA, entry, {
- // The schema admits any integer above zero; `resolveMaxParallelToolCalls`
- // owns the whole rule, so refusing here keeps the running scheduler on
- // its last good cap instead of failing at the next tool group.
- validate: value => void resolveMaxParallelToolCalls(value.maxParallelToolCalls),
- setSource: (current) => {
- source = current
- },
- // Nothing is derived from the cap: the getter above is the only reader.
- onChange: () => {},
- })
- })
- validateConfiguredAgents(this.config.agents)
- // Register only after every config validation above has passed, so a
- // rejected constructor leaves no projection unit behind.
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
- this.ownership = new FactoryOwnership(ctx.fiber)
- this.runtime = { ctx }
- ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
- ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
- ctx.systemPrompt.variable('provider', context => context.agent?.options.provider)
- ctx.systemPrompt.variable('model', context => context.agent?.options.model)
- ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
- for (const { id, sessionId, cwd, resumeSessionId, ...options } of this.config.agents) {
- const meta = cwd === undefined ? {} : { cwd }
- if (resumeSessionId === undefined || resumeSessionId === '') {
- const configuredId = sessionId ?? brandString<SessionId>(`${id}-session-${randomUUID()}`)
- const persistence = sessionId === undefined ? undefined : ctx.get('sessionPersistence')
- if (persistence === undefined) {
- const startup = this.create(configuredId, options, meta).then(() => undefined, (error: unknown) => {
- this.reportConfiguredStartupFailure(id, 'restore', configuredId, error)
- })
- this.ownership.trackStartup(startup)
- } else {
- const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => {
- this.reportConfiguredStartupFailure(id, 'restore', configuredId, error)
- })
- this.ownership.trackStartup(startup)
- }
- continue
- }
- ctx.effect(() => {
- const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
- void this.resumeWith(ctx, childCtx.sessionPersistence, {
- resumeSessionId,
- agentOptions: options,
- }).catch((error: unknown) => {
- this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error)
- })
- })
- return fiber.dispose
- }, `agentLoop.resume(${id})`)
- }
- }
- /** Report a contained declarative-start failure to identity-bound consumers. */
- private reportConfiguredStartupFailure(
- configId: string,
- action: 'restore' | 'resume',
- sessionId: SessionId,
- error: unknown,
- ): void {
- if (!this.ownership.isActive()) return
- this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`)
- const args: unknown[] = ['agent-loop/config-start-failed', { sessionId, error }]
- for (const callback of this.ctx.events.dispatch('emit', args)) {
- try {
- const returned: unknown = callback(...args)
- void Promise.resolve(returned).catch((listenerError: unknown) => {
- this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${errorChain(listenerError)}`)
- })
- } catch (listenerError: unknown) {
- this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${errorChain(listenerError)}`)
- }
- }
- }
- /** Restore a materialized exact config identity on remount, or create it on first use. */
- private async restoreOrCreateConfigured(
- ownerCtx: Context,
- persistence: SessionPersistence,
- sessionId: SessionId,
- agentOptions: AgentOptions,
- meta: Pick<SessionHeader, 'cwd'>,
- ): Promise<void> {
- await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId)
- if (!this.ownership.isActive()) return
- try {
- await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions })
- return
- } catch (error: unknown) {
- if (!this.ownership.isActive()) return
- // Only a genuinely absent stored session falls back to first creation;
- // corruption, ownership conflicts, and backend failures stay loud.
- if (!(error instanceof SessionPersistenceNotFoundError)) throw error
- }
- await this.create(sessionId, agentOptions, meta)
- }
- /** Wait for a draining same-id lifecycle to finish registry teardown. */
- private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise<void> {
- // Only an id still occupying a registry needs waiting for; a live healthy
- // occupant is a collision the create/resume below will surface itself.
- if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) return
- const released = Promise.withResolvers<void>()
- const checkReleased = (): void => {
- if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) {
- released.resolve()
- }
- }
- const disposeAgentListener = ownerCtx.on('agent/disposed', () => { checkReleased() })
- const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased)
- try {
- checkReleased()
- await this.ownership.waitWhileActive(released.promise)
- } finally {
- disposeAgentListener()
- disposeSessionListener()
- }
- }
- /**
- * Construct the driver, scope, and one memoized reverse teardown for a new
- * agent. The teardown is registered with the factory and the owner fiber
- * BEFORE publication, so a mid-setup unload rolls everything back; `signal`
- * fuses caller cancellation with lifecycle teardown for setup awaits.
- */
- private prepare(
- ownerCtx: Context,
- id: SessionId,
- options: AgentOptions,
- session: Session,
- callerSignal?: AbortSignal,
- handle?: SessionHandle,
- ): PreparedAgent {
- assertAgentOptions(options)
- ownerCtx.fiber.assertActive()
- // Every caller reaches prepare() synchronously from a service method
- // whose Cordis dispatch already requires the live factory fiber, or
- // re-checks ownership itself after its awaits (resume's load barrier).
- /* v8 ignore next -- unreachable backstop, see above */
- if (!this.ownership.isActive()) throw new Error('agent loop is not active')
- if (callerSignal?.aborted) {
- throw callerSignal.reason instanceof Error
- ? callerSignal.reason
- : new Error(`agent "${id}" creation aborted`, { cause: callerSignal.reason })
- }
- const loopCtx = this.runtime.ctx
- // Deactivation fuses three owners, each with its own reason: the caller's
- // cancellation signal, the owner fiber's unload, and factory teardown.
- // It is registered BEFORE any resource exists, over mutable slots, so an
- // unload arriving while the scope is still minting finds a working
- // disposer instead of a leak.
- const abort = new AbortController()
- const onCallerAbort = (): void => {
- abort.abort(callerSignal?.reason instanceof Error
- ? callerSignal.reason
- : new Error(`agent "${id}" creation aborted`, { cause: callerSignal?.reason }))
- }
- const onFactoryTeardown = (): void => { abort.abort(this.ownership.signal.reason) }
- callerSignal?.addEventListener('abort', onCallerAbort, { once: true })
- this.ownership.signal.addEventListener('abort', onFactoryTeardown, { once: true })
- let machine: ReactLoopAgent | undefined
- let detachSession: (() => void) | undefined
- let detachAgent: (() => void) | undefined
- let disposing: Promise<void> | undefined
- const machineReady = Promise.withResolvers<void>()
- // Reverse teardown, memoized so every racing owner awaits one quiescence:
- // stop the machine, drain and close the session's write path, leave the
- // registries, unwind the scope, release bookkeeping.
- const dispose = (ownerTriggered = false): Promise<void> => (disposing ??= (async () => {
- abort.abort(new Error(`agent "${id}" lifecycle disposed`))
- callerSignal?.removeEventListener('abort', onCallerAbort)
- this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
- // Teardown failures are collected, never swallowed: registry, scope,
- // and ownership cleanup always run to quiescence, then the memoized
- // disposal rejects with what failed so every racing owner observes it.
- const failures: unknown[] = []
- try {
- // Disposal IS a disposed-cause cancel followed by quiescence. New work
- // sent after this point is the sender's bug — the registries are about
- // to drop the agent, so nothing should still hold it.
- if (machine === undefined) await machineReady.promise
- if (machine !== undefined) {
- machine.cancel({ kind: 'disposed' })
- await machine.whenIdle()
- await machine.scope.dispose()
- }
- } catch (error: unknown) {
- failures.push(error)
- }
- // The loop above committed its closing events synchronously into the
- // session; handle close drains them durably before releasing the write
- // path. The close drain can be the first operation that surfaces a
- // durability failure, so its error is retained, not logged away.
- try {
- await handle?.close()
- } catch (error: unknown) {
- failures.push(error)
- }
- try {
- detachAgent?.()
- detachSession?.()
- } finally {
- untrack()
- if (!ownerTriggered) await unfollowOwner()
- }
- if (failures.length === 1) throw failures[0]
- if (failures.length > 1) {
- throw new AggregateError(failures, `agent "${id}" disposal failed`)
- }
- })())
- const untrack = this.ownership.track(dispose)
- let unfollowOwner: () => Promise<void> | void
- try {
- unfollowOwner = ownerCtx.effect(() => () => {
- // Owner disposal owns the same quiescence boundary. Its teardown skips
- // unregistering this already-running owner effect from inside itself.
- if (disposing !== undefined) return
- abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
- return dispose(true)
- }, `agentLoop.lifecycle(${id})`)
- /* v8 ignore start -- ctx.effect throws only on an inactive fiber, which assertActive() above already rejected */
- } catch (error: unknown) {
- untrack()
- callerSignal?.removeEventListener('abort', onCallerAbort)
- this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
- throw error
- }
- /* v8 ignore stop */
- const assertLive = (): void => {
- if (!abort.signal.aborted) return
- // Every fused abort source carries an Error reason: onCallerAbort and
- // raceAbort wrap non-Error caller reasons, and the factory/lifecycle
- // owners abort with constructed Errors.
- /* v8 ignore next -- unreachable String() arm, see above */
- throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason))
- }
- try {
- const agent = machine = new ReactLoopAgent(loopCtx, id, options, session)
- machineReady.resolve()
- assertLive()
- return {
- agent,
- signal: abort.signal,
- publish: (source) => {
- assertLive()
- detachSession = agent.ctx.sessions.enter(session)
- // The mounted backend routes announced live events into the active
- // write handle by session id; the loop only owns the handle itself.
- detachAgent = loopCtx.agents.enter(agent, ownerCtx.agent)
- agent.ctx.sessions.announce(session)
- assertLive()
- loopCtx.agents.announce(agent)
- assertLive()
- // A synchronous announce/session-start listener may have started
- // teardown; the machine is already live (delivery works from the
- // session-start extension point), so only the liveness recheck is owed.
- emitAgentEvent(loopCtx, agent, 'agent/session-start', { source })
- assertLive()
- return { agent, dispose }
- },
- dispose,
- }
- } catch (error: unknown) {
- machineReady.resolve()
- // Rollback swallows a disposal rejection: the setup failure is primary.
- void dispose().catch(() => {})
- throw error
- }
- }
- /**
- * Create an agent and session under one caller-supplied identity, owned by
- * the accessing fiber. Constructor-driven config calls mint a fresh combined
- * id before entering this boundary. When a persistence backend is mounted,
- * the session's durable identity and any seed are stored before publication.
- * @param id - shared agent/session identity.
- * @param options - concrete loop options.
- * @param meta - optional fresh-session workspace metadata.
- * @returns the published running agent.
- */
- async create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Promise<Agent> {
- using preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, { meta }))
- const stored = await this.createStoredSession(preparation.session)
- let prepared: PreparedAgent
- try {
- prepared = this.prepare(this.ctx, id, options, preparation.session, undefined, stored?.handle)
- } catch (error: unknown) {
- await stored?.handle.close().catch(() => {})
- throw error
- }
- try {
- await this.appendUnstoredSuffix(stored, preparation.session)
- return prepared.publish('startup').agent
- } catch (error: unknown) {
- // Rollback swallows a disposal rejection: the setup failure is primary.
- void prepared.dispose().catch(() => {})
- throw error
- }
- }
- /**
- * Take a fresh session's write ownership when persistence is mounted.
- * Nothing is appended here: the constructor seed (which never re-emits
- * through `session/event`) is stored by `appendUnstoredSuffix` at the
- * publication commit point, so a failed or cancelled validation or setup
- * closes an unmaterialized handle and leaves no stored residue — the same
- * id can be created again.
- * @param session - the unpublished session to store.
- * @param signal - optional cancellation forwarded to the backend create.
- * @returns the owned handle and stored cursor, or `undefined` without a backend.
- */
- private async createStoredSession(session: Session, signal?: AbortSignal): Promise<StoredSession | undefined> {
- const persistence = this.runtime.ctx.get('sessionPersistence')
- if (persistence === undefined) return undefined
- const handle = await persistence.create(session.header, {
- inheritedEventCount: session.inheritedEventCount,
- ...signal === undefined ? {} : { signal },
- })
- return { handle, storedCount: 0 }
- }
- /**
- * Durably store the session events appended since the last stored cursor.
- * Pre-publication appends (constructor seed markers, setup-window events
- * such as delegation policy records) never re-emit through `session/event`,
- * so publication must flush them through the handle before live events
- * start routing into it.
- * @param stored - the session's owned handle and stored cursor, if any.
- * @param session - the unpublished session whose suffix is stored.
- */
- private async appendUnstoredSuffix(stored: StoredSession | undefined, session: Session): Promise<void> {
- if (stored === undefined) return
- const suffix = session.snapshotEvents(SessionLogOffset(stored.storedCount))
- if (suffix.length > 0) await stored.handle.append(suffix)
- // Advance by what was stored, not to `session.seq`: an event appended
- // during the await must stay unstored for the next flush.
- stored.storedCount += suffix.length
- }
- /**
- * Create an owned agent on a caller-supplied session id.
- * @param ownerCtx - caller context that structurally owns the lifecycle.
- * @param options - identities, session seed/metadata, loop options, setup, and cancellation.
- * @returns the published handle.
- */
- async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
- const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, {
- ...options.seed === undefined ? {} : { seed: options.seed },
- ...options.meta === undefined ? {} : { meta: options.meta },
- ...options.inheritedEventCount === undefined ? {} : { inheritedEventCount: options.inheritedEventCount },
- }))
- const published = (async () => {
- let stored: StoredSession | undefined
- try {
- // raceAbortCall normalizes a pre-aborted or mid-create abort and
- // closes a handle that finishes creating after abandonment.
- stored = options.signal === undefined
- ? await this.createStoredSession(preparation.session)
- : await raceAbortCall(
- () => this.createStoredSession(preparation.session, options.signal),
- options.signal,
- options.sessionId,
- (abandoned) => { void abandoned?.handle.close().catch(() => {}) },
- )
- } catch (error: unknown) {
- preparation[Symbol.dispose]()
- throw error
- }
- return this.setupAndPublish(
- ownerCtx,
- options.sessionId,
- preparation,
- options.agentOptions ?? {},
- options.setup,
- options.signal,
- 'startup',
- stored,
- )
- })()
- this.ownership.trackWrapper(published)
- return published
- }
- /** Prepare one Agent around an acquired Session, run setup, and publish it. */
- private async setupAndPublish(
- ownerCtx: Context,
- id: SessionId,
- preparation: SessionPreparation,
- agentOptions: AgentOptions,
- setup: AgentSetup | undefined,
- signal: AbortSignal | undefined,
- source: SessionStartSource,
- stored?: StoredSession,
- ): Promise<AgentHandle> {
- using ownedPreparation = preparation
- const session = ownedPreparation.session
- let prepared: PreparedAgent
- try {
- prepared = this.prepare(ownerCtx, id, agentOptions, session, signal, stored?.handle)
- } catch (error: unknown) {
- await stored?.handle.close().catch(() => {})
- throw error
- }
- try {
- const setupCommit = await raceAbort(setup?.(prepared.agent.ctx), prepared.signal, id)
- setupCommit?.commit()
- await this.appendUnstoredSuffix(stored, session)
- return prepared.publish(source)
- } catch (error: unknown) {
- // Rollback swallows a disposal rejection (a failing final handle close):
- // the setup failure is the primary error the caller must see.
- await prepared.dispose().catch(() => {})
- throw error
- }
- }
- /**
- * Resume an owned agent from the configured persistence service.
- * @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
- * @param options - persisted identity, loop options, setup, and cancellation.
- * @returns the published handle.
- */
- async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle> {
- const persistence = this.runtime.ctx.get('sessionPersistence')
- if (persistence === undefined) {
- throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
- }
- return this.resumeWith(ownerCtx, persistence, options)
- }
- /** Resume through an explicit persistence handle used by the deferred config path. */
- private resumeWith(
- ownerCtx: Context,
- persistence: SessionPersistence,
- options: ResumeAgentOptions,
- ): Promise<AgentHandle> {
- const id = options.resumeSessionId
- const published = (async () => {
- // The open and read may outlive their owner: race them against caller
- // cancellation, owner-fiber unload, and factory teardown so a
- // never-settling backend cannot pin the identity.
- const ownerAbort = new AbortController()
- const unfollowOwner = ownerCtx.effect(() => () => {
- ownerAbort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
- }, `agentLoop.resume-load(${id})`)
- const fused = AbortSignal.any([
- ...options.signal === undefined ? [] : [options.signal],
- ownerAbort.signal,
- this.ownership.signal,
- ])
- let handle: SessionHandle | undefined
- let stored: StoredSession | undefined
- let preparation: SessionPreparation | undefined
- try {
- try {
- // Taking write ownership FIRST excludes a concurrent resume of the
- // same id (in this process, a live agent's handle holds the claim).
- handle = await raceAbortCall(
- () => persistence.open(id, 'write', { signal: fused }),
- fused,
- id,
- (abandoned) => { void abandoned.close() },
- )
- // Semantic crash repair is the agent layer's job: persistence hands
- // back the physically valid log; an interrupted final turn receives
- // synthetic closers (missing tool errors, step/end, turn/end) that
- // are appended through the same handle as an ordinary batch.
- const coldRead = await handle.read(0, undefined, { signal: fused })
- fused.throwIfAborted()
- const persisted = coldRead.events
- const closers = interruptedTurnClosers(persisted)
- if (closers.length > 0) await handle.append(closers)
- preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, {
- seed: [...persisted, ...closers],
- meta: structuredClone(handle.header),
- inheritedEventCount: handle.inheritedEventCount,
- eventState: coldRead.eventState,
- }))
- stored = { handle, storedCount: persisted.length + closers.length }
- await this.appendUnstoredSuffix(stored, preparation.session)
- } finally {
- await unfollowOwner()
- }
- ownerCtx.fiber.assertActive()
- if (!this.ownership.isActive()) throw new Error('agent loop is not active')
- const owned = stored
- handle = undefined // ownership passes to setupAndPublish/prepare
- return await this.setupAndPublish(
- ownerCtx,
- id,
- preparation,
- options.agentOptions ?? {},
- options.setup,
- options.signal,
- 'resume',
- owned,
- )
- } finally {
- preparation?.[Symbol.dispose]()
- await handle?.close().catch(() => {})
- }
- })()
- this.ownership.trackWrapper(published)
- return published
- }
- }
- export default AgentLoop
|