feat(core): make turn cancellation explicit

This commit is contained in:
Yichen Jiang
2026-07-16 18:12:34 +08:00
parent b1e19d8b69
commit c238992fbb
55 changed files with 884 additions and 383 deletions

View File

@@ -48,11 +48,13 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re
### Loop lifecycle (`loop.ts`)
The driver owns one agent for its lifetime and runs inside `ctx.agentExecution.run({ agent }, ...)`, so process-local asynchronous continuations can recover the initiating Agent. Creation, persistence load, and unpublished setup stay outside the child boundary; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. The [execution-context package](../agent-execution/README.md) owns propagation and detached-work rules.
The driver owns one agent for its lifetime and runs inside `ctx.agentExecution.run({ agent }, ...)`, so process-local asynchronous continuations can recover the initiating Agent. The ALS frame contains only `{ agent }`: creation, persistence load, and unpublished setup stay outside the child boundary, while turn, step, signal, and other control state remain explicit at every seam. The [execution-context package](../agent-execution/README.md) owns propagation and detached-work rules.
The loop records turn, step, request, stream, and tool boundaries in the session log; live extension events coordinate policy around those durable facts. The [architecture turn flow](../../../docs/architecture.md#turn-flow) and generated [event catalog](../../../docs/cordis-catalog/events.md) are the authoritative sequence and signatures.
Plugin failure ends the current turn, not the loop. Cancellation clears pending work and aborts the current step without leaking to the next prompt. Terminal continuation stops remain authoritative through turn close and durability flush.
Plugin failure ends the current turn, not the loop. The loop creates one private turn cancellation holder before announcing `running`, passes its single signal through prompt handling, prompt assembly, every step, model and tool execution, continuation, terminal stop, turn end, and durability flush, then discards it. A replacement prompt accepted after cancellation receives a fresh holder, while all work in the cancelled turn observes the first typed runtime cause. The durable turn outcome is only `aborted`; disposal is a separate runtime interrupt and wins classification even if cancellation reached the signal first.
Cancellation is cooperative: the loop checks for interruption between awaited boundaries but does not abandon an in-process listener, adapter, or tool Promise with `Promise.race`. `whenIdle()` and handle disposal therefore observe real quiescence. See the [explicit turn cancellation RFC](../../../docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.md).
### What belongs to plugins

View File

@@ -7,12 +7,13 @@
*/
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
import { agentEvents, normalizeAgentCancelCause } from '@deepseek-ai/dsh-agent'
import type { AgentCancelCause, AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue, type Session } 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'
@@ -91,7 +92,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), the per-step AbortController, and
* Owns the inbox (queued + steering FIFOs), one turn cancellation holder, and
* the loop driver. Everything observable happens through session events and
* the agent/* event taxonomy — plugins never need this class.
*/
@@ -116,21 +117,18 @@ export class ReactLoopAgent implements Agent {
}
private _status: AgentStatus = 'idle'
private currentAbort: AbortController | undefined
/** Active turn owner, installed before the running notification and retained through flush. */
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
/**
* Turn-scoped cancel marker, set by {@link cancel} and read/cleared by the
* driver loop (via the LoopHandle) at every point a turn could start or
* continue. Armed ONLY when there is something to cancel (a running turn, an
* in-flight step, or queued/steering work), so an idle no-op cancel cannot
* leave it set to wrongly drop a later prompt.
* 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.
*/
private cancelRequested = false
/** Pending cancellation reason, preserved even outside an active step signal. */
private cancelReason = 'cancelled'
private preRunCancelled = false
private disposed: Promise<void>
private resolveDisposed!: () => void
/** Resolves when the driver loop has fully exited (tests/disposal). */
@@ -272,24 +270,18 @@ export class ReactLoopAgent implements Agent {
}
}
cancel(reason?: string): void {
// Arm only for current work; an idle marker would cancel the next prompt.
if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) {
this.cancelRequested = true
// Capture the resolved reason for the marker-only windows (pre-step /
// continuation). The mid-step path reads it from abort.signal.reason
// below; the marker path reads it via the LoopHandle's cancelReason().
this.cancelReason = reason ?? 'cancelled'
}
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
else active.request(accepted)
// Drop all pending queued + steering work (un-started prompts never run; the
// cancelled turn's steering is not re-enqueued). Cleared directly 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.
this.#inbox.clear()
// Interrupt an in-flight step immediately (the running turn observes the
// abort and ends `aborted`). The marker covers the windows where no step is
// running (pre-step, continuation).
this.currentAbort?.abort(reason ?? 'cancelled')
}
/**
@@ -330,13 +322,20 @@ export class ReactLoopAgent implements Agent {
this.done = this.loopCtx.agentExecution.run({ agent: this }, () => runLoop(this.loopCtx, this, {
inbox: this.#inbox,
setStatus: (status) => { this.setStatus(status) },
setAbort: controller => void (this.currentAbort = controller),
installTurnCancellation: () => {
const cancellation = new TurnCancellation()
this.turnCancellation = cancellation
return cancellation
},
clearTurnCancellation: (cancellation) => {
/* v8 ignore else -- the internal driver clears only the exact holder returned by its latest install */
if (this.turnCancellation === cancellation) this.turnCancellation = undefined
},
disposed: this.disposed,
isDisposed: () => this._status === 'disposed',
isCancelled: () => this.cancelRequested,
cancelReason: () => this.cancelReason,
clearCancel: () => { this.cancelRequested = false },
// Pre-step cancellation re-parks without emitting a status transition.
isPreRunCancelled: () => this.preRunCancelled,
clearPreRunCancel: () => { this.preRunCancelled = false },
// Pre-run cancellation re-parks without emitting a status transition.
settleIdle: () => { this.settleIdleWaiters() },
}))
}
@@ -354,7 +353,7 @@ export class ReactLoopAgent implements Agent {
// internal state that must settle even if a listener throws below. Each
// waiter chains `done`, so it resolves only once the loop actually exits.
this.settleIdleWaiters()
this.currentAbort?.abort('disposed')
this.turnCancellation?.request(DISPOSED_INTERRUPT_REASON)
// An unpublished rollback has no public status lifecycle to announce.
// Once publication begins, disposed is part of the agent/status contract.
if (this.published) {

View File

@@ -0,0 +1,31 @@
/** Turn-scoped cancellation ownership for the concrete AgentLoop driver. @module dsh-agent-loop/cancellation */
import type { AgentCancelCause } from '@deepseek-ai/dsh-agent'
/** Stable runtime-only reason used when lifecycle teardown interrupts a turn. */
export const DISPOSED_INTERRUPT_REASON = Object.freeze({ kind: 'disposed' } as const)
/**
* Owns the single controller shared by every asynchronous boundary of one turn.
* The first request wins because a later caller must not rewrite the cause
* observed by earlier listeners.
*/
export class TurnCancellation {
readonly #controller = new AbortController()
/** The explicit signal passed through this turn's execution boundaries. */
get signal(): AbortSignal {
return this.#controller.signal
}
/**
* Abort the turn once.
* @param reason - a validated caller cause or lifecycle disposal marker.
* @returns whether this request established the signal reason.
*/
request(reason: AgentCancelCause | typeof DISPOSED_INTERRUPT_REASON): boolean {
if (this.signal.aborted) return false
this.#controller.abort(reason)
return true
}
}

View File

@@ -29,7 +29,7 @@ export class Inbox {
return this.queuedMessages.length > 0
}
/** True while steering messages are pending — read by `cancel()`'s arm gate and the loop's stop-override check. */
/** True while steering messages are pending — read by cancellation and the loop's stop-override check. */
get hasSteering(): boolean {
return this.steeringMessages.length > 0
}

View File

@@ -6,9 +6,9 @@
*/
import type { Context } from 'cordis'
import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { FinishReason, GenerateOptions, LlmCallConfig, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import { assertNever, BlockAssembler, HarnessError, deepFreeze } 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 { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
@@ -19,6 +19,7 @@ import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
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 }
@@ -68,21 +69,65 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
}
}
/** Internal control-flow sentinel; durable classification comes only from the turn signal. */
const TURN_INTERRUPTED = new Error('turn interrupted')
/** Stop at an explicit cooperative boundary without stringifying the runtime reason. */
function interruptionCheckpoint(signal: AbortSignal): void {
if (signal.aborted) throw TURN_INTERRUPTED
}
/** Classify a supported turn interruption, with lifecycle disposal taking precedence. */
function interruptionTurnEndReason(handle: LoopHandle, signal: AbortSignal): TurnEndReason | undefined {
if (handle.isDisposed()) return { kind: 'disposed' }
const reason = agentInterruptReasonOf(signal)
if (reason === undefined) return undefined
switch (reason.kind) {
case 'user':
case 'parent':
return { kind: 'aborted' }
/* 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 */
default:
return assertNever(reason, 'AgentInterruptReason')
}
}
/** Append the durable assembled assistant message when it carries content or usage. */
function appendAssistantMessage(
session: Session,
turn: number,
step: number,
message: Message,
usage: TokenUsage | undefined,
chunkSeqs: number[],
): void {
if (message.content.length === 0 && usage === undefined) return
session.append(
'assistant/message',
{ turn, step, content: message.content, ...usage === undefined ? {} : { usage } },
{ surfaceOp: 'append', ...(chunkSeqs.length > 0 ? { sourceEventSeqs: chunkSeqs } : {}) },
)
}
/** Mutable agent controls supplied to the loop driver. */
export interface LoopHandle {
/** Native-private agent inbox handed to the driver only at internal startup. */
readonly inbox: Inbox
setStatus(status: 'idle' | 'running'): void
setAbort(controller: AbortController | undefined): void
/** Install a fresh active-turn owner before the running notification. */
installTurnCancellation(): TurnCancellation
/** Clear only the exact owner whose turn and durability flush settled. */
clearTurnCancellation(cancellation: TurnCancellation): void
/** Resolves when the agent is disposed — unblocks the idle wait. */
disposed: Promise<void>
isDisposed(): boolean
/** Whether cancellation is pending for the current loop iteration. */
isCancelled(): boolean
/** Resolved pending-cancellation reason; meaningful only while {@link isCancelled} is true. */
cancelReason(): string
/** Clear the cancel marker (called once per iteration after the turn returns). */
clearCancel(): void
/** Whether queued work was cancelled before an active turn owner existed. */
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`. */
settleIdle(): void
}
@@ -92,7 +137,7 @@ export interface LoopHandle {
* 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).
* @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads.
* @param handle - the bridge to status, turn cancellation ownership, disposal, and pre-run cancellation state.
*/
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
// Per-instance prefix and request-header state; conversation history remains in the session log.
@@ -108,31 +153,38 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
// Cancellation between wake and `running` skips only the cancelled work;
// a replacement prompt still runs and owns the eventual idle transition.
if (handle.isCancelled()) {
handle.clearCancel()
if (handle.isPreRunCancelled()) {
handle.clearPreRunCancel()
if (!handle.inbox.hasQueued) {
handle.settleIdle()
continue
}
}
let cancellation = handle.installTurnCancellation()
handle.setStatus('running')
// A synchronous `running` listener can cancel before `runTurn`; balance the
// status only when no replacement prompt was queued by that listener.
if (handle.isCancelled()) {
handle.clearCancel()
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.
if (cancellation.signal.aborted) {
handle.clearTurnCancellation(cancellation)
if (!handle.inbox.hasQueued) {
handle.setStatus('idle')
continue
}
cancellation = handle.installTurnCancellation()
}
// Idle injection can add a turn, so derive the next number from the log.
const turn = lastTurnNumber(session) + 1
let terminalStopped = false
try {
terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission)
terminalStopped = await runTurn(ctx, events, agent, 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)
@@ -140,11 +192,10 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
try {
events.emit('agent/error', turn, 0, err)
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
} finally {
handle.clearTurnCancellation(cancellation)
}
// Reset per iteration, including when a prompt arrives during the flush window.
handle.clearCancel()
// Late steering becomes queued input unless terminal policy stopped the turn.
for (const message of handle.inbox.drainSteering()) {
if (!terminalStopped) handle.inbox.enqueue(message)
@@ -156,6 +207,7 @@ 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,
signal: AbortSignal,
): Promise<boolean> {
const { session } = agent
@@ -202,6 +254,7 @@ async function runTurn(
// matter what throws below; the catch + closeTurn guarantee it. A pre-commit
// 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.
// Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
@@ -215,9 +268,10 @@ async function runTurn(
let lastBlockReason = 'prompt blocked by hook'
for (const message of queued) {
const decision = await events.waterfall(
'agent/prompt-submit', message.content, message.source,
'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
@@ -253,53 +307,28 @@ async function runTurn(
// the request.
drainSteering(agent, handle.inbox, turn)
// The step's AbortController exists BEFORE any async pre-step work so a
// dispose() or cancel() — in a synchronous turn-start listener or an
// async listener whose effect fires before we block — always has an armed
// abort to cancel against. isDisposed below covers disposal, which does
// NOT set the cancel marker. Cleared on every exit path below.
const abort = new AbortController()
handle.setAbort(abort)
// Assemble once before pre-step so pressure checks and the request share the same prompt.
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent, signal))
interruptionCheckpoint(signal)
const fullSystemPrompt = renderPrompt(assembly)
// Cancellation or disposal during assembly ends the turn before any step opens.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
break
}
// 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.
if (transmission.sessionPrefix === undefined) {
const emptyPrefix: Message[] = deepFreeze([])
const composed = await events.waterfall(
'agent/session-prefix', emptyPrefix, abort.signal,
'agent/session-prefix', emptyPrefix, signal,
() => Promise.resolve(emptyPrefix),
)
// Never cache an interrupted composition; the next turn recomposes it.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
break
}
interruptionCheckpoint(signal)
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, abort.signal)
// Interruption landing during the pre-step seam: do not open an empty step.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
break
}
await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, signal)
interruptionCheckpoint(signal)
// Snapshot the exact log prefix before step/start: the reconstruction
// boundary. Appends after this synchronous snapshot join the next request.
@@ -310,26 +339,15 @@ async function runTurn(
// pre-commit veto throws before this assignment; post-commit observers
// are contained inside Session.append().
stepOpen = true
// Cancel landing in the step-start window: a synchronous `session/event`
// step/start listener can cancel after the step is already open. Check
// AFTER the step/start append and before `runStep`: drop the step, end the
// turn accordingly. closeStep balances the already-appended step/start.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
closeStep()
break
}
// A synchronous step/start observer can cancel after the step opened.
interruptionCheckpoint(signal)
let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error }
try {
stepOutcome = await runStep(
ctx, events, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
ctx, events, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal)
} catch (error: unknown) {
stepOutcome = { error: toError(error) }
} finally {
handle.setAbort(undefined)
}
if ('error' in stepOutcome) {
@@ -338,14 +356,9 @@ async function runTurn(
// starts a fresh turn instead of being silently consumed.
closeStep()
const { error } = stepOutcome
if (handle.isDisposed()) {
reason = { kind: 'disposed' }
} else if (abort.signal.aborted) {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
} else {
failTurn(error)
}
const interruption = interruptionTurnEndReason(handle, signal)
if (interruption === undefined) failTurn(error)
else reason = interruption
break
}
@@ -357,17 +370,20 @@ async function runTurn(
const steered = drainSteering(agent, handle.inbox, turn)
closeStep()
interruptionCheckpoint(signal)
const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
let decision: ContinuationDecision
try {
decision = await events.waterfall(
'agent/turn-continuation', turn, defaultDecision,
'agent/turn-continuation', turn, defaultDecision, signal,
() => Promise.resolve(defaultDecision),
)
interruptionCheckpoint(signal)
} catch (error: unknown) {
// A broken continuation plugin ends the turn, not the loop.
failTurn(toError(error))
const interruption = interruptionTurnEndReason(handle, signal)
if (interruption === undefined) failTurn(toError(error))
else reason = interruption
break
}
@@ -383,12 +399,13 @@ async function runTurn(
// Terminal policy is monotonic and runs after ordinary continuation folding.
let terminalStop = false
try {
const stop = await events.serial('agent/turn-stop', turn)
const stop = await events.serial('agent/turn-stop', turn, signal)
interruptionCheckpoint(signal)
terminalStop = stop !== undefined
} catch (error: unknown) {
// A broken terminal policy is an ordinary continuation failure: fail
// this turn closed while leaving the driver alive for later turns.
failTurn(toError(error))
const interruption = interruptionTurnEndReason(handle, signal)
if (interruption === undefined) failTurn(toError(error))
else reason = interruption
break
}
if (terminalStop) {
@@ -398,12 +415,6 @@ async function runTurn(
shouldContinue = false
}
// The marker catches cancellation after the step controller was cleared.
if (handle.isCancelled()) {
reason = { kind: 'aborted', reason: handle.cancelReason() }
break
}
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' }
@@ -418,12 +429,9 @@ async function runTurn(
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
if (!turnStartLogged) throw error
closeStep()
// Preserve an established disposal reason; otherwise report the failure.
if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
reason = { kind: 'disposed' }
} else {
failTurn(toError(error))
}
const interruption = interruptionTurnEndReason(handle, signal)
if (interruption === undefined) failTurn(toError(error))
else reason = interruption
closeTurn()
}
@@ -480,7 +488,8 @@ async function runStep(
: { model: options.model ?? '' }))
// Listener replacements are recorded in the request header before dispatch.
const config = await events.waterfall('agent/request', turn, step, seedConfig, () => Promise.resolve(seedConfig))
const config = await events.waterfall('agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig))
interruptionCheckpoint(signal)
if (!config.model) {
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
}
@@ -514,12 +523,12 @@ async function runStep(
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
for await (const chunk of ctx.llm.stream(request)) {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
interruptionCheckpoint(signal)
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
chunkSeqs.push(chunkEvent.seq)
assembler.push(chunk)
}
interruptionCheckpoint(signal)
// Normalize failure finish chunks into the same path as thrown stream errors.
const stepError = finishError(assembler.finish)
@@ -527,39 +536,26 @@ async function runStep(
if (assembler.finish.kind === 'max-tokens') {
let message: Message = withoutToolCalls(assembler.message())
message = withoutToolCalls(await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message)))
message = withoutToolCalls(await events.waterfall('agent/step-result', turn, step, message, signal, () => Promise.resolve(message)))
interruptionCheckpoint(signal)
// Preserve usage even when max-token truncation produced no content.
if (message.content.length > 0 || assembler.usage) {
// The finish chunk guarantees non-empty provenance here.
session.append(
'assistant/message',
{ turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
)
}
appendAssistantMessage(session, turn, step, message, assembler.usage, chunkSeqs)
return { hadToolCalls: false, finish: assembler.finish }
}
// Record the post-waterfall message that tool dispatch uses.
let message: Message = assembler.message()
message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message))
message = await events.waterfall('agent/step-result', turn, step, message, signal, () => Promise.resolve(message))
interruptionCheckpoint(signal)
// Empty messages exist only to carry usage; omit empty provenance.
if (message.content.length > 0 || assembler.usage) {
session.append(
'assistant/message',
{ turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
{ surfaceOp: 'append', ...(chunkSeqs.length > 0 ? { sourceEventSeqs: chunkSeqs } : {}) },
)
}
appendAssistantMessage(session, turn, step, message, assembler.usage, chunkSeqs)
// Tool execution stays sequential; recheck abort around each normalized result.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
// Buffer context until all results are appended to preserve call/result adjacency.
const pendingContext: HookContext[] = []
for (const call of toolCalls) {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
interruptionCheckpoint(signal)
const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
let parsedArguments: unknown
try {
@@ -588,11 +584,7 @@ async function runStep(
...result.meta !== undefined ? { meta: result.meta } : {},
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
if (result.additionalContext) pendingContext.push(result.additionalContext)
// The signal may flip while the tool is awaited.
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
/* v8 ignore stop */
interruptionCheckpoint(signal)
}
// Append buffered context after the complete result batch.

View File

@@ -146,6 +146,82 @@ describe('AgentLoop execution context', () => {
await ctx.fiber.dispose()
})
it('keeps ALS identity minimal while one explicit signal spans each turn seam', async () => {
const adapter = new MockAdapter([
toolCallResponse('observe-call', 'observe', {}),
textResponse('first done'),
textResponse('second done'),
])
const { ctx } = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('signal-owner'), { model: 'mock' })
let signals: AbortSignal[] = []
const capture = (signal: AbortSignal | undefined): void => {
if (signal === undefined) throw new Error('turn seam omitted its explicit signal')
const execution = ctx.agentExecution.require()
expect(Object.keys(execution)).toEqual(['agent'])
expect(execution.agent).toBe(agent)
signals.push(signal)
}
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
if (context.agent === agent) capture(context.signal)
return next()
})
ctx.on('agent/prompt-submit', async (subject, _content, _source, signal, next) => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/session-prefix', async (subject, _prefix, signal, next) => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/pre-step', (subject, _turn, _step, _system, _prefix, signal) => {
if (subject === agent) capture(signal)
})
ctx.on('agent/request', async (subject, _turn, _step, _config, signal, next) => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/step-result', async (subject, _turn, _step, _message, signal, next) => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/turn-continuation', async (subject, _turn, _decision, signal, next) => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/turn-stop', (subject, _turn, signal) => {
if (subject === agent) capture(signal)
})
ctx.tools.register(defineTool({
name: 'observe',
description: 'observe explicit turn state',
parameters: {},
execute: async (_args, exec) => {
capture(exec.signal)
return [{ type: 'text', text: 'observed' }]
},
}))
const firstIdle = waitForIdle(ctx, agent)
send(agent, 'first')
await firstIdle
const firstSignal = signals[0]
expect(firstSignal).toBeDefined()
expect(new Set([...signals, ...adapter.requests.slice(0, 2).map(request => request.signal!)])).toEqual(new Set([firstSignal]))
signals = []
const secondIdle = waitForIdle(ctx, agent)
send(agent, 'second')
await secondIdle
const secondSignal = signals[0]
expect(secondSignal).toBeDefined()
expect(new Set([...signals, adapter.requests[2]!.signal!])).toEqual(new Set([secondSignal]))
expect(secondSignal).not.toBe(firstSignal)
expect(ctx.agentExecution.current()).toBeUndefined()
await ctx.fiber.dispose()
})
it('keeps child setup under the parent boundary, switches for the child driver, then restores the parent', async () => {
const adapter = new MockAdapter([
toolCallResponse('spawn', 'spawn-child', {}),

View File

@@ -329,7 +329,7 @@ describe('ReactLoopAgent', () => {
expect(settled).toBe(false)
await waitForStatus(ctx, agent, 'running')
agent.cancel('done')
agent.cancel({ kind: 'user' })
await idle
expect(settled).toBe(true)
expect(agent.status).toBe('idle')

View File

@@ -1,9 +1,8 @@
/**
* Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the broad verb — it
* clears queued + steering work, aborts an in-flight step, and drops a turn about to start —
* whereas a bare step abort (the loop's private `AbortController`) kills only the current step
* and leaves the queue intact. The suite covers every landing window plus marker
* reset and `whenIdle()` quiescence.
* clears queued + steering work, aborts the active turn, and drops work not yet claimed by the
* driver without leaking cancellation into a replacement prompt. The suite covers every landing
* window plus marker reset and `whenIdle()` quiescence.
* @module dsh-agent-loop/tests/cancel
*/
@@ -12,11 +11,11 @@ import { Context } from 'cordis'
import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
const ctx = new Context()
@@ -60,7 +59,7 @@ describe('Agent.cancel()', () => {
// The loop is parked at the idle wait with nothing queued. A cancel here must
// NOT arm the marker — otherwise the next legitimate prompt would be dropped.
agent.cancel('nothing to cancel')
agent.cancel({ kind: 'user' })
send(agent, 'real prompt')
await waitForIdle(ctx, agent)
@@ -78,7 +77,7 @@ describe('Agent.cancel()', () => {
// send() queues synchronously (status still idle, loop microtask not yet
// resumed). Cancel in that pre-step window: the queued turn must not run.
send(agent, 'drop me')
agent.cancel('pre-step')
agent.cancel({ kind: 'user' })
// Give the loop a chance to wake and process the cancel.
await new Promise(r => setTimeout(r, 30))
@@ -98,7 +97,7 @@ describe('Agent.cancel()', () => {
// drops the turn before it runs; the skip path must settle it directly.
send(agent, 'q')
const idle = agent.whenIdle()
agent.cancel('pre-step')
agent.cancel({ kind: 'user' })
// Must resolve (not hang). A timeout makes the failure a clear test failure.
await Promise.race([
@@ -119,13 +118,13 @@ describe('Agent.cancel()', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
agent.cancel('mid-step')
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'mid-step' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => {
it('cancel() with no cause defaults to user when aborting an active turn', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -135,10 +134,10 @@ describe('Agent.cancel()', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.cancel() // no reason → default 'cancelled'
agent.cancel()
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => {
@@ -149,7 +148,7 @@ describe('Agent.cancel()', () => {
// First turn hangs; cancel it mid-step.
send(agent, 'first')
await new Promise(r => setTimeout(r, 30))
agent.cancel('cancel first')
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
// The marker must have been reset after the cancelled turn — a fresh prompt
@@ -174,7 +173,7 @@ describe('Agent.cancel()', () => {
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
agent.cancel('from prefix composition')
agent.cancel({ kind: 'user' })
return next()
})
@@ -185,7 +184,7 @@ describe('Agent.cancel()', () => {
await waitForIdle(ctx, agent)
expect(streamed).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from prefix composition' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('disposal from inside the agent/session-prefix waterfall ends the turn disposed (prefix-composition window)', async () => {
@@ -239,7 +238,7 @@ describe('Agent.cancel()', () => {
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
compositions += 1
if (compositions === 1) {
agent.cancel('mid-composition')
agent.cancel({ kind: 'user' })
return next()
}
return [opener, ...await next()]
@@ -262,12 +261,11 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// A turn/start listener fires before a step controller exists, so the
// turn-scoped marker—not step abort—must drop the pending step.
// The turn holder is already installed when turn/start is appended.
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/start') agent.cancel('from turn-start')
if (session === agent.session && event.type === 'turn/start') agent.cancel({ kind: 'user' })
})
const reasons: TurnEndReason[] = []
@@ -277,11 +275,9 @@ describe('Agent.cancel()', () => {
await waitForIdle(ctx, agent)
dispose()
// No step streamed (the model never ran), and the turn ended aborted with
// the CALLER's reason — the marker carries `cancel(reason)` through even
// though no AbortController observed it in this window.
// The turn closes as aborted after its single cancellation holder fires.
expect(streamed).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from turn-start' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => {
@@ -296,7 +292,7 @@ describe('Agent.cancel()', () => {
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'step/start') agent.cancel('from step-start')
if (session === agent.session && event.type === 'step/start') agent.cancel({ kind: 'user' })
})
const reasons: TurnEndReason[] = []
@@ -309,7 +305,7 @@ describe('Agent.cancel()', () => {
// No step streamed, the turn ended aborted with the caller's reason, and the
// log is balanced (the open step was closed by the cancel branch).
expect(streamed).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from step-start' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
const types = agent.session.events.map(e => e.type)
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
})
@@ -353,10 +349,8 @@ describe('Agent.cancel()', () => {
})
it('cancel during the continuation window ends the turn aborted and runs no further step', async () => {
// A continuation-waterfall listener cancels DURING the continuation decision
// (the finished step's AbortController is already cleared), and votes to
// continue — but the turn-scoped marker checked right after must end the turn
// `aborted` and run NO second step.
// A continuation-waterfall listener cancels during the continuation decision
// and votes to continue, but the turn signal remains authoritative.
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -369,11 +363,11 @@ describe('Agent.cancel()', () => {
})
let continued = false
ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => {
if (subject === agent && !continued) {
continued = true
agent.cancel('from continuation')
return { action: 'continue' as const } // vote to continue — the post-waterfall marker check must override
agent.cancel({ kind: 'user' })
return { action: 'continue' as const }
}
return next()
})
@@ -381,11 +375,9 @@ describe('Agent.cancel()', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
// Only ONE step ran (the second was cancelled in the continuation window),
// and the turn ended aborted with the CALLER's reason (carried by the
// marker, since the finished step's AbortController was already cleared).
// Only one step ran and the turn ended with the coarse aborted outcome.
expect(steps).toBe(1)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from continuation' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => {
@@ -398,7 +390,7 @@ describe('Agent.cancel()', () => {
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'running') agent.cancel('from running listener')
if (subject === agent && status === 'running') agent.cancel({ kind: 'user' })
})
send(agent, 'go')
@@ -411,6 +403,33 @@ describe('Agent.cancel()', () => {
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
})
it('disposal from a synchronous running listener stops before opening a turn', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = await harness(adapter)
const handle = await ctx.agents.create({
agentId: AgentId('dispose-running-listener'),
sessionId: SessionId('dispose-running-listener-session'),
agentOptions: { model: 'mock' },
})
const { agent } = handle
let disposalDone: Promise<void> | undefined
const disposalStarted = Promise.withResolvers<undefined>()
ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'running') {
disposalDone = handle.dispose()
disposalStarted.resolve(undefined)
}
})
agent.send([{ type: 'text', text: 'go' }])
await disposalStarted.promise
await disposalDone
expect(agent.status).toBe('disposed')
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
expect(adapter.requests).toHaveLength(0)
})
it('window 2: whenIdle() does NOT resolve early when a running listener cancels then queues replacement work', async () => {
// Cancellation must not settle idle while replacement work remains queued.
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
@@ -421,7 +440,7 @@ describe('Agent.cancel()', () => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject !== agent || status !== 'running' || replaced) return
replaced = true
agent.cancel('drop A')
agent.cancel({ kind: 'user' })
send(agent, 'B')
})
@@ -446,7 +465,7 @@ describe('Agent.cancel()', () => {
send(agent, 'A') // queues A (status still idle, loop microtask pending)
const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path)
agent.cancel('drop A') // arms marker, clears A
agent.cancel({ kind: 'user' }) // arms marker, clears A
send(agent, 'B') // B races in before the loop resumes
// whenIdle() must resolve only after B's turn fully ran — by which point B's user message
@@ -469,7 +488,7 @@ describe('Agent.cancel()', () => {
// Steer (joins the running turn's steering FIFO), then cancel: the steering
// must be dropped, NOT re-enqueued as a new queued turn.
agent.steer([{ type: 'text', text: 'steer text' }])
agent.cancel('cancel with steering')
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
// After the cancelled turn settles, the agent is idle with NO follow-up turn
@@ -485,4 +504,169 @@ describe('Agent.cancel()', () => {
.flatMap(b => b.type === 'text' ? [b.text] : [])
expect(flat).not.toContain('steer text')
})
it('keeps the first typed cause for an active turn and detaches the runtime reason', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('typed-first-wins'), { model: 'mock' })
const supplied: { kind: 'parent' | 'user' } = { kind: 'parent' }
send(agent, 'go')
await new Promise(resolve => setTimeout(resolve, 30))
agent.cancel(supplied)
supplied.kind = 'user'
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
const runtimeReason: unknown = adapter.requests[0]?.signal?.reason
expect(runtimeReason).toEqual({ kind: 'parent' })
expect(runtimeReason).not.toBe(supplied)
expect(Object.isFrozen(runtimeReason)).toBe(true)
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
})
it('rejects invalid causes synchronously while idle and running', async () => {
class Cause {
readonly kind = 'user'
}
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('invalid-cause'), { model: 'mock' })
const controller = new AbortController()
const invalid: unknown[] = [
'user',
{ kind: 'timeout' },
{ kind: 'user', detail: 'extra' },
new Error('cancelled'),
controller.signal,
new Cause(),
]
for (const value of invalid) expect(() => { agent.cancel(value as never) }).toThrow(TypeError)
send(agent, 'go')
await new Promise(resolve => setTimeout(resolve, 30))
for (const value of invalid) expect(() => { agent.cancel(value as never) }).toThrow(TypeError)
expect(agent.status).toBe('running')
agent.cancel()
await waitForIdle(ctx, agent)
})
it('records disposed when lifecycle teardown races an already-requested cancel', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const handle = await ctx.agents.create({
agentId: AgentId('cancel-dispose-race'),
sessionId: SessionId('cancel-dispose-race-session'),
agentOptions: { model: 'mock' },
})
const agent = handle.agent
agent.send([{ type: 'text', text: 'go' }])
await new Promise(resolve => setTimeout(resolve, 30))
agent.cancel({ kind: 'user' })
await handle.dispose()
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
})
it.each([
'prompt-submit',
'system-prompt',
'session-prefix',
'pre-step',
'request',
'step-result',
'turn-continuation',
'turn-stop',
'tool',
] as const)('lets a cooperative %s boundary settle from the explicit turn signal', async (stage) => {
const adapter = new MockAdapter(stage === 'tool'
? [toolCallResponse('blocked-tool', 'blocked', {})]
: [textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId(`cooperative-${stage}`), { model: 'mock' })
const started = Promise.withResolvers<undefined>()
const blockUntilAbort = async (signal: AbortSignal): Promise<void> => {
started.resolve(undefined)
if (signal.aborted) return
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
switch (stage) {
case 'prompt-submit':
ctx.on('agent/prompt-submit', async (subject, _content, _source, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
break
case 'system-prompt':
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
if (context.agent === agent) {
if (context.signal === undefined) throw new Error('turn assembly omitted its signal')
await blockUntilAbort(context.signal)
}
return next()
})
break
case 'session-prefix':
ctx.on('agent/session-prefix', async (subject, _prefix, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
break
case 'pre-step':
ctx.on('agent/pre-step', async (subject, _turn, _step, _system, _prefix, signal) => {
if (subject === agent) await blockUntilAbort(signal)
})
break
case 'request':
ctx.on('agent/request', async (subject, _turn, _step, _config, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
break
case 'step-result':
ctx.on('agent/step-result', async (subject, _turn, _step, _message, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
break
case 'turn-continuation':
ctx.on('agent/turn-continuation', async (subject, _turn, _decision, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
break
case 'turn-stop':
ctx.on('agent/turn-stop', async (subject, _turn, signal) => {
if (subject === agent) await blockUntilAbort(signal)
})
break
case 'tool':
ctx.tools.register(defineTool({
name: 'blocked',
description: 'wait for cancellation',
parameters: {},
execute: async (_args, exec) => {
if (exec.signal === undefined) throw new Error('tool execution omitted its signal')
await blockUntilAbort(exec.signal)
return [{ type: 'text', text: 'cancelled' }]
},
}))
break
}
send(agent, 'go')
await started.promise
const idle = waitForIdle(ctx, agent)
agent.cancel({ kind: 'user' })
await idle
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
await ctx.fiber.dispose()
})
})

View File

@@ -59,7 +59,7 @@ describe('session log records what agent/step-result actually produced', () => {
// Plugin rewrites the message: replaces the text AND adds a tool call.
let rewritten = false
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, next) => {
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _signal, next) => {
if (rewritten) return next()
rewritten = true
return {
@@ -92,7 +92,7 @@ describe('session log records what agent/step-result actually produced', () => {
})
describe('abort during tool execution ends the turn', () => {
it('aborting the in-flight step inside a tool prevents both remaining tools and the next model step', async () => {
it('cancelling the active turn inside a tool prevents both remaining tools and the next model step', async () => {
const adapter = new MockAdapter([
// model asks for two tool calls in one step
[
@@ -113,11 +113,7 @@ describe('abort during tool execution ends the turn', () => {
parameters: {},
async execute() {
executed.push('aborter')
// Fire the in-flight step's AbortController directly (the loop registers
// it on the agent). This is the bare step-abort path — distinct from
// cancel(), which would also clear the inbox; here the subject is the
// loop's response to its running step being aborted mid-tool.
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
agent.cancel({ kind: 'user' })
return [{ type: 'text', text: 'done' }]
},
}))
@@ -139,7 +135,7 @@ describe('abort during tool execution ends the turn', () => {
expect(executed).toEqual(['aborter']) // second tool never ran
expect(adapter.requests).toHaveLength(1) // no follow-up model call
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
})
@@ -153,7 +149,7 @@ describe('steering from late extension points is never stranded', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let steeredOnce = false
ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => {
ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, _signal, next) => {
if (!steeredOnce) {
steeredOnce = true
agent.steer([{ type: 'text', text: 'one more thing' }])
@@ -227,25 +223,19 @@ describe('steering from late extension points is never stranded', () => {
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('too late for this turn')
})
it('steering queued during an aborted step is re-delivered, not silently consumed', async () => {
const adapter = new MockAdapter(['hang', textResponse('recovered')])
it('steering queued before turn cancellation is discarded with the cancelled work', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.steer([{ type: 'text', text: 'redirect' }])
// Abort ONLY the in-flight step, via its AbortController directly — NOT
// cancel(), which clears the inbox and would drop the queued steering this
// test proves survives a step abort. There is no public step-only abort
// verb (cancel() is the only public stop primitive), so reach the private
// controller the loop registered.
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
// a new turn ran with the steering content delivered as a message
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('redirect')
expect(adapter.requests).toHaveLength(1)
expect(JSON.stringify(agent.session.events)).not.toContain('redirect')
})
})
@@ -383,7 +373,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model — router plugin decides
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => {
return { ...config, model: 'mock' }
})
@@ -843,7 +833,7 @@ describe('turn and step boundary recovery', () => {
it('disposal during a running turn ends the turn with reason disposed (balanced)', async () => {
// The 'hang' adapter blocks in stream() until the signal aborts; disposing
// the agent's fiber mid-turn aborts the in-flight step. The turn must close
// the agent's fiber mid-turn aborts the active turn. The turn must close
// balanced with reason disposed (no error event for a disposal).
const adapter = new MockAdapter(['hang'])
const ctx = await balancedHarness(adapter)
@@ -1085,7 +1075,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream
await ctx.plugin(Invariants)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _signal, _next) => ({
role: 'assistant' as const,
content: [{ type: 'text' as const, text: 'injected' }],
}))
@@ -1190,7 +1180,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 50))
agent.cancel('user cancelled during assembly')
agent.cancel({ kind: 'user' })
releaseAssemble()
await waitForIdle(ctx, agent)
@@ -1202,15 +1192,12 @@ describe('disposal and cancellation during pre-step assembly', () => {
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({
kind: 'aborted',
reason: 'user cancelled during assembly',
})
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
expect(adapter.requests).toHaveLength(0)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled during assembly' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => {
@@ -1297,7 +1284,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.cancel('user cancelled')
agent.cancel({ kind: 'user' })
releasePreStep()
await waitForIdle(ctx, agent)
@@ -1308,10 +1295,10 @@ describe('disposal and cancellation during pre-step assembly', () => {
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: 'user cancelled' })
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => {

View File

@@ -157,7 +157,7 @@ describe('toError normalization', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/request', async (_agent, _turn, _step, _options, _next) => {
ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, _next) => {
if (!threwOnce) {
threwOnce = true
throw { code: 500 } // non-Error throw, goes through runStep catch
@@ -185,7 +185,7 @@ describe('coded error data emission', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => {
ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, next) => {
if (!threwOnce) {
threwOnce = true
throw new LlmError('server overloaded', 'RATE_LIMIT')

View File

@@ -62,7 +62,7 @@ describe('agent/prompt-submit', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const seen: string[] = []
ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => {
ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next) => {
seen.push(content.map(b => (b.type === 'text' ? b.text : '')).join(''))
return next()
})
@@ -191,7 +191,7 @@ describe('agent/prompt-submit', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next): Promise<PromptDecision> => {
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next()
})
@@ -498,7 +498,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let forced = false
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise<ContinuationDecision> => {
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, _signal, next): Promise<ContinuationDecision> => {
if (!forced) {
forced = true
return { action: 'continue', reason: { content: [{ type: 'text', text: 'keep going on the goal' }], source: { kind: 'plugin', plugin: 'goal' } } }
@@ -626,7 +626,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
)
})
// 2. PromptSubmit: block a forbidden prompt, annotate the rest.
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next): Promise<PromptDecision> => {
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
if (text.includes('rm -rf')) return { kind: 'block', reason: 'destructive prompt blocked' }
return next()

View File

@@ -228,7 +228,7 @@ describe('agent loop', () => {
assembly.variables['model'] = 'mock'
return next()
})
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => {
return { ...config, model: 'mock' }
})
const agent = ctx.agentLoop.create(AgentId('a-late-model'), {})
@@ -429,7 +429,7 @@ describe('agent loop', () => {
let steps = 0
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, _signal, next) => {
if (steps < 3) return { action: 'continue' as const }
return next()
})
@@ -469,7 +469,7 @@ describe('agent loop', () => {
ctx.llm.registerAdapter(['other-model'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => {
// The seed is frozen — config is not a mutable per-call knob; a switch
// is proposed by returning a replacement, and the loop logs it.
expect(Object.isFrozen(config)).toBe(true)
@@ -601,10 +601,10 @@ describe('agent loop', () => {
// wait until the stream is hanging, then cancel
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
agent.cancel('user interrupt')
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('surfaces max-tokens as the turn-end reason when the last step is cut off', async () => {
@@ -641,7 +641,7 @@ describe('agent loop', () => {
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
// Force exactly one continuation (step 1 → step 2), then defer to default
// (step 2 is a plain stop with no tool calls → stops).
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, _signal, next) => {
if (steps < 2) return { action: 'continue' as const }
return next()
})
@@ -781,7 +781,7 @@ describe('agent loop', () => {
]])
const ctx = await harness(adapter)
let stepResults = 0
ctx.on('agent/step-result', async (_agent, _turn, _step, message, next) => {
ctx.on('agent/step-result', async (_agent, _turn, _step, message, _signal, next) => {
stepResults += 1
expect(message.content).toEqual([{ type: 'text', text: 'partial text' }])
return next()

View File

@@ -168,7 +168,7 @@ describe('request stability across the loop', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let injected = false
ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => {
ctx.on('agent/request', async (_agent, _turn, _step, _config, _signal, next) => {
if (!injected) {
injected = true
agent.inject([{ type: 'text', text: '[late context]' }], { source: { kind: 'plugin', plugin: 'test' } })
@@ -245,7 +245,7 @@ describe('request stability across the loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => {
ctx.on('agent/request', async (_agent, _turn, _step, _config, _signal, next) => {
const config = await next()
// next() resolves the SAME frozen seed — in-place shaping after
// delegation is unrepresentable, so a "mutate what next() returned"
@@ -282,7 +282,7 @@ describe('request stability across the loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'now with guidance' })
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => ({ ...config, temperature: 0.5, maxTokens: 99, stop: ['<END>'] }))
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => ({ ...config, temperature: 0.5, maxTokens: 99, stop: ['<END>'] }))
send(agent, 'again')
await waitForIdle(ctx, agent)

View File

@@ -198,7 +198,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
order.push('agent/created')
})
ctx.on('agent/session-start', (agent) => {
expect(() => { agent.cancel('now live') }).not.toThrow()
expect(() => { agent.cancel({ kind: 'user' }) }).not.toThrow()
order.push('agent/session-start')
})

View File

@@ -51,7 +51,7 @@ describe('agent/turn-stop', () => {
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
let steered = false
ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => {
const downstream = await next()
if (subject === agent && !steered) {
steered = true