refactor(agent): fold agentInterruptReasonOf into loop-private slot invariants

The public classifier existed to defend an exported reader against
arbitrary signals, but its only production caller is the loop reading
its own machine-private turn signal, where cancel() is the sole aborter
and always writes one frozen canonical cause. Delete the export and its
15-line structural validation: settle() states the slot invariant with
one cast, the boolean call sites ask signal.aborted directly, and the
retry veto drops entirely because a requested window already implies a
live signal (cancel() retires the window before aborting).

The abort(reason) channel and first-wins semantics are unchanged; only
the reader's publicness is gone, and with it the paranoia it required.
This commit is contained in:
_Kerman
2026-07-26 20:37:07 +08:00
parent 2985bc5267
commit 338da9f2e0
11 changed files with 22 additions and 77 deletions

View File

@@ -9,7 +9,7 @@
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import { AgentMessageId, agentCarrier, agentInterruptReasonOf, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
import { AgentMessageId, agentCarrier, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import type {
@@ -234,7 +234,7 @@ export class ReactLoopAgent implements Agent {
}
}
} catch (error: unknown) {
if (agentInterruptReasonOf(signal) === undefined) {
if (!signal.aborted) {
this.loopCtx.logger.warn(`agent "${this.id}": prompt admission failed: ${errorChain(error)}`)
}
}
@@ -317,7 +317,7 @@ export class ReactLoopAgent implements Agent {
// and before its own step/end, so the step is always open here.
this.stepOpen = false
this.session.append('step/end', { turn, step })
if (agentInterruptReasonOf(signal) === undefined) {
if (!signal.aborted) {
const retryWindow = { requested: false }
this.retryWindow = retryWindow
let recoveryCompleted = false
@@ -338,9 +338,10 @@ export class ReactLoopAgent implements Agent {
// start, so unconditional retirement is exact.
this.retryWindow = undefined
}
retry = recoveryCompleted
&& agentInterruptReasonOf(signal) === undefined
&& retryWindow.requested
// A requested retry implies the signal is still live: cancel()
// retires the window before it aborts, and retry() refuses to
// arm a window whose signal already aborted.
retry = recoveryCompleted && retryWindow.requested
}
const settlement = this.settle(turn, step, outcome.error, signal, outcome.failure)
reason = settlement.reason
@@ -587,8 +588,11 @@ export class ReactLoopAgent implements Agent {
signal: AbortSignal,
failure?: LlmFailure,
): { reason: TurnEndReason; idle: IdleReason } {
const interrupt = agentInterruptReasonOf(signal)
if (interrupt !== undefined) {
if (signal.aborted) {
// Slot invariant, stated rather than re-validated: the turn controller
// is machine-private and cancel() is its only aborter, always with one
// frozen canonical cause as the reason.
const interrupt = signal.reason as AgentInterruptReason
return { reason: { kind: interrupt.kind === 'disposed' ? 'disposed' : 'aborted' }, idle: { kind: 'aborted' } }
}
if (failure !== undefined) {

View File

@@ -1,30 +0,0 @@
/** Runtime reason inspection for explicit turn cancellation. @module @deepseek-ai/dsh-agent/cancellation */
import type { AgentInterruptReason } from './types.ts'
/**
* Read a supported agent interruption from an explicitly supplied signal.
* Unknown reasons return `undefined`; ambient initiator identity does not grant
* cancellation authority.
* @param signal - the current turn's explicit control signal.
* @returns its canonical reason, or `undefined` while live or unsupported.
*/
export function agentInterruptReasonOf(signal: AbortSignal): AgentInterruptReason | undefined {
if (!signal.aborted) return undefined
const reason: unknown = signal.reason
if (typeof reason !== 'object' || reason === null || Array.isArray(reason)) return undefined
const prototype = Object.getPrototypeOf(reason) as unknown
const keys = Reflect.ownKeys(reason)
if ((prototype !== Object.prototype && prototype !== null)
|| keys.length !== 1 || keys[0] !== 'kind') return undefined
switch ((reason as { readonly kind?: unknown }).kind) {
case 'user':
return Object.freeze({ kind: 'user' })
case 'parent':
return Object.freeze({ kind: 'parent' })
case 'disposed':
return Object.freeze({ kind: 'disposed' })
default:
return undefined
}
}

View File

@@ -15,7 +15,6 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { Agent, AgentOptions } from './types.ts'
export * from './types.ts'
export { agentInterruptReasonOf } from './cancellation.ts'
export * from './llm-target.ts'
export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'

View File

@@ -5,7 +5,6 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, {
AgentMessageId,
agentEvents,
agentInterruptReasonOf,
} from '@deepseek-ai/dsh-agent'
import type {
@@ -187,38 +186,11 @@ describe('agentEvents()', () => {
})
})
describe('explicit cancellation helpers', () => {
describe('explicit cancellation contract', () => {
it('exposes the closed typed cancellation cause at the Agent seam', () => {
expectTypeOf<Parameters<Agent['cancel']>[0]>().toEqualTypeOf<AgentCancelCause>()
expectTypeOf<Parameters<Events['agent/cancel-requested']>[1]>().toEqualTypeOf<AgentCancelCause>()
})
it('reads only supported reasons from an explicit signal', () => {
const read = (reason: unknown) => {
const controller = new AbortController()
controller.abort(reason)
return agentInterruptReasonOf(controller.signal)
}
const live = new AbortController()
expect(agentInterruptReasonOf(live.signal)).toBeUndefined()
expect(read({ kind: 'user' })).toEqual({ kind: 'user' })
expect(read({ kind: 'parent' })).toEqual({ kind: 'parent' })
const disposed = new AbortController()
disposed.abort(Object.assign(Object.create(null) as object, { kind: 'disposed' }))
const disposedReason = agentInterruptReasonOf(disposed.signal)
expect(disposedReason).toEqual({ kind: 'disposed' })
expect(Object.isFrozen(disposedReason)).toBe(true)
expect(read(null)).toBeUndefined()
expect(read([])).toBeUndefined()
expect(read('private runtime reason')).toBeUndefined()
expect(read(new Error('private runtime reason'))).toBeUndefined()
expect(read({ kind: 'user', detail: true })).toBeUndefined()
expect(read({ other: 'user' })).toBeUndefined()
expect(read({ kind: 'timeout' })).toBeUndefined()
})
})
describe('AgentRegistry factory seam', () => {