/** * Default Agent driver over queued turns and step-boundary input. Every request * is derived from the session log. * @module dsh-agent-loop/agent */ import type { Agent, AgentCancelCause, AgentEventDispatch, AgentOptions, AgentStatus, CancelOptions, InboxTarget, PreStepDecision, RequestErrorAction, } from '@deepseek-ai/dsh-agent' import { Inbox, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' import { BlockAssembler, LlmError, createAssistantMessage, deepFreeze, errorChain, markAgentLoopRequest, } from '@deepseek-ai/dsh-llm' import type { Scope } from '@deepseek-ai/dsh-scope' import { createScope } from '@deepseek-ai/dsh-scope' import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session' import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type { Context } from 'cordis' import { RuntimeContextProjection } from './runtime-context.ts' import { executeToolCalls } from './tool-calls.ts' type Phase = | { kind: 'idle'; lastTurn: number } | { kind: 'maintenance' abort: AbortController lastTurn: number wakeRequested: boolean } | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean } type StepEndReason = Extract type PreparedStep = | { kind: 'reject' } | { kind: 'enter'; messages: UserMessage[]; assembly: PromptAssembly } /** Remove adapter-derived values before plugins propose the next request config. */ function requestProposal(header: EpochHeader): LlmCallConfig { if (header.adapterDefaults === undefined) return header.config const proposal = { ...header.config } if (header.adapterDefaults.reasoningEffort === true) delete proposal.reasoningEffort if (header.adapterDefaults.maxTokens === true) delete proposal.maxTokens return proposal } /** Drives one session through turn and step boundaries. */ export class ReactLoopAgent implements Agent { readonly inbox: Inbox private phase: Phase private activityDone: Promise = Promise.resolve() /** The agent-scoped registration boundary; the lifecycle owner unwinds it after the driver exits. */ readonly scope: Scope readonly ctx: Context /** Fused dispatcher, built once in the constructor so hot-path dispatches never allocate. */ private readonly dispatch: AgentEventDispatch /** Whether this loop instance has appended its initial/resume request anchor. */ private requestHeaderLogged = false private readonly runtimeContext: RuntimeContextProjection constructor( private loopCtx: Context, public readonly id: SessionId, public readonly options: AgentOptions, public readonly session: Session, ) { this.dispatch = agentEvents(loopCtx, this) this.inbox = new Inbox(session, { inserted: (message) => { this.dispatch.emit('agent/inbox/inserted', { message }) }, discarded: (message) => { this.dispatch.emit('agent/inbox/discarded', { message }) }, claimed: (message, turn) => { this.dispatch.emit('agent/inbox/claimed', { message, turn }) }, }) const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 this.phase = { kind: 'idle', lastTurn } this.scope = createScope(loopCtx, this) this.ctx = this.scope.ctx.extend({ agent: this }) this.runtimeContext = new RuntimeContextProjection(this.ctx, session) } get status(): AgentStatus { return this.phase.kind === 'idle' || this.phase.kind === 'maintenance' ? 'idle' : 'running' } /** Commit a phase and publish its externally visible status transition. */ private setPhase(next: Phase): void { const previousStatus = this.status this.phase = next const status = this.status if (status !== previousStatus) { this.dispatch.emit('agent/status', { status }) } } send(message: UserMessage, target: InboxTarget, wakeup: boolean): void { // Waking input cannot join an aborted activity, so it starts the next turn. // Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted const resolvedTarget = wakingAfterAbort ? 'next-turn' : target this.inbox.splice(resolvedTarget, Infinity, 0, [message]) if (wakeup) this.wakeDriver(wakingAfterAbort) } followup(input: UserMessage): void { this.send(input, 'next-turn', true) } steer(input: UserMessage): void { this.send(input, 'next-step', true) } inject(input: UserMessage): void { this.send(input, 'next-step', false) } cancel(cause: AgentCancelCause, options: CancelOptions = {}): void { if (!options.keepInbox) { this.inbox.clear() if (this.phase.kind !== 'idle') this.phase.wakeRequested = false } if (this.phase.kind !== 'idle') this.phase.abort.abort(cause) } runMaintenance(task: (signal: AbortSignal) => Promise): Promise { if (this.phase.kind !== 'idle') throw new Error(`agent "${this.id}" already has active work`) const done = Promise.withResolvers() const maintenance: Phase = { kind: 'maintenance', abort: new AbortController(), lastTurn: this.phase.lastTurn, wakeRequested: false, } this.setPhase(maintenance) this.activityDone = done.promise return (async () => { try { return await task(maintenance.abort.signal) } finally { this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn }) if (maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver() done.resolve() } })() } /** * Start one driver, or latch its wake behind maintenance or an aborted * activity. A wake sent while idle always opens its turn boundary, even * when its message was cleared; only a latched replay is suppressed when * the queue no longer holds the wake. * @param wakeAfterAbort - the {@link send} classification, captured before * the inbox insertion so a reentrant cancel cannot reclassify it. */ private wakeDriver(wakeAfterAbort = false): void { if (this.phase.kind !== 'idle') { // Maintenance and aborted drivers cannot deliver the wake: latch it for // replay at convergence. Live drivers claim queued work themselves; // disposal never latches, so teardown waits on no model turn. const reason = this.phase.abort.signal.reason as AgentCancelCause | undefined if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) { this.phase.wakeRequested = true } return } const driver = Promise.withResolvers() this.activityDone = driver.promise this.setPhase({ kind: 'running', abort: new AbortController(), turn: this.phase.lastTurn, step: 0, wakeRequested: false, }) this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject) } async whenIdle(): Promise { let activity: Promise do { await (activity = this.activityDone) } while (activity !== this.activityDone) } /** Report one failure at its live boundary, then preserve it for driver containment. */ private throwError(error: unknown): never { const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn const step = this.phase.kind === 'running' ? this.phase.step : 0 this.dispatch.emit('agent/error', { turn, step, error }) throw error } private async kick(): Promise { try { while (await this.turn()) {} } catch (_error) { // Reported failures and cancellation are contained at the driver boundary. } finally { /* v8 ignore next -- kick owns a running phase until this driver boundary */ if (this.phase.kind === 'running') { const { turn, wakeRequested } = this.phase this.setPhase({ kind: 'idle', lastTurn: turn }) if (wakeRequested && this.inbox.hasPending) this.wakeDriver() } } } private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise { /* v8 ignore next -- private callers establish the running phase before proposing a step */ if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`) const signal = this.phase.abort.signal const claimed = this.inbox.claim(target, position.turn) const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal)) signal.throwIfAborted() const sections = renderContextSections(assembly) const context = this.runtimeContext.project(joinContextSections(sections), sections) const decision = await this.dispatch.waterfall( 'agent/pre-step', { messages: claimed, ...position, signal }, (): Promise => Promise.resolve({ kind: 'enter', messages: context === undefined ? claimed : [...claimed, context], }), ) signal.throwIfAborted() return decision.kind === 'reject' ? decision : { ...decision, assembly } } /** Open one turn before claiming its first proposed step. */ private async turn(): Promise { if (this.phase.kind !== 'running') { this.throwError(new Error(`agent "${this.id}": turn without driver reservation`)) } const phase = this.phase const { signal } = phase.abort signal.throwIfAborted() const turn = phase.turn + 1 try { this.session.append('turn/start', { turn }) } catch (error: unknown) { this.throwError(error) } phase.turn = turn let turnEnds: TurnEndReason | null = null let target: InboxTarget = 'next-turn' try { while (true) { signal.throwIfAborted() const step = phase.step + 1 const decision = await this.preStep(target, { turn, step }) if (decision.kind === 'reject') { turnEnds = { kind: 'blocked' } return false } if (turnEnds && decision.messages.length === 0) break // A removed waking message or an enter decision rewritten to empty // still owns the initial turn boundary, but it spends no model call. if (phase.step === 0 && decision.messages.length === 0) { turnEnds = { kind: 'completed' } return false } signal.throwIfAborted() this.session.append('step/start', { turn, step }) phase.step = step try { for (const message of decision.messages) { this.session.append('user/message', message, { surfaceOp: 'append' }) } // max-tokens is sticky: once any step hits the ceiling, later steps // that complete normally must not downgrade the turn outcome. const stepEnd = await this.step(decision.assembly) // max-tokens stays sticky: a later completed step must not // downgrade the turn outcome. if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd } finally { this.session.append('step/end', { turn, step }) } signal.throwIfAborted() if (turnEnds && this.inbox.nextStep.length === 0) { await this.dispatch.serial('agent/turn-stopping', { turn, signal }) signal.throwIfAborted() } if (turnEnds && this.inbox.nextStep.length === 0) break target = 'next-step' } } catch (error: unknown) { if (signal.aborted) { turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause } throw error } // Every failure is structured: an `LlmError` keeps its facts, anything // else flattens to `errorChain` text under the `UNKNOWN` code. turnEnds = { kind: 'error', error: error instanceof LlmError ? error.failure : { message: errorChain(error), code: 'UNKNOWN' }, } this.throwError(error) } finally { try { // oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending this.session.append('turn/end', { turn, reason: turnEnds! }) } catch (error: unknown) { this.throwError(error) } } if (!this.inbox.hasPending) return false phase.abort = new AbortController() // A fresh controller makes a latch set on the old one stale: the live driver claims the queue itself. phase.wakeRequested = false phase.step = 0 return true } private async step(assembly: PromptAssembly): Promise { /* v8 ignore next -- private callers establish the running phase before executing a step */ if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`) const { turn, step, abort: { signal } } = this.phase signal.throwIfAborted() const system = renderPrompt(assembly) while (true) { const { request, preparedCall } = await this.buildRequest( turn, step, assembly.tools, system, this.session.deriveMessages(), signal, ) const assembler = new BlockAssembler() const chunkSeqs: number[] = [] const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) signal.throwIfAborted() for await (const chunk of stream) { signal.throwIfAborted() chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq) assembler.push(chunk) } signal.throwIfAborted() const finish = assembler.finish if (finish.kind === 'error' || finish.kind === 'aborted') { const action = await this.dispatch.waterfall( 'agent/request-error', { turn, step, provider: request.provider, failure: finish.failure, retryPolicy: preparedCall?.retryPolicy, signal, }, () => Promise.resolve(undefined), ) signal.throwIfAborted() if (action?.kind !== 'retry') { throw new LlmError(finish.failure.message, finish.failure.code, finish.failure) } continue } const message = createAssistantMessage({ content: assembler.blocks(), source: { provider: request.provider, model: request.model, ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {}, }, }) this.session.append( 'assistant/message', { turn, step, message, ...assembler.usage === undefined ? {} : { usage: assembler.usage }, }, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, ) if (finish.kind === 'max-tokens') return { kind: 'max-tokens' } const toolCalls = message.content.filter(block => block.type === 'tool-call') if (toolCalls.length === 0) return { kind: 'completed' } const { concluded } = await executeToolCalls( this.loopCtx, turn, step, toolCalls, signal, context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]), ) return concluded ? { kind: 'completed' } : null } } /** * 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 explicit // effort owned by that exact model. Later steps re-resolve marked defaults. const persistedHeader = session.requestHeader() const persistedConfig = persistedHeader?.config const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' } const reasoningEffort = persistedConfig?.provider === route.provider && persistedConfig.model === route.model && persistedHeader?.adapterDefaults?.reasoningEffort !== true ? persistedConfig.reasoningEffort : undefined const maxTokens = this.options.maxTokens const seedConfig = deepFreeze(structuredClone( this.requestHeaderLogged // oxlint-disable-next-line typescript/no-non-null-assertion -- the instance logged the header it now folds ? requestProposal(persistedHeader!) : { ...route, ...reasoningEffort === undefined ? {} : { reasoningEffort }, ...maxTokens === undefined ? {} : { maxTokens }, }, )) const proposedConfig = await this.dispatch.waterfall( 'agent/request', { 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) { // Middleware may serve an unregistered route; terminal dispatch still requires an adapter. if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error config = proposedConfig } signal.throwIfAborted() const header = canonicalHeader({ config, ...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults }, ...system ? { system } : {}, ...tools.length > 0 ? { tools } : {}, }) const baseline = this.session.requestHeader() if (!this.requestHeaderLogged) { this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' }) this.requestHeaderLogged = true } else if (baseline === undefined || !headerEquals(baseline, header)) { this.session.append('request/header', { header, reason: 'change' }) } const contextWindow = preparedCall?.context?.contextWindow const requestContext: RequestContext = { provider: config.provider, model: config.model, ...contextWindow === undefined ? {} : { contextWindow }, } const previousContext = session.requestContext() if (previousContext?.provider !== requestContext.provider || previousContext.model !== requestContext.model || previousContext.contextWindow !== requestContext.contextWindow) { session.append('request/context', requestContext) } signal.throwIfAborted() const request = markAgentLoopRequest(deepFreeze({ ...header.config, messages: boundaryMessages, ...header.system !== undefined ? { system: header.system } : {}, ...header.tools !== undefined ? { tools: header.tools } : {}, sessionId: this.session.id, signal, })) return { request, ...preparedCall === undefined ? {} : { preparedCall } } } }