| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589 |
- /**
- * 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 {
- LlmError,
- createAssistantMessage,
- errorChain,
- markAgentLoopRequest,
- } from '@deepseek-ai/dsh-llm'
- import { deepFreeze } from '@deepseek-ai/dsh-util-values'
- 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 {} from '@deepseek-ai/dsh-session-projection'
- import type { Context } from '@deepseek-ai/cordis'
- import { RuntimeContextProjection } from './runtime-context.ts'
- import { AssistantStreamAttempt } from './assistant-stream.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<TurnEndReason, { kind: 'completed' | 'max-tokens' }>
- type PreparedStep =
- | { kind: 'reject' }
- | {
- kind: 'enter'
- messages: UserMessage[]
- startsRequestSeries?: true
- 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<void> = 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
- /** Surface generation of the preceding built request. */
- private requestSurfaceGeneration: number | undefined
- private readonly runtimeContext: RuntimeContextProjection
- /** Process-local revision of assistant frames for this attached Session. */
- private assistantStreamRevision = 0
- private assistantAttemptCounter = 0
- 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 }) },
- })
- /* v8 ignore next -- the loop registers its own turnBoundary unit, so the key is always present */
- const lastTurn = this.loopCtx.sessionProjections.stateOf(session, 'turnBoundary')?.lastTurn ?? 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<T>(job: (signal: AbortSignal) => Promise<T>): Promise<T> {
- if (this.phase.kind !== 'idle') throw new Error(`agent "${this.id}" already has active work`)
- const done = Promise.withResolvers<void>()
- 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 job(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<void>()
- 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<void> {
- let activity: Promise<void>
- 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<void> {
- 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<PreparedStep> {
- /* 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<PreStepDecision> => Promise.resolve<PreStepDecision>({
- 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<boolean> {
- 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, decision.startsRequestSeries === true)
- // 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, startsRequestSeries: boolean): Promise<StepEndReason | null> {
- /* 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 surfaceGeneration = this.session.surface.replaceGeneration
- const { request, preparedCall } = await this.buildRequest(
- turn,
- step,
- assembly.tools,
- system,
- this.session.deriveMessages(),
- startsRequestSeries,
- surfaceGeneration,
- signal,
- )
- startsRequestSeries = false
- const live = new AssistantStreamAttempt(
- this.session.id,
- ++this.assistantAttemptCounter,
- () => ++this.assistantStreamRevision,
- turn,
- step,
- (frame) => { this.dispatch.emit('agent/assistant-stream', { frame }) },
- )
- let started = false
- try {
- const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
- signal.throwIfAborted()
- live.start()
- started = true
- for await (const chunk of stream) {
- signal.throwIfAborted()
- live.push(chunk)
- }
- signal.throwIfAborted()
- } catch (error: unknown) {
- if (!started) throw error
- try {
- if (signal.aborted) {
- const content = live.interruptedBlocks()
- if (content.length > 0) {
- live.settle('assistant/message', () => this.session.append('assistant/message', {
- turn,
- step,
- message: createAssistantMessage({
- content,
- source: {
- provider: request.provider,
- model: request.model,
- ...live.replayState === undefined ? {} : { replayState: live.replayState },
- },
- }),
- interrupted: true,
- ...live.usage === undefined ? {} : { usage: live.usage },
- stream: live.stream,
- }, { surfaceOp: 'append' }).seq)
- } else {
- live.settle(
- 'assistant/attempt',
- () => this.session.append('assistant/attempt', { turn, step, stream: live.stream }).seq,
- )
- }
- } else {
- live.settle(
- 'assistant/attempt',
- () => this.session.append('assistant/attempt', { turn, step, stream: live.stream }).seq,
- )
- }
- } catch (settlementError: unknown) {
- throw new AggregateError(
- [error, settlementError],
- 'Assistant stream failed and its durable settlement was rejected',
- { cause: error },
- )
- }
- throw error
- }
- try {
- const finish = live.finish
- if (finish.kind === 'error' || finish.kind === 'aborted') {
- live.settle(
- 'assistant/attempt',
- () => this.session.append('assistant/attempt', { turn, step, stream: live.stream }).seq,
- )
- const action = await this.dispatch.waterfall(
- 'agent/request-error', {
- turn,
- step,
- provider: request.provider,
- failure: finish.failure,
- retryPolicy: preparedCall?.retryPolicy,
- signal,
- },
- () => Promise.resolve<RequestErrorAction>(undefined),
- )
- signal.throwIfAborted()
- if (action?.kind !== 'retry') {
- throw new LlmError(finish.failure.message, finish.failure.code, finish.failure)
- }
- continue
- }
- const message = createAssistantMessage({
- content: live.blocks(),
- source: {
- provider: request.provider,
- model: request.model,
- ...live.replayState !== undefined ? { replayState: live.replayState } : {},
- },
- })
- live.settle(
- 'assistant/message',
- () => this.session.append('assistant/message', {
- turn,
- step,
- message,
- ...live.usage === undefined ? {} : { usage: live.usage },
- stream: live.stream,
- }, { surfaceOp: 'append' }).seq,
- )
- 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
- } catch (error: unknown) {
- if (!live.ended) live.abandon()
- throw error
- }
- }
- }
- /**
- * 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[],
- startsRequestSeries: boolean,
- surfaceGeneration: number,
- 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 persistedReasoningEffort = persistedConfig?.provider === route.provider
- && persistedConfig.model === route.model
- && persistedHeader?.adapterDefaults?.reasoningEffort !== true
- ? persistedConfig.reasoningEffort
- : undefined
- const reasoningEffort = this.options.reasoningEffort ?? persistedReasoningEffort
- 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()
- const startsSeries = startsRequestSeries
- || this.requestSurfaceGeneration !== surfaceGeneration
- 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',
- ...startsSeries ? { startsSeries: true } : {},
- })
- } else if (startsSeries) {
- this.session.append('request/header', { header, reason: 'series' })
- }
- this.requestSurfaceGeneration = surfaceGeneration
- 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 } }
- }
- }
|