Files
deepseek-harness/packages/core/agent-loop/src/index.ts
T
_KermanandTianyi Cui fb79a944f5 refactor(llm): separate durable producer sources from request inputs
Give durable user context its producer-owned source kind instead of a generic plugin wrapper. The V3-to-V4 conversion preserves the frozen rename and collision rules, all own JSON metadata, event coordinates, and historical request-only kinds. Native catalog and JSONL admission retain source validation so unknown attribution can survive without its producer while malformed owned sources are refused.

Use an explicit user-only RequestUserInput for auxiliary LLM prompts that do not own durable message identity. Auto Review and compaction remove their request-only source registrations while retaining prompt content and freezing; provider serializers and projections keep the same wire data, assistant replay isolation, image ordering, and tool correlation. Session writes, Agent delivery, and recorded title requests continue to require identified durable messages.

Bind the saved compatibility policy only to user sources and qualify tmux location attribution without weakening fixed system, model, or tool semantics. Rebuild the current inventory and unpublished V4 acknowledgement on the actual tool-role parent, preserving its rationale and all accepted historical records. The catalog format version records tooling metadata; Session remains V4. Developer-role policy and activation belong to their own feature.

The latest-master Messages adapter, serialization, request-input typing, EpochHeader reservation, and attribution checks pass 103 tests across five files. The retained V3 Messages input-history snapshot passes through the source profile; Host/Client typechecks and all 41 documentation gates pass. The generated comparison records four changed roots and 447 unchanged type fingerprints. Latest-master ARIA changes are retained, with producer source labels applied independently; new Messages fixtures retain their producer names as declared source kinds and use the fixed system-message helper; recorded Session generations are unchanged by this regrouping.

Co-authored-by: Tianyi Cui <[email protected]>
2026-09-18 19:07:12 +08:00

947 lines
39 KiB
TypeScript

/**
* 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 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 { inboxProjectionDefinition } from './inbox.ts'
import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
import type {} from './runtime-context.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 both registries and await creation listeners. */
publish(source: SessionStartSource): Promise<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)
ctx.sessionProjections.register(inboxProjectionDefinition)
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,
parentAgent?: Agent,
): 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
let publication: ReturnType<typeof Promise.withResolvers<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 {
// Creation listeners retain the session and scope through their awaits.
if (publication !== undefined) await publication.promise
// 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.
/* v8 ignore next -- Cordis effect teardown waits for synchronous setup before observing the machine slot. */
if (machine === undefined) await machineReady.promise
/* v8 ignore next -- setup failure untracks this disposer before resolving without a machine. */
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(function* () {
machine = new ReactLoopAgent(loopCtx, id, options, session)
machineReady.resolve()
yield machine.scope.rawDispose
yield () => {
// 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) {
machineReady.resolve()
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 {
/* v8 ignore next -- a synchronous effect exhausts the generator before returning */
if (machine === undefined) throw new Error(`agent "${id}" lifecycle did not construct its driver`)
const agent = machine
assertLive()
return {
agent,
signal: abort.signal,
publish: async (source) => {
publication = Promise.withResolvers<void>()
try {
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, parentAgent)
agent.ctx.sessions.announce(session)
assertLive()
await loopCtx.agents.announce(agent, source, abort.signal)
assertLive()
return { agent, dispose }
} finally {
publication.resolve()
publication = undefined
}
},
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
}
return (await this.initializeAgent(prepared, async () => {
await this.appendUnstoredSuffix(stored, preparation.session)
return await prepared.publish('startup')
})).agent
}
/**
* 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
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
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, optional live parent, 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,
options.parentAgent,
)
})()
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,
parentAgent?: Agent,
): Promise<AgentHandle> {
using ownedPreparation = preparation
const session = ownedPreparation.session
let prepared: PreparedAgent
try {
prepared = this.prepare(ownerCtx, id, agentOptions, session, signal, stored?.handle, parentAgent)
} catch (error: unknown) {
await stored?.handle.close().catch(() => {})
throw error
}
return await this.initializeAgent(prepared, async () => {
const setupCommit = await raceAbort(setup?.(prepared.agent.ctx, prepared.agent), prepared.signal, id)
setupCommit?.commit()
await this.appendUnstoredSuffix(stored, session)
return await prepared.publish(source)
})
}
private async initializeAgent(prepared: PreparedAgent, initialize: () => Promise<AgentHandle>): Promise<AgentHandle> {
try {
return await prepared.agent.runMaintenance(async () => {
try {
return await initialize()
} catch (error: unknown) {
// Teardown owns inbox cleanup and may already have removed its projection.
prepared.agent.cancel({ kind: 'disposed' }, { keepInbox: true })
throw error
}
})
} 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, optional live parent, 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,
options.parentAgent,
)
} finally {
preparation?.[Symbol.dispose]()
await handle?.close().catch(() => {})
}
})()
this.ownership.trackWrapper(published)
return published
}
}
export default AgentLoop