Files
deepseek-harness/packages/core/agent-loop/src/agent.ts
T
Yixiang Tu bc8c0dbf40 feat(llm): emit dynamic tool updates and project them per route
Log tool changes as developer messages independently of model capability,
fold committed headers and updates into Session.toolHistory(), and let the
LLM runtime construct deferred and retained declarations for routes that
declare a toolUpdate mode. DeepSeek serializes updates as system-role
tool_addition/tool_removal blocks with defer_loading and the tool-changes
beta header. An unchanged restored tool continues the series and is
re-offered through its recorded blocks instead of rewriting declarations.

Replace the obsolete rejection tests with wire assertions and add coverage
for the session fold, loop emission, route projection, config validation,
and the beta header. Chat and Trajectory changes land separately.
2026-09-23 22:39:26 +08:00

672 lines
27 KiB
TypeScript

/**
* 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 { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
import {
LlmError,
createAssistantMessage,
createDeveloperMessage,
errorChain,
markAgentLoopRequest,
} from '@deepseek-ai/dsh-llm'
import { assertNever, 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, SessionSeq, 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 { ReactLoopInbox } from './inbox.ts'
import { RuntimeContextProjection } from './runtime-context.ts'
import { AssistantStreamAttempt } from './assistant-stream.ts'
import { SystemPromptProjection } 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<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
}
/**
* Read the cause `cancel()` passed when aborting a loop-owned signal, copying
* only the fields `turn/end` records. The live reason stays the caller's
* object, and Node's fetch assigns a `stack` onto it that `Session.append`
* would either log or reject as data JSON cannot hold.
* @param signal - a turn or maintenance signal this loop owns.
* @returns the copied cause, or undefined while the signal is still live.
*/
function abortedCancelCause(signal: AbortSignal): AgentCancelCause | undefined {
if (!signal.aborted) return undefined
// `cancel()` is the only aborter of the signals this loop owns.
const cause = signal.reason as AgentCancelCause
switch (cause.kind) {
case 'user':
case 'parent':
case 'disposed':
return { kind: cause.kind }
case 'hook':
return { kind: 'hook', reason: cause.reason }
/* v8 ignore next -- cancel accepts the closed AgentCancelCause union */
default:
return assertNever(cause)
}
}
/** Drives one session through turn and step boundaries. */
export class ReactLoopAgent implements Agent {
readonly inbox: ReactLoopInbox
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 at attachment or the preceding built request. */
private requestSurfaceGeneration: number
private readonly runtimeContext: RuntimeContextProjection
/** Process-local revision of assistant frames for this attached Session. */
private assistantStreamRevision = 0
private assistantAttemptCounter = 0
private readonly systemPrompt: SystemPromptProjection
/** Identities fully frozen by this loop; weak references do not retain replaced history. */
private readonly frozenMessages = new WeakSet<Message>()
constructor(
private loopCtx: Context,
public readonly id: SessionId,
public readonly options: AgentOptions,
public readonly session: Session,
) {
this.requestSurfaceGeneration = session.surface.contentGeneration
this.dispatch = agentEvents(loopCtx, this)
this.scope = createScope(loopCtx, this)
this.ctx = this.scope.ctx
this.inbox = new ReactLoopInbox(this.ctx.sessionProjections, session, this.dispatch)
/* 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.runtimeContext = new RuntimeContextProjection(this.ctx, session)
this.systemPrompt = new SystemPromptProjection(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 })
const cause = abortedCancelCause(maintenance.abort.signal)
if (cause?.kind !== 'disposed' && 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 = abortedCancelCause(this.phase.abort.signal)
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()
if (decision.kind === 'reject') return decision
return { ...decision, assembly }
}
/** Whether the assembled tool schemas differ from the logged request header's. */
private toolsChanged(tools: PromptAssembly['tools']): boolean {
const baseline = this.session.requestHeader()
if (baseline === undefined) return false
return !headerEquals(baseline, canonicalHeader({ ...baseline, tools: [...tools] }))
}
/** 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 {
// 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)
// 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) {
// A cause is present exactly while the signal is aborted.
const cause = abortedCancelCause(signal)
if (cause !== undefined) {
turnEnds = { kind: 'aborted', reason: cause }
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(decision: Extract<PreparedStep, { kind: 'enter' }>): 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 { assembly } = decision
const renderedPrompt = renderPrompt(assembly)
let firstAttempt = true
while (true) {
const { config, preparedCall } = await this.prepareRequest(turn, step, signal)
const startsRequestSeries = firstAttempt && decision.startsRequestSeries === true
const commits = this.systemPrompt.project(renderedPrompt, {
inHistory: preparedCall?.systemPromptUpdate === 'in-history',
startsSeries: startsRequestSeries
|| this.requestSurfaceGeneration !== this.session.surface.contentGeneration
|| this.toolsChanged(assembly.tools),
})
for (const { message, intent } of commits) {
this.session.append('system/message', { turn, step, message }, intent)
}
if (firstAttempt) {
for (const message of decision.messages) {
this.session.append('user/message', message, { surfaceOp: 'append' })
}
}
firstAttempt = false
const request = this.buildRequest(config, preparedCall, assembly.tools, { turn, step }, startsRequestSeries, signal)
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
}
}
}
/** Resolve request config and bind its adapter before admitting model-visible input. */
private async prepareRequest(
turn: number,
step: number,
signal: AbortSignal,
): Promise<{ config: LlmCallConfig; 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()
return { config, ...preparedCall === undefined ? {} : { preparedCall } }
}
/** Log the resolved envelope and derive a frozen request from the admitted surface. */
private buildRequest(
config: LlmCallConfig,
preparedCall: PreparedLlmCall | undefined,
tools: GenerateOptions['tools'] & object,
position: { turn: number; step: number },
startsRequestSeries: boolean,
signal: AbortSignal,
): GenerateOptions {
const { session } = this
const surfaceGeneration = session.surface.contentGeneration
const header = canonicalHeader({
config,
...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults },
...tools.length > 0 ? { tools } : {},
})
const baseline = this.session.requestHeader()
const startsSeries = startsRequestSeries
|| this.requestSurfaceGeneration !== surfaceGeneration
let headerSeq: SessionSeq | undefined
if (!this.requestHeaderLogged) {
// Compaction during the first resumed pre-step must still mark a new series.
headerSeq = this.session.append('request/header', {
header,
reason: baseline === undefined ? 'initial' : 'resume',
...startsSeries ? { startsSeries: true } : {},
}).seq
this.requestHeaderLogged = true
} else if (baseline === undefined || !headerEquals(baseline, header)) {
headerSeq = this.session.append('request/header', {
header,
reason: 'change',
...startsSeries ? { startsSeries: true } : {},
}).seq
} else if (startsSeries) {
this.session.append('request/header', { header, reason: 'series' })
}
if (baseline !== undefined && headerSeq !== undefined) {
const previousNames = new Set(baseline.tools?.map(tool => tool.name))
const currentNames = new Set(tools.map(tool => tool.name))
const additions = tools.filter(tool => !previousNames.has(tool.name))
.map(tool => ({ type: 'tool-addition' as const, toolName: tool.name }))
const removals = (baseline.tools ?? []).filter(tool => !currentNames.has(tool.name))
.map(tool => ({ type: 'tool-removal' as const, toolName: tool.name }))
if (additions.length > 0 || removals.length > 0) {
session.append('developer/message', {
...position,
message: createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [...additions, ...removals] }),
...additions.length > 0 ? { headerSeq } : {},
}, { surfaceOp: 'append' })
}
}
this.requestSurfaceGeneration = surfaceGeneration
const contextWindow = preparedCall?.context?.contextWindow
const systemPromptUpdate = preparedCall?.systemPromptUpdate
const requestContext: RequestContext = {
provider: config.provider,
model: config.model,
...contextWindow === undefined ? {} : { contextWindow },
...systemPromptUpdate === undefined ? {} : { systemPromptUpdate },
}
const previousContext = session.requestContext()
if (previousContext?.provider !== requestContext.provider
|| previousContext.model !== requestContext.model
|| previousContext.contextWindow !== requestContext.contextWindow
|| previousContext.systemPromptUpdate !== requestContext.systemPromptUpdate) {
session.append('request/context', requestContext)
}
signal.throwIfAborted()
// canonicalHeader is shallow; append logs a detached snapshot, not these local values.
deepFreeze(header)
const boundaryMessages = session.deriveMessages()
for (const message of boundaryMessages) {
if (this.frozenMessages.has(message)) continue
deepFreeze(message)
this.frozenMessages.add(message)
}
Object.freeze(boundaryMessages)
const request = markAgentLoopRequest(Object.freeze({
...header.config,
messages: boundaryMessages,
toolHistory: session.toolHistory(),
...header.tools !== undefined ? { tools: header.tools } : {},
sessionId: this.session.id,
signal,
}))
return request
}
}