/** * Concrete Agent loop over two pending-input lists: queued prompts each open a * turn that logs its admitted input after `turn/start` commits, while steering * and injected context enter through the outbox at step boundaries. Every * request is derived from the session log. * * @module dsh-agent-loop/agent */ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' import { AgentMessageId, agentCarrier, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import type { AgentMessage, Agent, CancelOptions, AgentInterruptReason, InboxPlacement, AgentOptions, AgentStatus, SettleReason, PromptDecision, RequestError, RequestErrorAction, SendOptions, } from '@deepseek-ai/dsh-agent' import { BlockAssembler, LlmError, assertNever, deepFreeze, errorChain, isHarnessError, llmFailureOf, llmRetryPolicyOf, markAgentLoopRequest, } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' import type { Session, SessionId, TurnEndReason, TurnTrigger, UserMessageData } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' /** One completed step or a final-adapter failure eligible for recovery. */ type StepOutcome = | { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean } | { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined } /** * The concrete {@link Agent}: each `run()` owns one turn and repeats model * steps while tools or steering require another request. */ export class ReactLoopAgent implements Agent { /** Prompts awaiting individual turns. */ private queued: { message: AgentMessage; wakeup: boolean }[] = [] /** Input taken into the session log at step boundaries. */ private outbox: (UserMessageData | AgentMessage)[] = [] /** Whether observers see a running interval; consecutive turns share it. */ private busy = false /** Whether an idle waking send has deferred driver admission. */ private wakeScheduled = false /** Whether next-step input belongs to the current admission or open turn. */ acceptsNextStep = false /** Abort owner for the current admission or turn. */ private abort: AbortController | undefined /** Resolves when the current admission and turn exit. */ done: Promise = Promise.resolve() /** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */ readonly scope: Scope /** The agent's scoped composition context ({@link Agent.ctx}). */ readonly ctx: Context /** Last turn number opened by this loop or present in its seeded log. */ private lastTurn: number /** Whether the session log is owed a matching turn end event. */ private turnOpen = false private stepOpen = false /** Whether this loop instance has appended its initial/resume request anchor. */ private requestHeaderLogged = false constructor( private loopCtx: Context, public readonly id: SessionId, public readonly options: AgentOptions, public readonly session: Session, ) { this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 this.scope = createScope(loopCtx, this) this.ctx = this.scope.ctx.extend({ agent: this }) } /** Last activity state published to observers. */ get status(): AgentStatus { return this.busy ? 'running' : 'idle' } /** Accept and route one unified send item. */ send( input: UserMessageData, options: SendOptions, ): AgentMessageId { const { content, source } = deepFreeze(structuredClone(input)) const { target, wakeup } = options const id = AgentMessageId(randomUUID()) if (target === 'next-step' && !wakeup) { if (this.acceptsNextStep) { this.outbox.push({ content, source }) return id } this.session.append('user/message', { content, source }, { surfaceOp: 'append' }) return id } const placement: InboxPlacement = target === 'next-step' && this.acceptsNextStep ? 'steering' : 'queued' const message: AgentMessage = { id, content, source, } deepFreeze(message) if (placement === 'steering') { this.outbox.push(message) } else { this.queued.push({ message, wakeup }) } // Preserve the routing decision for every send in this synchronous caller // stack, while installing quiescence ownership before enqueue observers // can cancel or dispose. if (placement === 'queued' && wakeup) this.scheduleKick() emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', message, placement) return id } /** Queue one ordinary prompt turn and wake the driver. */ followup(input: UserMessageData): AgentMessageId { return this.send(input, { target: 'next-turn', wakeup: true, }) } /** Steer the open turn, falling back to a waking prompt while idle. */ steer(input: UserMessageData): AgentMessageId { return this.send(input, { target: 'next-step', wakeup: true, }) } /** Append model-facing context without waking the driver. */ inject(input: UserMessageData): AgentMessageId { return this.send(input, { target: 'next-step', wakeup: false, }) } /** * Clear all pending work and abort the active turn; the first cause wins. * The cause is signal payload for observers and the durable turn/end * classification — it selects no machine behavior. Teardown is just * `cancel({kind:'disposed'})` + await {@link done} + {@link scope} dispose, * all owned by the factory. */ cancel(cause: AgentInterruptReason, options: CancelOptions = {}): void { // Effective only when it aborts the active turn or actually discards // pending work: a keepInbox call with no active turn is a documented // no-op, so it must not emit cancel-requested for consumers to misread. const discards = !options.keepInbox && (this.queued.length > 0 || this.outbox.length > 0) if (this.abort !== undefined || discards) { // Observe-only: coordination consumers update their state before the // inboxes clear; listener failures are contained by the dispatcher. if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause) } if (!options.keepInbox) { const discarded = this.queued.map(item => item.message) for (const message of this.outbox) { if ('id' in message) discarded.push(message) } // Clear before abort observers run: replacement work belongs to the next turn. this.queued.length = 0 this.outbox.length = 0 if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded) } const reason = Object.freeze({ kind: cause.kind }) this.abort?.abort(reason) } /** Resolve at idle quiescence: no run driving and no waking prompt waiting. */ async whenIdle(): Promise { // `done` is replaced per activity, so re-reading it follows chained turns. // Every driver failure today is contained before it can reject `done`, // but the waiter must not gamble quiescence on that: a future escape // still counts as settled activity. /* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */ while (this.busy || this.wakeScheduled || this.abort !== undefined || this.queued.some(item => item.wakeup)) { await this.done.catch(() => undefined) } } /** Defer idle admission while keeping {@link done} as its quiescence owner. */ private scheduleKick(): void { if (this.abort !== undefined || this.wakeScheduled) return this.wakeScheduled = true const pending = Promise.withResolvers() const scheduled = pending.promise queueMicrotask(() => { this.wakeScheduled = false this.kick() const activity = this.done if (activity === scheduled) { pending.resolve() } else { void activity.then( () => { pending.resolve() }, () => { pending.resolve() }, ) } }) this.done = scheduled } /** Claim and admit the next queued prompt, then start its turn. */ private kick(): void { if (this.abort !== undefined || !this.queued.some(item => item.wakeup)) return // The some() guard above proves the queue is non-empty; the non-null // assertion expresses that invariant. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const { message } = this.queued.shift()! const inheritedOutboxLength = this.outbox.length const admission = new AbortController() this.abort = admission this.acceptsNextStep = true // Claimed admission is part of the running interval: it is cancellable // activity, so observers (and their cancel routing) must see it. if (!this.busy) { this.busy = true emitAgentEvent(this.loopCtx, this, 'agent/status', 'running') } // The admission body runs synchronously up to the prompt-submit // waterfall's first await, so the waterfall snapshots its listeners // before a disposal initiated by the running-status emit above can // unregister a vetoing plugin. this.done = this.loopCtx.agents.withInitiator(this, async () => { const signal = admission.signal const trigger: TurnTrigger = { kind: 'message', source: message.source } // Admitted input stays on the stack until its turn/start commits: the // turn owns it only once the turn exists in the log. let admitted: UserMessageData[] | undefined try { signal.throwIfAborted() const decision = await this.loopCtx.waterfall( agentCarrier(this), 'agent/prompt-submit', this, message.content, message.source, signal, () => Promise.resolve({ kind: 'allow' }), ) signal.throwIfAborted() if (decision.kind === 'allow') { admitted = [{ content: decision.content ?? message.content, source: message.source }] for (const context of decision.additionalContexts ?? []) { admitted.push({ content: context.content, source: context.source }) } } } catch (error: unknown) { if (!signal.aborted) { this.loopCtx.logger.warn(`agent "${this.id}": prompt admission failed: ${errorChain(error)}`) } } // cancel() aborts but never clears the slot, and kick()/run() // all refuse to install a new owner while one exists, so the admission // still owns the slot here and releasing it unconditionally is exact. this.abort = undefined if (admitted === undefined) { this.acceptsNextStep = false try { this.flushRejectedAdmissionContexts() } catch (error: unknown) { // No turn exists for agent/error coordinates. Preserve the // uncommitted suffix for a later boundary and report locally. this.loopCtx.logger.warn( `agent "${this.id}": committing rejected-admission context failed: ${errorChain(error)}`, ) } // A synchronously aborted admission would otherwise publish idle // inside send()'s own synchronous extent, before any post-send // subscriber could observe the transition. await Promise.resolve() this.continueOrIdle() return } await this.run(trigger, admitted, inheritedOutboxLength) }) // Published only after the abort owner and pending done are installed: a // dequeue listener that cancels or disposes must find live cancellation // and quiescence ownership, not the previous activity's settled state. emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message) } /** * Run one turn and any request-error retry. `admitted` input enters the log * only after `turn/start` commits; until then it has no owner state to unwind. */ private async run( trigger: TurnTrigger, admitted: UserMessageData[] = [], inheritedOutboxLength = 0, priorFailures: readonly LlmFailure[] = Object.freeze([]), ): Promise { // Both entries hold the invariant: kick() clears the admission slot before // awaiting run(), and a retry is entered only after the prior run clears it. /* v8 ignore next -- unreachable guard: every caller clears or checks the abort slot first */ if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`) const controller = new AbortController() this.abort = controller this.acceptsNextStep = true const signal = controller.signal const turn = this.lastTurn + 1 let step = 0 let opened = false let reason: TurnEndReason = { kind: 'completed' } let settleReason: SettleReason = { kind: 'completed' } let requestFailureHistory = priorFailures let retryFailures: readonly LlmFailure[] | undefined const cancelRetry = (): void => { retryFailures = undefined } signal.addEventListener('abort', cancelRetry, { once: true }) try { signal.throwIfAborted() this.session.append('turn/start', { turn, trigger }) // Committed: publish the turn to the machine's own bookkeeping and let // the admitted input enter the log it now belongs to. this.turnOpen = true opened = true this.lastTurn = turn // Context or steering retained by an earlier rejected admission happened // before this prompt and must occupy the same order in durable history. this.drainOutbox(turn, inheritedOutboxLength) for (const input of admitted) { this.session.append('user/message', input, { surfaceOp: 'append' }) } signal.throwIfAborted() this.drainOutbox(turn) steps: while (true) { step += 1 const outcome = await this.step(turn, step, signal) switch (outcome.kind) { case 'completed': requestFailureHistory = Object.freeze([]) if (outcome.maxTokens) reason = { kind: 'max-tokens' } // A concluding tool result is terminal: steering already in the // log waits for the next turn's request instead of reopening this // one, and the agent/turn-stopping drain below is skipped for the same // reason. if (outcome.concluded) break steps if (outcome.continueTurn || this.outbox.some(item => 'id' in item)) continue break case 'request-failed': { // step() reports request failures only after step/start commits // and before its own step/end, so the step is always open here. this.stepOpen = false this.session.append('step/end', { turn, step }) if (!signal.aborted) { try { const action = await this.loopCtx.waterfall( agentCarrier(this), 'agent/request-error', this, turn, step, outcome.error, outcome.failure, requestFailureHistory, outcome.retryPolicy, signal, () => Promise.resolve(undefined), ) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited. if (action?.kind === 'retry' && !signal.aborted) { retryFailures = Object.freeze([...requestFailureHistory, outcome.failure]) } } catch (recoveryError: unknown) { this.loopCtx.logger.warn( `agent "${this.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`, ) } } const settlement = this.settle(turn, step, outcome.error, signal, outcome.failure) reason = settlement.reason settleReason = settlement.settleReason break steps } /* v8 ignore next 2 -- closed-union exhaustiveness guard */ default: assertNever(outcome) } await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal) signal.throwIfAborted() if (!this.drainOutbox(turn)) break } } catch (caught: unknown) { try { if (this.stepOpen) { this.stepOpen = false this.session.append('step/end', { turn, step }) } } catch (closeError: unknown) { // Contained like the finally's turn close: a persistently rejecting // step boundary must not escape run(), or the post-finally tail would // never publish the terminal status and observers would see a // permanently running agent whose whenIdle() already resolved. this.loopCtx.logger.warn(`agent "${this.id}": closing step ${turn}/${step} failed: ${errorChain(closeError)}`) emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, closeError) } ({ reason, settleReason } = this.settle(turn, step, caught, signal)) } finally { // Every step-close happens before this point on both success and // failure paths (step(), the request-failed branch, the catch), so the // finally owes only the turn boundary. this.acceptsNextStep = false try { if (this.turnOpen) { // Re-entrant turn/end listeners must route new input to a later turn. this.turnOpen = false this.session.append('turn/end', { turn, reason }) } } catch (error: unknown) { retryFailures = undefined this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(error)}`) emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) } // cancel() aborts but never clears the slot, and no second run can // install a controller while this one is still unwinding, so the slot // is still this run's controller here. this.abort = undefined signal.removeEventListener('abort', cancelRetry) } if (opened) { try { await this.loopCtx.sessions.flush(this.session) } catch (error: unknown) { this.loopCtx.logger.warn(`agent "${this.id}": session/flush failed at turn ${turn}: ${errorChain(error)}`) emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) } } if (retryFailures !== undefined) { await this.run({ kind: 'retry' }, [], 0, retryFailures) } else { // agent/settled names only committed turns: a run aborted or rejected // before turn/start has no durable turn/end for consumers to settle // against, so it exits without the notification. if (opened) emitAgentEvent(this.loopCtx, this, 'agent/settled', turn, settleReason) this.continueOrIdle() } } /** * Run the `agent/step` extension point, commit pending input, derive one * request, and execute its tool calls inside one durable step boundary. */ private async step( turn: number, step: number, signal: AbortSignal, ): Promise { const { session } = this // The single between-steps extension point: listeners inject, steer, or // edit the log here; the request derives from the log after this settles. await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal) signal.throwIfAborted() // Take the outbox whole — same-boundary steering and context leave in // this request together. this.drainOutbox(turn) // Assemble the system prompt fresh each step (it may depend on log state). const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal)) signal.throwIfAborted() const system = renderPrompt(assembly) // Snapshot the exact log prefix: the reconstruction boundary. Appends // after this synchronous snapshot join the next request. const boundaryMessages = session.deriveMessages() session.append('step/start', { turn, step }) this.stepOpen = true signal.throwIfAborted() const { request, preparedCall } = await this.buildRequest( turn, step, assembly.tools, system, boundaryMessages, signal, ) const assembler = new BlockAssembler() const chunkSeqs: number[] = [] const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) emitAgentEvent( this.loopCtx, this, 'agent/model-request', turn, step, { provider: request.provider, model: request.model, ...preparedCall?.context === undefined ? {} : { contextWindow: preparedCall.context.contextWindow }, }, ) try { for await (const chunk of stream) { signal.throwIfAborted() const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) chunkSeqs.push(chunkEvent.seq) assembler.push(chunk) } } catch (error: unknown) { const facts = llmFailureOf(stream, error) if (facts !== undefined && error instanceof Error) { return { kind: 'request-failed', error, failure: facts, retryPolicy: llmRetryPolicyOf(stream) } } throw error } signal.throwIfAborted() // Failure finish chunks take the same path as thrown stream errors. const finish = assembler.finish if (finish.kind === 'error' || finish.kind === 'aborted') { const error = new LlmError(finish.failure.message, finish.failure.code, finish.failure) return { kind: 'request-failed', error, failure: finish.failure, retryPolicy: llmRetryPolicyOf(stream) } } // Truncated (max-tokens) output cannot owe tool calls. const assembled = assembler.message() const content = finish.kind === 'max-tokens' ? assembled.content.filter(block => block.type !== 'tool-call') : assembled.content session.append( 'assistant/message', { turn, step, content, provenance: { provider: request.provider, model: request.model, ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {}, }, ...assembler.usage === undefined ? {} : { usage: assembler.usage }, }, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, ) const toolCalls = content.filter(block => block.type === 'tool-call') let concluded = false if (toolCalls.length > 0) { ({ concluded } = await executeToolCalls( this.loopCtx, turn, step, toolCalls, signal, context => this.outbox.push({ content: context.content, source: context.source }), )) } // Tool results stay adjacent to their calls; input accepted during the // request enters the log only after the complete result batch. const steered = this.drainOutbox(turn) session.append('step/end', { turn, step }) this.stepOpen = false return { kind: 'completed', continueTurn: (toolCalls.length > 0 && !concluded) || steered, concluded, maxTokens: finish.kind === 'max-tokens', } } /** * Compose one frozen request and bind it to the adapter registration that * resolved its exact-model defaults. */ private async buildRequest( turn: number, step: number, tools: GenerateOptions['tools'] & object, system: string, boundaryMessages: Message[], signal: AbortSignal, ): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> { const { session } = this // A loop instance starts from its declared route, restoring only an opaque // effort owned by that exact model. Later steps fold the config it logged. const persistedConfig = session.requestHeader()?.config const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' } const reasoningEffort = persistedConfig?.provider === route.provider && persistedConfig.model === route.model ? persistedConfig.reasoningEffort : undefined const seedConfig = deepFreeze(structuredClone( this.requestHeaderLogged // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the instance logged the header it now folds ? persistedConfig! : { ...route, ...reasoningEffort === undefined ? {} : { reasoningEffort } }, )) const proposedConfig = await this.loopCtx.waterfall( agentCarrier(this), 'agent/request', this, turn, step, signal, () => Promise.resolve(seedConfig), ) signal.throwIfAborted() if (!proposedConfig.provider || !proposedConfig.model) { throw new Error(`agent "${this.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`) } let config: LlmCallConfig let preparedCall: PreparedLlmCall | undefined try { preparedCall = await this.loopCtx.llm.prepareCall(proposedConfig, signal) config = preparedCall.config } catch (error: unknown) { // A llm/stream listener may own and short-circuit a route with no // adapter. Terminal dispatch still raises NO_ADAPTER when none does. if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error config = proposedConfig } signal.throwIfAborted() const header = canonicalHeader({ config, ...system ? { system } : {}, ...tools.length > 0 ? { tools } : {}, }) const baseline = session.requestHeader() if (!this.requestHeaderLogged) { session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' }) this.requestHeaderLogged = true } else if (baseline === undefined || !headerEquals(baseline, header)) { session.append('request/header', { header, reason: 'change' }) } const request = markAgentLoopRequest(deepFreeze({ ...header.config, messages: boundaryMessages, ...header.system !== undefined ? { system: header.system } : {}, ...header.tools !== undefined ? { tools: header.tools } : {}, sessionId: session.id, signal, })) return { request, ...preparedCall === undefined ? {} : { preparedCall } } } /** Commit the outbox and report whether it contained steering. */ private drainOutbox(turn: number, limit = this.outbox.length): boolean { let steered = false for (const message of this.outbox.splice(0, limit)) { if ('id' in message) { steered = true emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message) this.session.append( 'steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' }, ) } else { this.session.append('user/message', message, { surfaceOp: 'append' }) } } return steered } /** * Give context-only input its ordinary idle placement when admission * produces no turn. Steering keeps the whole boundary staged so context * accepted beside it cannot split from the request it accompanies. */ private flushRejectedAdmissionContexts(): void { if (this.outbox.some(message => 'id' in message)) return const contexts = this.outbox.splice(0) for (let index = 0; index < contexts.length; index += 1) { const context = contexts[index] /* v8 ignore next 2 -- the steering precheck proves this batch is context-only */ if (context === undefined || 'id' in context) throw new Error('rejected-admission context batch changed') try { this.session.append('user/message', context, { surfaceOp: 'append' }) } catch (error: unknown) { this.outbox.unshift(...contexts.slice(index)) throw error } } } /** * The single settlement funnel: classify one turn failure (interruption * beats error) into the durable turn/end reason and live settlement report. */ private settle( turn: number, step: number, error: unknown, signal: AbortSignal, failure?: LlmFailure, ): { reason: TurnEndReason; settleReason: SettleReason } { if (signal.aborted) { // Slot invariant, stated rather than re-validated: the turn controller // is machine-private and cancel() is its only aborter, always with one // frozen canonical cause as the reason. const interrupt = signal.reason as AgentInterruptReason return { reason: { kind: interrupt.kind === 'disposed' ? 'disposed' : 'aborted' }, settleReason: { kind: 'aborted' }, } } if (failure !== undefined) { emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) // The durable record renders the full cause chain: turn/end is the one // durable trace of the failure, so a wrapper message alone would lose // the transport detail the log exists to keep. const rendered = errorChain(error) return { reason: { kind: 'error', step, failure: { ...failure, ...rendered === '' ? {} : { message: rendered } } }, settleReason: { kind: 'error', error, failure }, } } emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) return { reason: { kind: 'error', step, message: errorChain(error), ...isHarnessError(error) ? { code: error.code } : {} }, settleReason: { kind: 'error', error }, } } /** Continue with a waking prompt, or publish the idle status. */ private continueOrIdle(): void { if (this.queued.some(item => item.wakeup)) { this.kick() } else { // Every caller sits inside an admission or run whose install marked the // interval busy, so the flag is still set here. this.busy = false emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle') } } }