Merge origin/master into worktree/explicit-turn-signal

This commit is contained in:
Yichen Jiang
2026-07-20 21:38:49 +08:00
1322 changed files with 47895 additions and 20257 deletions

View File

@@ -8,11 +8,11 @@
import type { Context } from 'cordis'
import { agentEvents, normalizeAgentCancelCause } from '@deepseek-ai/dsh-agent'
import type { AgentCancelCause, AgentId, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
import type { AgentCancelCause, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session'
import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session'
import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts'
import { Inbox, type InboxMessage } from './inbox.ts'
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
@@ -59,7 +59,11 @@ export interface PreparedReactLoopAgent {
* @returns the agent and closures bound only to that exact instance.
*/
export function prepareReactLoopAgent(
ctx: Context, id: AgentId, options: AgentOptions, session: Session, maxParallelToolCalls: number,
ctx: Context,
id: SessionId,
options: AgentOptions,
session: Session,
maxParallelToolCalls: number,
): PreparedReactLoopAgent {
if (claimedDriverSessions.has(session)) {
throw new Error(`session "${session.id}" already has a concrete agent driver`)
@@ -77,7 +81,6 @@ export function prepareReactLoopAgent(
},
}
}
/**
* Install the concrete agent's scope context exactly once. Construction and
* scope minting are mutually referential (the scope key is the agent), so the
@@ -93,7 +96,7 @@ export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context):
/**
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
*
* Owns the inbox (queued + steering FIFOs), one turn cancellation holder, and
* Owns the inbox (queued + steering FIFOs), turn cancellation, and
* the loop driver. Everything observable happens through session events and
* the agent/* event taxonomy — plugins never need this class.
*/
@@ -118,17 +121,13 @@ export class ReactLoopAgent implements Agent {
}
private _status: AgentStatus = 'idle'
/** Active turn owner, installed before the running notification and retained through flush. */
/** Active turn owner from pre-running publication through durability settlement. */
private turnCancellation: TurnCancellation | undefined
/** Whether runLoop has been installed into {@link done}. */
private driverStarted = false
/** Whether registry publication began and status disposal is externally visible. */
private published = false
/**
* Cause-less marker for queued work cancelled before the driver installs a
* turn owner. It never represents an active turn and cannot leak a cause into
* replacement work.
*/
/** Cause-less marker for queued work cancelled before the driver installs a turn owner. */
private preRunCancelled = false
private disposed: Promise<void>
private resolveDisposed!: () => void
@@ -157,7 +156,7 @@ export class ReactLoopAgent implements Agent {
constructor(
private loopCtx: Context,
public readonly id: AgentId,
public readonly id: SessionId,
public readonly options: AgentOptions,
public readonly session: Session,
maxParallelToolCalls: number,
@@ -246,7 +245,6 @@ export class ReactLoopAgent implements Agent {
const context = {
content,
source,
...options?.envelope !== undefined ? { envelope: options.envelope } : {},
...options?.meta !== undefined ? { meta: options.meta } : {},
}
if (isTurnOpen(this.session)) {
@@ -285,7 +283,7 @@ export class ReactLoopAgent implements Agent {
if (turnRecorded) {
// Through the store's flush (the carrier owner), never a raw parallel.
const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
const rendered = renderThrown(error)
const rendered = errorChain(error)
const err = error instanceof Error ? error : new Error(rendered)
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`)
agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err)
@@ -327,18 +325,16 @@ export class ReactLoopAgent implements Agent {
}
cancel(cause?: AgentCancelCause): void {
// Validate before the idle no-op so misuse fails consistently in every state.
const accepted = normalizeAgentCancelCause(cause ?? { kind: 'user' })
const active = this.turnCancellation
if (active === undefined && !this.#inbox.hasQueued && !this.#inbox.hasSteering) return
if (active === undefined) this.preRunCancelled = true
// Drop all pending queued + steering work (un-started prompts never run; the
// cancelled turn's steering is not re-enqueued). Clear before abort dispatch,
// whose synchronous observers may enqueue replacement work that must survive.
// This is direct even when the loop is parked in waitForQueued — there is no
// turn to stop and nothing left for the parked loop to run, so no wake is needed.
const normalized = normalizeAgentCancelCause(cause ?? { kind: 'user' })
const cancellation = this.turnCancellation
const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering)
if (preRun) {
this.preRunCancelled = true
}
// Clear work already present before abort observers run. A replacement
// synchronously enqueued by an observer belongs to the next turn.
this.#inbox.clear()
if (active !== undefined) active.request(accepted)
cancellation?.request(normalized)
}
/**
@@ -376,7 +372,7 @@ export class ReactLoopAgent implements Agent {
[startDriver](): void {
if (this._status === 'disposed') return
this.driverStarted = true
this.done = this.loopCtx.agentExecution.run({ agent: this }, () => runLoop(this.loopCtx, this, {
this.done = this.loopCtx.agents.withInitiator(this, () => runLoop(this.loopCtx, {
inbox: this.#inbox,
maxParallelToolCalls: this.maxParallelToolCalls,
setStatus: (status) => { this.setStatus(status) },
@@ -386,7 +382,7 @@ export class ReactLoopAgent implements Agent {
return cancellation
},
clearTurnCancellation: (cancellation) => {
/* v8 ignore else -- the internal driver clears only the exact holder returned by its latest install */
/* v8 ignore else -- the driver clears only the exact owner returned by its latest install. */
if (this.turnCancellation === cancellation) this.turnCancellation = undefined
},
disposed: this.disposed,
@@ -394,7 +390,7 @@ export class ReactLoopAgent implements Agent {
isPreRunCancelled: () => this.preRunCancelled,
clearPreRunCancel: () => { this.preRunCancelled = false },
withToolBatch: run => this.withToolBatch(run),
// Pre-run cancellation re-parks without emitting a status transition.
// Pre-run cancellation settles queued-work waiters before publishing idle.
settleIdle: () => { this.settleIdleWaiters() },
}))
}
@@ -441,8 +437,3 @@ export class ReactLoopAgent implements Agent {
}
}
}
/** Render an ordinary thrown value for the error event and log. */
function renderThrown(value: unknown): string {
return value instanceof Error ? value.message : String(value)
}

View File

@@ -15,7 +15,7 @@ export interface InboxMessage {
}
/**
* Per-agent inbox: a queued FIFO (drained at turn start) and a steering FIFO
* Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO
* (drained between steps of a running turn). Purely an in-memory mechanism of
* the loop — the public surface is `Agent.send()` / `Agent.steer()`.
*/
@@ -54,11 +54,11 @@ export class Inbox {
}
/**
* Drain all queued messages (turn start).
* @returns the drained messages in arrival order; the queued FIFO is left empty.
* Remove the oldest queued message for one turn start.
* @returns the oldest message, or `undefined` when the queued FIFO is empty.
*/
drainQueued(): InboxMessage[] {
return this.queuedMessages.splice(0)
dequeueQueued(): InboxMessage | undefined {
return this.queuedMessages.shift()
}
/**
@@ -72,7 +72,7 @@ export class Inbox {
/**
* Discard all pending messages (queued + steering) without delivering them —
* used by `cancel()`, which drops un-started work rather than draining it into
* a turn. Unlike `drainQueued`/`drainSteering`, the messages are thrown away.
* a turn. Unlike `dequeueQueued`/`drainSteering`, the messages are thrown away.
*/
clear(): void {
this.queuedMessages.length = 0

View File

@@ -11,17 +11,16 @@ import z from 'schemastery'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-execution'
import type {
Agent,
AgentFactory,
AgentHandle,
AgentId,
AgentOptions,
CreateAgentOptions,
ResumeAgentOptions,
SessionStartSource,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -35,8 +34,6 @@ import {
import type { PreparedReactLoopAgent } from './agent.ts'
import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
export { ReactLoopAgent } from './agent.ts'
/** Fiber states that cannot own or serve a new lifecycle. */
const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
FiberState.UNLOADING,
@@ -47,7 +44,9 @@ const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
/** Factory-level ownership of every preparing or live transaction. */
class FactoryOwnership {
private accepting = true
private readonly inactive = Promise.withResolvers<void>()
private transactions = new Set<AgentCreationTransaction>()
private startupTasks = new Set<Promise<void>>()
constructor(private readonly fiber: Context['fiber']) {}
@@ -60,17 +59,31 @@ class FactoryOwnership {
return () => { this.transactions.delete(transaction) }
}
/** Join config startup work that begins before an agent transaction exists. */
trackStartup(task: Promise<void>): void {
this.startupTasks.add(task)
const forget = () => { this.startupTasks.delete(task) }
void task.then(forget, forget)
}
/** Resolve `task`, or stop waiting when factory teardown begins. */
async waitWhileActive(task: Promise<void>): Promise<void> {
await Promise.race([task, this.inactive.promise])
}
async dispose(): Promise<void> {
this.accepting = false
this.inactive.resolve()
const reason = new Error('agent loop is not active')
await Promise.all(
[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
)
await Promise.all([
...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
...this.startupTasks,
])
}
}
/** Build the public cancellation error while preserving a caller-supplied cause. */
function signalAbortError(id: AgentId, signal: AbortSignal): Error {
function signalAbortError(id: SessionId, signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason
return new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
}
@@ -116,7 +129,7 @@ class AgentCreationTransaction {
private readonly loopCtx: Context,
private readonly ownerCtx: Context,
private readonly ownership: FactoryOwnership,
readonly id: AgentId,
readonly id: SessionId,
signal?: AbortSignal,
) {
ownerCtx.fiber.assertActive()
@@ -238,7 +251,7 @@ class AgentCreationTransaction {
this.publishing = true
try {
this.detachSession = agent.ctx.sessions.enter(session)
this.detachAgent = this.loopCtx.agents.enter(agent)
this.detachAgent = this.loopCtx.agents.enter(agent, this.ownerAgent)
agent.ctx.sessions.announce(session)
this.assertActive()
@@ -327,6 +340,18 @@ declare module 'cordis' {
interface Context {
agentLoop: AgentLoop
}
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 sessionId - exact shared agent/session identity that failed startup.
* @param error - persistence, setup, or publication failure.
* @mode emit
*/
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
}
}
export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
@@ -340,8 +365,10 @@ export interface Config {
maxParallelToolCalls?: number
/** Agents created or resumed at plugin startup. */
agents: (AgentOptions & {
/** Registry identity for the live agent. */
id: AgentId
/** 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. */
@@ -349,15 +376,34 @@ export interface Config {
})[]
}
/** Concrete ReactLoopAgent factory and driver service. */
/** 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', 'agentExecution', 'sessions', 'llm', 'tools', 'systemPrompt']
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
/** 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(),
cwd: z.string(),
@@ -373,6 +419,7 @@ export class AgentLoop extends Service implements AgentFactory {
constructor(ctx: Context, public config: Config) {
super(ctx, 'agentLoop')
validateConfiguredAgents(config.agents)
this.maxParallelToolCalls = resolveMaxParallelToolCalls(config.maxParallelToolCalls)
this.ownership = new FactoryOwnership(ctx.fiber)
this.runtime = { ctx }
@@ -382,19 +429,28 @@ export class AgentLoop extends Service implements AgentFactory {
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
for (const { id, cwd, resumeSessionId, ...options } of config.agents) {
for (const { id, sessionId, cwd, resumeSessionId, ...options } of config.agents) {
const meta = cwd === undefined ? {} : { cwd }
if (resumeSessionId === undefined || resumeSessionId === '') {
this.create(id, options, cwd === undefined ? {} : { cwd })
const configuredId = sessionId ?? SessionId(`${id}-session-${randomUUID()}`)
const persistence = sessionId === undefined ? undefined : ctx.get('sessionPersistence')
if (persistence === undefined) {
this.create(configuredId, options, meta)
} 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, {
agentId: id,
resumeSessionId,
agentOptions: options,
}).catch((error: unknown) => {
ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error)
})
})
return fiber.dispose
@@ -402,20 +458,83 @@ export class AgentLoop extends Service implements AgentFactory {
}
}
/** 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
const exists = (await persistence.list()).some(header => header.id === sessionId)
if (!this.ownership.isActive()) return
if (exists) {
await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions })
return
}
this.create(sessionId, agentOptions, meta)
}
/** Wait for an already-disposed same-id lifecycle to finish registry teardown. */
private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise<void> {
const current = ownerCtx.agents.get(sessionId)
if (current?.status !== 'disposed') 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()
}
}
/**
* Create an agent on a fresh per-run session, owned by the accessing fiber.
* Constructor-driven config calls use the loop fiber itself.
* @param id - agent registry id.
* 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.
* @param id - shared agent/session identity.
* @param options - concrete loop options.
* @param meta - optional fresh-session workspace metadata.
* @returns the published running agent.
*/
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent {
const loopCtx = this.runtime.ctx
const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
try {
const sessionId = SessionId(`${id}-session-${randomUUID()}`)
const session = loopCtx.sessions.prepare(sessionId, { meta })
const session = loopCtx.sessions.prepare(id, { meta })
const agent = transaction.prepare(options, session, this.maxParallelToolCalls)
transaction.publish('startup')
return agent
@@ -439,7 +558,7 @@ export class AgentLoop extends Service implements AgentFactory {
this.runtime.ctx,
ownerCtx,
this.ownership,
options.agentId,
options.sessionId,
options.signal,
)
try {
@@ -484,7 +603,7 @@ export class AgentLoop extends Service implements AgentFactory {
this.runtime.ctx,
ownerCtx,
this.ownership,
options.agentId,
options.resumeSessionId,
options.signal,
)
try {
@@ -492,12 +611,7 @@ export class AgentLoop extends Service implements AgentFactory {
transaction.assertActive()
const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, {
seed: loaded.events,
meta: {
createdAt: loaded.meta.createdAt,
...loaded.meta.cwd === undefined ? {} : { cwd: loaded.meta.cwd },
...loaded.meta.parentSession === undefined ? {} : { parentSession: loaded.meta.parentSession },
...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength },
},
meta: loaded.meta,
})
const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
await transaction.waitFor(options.setup?.(agent.ctx))

View File

@@ -1,16 +1,16 @@
/**
* Drives one agent across queued durable turns. Turn failures are contained so
* later work can run; the session log, not this driver, owns conversation state.
* See docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md.
* See .agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md.
* @module dsh-agent-loop/loop
*/
import type { Context } from 'cordis'
import { isDeepStrictEqual } from 'node:util'
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
import { assertNever, BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
import { isDeepStrictEqual } from 'node:util'
import { BlockAssembler, HarnessError, assertNever, deepFreeze, errorChain, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm'
import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
@@ -19,28 +19,32 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
import type { ReactLoopAgent } from './agent.ts'
import type { Inbox } from './inbox.ts'
import type { TurnCancellation } from './cancellation.ts'
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
type CodedError = Error & { code?: string }
/** Normalize thrown values while preserving an existing error code. */
function toError(error: unknown): CodedError {
function toError(error: unknown): RequestError {
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
}
/** Distinguishes final model-request failures from failures in later step processing. */
class TerminalModelRequestFailure extends Error {
constructor(readonly requestError: RequestError) {
super(requestError.message, { cause: requestError })
this.name = 'TerminalModelRequestFailure'
}
}
/** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */
function finishError(finish: FinishReason): CodedError | undefined {
function finishError(finish: FinishReason): RequestError | undefined {
switch (finish.kind) {
case 'error': {
const error: CodedError = new Error(finish.message)
const error: RequestError = new Error(finish.message)
if (finish.code !== undefined) error.code = finish.code
return error
}
case 'aborted': {
const error: CodedError = new Error('model stream aborted')
const error: RequestError = new Error('model stream aborted')
error.code = 'ABORTED'
return error
}
@@ -53,9 +57,12 @@ function finishError(finish: FinishReason): CodedError | undefined {
/**
* Build the `{ message, code? }` part of an error payload, omitting the
* `code` key entirely when absent (exactOptionalPropertyTypes-correct).
* The durable message renders the full cause chain: `turn/end` is the single
* durable record of an in-turn failure, so a wrapper message alone (e.g.
* `fetch failed`) would lose the diagnosis the session log exists to keep.
*/
function errorData(err: CodedError): { message: string; code?: string } {
return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} }
function errorData(err: RequestError): { message: string; code?: string } {
return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} }
}
/** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */
@@ -88,10 +95,10 @@ function interruptionTurnEndReason(handle: LoopHandle, signal: AbortSignal): Tur
case 'user':
case 'parent':
return { kind: 'aborted' }
/* v8 ignore next 2 -- the private holder requests disposed only after lifecycle state flips, which returns above */
/* v8 ignore next 2 -- the private holder requests disposed only after lifecycle state flips, which returns above. */
case 'disposed':
return { kind: 'disposed' }
/* v8 ignore next 2 -- AgentInterruptReason is closed and the public helper filters unsupported reasons */
/* v8 ignore next 2 -- AgentInterruptReason is closed and the public helper filters unsupported reasons. */
default:
return assertNever(reason, 'AgentInterruptReason')
}
@@ -115,20 +122,25 @@ export interface LoopHandle {
isPreRunCancelled(): boolean
/** Clear the cause-less pre-run marker without affecting replacement work. */
clearPreRunCancel(): void
/** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */
/** Settle idle waiters before pre-running cancellation publishes idle. */
settleIdle(): void
/** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */
readonly withToolBatch: <T>(run: (acceptContext: (context: HookContext) => void) => Promise<T>) => Promise<T>
}
/**
* Drive queued batches as durable turns until disposal. Plugin failures end the
* current turn without terminating the driver.
* @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through.
* @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options).
* Drive queued messages as independent durable turns until disposal. Plugin
* failures end the current turn without terminating the driver. The caller
* establishes the `ctx.agents.withInitiator()` boundary before entry; package-private
* orchestration recovers that exact Agent and captures its Session locally.
* @param ctx - the plugin context the loop reaches its initiating Agent,
* events (agent/…, session/flush), and services (systemPrompt, llm, tools)
* through.
* @param handle - the bridge to status, turn cancellation ownership, disposal, and pre-run cancellation state.
* @throws when no initiating Agent is active.
*/
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
const agent = ctx.agents.requireInitiator()
// Per-instance prefix and request-header state; conversation history remains in the session log.
const transmission = createTransmissionLog()
@@ -137,29 +149,42 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
const events = agentEvents(ctx, agent)
while (!handle.isDisposed()) {
await handle.inbox.waitForQueued(handle.disposed)
if (handle.isDisposed()) break
// Cancellation between wake and `running` skips only the cancelled work;
// a replacement prompt still runs and owns the eventual idle transition.
// An idle listener can enqueue and cancel replacement work before the next
// wait is installed. Consume that empty marker before parking the driver.
if (handle.isPreRunCancelled()) {
handle.clearPreRunCancel()
if (!handle.inbox.hasQueued) {
handle.settleIdle()
handle.setStatus('idle')
continue
}
}
await handle.inbox.waitForQueued(handle.disposed)
if (handle.isDisposed()) break
// Cancellation between wake and `running` skips only the cancelled work;
// a replacement prompt still runs before the eventual idle transition.
if (handle.isPreRunCancelled()) {
handle.clearPreRunCancel()
if (!handle.inbox.hasQueued) {
// Settle before publishing idle: the already-idle path has no status
// transition, while an idle listener can register waiters for new work.
handle.settleIdle()
handle.setStatus('idle')
continue
}
}
let cancellation = handle.installTurnCancellation()
handle.setStatus('running')
if (handle.isDisposed()) {
handle.clearTurnCancellation(cancellation)
break
}
// A synchronous running listener may cancel old work and enqueue a
// replacement. The replacement receives a fresh, non-aborted turn owner.
// A synchronous `running` listener can cancel before `runTurn`; balance the
// status only when no replacement prompt was queued by that listener.
if (cancellation.signal.aborted) {
handle.clearTurnCancellation(cancellation)
if (!handle.inbox.hasQueued) {
@@ -173,11 +198,11 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
const turn = lastTurnNumber(session) + 1
let terminalStopped = false
try {
terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission, cancellation.signal)
terminalStopped = await runTurn(ctx, events, handle, turn, transmission, cancellation.signal)
} catch (error: unknown) {
// Pre-turn failure has no durable boundary to close; report it without appending outside a turn.
const err = toError(error)
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${errorChain(err)}`)
try {
events.emit('agent/error', turn, 0, err)
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
@@ -195,20 +220,28 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
}
async function runTurn(
ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog,
ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog,
signal: AbortSignal,
): Promise<boolean> {
const agent = ctx.agents.requireInitiator()
const { session } = agent
const drainSteering = (): boolean => {
const messages = handle.inbox.drainSteering()
for (const message of messages) {
session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
}
return messages.length > 0
}
// Drain before opening the turn, but append only after `turn/start`.
const queued = handle.inbox.drainQueued()
const first = queued[0]
// Claim one queued message before opening its turn, but append it only after `turn/start`.
const message = handle.inbox.dequeueQueued()
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
if (!first) throw new Error('runTurn invariant violated: no queued message at turn start')
const trigger: TurnTrigger = { kind: 'message', source: first.source }
if (!message) throw new Error('runTurn invariant violated: no queued message at turn start')
const trigger: TurnTrigger = { kind: 'message', source: message.source }
let reason: TurnEndReason = { kind: 'completed' }
let step = 0
let requestRetryAttempt = 0
let stepOpen = false
let errorReported = false
let terminalStopped = false
@@ -221,7 +254,7 @@ async function runTurn(
}
// Record the durable turn failure once and contain the live error notification.
const failTurn = (err: CodedError): void => {
const failTurn = (err: RequestError): void => {
if (errorReported) return
errorReported = true
reason = { kind: 'error', step, ...errorData(err) }
@@ -244,71 +277,51 @@ async function runTurn(
// veto leaves no turn/start in the log and therefore owes no turn/end.
session.append('turn/start', { turn, trigger })
interruptionCheckpoint(signal)
// Each drained queued message runs the `agent/prompt-submit` waterfall before
// it becomes a `user/message` — a hook can rewrite the prompt or block it.
// The claimed message runs the `agent/prompt-submit` waterfall before it
// becomes a `user/message` — a hook can rewrite the prompt or block it.
// Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
// turn/end is now owed, so a throwing prompt-submit listener (the waterfall
// throws) is caught below and the turn still closes.
let anyAllowed = false
// Seeded with a floor (only observable if the batch were empty, which
// runTurn never allows — it is called with ≥1 queued message); each `block`
// decision carries a required `reason` and overwrites it, so a fully-blocked
// batch always reports the last vetoing reason.
let lastBlockReason = 'prompt blocked by hook'
for (const message of queued) {
const decision = await events.waterfall(
'agent/prompt-submit', message.content, message.source, signal,
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
)
interruptionCheckpoint(signal)
if (decision.kind === 'block') {
lastBlockReason = decision.reason
// Record the veto durably: `PromptDecision.reason` is the durable record
// of why a prompt was blocked, but a fully-blocked batch's `rejected`
// turn/end only preserves the LAST reason, and a MIXED batch (this prompt
// blocked, another allowed) does not end `rejected` at all — so without
// this append a blocked prompt would vanish from the log whenever any
// sibling prompt is allowed. `prompt/blocked` sits in the open turn in
// place of the `user/message` this prompt would have become.
session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason })
continue
}
anyAllowed = true
const promptDecision = await events.waterfall(
'agent/prompt-submit', message.content, message.source, signal,
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
)
interruptionCheckpoint(signal)
if (promptDecision.kind === 'block') {
session.append('prompt/blocked', { content: message.content, source: message.source, reason: promptDecision.reason })
reason = { kind: 'rejected', reason: promptDecision.reason }
} else {
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
const content = decision.content ?? message.content
const content = promptDecision.content ?? message.content
session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
// Every `allow.additionalContexts` entry is a separate context/message the
// next request also sees. The turn is open, so inject() appends each one
// into THIS turn without flattening provenance, framing, or metadata.
for (const context of decision.additionalContexts ?? []) {
// into THIS turn without flattening provenance or metadata.
for (const context of promptDecision.additionalContexts ?? []) {
agent.inject(context.content, {
source: context.source,
...context.envelope !== undefined ? { envelope: context.envelope } : {},
...context.meta !== undefined ? { meta: context.meta } : {},
})
}
}
while (true) {
// A fully blocked batch closes its zero-step turn as rejected.
if (!anyAllowed) {
reason = { kind: 'rejected', reason: lastBlockReason }
break
}
// A blocked prompt closes its zero-step turn as rejected.
if (promptDecision.kind === 'block') break
step += 1
// Steering from the previous round's continuation listeners joins before
// the request.
drainSteering(agent, handle.inbox, turn)
drainSteering()
// Assemble once before pre-step so pressure checks and the request share the same prompt.
// Assemble once before pre-step so listener work and the request share one prompt value.
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent, signal))
interruptionCheckpoint(signal)
const fullSystemPrompt = renderPrompt(assembly)
// Compose the request-only prefix once per loop instance before pressure
// checks. It precedes all derived history and is recorded only in the
// request header, not as session history.
// Compose the request-only prefix once per loop instance before the first
// request boundary. It precedes all derived history and is recorded only
// in the request header, not as session history.
if (transmission.sessionPrefix === undefined) {
const emptyPrefix: Message[] = deepFreeze([])
const composed = await events.waterfall(
@@ -320,8 +333,8 @@ async function runTurn(
transmission.sessionPrefix = deepFreeze(structuredClone(composed))
}
// Await surface mutations outside the step; pressure checks receive the pending prefix.
await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, signal)
// Await surface mutations outside the step before snapshotting history.
await events.serial('agent/pre-step', turn, step, signal)
interruptionCheckpoint(signal)
// Snapshot the exact log prefix before step/start: the reconstruction
@@ -333,15 +346,67 @@ async function runTurn(
// pre-commit veto throws before this assignment; post-commit observers
// are contained inside Session.append().
stepOpen = true
// A synchronous step/start observer can cancel after the step opened.
interruptionCheckpoint(signal)
let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error }
let stepOutcome:
| { hadToolCalls: boolean; finish: FinishReason }
| { requestError: RequestError }
| { error: RequestError }
try {
stepOutcome = await runStep(
ctx, events, agent, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal)
ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal)
} catch (error: unknown) {
stepOutcome = { error: toError(error) }
if (error instanceof TerminalModelRequestFailure) {
stepOutcome = { requestError: error.requestError }
} else {
stepOutcome = { error: toError(error) }
}
}
if ('requestError' in stepOutcome) {
// Recovery observes a balanced failed step and the original provider
// error while the failed step's signal remains the active owner.
closeStep()
const interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted !== undefined) {
reason = interrupted
break
}
const defaultDecision: RequestErrorDecision = { action: 'fail' }
let recoveryDecision: RequestErrorDecision = defaultDecision
try {
recoveryDecision = await events.waterfall(
'agent/request-error', turn, step, stepOutcome.requestError,
requestRetryAttempt, signal,
() => Promise.resolve(defaultDecision),
)
} catch (recoveryError: unknown) {
ctx.logger.warn(
`agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
)
}
// Cancellation and disposal always win over either a recovery decision
// or a recovery-listener failure.
const recoveryInterrupted = interruptionTurnEndReason(handle, signal)
if (recoveryInterrupted !== undefined) {
reason = recoveryInterrupted
break
}
switch (recoveryDecision.action) {
case 'retry':
requestRetryAttempt += 1
continue
case 'fail':
failTurn(stepOutcome.requestError)
break
/* v8 ignore next -- closed-union exhaustiveness guard */
default:
assertNever(recoveryDecision, 'agent request-error decision')
}
break
}
if ('error' in stepOutcome) {
@@ -350,21 +415,43 @@ async function runTurn(
// starts a fresh turn instead of being silently consumed.
closeStep()
const { error } = stepOutcome
const interruption = interruptionTurnEndReason(handle, signal)
if (interruption === undefined) failTurn(error)
else reason = interruption
const interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted === undefined) failTurn(error)
else reason = interrupted
break
}
requestRetryAttempt = 0
// Preserve max-token completion unless a later disposal, abort, or error wins.
const stepReason = stepFinishReason(stepOutcome.finish)
if (stepReason) reason = stepReason
// Steering that arrived during streaming/tool execution.
const steered = drainSteering(agent, handle.inbox, turn)
const steered = drainSteering()
try {
await events.serial('agent/post-step', turn, step, signal)
} catch (error: unknown) {
stepOutcome = { error: toError(error) }
}
if ('error' in stepOutcome) {
closeStep()
const interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted === undefined) failTurn(stepOutcome.error)
else reason = interrupted
break
}
const postStepInterrupted = interruptionTurnEndReason(handle, signal)
if (postStepInterrupted !== undefined) {
reason = postStepInterrupted
closeStep()
break
}
closeStep()
interruptionCheckpoint(signal)
const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
let decision: ContinuationDecision
@@ -375,9 +462,9 @@ async function runTurn(
)
interruptionCheckpoint(signal)
} catch (error: unknown) {
const interruption = interruptionTurnEndReason(handle, signal)
if (interruption === undefined) failTurn(toError(error))
else reason = interruption
const interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted === undefined) failTurn(toError(error))
else reason = interrupted
break
}
@@ -397,9 +484,11 @@ async function runTurn(
interruptionCheckpoint(signal)
terminalStop = stop !== undefined
} catch (error: unknown) {
const interruption = interruptionTurnEndReason(handle, signal)
if (interruption === undefined) failTurn(toError(error))
else reason = interruption
// A broken terminal policy is an ordinary continuation failure: fail
// this turn closed while leaving the driver alive for later turns.
const interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted === undefined) failTurn(toError(error))
else reason = interrupted
break
}
if (terminalStop) {
@@ -409,11 +498,7 @@ async function runTurn(
shouldContinue = false
}
if (!shouldContinue || handle.isDisposed()) {
/* v8 ignore next -- disposal during continuation-decision window is a narrow race; error-path disposal is covered elsewhere */
if (handle.isDisposed()) reason = { kind: 'disposed' }
break
}
if (!shouldContinue) break
}
// Normal / inline-error loop exit: close the turn.
@@ -423,9 +508,9 @@ async function runTurn(
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
if (!turnStartLogged) throw error
closeStep()
const interruption = interruptionTurnEndReason(handle, signal)
if (interruption === undefined) failTurn(toError(error))
else reason = interruption
const interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted === undefined) failTurn(toError(error))
else reason = interrupted
closeTurn()
}
@@ -435,7 +520,7 @@ async function runTurn(
} catch (error: unknown) {
// The turn is closed, so report the failed flush live rather than append outside a turn.
const err = toError(error)
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`)
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${errorChain(err)}`)
try {
events.emit('agent/error', turn, step, err)
} catch {
@@ -445,15 +530,6 @@ async function runTurn(
return terminalStopped
}
/** Drain the steering queue into the session. Returns whether any arrived. */
function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boolean {
const messages = inbox.drainSteering()
for (const message of messages) {
agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
}
return messages.length > 0
}
/**
* Run one committed step: transform call config, log the request header, build
* the request from the cached prefix plus the step-boundary snapshot, stream and
@@ -463,7 +539,6 @@ function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boole
async function runStep(
ctx: Context,
events: AgentEventDispatch,
agent: ReactLoopAgent,
handle: LoopHandle,
turn: number,
step: number,
@@ -473,6 +548,7 @@ async function runStep(
transmission: TransmissionLog,
signal: AbortSignal,
): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
const agent = ctx.agents.requireInitiator()
const { session, options } = agent
// Seed the first request from agent options and later requests from the logged header;
@@ -483,7 +559,9 @@ async function runStep(
: { provider: options.provider ?? '', model: options.model ?? '' }))
// Listener replacements are recorded in the request header before dispatch.
const config = await events.waterfall('agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig))
const config = await events.waterfall(
'agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig),
)
interruptionCheckpoint(signal)
if (!config.provider || !config.model) {
throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
@@ -518,27 +596,67 @@ async function runStep(
// --- Model call (streaming-first; raw chunks are the replay record) ---
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
for await (const chunk of ctx.llm.stream(request)) {
interruptionCheckpoint(signal)
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
chunkSeqs.push(chunkEvent.seq)
assembler.push(chunk)
const stream = ctx.llm.stream(request)
try {
for await (const chunk of stream) {
interruptionCheckpoint(signal)
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
chunkSeqs.push(chunkEvent.seq)
assembler.push(chunk)
}
} catch (error: unknown) {
if (isLlmAdapterFailure(stream, error)) throw new TerminalModelRequestFailure(error)
throw error
}
interruptionCheckpoint(signal)
// Normalize failure finish chunks into the same path as thrown stream errors.
const stepError = finishError(assembler.finish)
if (stepError) throw stepError
if (stepError) throw new TerminalModelRequestFailure(stepError)
const recordAssistantMessage = (
assembledContent: ContentBlock[],
message: Message,
preserveReplayState = true,
): void => {
session.append(
'assistant/message',
{
turn,
step,
content: message.content,
provenance: assistantProvenance(
header.config,
assembler.replayState,
preserveReplayState && isDeepStrictEqual(message.content, assembledContent),
),
...assembler.usage === undefined ? {} : { usage: assembler.usage },
},
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
)
}
// A rejected result still records the successful provider call without retaining rejected output.
const processStepResult = async (assembledContent: ContentBlock[], message: Message): Promise<Message> => {
try {
const processed = await events.waterfall(
'agent/step-result', turn, step, message, signal, () => Promise.resolve(message),
)
interruptionCheckpoint(signal)
return processed
} catch (error: unknown) {
recordAssistantMessage(assembledContent, { ...message, content: [] }, false)
throw error
}
}
if (assembler.finish.kind === 'max-tokens') {
const assembled = assembler.message()
const assembledContent = structuredClone(assembled.content)
let message: Message = withoutToolCalls(assembled)
message = withoutToolCalls(await processStepResult(
events, session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs, signal,
))
message = withoutToolCalls(await processStepResult(assembledContent, message))
// Preserve usage even when max-token truncation produced no content.
recordAssistantMessage(session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs)
recordAssistantMessage(assembledContent, message)
return { hadToolCalls: false, finish: assembler.finish }
}
@@ -546,89 +664,23 @@ async function runStep(
const assembled = assembler.message()
const assembledContent = structuredClone(assembled.content)
let message: Message = assembled
message = await processStepResult(
events, session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs, signal,
)
message = await processStepResult(assembledContent, message)
// Every successful call records its completion anchor, including explicit
// empty chunk provenance for a contentless, usage-less provider response.
recordAssistantMessage(session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs)
recordAssistantMessage(assembledContent, message)
// Dispatch may overlap; policy, durable results, and result context stay model-ordered.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish }
return handle.withToolBatch(async (acceptContext) => {
await executeToolCalls(
ctx, agent, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext,
ctx, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext,
)
return { hadToolCalls: true, finish: assembler.finish }
})
}
/** Preserve successful-call accounting without retaining output that result processing rejected. */
async function processStepResult(
events: AgentEventDispatch,
session: Session,
turn: number,
step: number,
config: LlmCallConfig,
assembledContent: ContentBlock[],
message: Message,
assembler: BlockAssembler,
chunkSeqs: number[],
signal: AbortSignal,
): Promise<Message> {
try {
const processed = await events.waterfall(
'agent/step-result', turn, step, message, signal, () => Promise.resolve(message),
)
interruptionCheckpoint(signal)
return processed
} catch (error: unknown) {
recordAssistantMessage(
session,
turn,
step,
config,
assembledContent,
{ ...message, content: [] },
assembler,
chunkSeqs,
false,
)
throw error
}
}
/** Record one content-or-usage assistant message with replay-safe provenance. */
function recordAssistantMessage(
session: Session,
turn: number,
step: number,
config: LlmCallConfig,
assembledContent: ContentBlock[],
message: Message,
assembler: BlockAssembler,
chunkSeqs: number[],
preserveReplayState = true,
): void {
session.append(
'assistant/message',
{
turn,
step,
content: message.content,
provenance: assistantProvenance(
config,
assembler.replayState,
preserveReplayState && isDeepStrictEqual(message.content, assembledContent),
),
...assembler.usage === undefined ? {} : { usage: assembler.usage },
},
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
)
}
/** Build durable assistant provenance, dropping replay state after any content rewrite. */
function assistantProvenance(config: LlmCallConfig, replayState: unknown, contentUnchanged: boolean): NonNullable<Message['provenance']> {
return {

View File

@@ -4,8 +4,8 @@
* Dispatch may overlap, while policy, results, and result context remain
* model-ordered. Abort stops replenishment and drains started calls.
*
* Each started call records `tool/call`; `tool/result` commits in model order,
* preserving derived history when audit events interleave with earlier results.
* Each advertised call records a balanced `tool/call`/`tool/result` pair. Calls
* skipped after abort receive synthetic error results so replay stays valid.
* @module dsh-agent-loop/tool-calls
*/
@@ -14,7 +14,6 @@ import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
import type { HookContext } from '@deepseek-ai/dsh-agent'
import type { Session } from '@deepseek-ai/dsh-session'
import { TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
import type { ReactLoopAgent } from './agent.ts'
/** One tool call after argument parsing, ready to schedule. */
interface PlannedCall {
@@ -29,21 +28,21 @@ interface Slot {
needsPost: boolean
}
/** Control-flow sentinel; turn classification reads only the explicit signal. */
const TOOL_CALLS_INTERRUPTED = new Error('tool calls interrupted')
/** Stop scheduling at a cooperative boundary without exposing the runtime reason. */
function interruptionCheckpoint(signal: AbortSignal): void {
if (signal.aborted) throw TOOL_CALLS_INTERRUPTED
/** One scheduler group outcome, including a drained cancellation. */
interface GroupOutcome {
consumed: number
aborted: boolean
}
/**
* Schedule one assistant step's tool calls by their live concurrency mode.
* Started calls receive ordered results. Abort drains them and rethrows after
* accepting their context into the batch FIFO owned by the caller.
* Started calls receive ordered results. Abort drains them, records synthetic
* results for unstarted calls, and returns with the signal still aborted after
* accepting started-call context into the batch FIFO owned by the caller.
* The committed step's AgentLoop driver boundary supplies the initiating Agent
* that becomes each explicit {@link ToolExecutionInput.agent}.
*
* @param ctx - loop context that owns the tool registry.
* @param agent - agent and session receiving the call lifecycle.
* @param ctx - loop context that owns the tool registry and carries the initiating Agent.
* @param turn - current turn number.
* @param step - current step number.
* @param toolCalls - assistant calls in model order.
@@ -53,7 +52,6 @@ function interruptionCheckpoint(signal: AbortSignal): void {
*/
export async function executeToolCalls(
ctx: Context,
agent: ReactLoopAgent,
turn: number,
step: number,
toolCalls: ToolCallBlock[],
@@ -61,6 +59,7 @@ export async function executeToolCalls(
maxParallel: number,
acceptContext: (context: HookContext) => void,
): Promise<void> {
const agent = ctx.agents.requireInitiator()
const { session } = agent
// Inputs are distinct because tools/execute wrappers may replace `exec.signal`.
@@ -82,7 +81,14 @@ export async function executeToolCalls(
const first = planned[next]!
const mode = ctx.tools.executionMode(first.exec).kind
const group = mode === 'parallel' ? planned.slice(next) : [first]
next += await runGroup(ctx, session, turn, step, group, mode, signal, maxParallel, acceptContext)
const outcome = await runGroup(
ctx, turn, step, group, mode, signal, maxParallel, acceptContext,
)
next += outcome.consumed
if (outcome.aborted) {
for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block)
return
}
}
}
@@ -100,11 +106,11 @@ function parseArguments(raw: string): unknown {
* before start; an exclusive reclassification waits for the current pool to
* drain and remains for the caller's next barrier. Results and contexts commit
* in model order. Abort stops starts, drains and commits started calls, accepts
* their contexts into the owning batch, and throws.
* their contexts into the owning batch, records results for skipped calls, and
* returns an aborted outcome.
*/
async function runGroup(
ctx: Context,
session: Session,
turn: number,
step: number,
group: PlannedCall[],
@@ -112,8 +118,8 @@ async function runGroup(
signal: AbortSignal,
maxParallel: number,
acceptContext: (context: HookContext) => void,
): Promise<number> {
interruptionCheckpoint(signal)
): Promise<GroupOutcome> {
const { session } = ctx.agents.requireInitiator()
const slots: (Slot | undefined)[] = group.map(() => undefined)
// Started slots retain their tool/call seq for result provenance.
const callSeqs: number[] = group.map(() => -1)
@@ -191,17 +197,30 @@ async function runGroup(
inFlight.delete(settledIndex)
await commitReady()
// Abort may arrive while a tool or ordered commit awaits.
if (signal.aborted) aborted = true
await fillPool()
}
if (aborted) {
// Started calls and accepted context settle before the turn records the abort.
throw TOOL_CALLS_INTERRUPTED
// Started calls and accepted context settle first; every remaining model
// call then receives an ordered synthetic result before the turn aborts.
for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block)
return { consumed: group.length, aborted: true }
}
/* v8 ignore next -- unreachable: a non-aborted group commits every started call */
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
return started
return { consumed: started, aborted: false }
}
/** Append the durable call/result pair for a model call skipped after cancellation. */
function appendSkippedToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): void {
const callSeq = appendToolCall(session, turn, step, block)
appendToolResult(session, turn, step, block, {
content: [{ type: 'text', text: 'Error: tool call skipped because the step was aborted before execution' }],
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
}, callSeq)
}
/** Append a started call and return its provenance sequence. */