fix(core): close turn cancellation contract gaps
This commit is contained in:
@@ -56,7 +56,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti
|
||||
|
||||
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
|
||||
|
||||
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, terminal stop, turn close, and flush; the next turn gets a fresh signal. `cancel()` strictly normalizes a runtime-only `user | parent` cause, clears pending work, and cooperatively aborts the holder without leaking to the next prompt; durable `turn/end` remains coarse `aborted`, and undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush.
|
||||
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, and terminal stop; it retires the holder immediately before publishing `turn/end`, while the driver may remain `running` through the durability flush. `cancel()` accepts the typed runtime-only `user | parent` cause, clears pending work, and cooperatively aborts the holder without leaking to the next prompt; durable `turn/end` remains coarse `aborted`, and undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush.
|
||||
|
||||
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { agentEvents, normalizeAgentCancelCause } from '@deepseek-ai/dsh-agent'
|
||||
import { agentEvents } 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, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
@@ -325,7 +325,7 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
cancel(cause?: AgentCancelCause): void {
|
||||
const normalized = normalizeAgentCancelCause(cause ?? { kind: 'user' })
|
||||
const reason = cause ?? { kind: 'user' }
|
||||
const cancellation = this.turnCancellation
|
||||
const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering)
|
||||
if (preRun) {
|
||||
@@ -334,7 +334,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// Clear work already present before abort observers run. A replacement
|
||||
// synchronously enqueued by an observer belongs to the next turn.
|
||||
this.#inbox.clear()
|
||||
cancellation?.request(normalized)
|
||||
cancellation?.request(reason)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,12 +20,12 @@ export class TurnCancellation {
|
||||
|
||||
/**
|
||||
* Abort the turn once.
|
||||
* @param reason - a validated caller cause or lifecycle disposal marker.
|
||||
* @param reason - a typed 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)
|
||||
this.#controller.abort(Object.freeze({ kind: reason.kind }))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export interface LoopHandle {
|
||||
setStatus(status: 'idle' | 'running'): void
|
||||
/** Install a fresh active-turn owner before the running notification. */
|
||||
installTurnCancellation(): TurnCancellation
|
||||
/** Clear only the exact owner whose turn and durability flush settled. */
|
||||
/** Clear only the exact owner whose turn reached its terminal event boundary. */
|
||||
clearTurnCancellation(cancellation: TurnCancellation): void
|
||||
/** Resolves when the agent is disposed — unblocks the idle wait. */
|
||||
disposed: Promise<void>
|
||||
@@ -209,7 +209,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
const turn = lastTurnNumber(session) + 1
|
||||
let terminalStopped = false
|
||||
try {
|
||||
terminalStopped = await runTurn(ctx, events, handle, turn, transmission, cancellation.signal)
|
||||
terminalStopped = await runTurn(ctx, events, handle, turn, transmission, cancellation)
|
||||
} catch (error: unknown) {
|
||||
// Pre-turn failure has no durable boundary to close; report it without appending outside a turn.
|
||||
const err = toError(error)
|
||||
@@ -232,10 +232,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
|
||||
async function runTurn(
|
||||
ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog,
|
||||
signal: AbortSignal,
|
||||
cancellation: TurnCancellation,
|
||||
): Promise<boolean> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session } = agent
|
||||
const { signal } = cancellation
|
||||
const drainSteering = (): boolean => {
|
||||
const messages = handle.inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
@@ -279,8 +280,11 @@ async function runTurn(
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-commit validation failure escapes rather than masquerading as a committed boundary.
|
||||
// Retire cancellation authority before publishing the terminal event. The
|
||||
// following durability flush is quiescent turn work, but no longer part of
|
||||
// the cancellable turn lifetime.
|
||||
const closeTurn = (): void => {
|
||||
handle.clearTurnCancellation(cancellation)
|
||||
session.append('turn/end', { turn, reason })
|
||||
}
|
||||
|
||||
|
||||
@@ -779,30 +779,43 @@ describe('Agent.cancel()', () => {
|
||||
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'])
|
||||
it('retires turn cancellation before terminal publication and a blocked durability flush', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('invalid-cause'), { provider: 'mock', 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)
|
||||
const agent = ctx.agentLoop.create(SessionId('terminal-cancellation-authority'), { provider: 'mock', model: 'mock' })
|
||||
const flushStarted = Promise.withResolvers<undefined>()
|
||||
const releaseFlush = Promise.withResolvers<undefined>()
|
||||
let abortedDuringTurnEnd: boolean | undefined
|
||||
|
||||
send(agent, 'go')
|
||||
await expect.poll(() => adapter.requests.length).toBe(1)
|
||||
for (const value of invalid) expect(() => { agent.cancel(value as never) }).toThrow(TypeError)
|
||||
expect(agent.status).toBe('running')
|
||||
agent.cancel()
|
||||
await waitForIdle(ctx, agent)
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session || event.type !== 'turn/end') return
|
||||
const signal = adapter.requests[0]?.signal
|
||||
if (signal === undefined) throw new Error('model request omitted its turn signal')
|
||||
agent.cancel({ kind: 'user' })
|
||||
abortedDuringTurnEnd = signal.aborted
|
||||
})
|
||||
ctx.on('session/flush', async (session) => {
|
||||
if (session !== agent.session) return
|
||||
flushStarted.resolve(undefined)
|
||||
await releaseFlush.promise
|
||||
})
|
||||
|
||||
send(agent, 'finish before persistence drains')
|
||||
await flushStarted.promise
|
||||
const signal = adapter.requests[0]?.signal
|
||||
if (signal === undefined) throw new Error('model request omitted its turn signal')
|
||||
const idle = agent.whenIdle()
|
||||
agent.cancel({ kind: 'user' })
|
||||
|
||||
expect(abortedDuringTurnEnd).toBe(false)
|
||||
expect(signal.aborted).toBe(false)
|
||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
|
||||
data: { reason: { kind: 'completed' } },
|
||||
})
|
||||
|
||||
releaseFlush.resolve(undefined)
|
||||
await idle
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('records disposed when lifecycle teardown races an already-requested cancel', async () => {
|
||||
|
||||
Reference in New Issue
Block a user