Merge refreshed rfc/pty into feature/persistent-pty-sessions

# Conflicts:
#	docs/architecture.md
#	docs/capability-seams.md
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/module-graph.md
#	docs/tool-catalog.md
#	examples/acp-agent/tests/acp.snapshot.ts
#	examples/headless-agent/tests/headless.snapshot.ts
#	examples/package.json
#	packages/README.md
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/tools/tests/gen-tool-catalog.spec.ts
#	pnpm-lock.yaml
#	pnpm-workspace.yaml
#	scripts/gen-tool-catalog.ts
#	scripts/type-equiv.manifest.json
#	website/.vitepress/config/api-sidebar.json
This commit is contained in:
Tianyi Cui
2026-07-22 21:12:16 +08:00
1856 changed files with 106649 additions and 23384 deletions

View File

@@ -15,4 +15,4 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop. It runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent`, including when they need the initiating Agent, and never on `agent-loop` directly, so the loop stays swappable.
The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door.
The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + fallback session titles + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door.

View File

@@ -27,6 +27,10 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
`agents`, `sessions`, `llm`, `tools`, `systemPrompt` — all five interface services.
### Invariant companion
The optional `@deepseek-ai/dsh-agent-loop/invariant` companion registers request reconstruction with `ctx.invariants`. The loop records each exact frozen request in the process-local identity set owned by `dsh-llm`; the companion then requires a live session and independently rebuilds the message boundary and folded request header from the log. Direct one-shot calls remain outside this contract even when callers freeze them or attach a session id.
### Configuration (schemastery)
```ts
@@ -46,7 +50,9 @@ Configured agents start automatically. A model call requires both `provider` and
### Internal concrete driver
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. The concrete `send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while the current step executes assistant tool calls stays in a FIFO until the batch settles; successful batches place it after the complete result batch, and interrupted batches drain it before the turn closes. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
Each concrete `send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; a successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, or a pre-start failure may drop it without a turn. Running `steer()` enters the steering FIFO: an open turn records it at the next steering checkpoint before a request or continuation decision, but policy can still stop before another step; steering left after turn close and its checkpoint becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append.
### Loop lifecycle (`loop.ts`)
@@ -54,7 +60,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 observes a closed failed step, and a retry rebuilds the request from the durable log in a new numbered step. Cancellation clears pending work and aborts the current step without leaking to the next prompt; undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. 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. An effective `cancel()` emits the typed runtime-only `user | parent` cause before clearing pending work and cooperatively aborting the holder; notification failures cannot veto cancellation, work queued by a notification observer is cleared, work queued by a later abort observer belongs to the next turn, and idle cancellation emits nothing. Durable `turn/end` remains coarse `aborted`; 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.
@@ -63,6 +69,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute``tools/execute``tools/post-execute``tools/result` pipeline; exact signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md)
- Compaction: pressure on `agent/post-step`; canonical context overflow on `agent/request-error`
- Transient model recovery: `dsh-llm-retry` on `agent/request-error`, with finite code-specific budgets and non-surface `llm/retry` status events
- Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection.
- Persistence: `session/event` + `session/flush`
@@ -102,7 +109,7 @@ Ordinary history growth is append-only and preserves reusable entries. A surface
#### What the model sees
If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has the error result text `Error: tool call skipped because the step was aborted before execution`.
If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has error code `ABORTED_BEFORE_DISPATCH` and result text `Error: tool call aborted before dispatch`.
#### Token effect

View File

@@ -11,10 +11,15 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -22,6 +27,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",

View File

@@ -8,11 +8,12 @@
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { 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, 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'
@@ -80,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
@@ -96,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), the per-step AbortController, 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.
*/
@@ -121,21 +121,14 @@ export class ReactLoopAgent implements Agent {
}
private _status: AgentStatus = 'idle'
private currentAbort: AbortController | undefined
/** 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
/**
* 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.
*/
private cancelRequested = false
/** Pending cancellation reason, preserved even outside an active step signal. */
private cancelReason = 'cancelled'
/** Cause-less marker for queued work cancelled before the driver installs a turn owner. */
private preRunCancelled = false
private disposed: Promise<void>
private resolveDisposed!: () => void
/** Resolves when the driver loop has fully exited (tests/disposal). */
@@ -290,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)
@@ -331,24 +324,21 @@ 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 {
const resolvedCause = cause ?? { kind: 'user' }
const cancellation = this.turnCancellation
const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering)
if (cancellation !== undefined || preRun) {
if (preRun) this.preRunCancelled = true
// Coordination consumers must update their own state before this call
// clears the inbox or aborts the turn. Notification failures are
// contained by the fused dispatcher and cannot veto cancellation.
agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause)
}
// 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.
// Clear work already present before abort observers run. A replacement
// synchronously enqueued by an observer belongs to the next turn.
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')
cancellation?.request(resolvedCause)
}
/**
@@ -390,14 +380,21 @@ export class ReactLoopAgent implements Agent {
inbox: this.#inbox,
maxParallelToolCalls: this.maxParallelToolCalls,
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 driver clears only the exact owner 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 },
isPreRunCancelled: () => this.preRunCancelled,
clearPreRunCancel: () => { this.preRunCancelled = false },
withToolBatch: run => this.withToolBatch(run),
// Pre-step cancellation re-parks without emitting a status transition.
// Pre-run cancellation settles queued-work waiters before publishing idle.
settleIdle: () => { this.settleIdleWaiters() },
}))
}
@@ -415,7 +412,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) {
@@ -444,8 +441,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

@@ -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 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(Object.freeze({ kind: reason.kind }))
return true
}
}

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()`.
*/
@@ -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
}
@@ -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

@@ -20,7 +20,7 @@ import type {
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'
@@ -41,15 +41,6 @@ const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
FiberState.FAILED,
])
/** Render an arbitrary thrown value without letting coercion escape containment. */
function renderThrown(value: unknown): string {
try {
return String(value)
} catch {
return '<unrenderable thrown value>'
}
}
/** Factory-level ownership of every preparing or live transaction. */
class FactoryOwnership {
private accepting = true
@@ -475,16 +466,16 @@ export class AgentLoop extends Service implements AgentFactory {
error: unknown,
): void {
if (!this.ownership.isActive()) return
this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${renderThrown(error)}`)
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: ${renderThrown(listenerError)}`)
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: ${renderThrown(listenerError)}`)
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${errorChain(listenerError)}`)
}
}
}
@@ -620,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

@@ -0,0 +1,75 @@
/**
* Package-owned request-reconstruction invariant for loop-built LLM calls.
* @module @deepseek-ai/dsh-agent-loop/invariant
*/
import type { Context } from 'cordis'
import { isAgentLoopRequest, type GenerateOptions } from '@deepseek-ai/dsh-llm'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop'
/** Cordis companion plugin name. */
export const name = 'agent-loop-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Install the request-reconstruction contribution into its child registration fiber. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
// Prepend prevents a short-circuiting replay listener from silencing the
// check; correctness itself comes from the sequence-bounded reconstruction.
ctx.on('llm/stream', (options: GenerateOptions, next) => {
if (!isAgentLoopRequest(options)) return next()
if (!Object.isFrozen(options)) fail('a loop-built request must be frozen')
if (options.sessionId === undefined) fail('a loop-built request must carry a session id')
const session = ctx.sessions.get(options.sessionId)
if (!session) fail(`a loop-built request must carry a live session id, got "${String(options.sessionId)}"`)
if (!Object.isFrozen(options.messages)) {
fail('a loop-built request must carry a frozen messages array')
}
const events = session.events
let boundary = -1
for (let index = events.length - 1; index >= 0; index -= 1) {
if (events[index]?.type === 'step/start') {
boundary = index
break
}
}
if (boundary === -1) {
return fail('a loop-built request with no step/start in its session log')
}
const header = foldRequestHeader(events)
if (header === undefined) {
return fail('a loop-built request with no request/header event in its session log')
}
const rebuilt = new Session(
SessionId(`${String(session.id)}-invariant-rebuild`),
structuredClone(events.slice(0, boundary)),
)
const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()]
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
fail(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`)
}
const headerMatches = options.model === header.config.model
&& options.system === header.system
&& options.temperature === header.config.temperature
&& options.maxTokens === header.config.maxTokens
&& JSON.stringify(options.stop) === JSON.stringify(header.config.stop)
&& JSON.stringify(options.tools ?? []) === JSON.stringify(header.tools ?? [])
if (!headerMatches) {
fail(`llm request for session "${String(session.id)}" diverges from the folded request header`)
}
return next()
}, { global: true, prepend: true })
}, { inject: ['sessions'] })
/**
* Register the agent-loop invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -6,10 +6,10 @@
*/
import type { Context } from 'cordis'
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
import { isDeepStrictEqual } from 'node:util'
import { BlockAssembler, HarnessError, assertNever, deepFreeze, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm'
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import { agentEvents, agentInterruptReasonOf, assembleContextFor } 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'
@@ -20,6 +20,7 @@ import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
import type { Inbox } from './inbox.ts'
import type { TurnCancellation } from './cancellation.ts'
/** Normalize thrown values while preserving an existing error code. */
function toError(error: unknown): RequestError {
@@ -28,24 +29,29 @@ function toError(error: unknown): RequestError {
/** Distinguishes final model-request failures from failures in later step processing. */
class TerminalModelRequestFailure extends Error {
constructor(readonly requestError: RequestError) {
super(requestError.message, { cause: requestError })
constructor(
readonly requestError: RequestError,
readonly failure: LlmFailure,
) {
super(failure.message, { cause: requestError })
this.name = 'TerminalModelRequestFailure'
}
}
/** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */
function finishError(finish: FinishReason): RequestError | undefined {
function finishError(finish: FinishReason): { error: RequestError; failure: LlmFailure } | undefined {
switch (finish.kind) {
case 'error': {
const error: RequestError = new Error(finish.message)
if (finish.code !== undefined) error.code = finish.code
return error
}
case 'error':
case 'aborted': {
const error: RequestError = new Error('model stream aborted')
error.code = 'ABORTED'
return error
const facts = finish.failure
const error = new LlmError(facts.message, facts.code, {
...facts.status === undefined ? {} : { status: facts.status },
...facts.providerRetryAfterMs === undefined
? {}
: { providerRetryAfterMs: facts.providerRetryAfterMs },
...facts.requestId === undefined ? {} : { requestId: facts.requestId },
})
return { error, failure: error.failure }
}
// stop / tool-calls / max-tokens / plugin-added kinds → not a failure.
default:
@@ -56,9 +62,18 @@ function finishError(finish: FinishReason): RequestError | 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: RequestError): { message: string; code?: string } {
return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} }
return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} }
}
/** Preserve cause diagnostics, falling back to adapter-normalized prose for a hostile Error. */
function durableFailure(err: RequestError, failure: LlmFailure): LlmFailure {
const message = errorChain(err)
return { ...failure, message: message === '<unrenderable value>' ? failure.message : message }
}
/** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */
@@ -74,6 +89,32 @@ 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')
}
}
/** Mutable agent controls supplied to the loop driver. */
export interface LoopHandle {
/** Native-private agent inbox handed to the driver only at internal startup. */
@@ -81,31 +122,32 @@ export interface LoopHandle {
/** Maximum parallel-safe calls allowed in one step. */
readonly maxParallelToolCalls: number
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 reached its terminal event boundary. */
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
/** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */
/** 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 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. The caller establishes the
* `ctx.agents.withInitiator()` boundary before entry; package-private
* 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 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.
* @throws when no initiating Agent is active.
*/
export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
@@ -118,48 +160,67 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
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.
if (handle.isCancelled()) {
handle.clearCancel()
// 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 can cancel before `runTurn`; balance the
// status only when no replacement prompt was queued by that listener.
if (handle.isCancelled()) {
handle.clearCancel()
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, handle, turn, transmission)
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)
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 */ }
} 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)
@@ -171,9 +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,
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) {
@@ -182,16 +245,15 @@ async function runTurn(
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 requestFailureHistory: readonly LlmFailure[] = Object.freeze([])
let stepOpen = false
let errorReported = false
let terminalStopped = false
@@ -204,10 +266,12 @@ async function runTurn(
}
// Record the durable turn failure once and contain the live error notification.
const failTurn = (err: RequestError): void => {
const failTurn = (err: RequestError, failure?: LlmFailure): void => {
if (errorReported) return
errorReported = true
reason = { kind: 'error', step, ...errorData(err) }
reason = failure === undefined
? { kind: 'error', step, ...errorData(err) }
: { kind: 'error', step, failure: durableFailure(err, failure) }
try {
events.emit('agent/error', turn, step, err)
} catch {
@@ -216,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 })
}
@@ -226,42 +293,28 @@ 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 })
// 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.
interruptionCheckpoint(signal)
// 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,
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
)
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 or metadata.
for (const context of decision.additionalContexts ?? []) {
for (const context of promptDecision.additionalContexts ?? []) {
agent.inject(context.content, {
source: context.source,
...context.meta !== undefined ? { meta: context.meta } : {},
@@ -270,64 +323,36 @@ async function runTurn(
}
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()
// 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 listener work and the request share one prompt value.
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 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(
'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 before snapshotting history.
await events.serial('agent/pre-step', turn, step, 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, signal)
interruptionCheckpoint(signal)
// Snapshot the exact log prefix before step/start: the reconstruction
// boundary. Appends after this synchronous snapshot join the next request.
@@ -339,27 +364,19 @@ async function runTurn(
// 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 }
| { requestError: RequestError }
| { requestError: RequestError; failure: LlmFailure }
| { error: RequestError }
try {
stepOutcome = await runStep(
ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal)
} catch (error: unknown) {
if (error instanceof TerminalModelRequestFailure) {
stepOutcome = { requestError: error.requestError }
stepOutcome = { requestError: error.requestError, failure: error.failure }
} else {
stepOutcome = { error: toError(error) }
}
@@ -369,11 +386,9 @@ async function runTurn(
// Recovery observes a balanced failed step and the original provider
// error while the failed step's signal remains the active owner.
closeStep()
if (handle.isDisposed() || abort.signal.aborted) {
handle.setAbort(undefined)
reason = handle.isDisposed()
? { kind: 'disposed' }
: { kind: 'aborted', reason: String(abort.signal.reason) }
const interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted !== undefined) {
reason = interrupted
break
}
@@ -382,31 +397,27 @@ async function runTurn(
try {
recoveryDecision = await events.waterfall(
'agent/request-error', turn, step, stepOutcome.requestError,
requestRetryAttempt, abort.signal,
stepOutcome.failure, requestFailureHistory, signal,
() => Promise.resolve(defaultDecision),
)
} catch (recoveryError: unknown) {
ctx.logger.warn(
`agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${toError(recoveryError).message}`,
`agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
)
}
handle.setAbort(undefined)
// Cancellation and disposal always win over either a recovery decision
// or a recovery-listener failure.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (handle.isDisposed() || abort.signal.aborted) {
reason = handle.isDisposed()
? { kind: 'disposed' }
: { kind: 'aborted', reason: String(abort.signal.reason) }
const recoveryInterrupted = interruptionTurnEndReason(handle, signal)
if (recoveryInterrupted !== undefined) {
reason = recoveryInterrupted
break
}
switch (recoveryDecision.action) {
case 'retry':
requestRetryAttempt += 1
requestFailureHistory = Object.freeze([...requestFailureHistory, stepOutcome.failure])
continue
case 'fail':
failTurn(stepOutcome.requestError)
failTurn(stepOutcome.requestError, stepOutcome.failure)
break
/* v8 ignore next -- closed-union exhaustiveness guard */
default:
@@ -420,21 +431,14 @@ async function runTurn(
// runLoop re-enqueues it as a queued message, so an abort-then-steer
// starts a fresh turn instead of being silently consumed.
closeStep()
handle.setAbort(undefined)
const { error } = stepOutcome
/* v8 ignore next -- narrow race: disposal while non-request step work throws. */
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 interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted === undefined) failTurn(error)
else reason = interrupted
break
}
requestRetryAttempt = 0
requestFailureHistory = Object.freeze([])
// Preserve max-token completion unless a later disposal, abort, or error wins.
const stepReason = stepFinishReason(stepOutcome.finish)
@@ -444,48 +448,40 @@ async function runTurn(
const steered = drainSteering()
try {
await events.serial('agent/post-step', turn, step, abort.signal)
await events.serial('agent/post-step', turn, step, signal)
} catch (error: unknown) {
stepOutcome = { error: toError(error) }
}
if ('error' in stepOutcome) {
closeStep()
handle.setAbort(undefined)
/* v8 ignore next -- narrow race: disposal while a post-step listener throws. */
if (handle.isDisposed()) {
reason = { kind: 'disposed' }
} else if (abort.signal.aborted) {
/* v8 ignore next -- signal.reason always set by cancellation or disposal. */
reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
} else {
failTurn(stepOutcome.error)
}
const interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted === undefined) failTurn(stepOutcome.error)
else reason = interrupted
break
}
if (handle.isDisposed() || abort.signal.aborted) {
reason = handle.isDisposed()
? { kind: 'disposed' }
: { kind: 'aborted', reason: String(abort.signal.reason) }
const postStepInterrupted = interruptionTurnEndReason(handle, signal)
if (postStepInterrupted !== undefined) {
reason = postStepInterrupted
closeStep()
handle.setAbort(undefined)
break
}
closeStep()
handle.setAbort(undefined)
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 interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted === undefined) failTurn(toError(error))
else reason = interrupted
break
}
@@ -501,12 +497,15 @@ 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 interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted === undefined) failTurn(toError(error))
else reason = interrupted
break
}
if (terminalStop) {
@@ -516,17 +515,7 @@ 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' }
break
}
if (!shouldContinue) break
}
// Normal / inline-error loop exit: close the turn.
@@ -536,12 +525,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 interrupted = interruptionTurnEndReason(handle, signal)
if (interrupted === undefined) failTurn(toError(error))
else reason = interrupted
closeTurn()
}
@@ -551,7 +537,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 {
@@ -590,7 +576,10 @@ 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, () => 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`)
}
@@ -608,7 +597,7 @@ async function runStep(
recordRequestHeader(session, transmission, header)
// Freeze the logged header plus boundary snapshot; the prefix precedes derived history.
const request: GenerateOptions = deepFreeze({
const request: GenerateOptions = markAgentLoopRequest(deepFreeze({
provider: header.config.provider,
model: header.config.model,
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
@@ -619,7 +608,7 @@ async function runStep(
...header.config.stop !== undefined ? { stop: header.config.stop } : {},
sessionId: session.id,
signal,
})
}))
// --- Model call (streaming-first; raw chunks are the replay record) ---
const assembler = new BlockAssembler()
@@ -627,20 +616,21 @@ async function runStep(
const stream = ctx.llm.stream(request)
try {
for await (const chunk of stream) {
/* 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)
}
} catch (error: unknown) {
if (isLlmAdapterFailure(stream, error)) throw new TerminalModelRequestFailure(error)
const failure = llmFailureOf(stream, error)
if (failure !== undefined && error instanceof Error) throw new TerminalModelRequestFailure(error, failure)
throw error
}
interruptionCheckpoint(signal)
// Normalize failure finish chunks into the same path as thrown stream errors.
const stepError = finishError(assembler.finish)
if (stepError) throw new TerminalModelRequestFailure(stepError)
if (stepError) throw new TerminalModelRequestFailure(stepError.error, stepError.failure)
const recordAssistantMessage = (
assembledContent: ContentBlock[],
@@ -667,9 +657,11 @@ async function runStep(
// A rejected result still records the successful provider call without retaining rejected output.
const processStepResult = async (assembledContent: ContentBlock[], message: Message): Promise<Message> => {
try {
return await events.waterfall(
'agent/step-result', turn, step, message, () => Promise.resolve(message),
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

View File

@@ -13,7 +13,7 @@ import type { Context } from 'cordis'
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 { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
/** One tool call after argument parsing, ready to schedule. */
interface PlannedCall {
@@ -217,9 +217,9 @@ async function runGroup(
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' }],
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
}, callSeq)
}

View File

@@ -9,6 +9,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
const testToolSignal = new AbortController().signal
interface Harness {
ctx: Context
agentsFiber: Fiber
@@ -142,6 +144,80 @@ describe('AgentLoop initiator scope', () => {
await ctx.fiber.dispose()
})
it('keeps initiator 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(SessionId('signal-owner'), { provider: 'mock', model: 'mock' })
let signals: AbortSignal[] = []
const capture = (signal: AbortSignal | undefined): void => {
if (signal === undefined) throw new Error('turn seam omitted its explicit signal')
expect(ctx.agents.requireInitiator()).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, 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.agents.currentInitiator()).toBeUndefined()
await ctx.fiber.dispose()
})
it('keeps child setup under the parent boundary and restores the parent while the child driver remains active', async () => {
const adapter = new MockAdapter([
toolCallResponse('spawn', 'spawn-child', {}),
@@ -239,6 +315,7 @@ describe('AgentLoop initiator scope', () => {
}))
const direct = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('direct'),
name: 'agentless-probe',
arguments: {},

View File

@@ -345,7 +345,7 @@ describe('Agent', () => {
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,18 +1,17 @@
/**
* 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 signal reset and `whenIdle()` quiescence.
* @module dsh-agent-loop/tests/cancel
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
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, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -55,6 +54,33 @@ function userTexts(agent: Agent): string[] {
}
describe('Agent.cancel()', () => {
it('notifies every observer before clearing work and contains listener failures', async () => {
const adapter = new MockAdapter([textResponse('must remain unused')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('cancel-event'), { provider: 'mock', model: 'mock' })
const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: string[] = []
ctx.on('agent/cancel-requested', (subject, cause) => {
if (subject !== agent) return
seen.push(`first:${cause.kind}`)
subject.send([{ type: 'text', text: 'queued by cancel observer' }])
throw new Error('observer failed')
})
ctx.on('agent/cancel-requested', (subject, cause) => {
if (subject === agent) seen.push(`second:${cause.kind}`)
})
send(agent, 'drop me')
agent.cancel()
await new Promise(resolve => setTimeout(resolve, 30))
agent.cancel({ kind: 'parent' })
expect(seen).toEqual(['first:user', 'second:user'])
expect(userTexts(agent)).toEqual([])
expect(adapter.requests).toHaveLength(0)
expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested'))
})
it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
@@ -62,7 +88,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)
@@ -79,8 +105,9 @@ 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')
send(agent, 'drop me first')
send(agent, 'drop me second')
agent.cancel({ kind: 'user' })
// Give the loop a chance to wake and process the cancel.
await new Promise(r => setTimeout(r, 30))
@@ -91,6 +118,35 @@ describe('Agent.cancel()', () => {
expect(agent.status).toBe('idle')
})
it('disposal from the running notification drops queued work before turn start', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('dispose-running-session'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const agent = handle.agent
const running = Promise.withResolvers<undefined>()
let disposalDone: Promise<void> | undefined
ctx.on('agent/status', (subject, status) => {
if (subject !== agent || status !== 'running') return
disposalDone = handle.dispose()
running.resolve(undefined)
})
send(agent, 'drop before claim')
await running.promise
if (disposalDone === undefined) throw new Error('running listener did not start disposal')
await disposalDone
await driverDone(agent)
expect(agent.status).toBe('disposed')
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
expect(userTexts(agent)).toEqual([])
expect(adapter.requests).toHaveLength(0)
})
it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => {
const adapter = new MockAdapter([textResponse('x')])
const ctx = await harness(adapter)
@@ -100,7 +156,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([
@@ -110,7 +166,162 @@ describe('Agent.cancel()', () => {
expect(agent.status).toBe('idle')
})
it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => {
it('cancel() between consecutive turns restores idle and leaves idle steer usable', async () => {
const adapter = new MockAdapter([textResponse('first reply'), textResponse('steer reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('between-turn-cancel'), { provider: 'mock', model: 'mock' })
let rejectFirstFlush = true
ctx.on('session/flush', (session) => {
if (session !== agent.session || !rejectFirstFlush) return
rejectFirstFlush = false
throw new Error('first flush failed')
})
const cancelled = Promise.withResolvers<undefined>()
ctx.on('agent/error', (subject, _turn, _step, error) => {
if (subject !== agent || error.message !== 'first flush failed') return
// The first hop runs before runLoop resumes from runTurn; the second lands
// before its resolved waitForQueued continuation checks cancellation.
queueMicrotask(() => {
queueMicrotask(() => {
agent.cancel({ kind: 'user' })
cancelled.resolve(undefined)
})
})
})
const statuses: string[] = []
ctx.on('agent/status', (subject, status) => {
if (subject === agent) statuses.push(status)
})
send(agent, 'first')
send(agent, 'queued tail')
await cancelled.promise
expect(agent.status).toBe('idle')
expect(statuses).toEqual(['running', 'idle'])
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(userTexts(agent)).toEqual(['first'])
let idleResolved = false
void agent.whenIdle().then(() => { idleResolved = true })
await Promise.resolve()
expect(idleResolved).toBe(true)
const idle = waitForIdle(ctx, agent)
agent.steer([{ type: 'text', text: 'idle steer' }])
await idle
expect(statuses).toEqual(['running', 'idle', 'running', 'idle'])
expect(adapter.requests).toHaveLength(2)
expect(userTexts(agent)).toEqual(['first', 'idle steer'])
})
it('an idle-listener replacement keeps whenIdle pending until the replacement turn finishes', async () => {
const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('between-turn-idle-listener'), { provider: 'mock', model: 'mock' })
let rejectFirstFlush = true
ctx.on('session/flush', (session) => {
if (session !== agent.session || !rejectFirstFlush) return
rejectFirstFlush = false
throw new Error('first flush failed')
})
ctx.on('agent/error', (subject, _turn, _step, error) => {
if (subject !== agent || error.message !== 'first flush failed') return
queueMicrotask(() => {
queueMicrotask(() => { agent.cancel({ kind: 'user' }) })
})
})
const replacementRegistered = Promise.withResolvers<undefined>()
let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined
ctx.on('agent/status', (subject, status) => {
if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return
send(agent, 'replacement')
replacementObservation = agent.whenIdle().then(() => ({
status: agent.status,
requests: adapter.requests.length,
turns: agent.session.events.filter(event => event.type === 'turn/start').length,
}))
replacementRegistered.resolve(undefined)
})
send(agent, 'first')
send(agent, 'cancelled tail')
await replacementRegistered.promise
if (replacementObservation === undefined) throw new Error('idle listener did not register replacement work')
await expect(replacementObservation).resolves.toEqual({ status: 'idle', requests: 2, turns: 2 })
expect(userTexts(agent)).toEqual(['first', 'replacement'])
})
it('idle-listener cancellation settles its waiter without cancelling later work', async () => {
const adapter = new MockAdapter([textResponse('first reply'), textResponse('later reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('idle-listener-cancel'), { provider: 'mock', model: 'mock' })
const replacementRegistered = Promise.withResolvers<undefined>()
let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined
ctx.on('agent/status', (subject, status) => {
if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return
send(agent, 'cancelled replacement')
replacementObservation = agent.whenIdle().then(() => ({
status: agent.status,
requests: adapter.requests.length,
turns: agent.session.events.filter(event => event.type === 'turn/start').length,
}))
agent.cancel({ kind: 'user' })
replacementRegistered.resolve(undefined)
})
send(agent, 'first')
await replacementRegistered.promise
if (replacementObservation === undefined) throw new Error('idle listener did not register replacement work')
await expect(Promise.race([
replacementObservation,
new Promise((_resolve, reject) => setTimeout(() => { reject(new Error('whenIdle hung after idle-listener cancel')) }, 1000)),
])).resolves.toEqual({ status: 'idle', requests: 1, turns: 1 })
const idle = waitForIdle(ctx, agent)
send(agent, 'later')
await idle
expect(adapter.requests).toHaveLength(2)
expect(userTexts(agent)).toEqual(['first', 'later'])
})
it('replacement work queued after idle-listener cancellation still runs', async () => {
const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('idle-listener-post-cancel-send'), { provider: 'mock', model: 'mock' })
const replacementRegistered = Promise.withResolvers<undefined>()
let replacementIdle: Promise<void> | undefined
ctx.on('agent/status', (subject, status) => {
if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return
send(agent, 'cancelled replacement')
agent.cancel({ kind: 'user' })
send(agent, 'surviving replacement')
replacementIdle = agent.whenIdle()
replacementRegistered.resolve(undefined)
})
send(agent, 'first')
await replacementRegistered.promise
if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work')
await replacementIdle
expect(adapter.requests).toHaveLength(2)
expect(userTexts(agent)).toEqual(['first', 'surviving replacement'])
})
it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -121,13 +332,17 @@ describe('Agent.cancel()', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
agent.cancel('mid-step')
send(agent, 'queued tail')
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'mid-step' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
expect(userTexts(agent)).toEqual(['go'])
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(adapter.requests).toHaveLength(1)
})
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(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -137,10 +352,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('cancel from an assistant/message observer skips execution but balances replay', async () => {
@@ -162,7 +377,7 @@ describe('Agent.cancel()', () => {
const agent = ctx.agentLoop.create(SessionId('cancel-after-assistant-message'), { provider: 'mock', model: 'mock' })
const dispose = ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'assistant/message') {
agent.cancel('cancelled after assistant message')
agent.cancel({ kind: 'user' })
}
})
@@ -174,14 +389,14 @@ describe('Agent.cancel()', () => {
dispose()
expect(executions).toBe(0)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled after assistant message' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
const call = agent.session.events.find(event => event.type === 'tool/call')
const result = agent.session.events.find(event => event.type === 'tool/result')
expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1')
expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({
callId: 'c1',
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
send(agent, 'continue safely')
@@ -191,7 +406,7 @@ describe('Agent.cancel()', () => {
.find(block => block.type === 'tool-result')
expect(replayedResult).toMatchObject({ toolCallId: 'c1', isError: true })
expect(reasons).toEqual([
{ kind: 'aborted', reason: 'cancelled after assistant message' },
{ kind: 'aborted' },
{ kind: 'completed' },
])
})
@@ -204,7 +419,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
@@ -229,7 +444,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()
})
@@ -240,7 +455,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 () => {
@@ -292,7 +507,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()]
@@ -320,7 +535,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 === 'turn/start') agent.cancel('from turn-start')
if (session === agent.session && event.type === 'turn/start') agent.cancel({ kind: 'user' })
})
const reasons: TurnEndReason[] = []
@@ -331,10 +546,10 @@ describe('Agent.cancel()', () => {
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
// the caller's cause — the marker carries `cancel(cause)` through even
// though no AbortController observed it in this window.
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 () => {
@@ -349,7 +564,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[] = []
@@ -359,10 +574,10 @@ describe('Agent.cancel()', () => {
await waitForIdle(ctx, agent)
dispose()
// No step streamed, the turn ended aborted with the caller's reason, and the
// No step streamed, the turn ended with the coarse aborted outcome, 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)
})
@@ -420,11 +635,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()
})
@@ -433,10 +648,9 @@ describe('Agent.cancel()', () => {
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).
// and the shared turn signal classified the durable outcome as aborted.
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 () => {
@@ -449,7 +663,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')
@@ -472,7 +686,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')
})
@@ -497,7 +711,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
@@ -520,7 +734,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
@@ -536,4 +750,228 @@ describe('Agent.cancel()', () => {
.flatMap(b => b.type === 'text' ? [b.text] : [])
expect(flat).not.toContain('steer text')
})
it('keeps replacement work queued synchronously by an abort observer', async () => {
const adapter = new MockAdapter(['hang', textResponse('replacement reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('abort-observer-replacement'), { provider: 'mock', model: 'mock' })
send(agent, 'original')
await expect.poll(() => adapter.requests.length).toBe(1)
const signal = adapter.requests[0]?.signal
if (signal === undefined) throw new Error('model request omitted its turn signal')
signal.addEventListener('abort', () => { send(agent, 'replacement') }, { once: true })
const idle = waitForIdle(ctx, agent)
agent.cancel({ kind: 'user' })
await Promise.race([
idle,
new Promise((_resolve, reject) => {
setTimeout(() => {
reject(new Error(`replacement did not settle: ${JSON.stringify({
status: agent.status,
requests: adapter.requests.length,
users: userTexts(agent),
events: agent.session.events.map(event => event.type),
})}`))
}, 1000)
}),
])
expect(adapter.requests).toHaveLength(2)
expect(userTexts(agent)).toEqual(['original', 'replacement'])
const reasons = agent.session.events
.filter(event => event.type === 'turn/end')
.map(event => event.type === 'turn/end' ? event.data.reason : undefined)
expect(reasons).toEqual([{ kind: 'aborted' }, { kind: 'completed' }])
})
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(SessionId('typed-first-wins'), { provider: 'mock', model: 'mock' })
const supplied: { kind: 'parent' | 'user' } = { kind: 'parent' }
send(agent, 'go')
await expect.poll(() => adapter.requests.length).toBe(1)
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('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('terminal-cancellation-authority'), { provider: 'mock', model: 'mock' })
const flushStarted = Promise.withResolvers<undefined>()
const releaseFlush = Promise.withResolvers<undefined>()
let abortedDuringTurnEnd: boolean | undefined
let cancelNotifications = 0
ctx.on('agent/cancel-requested', (subject) => {
if (subject === agent) cancelNotifications += 1
})
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(cancelNotifications).toBe(0)
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 () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('cancel-dispose-race'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const { agent } = handle
send(agent, 'go')
await expect.poll(() => adapter.requests.length).toBe(1)
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',
'post-step',
'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(SessionId(`cooperative-${stage}`), { provider: 'mock', 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, 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 'post-step':
ctx.on('agent/post-step', async (subject, _turn, _step, signal) => {
if (subject !== agent) return
await blockUntilAbort(signal)
throw new Error('post-step failed after cancellation')
})
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

@@ -47,9 +47,9 @@ describe('config-driven session id', () => {
it('accepts one exact fresh id and rejects it alongside a resume id', async () => {
const exact = await makeCoreContext()
await exact.plugin(AgentLoop, {
agents: [{ id: 'main', sessionId: SessionId('stdio-exact'), model: 'mock' }],
agents: [{ id: 'main', sessionId: SessionId('config-exact'), model: 'mock' }],
})
expect(exact.agents.get(SessionId('stdio-exact'))?.session.id).toBe('stdio-exact')
expect(exact.agents.get(SessionId('config-exact'))?.session.id).toBe('config-exact')
await exact.fiber.dispose()
const conflicting = await makeCoreContext()
@@ -89,13 +89,13 @@ describe('config-driven session id', () => {
const ctx = await makeCoreContext()
await ctx.plugin(SessionPersistenceJsonl, { root })
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first'), textResponse('second')]))
const config = { agents: [{ id: 'main', sessionId: SessionId('stdio-exact-reload'), model: 'mock' }] }
const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), model: 'mock' }] }
const firstLoop = await ctx.plugin(AgentLoop, config)
let first: Agent | undefined
for (let i = 0; i < 50 && first === undefined; i++) {
await new Promise(resolve => setTimeout(resolve, 5))
first = ctx.agents.get(SessionId('stdio-exact-reload'))
first = ctx.agents.get(SessionId('config-exact-reload'))
}
expect(first).toBeDefined()
first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
@@ -106,14 +106,14 @@ describe('config-driven session id', () => {
let second: Agent | undefined
for (let i = 0; i < 50 && second === undefined; i++) {
await new Promise(resolve => setTimeout(resolve, 5))
second = ctx.agents.get(SessionId('stdio-exact-reload'))
second = ctx.agents.get(SessionId('config-exact-reload'))
}
expect(second).toBeDefined()
expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me')
second!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } })
await waitForIdle(ctx, second!)
await ctx.sessions.flush(second!.session)
const loaded = await ctx.sessionPersistence.load(SessionId('stdio-exact-reload'))
const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload'))
expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
await secondLoop.dispose()
@@ -125,7 +125,7 @@ describe('config-driven session id', () => {
dirs.push(root)
const ctx = await makeCoreContext()
await ctx.plugin(SessionPersistenceJsonl, { root })
const sessionId = SessionId('stdio-exact-overlap')
const sessionId = SessionId('config-exact-overlap')
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
const firstLoop = await ctx.plugin(AgentLoop, config)
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
@@ -169,7 +169,7 @@ describe('config-driven session id', () => {
dirs.push(root)
const ctx = await makeCoreContext()
await ctx.plugin(SessionPersistenceJsonl, { root })
const sessionId = SessionId('stdio-exact-cancel')
const sessionId = SessionId('config-exact-cancel')
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
const firstLoop = await ctx.plugin(AgentLoop, config)
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
@@ -213,20 +213,20 @@ describe('config-driven session id', () => {
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
await ctx.plugin(AgentLoop, {
agents: [{ id: 'main', sessionId: SessionId('stdio-exact-failure'), model: 'mock' }],
agents: [{ id: 'main', sessionId: SessionId('config-exact-failure'), model: 'mock' }],
})
await expect.poll(() => warn).toHaveBeenCalledWith(expect.stringContaining(
'config-driven restore of "stdio-exact-failure" failed: Error: persistence index failed',
'config-driven restore of "config-exact-failure" failed: persistence index failed',
))
expect(failures).toEqual([{ sessionId: SessionId('stdio-exact-failure'), error: failure }])
expect(failures).toEqual([{ sessionId: SessionId('config-exact-failure'), error: failure }])
expect(warn).toHaveBeenCalledWith(
'agent "main": config-start-failed listener threw: Error: failure observer failed',
'agent "main": config-start-failed listener threw: failure observer failed',
)
await expect.poll(() => warn).toHaveBeenCalledWith(
'agent "main": config-start-failed listener rejected: Error: async failure observer failed',
'agent "main": config-start-failed listener rejected: async failure observer failed',
)
expect(ctx.agents.get(SessionId('stdio-exact-failure'))).toBeUndefined()
expect(ctx.agents.get(SessionId('config-exact-failure'))).toBeUndefined()
warn.mockRestore()
await ctx.fiber.dispose()
})
@@ -251,18 +251,18 @@ describe('config-driven session id', () => {
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
await ctx.plugin(AgentLoop, {
agents: [{ id: 'main', sessionId: SessionId('stdio-exact-unrenderable'), model: 'mock' }],
agents: [{ id: 'main', sessionId: SessionId('config-exact-unrenderable'), model: 'mock' }],
})
await expect.poll(() => failures).toEqual([unrenderable])
expect(warn).toHaveBeenCalledWith(
'agent "main": config-driven restore of "stdio-exact-unrenderable" failed: <unrenderable thrown value>',
'agent "main": config-driven restore of "config-exact-unrenderable" failed: <unrenderable value>',
)
expect(warn).toHaveBeenCalledWith(
'agent "main": config-start-failed listener threw: <unrenderable thrown value>',
'agent "main": config-start-failed listener threw: <unrenderable value>',
)
await expect.poll(() => warn).toHaveBeenCalledWith(
'agent "main": config-start-failed listener rejected: <unrenderable thrown value>',
'agent "main": config-start-failed listener rejected: <unrenderable value>',
)
await ctx.fiber.dispose()
})
@@ -281,7 +281,7 @@ describe('config-driven session id', () => {
ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) })
const loop = await ctx.plugin(AgentLoop, {
agents: [{ id: 'main', sessionId: SessionId('stdio-exact-dispose'), model: 'mock' }],
agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }],
})
let disposed = false
const disposal = loop.dispose().then(() => { disposed = true })
@@ -291,7 +291,7 @@ describe('config-driven session id', () => {
if (outcome === 'resolve') listing.resolve([])
else listing.reject(new Error('startup cancelled by teardown'))
await disposal
expect(ctx.agents.get(SessionId('stdio-exact-dispose'))).toBeUndefined()
expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined()
expect(failures).toEqual([])
expect(warn).not.toHaveBeenCalled()
warn.mockRestore()

View File

@@ -1,15 +1,25 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmService, { CallId, ContentBlock, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineTool, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
async function mountInvariants(ctx: Context): Promise<void> {
await ctx.plugin(InvariantService)
await ctx.plugin(SessionInvariant)
await ctx.plugin(AgentInvariant)
await ctx.plugin(AgentLoopInvariant)
}
function driverDone(agent: Agent): Promise<void> {
return (agent as Agent & { done: Promise<void> }).done
}
@@ -63,7 +73,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 {
@@ -144,7 +154,7 @@ describe('successful provider completion survives agent/step-result failure', ()
): Promise<void> {
const adapter = new MockAdapter([response])
const ctx = await harness(adapter)
await ctx.plugin(Invariants)
await mountInvariants(ctx)
const agent = ctx.agentLoop.create(SessionId(id), { provider: 'mock', model: 'mock' })
const failure = new Error(`${id} result processing failed`)
const reported: Error[] = []
@@ -204,7 +214,7 @@ describe('successful provider completion survives agent/step-result failure', ()
})
describe('abort during tool execution ends the turn', () => {
it('balances an aborted tool batch through context, steering, and post-step before closing', async () => {
it('balances a cancelled tool batch through context and post-step before closing', async () => {
const adapter = new MockAdapter([
// model asks for two tool calls in one step
[
@@ -229,8 +239,7 @@ describe('abort during tool execution ends the turn', () => {
[{ type: 'text', text: 'steering before abort' }],
{ source: { kind: 'plugin', plugin: 'abort-test' } },
)
// Exercise bare step abort without `cancel()` clearing queued work.
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
agent.cancel({ kind: 'user' })
return [{ type: 'text', text: 'done' }]
},
}))
@@ -259,7 +268,10 @@ describe('abort during tool execution ends the turn', () => {
case 'assistant/message': order.push('assistant/message'); break
case 'tool/call': order.push(`tool/call:${event.data.callId}`); break
case 'tool/result': {
const outcome = event.data.error?.code === 'ABORTED' ? 'synthetic-aborted' : 'real'
const outcome = event.data.error?.code === TOOL_ABORTED
|| event.data.error?.code === TOOL_ABORTED_BEFORE_DISPATCH
? 'aborted'
: 'completed'
order.push(`tool/result:${event.data.callId}:${outcome}`)
break
}
@@ -290,25 +302,29 @@ describe('abort during tool execution ends the turn', () => {
expect(order).toEqual([
'assistant/message',
'tool/call:c1',
'tool/result:c1:real',
'tool/result:c1:aborted',
'tool/call:c2',
'tool/result:c2:synthetic-aborted',
'tool/result:c2:aborted',
'context/message',
'steering/message',
'agent/post-step',
'step/end',
'turn/end:aborted',
])
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
const calls = agent.session.events.filter(event => event.type === 'tool/call')
const results = agent.session.events.filter(event => event.type === 'tool/result')
expect(calls.map(event => event.data.callId)).toEqual([CallId('c1'), CallId('c2')])
expect(results).toHaveLength(2)
expect(results[0]!.data).toMatchObject({ callId: CallId('c1'), isError: false })
expect(results[0]!.data).toMatchObject({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: tool call aborted' }],
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED },
})
expect(results[1]!.data).toMatchObject({
callId: CallId('c2'),
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
})
@@ -322,7 +338,7 @@ describe('abort during tool execution ends the turn', () => {
parameters: {},
async execute() {
agent.inject([{ type: 'text', text: 'accepted before abort' }], { source: { kind: 'plugin', plugin: 'test' } })
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
agent.cancel({ kind: 'user' })
return [{ type: 'text', text: 'done' }]
},
}))
@@ -375,7 +391,7 @@ describe('abort during tool execution ends the turn', () => {
description: '',
parameters: {},
async execute() {
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
agent.cancel({ kind: 'user' })
return [{ type: 'text', text: 'aborted' }]
},
}))
@@ -468,7 +484,7 @@ describe('abort during tool execution ends the turn', () => {
description: '',
parameters: {},
async execute() {
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
agent.cancel({ kind: 'user' })
return [{ type: 'text', text: 'done' }]
},
}))
@@ -507,7 +523,7 @@ describe('steering from late extension points is never stranded', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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' }])
@@ -523,7 +539,7 @@ describe('steering from late extension points is never stranded', () => {
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing')
})
it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => {
it('steer() from a step/end session-event listener forces a SAME-TURN next step', async () => {
// Assert the same-turn shape; content alone cannot distinguish re-enqueue.
const adapter = new MockAdapter([
textResponse('no tools, would stop'),
@@ -581,26 +597,6 @@ 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')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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')
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')
})
})
describe('plugin exceptions are contained', () => {
@@ -632,15 +628,20 @@ describe('plugin exceptions are contained', () => {
expect(agent.status).toBe('idle')
})
it('a rejecting session/flush listener is reported but does not kill the agent', async () => {
it('a rejecting first-turn flush settles before the queued tail starts', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let rejectedOnce = false
ctx.on('session/flush', async () => {
if (!rejectedOnce) {
rejectedOnce = true
const firstFlush = Promise.withResolvers<undefined>()
const releaseFirstFlush = Promise.withResolvers<undefined>()
let flushes = 0
ctx.on('session/flush', async (session) => {
if (session !== agent.session) return
flushes += 1
if (flushes === 1) {
firstFlush.resolve(undefined)
await releaseFirstFlush.promise
throw new Error('disk full')
}
})
@@ -648,18 +649,25 @@ describe('plugin exceptions are contained', () => {
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
const idle = waitForIdle(ctx, agent)
send(agent, 'first')
await waitForIdle(ctx, agent)
expect(errors.map(e => e.message)).toEqual(['disk full'])
send(agent, 'second')
await waitForIdle(ctx, agent)
await firstFlush.promise
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
releaseFirstFlush.resolve(undefined)
await idle
expect(errors.map(e => e.message)).toEqual(['disk full'])
expect(adapter.requests).toHaveLength(2)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
})
})
describe('disposed status is part of the agent/status contract', () => {
it('disposing the fiber emits agent/status(disposed) and ends the turn with reason disposed', async () => {
it('disposing the fiber ends the active turn and never starts its queued tail', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
@@ -675,11 +683,19 @@ describe('disposed status is part of the agent/status contract', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
send(agent, 'queued tail')
await fiber.dispose()
await driverDone(agent)
expect(statuses).toEqual(['running', 'disposed'])
expect(reasons).toEqual([{ kind: 'disposed' }])
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
const messages = agent.session.events
.filter(event => event.type === 'user/message')
.flatMap(event => event.data.content)
.flatMap(block => block.type === 'text' ? [block.text] : [])
expect(messages).toEqual(['go'])
expect(adapter.requests).toHaveLength(1)
})
it('a throwing agent/status listener cannot break disposal or leak the registry entry', async () => {
@@ -737,7 +753,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('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) => {
return { ...config, provider: 'mock', model: 'mock' }
})
@@ -925,8 +941,15 @@ describe('discriminated SessionEvent narrows without casts', () => {
describe('a finish-error stream chunk ends the turn as error, not completed', () => {
it('translates finish {kind:error} into a turn error with a logged error event', async () => {
// A finish-error chunk must not produce a completed assistant turn.
const failure = {
message: 'provider 401',
code: 'AUTH',
status: 401,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('finish-request-1'),
}
const errorStream: StreamChunk[] = [
{ type: 'finish', reason: { kind: 'error', message: 'provider 401', code: 'AUTH' } },
{ type: 'finish', reason: { kind: 'error', failure } },
]
const adapter = new MockAdapter([errorStream])
const ctx = await harness(adapter)
@@ -938,20 +961,20 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' }])
expect(reasons).toEqual([{ kind: 'error', step: 1, failure }])
const events = [...agent.session.events]
// The durable failure lives on turn/end.reason (with the failing step), not
// a standalone error event.
const turnEnd = events.find(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' })
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, failure })
// A failed step must not synthesize an assistant message.
expect(events.some(event => event.type === 'assistant/message')).toBe(false)
})
it('translates finish {kind:aborted} into a turn error coded ABORTED', async () => {
const abortedStream: StreamChunk[] = [
{ type: 'finish', reason: { kind: 'aborted' } },
{ type: 'finish', reason: { kind: 'aborted', failure: { message: 'model stream aborted', code: 'ABORTED' } } },
]
const adapter = new MockAdapter([abortedStream])
const ctx = await harness(adapter)
@@ -963,13 +986,13 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'model stream aborted', code: 'ABORTED' }])
expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'model stream aborted', code: 'ABORTED' } }])
expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false)
})
it('handles a finish error without a code (code key omitted)', async () => {
const errorStream: StreamChunk[] = [
{ type: 'finish', reason: { kind: 'error', message: 'codeless failure' } },
{ type: 'finish', reason: { kind: 'error', failure: { message: 'codeless failure', code: 'UNKNOWN' } } },
]
const adapter = new MockAdapter([errorStream])
const ctx = await harness(adapter)
@@ -981,7 +1004,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'codeless failure' }])
expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'codeless failure', code: 'UNKNOWN' } }])
})
})
@@ -1014,7 +1037,7 @@ describe('step boundary publication order', () => {
})
describe('turn and step boundary recovery', () => {
// The invariants plugin makes an unbalanced log fail the test.
// The session invariant companion makes an unbalanced log fail the test.
async function balancedHarness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -1023,7 +1046,7 @@ describe('turn and step boundary recovery', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants)
await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
@@ -1101,7 +1124,7 @@ describe('turn and step boundary recovery', () => {
})
it('a one-shot turn/end validation failure preserves the earlier turn error on retry', async () => {
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }]
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider failed', code: 'UNKNOWN' } } }]
const adapter = new MockAdapter([errorStream])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-turnend-veto'), { provider: 'mock', model: 'mock' })
@@ -1131,7 +1154,7 @@ describe('turn and step boundary recovery', () => {
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({
kind: 'error',
message: 'provider failed',
failure: { message: 'provider failed', code: 'UNKNOWN' },
})
})
@@ -1167,7 +1190,7 @@ describe('turn and step boundary recovery', () => {
it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => {
// Listener failure cannot interrupt error finalization or the next turn.
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }]
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-errorlistener'), { provider: 'mock', model: 'mock' })
@@ -1183,7 +1206,11 @@ describe('turn and step boundary recovery', () => {
expect(c.turnStart).toBe(1)
expect(c.turnEnd).toBe(1)
expect(c.stepStart).toBe(c.stepEnd)
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1, message: 'provider 500' })
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({
kind: 'error',
step: 1,
failure: { message: 'provider 500', code: 'SERVER' },
})
// loop survives: a second turn runs to completion (invariants oracle would
// throw on its turn/start if turn 1 had been left open).
@@ -1328,7 +1355,7 @@ describe('turn and step boundary recovery', () => {
it('a throwing step/end observer cannot interrupt error finalization', async () => {
// Observer failure after step/end commit cannot interrupt turn finalization.
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }]
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-stependthrow'), { provider: 'mock', model: 'mock' })
@@ -1437,10 +1464,10 @@ describe('surface: assistant/message records exact empty provenance when no chun
// stream from legacy events whose provenance was not recorded.
const adapter = new MockAdapter([[]])
const ctx = await harness(adapter)
await ctx.plugin(Invariants)
await mountInvariants(ctx)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _signal) => ({
role: 'assistant' as const,
content: [{ type: 'text' as const, text: 'injected' }],
}))
@@ -1474,7 +1501,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants)
await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
// Parent-owned listener survives agent-fiber disposal.
@@ -1525,7 +1552,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants)
await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
@@ -1543,7 +1570,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)
@@ -1555,15 +1582,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 () => {
@@ -1580,7 +1604,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants)
await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async () => {
@@ -1631,7 +1655,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants)
await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async () => {
@@ -1648,7 +1672,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)
@@ -1659,10 +1683,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 () => {
@@ -1680,7 +1704,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(Invariants)
await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {

View File

@@ -146,12 +146,22 @@ describe('toError normalization', () => {
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
send(agent, 'go')
send(agent, 'fails before turn start')
send(agent, 'survives as the next item')
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]).toMatchObject({ message: 'naked string error', code: 'UNKNOWN' })
expect(adapter.requests).toEqual([])
expect(agent.session.events.some(event => event.type === 'turn/start' || event.type === 'turn/end')).toBe(false)
expect(adapter.requests).toHaveLength(1)
const starts = agent.session.events.filter(event => event.type === 'turn/start')
const ends = agent.session.events.filter(event => event.type === 'turn/end')
const messages = agent.session.events.filter(event => event.type === 'user/message')
expect(starts).toHaveLength(1)
expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(1)
expect(ends).toHaveLength(1)
expect(messages).toHaveLength(1)
expect(messages[0]?.type === 'user/message' && messages[0].data.content).toEqual([
{ type: 'text', text: 'survives as the next item' },
])
})
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {
@@ -160,7 +170,7 @@ describe('toError normalization', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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
@@ -177,7 +187,9 @@ describe('toError normalization', () => {
// String() of { code: 500 } is '[object Object]'
expect(errors[0]!.message).toBe('[object Object]')
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
&& ('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code))
.toBe('UNKNOWN')
})
})
@@ -188,7 +200,7 @@ describe('coded error data emission', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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')
@@ -208,7 +220,8 @@ describe('coded error data emission', () => {
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd).toBeDefined()
if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') {
expect(turnEnd.data.reason.code).toBe('RATE_LIMIT')
expect('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code)
.toBe('RATE_LIMIT')
}
})
})

View File

@@ -8,17 +8,17 @@ function resolverPair() {
}
describe('Inbox', () => {
it('enqueues and drains queued messages in FIFO order', () => {
it('dequeues one queued message at a time in FIFO order', () => {
const inbox = new Inbox()
inbox.enqueue({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } })
inbox.enqueue({ content: [{ type: 'text', text: 'second' }], source: { kind: 'user' } })
expect(inbox.hasQueued).toBe(true)
const drained = inbox.drainQueued()
expect(drained).toHaveLength(2)
expect(drained[0]!.content[0]).toMatchObject({ text: 'first' })
expect(drained[1]!.content[0]).toMatchObject({ text: 'second' })
expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'first' })
expect(inbox.hasQueued).toBe(true)
expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'second' })
expect(inbox.hasQueued).toBe(false)
expect(inbox.dequeueQueued()).toBeUndefined()
})
it('pushes and drains steering messages separately from queued', () => {

View File

@@ -56,7 +56,7 @@ describe('agent/prompt-submit', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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()
})
@@ -177,14 +177,12 @@ describe('agent/prompt-submit', () => {
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' })
})
it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => {
// Blocking one prompt in a mixed batch must persist its reason even though
// the allowed prompt keeps the turn from ending rejected.
it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => {
const adapter = new MockAdapter([textResponse('ran once')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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()
})
@@ -192,13 +190,13 @@ describe('agent/prompt-submit', () => {
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
// both sends land before the loop drains → one batched turn
// Both sends land before the driver wakes, but each remains its own turn.
send(agent, 'secret')
send(agent, 'safe')
await waitForIdle(ctx, agent)
const log = events(agent)
// the allowed prompt became a user/message and drove exactly one model call
// The allowed prompt became a user/message and drove exactly one model call.
const userMsgs = log.filter(e => e.type === 'user/message')
expect(userMsgs).toHaveLength(1)
expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
@@ -210,12 +208,14 @@ describe('agent/prompt-submit', () => {
content: [{ type: 'text', text: 'secret' }],
reason: 'policy: no secrets',
})
// the turn did NOT reject — a sibling was allowed — so the boundary reason
// alone would not have preserved the block
expect(reasons.some(r => r.kind === 'rejected')).toBe(false)
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2)
expect(reasons).toEqual([
{ kind: 'rejected', reason: 'policy: no secrets' },
{ kind: 'completed' },
])
})
it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => {
it('a throwing prompt-submit listener ends its turn balanced while an adjacent message survives', async () => {
const adapter = new MockAdapter([textResponse('after')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -226,20 +226,31 @@ describe('agent/prompt-submit', () => {
return { kind: 'allow' as const }
})
const errors: Error[] = []
const reasons: TurnEndReason[] = []
const statuses: string[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) })
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
})
const idle = waitForIdle(ctx, agent)
send(agent, 'first')
await waitForIdle(ctx, agent)
expect(errors.map(e => e.message)).toEqual(['prompt hook broke'])
// turn balanced
const log = events(agent)
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
// loop survives: a second prompt runs normally
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
await idle
expect(errors.map(e => e.message)).toEqual(['prompt hook broke'])
// The failed prompt forms one balanced error turn; the adjacent prompt forms
// the following normal turn without an intermediate idle transition.
const log = events(agent)
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2)
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(2)
expect(reasons).toEqual([
{ kind: 'error', step: 0, message: 'prompt hook broke' },
{ kind: 'completed' },
])
expect(statuses).toEqual(['running', 'idle'])
expect(adapter.requests).toHaveLength(1)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second')
})
})
@@ -486,7 +497,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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' } } }
@@ -651,7 +662,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

@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import { markAgentLoopRequest, type GenerateOptions } from '@deepseek-ai/dsh-llm'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
await ctx.plugin(AgentLoopInvariant)
return ctx
}
function dispatch(ctx: Context, options: unknown): void {
void ctx.waterfall('llm/stream', options as never, () => (async function* () {})() as never)
}
function loopRequest<T extends object>(options: T): Readonly<T> {
markAgentLoopRequest(options as GenerateOptions)
return Object.freeze(options)
}
async function requestSetup() {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('req-check'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const boundary = session.deriveMessages()
session.append('step/start', { turn: 1, step: 1 })
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
return { ctx, session, boundary }
}
describe('request-reconstruction invariant', () => {
it('accepts a frozen request equal to the boundary derivation and folded header', async () => {
const { ctx, session, boundary } = await requestSetup()
const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
expect(() => { dispatch(ctx, options) }).not.toThrow()
})
it('uses the step boundary rather than content appended afterward', async () => {
const { ctx, session, boundary } = await requestSetup()
session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
expect(() => { dispatch(ctx, options) }).not.toThrow()
})
it('requires the folded session prefix ahead of derived history', async () => {
const { ctx, session, boundary } = await requestSetup()
const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: '<system-reminder>catalog</system-reminder>' }] }
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' })
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) })
.not.toThrow()
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) })
.toThrow(/diverges from the boundary derivation/)
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })) })
.toThrow(/diverges from the boundary derivation/)
})
it('rejects message and header divergence', async () => {
const { ctx, session, boundary } = await requestSetup()
const divergent = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }]
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) })
.toThrow(/diverges from the boundary derivation/)
expect(() => { dispatch(ctx, loopRequest({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) })
.toThrow(/diverges from the folded request header/)
})
it('rejects loop requests with no boundary or header', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('req-bare'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const bare = loopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/)
session.append('step/start', { turn: 1, step: 1 })
expect(() => { dispatch(ctx, bare) }).toThrow(/no request\/header event/)
})
it('rejects an unfrozen messages array but skips requests outside the loop contract', async () => {
const { ctx, session, boundary } = await requestSetup()
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: [...boundary], sessionId: session.id })) })
.toThrow(/frozen messages array/)
expect(() => { dispatch(ctx, { model: 'summarizer', messages: [], sessionId: session.id }) }).not.toThrow()
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]) })) }).not.toThrow()
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })) })
.not.toThrow()
const directSession = ctx.sessions.create(SessionId('direct-one-shot'))
expect(() => {
dispatch(ctx, Object.freeze({ model: 'one-shot', messages: Object.freeze([]), sessionId: directSession.id }))
}).not.toThrow()
})
it('rejects malformed requests carrying the loop marker', async () => {
const { ctx, session } = await requestSetup()
const messages: GenerateOptions['messages'] = []
Object.freeze(messages)
expect(() => {
dispatch(ctx, markAgentLoopRequest({ provider: 'p', model: 'm', messages, sessionId: session.id }))
}).toThrow(/request must be frozen/)
expect(() => {
dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([]) }))
}).toThrow(/carry a session id/)
expect(() => {
dispatch(ctx, loopRequest({
model: 'm',
messages: Object.freeze([]),
sessionId: SessionId('missing-loop-session'),
}))
}).toThrow(/live session id/)
})
it('prepends ahead of a short-circuiting stream listener', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.on('llm/stream', () => (async function* () {})() as never)
await ctx.plugin(InvariantService)
await ctx.plugin(AgentLoopInvariant)
const session = ctx.sessions.create(SessionId('prepend-check'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
const divergent = loopRequest({
model: 'm',
messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]),
sessionId: session.id,
})
expect(() => { dispatch(ctx, divergent) }).toThrow(/diverges from the boundary derivation/)
})
})

View File

@@ -231,7 +231,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, provider: 'mock', model: 'mock' }
})
const agent = ctx.agentLoop.create(SessionId('a-late-model'), {})
@@ -354,14 +354,24 @@ describe('agent loop', () => {
expect(flat).toContain('change of plans')
})
it('steering while idle behaves like send (starts a turn)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
it('same-tick idle steering inherits one-send-one-turn FIFO behavior', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.steer([{ type: 'text', text: 'hello' }])
await waitForIdle(ctx, agent)
expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true)
const idle = waitForIdle(ctx, agent)
agent.steer([{ type: 'text', text: 'first idle steer' }])
agent.steer([{ type: 'text', text: 'second idle steer' }])
await idle
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(agent.session.events
.filter(event => event.type === 'user/message')
.map(event => event.data.content)).toEqual([
[{ type: 'text', text: 'first idle steer' }],
[{ type: 'text', text: 'second idle steer' }],
])
expect(adapter.requests).toHaveLength(2)
})
it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => {
@@ -517,7 +527,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()
})
@@ -556,7 +566,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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)
@@ -682,10 +692,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 () => {
@@ -722,7 +732,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()
})
@@ -874,7 +884,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()
@@ -922,7 +932,149 @@ describe('agent loop', () => {
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed')
})
it('chains queued messages into consecutive turns', async () => {
it('keeps same-tick sends in separate turns and checkpoints before the next starts', async () => {
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const firstFlush = Promise.withResolvers<undefined>()
const releaseFirstFlush = Promise.withResolvers<undefined>()
let flushes = 0
ctx.on('session/flush', async (session) => {
if (session !== agent.session) return
flushes += 1
if (flushes === 1) {
firstFlush.resolve(undefined)
await releaseFirstFlush.promise
}
})
const turns: number[] = []
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/start') turns.push(event.data.turn)
})
const idle = waitForIdle(ctx, agent)
send(agent, 'first message')
send(agent, 'second message')
await firstFlush.promise
expect(turns).toEqual([1])
expect(adapter.requests).toHaveLength(1)
releaseFirstFlush.resolve(undefined)
await idle
expect(turns).toEqual([1, 2])
expect(flushes).toBe(2)
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer')
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message')
})
it('holds a turn-end listener send behind the closing turn checkpoint', async () => {
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const firstFlush = Promise.withResolvers<undefined>()
const releaseFirstFlush = Promise.withResolvers<undefined>()
let flushes = 0
ctx.on('session/flush', async (session) => {
if (session !== agent.session) return
flushes += 1
if (flushes === 1) {
firstFlush.resolve(undefined)
await releaseFirstFlush.promise
}
})
const turns: number[] = []
const statuses: string[] = []
ctx.on('agent/status', (subject, status) => {
if (subject === agent) statuses.push(status)
})
ctx.on('session/event', (session, event) => {
if (session !== agent.session) return
if (event.type === 'turn/start') turns.push(event.data.turn)
if (event.type === 'turn/end' && event.data.turn === 1) send(agent, 'turn-end listener message')
})
const idle = waitForIdle(ctx, agent)
send(agent, 'first message')
await firstFlush.promise
expect(turns).toEqual([1])
expect(adapter.requests).toHaveLength(1)
releaseFirstFlush.resolve(undefined)
await idle
expect(turns).toEqual([1, 2])
expect(statuses).toEqual(['running', 'idle'])
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer')
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message')
})
it('keeps a reentrant agent/queued send as the next independent turn', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let nested = false
ctx.on('agent/queued', (subject) => {
if (subject !== agent || nested) return
nested = true
send(agent, 'queued listener message')
})
const idle = waitForIdle(ctx, agent)
send(agent, 'outer message')
await idle
const turns = agent.session.events.filter(event => event.type === 'turn/start')
const messages = agent.session.events
.filter(event => event.type === 'user/message')
.map(event => event.data.content)
expect(turns).toHaveLength(2)
expect(messages).toEqual([
[{ type: 'text', text: 'outer message' }],
[{ type: 'text', text: 'queued listener message' }],
])
})
it('preserves independent turn sources across an adjacent microtask send', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'user message' }])
await Promise.resolve()
agent.send(
[{ type: 'text', text: 'plugin message' }],
{ source: { kind: 'plugin', plugin: 'test' } },
)
await idle
const triggers = agent.session.events
.filter(event => event.type === 'turn/start')
.map(event => event.data.trigger)
const sources = agent.session.events
.filter(event => event.type === 'user/message')
.map(event => event.data.source)
expect(triggers).toEqual([
{ kind: 'message', source: { kind: 'user' } },
{ kind: 'message', source: { kind: 'plugin', plugin: 'test' } },
])
expect(sources).toEqual([
{ kind: 'user' },
{ kind: 'plugin', plugin: 'test' },
])
})
it('keeps a session-listener send after dequeue in the following turn', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -945,6 +1097,37 @@ describe('agent loop', () => {
expect(turns).toEqual([1, 2])
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first')
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message')
})
it('keeps a model-adapter callback send in the following turn', async () => {
const agentRef: { current?: Agent } = {}
const adapter = new MockAdapter([
() => {
const agent = agentRef.current
if (agent === undefined) throw new Error('model callback ran before agent setup')
send(agent, 'model callback message')
return textResponse('first')
},
textResponse('second'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agentRef.current = agent
const idle = waitForIdle(ctx, agent)
send(agent, 'outer message')
await idle
const messages = agent.session.events
.filter(event => event.type === 'user/message')
.map(event => event.data.content)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(messages).toEqual([
[{ type: 'text', text: 'outer message' }],
[{ type: 'text', text: 'model callback message' }],
])
})
it('awaits session/flush at turn end (persistence checkpoint)', async () => {

View File

@@ -81,6 +81,21 @@ function turnNumbers(agent: Agent): number[] {
.map(e => (e.data as { turn: number }).turn)
}
function turnEndNumbers(agent: Agent): number[] {
return agent.session.events
.filter(e => e.type === 'turn/end')
.map(e => (e.data as { turn: number }).turn)
}
function userMessageCountsByTurn(agent: Agent): number[] {
const counts: number[] = []
for (const event of agent.session.events) {
if (event.type === 'turn/start') counts.push(0)
if (event.type === 'user/message') counts[counts.length - 1]! += 1
}
return counts
}
/** Assert a status trace is a legal run: idle/running alternating, ending idle. */
function assertLegalStatusTrace(trace: string[]): void {
for (let i = 1; i < trace.length; i++) {
@@ -90,7 +105,7 @@ function assertLegalStatusTrace(trace: string[]): void {
}
describe('agent loop scheduling properties', () => {
it('a synchronous burst loses no message and uses strictly increasing turns', async () => {
it('a synchronous burst gives every message its own strictly increasing turn', async () => {
await fc.assert(fc.asyncProperty(
fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 6 }),
async (texts) => {
@@ -105,8 +120,11 @@ describe('agent loop scheduling properties', () => {
// No message lost: every send appears as a user/message, in order.
expect(userMessageTexts(agent)).toEqual(texts)
// A synchronous burst batches into exactly one turn.
expect(turnNumbers(agent)).toEqual([1])
// This failure-free fixture maps every item to an independent turn.
expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1))
expect(turnEndNumbers(agent)).toEqual(texts.map((_, i) => i + 1))
expect(userMessageCountsByTurn(agent)).toEqual(texts.map(() => 1))
expect(trace).toEqual(['running', 'idle'])
assertLegalStatusTrace(trace)
} finally {
await ctx.fiber.dispose()
@@ -137,9 +155,9 @@ describe('agent loop scheduling properties', () => {
), { numRuns: 20, timeout: 2000 })
})
it('mixed schedule (send, optionally settle) loses no message and orders turns', async () => {
// Each step is a (text, settle?) pair: settle=true awaits idle before the
// next send (own turn); settle=false sends in the same tick (batches).
it('mixed settled and same-tick sends preserve one turn per message', async () => {
// Each step optionally waits for idle before the next send; that scheduling
// choice must not change the ordinary message-to-turn mapping.
const stepArb = fc.record({ text: fc.string({ minLength: 1 }), settle: fc.boolean() })
await fc.assert(fc.asyncProperty(
fc.array(stepArb, { minLength: 1, maxLength: 6 }),
@@ -158,14 +176,13 @@ describe('agent loop scheduling properties', () => {
}
await lastIdle
// No message lost or reordered, regardless of batching.
// No message is lost or reordered, regardless of driver timing.
expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text))
// Turn numbers are a strictly increasing 1..N prefix (N = turn count).
// Every item forms one FIFO-ordered turn containing only that message.
const turns = turnNumbers(agent)
expect(turns).toEqual(turns.map((_, i) => i + 1))
// Every message landed in some turn; turns never exceed messages.
expect(turns.length).toBeLessThanOrEqual(steps.length)
expect(turns.length).toBeGreaterThanOrEqual(1)
expect(turns).toEqual(steps.map((_, i) => i + 1))
expect(turnEndNumbers(agent)).toEqual(turns)
expect(userMessageCountsByTurn(agent)).toEqual(steps.map(() => 1))
} finally {
await ctx.fiber.dispose()
}

View File

@@ -167,7 +167,7 @@ describe('request stability across the loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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' } })
@@ -243,7 +243,7 @@ describe('request stability across the loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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"
@@ -280,7 +280,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

@@ -3,10 +3,12 @@ import { Context } from 'cordis'
import LlmService, {
CallId,
CONTEXT_WINDOW_EXCEEDED_CODE,
HarnessError,
LlmAdapter,
LlmError,
ProviderRequestId,
} from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
@@ -202,7 +204,7 @@ describe('agent post-step and request-error lifecycle', () => {
send(agent)
const idle = waitForIdle(ctx, agent)
await postStepEntered
agent.cancel('cancelled during max-tokens post-step')
agent.cancel({ kind: 'user' })
await idle
expect(agent.session.events.find(event => event.type === 'assistant/message')).toMatchObject({
@@ -210,7 +212,7 @@ describe('agent post-step and request-error lifecycle', () => {
})
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'aborted', reason: 'cancelled during max-tokens post-step' } },
data: { reason: { kind: 'aborted' } },
})
})
@@ -258,16 +260,17 @@ describe('agent post-step and request-error lifecycle', () => {
it.each([
['thrown', contextError()],
['in-band', [{ type: 'finish', reason: { kind: 'error', message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE } }] satisfies StreamChunk[]],
['in-band', [{ type: 'finish', reason: { kind: 'error', failure: { message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE, status: 400 } } }] satisfies StreamChunk[]],
] as const)('recovers a %s request failure in a new reconstructable step', async (_style, failure) => {
const adapter = new FailureScriptAdapter([failure, textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId(`recover-${_style}`), { provider: 'mock', model: 'mock' })
const attempts: number[] = []
ctx.on('agent/request-error', async (subject, turn, step, error, attempt) => {
ctx.on('agent/request-error', async (subject, turn, step, error, facts, history) => {
expect(subject).toBe(agent)
expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE })
attempts.push(attempt)
expect(facts.code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
attempts.push(history.length)
subject.session.append('context/message', {
content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }],
source: { kind: 'plugin', plugin: 'test-recovery' },
@@ -295,7 +298,7 @@ describe('agent post-step and request-error lifecycle', () => {
const agent = ctx.agentLoop.create(SessionId(`stream-plugin-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' })
let recoveries = 0
install(ctx)
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
recoveries += 1
return next()
})
@@ -326,7 +329,7 @@ describe('agent post-step and request-error lifecycle', () => {
})
const agent = ctx.agentLoop.create(SessionId('nested-stream-not-recoverable'), { provider: 'mock', model: 'mock' })
let recoveries = 0
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
recoveries += 1
return next()
})
@@ -359,7 +362,7 @@ describe('agent post-step and request-error lifecycle', () => {
}
const agent = ctx.agentLoop.create(SessionId(`${boundary}-not-recoverable`), { provider: 'mock', model: 'mock' })
let recoveries = 0
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
recoveries += 1
return next()
})
@@ -387,7 +390,7 @@ describe('agent post-step and request-error lifecycle', () => {
}
const agent = ctx.agentLoop.create(SessionId(`${failure}-not-recoverable`), { provider: 'mock', model: 'mock' })
let recoveries = 0
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
recoveries += 1
return next()
})
@@ -406,7 +409,7 @@ describe('agent post-step and request-error lifecycle', () => {
const ctx = await harness(makeAdapter(original))
const agent = ctx.agentLoop.create(SessionId(`identity-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' })
let seen: Error | undefined
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => {
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _failure, _history, _signal, next) => {
seen = error
return next()
})
@@ -417,12 +420,90 @@ describe('agent post-step and request-error lifecycle', () => {
expect(seen).toBe(original)
})
it('keeps an adapter error with a hostile message accessor on the recovery path', async () => {
const original = Object.defineProperty(new HarnessError('provider failed', 'SERVER'), 'message', {
get() { throw new Error('SDK message accessor trap') },
})
const ctx = await harness(new SynchronousDispatchFailureAdapter(original))
const agent = ctx.agentLoop.create(SessionId('hostile-message-recovery'), { provider: 'mock', model: 'mock' })
let seenError: Error | undefined
let seenFailure: LlmFailure | undefined
ctx.on('agent/request-error', async (_agent, _turn, _step, error, failure, _history, _signal, next) => {
seenError = error
seenFailure = failure
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(seenError).toBe(original)
expect(seenFailure).toEqual({ message: 'LLM adapter failed', code: 'SERVER' })
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', failure: { message: 'LLM adapter failed', code: 'SERVER' } } },
})
})
it('passes structured facts beside the original Error and records its cause chain on exhaustion', async () => {
const original = new LlmError('provider busy', 'RATE_LIMIT', {
cause: new Error('upstream connection reset'),
status: 429,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('req-9'),
})
Object.freeze(original)
const ctx = await harness(new SynchronousDispatchFailureAdapter(original))
const agent = ctx.agentLoop.create(SessionId('structured-request-failure'), { provider: 'mock', model: 'mock' })
let seenError: Error | undefined
let seenFailure: LlmFailure | undefined
let seenHistory: readonly LlmFailure[] | undefined
ctx.on('agent/request-error', async (
_agent, _turn, _step, error, failure, history, _signal, next,
) => {
seenError = error
seenFailure = failure
seenHistory = history
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(seenError).toBe(original)
expect(seenFailure).toEqual({
message: 'provider busy',
code: 'RATE_LIMIT',
status: 429,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('req-9'),
})
expect(seenHistory).toEqual([])
expect(Object.isFrozen(seenHistory)).toBe(true)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: {
reason: {
kind: 'error',
step: 1,
failure: {
message: 'provider busy: upstream connection reset',
code: 'RATE_LIMIT',
status: 429,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('req-9'),
},
},
},
})
})
it('classifies iterator construction and explicit NO_ADAPTER as model-request failures', async () => {
for (const scenario of ['iterator', 'no-adapter'] as const) {
const ctx = scenario === 'iterator' ? await harness(new IteratorConstructionFailureAdapter()) : await harness()
const agent = ctx.agentLoop.create(SessionId(`request-boundary-${scenario}`), { provider: 'mock', model: 'mock' })
let seen = ''
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => {
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _failure, _history, _signal, next) => {
seen = error.code ?? ''
return next()
})
@@ -436,14 +517,17 @@ describe('agent post-step and request-error lifecycle', () => {
const capped = new FailureScriptAdapter([contextError('first overflow'), contextError('second overflow')])
const cappedCtx = await harness(capped)
const cappedAgent = cappedCtx.agentLoop.create(SessionId('retry-cap'), { provider: 'mock', model: 'mock' })
const cappedAttempts: number[] = []
cappedCtx.on('agent/request-error', async (_agent, _turn, _step, _error, attempt, _signal, next) => {
cappedAttempts.push(attempt)
return attempt < 1 ? { action: 'retry' } : next()
const cappedHistories: string[][] = []
cappedCtx.on('agent/request-error', async (
_agent, _turn, _step, _error, _failure, history, _signal, next,
) => {
const codes = history.map(entry => entry.code)
cappedHistories.push(codes)
return codes.length < 1 ? { action: 'retry' } : next()
})
send(cappedAgent)
await waitForIdle(cappedCtx, cappedAgent)
expect(cappedAttempts).toEqual([0, 1])
expect(cappedHistories).toEqual([[], [CONTEXT_WINDOW_EXCEEDED_CODE]])
const reset = new FailureScriptAdapter([
contextError('first overflow'),
@@ -458,14 +542,16 @@ describe('agent post-step and request-error lifecycle', () => {
async execute() { return [{ type: 'text', text: 'worked' }] },
}))
const resetAgent = resetCtx.agentLoop.create(SessionId('retry-reset'), { provider: 'mock', model: 'mock' })
const resetAttempts: { step: number; attempt: number }[] = []
resetCtx.on('agent/request-error', async (_agent, _turn, step, _error, attempt, _signal, next) => {
resetAttempts.push({ step, attempt })
return resetAttempts.length === 1 ? { action: 'retry' } : next()
const resetHistories: { step: number; codes: string[] }[] = []
resetCtx.on('agent/request-error', async (
_agent, _turn, step, _error, _failure, history, _signal, next,
) => {
resetHistories.push({ step, codes: history.map(entry => entry.code) })
return resetHistories.length === 1 ? { action: 'retry' } : next()
})
send(resetAgent)
await waitForIdle(resetCtx, resetAgent)
expect(resetAttempts).toEqual([{ step: 1, attempt: 0 }, { step: 3, attempt: 0 }])
expect(resetHistories).toEqual([{ step: 1, codes: [] }, { step: 3, codes: [] }])
})
it('preserves the original provider error when recovery throws', async () => {
@@ -479,7 +565,7 @@ describe('agent post-step and request-error lifecycle', () => {
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', message: 'original overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } },
data: { reason: { kind: 'error', failure: { message: 'original overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } } },
})
})
@@ -489,7 +575,7 @@ describe('agent post-step and request-error lifecycle', () => {
const agent = ctx.agentLoop.create(SessionId(`${action}-recovery`), { provider: 'mock', model: 'mock' })
let entered!: () => void
const recoveryEntered = new Promise<void>((resolve) => { entered = resolve })
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, signal) => {
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, signal) => {
entered()
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
@@ -501,7 +587,7 @@ describe('agent post-step and request-error lifecycle', () => {
const idle = waitForIdle(ctx, agent)
await recoveryEntered
if (action === 'cancel') {
agent.cancel('cancelled during recovery')
agent.cancel({ kind: 'user' })
await idle
} else {
await ctx.fiber.dispose()
@@ -510,7 +596,7 @@ describe('agent post-step and request-error lifecycle', () => {
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: action === 'cancel' ? { kind: 'aborted', reason: 'cancelled during recovery' } : { kind: 'disposed' } },
data: { reason: action === 'cancel' ? { kind: 'aborted' } : { kind: 'disposed' } },
})
})
})

View File

@@ -193,7 +193,7 @@ describe('the session-persistence Agent Note: 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')
})
@@ -411,7 +411,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await ctx.fiber.dispose()
})
it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
it('resume of a forked session preserves the lineage, seed boundary, and delegation depth in the header', async () => {
// Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
// in its header) by creating it with a complete-turn seed — the write path
// materializes the fork (header + seed) on disk.
@@ -423,9 +423,9 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const forked = ctx1.sessions.create(SessionId('forked-sess'), {
seed,
meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length },
meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length, delegationDepth: 1 },
})
await ctx1.parallel('session/flush', forked)
await ctx1.sessions.flush(forked)
await ctx1.fiber.dispose()
// Lifecycle 2: resume it; the parentSession + seedLength header survives the
@@ -447,6 +447,9 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
expect(a2.session.header.parentSession).toBe('parent-sess')
expect(a2.session.header.cwd).toBe('/w')
expect(a2.session.header.seedLength).toBe(seed.length)
// The recursion budget survives resume — a dropped depth would let a
// resumed child delegate as if it were top-level.
expect(a2.session.header.delegationDepth).toBe(1)
await ctx2.fiber.dispose()
})
@@ -482,7 +485,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
await ctx1.parallel('session/flush', a1.session)
await ctx1.sessions.flush(a1.session)
await ctx1.fiber.dispose()
// Lifecycle 2: resume; the injected context is still in the derived history.

View File

@@ -9,7 +9,7 @@ import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import LlmService from '@deepseek-ai/dsh-llm'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -461,7 +461,7 @@ describe('tool-call scheduler: abort handling', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'assistant/message') {
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('already aborted')
agent.cancel({ kind: 'user' })
}
})
@@ -476,12 +476,12 @@ describe('tool-call scheduler: abort handling', () => {
isError: e.data.isError,
error: e.data.error,
}))).toEqual([
{ callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } },
{ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } },
{ callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
{ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
])
})
it('stops starting siblings when abort fires during ordered pre-execute', async () => {
it('skips dispatch and stops starting siblings when abort fires during ordered pre-execute', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('should never be requested'),
@@ -492,24 +492,25 @@ describe('tool-call scheduler: abort handling', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.callId === CallId('c1')) {
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('pre cancelled')
agent.cancel({ kind: 'user' })
}
return next()
})
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1'])
gated.release('1')
await waitForIdle(ctx, agent)
expect(gated.started).toEqual([])
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2')])
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2')])
expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data)
.toMatchObject({ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } })
expect(events(agent).filter(e => e.type === 'tool/result').map(e => ({
callId: e.data.callId,
isError: e.data.isError,
error: e.data.error,
}))).toEqual([
{ callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
{ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
])
})
it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => {
@@ -528,7 +529,7 @@ describe('tool-call scheduler: abort handling', () => {
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop now')
agent.cancel({ kind: 'user' })
gated.release('1')
gated.release('2')
await waitForIdle(ctx, agent)
@@ -540,8 +541,8 @@ describe('tool-call scheduler: abort handling', () => {
.toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')])
expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => e.data))
.toEqual([
expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }),
expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }),
expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }),
expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }),
])
const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message')
expect(settled.map(e => e.type))
@@ -574,7 +575,7 @@ describe('tool-call scheduler: abort handling', () => {
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop before barrier')
agent.cancel({ kind: 'user' })
gated.release('1')
gated.release('2')
await waitForIdle(ctx, agent)
@@ -583,6 +584,6 @@ describe('tool-call scheduler: abort handling', () => {
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2'), CallId('c3')])
expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data)
.toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } })
.toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } })
})
})

View File

@@ -7,9 +7,19 @@ import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
async function mountInvariants(ctx: Context): Promise<void> {
await ctx.plugin(InvariantService)
await ctx.plugin(SessionInvariant)
await ctx.plugin(AgentInvariant)
await ctx.plugin(AgentLoopInvariant)
}
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -17,7 +27,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(Invariants)
await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
@@ -50,7 +60,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

View File

@@ -37,6 +37,9 @@
},
{
"path": "../../core/scope"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'tsdown'
/** Build the package root and optional invariant companion as independent bundles. */
export default defineConfig([
{
entry: ['lib/types/index.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
{
entry: ['lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])

View File

@@ -2,13 +2,15 @@
Agent interface, registry, process-local initiator scope, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable.
The optional `@deepseek-ai/dsh-agent/invariant` companion registers this package's agent-status transition checks with `ctx.invariants`. The root agent service does not load diagnostics implicitly.
## Service: `AgentRegistry` (ctx key: `agents`)
Tracks live agents and carries the initiating Agent through asynchronous driver work without importing the concrete loop package.
### Public API
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model selection during prompt assembly and applies that pair to both prompt variables and request routing for one step. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
- Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`.
@@ -44,7 +46,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: a retry opens a new numbered step after the failed step closes. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way).
Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
`PromptDecision.additionalContexts` is an array so every injected context keeps its own source and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source.
@@ -54,13 +56,15 @@ Turn and step boundaries and the model token stream are durable `session/event`
The handle every plugin programs against:
- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content).
- `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle
- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Omitting `options.source` attests direct human input as `{ kind: 'user' }` and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale.
- `agent.steer(content, options?)` — submit steering while the agent is `running`. An open turn records it at the next steering checkpoint before a request or continuation decision; policy can still stop before another step. After turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. The method uses the same synchronous snapshot-and-validation boundary as `send` and delegates to `send` when idle
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)).
- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op.
- `agent.cancel(cause?)` — cancel ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; observers may synchronize state but cannot veto cancellation. The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
`running` describes a driver-wide drain interval, not proof that a turn is still open; it can cover turn close, the durability checkpoint, and consecutive queued turns.
### Extension points
- Agent creation: `AgentLoop.create()` is the concrete config-path implementation (in `dsh-agent-loop`), while programmatic consumers create/resume owned agents through `ctx.agents.create()` / `ctx.agents.resume()`. Replace the loop by implementing `Agent` and registering via `ctx.agents.register()`.

View File

@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -23,6 +28,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -31,6 +37,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -0,0 +1,30 @@
/** 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

@@ -115,8 +115,9 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
* Build the prompt assembly context with agent and scope set together, so
* agent-scoped prompt and tool contributions cannot be silently omitted.
* @param agent - the agent the assembly is for.
* @param signal - the current turn's explicit control signal, when assembly belongs to a turn.
* @returns the context to pass to `assemble()`.
*/
export function assembleContextFor(agent: Agent): AssembleContext {
return { agent, scope: agent }
export function assembleContextFor(agent: Agent, signal?: AbortSignal): AssembleContext {
return { agent, scope: agent, ...signal === undefined ? {} : { signal } }
}

View File

@@ -15,6 +15,8 @@ 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 { agentEvents, assembleContextFor } from './dispatch.ts'
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
@@ -46,15 +48,21 @@ export interface CreateAgentOptions {
readonly sessionId: SessionId
/**
* Session creation metadata: validated absolute `cwd`, `parentSession`
* fork lineage, and the `seedLength` seed boundary. Mirrors the
* `cwd`/`parentSession`/`seedLength` fields of
* fork lineage, the `seedLength` seed boundary, and the `delegationDepth`
* recursion budget. Mirrors the
* `cwd`/`parentSession`/`seedLength`/`delegationDepth` fields of
* {@link CreateSessionOptions.meta} in dsh-session (the internal-only
* `createdAt`, used when reconstructing a persisted session, is deliberately
* excluded — a factory caller never sets it). This is durable session data,
* so the session boundary validates and snapshots it before asynchronous
* setup begins.
*/
readonly meta?: { readonly cwd?: string; readonly parentSession?: SessionId; readonly seedLength?: number }
readonly meta?: {
readonly cwd?: string
readonly parentSession?: SessionId
readonly seedLength?: number
readonly delegationDepth?: number
}
/**
* Seed events to reconstruct the child session's log from (the fork lineage
* primitive). When present, the factory creates the session with this event

View File

@@ -0,0 +1,35 @@
/** Package-owned agent lifecycle invariants. @module @deepseek-ai/dsh-agent/invariant */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
const PACKAGE_NAME = '@deepseek-ai/dsh-agent'
/** Cordis companion plugin name. */
export const name = 'agent-invariant'
/** Services required before the companion can register. */
export const inject = ['invariants']
/** Install the agent contribution into its child registration fiber. */
const install: InvariantInstaller = (ctx, fail) => {
const lastStatus = new WeakMap<Agent, AgentStatus>()
ctx.on('agent/status', (agent, status) => {
const previous = lastStatus.get(agent)
if (previous === status) {
fail(`agent/status repeated ${status} (no-op transition)`)
}
if (previous === 'disposed') {
fail(`agent/status left terminal state disposed → ${status}`)
}
lastStatus.set(agent, status)
}, { global: true })
}
/**
* Register the agent invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,66 @@
/**
* Agent-scoped provider/model target snapshot shared by interactive front doors.
* @module @deepseek-ai/dsh-agent/llm-target
*/
import type { Context } from 'cordis'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
/** Complete provider/model route selected for one live agent. */
export interface AgentLlmTarget {
/** Registered provider route. */
provider: string
/** Provider-owned model id. */
model: string
}
/** Mutable selection plus the target captured for the current step. */
export interface AgentLlmTargetRef {
/** Target selected for the next step that enters prompt assembly. */
current: AgentLlmTarget | undefined
/** Target captured when the current step entered prompt assembly. */
assembled: AgentLlmTarget | undefined
}
/**
* Couple one mutable target to agent-scoped prompt assembly and request routing.
* Prompt assembly snapshots the selected pair before delegating, then applies
* both prompt variables and request config to that snapshot so a concurrent
* switch takes effect on a later step instead of splitting the two surfaces.
*
* @param agentCtx - The target agent's scoped context.
* @param target - Mutable selection owned by the calling front door.
* @returns Disposer for both scoped waterfall listeners.
*/
export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetRef): () => void {
const disposeAssembly = agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const selected = target.current
const assembled = await next()
target.assembled = selected
if (selected === undefined) return assembled
return {
...assembled,
variables: {
...assembled.variables,
provider: selected.provider,
model: selected.model,
},
}
})
const disposeRequest = agentCtx.on(
'agent/request',
async (_agent, _turn, _step, _config, _signal, next): Promise<LlmCallConfig> => {
const resolved = await next()
const selected = target.assembled
return selected === undefined ? resolved : {
...resolved,
provider: selected.provider,
model: selected.model,
}
},
)
return () => {
disposeAssembly()
disposeRequest()
}
}

View File

@@ -7,7 +7,7 @@
import type { Context } from 'cordis'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
declare module '@deepseek-ai/dsh-system-prompt' {
@@ -25,7 +25,10 @@ export interface AgentOptions {
model?: string
}
/** Message options; an omitted source resolves to `{ kind: 'user' }`, so plugins must label their own content. */
/**
* Message options. An omitted source attests direct human input as `{ kind: 'user' }`
* and may authorize policy consumers, so non-human producers must label their content.
*/
export interface SendOptions {
source?: MessageSource
}
@@ -38,9 +41,9 @@ export interface InjectOptions extends SendOptions {
/**
* An agent's lifecycle state, emitted on every transition as `agent/status`:
* `idle` (parked, waiting for queued work), `running` (a turn is in progress),
* `disposed` (terminal — no transition leaves it, and `send`/`steer`/`inject`
* throw).
* `idle` (parked, waiting for queued work), `running` (the driver is draining
* work and may be closing or checkpointing a turn), `disposed` (terminal — no
* transition leaves it, and `send`/`steer`/`inject` throw).
*/
export type AgentStatus = 'idle' | 'running' | 'disposed'
@@ -54,8 +57,9 @@ export interface HookContext {
/**
* Prompt interception result. `allow.content` replaces the prompt and each
* `additionalContexts` entry becomes a separate context message. `block` records a
* durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn.
* `additionalContexts` entry becomes a separate context message. `block`
* records a durable `prompt/blocked` and ends the claimed prompt's zero-step
* turn as rejected.
*/
export type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
@@ -82,6 +86,14 @@ export type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }>
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
/** Stable runtime cause accepted by {@link Agent.cancel}. */
export type AgentCancelCause =
| { readonly kind: 'user' }
| { readonly kind: 'parent' }
/** Runtime reason carried by the signal that controls one live turn. */
export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' }
/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */
export interface Agent {
/** The single identity shared with {@link session}. */
@@ -93,15 +105,20 @@ export interface Agent {
readonly ctx: Context
/**
* Queue detached, frozen lossless-JSON input; starts a turn when idle.
* Queue one detached, frozen lossless-JSON item. If claimed, it is the sole
* ordinary message in its FIFO-ordered turn; the next claimed item waits for
* that turn's checkpoint.
* Invalid input throws synchronously before notification or enqueue.
*/
send(content: ContentBlock[], options?: SendOptions): void
/**
* Steer a running turn: content is injected between steps of the current
* turn. Uses the same owned-value and synchronous-validation boundary as
* {@link send}; when idle, behaves exactly like that method.
* Submit steering while the agent is `running`. An open turn records it at
* the next steering checkpoint before a request or continuation decision;
* policy may stop before another step. After turn close and its checkpoint,
* any remainder is queued for a later turn; terminal `agent/turn-stop`,
* cancellation, or disposal may discard it. Uses the same synchronous
* snapshot-and-validation boundary as {@link send}; when idle, delegates to it.
*/
steer(content: ContentBlock[], options?: SendOptions): void
@@ -115,12 +132,15 @@ export interface Agent {
inject(content: ContentBlock[], options?: InjectOptions): void
/**
* Clear queued and steering work, including work waiting to start, and abort
* the active step. The supplied reason is preserved across pre-step and active
* cancellation windows, and `whenIdle()` resolves after cancellation reaches
* quiescence. Idle cancellation is a no-op and does not arm a later cancel.
* Clear all queued and steering work, including items waiting to start, and
* abort the active turn. An effective call first emits
* `agent/cancel-requested` with the resolved typed cause. The first cause wins
* for the active turn, and `whenIdle()` resolves after cancellation reaches
* quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op
* and does not arm later work. The active turn snapshots and freezes the cause.
* @param cause - the stable caller intent carried by the current turn signal.
*/
cancel(reason?: string): void
cancel(cause?: AgentCancelCause): void
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
whenIdle(): Promise<void>
@@ -169,6 +189,16 @@ declare module 'cordis' {
* @mode emit
*/
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
/**
* Effective broad cancellation was requested, before queued/steering work
* is cleared or the active turn is aborted. This observe-only notification
* cannot veto cancellation; listener failures are contained.
* @param agent - the agent whose current work is being cancelled.
* @param cause - resolved typed cancellation cause, including the default.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/cancel-requested'(this: Scoped<Agent>, agent: Agent, cause: AgentCancelCause): void
// ---- session lifecycle (emit) ----
/**
@@ -199,15 +229,18 @@ declare module 'cordis' {
*/
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void
/**
* Allow, rewrite, or block one drained prompt before it becomes a user
* message. Call `next()` for the unchanged default.
* @param agent - the agent draining its inbox.
* @param content - the drained message's blocks, as queued.
* Allow, rewrite, or block one claimed prompt before it becomes a user
* message. Call `next()` for the unchanged default. The signal controls only
* this turn; listeners may cooperate with it but must not retain it to
* control another turn.
* @param agent - the agent whose turn claimed the message.
* @param content - the claimed message's blocks, as queued.
* @param source - the message's resolved source.
* @param signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>
/**
* Replace the frozen call configuration. Model-visible content must use
* logged channels; this seam cannot mutate messages. Injection here joins
@@ -216,10 +249,12 @@ declare module 'cordis' {
* @param turn - the open turn number.
* @param step - the step whose request this is.
* @param config - the config the loop would use (frozen); return a replacement to switch.
* @param signal - the current turn's explicit abort signal; ambient
* initiator identity does not imply liveness or cancellation authority.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
/**
* Compose request-only messages placed before derived history. The frozen
* result is computed once per loop instance, logged on its anchoring request
@@ -231,7 +266,7 @@ declare module 'cordis' {
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param agent - the agent whose session prefix is being composed.
* @param prefix - the frozen seed; return an extended replacement.
* @param signal - aborts composition when the step is torn down.
* @param signal - the current turn's explicit abort signal.
* @mode waterfall
*/
'agent/session-prefix'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
@@ -242,10 +277,11 @@ declare module 'cordis' {
* @param turn - the open turn number.
* @param step - the step that produced the message.
* @param message - the assistant message as assembled from the stream.
* @param signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise<Message>): Promise<Message>
/**
* Awaited serial checkpoint after the response, real or synthetic tool
* results, injected context, and steering are durable but before `step/end`.
@@ -266,32 +302,35 @@ declare module 'cordis' {
* @param turn - the open turn number.
* @param step - the failed step number.
* @param error - the original model-request failure.
* @param retryAttempt - zero-based number of prior recovery retries.
* @param failure - serializable facts normalized at the final adapter boundary.
* @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.
* @param signal - the turn abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise<RequestErrorDecision>): Promise<RequestErrorDecision>
'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise<RequestErrorDecision>): Promise<RequestErrorDecision>
/**
* Override whether the turn continues. The default continues after tool
* calls or steering and stops otherwise; a continue reason becomes steering.
* @param agent - the agent deciding whether to run another step.
* @param turn - the turn being continued or stopped.
* @param defaultDecision - what the loop would do absent an override.
* @param signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
/**
* Monotonic terminal-stop checkpoint after continuation and steering are
* folded; a stop remains authoritative through turn close and flush:
* steering queued in that window is discarded, while ordinary sends survive.
* @param agent - the agent whose composed continuation outcome may be stopped.
* @param turn - the turn at its terminal-stop checkpoint.
* @param signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode serial
*/
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): ContinuationStop | undefined
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
// ---- error notifications (emit) ----
/**

View File

@@ -2,9 +2,12 @@ import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context, Service, symbols } from 'cordis'
import type { Events } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import AgentRegistry, {
agentEvents,
agentInterruptReasonOf,
} from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
function stubAgent(rawId: string): Agent {
const id = SessionId(rawId)
@@ -23,12 +26,12 @@ function stubAgent(rawId: string): Agent {
}
describe('AgentRegistry', () => {
it('keeps terminal stop decisions synchronous', () => {
it('allows terminal stop policy to cooperate asynchronously with turn cancellation', () => {
type TurnStopListener = Events['agent/turn-stop']
type AsyncTurnStopListener = () => Promise<ContinuationStop | undefined>
expectTypeOf<AsyncTurnStopListener>().not.toExtend<TurnStopListener>()
expectTypeOf<ReturnType<TurnStopListener>>().toEqualTypeOf<ContinuationStop | undefined>()
expectTypeOf<AsyncTurnStopListener>().toExtend<TurnStopListener>()
expectTypeOf<Awaited<ReturnType<TurnStopListener>>>().toEqualTypeOf<ContinuationStop | undefined>()
})
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
@@ -182,6 +185,40 @@ describe('agentEvents()', () => {
})
})
describe('explicit cancellation helpers', () => {
it('exposes the closed typed cancellation cause at the Agent seam', () => {
expectTypeOf<Parameters<Agent['cancel']>[0]>().toEqualTypeOf<AgentCancelCause | undefined>()
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', () => {
function stubFactory() {
const calls: {

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import InvariantService from '@deepseek-ai/dsh-invariants'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(InvariantService)
await ctx.plugin(AgentInvariant)
return ctx
}
function mockAgent(id: string): Agent {
return { id } as unknown as Agent
}
describe('agent status invariants', () => {
it('accepts lifecycle transitions through idle, running, and disposed', async () => {
const ctx = await setup()
const agent = mockAgent('a1')
expect(() => {
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
}).not.toThrow()
const running = mockAgent('a2')
ctx.emit(scopeTarget(running, running), 'agent/status', running, 'running')
expect(() => { ctx.emit(scopeTarget(running, running), 'agent/status', running, 'disposed') }).not.toThrow()
})
it('rejects a no-op transition', async () => {
const ctx = await setup()
const agent = mockAgent('a3')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') })
.toThrow(/no-op transition/)
})
it('rejects leaving the terminal disposed state', async () => {
const ctx = await setup()
const agent = mockAgent('a4')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') })
.toThrow(/left terminal state disposed/)
})
it('tracks agents independently', async () => {
const ctx = await setup()
const a = mockAgent('a5')
const b = mockAgent('b5')
ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running')
expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow()
})
})

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import {
agentEvents,
installAgentLlmTarget,
type Agent,
type AgentLlmTargetRef,
} from '../src/index.ts'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
describe('installAgentLlmTarget()', () => {
it('snapshots prompt variables and request routing together, then disposes both listeners', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
const target: AgentLlmTargetRef = { current: undefined, assembled: undefined }
const dispose = installAgentLlmTarget(ctx, target)
const agent = {} as Agent
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
)).resolves.toBe(seed)
target.current = { provider: 'alpha', model: 'a1' }
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' })
target.current = { provider: 'beta', model: 'b1' }
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
)).resolves.toEqual({ provider: 'alpha', model: 'a1', temperature: 0.2 })
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' })
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 1, seed, signal, () => Promise.resolve(seed),
)).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
dispose()
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 2, 0, seed, signal, () => Promise.resolve(seed),
)).resolves.toBe(seed)
await ctx.fiber.dispose()
})
})

View File

@@ -28,6 +28,9 @@
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'tsdown'
/** Build the package root and optional invariant companion as independent bundles. */
export default defineConfig([
{
entry: ['lib/types/index.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
{
entry: ['lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])

View File

@@ -12,11 +12,19 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics).
- `Scoped<T>` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties.
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
- `ScopeLayer` Aggregate contract for one registry's complete global or exact-scope contribution; `isEmpty()` controls scoped-layer reclamation.
- `ScopedLayers<L>` Own one eager global layer and lazy exact-scope layers. `peek()` never creates, `merge()` materializes insertion-ordered named shadows, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer.
- `NamedEntries<V>` Insertion-ordered named storage with caller-owned duplicate diagnostics, lookup, and live iteration within one nonempty table generation; draining the table detaches existing iterators from later insertions, and `insert()` returns an idempotent exact-entry undo.
- `AnonymousEntries<V>` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; it uses the same drained-generation iterator boundary, and `append()` returns an idempotent exact-entry undo.
The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime assertion. It uses the generated `scoped-events.generated.ts` resolver map to require a carrier for every declared scoped event and, when the payload exposes its routing subject, require identity with the carrier key. The Program-backed generator derives the map from event declarations and real `scopeTarget(base, key)` calls.
## Design contract
The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals.
Scope-aware services define a concrete `ScopeLayer` that aggregates their heterogeneous tables and domain helpers. `ScopedLayers.effect()` accepts one synchronous action returning one synchronous undo, installs that undo before optional notification, and reclaims an exact-scope layer only when the complete aggregate is empty. `notify` defaults to `true`; the supplied callback owns whether observer failures throw or are contained. `EntryValues` remains internal, the storage classes are imported from the package root rather than a `/store` subpath, and the shared storage does not define registry-specific filtering or iteration policy. See the [shared scoped-layer storage Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md).
Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.
## Known Limitations and Deferred Work

View File

@@ -11,20 +11,27 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -8,6 +8,9 @@
import type { Context, Fiber } from 'cordis'
import { Context as CordisContext } from 'cordis'
export { AnonymousEntries, NamedEntries, ScopedLayers } from './store.ts'
export type { ScopeLayer } from './store.ts'
/** An opaque, identity-compared scope key. */
export type ScopeKey = object

View File

@@ -0,0 +1,41 @@
/** Package-owned scoped-dispatch invariants. @module @deepseek-ai/dsh-scope/invariant */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope'
import { scopedSubjectResolverFor } from './scoped-events.generated.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-scope'
/** Cordis companion plugin name. */
export const name = 'scope-invariant'
/** Services required before the companion can register. */
export const inject = ['invariants']
/** Install the scoped-dispatch contribution into its child registration fiber. */
const install: InvariantInstaller = (ctx, fail) => {
ctx.on('internal/dispatch', (_mode, eventName, args, thisArg) => {
const subjectOf = scopedSubjectResolverFor(eventName)
if (subjectOf === undefined) return
if (!isScopeCarrier(thisArg)) {
fail(
`"${eventName}" is a scope-filtered event but was dispatched without a scope carrier — `
+ 'pass scopeTarget(base, subject) as the dispatch thisArg (agent events: use agentEvents(ctx, agent))',
)
}
if (subjectOf !== null && carrierKeyOf(thisArg) !== subjectOf(args)) {
fail(
`"${eventName}" was dispatched with a scope carrier keyed to a DIFFERENT subject than its arguments name — `
+ 'the carrier key and the event\'s subject must be the same object (use agentEvents(ctx, agent))',
)
}
}, { global: true })
}
/**
* Register the scope invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,52 @@
/**
* Generated scoped-event routing-subject resolvers for dsh-scope invariants.
* Do not edit by hand; run `pnpm run gen-scoped-events`.
*
* @module @deepseek-ai/dsh-scope/scoped-events.generated
*/
type ScopedSubjectResolver = (args: readonly unknown[]) => unknown
const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({
'agent/cancel-requested': args => args[0],
'agent/created': args => args[0],
'agent/disposed': args => args[0],
'agent/error': args => args[0],
'agent/post-step': args => args[0],
'agent/pre-step': args => args[0],
'agent/prompt-submit': args => args[0],
'agent/queued': args => args[0],
'agent/request': args => args[0],
'agent/request-error': args => args[0],
'agent/session-prefix': args => args[0],
'agent/session-start': args => args[0],
'agent/status': args => args[0],
'agent/step-result': args => args[0],
'agent/turn-continuation': args => args[0],
'agent/turn-stop': args => args[0],
'approval/request': args => (args[0] as Record<string, unknown>)['agent'],
'goal/changed': args => args[0],
'session/created': null,
'session/disposed': null,
'session/event': null,
'session/flush': null,
'subagent/end': null,
'subagent/start': null,
'system-prompt/assemble': args => (args[1] as Record<string, unknown>)['scope'],
'tools/execute': args => (args[0] as Record<string, unknown>)['agent'],
'tools/post-execute': args => (args[0] as Record<string, unknown>)['agent'],
'tools/pre-execute': args => (args[0] as Record<string, unknown>)['agent'],
'tools/result': args => (args[0] as Record<string, unknown>)['agent'],
})
/**
* Resolve the routing key named by one scoped event payload. A null
* resolver means the payload cannot expose its external routing key, so the
* invariant checks carrier presence only.
* @param event - runtime Cordis event name.
* @returns the generated subject resolver, null for presence-only,
* or undefined when the event is not scope-filtered.
*/
export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {
return scopedSubjectResolvers[event]
}

View File

@@ -0,0 +1,247 @@
/**
* Shared insertion-ordered storage and effect ownership for scope-aware registries.
*
* @module @deepseek-ai/dsh-scope
*/
import type { Context } from 'cordis'
import { scopeOf } from './index.ts'
import type { ScopeKey } from './index.ts'
/** One scope's aggregate contribution to a registry. */
export interface ScopeLayer {
/** Whether every table in this layer is empty. */
isEmpty(): boolean
}
/** Internal common read contract for the two entry-table implementations. */
interface EntryValues<V> {
values(): IterableIterator<V>
isEmpty(): boolean
}
/**
* Insertion-ordered named entries with caller-owned duplicate diagnostics.
*
* Values are borrowed. Iterators are live within one nonempty table
* generation; draining the table detaches them from later insertions. Each
* successful insertion returns an idempotent undo for that exact entry.
*/
export class NamedEntries<V> implements EntryValues<V> {
private data = new Map<string, V>()
constructor(
private readonly duplicateError: (name: string) => Error,
) {}
/**
* Insert one unique name.
* @param name - name unique within this table.
* @param value - borrowed value to retain.
* @returns an idempotent undo that removes only this insertion.
*/
insert(name: string, value: V): () => void {
const data = this.data
if (data.has(name)) throw this.duplicateError(name)
data.set(name, value)
let active = true
return () => {
if (!active) return
active = false
data.delete(name)
if (data.size === 0 && this.data === data) this.data = new Map()
}
}
/**
* Read one named value.
* @param name - name to resolve.
* @returns the retained value, or `undefined` when absent.
*/
get(name: string): V | undefined {
return this.data.get(name)
}
/**
* Test one name for membership.
* @param name - name to test.
* @returns whether the table contains that name.
*/
has(name: string): boolean {
return this.data.has(name)
}
/**
* Iterate live names in insertion order.
* @returns the native live key iterator.
*/
keys(): IterableIterator<string> {
return this.data.keys()
}
/**
* Iterate live entries in insertion order.
* @returns the native live entry iterator.
*/
entries(): IterableIterator<[string, V]> {
return this.data.entries()
}
/**
* Iterate live values in insertion order.
* @returns the native live value iterator.
*/
values(): IterableIterator<V> {
return this.data.values()
}
/**
* Test whether this table has no entries.
* @returns whether the table is empty.
*/
isEmpty(): boolean {
return this.data.size === 0
}
}
/**
* Insertion-ordered anonymous entries with independent registration identity.
*
* Equal values remain separate registrations. Values are borrowed, and
* iterators are live within one nonempty table generation; draining the table
* detaches them from later appends.
*/
export class AnonymousEntries<V> implements EntryValues<V> {
private data = new Map<symbol, V>()
/**
* Append one independently owned value.
* @param value - borrowed value to retain.
* @returns an idempotent undo for this exact append.
*/
append(value: V): () => void {
const data = this.data
const key = Symbol()
data.set(key, value)
let active = true
return () => {
if (!active) return
active = false
data.delete(key)
if (data.size === 0 && this.data === data) this.data = new Map()
}
}
/**
* Iterate live values in insertion order.
* @returns the native live value iterator.
*/
values(): IterableIterator<V> {
return this.data.values()
}
/**
* Test whether this table has no entries.
* @returns whether the table is empty.
*/
isEmpty(): boolean {
return this.data.size === 0
}
}
/**
* Own the global and exact-scope layers for one registry.
*
* Reads never create scoped layers. Registrations derive both visibility and
* effect ownership from the supplied Cordis context, collect undo before
* notification, and reclaim only a completely empty aggregate layer.
*/
export class ScopedLayers<L extends ScopeLayer> {
/** The eagerly constructed context-global layer. */
readonly global: L
private readonly scoped = new Map<ScopeKey, L>()
constructor(
private readonly createLayer: (scope: ScopeKey | undefined) => L,
private readonly onChange: () => void,
) {
this.global = createLayer(undefined)
}
/**
* Read an existing exact-scope overlay.
* @param scope - exact scope key; `undefined` denotes no overlay.
* @returns the existing scoped layer, or `undefined` without creating one.
*/
peek(scope: ScopeKey | undefined): L | undefined {
if (scope === undefined) return undefined
return this.scoped.get(scope)
}
/**
* Materialize global named entries followed by exact-scope shadows.
* @param scope - exact viewing scope, or `undefined` for the global view.
* @param pick - select the named table from a layer.
* @returns an insertion-ordered effective map.
*/
merge<V>(
scope: ScopeKey | undefined,
pick: (layer: L) => NamedEntries<V>,
): Map<string, V> {
const merged = new Map(pick(this.global).entries())
const layer = this.peek(scope)
if (layer === undefined) return merged
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
return merged
}
/**
* Attach one synchronous layer mutation to its registration context.
* @param ctx - context that determines both scope visibility and effect ownership.
* @param action - atomic mutation returning its synchronous undo.
* @param options - Cordis effect label and optional change notification.
* @returns the exact disposer returned by `ctx.effect()`.
*/
effect(
ctx: Context,
action: (layer: L) => () => void,
options: { label: string; notify?: boolean },
): () => void {
const scope = scopeOf(ctx)
const notify = options.notify ?? true
const dispose = ctx.effect(function* (this: ScopedLayers<L>) {
let layer: L
let created = false
if (scope === undefined) {
layer = this.global
} else {
const existing = this.scoped.get(scope)
if (existing === undefined) {
layer = this.createLayer(scope)
this.scoped.set(scope, layer)
created = true
} else {
layer = existing
}
}
let undo: () => void
try {
undo = action(layer)
} catch (error) {
if (scope !== undefined && created && layer.isEmpty()) this.scoped.delete(scope)
throw error
}
yield () => {
undo()
if (scope !== undefined && layer.isEmpty()) this.scoped.delete(scope)
if (notify) this.onChange()
}
if (notify) this.onChange()
}.bind(this), options.label)
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity
return dispose
}
}

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Events } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(InvariantService)
await ctx.plugin(ScopeInvariant)
return ctx
}
function emit(ctx: Context, receiver: object | undefined, event: string, args: unknown[]): void {
const dispatch = ctx.emit.bind(ctx) as (...values: unknown[]) => void
if (receiver === undefined) dispatch(event, ...args)
else dispatch(receiver, event, ...args)
}
describe('scoped-dispatch invariants', () => {
type AgentEventName = Extract<keyof Events, `agent/${string}`>
type EventArgs<K extends keyof Events> = Events[K] extends (...args: infer Args) => unknown ? Args : never
it('ignores ordinary events and rejects a scoped dispatch without a carrier', async () => {
const ctx = await setup()
expect(() => { emit(ctx, undefined, 'ordinary/event', []) }).not.toThrow()
const agent = { id: 'a1' }
expect(() => { emit(ctx, undefined, 'agent/error', [agent, 1, 0, new Error('x')]) })
.toThrow(/dispatched without a scope carrier/)
})
it('checks every generated subject resolver against the carrier key', async () => {
const ctx = await setup()
const agent = { id: 'a1' } as unknown as Agent
const other = { id: 'a2' } as unknown as Agent
const signal = new AbortController().signal
const config = { provider: 'p', model: 'm' }
const message = { role: 'assistant' as const, content: [] }
const agentRows = {
'agent/created': [agent],
'agent/disposed': [agent],
'agent/status': [agent, 'idle'],
'agent/queued': [agent, [], { source: { kind: 'user' }, steering: false }],
'agent/cancel-requested': [agent, { kind: 'user' }],
'agent/session-start': [agent, 'startup'],
'agent/pre-step': [agent, 1, 1, signal],
'agent/post-step': [agent, 1, 1, signal],
'agent/prompt-submit': [agent, [], { kind: 'user' }, signal, () => Promise.resolve({ kind: 'allow' })],
'agent/request': [agent, 1, 1, config, signal, () => Promise.resolve(config)],
'agent/request-error': [agent, 1, 1, new Error('request failed'), { message: 'request failed', code: 'UNKNOWN' }, [], signal, () => Promise.resolve({ action: 'fail' })],
'agent/session-prefix': [agent, [], signal, () => Promise.resolve([])],
'agent/step-result': [agent, 1, 1, message, signal, () => Promise.resolve(message)],
'agent/turn-continuation': [agent, 1, { action: 'stop' }, signal, () => Promise.resolve({ action: 'stop' })],
'agent/turn-stop': [agent, 1, signal],
'agent/error': [agent, 1, 0, new Error('x')],
} satisfies { [K in AgentEventName]: EventArgs<K> }
const rows: Array<[string, unknown[]]> = [
...Object.entries(agentRows),
['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]],
['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]],
['system-prompt/assemble', [[], { scope: agent }]],
['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]],
['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]],
]
for (const [event, args] of rows) {
expect(() => { emit(ctx, scopeTarget(agent, agent), event, args) }, `${event} matching`).not.toThrow()
expect(() => { emit(ctx, scopeTarget(agent, other), event, args) }, `${event} mismatched`)
.toThrow(/DIFFERENT subject/)
}
})
it('requires carriers for generated presence-only scoped events without comparing a payload subject', async () => {
const ctx = await setup()
const agent = { id: 'a1' }
const rows: Array<[string, unknown[]]> = [
['session/created', [{}]],
['session/disposed', [{}]],
['session/event', [{}, {}]],
['session/flush', [{}]],
['subagent/end', [{}]],
['subagent/start', [{}]],
]
for (const [event, args] of rows) {
expect(() => { emit(ctx, scopeTarget(agent, agent), event, args) }, `${event} carrier`).not.toThrow()
expect(() => { emit(ctx, undefined, event, args) }, `${event} no carrier`)
.toThrow(/dispatched without a scope carrier/)
}
})
})

View File

@@ -0,0 +1,289 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import {
AnonymousEntries,
createScope,
NamedEntries,
ScopedLayers,
type Scope,
type ScopeKey,
type ScopeLayer,
} from '@deepseek-ai/dsh-scope'
class TestLayer implements ScopeLayer {
readonly named: NamedEntries<number>
readonly anonymous = new AnonymousEntries<string>()
constructor(scope: ScopeKey | undefined) {
this.named = new NamedEntries(name =>
new Error(`${scope === undefined ? 'global' : 'scoped'} duplicate: ${name}`))
}
isEmpty(): boolean {
return this.named.isEmpty() && this.anonymous.isEmpty()
}
}
/** Mint one active scope for lifecycle tests. */
async function mintScope(ctx: Context, key: ScopeKey): Promise<Scope> {
let scope!: Scope
await ctx.plugin((inner: Context) => { scope = createScope(inner, key) })
return scope
}
describe('NamedEntries', () => {
it('owns duplicate diagnostics, lookup, insertion order, live iteration, and exact idempotent undo', () => {
const duplicate = new Error('caller duplicate')
const duplicateError = vi.fn(() => duplicate)
const entries = new NamedEntries<number>(duplicateError)
const undoA = entries.insert('a', 1)
const values = entries.values()
expect(values.next()).toEqual({ value: 1, done: false })
const undoB = entries.insert('b', 2)
expect([...values]).toEqual([2])
expect([...entries.keys()]).toEqual(['a', 'b'])
expect([...entries.entries()]).toEqual([['a', 1], ['b', 2]])
expect(entries.get('a')).toBe(1)
expect(entries.get('missing')).toBeUndefined()
expect(entries.has('b')).toBe(true)
expect(entries.has('missing')).toBe(false)
expect(entries.isEmpty()).toBe(false)
expect(() => entries.insert('a', 3)).toThrow(duplicate)
expect(duplicateError).toHaveBeenCalledWith('a')
undoA()
entries.insert('a', 3)
undoA()
expect(entries.get('a')).toBe(3)
undoB()
expect([...entries.entries()]).toEqual([['a', 3]])
})
it('starts a fresh iterator generation after the table drains', () => {
const entries = new NamedEntries<number>(name => new Error(`duplicate: ${name}`))
const undo = entries.insert('first', 1)
const values = entries.values()
expect(values.next()).toEqual({ value: 1, done: false })
undo()
entries.insert('replacement', 2)
expect(values.next().done).toBe(true)
expect([...entries.values()]).toEqual([2])
})
})
describe('AnonymousEntries', () => {
it('owns equal values independently with live insertion-ordered iteration and idempotent undo', () => {
const entries = new AnonymousEntries<object>()
const value = {}
const undoFirst = entries.append(value)
const values = entries.values()
expect(values.next()).toEqual({ value, done: false })
const undoSecond = entries.append(value)
expect([...values]).toEqual([value])
expect([...entries.values()]).toEqual([value, value])
undoFirst()
undoFirst()
expect([...entries.values()]).toEqual([value])
undoSecond()
expect(entries.isEmpty()).toBe(true)
})
it('starts a fresh iterator generation after the table drains', () => {
const entries = new AnonymousEntries<number>()
const undo = entries.append(1)
const values = entries.values()
expect(values.next()).toEqual({ value: 1, done: false })
undo()
entries.append(2)
expect(values.next().done).toBe(true)
expect([...entries.values()]).toEqual([2])
})
})
describe('ScopedLayers', () => {
it('constructs global state eagerly while reads stay non-creating and merge named shadows in order', () => {
const created: Array<ScopeKey | undefined> = []
const layers = new ScopedLayers(
(scope) => {
created.push(scope)
return new TestLayer(scope)
},
vi.fn(),
)
const key = {}
layers.global.named.insert('a', 1)
layers.global.named.insert('shared', 2)
expect(created).toEqual([undefined])
expect(layers.peek(undefined)).toBeUndefined()
expect(layers.peek(key)).toBeUndefined()
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2]])
expect(created).toEqual([undefined])
})
it('uses the same scoped context for lazy visibility and ownership, and reclaims only an empty aggregate', async () => {
const ctx = new Context()
const key = {}
const scope = await mintScope(ctx, key)
const changed = vi.fn()
const created: Array<ScopeKey | undefined> = []
const layers = new ScopedLayers(
(selected) => {
created.push(selected)
return new TestLayer(selected)
},
changed,
)
layers.global.named.insert('a', 1)
layers.global.named.insert('shared', 1)
const removeNamed = layers.effect(
scope.ctx,
layer => layer.named.insert('shared', 2),
{ label: 'test.named', notify: false },
)
const removeTail = layers.effect(
scope.ctx,
layer => layer.named.insert('c', 3),
{ label: 'test.tail', notify: false },
)
const removeAnonymous = layers.effect(
scope.ctx,
layer => layer.anonymous.append('kept'),
{ label: 'test.anonymous', notify: false },
)
expect(created).toEqual([undefined, key])
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2], ['c', 3]])
expect(changed).not.toHaveBeenCalled()
removeNamed()
expect(layers.peek(key)).toBeDefined()
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 1], ['c', 3]])
removeTail()
expect(layers.peek(key)).toBeDefined()
removeAnonymous()
expect(layers.peek(key)).toBeUndefined()
await scope.dispose()
})
it('runs action, notification, undo, and disposal notification in order with Cordis idempotence and labels', async () => {
const ctx = new Context()
const events: string[] = []
const layers = new ScopedLayers(
scope => new TestLayer(scope),
() => void events.push('notify'),
)
const dispose = layers.effect(
ctx,
(layer) => {
events.push('action')
const undo = layer.named.insert('x', 1)
return () => {
events.push('undo')
undo()
}
},
{ label: 'store.order' },
)
expect(events).toEqual(['action', 'notify'])
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain('store.order')
dispose()
dispose()
expect(events).toEqual(['action', 'notify', 'undo', 'notify'])
expect(layers.global.isEmpty()).toBe(true)
})
it('returns the exact context effect disposer', () => {
const rawDispose = vi.fn()
const effect = vi.fn(() => rawDispose)
const ctx = { effect } as unknown as Context
const action = vi.fn(() => vi.fn())
const layers = new ScopedLayers(scope => new TestLayer(scope), vi.fn())
const returned = layers.effect(ctx, action, { label: 'store.identity', notify: false })
expect(returned).toBe(rawDispose)
expect(effect).toHaveBeenCalledWith(expect.any(Function), 'store.identity')
expect(action).not.toHaveBeenCalled()
})
it('cleans up failed factories and empty failed actions without discarding an existing layer', async () => {
const ctx = new Context()
const key = {}
const scope = await mintScope(ctx, key)
let failFactory = true
const layers = new ScopedLayers(
(selected) => {
if (selected !== undefined && failFactory) throw new Error('factory failed')
return new TestLayer(selected)
},
vi.fn(),
)
expect(() => layers.effect(
scope.ctx,
layer => layer.named.insert('never', 1),
{ label: 'store.factory', notify: false },
)).toThrow('factory failed')
expect(layers.peek(key)).toBeUndefined()
failFactory = false
expect(() => layers.effect(
scope.ctx,
() => { throw new Error('action failed') },
{ label: 'store.action', notify: false },
)).toThrow('action failed')
expect(layers.peek(key)).toBeUndefined()
const dispose = layers.effect(
scope.ctx,
layer => layer.named.insert('kept', 1),
{ label: 'store.kept', notify: false },
)
expect(() => layers.effect(
scope.ctx,
() => { throw new Error('second action failed') },
{ label: 'store.existing-action', notify: false },
)).toThrow('second action failed')
expect(layers.peek(key)?.named.get('kept')).toBe(1)
dispose()
await scope.dispose()
})
it('rolls back a scoped insertion when notification throws', async () => {
const ctx = new Context()
const key = {}
const scope = await mintScope(ctx, key)
const events: string[] = []
let notifications = 0
const layers = new ScopedLayers(
selected => new TestLayer(selected),
() => {
events.push('notify')
if (++notifications === 1) throw new Error('change failed')
},
)
expect(() => layers.effect(
scope.ctx,
(layer) => {
const undo = layer.named.insert('rollback', 1)
return () => {
events.push('undo')
undo()
}
},
{ label: 'store.rollback' },
)).toThrow('change failed')
expect(events).toEqual(['notify', 'undo', 'notify'])
expect(layers.peek(key)).toBeUndefined()
await scope.dispose()
})
})

View File

@@ -13,6 +13,9 @@
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'tsdown'
/** Build the package root and optional invariant companion as independent bundles. */
export default defineConfig([
{
entry: ['lib/types/index.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
{
entry: ['lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
// Preserve the root entry's carrier WeakMap identity across bundles.
deps: { neverBundle: ['@deepseek-ai/dsh-scope'] },
},
])

View File

@@ -2,14 +2,18 @@
Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it. A **surface** layer (an ordered projection of message-producing events) is maintained on top of the raw log for efficient derivation and compaction.
The optional `@deepseek-ai/dsh-session/invariant` companion registers this package's relational trace checks with `ctx.invariants`: monotonic sequence numbers, turn/step enclosure, and same-step tool call/result pairing. It replays existing sessions when loaded or reloaded; storage validation, snapshotting, freezing, provenance, and surface acceptance remain always-on responsibilities of the root session package.
## Service: `SessionStore` (ctx key: `sessions`)
Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event`, flush on `session/flush`, and may mirror the paired `session/created`/`session/disposed` lifecycle.
### Public API
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt` and `seedLength`.
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` accepts only plugin event types opted into `OutOfBandSessionEventMap`. It appends directly inside an open turn; otherwise it atomically opens a zero-step plugin turn, appends, closes, and flushes. A target failure still closes and flushes the synthetic turn, and detach is deferred until the sequence settles.
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest turn boundary because a later injection or plugin-owned zero-step turn has its own outcome.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -32,13 +36,13 @@ The store pairs announced creation with disposal, publishes post-commit append n
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, complete replacement coverage, and content-only single-result `tool/result` rewrites, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback.
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants.
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and request checks.
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
- `session.seq`, `session.id` — current sequence and readonly typed identity.
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`.
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`.
### Lossless JSON utilities
@@ -49,7 +53,7 @@ Durable values need one accepted representation, not a check followed by a secon
- `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them.
- `SurfaceIntent``{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
- `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`.
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface entry; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log.
### Request-header reconstruction (`request-header.ts`)
@@ -60,11 +64,13 @@ Durable values need one accepted representation, not a check followed by a secon
### Session event vocabulary (`types.ts`)
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token usage and provider/model/replay provenance ride on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure.
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, the hook bridges' `hook/*`); merged members appear in the same catalog.
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. `OutOfBandSessionEventMap` is a separate empty-by-default marker map: an event owner must merge the same key there before `appendOutOfBand()` accepts that log-only type, while surface and lifecycle types remain excluded.
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings).
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step.
An interrupted live turn ends with the coarse `{ kind: 'aborted' }` outcome. Caller identity belongs to the Agent's runtime cancellation signal rather than the durable transcript; disposal remains the separate `{ kind: 'disposed' }` terminal state.
Every `SessionEvent` carries two optional top-level fields (structural metadata):
@@ -73,13 +79,13 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
### Metadata types (`types.ts`)
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
### Extension points
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model and assistant messages require provider/model provenance. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface entries behind a summary checkpoint. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership and `replaceGeneration`.
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model, assistant messages require provider/model provenance, and a coarse aborted outcome must contain only `{ kind: 'aborted' }` (legacy reason-bearing records are rejected). `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
- Compaction: `dsh-compact-basic` appends a `user/message` replacement for summary checkpoints, while `dsh-compact-tool-result-prune` appends a content-only `tool/result` replacement. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership, replacement validation, and `replaceGeneration`.
## Model Experience

View File

@@ -11,11 +11,25 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
"./package.json": "./package.json",
"./surface": {
"types": "./lib/types/surface.d.ts",
"default": "./lib/types/surface.js"
}
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -23,12 +37,14 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
@@ -27,6 +27,27 @@ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from '
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
* Find the latest closed message-triggered turn, excluding injection and
* plugin-owned zero-step turns.
* @param events - session events, or an owned suffix, to inspect.
* @returns the latest matching turn end, or `undefined`.
*/
export function findLastMessageTurnEnd(
events: readonly SessionEvent[],
): SessionEvent<'turn/end'> | undefined {
const messageTurns = new Set<number>()
let latest: SessionEvent<'turn/end'> | undefined
for (const event of events) {
if (event.type === 'turn/start') {
if (event.data.trigger.kind === 'message') messageTurns.add(event.data.turn)
continue
}
if (event.type === 'turn/end' && messageTurns.delete(event.data.turn)) latest = event
}
return latest
}
declare module 'cordis' {
interface Context {
sessions: SessionStore
@@ -113,6 +134,10 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
&& (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
throw new Error('session header seedLength must be a non-negative safe integer')
}
if (record.delegationDepth !== undefined
&& (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
throw new Error('session header delegationDepth must be a non-negative safe integer')
}
return deepFreeze(record as unknown as SessionHeader)
}
@@ -133,6 +158,7 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
throw new Error(`seed event at index ${index} has an invalid event envelope`)
}
assertCurrentLlmShape(event, index)
assertCurrentTurnEndShape(event, index)
}
/** Reject pre-provider request headers and assistant messages at the seed/load boundary. */
@@ -150,6 +176,22 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
}
}
/** Reject legacy aborted outcomes that persisted caller-owned reason detail. */
function assertCurrentTurnEndShape(event: Record<string, unknown>, index: number): void {
if (event['type'] !== 'turn/end') return
const data = event['data']
/* v8 ignore next -- this migration recognizes only the legacy object shape; format-wide payload validation is separate. */
if (typeof data !== 'object' || data === null) return
const reason = (data as Record<string, unknown>)['reason']
/* v8 ignore next -- non-object reasons cannot carry the legacy aborted detail this migration removes. */
if (typeof reason !== 'object' || reason === null || Array.isArray(reason)) return
const record = reason as Record<string, unknown>
if (record['kind'] === 'aborted'
&& (Object.keys(record).length !== 1 || !Object.hasOwn(record, 'kind'))) {
throw new Error(`seed turn/end at index ${index} uses unsupported reason-bearing aborted format`)
}
}
/** Whether an unknown value carries the current provider/model pair. */
function hasProviderModel(value: unknown): boolean {
if (typeof value !== 'object' || value === null) return false
@@ -206,6 +248,7 @@ interface SessionEntry {
announced: boolean
announcing: boolean
appending: boolean
outOfBand: boolean
detachRequested: boolean
detach(): void
}
@@ -380,7 +423,7 @@ export class Session {
} finally {
if (entry !== undefined) {
entry.appending = false
if (entry.detachRequested && !entry.announcing) entry.detach()
if (entry.detachRequested && !entry.announcing && !entry.outOfBand) entry.detach()
}
}
}
@@ -558,9 +601,9 @@ export class SessionStore extends Service {
* Create a session owned by the calling fiber: disposing that fiber stops
* event notification and removes the session from the store. `options.seed`
* populates the session with a copy of those events (replay/fork);
* `options.meta` attaches creation metadata (validated absolute `cwd`,
* `parentSession` lineage) as the immutable {@link SessionHeader} (the store
* fills `version`/`id`/`createdAt`).
* `options.meta` attaches creation metadata (validated absolute `cwd`, seed
* and parent lineage, and delegation depth) as the immutable
* {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
*
* For an agent whose session must be torn down IN ORDER with its loop (so the
* loop's final flush is captured before the store attachment ends), do NOT use this
@@ -622,6 +665,7 @@ export class SessionStore extends Service {
...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
}
return new Session(sessionId, seed, header)
}
@@ -663,6 +707,7 @@ export class SessionStore extends Service {
announced: false,
announcing: false,
appending: false,
outOfBand: false,
detachRequested: false,
detach: () => { this.detachEntered(entry) },
}
@@ -675,7 +720,7 @@ export class SessionStore extends Service {
// A lifecycle listener may own the advanced detach capability. Keep the
// entry and its publication hooks live until synchronous creation or append
// publication unwinds, then publish the paired disposal edge.
if (entry.announcing || entry.appending) {
if (entry.announcing || entry.appending || entry.outOfBand) {
entry.detachRequested = true
return
}
@@ -729,7 +774,7 @@ export class SessionStore extends Service {
}
} finally {
entry.announcing = false
if (entry.detachRequested && !entry.appending) entry.detach()
if (entry.detachRequested && !entry.appending && !entry.outOfBand) entry.detach()
}
}
@@ -773,6 +818,87 @@ export class SessionStore extends Service {
if (failure !== undefined) throw failure.reason
}
/**
* Append one plugin-declared log-only event without borrowing the agent
* loop's lifecycle. An open turn receives the event directly and remains
* responsible for its ordinary checkpoint. A closed log receives one
* zero-step turn around the event, followed by an awaited flush.
*
* Once the synthetic `turn/start` commits, this method always attempts its
* matching `turn/end` and flush, including when the target append fails.
* Detachment requested by an event or flush listener is deferred until that
* sequence settles, so publication cannot switch from a live scoped session
* to an unobserved bare `Session` halfway through the update.
*
* @param session - exact live session that owns the target log.
* @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner.
* @param data - typed JSON payload for the target event.
* @param trigger - plugin-owned turn trigger used only when the log is closed.
* @returns the accepted target event with its assigned sequence and timestamp.
* @throws when the session is detached, another out-of-band append is active,
* event acceptance fails, the synthetic turn cannot close, or flushing fails.
*/
async appendOutOfBand<T extends OutOfBandSessionEventType>(
session: Session,
type: T,
data: SessionEventMap[T],
trigger: TurnTrigger,
): Promise<SessionEvent<T>> {
const entry = this.liveEntryFor(session)
if (entry.outOfBand) {
throw new Error(`session "${session.id}" already has an out-of-band append in progress`)
}
entry.outOfBand = true
// `T` is excluded from SurfaceEventType by OutOfBandSessionEventType, but
// TypeScript does not reduce Session.append's conditional rest parameter
// through a generic intersection. Preserve that proven two-argument call
// shape without widening the public Session.append overload.
const appendLogOnly = session.append.bind(session) as unknown as <K extends OutOfBandSessionEventType>(
eventType: K,
eventData: SessionEventMap[K],
) => SessionEvent<K>
try {
const lastBoundary = session.events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
if (lastBoundary?.type === 'turn/start') {
return appendLogOnly(type, data)
}
const lastStart = session.events.findLast(event => event.type === 'turn/start')
const turn = (lastStart?.data.turn ?? 0) + 1
let accepted: SessionEvent<T> | undefined
let failure: unknown
let opened = false
try {
session.append('turn/start', { turn, trigger })
opened = true
accepted = appendLogOnly(type, data)
} catch (error: unknown) {
failure = error
} finally {
if (opened) {
// The only target types admitted by OutOfBandSessionEventMap are
// log-only plugin events, so the synthetic turn remains open here.
session.append('turn/end', { turn, reason: { kind: 'completed' } })
try {
await this.flush(session)
} catch (error: unknown) {
if (failure === undefined) failure = error
}
}
}
if (failure !== undefined) {
// eslint-disable-next-line @typescript-eslint/only-throw-error -- preserve an arbitrary flush-listener rejection exactly
throw failure
}
/* v8 ignore next -- accepted is assigned unless an append failure was captured above. */
if (accepted === undefined) throw new Error('out-of-band append completed without an accepted event')
return accepted
} finally {
entry.outOfBand = false
if (entry.detachRequested && !entry.announcing && !entry.appending) entry.detach()
}
}
/** Return the exact live entry; detached/prepared objects reject. */
private liveEntryFor(session: Session): SessionEntry {
const entry = attachments.get(session)

View File

@@ -0,0 +1,238 @@
/**
* Package-owned relational invariants for the session event log. Load this
* companion beside `@deepseek-ai/dsh-invariants` to enable the checks.
*
* @module @deepseek-ai/dsh-session/invariant
*/
import type { Context } from 'cordis'
import { assertNever } from '@deepseek-ai/dsh-llm'
import type { CallId } from '@deepseek-ai/dsh-llm'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
const PACKAGE_NAME = '@deepseek-ai/dsh-session'
/** Cordis companion plugin name. */
export const name = 'session-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Per-session bookkeeping for relational log checks. */
interface SessionTrace {
lastSeq: number
openTurn: number | null
openStep: number | null
nextTurn: number
nextStep: number
pendingCalls: Set<CallId>
}
/** One accepted event's deferred mutation of a committed session trace. */
interface SessionTraceTransition {
scalars: Pick<SessionTrace, 'lastSeq' | 'openTurn' | 'openStep' | 'nextTurn' | 'nextStep'>
pendingCalls:
| { kind: 'none' }
| { kind: 'add' | 'delete'; callId: CallId }
| { kind: 'clear' }
}
/** Assert that a step-scoped event names the currently open turn and step. */
function requireOpenStep(
trace: SessionTrace,
kind: string,
turn: number,
step: number,
fail: InvariantFailure,
): void {
if (trace.openTurn !== turn || trace.openStep !== step) {
fail(`${kind} names turn ${turn}/step ${step} but open is turn ${trace.openTurn}/step ${trace.openStep}`)
}
}
/** Validate one candidate event without mutating the committed trace. */
function validateEvent(
trace: SessionTrace,
event: SessionEvent,
fail: InvariantFailure,
): SessionTraceTransition {
if (event.seq <= trace.lastSeq) {
fail(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`)
}
let openTurn = trace.openTurn
let openStep = trace.openStep
let nextTurn = trace.nextTurn
let nextStep = trace.nextStep
let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
// SessionEventMap is merge-extensible, so the default enforces turn
// enclosure for package-added events as well as the built-in variants.
switch (event.type) {
case 'turn/start': {
if (trace.openTurn !== null) {
fail(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`)
}
if (event.data.turn !== trace.nextTurn) {
fail(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`)
}
openTurn = event.data.turn
nextStep = 1
break
}
case 'turn/end': {
if (trace.openTurn !== event.data.turn) {
fail(`turn/end ${event.data.turn} does not match open turn ${trace.openTurn}`)
}
if (trace.openStep !== null) {
fail(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`)
}
openTurn = null
nextTurn += 1
break
}
case 'step/start': {
if (trace.openTurn !== event.data.turn) {
fail(`step/start in turn ${event.data.turn} but open turn is ${trace.openTurn}`)
}
if (trace.openStep !== null) {
fail(`step/start ${event.data.step} while step ${trace.openStep} is still open`)
}
if (event.data.step !== trace.nextStep) {
fail(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`)
}
openStep = event.data.step
break
}
case 'step/end': {
requireOpenStep(trace, 'step/end', event.data.turn, event.data.step, fail)
pendingCalls = { kind: 'clear' }
openStep = null
nextStep += 1
break
}
case 'assistant/chunk': {
requireOpenStep(trace, 'assistant/chunk', event.data.turn, event.data.step, fail)
break
}
case 'assistant/message': {
requireOpenStep(trace, 'assistant/message', event.data.turn, event.data.step, fail)
break
}
case 'tool/call': {
requireOpenStep(trace, 'tool/call', event.data.turn, event.data.step, fail)
pendingCalls = { kind: 'add', callId: event.data.callId }
break
}
case 'tool/result': {
// Session has already validated a provenance-backed content rewrite.
// It is durable turn work, not a second execution of the original call.
if (event.surfaceOp !== 'append') {
if (trace.openTurn === null) {
fail('tool/result surface replacement appended outside any open turn')
}
break
}
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail)
const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) {
fail(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
}
pendingCalls = { kind: 'delete', callId: event.data.callId }
break
}
default: {
if (trace.openTurn === null) {
fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`)
}
break
}
}
return {
scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep },
pendingCalls,
}
}
/** Apply one already-validated transition after its event commits. */
function applyTransition(trace: SessionTrace, transition: SessionTraceTransition): void {
Object.assign(trace, transition.scalars)
switch (transition.pendingCalls.kind) {
case 'none':
break
case 'add':
trace.pendingCalls.add(transition.pendingCalls.callId)
break
case 'delete':
trace.pendingCalls.delete(transition.pendingCalls.callId)
break
case 'clear':
trace.pendingCalls.clear()
break
/* v8 ignore next -- validateEvent produces this closed transition union */
default:
assertNever(transition.pendingCalls, 'session trace pending-call transition')
}
}
/** Install the session contribution into its child registration fiber. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
const traces = new WeakMap<Session, SessionTrace>()
const stagedTransitions = new WeakMap<SessionEvent, {
session: Session
trace: SessionTrace
transition: SessionTraceTransition
}>()
const freshTrace = (): SessionTrace => ({
lastSeq: -1,
openTurn: null,
openStep: null,
nextTurn: 1,
nextStep: 1,
pendingCalls: new Set(),
})
const seedSession = (session: Session): SessionTrace => {
const trace = freshTrace()
traces.set(session, trace)
for (const event of session.events) {
applyTransition(trace, validateEvent(trace, event, fail))
}
return trace
}
/* v8 ignore next -- session/event always follows list() or session/created seeding */
const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seedSession(session)
for (const session of ctx.sessions.list()) seedSession(session)
ctx.on('session/created', (session) => { seedSession(session) }, { global: true })
ctx.on('session/event', (session, event) => {
const staged = stagedTransitions.get(event)
/* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */
if (staged === undefined || staged.session !== session) {
return fail('session/event reached publication without matching pre-commit validation')
}
stagedTransitions.delete(event)
applyTransition(staged.trace, staged.transition)
}, { global: true })
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
const trace = traceFor(session)
const transition = validateEvent(trace, event, fail)
// A later dispatch listener may veto. Validation is pure, so abandoning
// this weakly keyed transition does not advance or retain the session.
stagedTransitions.set(event, { session, trace, transition })
}, { global: true })
}, { inject: ['sessions'] })
/**
* Register the session invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -2,6 +2,9 @@
* Surface layer on top of the session event log: an ordered view of events
* that produce LLM messages. The append-only log remains the source of truth.
*
* Browser-safe: web clients consume this subpath export, so it must stay free
* of `node:` imports (they break the vite bundle).
*
* @module @deepseek-ai/dsh-session/surface
*/
@@ -187,11 +190,55 @@ function replacementRange(
}
}
/**
* Deep structural equality over the session-event JSON value domain
* (null/boolean/number/string, arrays, plain objects). Replaces
* `node:util`'s isDeepStrictEqual to keep this module browser-safe.
*/
function isDeepEqualJson(a: unknown, b: unknown): boolean {
if (a === b) return true
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
return a.every((item, i) => isDeepEqualJson(item, b[i]))
}
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
const aKeys = Object.keys(a)
const bRecord = b as Record<string, unknown>
if (aKeys.length !== Object.keys(b).length) return false
return aKeys.every(key => Object.hasOwn(b, key) && isDeepEqualJson((a as Record<string, unknown>)[key], bRecord[key]))
}
/** Restrict a tool-result replacement to one current result's content. */
function assertToolResultRewrite(
event: SessionEvent,
shadowedSeqs: readonly number[],
events: readonly SessionEvent[],
): void {
if (event.type !== 'tool/result') return
if (shadowedSeqs.length !== 1) {
throw new Error('tool/result surface replacement must rewrite exactly one current node')
}
for (const originalSeq of shadowedSeqs) {
const original = events[originalSeq]
if (original?.type !== 'tool/result') {
throw new Error('tool/result surface replacement must target a current tool/result')
}
const originalRest = { ...original.data } as Record<string, unknown>
const replacementRest = { ...event.data } as Record<string, unknown>
delete originalRest['content']
delete replacementRest['content']
if (!isDeepEqualJson(originalRest, replacementRest)) {
throw new Error('tool/result surface replacement may change only content')
}
}
}
/** Validate one event at its replay boundary and prepare its atomic fold transition. */
function planSurfaceEvent(
state: SurfaceFoldState,
event: SessionEvent,
expectedSeq: number,
events: readonly SessionEvent[],
): SurfacePlan | undefined {
if (event.seq !== expectedSeq) {
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
@@ -204,6 +251,7 @@ function planSurfaceEvent(
}
const range = replacementRange(state, surfaceOp)
assertProvenance(event, range.shadowedSeqs)
assertToolResultRewrite(event, range.shadowedSeqs, events)
return {
kind: 'replace',
seq: event.seq,
@@ -218,8 +266,9 @@ function applySurfaceEvent(
state: SurfaceFoldState,
event: SessionEvent,
expectedSeq: number,
events: readonly SessionEvent[],
): SurfaceFoldReplacement | undefined {
const plan = planSurfaceEvent(state, event, expectedSeq)
const plan = planSurfaceEvent(state, event, expectedSeq, events)
if (plan?.kind === 'append') {
state.nodes.push(plan.seq)
} else if (plan?.kind === 'replace') {
@@ -239,13 +288,13 @@ function applySurfaceEvent(
* Replay a complete session log through the canonical surface fold.
* @param events - session events in contiguous seq order.
* @returns detached current sequences and replacement history.
* @throws when an event violates surface metadata, provenance, or range rules.
* @throws when an event violates surface metadata, provenance, range, or tool-result rewrite rules.
*/
export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
const state = createFoldState()
const replacements: SurfaceFoldReplacement[] = []
for (const [index, event] of events.entries()) {
const replacement = applySurfaceEvent(state, event, index)
const replacement = applySurfaceEvent(state, event, index, events)
if (replacement !== undefined) replacements.push(replacement)
}
return { nodes: [...state.nodes], replacements }
@@ -266,7 +315,7 @@ export class SurfaceManager implements SessionSurface {
*/
validateNext(event: SessionEvent): void {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
planSurfaceEvent(this._state, event, this.log.length)
planSurfaceEvent(this._state, event, this.log.length, this.log)
}
/** Monotonic count of folded positional replacements. */
@@ -285,7 +334,7 @@ export class SurfaceManager implements SessionSurface {
private _processDelta(): void {
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
applySurfaceEvent(this._state, this.log[i]!, i)
applySurfaceEvent(this._state, this.log[i]!, i, this.log)
this._lastProcessedSeq = i
}
}

View File

@@ -1,5 +1,5 @@
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from './json.ts'
/** Identifies one session in the store (and its persistence artifacts). */
@@ -47,6 +47,12 @@ export interface SessionHeader {
* boundary lets resume and replay distinguish parent history from child work.
*/
readonly seedLength?: number
/**
* Delegation depth: absent (zero) for a top-level session, parent depth + 1
* for a subagent child. Persisted so a recursion budget survives restart and
* resume — a runtime-only depth would reset a resumed child to top-level.
*/
readonly delegationDepth?: number
}
/**
@@ -66,6 +72,7 @@ export interface CreateSessionOptions {
readonly parentSession?: SessionId
readonly createdAt?: number
readonly seedLength?: number
readonly delegationDepth?: number
}
}
@@ -94,20 +101,25 @@ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
*/
export interface TurnEndReasonMap {
completed: { kind: 'completed' }
aborted: { kind: 'aborted'; reason?: string }
/** A cancellation request interrupted the live turn. */
aborted: { kind: 'aborted' }
/**
* The turn failed: a step threw or the model reported a failure. `step` is the
* step number the failure occurred on (the operational error's location — the
* single durable record of an in-turn failure; live diagnostics also fire via
* `agent/error`). `code` is the error's code when one was attached.
* `agent/error`). Final model-request failures retain their normalized facts
* as one `failure`; other turn failures retain their live Error message/code.
*/
error: { kind: 'error'; step: number; message: string; code?: string }
error: { kind: 'error'; step: number } & (
| { failure: LlmFailure; message?: never; code?: never }
| { message: string; code?: string; failure?: never }
)
disposed: { kind: 'disposed' }
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
'max-tokens': { kind: 'max-tokens' }
/**
* Policy blocked every prompt before the first step. The zero-step turn still
* records a balanced durable boundary and the veto reason.
* Policy blocked the turn's claimed prompt before the first step. The
* zero-step turn still records a balanced durable boundary and veto reason.
*/
rejected: { kind: 'rejected'; reason: string }
/**
@@ -176,27 +188,28 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change'
*/
export interface SessionEventMap {
/**
* Opens turn `turn`. `trigger` records what started it — a drained message
* batch or an idle-time injection. The turn is the durability/replay
* Opens turn `turn`. `trigger` records what started it — one claimed queued
* message or an idle-time injection. The turn is the durability/replay
* boundary: every event sits between a `turn/start` and its matching
* `turn/end` (the turn-enclosure invariant).
*/
'turn/start': { turn: number; trigger: TurnTrigger }
/**
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
* fires the awaited `session/flush` checkpoint at every turn end, so the turn
* boundary is also the durable-commit boundary.
* awaits `session/flush` after an ordinary turn ends before claiming the next
* queued item. Success commits the turn; rejection is reported live and does
* not prevent later work.
*/
'turn/end': { turn: number; reason: TurnEndReason }
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
'step/start': { turn: number; step: number }
/** Closes step `step` of turn `turn`. */
'step/end': { turn: number; step: number }
/** A user-visible prompt (queued message drained at turn start). */
/** A user-visible prompt (the queued message claimed for this turn). */
'user/message': { content: ContentBlock[]; source: MessageSource }
/**
* Durable record of a prompt veto and its reason. It is log-only: the blocked
* prompt never enters the model-visible surface, including in a mixed batch.
* prompt never enters the model-visible surface, and its turn runs zero steps.
*/
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
/**
@@ -251,9 +264,23 @@ export interface SessionEventMap {
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
}
/**
* Marker map for plugin-owned log-only events accepted by
* `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key
* it adds to {@link SessionEventMap}; surface and lifecycle events stay
* ineligible unless their owner explicitly opts them into this narrow seam.
*/
export interface OutOfBandSessionEventMap {}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
export type SessionEventType = keyof SessionEventMap
/** Plugin-declared non-surface event types accepted by `SessionStore.appendOutOfBand()`. */
export type OutOfBandSessionEventType = Exclude<
Extract<SessionEventType, keyof OutOfBandSessionEventMap>,
SurfaceEventType
>
/**
* The subset of {@link SessionEventType} values whose events produce LLM
* messages and are eligible to appear on the ordered surface. Only these

View File

@@ -102,7 +102,7 @@ describe('SessionStore.fork', () => {
const { ctx, sessions } = await setup()
const reasons: TurnEndReason[] = [
{ kind: 'completed' },
{ kind: 'aborted', reason: 'cancelled by user' },
{ kind: 'aborted' },
{ kind: 'error', step: 1, message: 'model failed', code: 'MODEL' },
{ kind: 'disposed' },
{ kind: 'max-tokens' },

View File

@@ -0,0 +1,344 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
async function setup(): Promise<{ ctx: Context; fiber: Awaited<ReturnType<Context['plugin']>> }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
const fiber = await ctx.plugin(SessionInvariant)
return { ctx, fiber }
}
describe('session-log invariants', () => {
it('keeps registration global when the companion is mounted under a scope', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
let scopedCtx!: Context
await ctx.plugin(Object.assign((inner: Context) => {
scopedCtx = createScope(inner, {}).ctx
}, { inject: ['sessions', 'invariants'] }))
await scopedCtx.plugin(SessionInvariant)
const session = ctx.sessions.create(SessionId('global-under-scoped-invariants'))
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
it('accepts a well-formed turn, step, and tool sequence', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
session.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }],
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false }, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
it('does not advance committed trace state when a later dispatch listener vetoes', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create(SessionId('dispatch-veto-rollback'))
let veto = true
ctx.on('internal/dispatch', (_mode, name) => {
if (name !== 'session/event' || !veto) return
veto = false
throw new Error('later dispatch veto')
})
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('later dispatch veto')
expect(session.events).toEqual([])
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
it('applies the committed transition after another postcommit observer throws', async () => {
const { ctx } = await setup()
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const session = ctx.sessions.create(SessionId('postcommit-peer'))
ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true })
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
expect(warnings).toHaveLength(2)
})
it('rejects non-monotonic event sequence numbers', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
ctx.emit(scopeTarget(session, undefined), 'session/event', session, {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
} as never)
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, {
type: 'turn/end',
seq: 0,
time: 2,
data: { turn: 1, reason: { kind: 'completed' } },
} as never) }).toThrow(/seq must strictly increase/)
})
it('enforces turn numbering and enclosure', async () => {
const first = await setup()
const open = first.ctx.sessions.create()
open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
.toThrow(/turn 1 is still open/)
expect(() => open.append('turn/end', { turn: 2, reason: { kind: 'completed' } }))
.toThrow(/does not match open turn 1/)
const second = (await setup()).ctx.sessions.create()
second.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
second.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(() => second.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }))
.toThrow(/expected turn 2, got 3/)
const outside = (await setup()).ctx.sessions.create()
expect(() => outside.append('user/message', {
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
expect(() => outside.append('steering/message', {
turn: 1,
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
// Merge-extensible session events use the same default enclosure branch.
const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown
expect(() => { appendUnknown('plugin/marker', {}) }).toThrow(/outside any open turn/)
})
it('enforces open-step identity and numbering', async () => {
const wrongTurn = (await setup()).ctx.sessions.create()
wrongTurn.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => wrongTurn.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/)
const nested = (await setup()).ctx.sessions.create()
nested.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
nested.append('step/start', { turn: 1, step: 1 })
expect(() => nested.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/)
expect(() => nested.append('turn/end', { turn: 1, reason: { kind: 'completed' } }))
.toThrow(/while step 1 is still open/)
expect(() => nested.append('step/end', { turn: 1, step: 2 })).toThrow(/open is turn 1\/step 1/)
expect(() => nested.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn: 1,
step: 2,
content: [],
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step 1/)
const skipped = (await setup()).ctx.sessions.create()
skipped.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
skipped.append('step/start', { turn: 1, step: 1 })
skipped.append('step/end', { turn: 1, step: 1 })
expect(() => skipped.append('step/start', { turn: 1, step: 3 }))
.toThrow(/expected step 2 in turn 1, got 3/)
})
it('requires step-scoped stream and tool events to name the open step', async () => {
const chunk = (await setup()).ctx.sessions.create()
chunk.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => chunk.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'x' },
})).toThrow(/open is turn 1\/step null/)
const tool = (await setup()).ctx.sessions.create()
tool.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
tool.append('step/start', { turn: 1, step: 1 })
expect(() => tool.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('ghost'),
content: [],
isError: false,
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call/)
})
it('keeps fresh tool-result appends open-step checked', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('closed'),
content: [],
isError: false,
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step null/)
})
it('treats a validated tool-result replacement as a turn-enclosed rewrite', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
name: 'echo',
arguments: '{}',
})
const original = session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
content: [{ type: 'text', text: 'original' }],
isError: false,
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'pruned' }],
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})).not.toThrow()
})
it('rejects a tool-result replacement outside a turn', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
name: 'echo',
arguments: '{}',
})
const original = session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
content: [{ type: 'text', text: 'original' }],
isError: false,
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'pruned' }],
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})).toThrow(/outside any open turn/)
})
it('allows interrupted repair results and unresolved calls at step end', async () => {
const repaired = (await setup()).ctx.sessions.create()
expect(() => {
repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
repaired.append('step/start', { turn: 1, step: 1 })
repaired.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('crashed'),
content: [],
isError: true,
error: { name: 'InterruptedError', code: 'interrupted' },
}, { surfaceOp: 'append' })
repaired.append('step/end', { turn: 1, step: 1 })
repaired.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
}).not.toThrow()
const unresolved = (await setup()).ctx.sessions.create()
expect(() => {
unresolved.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
unresolved.append('step/start', { turn: 1, step: 1 })
unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
unresolved.append('step/end', { turn: 1, step: 1 })
unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
}).not.toThrow()
})
it('does not let a result in a later step satisfy an earlier call', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
session.append('step/end', { turn: 1, step: 1 })
session.append('step/start', { turn: 1, step: 2 })
expect(() => session.append('tool/result', {
turn: 1,
step: 2,
callId: CallId('c1'),
content: [],
isError: false,
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call in this step/)
})
it('replays seeded sessions and tracks each session independently', async () => {
const { ctx } = await setup()
const badSeed = [
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
]
expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError)
const a = ctx.sessions.create(SessionId('a'))
const b = ctx.sessions.create(SessionId('b'))
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))
.not.toThrow()
})
it('rebuilds trace state for sessions that exist when the companion reloads', async () => {
const { ctx, fiber } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
await fiber.dispose()
await ctx.plugin(SessionInvariant)
expect(() => session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'h' },
})).not.toThrow()
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
.toThrow(/turn 1 is still open/)
})
it('removes all listeners when the companion is disposed', async () => {
const { ctx, fiber } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await fiber.dispose()
expect(() => session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})).not.toThrow()
})
})

View File

@@ -0,0 +1,226 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
'test/log-only': { value: string }
}
interface OutOfBandSessionEventMap {
'test/log-only': true
}
}
const updateTrigger = { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } as const
describe('SessionStore.appendOutOfBand', () => {
it('joins an open turn without adding a boundary or flushing it', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('open'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const event = await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'inside' },
updateTrigger,
)
expect(event).toMatchObject({ type: 'test/log-only', seq: 1, data: { value: 'inside' } })
expect(session.events.map(item => item.type)).toEqual(['turn/start', 'test/log-only'])
expect(flushes).toBe(0)
})
it('wraps a closed log in one zero-step turn and flushes the balanced update', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('closed'))
const flushedTypes: string[][] = []
ctx.on('session/flush', (flushed) => {
flushedTypes.push(flushed.events.map(event => event.type))
})
const first = await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'first' },
updateTrigger,
)
const second = await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'second' },
updateTrigger,
)
expect(first.seq).toBe(1)
expect(second.seq).toBe(4)
expect(session.events).toMatchObject([
{ type: 'turn/start', seq: 0, data: { turn: 1, trigger: updateTrigger } },
{ type: 'test/log-only', seq: 1, data: { value: 'first' } },
{ type: 'turn/end', seq: 2, data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'turn/start', seq: 3, data: { turn: 2, trigger: updateTrigger } },
{ type: 'test/log-only', seq: 4, data: { value: 'second' } },
{ type: 'turn/end', seq: 5, data: { turn: 2, reason: { kind: 'completed' } } },
])
expect(flushedTypes).toEqual([
['turn/start', 'test/log-only', 'turn/end'],
['turn/start', 'test/log-only', 'turn/end', 'turn/start', 'test/log-only', 'turn/end'],
])
})
it('closes and flushes a zero-step turn when the target event is rejected', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('rejected'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 1n } as never,
updateTrigger,
)).rejects.toThrow(/non-JSON-serializable/)
expect(session.events).toMatchObject([
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
])
expect(flushes).toBe(1)
})
it('does not flush when the synthetic turn cannot open', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('start-failure'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'unreachable' },
{ ...updateTrigger, invalid: 1n } as never,
)).rejects.toThrow(/non-JSON-serializable/)
expect(session.events).toEqual([])
expect(flushes).toBe(0)
})
it('preserves a target rejection when the balancing flush also rejects', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('target-and-flush-failure'))
ctx.on('session/flush', () => { throw new Error('disk failed') })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 1n } as never,
updateTrigger,
)).rejects.toThrow(/non-JSON-serializable/)
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'turn/end',
])
})
it('keeps the session attached through publication and its flush', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.prepare(SessionId('dispose'))
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
let liveDuringFlush = false
ctx.on('session/event', (_observed, event) => {
if (event.type === 'turn/start') detach()
})
ctx.on('session/flush', () => {
liveDuringFlush = ctx.sessions.get(session.id) === session
})
await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'last' },
updateTrigger,
)
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'test/log-only',
'turn/end',
])
expect(liveDuringFlush).toBe(true)
expect(ctx.sessions.get(session.id)).toBeUndefined()
})
it('rejects detached sessions before opening a turn', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.prepare(SessionId('detached'))
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'nope' },
updateTrigger,
)).rejects.toThrow('session "detached" is not live in this store')
expect(session.events).toEqual([])
})
it('leaves a balanced log when the durability checkpoint rejects', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('flush-failure'))
ctx.on('session/flush', () => { throw new Error('disk failed') })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'accepted' },
updateTrigger,
)).rejects.toThrow('disk failed')
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'test/log-only',
'turn/end',
])
})
it('rejects overlapping updates while the first append is still settling', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('overlap'))
let release!: () => void
const checkpoint = new Promise<void>((resolve) => {
release = resolve
})
ctx.on('session/flush', () => checkpoint)
const first = ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'first' },
updateTrigger,
)
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'overlap' },
updateTrigger,
)).rejects.toThrow(/out-of-band append in progress/)
release()
await expect(first).resolves.toMatchObject({ data: { value: 'first' } })
})
})

View File

@@ -1,7 +1,13 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, {
findLastMessageTurnEnd,
SESSION_FORMAT_VERSION,
Session,
SessionEvent,
SessionId,
} from '@deepseek-ai/dsh-session'
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
describe('Session', () => {
@@ -48,6 +54,68 @@ describe('Session', () => {
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
})
it('finds the latest message-turn outcome past later non-message turns', () => {
const session = new Session(SessionId('message-turn-outcome'))
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
session.append('turn/start', {
turn: 1,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
})
session.append('context/message', {
content: [{ type: 'text', text: 'before' }],
source: { kind: 'plugin', plugin: 'before' },
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('user/message', {
content: [{ type: 'text', text: 'bounded prompt' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const messageEnd = session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
session.append('turn/start', {
turn: 3,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } },
})
session.append('context/message', {
content: [{ type: 'text', text: 'after' }],
source: { kind: 'plugin', plugin: 'after' },
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd)
})
it('round-trips the coarse aborted turn outcome', () => {
const session = new Session(SessionId('aborted'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
expect(replayed.events).toEqual(session.events)
const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
})
it('rejects legacy reason-bearing aborted outcomes at the seed/load boundary', () => {
const legacy = [
{
type: 'turn/start', seq: 0, time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
},
{
type: 'turn/end', seq: 1, time: 2,
data: { turn: 1, reason: { kind: 'aborted', reason: 'legacy cancellation detail' } },
},
] as unknown as SessionEvent[]
expect(() => new Session(SessionId('legacy-aborted'), legacy))
.toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format')
})
it('renders context and steering messages as plain user content', () => {
const session = new Session(SessionId('s2'))
session.append('context/message', {
@@ -717,10 +785,11 @@ describe('SessionStore', () => {
// may create an unrelated property with the old implementation's name,
// but cannot suppress the durable event feed.
expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(events).toHaveLength(1)
expect(events[0]![0]).toBe(session)
expect(events[0]![1].type).toBe('user/message')
expect(events).toHaveLength(2)
expect(events[1]![0]).toBe(session)
expect(events[1]![1].type).toBe('user/message')
expect(ctx.sessions.get(session.id)).toBe(session)
expect(ctx.sessions.list()).toEqual([session])
@@ -732,6 +801,7 @@ describe('SessionStore', () => {
const a = ctx.sessions.create(SessionId('fixed'))
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] })
expect(forked.deriveMessages()).toEqual(a.deriveMessages())
@@ -881,6 +951,19 @@ describe('SessionStore', () => {
})
})
it('attaches delegationDepth from meta to the header', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('delegated-child'), {
meta: { parentSession: SessionId('parent'), delegationDepth: 2 },
})
expect(session.header).toMatchObject({
id: 'delegated-child',
parentSession: 'parent',
delegationDepth: 2,
})
})
it('rejects non-JSON and invalid scalar session metadata', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -892,6 +975,9 @@ describe('SessionStore', () => {
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ },
{ meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ },
{ meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ },
]
for (const [index, { meta, error }] of cases.entries()) {
@@ -957,8 +1043,9 @@ describe('SessionStore', () => {
ctx.on('session/event', (_session, event) => void events.push(event))
const session = ctx.sessions.create(SessionId('fixed'))
expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(events).toHaveLength(1)
expect(events.at(-1)?.type).toBe('user/message')
})
it('contains session/event observer failures after the append commit point', async () => {
@@ -1042,6 +1129,8 @@ describe('SessionStore', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('surface-dispatch-veto'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', {
content: [{ type: 'text', text: 'source' }],
source: { kind: 'user' },
@@ -1061,19 +1150,19 @@ describe('SessionStore', () => {
step: 1,
content: [{ type: 'text', text: 'replacement' }],
}, {
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
surfaceOp: { op: 'replace', start: 2, end: 2 },
sourceEventSeqs: [2],
})).toThrow('reject surface candidate')
expect(session.events).toHaveLength(1)
expect(surface.nodes).toEqual([0])
expect(session.events).toHaveLength(3)
expect(surface.nodes).toEqual([2])
expect(surface.replaceGeneration).toBe(0)
session.append('user/message', {
content: [{ type: 'text', text: 'next' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
expect(surface.nodes).toEqual([0, 1])
expect(surface.nodes).toEqual([2, 3])
expect(surface.replaceGeneration).toBe(0)
})

View File

@@ -30,6 +30,28 @@ function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent {
} as unknown as SessionEvent
}
function toolResultEvent(
seq: number,
callId: string,
surfaceOp: SurfaceEvent['surfaceOp'] = 'append',
sourceEventSeqs?: number[],
): SessionEvent {
return {
type: 'tool/result',
seq,
time: seq,
data: {
turn: 1,
step: 1,
callId: CallId(callId),
content: [{ type: 'text', text: `result ${seq}` }],
isError: false,
},
surfaceOp,
...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
}
}
describe('foldSurface provenance', () => {
it('accepts absent or valid provenance and complete replacement coverage', () => {
const events = [
@@ -94,6 +116,65 @@ describe('foldSurface provenance', () => {
)
})
describe('foldSurface tool-result rewrites', () => {
it('rejects a replacement spanning multiple current nodes', () => {
const events = [
provenanceEvent(0, undefined),
provenanceEvent(1, undefined),
toolResultEvent(2, 'rewrite', { op: 'replace', start: 0, end: 1 }, [0, 1]),
]
expect(() => foldSurface(events)).toThrow(/must rewrite exactly one current node/)
})
it('rejects a replacement targeting a non-result node', () => {
const events = [
provenanceEvent(0, undefined),
toolResultEvent(1, 'rewrite', { op: 'replace', start: 0, end: 0 }, [0]),
]
expect(() => foldSurface(events)).toThrow(/must target a current tool\/result/)
})
it('rejects changes outside tool-result content', () => {
const events = [
toolResultEvent(0, 'original'),
toolResultEvent(1, 'changed', { op: 'replace', start: 0, end: 0 }, [0]),
]
expect(() => foldSurface(events)).toThrow(/may change only content/)
})
it('compares array-valued rest fields structurally (meta arrays: equal accepted, drifted rejected)', () => {
const withMeta = (seq: number, meta: unknown, surfaceOp: SurfaceEvent['surfaceOp'] = 'append', sourceEventSeqs?: number[]): SessionEvent => {
const event = toolResultEvent(seq, 'c-meta', surfaceOp, sourceEventSeqs)
return { ...event, data: { ...(event.data as object), meta } } as SessionEvent
}
// Structurally equal arrays (fresh references) pass the rest-field equality.
expect(() => foldSurface([
withMeta(0, { tags: ['a', { n: 1 }] }),
withMeta(1, { tags: ['a', { n: 1 }] }, { op: 'replace', start: 0, end: 0 }, [0]),
])).not.toThrow()
// Same length, drifted element: the array branch must reject.
expect(() => foldSurface([
withMeta(0, { tags: ['a'] }),
withMeta(1, { tags: ['b'] }, { op: 'replace', start: 0, end: 0 }, [0]),
])).toThrow(/may change only content/)
// Array vs non-array on one side: the mixed-shape guard rejects.
expect(() => foldSurface([
withMeta(0, { tags: ['a'] }),
withMeta(1, { tags: 'a' }, { op: 'replace', start: 0, end: 0 }, [0]),
])).toThrow(/may change only content/)
// Same key count, different key names: the hasOwn branch rejects.
expect(() => foldSurface([
withMeta(0, { left: 1 }),
withMeta(1, { right: 1 }, { op: 'replace', start: 0, end: 0 }, [0]),
])).toThrow(/may change only content/)
// Different key counts: the key-length branch rejects.
expect(() => foldSurface([
withMeta(0, { one: 1 }),
withMeta(1, { one: 1, two: 2 }, { op: 'replace', start: 0, end: 0 }, [0]),
])).toThrow(/may change only content/)
})
})
describe('SurfaceManager', () => {
it('shares ordered entries and nested replacement ranges with foldSurface', () => {
const s = new Session(SessionId('shared-fold'))

View File

@@ -22,6 +22,9 @@
},
{
"path": "../../core/scope"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'tsdown'
/** Build the package root and optional invariant companion as independent bundles. */
export default defineConfig([
{
entry: ['lib/types/index.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
{
entry: ['lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])

View File

@@ -16,7 +16,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
### Live events
@@ -24,7 +24,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem
### Key types
- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) here, and `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent)`). Providers must tolerate absent fields (a bare `assemble()` carries an empty, scope-less context).
- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame.
- `PromptSection``{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100199`.
- `PromptAssembly``{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned.

View File

@@ -11,17 +11,23 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -30,6 +36,7 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -6,8 +6,8 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
declare module 'cordis' {
@@ -20,6 +20,8 @@ declare module 'cordis' {
* Expert waterfall over the assembled sections, tools, and variables.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
* receive only that scope's assemblies. The returned value is authoritative.
* A supplied signal controls only this explicit assembly request and must not
* be retained to control later turns.
* @param assembly - the mutable assembly built from registered providers.
* @param context - the caller's per-assembly context.
* @mode waterfall
@@ -41,6 +43,8 @@ export interface AssembleContext {
* only global providers and subject-less listeners participate.
*/
scope?: ScopeKey
/** Explicit control signal for the turn that requested this assembly, when any. */
signal?: AbortSignal
}
/** One contributed section of the system prompt (registry input). */
@@ -205,6 +209,39 @@ function interpolate(section: AssembledSection, variables: Record<string, string
return result + text.slice(last)
}
/** One tool-schema provider stored in a prompt layer. */
type ToolProvider = (context: AssembleContext) => ToolProviderResult
/** One prompt-variable provider stored in a prompt layer. */
type VariableProvider = (context: AssembleContext) => string | undefined
/** All prompt registrations owned by one global or scoped layer. */
class PromptLayer implements ScopeLayer {
readonly sections: NamedEntries<PromptSection>
readonly toolProviders = new AnonymousEntries<ToolProvider>()
readonly variables: NamedEntries<VariableProvider>
/**
* Create one prompt layer with diagnostics specific to its ownership scope.
* @param scope - the scoped owner, or `undefined` for global registrations.
*/
constructor(scope: ScopeKey | undefined) {
this.sections = new NamedEntries(name => new Error(scope === undefined
? `prompt section "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${name}" is already registered in this scope`))
this.variables = new NamedEntries(name => new Error(scope === undefined
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
: `prompt variable "${name}" is already registered in this scope`))
}
/** @returns whether this layer owns no prompt registrations. */
isEmpty(): boolean {
return this.sections.isEmpty()
&& this.toolProviders.isEmpty()
&& this.variables.isEmpty()
}
}
/** Registry service for the prompt inputs assembled before each model step. */
export class SystemPrompt extends Service {
static Config: z<Config> = z.object({
@@ -213,13 +250,10 @@ export class SystemPrompt extends Service {
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
})
private sections: PromptSection[] = []
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
/** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */
private scopedSections = new Map<ScopeKey, PromptSection[]>()
private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
private readonly layers = new ScopedLayers(
scope => new PromptLayer(scope),
() => { this.ctx.emit('system-prompt/change') },
)
private readonly toolOrder: string[] | undefined
constructor(ctx: Context, config: Config) {
@@ -251,34 +285,11 @@ export class SystemPrompt extends Service {
if (!Number.isFinite(section.order)) {
throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
}
const scope = scopeOf(this.ctx)
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
? this.sections
: this.scopedSections.get(scope) ?? (() => {
const created: PromptSection[] = []
this.scopedSections.set(scope, created)
return created
})()
if (layer.some(existing => existing.name === section.name)) {
throw new Error(scope === undefined
? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${section.name}" is already registered in this scope`)
}
layer.push(section)
// Install rollback before notifying listeners that may throw.
yield () => {
const index = layer.indexOf(section)
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
if (index >= 0) layer.splice(index, 1)
if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.section()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
layer => layer.sections.insert(section.name, section),
{ label: 'systemPrompt.section()' },
)
}
/**
@@ -289,29 +300,11 @@ export class SystemPrompt extends Service {
* @returns the exact Cordis effect disposer.
*/
tools(provider: (context: AssembleContext) => ToolProviderResult): () => void {
const scope = scopeOf(this.ctx)
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
? this.toolProviders
: this.scopedToolProviders.get(scope) ?? (() => {
const created: ((context: AssembleContext) => ToolProviderResult)[] = []
this.scopedToolProviders.set(scope, created)
return created
})()
layer.push(provider)
// Install rollback before notifying listeners that may throw.
yield () => {
const index = layer.indexOf(provider)
/* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */
if (index >= 0) layer.splice(index, 1)
if (scope !== undefined && layer.length === 0) this.scopedToolProviders.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.tools()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
layer => layer.toolProviders.append(provider),
{ label: 'systemPrompt.tools()' },
)
}
/**
@@ -326,32 +319,11 @@ export class SystemPrompt extends Service {
if (!VARIABLE_NAME.test(name)) {
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
}
const scope = scopeOf(this.ctx)
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
? this.variableProviders
: this.scopedVariableProviders.get(scope) ?? (() => {
const created = new Map<string, (context: AssembleContext) => string | undefined>()
this.scopedVariableProviders.set(scope, created)
return created
})()
if (layer.has(name)) {
throw new Error(scope === undefined
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
: `prompt variable "${name}" is already registered in this scope`)
}
layer.set(name, provider)
// Install rollback before notifying listeners that may throw.
yield () => {
layer.delete(name)
if (scope !== undefined && layer.size === 0) this.scopedVariableProviders.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.variable()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
layer => layer.variables.insert(name, provider),
{ label: 'systemPrompt.variable()' },
)
}
/**
@@ -366,23 +338,19 @@ export class SystemPrompt extends Service {
const scope = context.scope
// Scoped variables shadow globals.
const variables: Record<string, string | undefined> = {}
for (const [name, provider] of this.variableProviders) {
for (const [name, provider] of this.layers.global.variables.entries()) {
variables[name] = provider(context)
}
const scopedVariables = scope === undefined ? undefined : this.scopedVariableProviders.get(scope)
for (const [name, provider] of scopedVariables ?? []) {
const scopedVariables = this.layers.peek(scope)?.variables
for (const [name, provider] of scopedVariables?.entries() ?? []) {
variables[name] = provider(context)
}
// Scoped sections shadow globals before the stable order sort.
const sectionByName = new Map<string, PromptSection>()
for (const section of this.sections) sectionByName.set(section.name, section)
for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) {
sectionByName.set(section.name, section)
}
const sectionByName = this.layers.merge(scope, layer => layer.sections)
// Validate order against pre-restriction names while collecting visible schemas.
const providers = [
...this.toolProviders,
...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [],
...this.layers.global.toolProviders.values(),
...(this.layers.peek(scope)?.toolProviders.values() ?? []),
]
const collected: ToolSchema[] = []
const knownNames = new Set<string>()

View File

@@ -0,0 +1,52 @@
/** Package-owned prompt-assembly invariants. @module @deepseek-ai/dsh-system-prompt/invariant */
import type { Context } from 'cordis'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { PromptAssembly } from './index.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-system-prompt'
const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
/** Cordis companion plugin name. */
export const name = 'system-prompt-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Validate the authoritative assembly returned by the waterfall. */
function validateAssembly(assembly: PromptAssembly, fail: InvariantFailure): void {
const sectionNames = new Set<string>()
for (const section of assembly.sections) {
if (section.name.length === 0) fail('assembled section names must be non-empty')
if (sectionNames.has(section.name)) fail(`assembled section name ${JSON.stringify(section.name)} is duplicated`)
sectionNames.add(section.name)
if (typeof section.text !== 'string') fail(`assembled section ${JSON.stringify(section.name)} text must be a string`)
}
for (const tool of assembly.tools) {
if (tool.name.length === 0) fail('assembled tool names must be non-empty')
}
for (const [name, value] of Object.entries(assembly.variables)) {
if (!VARIABLE_NAME.test(name)) fail(`assembled variable name ${JSON.stringify(name)} is invalid`)
if (value !== undefined && typeof value !== 'string') {
fail(`assembled variable ${JSON.stringify(name)} must be a string or undefined`)
}
}
}
/** Install validation around the authoritative assembly waterfall result. */
const install: InvariantInstaller = (ctx, fail) => {
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const assembled = await next()
validateAssembly(assembled, fail)
return assembled
}, { global: true, prepend: true })
}
/**
* Register the system-prompt invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import * as SystemPromptInvariant from '@deepseek-ai/dsh-system-prompt/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(InvariantService)
await ctx.plugin(SystemPromptInvariant)
return ctx
}
const valid = (): PromptAssembly => ({
sections: [{ name: 'identity', text: 'prompt' }],
tools: [{ name: 'echo', description: 'Echo', parameters: {} }],
variables: { cwd: '/repo', optional: undefined },
})
async function assemble(ctx: Context, result: PromptAssembly): Promise<PromptAssembly> {
return ctx.waterfall(
ctx as never, 'system-prompt/assemble', valid(), {},
() => Promise.resolve(result),
)
}
describe('system-prompt invariants', () => {
it('accepts a well-formed authoritative assembly', async () => {
const ctx = await setup()
await expect(assemble(ctx, valid())).resolves.toEqual(valid())
})
it.each([
[{ ...valid(), sections: [{ name: '', text: 'x' }] }, /section names must be non-empty/],
[{ ...valid(), sections: [{ name: 'x', text: 'a' }, { name: 'x', text: 'b' }] }, /section name "x" is duplicated/],
[{ ...valid(), sections: [{ name: 'x', text: 1 as never }] }, /section "x" text must be a string/],
[{ ...valid(), tools: [{ name: '', description: 'x', parameters: {} }] }, /tool names must be non-empty/],
[{ ...valid(), variables: { Bad: 'x' } }, /variable name "Bad" is invalid/],
[{ ...valid(), variables: { value: 1 as never } }, /variable "value" must be a string or undefined/],
])('rejects malformed authoritative assembly %#', async (assembly, message) => {
const ctx = await setup()
await expect(assemble(ctx, assembly)).rejects.toThrow(message)
})
})

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
@@ -63,6 +63,21 @@ describe('scoped sections', () => {
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
})
it('shadows a global section before evaluating either text provider', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
const globalText = vi.fn(() => 'global text')
const scopedText = vi.fn(() => 'scoped text')
ctx.systemPrompt.section({ name: 'shared', order: 1, text: globalText })
scope.ctx.systemPrompt.section({ name: 'shared', order: 1, text: scopedText })
const assembly = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
expect(assembly.sections.find(section => section.name === 'shared')?.text).toBe('scoped text')
expect(globalText).not.toHaveBeenCalled()
expect(scopedText).toHaveBeenCalledOnce()
})
})
describe('scoped variables', () => {
@@ -86,6 +101,28 @@ describe('scoped variables', () => {
const again = await mintScope(ctx, 'child2')
again.ctx.systemPrompt.variable('v', () => '3')
})
it('defers a scoped variable that replaces the last provider in its generation', async () => {
const ctx = await mount({ persona: 'Mode: {{mode}}.' })
const scope = await mintScope(ctx, 'child')
const key = scopeKeyOf(scope)
const calls: string[] = []
scope.ctx.systemPrompt.section({ name: 'scope:sibling', order: 1, text: 'Scoped.' })
const dispose = scope.ctx.systemPrompt.variable('mode', () => {
calls.push('first')
dispose()
scope.ctx.systemPrompt.variable('mode', () => {
calls.push('replacement')
return 'replacement'
})
return 'first'
})
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: first.')
expect(calls).toEqual(['first'])
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: replacement.')
expect(calls).toEqual(['first', 'replacement'])
})
})
describe('scoped tool providers and toolOrder × restriction', () => {

View File

@@ -157,6 +157,24 @@ describe('SystemPrompt', () => {
expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
})
it('snapshots tool-provider membership before evaluating an assembly', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
let added = false
ctx.systemPrompt.tools(() => {
if (!added) {
added = true
ctx.systemPrompt.tools(() => ({
schemas: [{ name: 'late', description: '', parameters: {} }],
}))
}
return { schemas: [{ name: 'first', description: '', parameters: {} }] }
})
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first'])
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first', 'late'])
})
it('rolls back a variable when a system-prompt/change listener throws (P1-1)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -314,6 +332,24 @@ describe('SystemPrompt', () => {
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
})
it('live-iterates variables registered by an earlier provider', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
let added = false
ctx.systemPrompt.variable('first', () => {
if (!added) {
added = true
ctx.systemPrompt.variable('late', () => 'second value')
}
return 'first value'
})
expect((await ctx.systemPrompt.assemble()).variables).toEqual({
first: 'first value',
late: 'second value',
})
})
it('rejects a duplicate variable name and an unreferenceable name', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)

View File

@@ -22,6 +22,9 @@
},
{
"path": "../../core/scope"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -20,24 +20,28 @@ tools:
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body; around wrappers may replace only `signal`.
- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body. Around wrappers may replace only `signal`; the registry re-fuses the original caller signal immediately before the body.
- `ctx.tools.executionMode(exec)` returns `parallel` only when the visible definition's `isConcurrencySafe(exec.arguments)` classifier returns exactly `true`; unknown, hidden, undeclared, invalid, or throwing classifications are exclusive.
### Injected services
`SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`. The approval seam is consumed opportunistically instead (`ctx.get('approval')`, no static inject): a deployment without it keeps the ask→deny degrade, and the registry stays active either way.
### Cancellation
Cancellation is cooperative and quiescent. Every typed invocation supplies a caller-owned `AbortSignal`; tool bodies receive it as required readonly `exec.signal`, while only `tools/execute` wrappers may temporarily replace the required signal. The registry preserves caller cancellation through replacement and never races away from a started same-process promise. Cancellation before body invocation is `ABORTED_BEFORE_DISPATCH`; cancellation after invocation can replace only a successful outcome with `ABORTED`. A denial, wrapper failure, tool failure, post-policy failure, or timeout-owned `TOOL_TIMEOUT` remains more specific. A pre-aborted entry materializes and freezes arguments, then skips every policy and dispatch phase and publishes one result. Every async tool must observe or forward the signal and settle only after owned work stops. The [tool-cancellation Agent Note](../../../.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md) owns the full contract and hard-termination limit.
### Live events
The live registry pipeline has three transformable waterfalls followed by the observe-only `tools/result` boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards.
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
- `ToolDefinition``ToolSchema` + `execute(args, exec)`, whose async work must cooperatively stop through `exec.signal`, plus optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, signal, agent?, parent? }`; `signal` is required and readonly, callers may pass an enclosing execution's opaque token as `parent`, and callers never choose the new execution's own token.
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately.
- `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
- `PostToolDecision``{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
@@ -74,7 +78,7 @@ ctx.tools.register(defineTool({
},
async execute(args, exec) {
// args is typed: { path: string; offset?: number; limit?: number }
const text = await readFile(args.path, 'utf8')
const text = await readFile(args.path, { encoding: 'utf8', signal: exec.signal })
return [{ type: 'text', text }]
},
}))

View File

@@ -11,11 +11,21 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./presentation": {
"types": "./lib/types/presentation.d.ts",
"default": "./lib/types/presentation.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -23,12 +33,13 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"@deepseek-ai/dsh-code-runtime": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -36,12 +47,13 @@
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -160,9 +160,8 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
// (its executor kills on this signal) instead of orphaned, and
// queued-unstarted dispatches are abandoned.
const runController = new AbortController()
const onOuterAbort = (): void => { runController.abort(exec.signal?.reason) }
if (exec.signal?.aborted) onOuterAbort()
exec.signal?.addEventListener('abort', onOuterAbort, { once: true })
const onOuterAbort = (): void => { runController.abort(exec.signal.reason) }
exec.signal.addEventListener('abort', onOuterAbort, { once: true })
let dispatches = 0
// The per-run serialization queue: every binding call chains onto the tail, so even
@@ -273,7 +272,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
meta,
}
} finally {
exec.signal?.removeEventListener('abort', onOuterAbort)
exec.signal.removeEventListener('abort', onOuterAbort)
}
},
// ACP execute cards use the program as their visible title.

View File

@@ -6,8 +6,8 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
@@ -72,7 +72,9 @@ declare module 'cordis' {
interface Events {
/**
* Allow, deny, or ask before dispatch. `next()` delegates to allow; missing
* approval support turns `ask` into denial.
* approval support turns `ask` into denial. Async gates must observe
* `exec.signal`; the registry rechecks cancellation after they settle but
* never abandons their promise.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
* @param exec - the pending call (name, parsed arguments, caller agent).
* @mode waterfall
@@ -81,15 +83,20 @@ declare module 'cordis' {
/**
* Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns
* a normalized result; wrappers may change only `exec.signal`, while call
* identity remains immutable.
* identity remains immutable. The registry re-fuses the original caller
* signal before the body, so replacement cannot detach caller cancellation;
* wrappers must still restore their signal and reach quiescence.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
* @mode waterfall
*/
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
/**
* Accept, replace, enrich, or block a normalized dispatch result. `next()`
* accepts it unchanged; thrown tools still reach this seam as errors.
* accepts it unchanged; thrown tools still reach this seam as errors. Async
* listeners must observe `exec.signal`; after they settle, caller
* cancellation replaces only a successful accepted outcome with the code
* selected by whether the tool body was invoked.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
* @param exec - the call that just ran (name, parsed arguments, caller agent).
* @param result - the dispatch outcome a listener may accept, replace, or block.
@@ -122,6 +129,15 @@ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta
/** A registered tool: its schema plus the execution function. */
export interface ToolDefinition extends ToolSchema {
/**
* Run one accepted call. Async work must observe or forward `exec.signal` and
* settle only after its owned work reaches quiescence. The registry preserves
* caller cancellation through around-dispatch signal replacement and does
* not abandon this promise, but it cannot hard-kill same-process code.
* @param args - losslessly snapshotted, frozen model arguments.
* @param exec - execution identity, cancellation signal, and context deferral.
* @returns model-facing content plus optional private presentation metadata.
*/
execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>
/**
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
@@ -203,7 +219,8 @@ export interface ToolExecutionInput {
* the outer `run_code` outcome without receiving its live mutable execution.
*/
readonly parent?: ToolExecutionToken
signal?: AbortSignal
/** Required caller-owned cancellation for this invocation. */
readonly signal: AbortSignal
}
/**
@@ -217,15 +234,25 @@ export type ToolExecutionMode =
/**
* One pending tool call inside the registry pipeline. Parsed arguments cross
* one lossless-JSON materialization boundary before policy and are deep-frozen;
* call identity and the registry-assigned {@link token} are readonly. An
* around-dispatch wrapper may set, replace, or remove `signal`. The registry
* freezes the complete object before `tools/result` observers run.
* call identity, the caller signal, and the registry-assigned {@link token} are
* readonly. The registry freezes the complete object before `tools/result`
* observers run.
*/
export interface ToolExecution extends ToolExecutionInput {
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
readonly token: ToolExecutionToken
}
/**
* Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper
* may replace the signal for its delegated lifetime, but it cannot remove it.
* The registry fuses every replacement with the captured caller signal.
*/
export interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> {
/** Cancellation signal visible to the next wrapper or tool body. */
signal: AbortSignal
}
/**
* Runtime context handed to a tool implementation after the registry has
* accepted a {@link ToolExecution}. A composite tool uses
@@ -241,6 +268,9 @@ export interface ToolRunContext extends ToolExecution {
deferContext(context: HookContext): void
}
/** Registry-owned live execution object; public pipeline views stay readonly. */
type MutableToolRunContext = Omit<ToolRunContext, 'signal'> & { signal: AbortSignal }
/**
* Scheduler-only result after ordered pre-execute and guards. A `post-result`
* still receives post-execute; a `final-result` bypasses it.
@@ -282,6 +312,13 @@ export interface ToolRegistryScheduler {
* @internal
*/
export const TOOL_REGISTRY_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler')
/** Canonical error code for cancellation after a tool body was invoked. */
export const TOOL_ABORTED = 'ABORTED'
/** Canonical error code for cancellation before a tool body was invoked. */
export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
export interface ToolErrorInfo {
name: string
@@ -426,9 +463,58 @@ interface ToolView {
*/
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
/** One guard registration; the wrapper preserves independent duplicate registrations. */
interface ToolGuardRegistration {
guard: ToolGuard
/** One scope's complete tool-registry contribution. */
class ToolLayer implements ScopeLayer {
readonly tools: NamedEntries<ToolDefinition>
readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
readonly guards = new AnonymousEntries<ToolGuard>()
constructor(scope: ScopeKey | undefined) {
this.tools = new NamedEntries(name => new Error(scope === undefined
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
: `tool "${name}" is already registered in this scope`))
}
/** Whether every contribution table in this aggregate layer is empty. */
isEmpty(): boolean {
return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty()
}
/** Whether every compiled restriction in this layer admits a global tool name. */
admits(name: string): boolean {
for (const filter of this.restrictions.values()) {
if ((filter.allow !== undefined && !filter.allow.has(name))
|| (filter.deny !== undefined && filter.deny.has(name))) return false
}
return true
}
/** First monotonic denial from this layer's live guard registrations. */
guardReason(exec: ToolExecution): string | undefined {
for (const guard of this.guards.values()) {
const reason = guard(exec)
if (reason !== undefined) return reason
}
return undefined
}
}
/** Approval decision plus whether the approval channel reported cancellation. */
interface ToolAskResolution {
readonly decision: Extract<PreToolDecision, { kind: 'allow' | 'deny' }>
readonly approvalCancelled: boolean
}
/** Caller cancellation and dispatch state kept outside the around-wrapper view. */
interface ToolCancellationState {
readonly callerSignal: AbortSignal
bodyInvoked: boolean
}
/** One dispatch-scoped fused signal plus listener cleanup after the body settles. */
interface FusedToolSignal {
readonly signal: AbortSignal
dispose(): void
}
/**
@@ -452,13 +538,12 @@ export class ToolRegistry extends Service {
/** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
private global = new Map<string, ToolDefinition>()
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
/** Compiled restriction filters, per scope (see {@link restrict}). */
private restrictions = new Map<ScopeKey, CompiledToolRestriction[]>()
/** Monotonic post-policy guards, split into global and per-agent layers. */
private globalGuards = new Set<ToolGuardRegistration>()
private scopedGuards = new Map<ScopeKey, Set<ToolGuardRegistration>>()
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */
private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
private readonly layers = new ScopedLayers(
scope => new ToolLayer(scope),
() => { this.ctx.emit('tools/change') },
)
private readonly mode: ToolPresentationMode
/** Reserved presentation transport, kept outside the filterable registration layers. */
private readonly codeTransport: ToolDefinition | undefined
@@ -536,7 +621,6 @@ export class ToolRegistry extends Service {
* @returns the exact disposer that unregisters the tool.
*/
register(definition: ToolDefinition): () => void {
const scope = scopeOf(this.ctx)
const name = definition.name
const timeoutMs = definition.timeoutMs
if (timeoutMs !== undefined
@@ -546,26 +630,11 @@ export class ToolRegistry extends Service {
if (this.codeTransport !== undefined && name === RUN_CODE_NAME) {
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
}
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
const layer = scope === undefined ? this.global : this.layerFor(scope)
if (layer.has(name)) {
throw new Error(scope === undefined
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
: `tool "${name}" is already registered in this scope`)
}
layer.set(name, definition)
// Install rollback before notifying listeners.
yield () => {
layer.delete(name)
// Drop empty scope layers.
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
this.ctx.emit('tools/change')
}
this.ctx.emit('tools/change')
}.bind(this), 'tools.register()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
layer => layer.tools.insert(name, definition),
{ label: 'tools.register()' },
)
}
/**
@@ -598,22 +667,11 @@ export class ToolRegistry extends Service {
if (unknown.length > 0) {
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
}
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
const list = this.restrictions.get(scope) ?? []
this.restrictions.set(scope, list)
list.push(compiled)
yield () => {
const index = list.indexOf(compiled)
/* v8 ignore next 3 -- defensive: the compiled restriction was pushed, so indexOf is guaranteed >= 0 */
if (index >= 0) list.splice(index, 1)
if (list.length === 0) this.restrictions.delete(scope)
this.ctx.emit('tools/change')
}
this.ctx.emit('tools/change')
}.bind(this), 'tools.restrict()')
// Return the exact disposer so composite effects preserve teardown order.
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
return this.layers.effect(
this.ctx,
layer => layer.restrictions.append(compiled),
{ label: 'tools.restrict()' },
)
}
/**
@@ -627,63 +685,18 @@ export class ToolRegistry extends Service {
* @returns the exact disposer that unregisters the guard.
*/
guard(guard: ToolGuard): () => void {
const scope = scopeOf(this.ctx)
const registration = { guard }
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
const layer = scope === undefined ? this.globalGuards : this.guardLayerFor(scope)
layer.add(registration)
yield () => {
layer.delete(registration)
if (scope !== undefined && layer.size === 0) this.scopedGuards.delete(scope)
}
}.bind(this), 'tools.guard()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}
/** The (created-on-demand) scoped layer for `scope`. */
private layerFor(scope: ScopeKey): Map<string, ToolDefinition> {
let layer = this.scoped.get(scope)
if (!layer) {
layer = new Map()
this.scoped.set(scope, layer)
}
return layer
}
/** Get or create the guard layer for one agent scope. */
private guardLayerFor(scope: ScopeKey): Set<ToolGuardRegistration> {
let layer = this.scopedGuards.get(scope)
if (layer === undefined) {
layer = new Set()
this.scopedGuards.set(scope, layer)
}
return layer
return this.layers.effect(
this.ctx,
layer => layer.guards.append(guard),
{ label: 'tools.guard()', notify: false },
)
}
/** First monotonic denial from the global then matching scoped guard layers. */
private guardReason(exec: ToolExecution): string | undefined {
for (const { guard } of this.globalGuards) {
const reason = guard(exec)
if (reason !== undefined) return reason
}
if (exec.agent !== undefined) {
for (const { guard } of this.scopedGuards.get(exec.agent) ?? []) {
const reason = guard(exec)
if (reason !== undefined) return reason
}
}
return undefined
}
/** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */
private admits(scope: ScopeKey | undefined, name: string): boolean {
if (scope === undefined) return true
const filters = this.restrictions.get(scope)
if (!filters) return true
return filters.every(filter =>
(filter.allow === undefined || filter.allow.has(name))
&& (filter.deny === undefined || !filter.deny.has(name)))
const globalReason = this.layers.global.guardReason(exec)
if (globalReason !== undefined) return globalReason
return exec.agent === undefined ? undefined : this.layers.peek(exec.agent)?.guardReason(exec)
}
/**
@@ -695,18 +708,18 @@ export class ToolRegistry extends Service {
* @returns the complete derived view for that scope.
*/
private view(scope?: ScopeKey): ToolView {
const layer = scope === undefined ? undefined : this.scoped.get(scope)
const layer = this.layers.peek(scope)
const visible = new Map<string, ToolDefinition>()
const knownNames = new Set<string>()
const restrictableNames = new Set<string>()
for (const [name, definition] of this.global) {
for (const [name, definition] of this.layers.global.tools.entries()) {
knownNames.add(name)
restrictableNames.add(name)
if (this.admits(scope, name)) visible.set(name, definition)
if (layer?.admits(name) ?? true) visible.set(name, definition)
}
// Scoped layer second: same-name entries REPLACE (shadow) the global ones,
// and scope-local registrations are never part of the global filter above.
for (const [name, definition] of layer ?? []) {
for (const [name, definition] of layer?.tools.entries() ?? []) {
knownNames.add(name)
visible.set(name, definition)
}
@@ -774,7 +787,11 @@ export class ToolRegistry extends Service {
* Execute through pre-policy, guards, around-dispatch, post-policy, and final
* notification. Tool and listener failures resolve as materialized error
* results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
* the same lossless, frozen snapshot final observers receive.
* the same lossless, frozen snapshot final observers receive. Cancellation
* arriving after entry and before final result materialization skips a
* not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a
* successful started outcome with `ABORTED`; already-started work is still
* drained and may retain a tool-owned structured error.
* @param exec - the typed same-process call input. The registry assigns its
* correlation token before policy begins.
* @returns the materialized final result.
@@ -801,7 +818,7 @@ export class ToolRegistry extends Service {
}
}
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: ToolRunContext } {
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } {
const deferredContexts: HookContext[] = []
const token = createExecutionToken()
const callId = exec.callId
@@ -813,9 +830,9 @@ export class ToolRegistry extends Service {
token,
callId,
name,
signal,
...agent !== undefined ? { agent } : {},
...parent !== undefined ? { parent } : {},
...signal !== undefined ? { signal } : {},
deferContext(context: HookContext): void {
deferredContexts.push(context)
},
@@ -825,11 +842,15 @@ export class ToolRegistry extends Service {
if (detached === undefined) {
throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
}
const execution: ToolRunContext = { ...base, arguments: deepFreeze(detached) }
const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) }
this.deferredContexts.set(execution, deferredContexts)
this.cancellationStates.set(execution, {
callerSignal: signal,
bodyInvoked: false,
})
return { kind: 'ready', exec: execution }
} catch (error: unknown) {
const execution: ToolRunContext = { ...base, arguments: undefined }
const execution: MutableToolRunContext = { ...base, arguments: undefined }
return { kind: 'final-result', exec: execution, result: toolErrorResult(error) }
}
}
@@ -851,13 +872,22 @@ export class ToolRegistry extends Service {
const created = this.createExecution(input)
if (created.kind !== 'ready') return next(created)
const exec = created.exec
if (this.callerCancelled(exec)) {
return next({ kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() })
}
try {
const carrier = scopeTarget(this, exec.agent)
const gate = await this.ctx.waterfall(
carrier, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)
const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
const askResolution: ToolAskResolution = gate.kind === 'ask'
? await this.serviceAsk(exec, gate)
: { decision: gate, approvalCancelled: false }
const { decision } = askResolution
if (this.callerCancelled(exec) && askResolution.approvalCancelled) {
return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
}
const denialReason = decision.kind === 'allow'
? this.guardReason(exec)
: decision.reason
@@ -871,12 +901,74 @@ export class ToolRegistry extends Service {
},
})
}
if (this.callerCancelled(exec)) {
return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
}
return await next({ kind: 'dispatch', exec })
} catch (error: unknown) {
return next({ kind: 'final-result', exec, result: toolErrorResult(error) })
}
}
/** Whether the original caller signal is currently aborted. */
private callerCancelled(exec: ToolRunContext): boolean {
const state = this.cancellationStates.get(exec)
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
return state.callerSignal.aborted
}
/** Canonical cancellation outcome selected by whether the tool body started. */
private cancellationResult(exec: ToolRunContext, prior?: ToolExecutionResult): ToolExecutionResult {
const state = this.cancellationStates.get(exec)
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
return state.bodyInvoked
? toolAbortedResult(prior)
: toolAbortedBeforeDispatchResult(prior)
}
/**
* Dispatch the registered body with the original caller signal fused back
* into any around-wrapper replacement. Cancellation never abandons the body:
* a started promise reaches quiescence before its outcome becomes `ABORTED`.
*/
private async dispatchToolBody(exec: MutableToolRunContext): Promise<ToolExecutionResult> {
const state = this.cancellationStates.get(exec)
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
const wrapperSignal = exec.signal
const fused = fuseToolSignals(state.callerSignal, wrapperSignal)
const signal = fused.signal
if (isAborted(signal)) {
fused.dispose()
return toolAbortedBeforeDispatchResult()
}
exec.signal = signal
try {
const tool = this.get(exec.name, exec.agent)
if (!tool) throw new ToolNotFoundError(exec.name)
state.bodyInvoked = true
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
const result: ToolExecutionResult = {
content,
isError: false,
...meta !== undefined ? { meta } : {},
}
return isAborted(signal)
? toolAbortedResult(result)
: result
} catch (error: unknown) {
return toolErrorResult(error)
} finally {
fused.dispose()
exec.signal = wrapperSignal
}
}
/**
* Run around-dispatch and the tool body. Tool and unknown-tool failures still
* receive post-execute; pipeline failures are already final.
@@ -886,21 +978,11 @@ export class ToolRegistry extends Service {
*/
private async dispatchScheduledExecution(exec: ToolRunContext): Promise<ScheduledToolDispatch> {
try {
const mutableExec = exec as MutableToolRunContext
const carrier = scopeTarget(this, exec.agent)
const result = await this.ctx.waterfall(
carrier, 'tools/execute', exec,
async (): Promise<ToolExecutionResult> => {
try {
const tool = this.get(exec.name, exec.agent)
if (!tool) throw new ToolNotFoundError(exec.name)
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
return { content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
return toolErrorResult(error)
}
},
carrier, 'tools/execute', mutableExec,
() => this.dispatchToolBody(mutableExec),
)
const deferredContexts = this.deferredContexts.get(exec)
/* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
@@ -914,7 +996,12 @@ export class ToolRegistry extends Service {
...result.additionalContexts ?? [],
],
}
return { kind: 'post-result', result: resultWithDeferredContexts }
return {
kind: 'post-result',
result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError
? this.cancellationResult(exec, resultWithDeferredContexts)
: resultWithDeferredContexts,
}
} catch (error: unknown) {
return { kind: 'final-result', result: toolErrorResult(error) }
}
@@ -929,7 +1016,13 @@ export class ToolRegistry extends Service {
*/
private async finalizeScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult> {
try {
return this.finishScheduledExecution(exec, await this.postExecute(exec, result))
const postResult = await this.postExecute(exec, result)
return this.finishScheduledExecution(
exec,
this.callerCancelled(exec) && !postResult.isError
? this.cancellationResult(exec, postResult)
: postResult,
)
} catch (error: unknown) {
return this.finishScheduledExecution(exec, toolErrorResult(error))
}
@@ -955,8 +1048,8 @@ export class ToolRegistry extends Service {
/** Notify observers without exposing a mutation or error channel into the outcome. */
private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void {
// Freeze the remaining mutable signal slot before observers receive the
// shared WeakMap-keyable execution object.
// Freeze the registry's live object before observers receive its readonly
// WeakMap-keyable view.
Object.freeze(exec)
const { name: toolName, callId } = exec
const reportFailure = (error: unknown): void => {
@@ -989,26 +1082,41 @@ export class ToolRegistry extends Service {
private async serviceAsk(
exec: ToolExecution,
ask: Extract<PreToolDecision, { kind: 'ask' }>,
): Promise<Extract<PreToolDecision, { kind: 'allow' | 'deny' }>> {
): Promise<ToolAskResolution> {
const approval = this.ctx.get('approval')
if (approval === undefined) {
return { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` }
return {
decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` },
approvalCancelled: false,
}
}
if (exec.agent === undefined) {
return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` }
return {
decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` },
approvalCancelled: false,
}
}
const outcome = await approval.request({
agent: exec.agent,
toolName: exec.name,
callId: exec.callId,
...ask.reason !== undefined ? { reason: ask.reason } : {},
...exec.signal !== undefined ? { signal: exec.signal } : {},
signal: exec.signal,
})
switch (outcome) {
case 'allowed-once': return { kind: 'allow' }
case 'rejected': return { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }
case 'cancelled': return { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` }
case 'unavailable': return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` }
case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false }
case 'rejected': return {
decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` },
approvalCancelled: false,
}
case 'cancelled': return {
decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` },
approvalCancelled: true,
}
case 'unavailable': return {
decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` },
approvalCancelled: false,
}
default: return assertNever(outcome, 'ApprovalOutcome')
}
}
@@ -1074,4 +1182,64 @@ function toolErrorResult(error: unknown): ToolExecutionResult {
}
}
/** Read live abort state across an await without treating it as synchronously immutable. */
function isAborted(signal: AbortSignal): boolean {
return signal.aborted
}
/**
* Fuse caller and wrapper cancellation without nesting `AbortSignal.any`.
* Keeping the relay dispatch-scoped also removes listeners when work settles.
*/
function fuseToolSignals(caller: AbortSignal, wrapper: AbortSignal): FusedToolSignal {
if (caller === wrapper) return { signal: caller, dispose() {} }
const controller = new AbortController()
let listening = false
const dispose = (): void => {
if (!listening) return
listening = false
caller.removeEventListener('abort', abortFromCaller)
wrapper.removeEventListener('abort', abortFromWrapper)
}
const abortFrom = (source: AbortSignal): void => {
const reason: unknown = source.reason
controller.abort(reason)
dispose()
}
const abortFromCaller = (): void => { abortFrom(caller) }
const abortFromWrapper = (): void => { abortFrom(wrapper) }
if (wrapper.aborted) abortFromWrapper()
else if (caller.aborted) abortFromCaller()
else {
listening = true
caller.addEventListener('abort', abortFromCaller, { once: true })
wrapper.addEventListener('abort', abortFromWrapper, { once: true })
}
return { signal: controller.signal, dispose }
}
/** Canonical result when cancellation supersedes success after body invocation. */
function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult {
const additionalContexts = prior?.additionalContexts ?? []
return {
content: [{ type: 'text', text: 'Error: tool call aborted' }],
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED },
...additionalContexts.length > 0 ? { additionalContexts } : {},
}
}
/** Canonical result when cancellation prevents tool body invocation. */
function toolAbortedBeforeDispatchResult(prior?: ToolExecutionResult): ToolExecutionResult {
const additionalContexts = prior?.additionalContexts ?? []
return {
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
...additionalContexts.length > 0 ? { additionalContexts } : {},
}
}
export default ToolRegistry

View File

@@ -0,0 +1,69 @@
/** Package-owned tool-pipeline invariants. @module @deepseek-ai/dsh-tools/invariant */
import type { Context } from 'cordis'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { ToolExecution, ToolExecutionResult } from './index.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-tools'
/** Cordis companion plugin name. */
export const name = 'tools-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
type ToolStage = 'pre' | 'execute' | 'post'
/** Validate the immutable final execution/result snapshot. */
function validateResult(
exec: Readonly<ToolExecution>,
result: Readonly<ToolExecutionResult>,
fail: InvariantFailure,
): void {
if (!Object.isFrozen(exec)) fail('tools/result execution must be frozen before publication')
if (!Object.isFrozen(result) || !Object.isFrozen(result.content)) {
fail('tools/result outcome and content must be frozen before publication')
}
if (exec.name.length === 0 || String(exec.callId).length === 0) {
fail('tools/result execution must carry non-empty name and callId')
}
}
/** Install monotonic pipeline and final-snapshot checks. */
const install: InvariantInstaller = (ctx, fail) => {
const stages = new WeakMap<object, ToolStage>()
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName === 'tools/pre-execute') {
const exec = args[0] as ToolExecution
if (stages.has(exec)) fail('tools/pre-execute repeated for one execution')
stages.set(exec, 'pre')
return
}
if (eventName === 'tools/execute') {
const exec = args[0] as ToolExecution
if (stages.get(exec) !== 'pre') fail('tools/execute must follow tools/pre-execute')
stages.set(exec, 'execute')
return
}
if (eventName === 'tools/post-execute') {
const exec = args[0] as ToolExecution
const previous = stages.get(exec)
if (previous !== 'pre' && previous !== 'execute') {
fail('tools/post-execute must follow tools/pre-execute or tools/execute')
}
stages.set(exec, 'post')
return
}
if (eventName !== 'tools/result') return
const [exec, result] = args as [Readonly<ToolExecution>, Readonly<ToolExecutionResult>]
validateResult(exec, result, fail)
stages.delete(exec)
}, { global: true })
}
/**
* Register the tools invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -6,12 +6,14 @@ import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineTool } from '@deepseek-ai/dsh-tools'
import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventMap } from '@deepseek-ai/dsh-session'
const testToolSignal = new AbortController().signal
/**
* Code Mode unit tier (per the Agent Note's plan): provider contribution per mode,
* misconfiguration rejections, the run_code dispatch bridge (serialization,
@@ -95,6 +97,7 @@ function fakeAgent(options: { cwd?: string } = { cwd: '/workspace' }): { agent:
/** Dispatch run_code through the registry pipeline, as the loop would. */
async function runCode(ctx: Context, code: string, extras: { agent?: Agent; signal?: AbortSignal } = {}): Promise<ToolExecutionResult> {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId('call-1'),
name: RUN_CODE_NAME,
arguments: { code },
@@ -357,8 +360,7 @@ describe('the run_code dispatch bridge', () => {
const previous = exec.signal
exec.signal = new AbortController().signal
const result = await next()
if (previous === undefined) delete exec.signal
else exec.signal = previous
exec.signal = previous
return result
})
ctx.on('tools/result', (exec) => {
@@ -574,7 +576,7 @@ describe('the run_code dispatch bridge', () => {
seen.push(args.id)
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 500)
exec.signal?.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true })
exec.signal.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true })
})
return [{ type: 'text' as const, text: args.id }]
},
@@ -610,7 +612,7 @@ describe('the run_code dispatch bridge', () => {
started()
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 500)
exec.signal?.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true })
exec.signal.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true })
})
return [{ type: 'text' as const, text: args.id }]
},
@@ -838,7 +840,7 @@ describe('the run_code dispatch bridge', () => {
expect((result.content[0] as { text: string }).text).toBe('{ n: 42 }')
})
it('reports a pre-aborted outer signal as the run failure without dispatching anything', async () => {
it('short-circuits a pre-aborted outer signal before the code runtime', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const calls = registerEcho(ctx)
runtime.behavior = (request) => {
@@ -850,11 +852,16 @@ describe('the run_code dispatch bridge', () => {
controller.abort('too-late')
const result = await runCode(ctx, 'program', { signal: controller.signal })
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('code run failed (abort)')
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(runtime.lastRequest).toBeUndefined()
expect(calls).toEqual([])
})
it('rejects a binding invoked after the run is over without dispatching it', async () => {
it('reports cancellation after rejecting a late binding without dispatching it', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const calls = registerEcho(ctx)
const controller = new AbortController()
@@ -865,8 +872,9 @@ describe('the run_code dispatch bridge', () => {
return { logs: [], value: message }
}
const result = await runCode(ctx, 'program', { signal: controller.signal })
expect(result.isError).toBe(false)
expect((result.content[0] as { text: string }).text).toContain('not dispatched')
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' })
expect((result.content[0] as { text: string }).text).toBe('Error: tool call aborted')
expect(calls).toEqual([])
})

View File

@@ -11,6 +11,8 @@ import ToolRegistry, {
type ToolExecutionMode,
} from '@deepseek-ai/dsh-tools'
const testToolSignal = new AbortController().signal
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -19,7 +21,7 @@ async function setup() {
}
function exec(name: string, args: unknown): ToolExecutionInput {
return { callId: CallId('c1'), name, arguments: args }
return { signal: testToolSignal, callId: CallId('c1'), name, arguments: args }
}
describe('ToolRegistry.executionMode', () => {

View File

@@ -0,0 +1,100 @@
import { describe, expectTypeOf, it } from 'vitest'
import type { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {
ToolDispatchExecution,
ToolExecution,
ToolExecutionInput,
ToolRunContext,
} from '@deepseek-ai/dsh-tools'
function inputAndExecutionContracts(
input: ToolExecutionInput,
execution: ToolExecution,
run: ToolRunContext,
): void {
// @ts-expect-error -- every typed invocation must supply a caller-owned signal.
const missingSignal: ToolExecutionInput = { callId: CallId('missing'), name: 'probe', arguments: {} }
void missingSignal
// @ts-expect-error -- caller input is readonly after construction.
input.signal = new AbortController().signal
// @ts-expect-error -- required readonly properties cannot be deleted.
delete input.signal
// @ts-expect-error -- required signals cannot become undefined.
input.signal = undefined
// @ts-expect-error -- pipeline observers receive a readonly execution view.
execution.signal = new AbortController().signal
// @ts-expect-error -- pipeline observers cannot remove the required signal.
delete execution.signal
// @ts-expect-error -- tool bodies receive a readonly run context.
run.signal = new AbortController().signal
// @ts-expect-error -- tool bodies cannot remove the required signal.
delete run.signal
// @ts-expect-error -- tool bodies cannot replace the required signal with undefined.
run.signal = undefined
}
void inputAndExecutionContracts
function observerContracts(ctx: Context): void {
ctx.on('tools/pre-execute', (exec, next) => {
// @ts-expect-error -- pre-policy sees a readonly signal.
exec.signal = new AbortController().signal
// @ts-expect-error -- pre-policy cannot remove the required signal.
delete exec.signal
// @ts-expect-error -- pre-policy cannot replace the required signal with undefined.
exec.signal = undefined
return next()
})
ctx.on('tools/post-execute', (exec, _result, next) => {
// @ts-expect-error -- post-policy sees a readonly signal.
exec.signal = new AbortController().signal
// @ts-expect-error -- post-policy sees a readonly signal.
delete exec.signal
// @ts-expect-error -- post-policy cannot replace the required signal with undefined.
exec.signal = undefined
return next()
})
ctx.on('tools/result', (exec) => {
// @ts-expect-error -- result observers see a readonly signal.
exec.signal = new AbortController().signal
// @ts-expect-error -- result observers cannot remove the required signal.
delete exec.signal
// @ts-expect-error -- result observers see a readonly signal.
exec.signal = undefined
})
ctx.on('tools/execute', (exec, next) => {
exec.signal = new AbortController().signal
// @ts-expect-error -- around-dispatch may replace but not remove the signal.
delete exec.signal
// @ts-expect-error -- around-dispatch cannot replace the required signal with undefined.
exec.signal = undefined
return next()
})
}
void observerContracts
const inferredTool = defineTool({
name: 'signal-inference',
description: 'Pins contextual signal inference.',
parameters: {},
async execute(_args, exec) {
expectTypeOf(exec.signal).toEqualTypeOf<AbortSignal>()
// @ts-expect-error -- defineTool contextually exposes a readonly signal.
exec.signal = new AbortController().signal
return []
},
})
void inferredTool
describe('tool execution signal types', () => {
it('requires an exact AbortSignal at every readonly tool view', () => {
expectTypeOf<ToolExecutionInput['signal']>().toEqualTypeOf<AbortSignal>()
expectTypeOf<ToolExecution['signal']>().toEqualTypeOf<AbortSignal>()
expectTypeOf<ToolRunContext['signal']>().toEqualTypeOf<AbortSignal>()
expectTypeOf<ToolDispatchExecution['signal']>().toEqualTypeOf<AbortSignal>()
expectTypeOf<typeof inferredTool.execute>().toBeFunction()
})
})

View File

@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import * as ToolsInvariant from '@deepseek-ai/dsh-tools/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
const testToolSignal = new AbortController().signal
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(InvariantService)
await ctx.plugin(ToolsInvariant)
return ctx
}
const execution = (overrides: Partial<ToolExecution> = {}): ToolExecution => ({
token: Symbol('tool') as ToolExecutionToken,
callId: CallId('call-1'),
name: 'echo',
arguments: Object.freeze({ text: 'hi' }),
...overrides,
signal: overrides.signal ?? testToolSignal,
})
const outcome = (): ToolExecutionResult => Object.freeze({
content: Object.freeze([{ type: 'text' as const, text: 'ok' }]) as never,
isError: false,
})
function emitResult(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): void {
ctx.emit(scopeTarget(ctx as never, undefined), 'tools/result', exec, result)
}
async function stage(ctx: Context, name: 'tools/pre-execute' | 'tools/execute', exec: ToolExecution): Promise<void> {
if (name === 'tools/pre-execute') {
await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve({ kind: 'allow' as const }))
} else {
await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve(outcome()))
}
}
describe('tool-pipeline invariants', () => {
it('accepts dispatch and denial stage orders with frozen results', async () => {
const ctx = await setup()
const dispatched = execution()
await stage(ctx, 'tools/pre-execute', dispatched)
await stage(ctx, 'tools/execute', dispatched)
await ctx.waterfall(ctx as never, 'tools/post-execute', dispatched, outcome(), () => Promise.resolve({ kind: 'accept' as const }))
Object.freeze(dispatched)
emitResult(ctx, dispatched, outcome())
const denied = execution({ callId: CallId('call-2') })
await stage(ctx, 'tools/pre-execute', denied)
await ctx.waterfall(ctx as never, 'tools/post-execute', denied, outcome(), () => Promise.resolve({ kind: 'accept' as const }))
Object.freeze(denied)
emitResult(ctx, denied, outcome())
ctx.emit('tools/change')
})
it('rejects repeated and out-of-order pipeline stages', async () => {
const ctx = await setup()
const exec = execution()
await stage(ctx, 'tools/pre-execute', exec)
await expect(stage(ctx, 'tools/pre-execute', exec)).rejects.toThrow(/repeated/)
const noPre = execution({ callId: CallId('call-2') })
await expect(stage(ctx, 'tools/execute', noPre)).rejects.toThrow(/must follow tools\/pre-execute/)
expect(() => ctx.waterfall(
ctx as never, 'tools/post-execute', noPre, outcome(),
() => Promise.resolve({ kind: 'accept' as const }),
)).toThrow(/must follow tools\/pre-execute or tools\/execute/)
})
it('rejects mutable or anonymous final snapshots', async () => {
const ctx = await setup()
expect(() => { emitResult(ctx, execution(), outcome()) }).toThrow(/execution must be frozen/)
const exec = Object.freeze(execution())
expect(() => { emitResult(ctx, exec, { content: [], isError: false }) })
.toThrow(/outcome and content must be frozen/)
const anonymous = Object.freeze(execution({ name: '' }))
expect(() => { emitResult(ctx, anonymous, outcome()) }).toThrow(/non-empty name and callId/)
})
})

View File

@@ -12,6 +12,8 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
const testToolSignal = new AbortController().signal
/** Mount the registry (with its systemPrompt dependency) on a fresh context. */
async function mount(): Promise<Context> {
const ctx = new Context()
@@ -43,6 +45,7 @@ function tool(name: string, reply = `ran:${name}`): ToolDefinition {
async function run(ctx: Context, name: string, agent?: Agent): Promise<string> {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('c1'),
name,
arguments: {},
@@ -263,6 +266,49 @@ describe('scoped execution dispatch', () => {
expect(bodyCalls).toBe(0)
})
it('live-iterates a guard registered by an earlier guard', async () => {
const ctx = await mount()
const calls: string[] = []
let added = false
ctx.tools.register(tool('t'))
ctx.tools.guard(() => {
calls.push('first')
if (!added) {
added = true
ctx.tools.guard(() => {
calls.push('late')
return 'late denial'
})
}
return undefined
})
expect(await run(ctx, 't')).toBe('Error: late denial')
expect(calls).toEqual(['first', 'late'])
})
it('defers a scoped guard that replaces the last guard in its generation', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
const calls: string[] = []
ctx.tools.register(tool('t'))
scope.ctx.tools.register(tool('scope_sibling'))
const lift = scope.ctx.tools.guard(() => {
calls.push('first')
lift()
scope.ctx.tools.guard(() => {
calls.push('replacement')
return 'replacement denial'
})
return undefined
})
expect(await run(ctx, 't', key)).toBe('ran:t')
expect(calls).toEqual(['first'])
expect(await run(ctx, 't', key)).toBe('Error: replacement denial')
expect(calls).toEqual(['first', 'replacement'])
})
it('shares one token and materialized argument value across the pipeline', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
@@ -305,6 +351,7 @@ describe('scoped execution dispatch', () => {
expect(await run(ctx, 'danger', key)).toBe('Error: danger denied')
const callerArguments = { source: true }
const safeResult = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('safe-call'),
name: 'safe',
arguments: callerArguments,
@@ -348,7 +395,7 @@ describe('scoped execution dispatch', () => {
if (exec.name === 'parent') parent = exec.token
return next()
})
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
await ctx.tools.execute({ signal: testToolSignal, callId: CallId('parent'), name: 'parent', arguments: {} })
stopCapture()
policyCalls = 0
const signal = new AbortController().signal
@@ -372,6 +419,7 @@ describe('scoped execution dispatch', () => {
signal,
})
const subjectlessResult = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('non-cloneable-subjectless'),
name: 't',
arguments: { invalid: () => undefined },
@@ -414,6 +462,7 @@ describe('scoped execution dispatch', () => {
callId: CallId('stateful-parent'),
name: 't',
arguments: {},
signal: testToolSignal,
get parent(): ToolExecutionToken | undefined {
parentReads += 1
return parentReads === 1 ? undefined : forged
@@ -438,7 +487,7 @@ describe('scoped execution dispatch', () => {
if (exec.name === 'parent') parent = exec.token
return next()
})
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
await ctx.tools.execute({ signal: testToolSignal, callId: CallId('parent'), name: 'parent', arguments: {} })
stopCapture()
const acceptedSignal = new AbortController().signal
const driftSignal = new AbortController().signal
@@ -485,6 +534,7 @@ describe('scoped execution dispatch', () => {
const input = {
callId: CallId('throwing-arguments'),
name: 't',
signal: testToolSignal,
get arguments(): unknown {
argumentReads += 1
throw new Error('getter exploded')
@@ -525,6 +575,7 @@ describe('scoped execution dispatch', () => {
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('bad-arguments'), name: 't', arguments: argumentsValue,
})
@@ -545,6 +596,7 @@ describe('scoped execution dispatch', () => {
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue,
})
@@ -585,7 +637,7 @@ describe('scoped execution dispatch', () => {
ctx.on('tools/result', () => Promise.reject(new Error('async observer failure')) as never)
ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) })
const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('final'), name: 't', arguments: {}, agent: key })
await Promise.resolve()
expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] })
expect(seen).toEqual([true, true])

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,9 @@
},
{
"path": "../../ui/user-approval"
},
{
"path": "../../support/invariants"
}
]
}