fix(core): enforce agent-scoped ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-11 22:55:26 +08:00
parent 850796bb35
commit 3263dab822
62 changed files with 3982 additions and 857 deletions
+2 -2
View File
@@ -39,11 +39,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
// dispose the whole context (simulating process exit) so only the JSONL
// log on disk survives.
ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root })
const first = ctx.agents.create({
const first = (await ctx.agents.create({
agentId: AgentId('resume-1'),
sessionId: SESSION_ID,
agentOptions: { model: 'deepseek-v4-flash' },
}).agent as ReactLoopAgent
})).agent as ReactLoopAgent
first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }])
await waitForIdle(ctx, first)
await ctx.fiber.dispose()
@@ -119,37 +119,30 @@ describe('bash tool through the agent loop', () => {
it('background: start → poll → completion notice lands as context/message', async () => {
const adapter = new MockAdapter([
toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }),
toolCallResponse('call-2', 'bash_output', {}, undefined),
// Each harness owns a fresh BashLocal service, whose first task id is
// deterministically bash-1. Keep the scripted call faithful to what the
// model sent; tool arguments are immutable once execution policy begins.
toolCallResponse('call-2', 'bash_output', { task_id: 'bash-1' }, undefined),
textResponse('Background task finished.'),
])
// The second tool call needs the REAL task id from the first result;
// a tools/pre-execute listener rewrites the scripted arguments. (This uses
// the low-level capability to mutate `exec` before dispatch — the
// unadvertised mechanism behind a future first-class input-rewrite decision;
// here it is a test shim to thread the generated id, not a product feature.)
let taskId = ''
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' })
// Intercept the first tool result to capture the generated task id, then
// rewrite the second scripted call's arguments to use it.
// Capture the generated id so the deterministic fixture is checked against
// the real executor instead of silently assuming it.
ctx.on('session/event', (_session, event) => {
if (event.type === 'tool/result' && taskId === '') {
const match = /task (bash-\d+)/.exec(resultText(event))
if (match) taskId = match[1]!
}
})
ctx.on('tools/pre-execute', async (exec, next) => {
if (exec.name === 'bash_output') {
exec.arguments = { task_id: taskId }
}
return next()
})
agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
await waitForIdle(ctx, agent)
expect(taskId).toBe('bash-1')
// Wait for the background task itself (completion may race turn end).
const task = ctx.bash.get(BashTaskId(taskId))
if (!task) throw new Error(`task ${taskId} not registered`)
+9 -1
View File
@@ -242,7 +242,6 @@ describe('bash tool', () => {
[{ command: ' ', description: 'd' }, /invalid command/],
[{ command: 'x', description: ' ' }, /invalid description/],
[{ command: 'x', description: 'd', timeoutMs: -1 }, /invalid timeoutMs/],
[{ command: 'x', description: 'd', timeoutMs: Number.NaN }, /invalid timeoutMs/],
])('rejects value-invalid args %j', async (args, pattern) => {
const ctx = await setup()
const result = await call(ctx, 'bash', args)
@@ -250,6 +249,15 @@ describe('bash tool', () => {
expect(text(result)).toMatch(pattern)
})
it('rejects a non-JSON numeric argument before tool-specific validation', async () => {
const ctx = await setup()
const result = await call(ctx, 'bash', {
command: 'x', description: 'd', timeoutMs: Number.NaN,
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('tool execution arguments must be losslessly JSON-serializable')
})
it('registers all three schemas in the system prompt assembly', async () => {
const ctx = await setup()
const names = ctx.tools.schemas().map(schema => schema.name)
+46 -14
View File
@@ -57,7 +57,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
summary: 'The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`.',
methods: [
'create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent',
'createAgent(options: CreateAgentOptions): AgentHandle',
'async createAgent(options: CreateAgentOptions): Promise<AgentHandle>',
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
],
},
@@ -66,9 +66,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
summary: 'Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package.',
methods: [
'setFactory(factory: AgentFactory): () => Promise<void> | void',
'create(options: CreateAgentOptions): AgentHandle',
'async create(options: CreateAgentOptions): Promise<AgentHandle>',
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
'register(agent: Agent): () => Promise<void> | void',
'enter(agent: Agent): () => void',
'announce(agent: Agent): void',
'get(id: AgentId): Agent | undefined',
'list(): Agent[]',
],
@@ -161,25 +163,27 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'systemPrompt',
summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step.',
summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and authoritative contribution protections; the agent loop calls `assemble(context)` once per step.',
methods: [
'section(section: PromptSection): () => Promise<void> | void',
'tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void',
'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise<void> | void',
'protect(protection: PromptProtection): () => Promise<void> | void',
'async assemble(context: AssembleContext = {}): Promise<PromptAssembly>',
],
},
{
key: 'tools',
summary: 'Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline.',
summary: 'Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result` pipeline.',
methods: [
'register(definition: ToolDefinition): () => Promise<void> | void',
'restrict(filter: ToolRestriction): () => Promise<void> | void',
'guard(guard: ToolGuard): () => Promise<void> | void',
'visible(scope?: ScopeKey): ToolDefinition[]',
'get(name: string, scope?: ScopeKey): ToolDefinition | undefined',
'schemas(scope?: ScopeKey): ToolSchema[]',
'knownNames(scope?: ScopeKey): string[]',
'async execute(exec: ToolExecution): Promise<ToolExecutionResult>',
'async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>',
],
},
{
@@ -215,13 +219,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/created',
mode: 'emit',
signature: '\'agent/created\'(this: Scoped<Agent>, agent: Agent): void',
summary: 'An agent was registered in the AgentRegistry and is ready to receive messages.',
summary: 'An agent\'s fully composed scoped world was published in the AgentRegistry.',
},
{
name: 'agent/disposed',
mode: 'emit',
signature: '\'agent/disposed\'(this: Scoped<Agent>, agent: Agent): void',
summary: 'An agent was disposed and removed from the registry; its fiber and any in-flight turn have been torn down.',
summary: 'An agent was removed from the registry after its driver and any in-flight turn reached quiescence.',
},
{
name: 'agent/error',
@@ -283,6 +287,12 @@ export const EVENT_API: readonly EventApiEntry[] = [
signature: '\'agent/turn-continuation\'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>',
summary: 'Waterfall: override the turn-continuation decision via a typed ContinuationDecision.',
},
{
name: 'agent/turn-stop',
mode: 'serial',
signature: '\'agent/turn-stop\'(this: Scoped<Agent>, agent: Agent, turn: number): Promise<ContinuationStop | undefined> | ContinuationStop | undefined',
summary: 'Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded.',
},
{
name: 'fs/edit-intent',
mode: 'waterfall',
@@ -328,7 +338,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
{
name: 'subagent/end',
mode: 'emit',
signature: '\'subagent/end\'(info: SubagentRunEndInfo): void',
signature: '\'subagent/end\'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void',
summary: 'A subagent run settled — emitted when SubagentRun.result resolves (any stop reason).',
},
{
@@ -346,7 +356,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
{
name: 'subagent/start',
mode: 'emit',
signature: '\'subagent/start\'(info: SubagentRunInfo): void',
signature: '\'subagent/start\'(this: Scoped<SubagentService>, info: SubagentRunInfo): void',
summary: 'A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins.',
},
{
@@ -359,7 +369,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'system-prompt/change',
mode: 'emit',
signature: '\'system-prompt/change\'(): void',
summary: 'A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed — possibly for one scope only).',
summary: 'A section, tool provider, variable provider, or protection was registered or unregistered (the assembly inputs changed — possibly for one scope only).',
},
{
name: 'tools/change',
@@ -385,6 +395,12 @@ export const EVENT_API: readonly EventApiEntry[] = [
signature: '\'tools/pre-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>',
summary: 'Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code\'s `PreToolUse`).',
},
{
name: 'tools/result',
mode: 'parallel',
signature: '\'tools/result\'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void',
summary: 'Awaited notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.',
},
{
name: 'workflow/agent-end',
mode: 'emit',
@@ -431,7 +447,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'AgentFactory',
declaration: 'export interface AgentFactory {\n createAgent(options: CreateAgentOptions): AgentHandle;\n resume(options: ResumeAgentOptions): Promise<AgentHandle>;\n}',
declaration: 'export interface AgentFactory {\n createAgent(options: CreateAgentOptions): Promise<AgentHandle>;\n resume(options: ResumeAgentOptions): Promise<AgentHandle>;\n}',
},
{
name: 'AgentHandle',
@@ -563,7 +579,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CreateAgentOptions',
declaration: 'export interface CreateAgentOptions {\n agentId: AgentId;\n sessionId: SessionId;\n meta?: {\n cwd?: string;\n parentSession?: SessionId;\n seedLength?: number;\n };\n seed?: SessionEvent[];\n agentOptions?: AgentOptions;\n setup?: (agentCtx: Context) => void;\n}',
declaration: 'export interface CreateAgentOptions {\n agentId: AgentId;\n sessionId: SessionId;\n meta?: {\n cwd?: string;\n parentSession?: SessionId;\n seedLength?: number;\n };\n seed?: SessionEvent[];\n agentOptions?: AgentOptions;\n setup?: (agentCtx: Context) => Promise<void> | void;\n}',
},
{
name: 'CreateSessionOptions',
@@ -665,6 +681,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PromptAssembly',
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
},
{
name: 'PromptProtection',
declaration: 'export interface PromptProtection {\n sections?: readonly string[];\n tools?: readonly string[];\n}',
},
{
name: 'PromptSection',
declaration: 'export interface PromptSection {\n name: string;\n order: number;\n text: string | ((context: AssembleContext) => string);\n}',
@@ -675,7 +695,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ResumeAgentOptions',
declaration: 'export interface ResumeAgentOptions {\n agentId: AgentId;\n resumeSessionId: SessionId;\n agentOptions?: AgentOptions;\n}',
declaration: 'export interface ResumeAgentOptions {\n agentId: AgentId;\n resumeSessionId: SessionId;\n agentOptions?: AgentOptions;\n setup?: (agentCtx: Context) => Promise<void> | void;\n}',
},
{
name: 'ScopeKey',
@@ -807,12 +827,24 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolExecution',
declaration: 'export interface ToolExecution {\n callId: CallId;\n name: string;\n arguments: unknown;\n agent?: Agent;\n signal?: AbortSignal;\n}',
declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n}',
},
{
name: 'ToolExecutionInput',
declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n}',
},
{
name: 'ToolExecutionResult',
declaration: 'export interface ToolExecutionResult {\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContext?: HookContext;\n meta?: unknown;\n}',
},
{
name: 'ToolExecutionToken',
declaration: 'export interface ToolExecutionToken {\n readonly [toolExecutionTokenBrand]: true;\n}',
},
{
name: 'ToolGuard',
declaration: 'export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;',
},
{
name: 'ToolProviderResult',
declaration: 'export interface ToolProviderResult {\n schemas: ToolSchema[];\n knownNames?: readonly string[];\n}',
+2 -2
View File
@@ -253,8 +253,8 @@ const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setT
* metadata (`schemas`, and `get` returning a schema view, never the live
* `ToolDefinition`). Exposing the raw definition would hand mount code the
* tool's `execute` function, letting it call another tool directly and bypass
* `ToolRegistry.execute` — the pre/post-execute waterfall (permission gates,
* accounting) and result normalization. So `get` returns the same
* `ToolRegistry.execute` — identity protection, pre-policy, monotonic guards,
* around dispatch, post-policy, final observation, and result normalization. So `get` returns the same
* name/description/parameters view as `schemas()`, and nothing invocable.
*/
function sandboxTools(ctx: Context): Record<string, unknown> {
-1
View File
@@ -11,7 +11,6 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
+65 -12
View File
@@ -16,6 +16,51 @@ import type { Session } from '@deepseek-ai/dsh-session'
import { Inbox } from './inbox.ts'
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
/** Agents whose rollback-covered publication enabled driving. */
const driveEnabledAgents = new WeakSet<ReactLoopAgent>()
/** Sessions already claimed by a concrete driver construction. */
const claimedDriverSessions = new WeakSet<Session>()
/** Module-private driver entry: its symbol is absent from the package surface. */
const startDriver = Symbol('dsh.agent-loop.start-driver')
/** Factory-owned controls that can operate only on the agent created with them. */
export interface PreparedReactLoopAgent {
/** The unpublished concrete agent. */
agent: ReactLoopAgent
/** Open its driving verbs at the rollback-covered publication boundary. */
enableDrive(): void
/** Start its driver after publication and session-start notification. */
startDriver(): () => void
}
/**
* Construct one concrete agent together with unforgeable, instance-bound
* lifecycle controls. The package surface deliberately exposes neither source
* subpaths nor this helper: setup code may identify the concrete class, but it
* cannot enable or start the factory's unpublished instance.
* @param ctx - the agent-loop service context used for driving and events.
* @param id - the concrete agent identity.
* @param options - loop options for the agent.
* @param session - the prepared session the agent will own.
* @returns the agent and closures bound only to that exact instance.
*/
export function prepareReactLoopAgent(
ctx: Context, id: AgentId, options: AgentOptions, session: Session,
): PreparedReactLoopAgent {
if (claimedDriverSessions.has(session)) {
throw new Error(`session "${session.id}" already has a concrete agent driver`)
}
claimedDriverSessions.add(session)
const agent = new ReactLoopAgent(ctx, id, options, session)
return {
agent,
enableDrive: () => { driveEnabledAgents.add(agent) },
startDriver: () => agent[startDriver](),
}
}
/**
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
*
@@ -24,11 +69,8 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
* the agent/* event taxonomy — plugins never need this class.
*/
export class ReactLoopAgent implements Agent {
/**
* The queued + steering FIFOs behind {@link send}/{@link steer}. Public so
* the driver loop can drain it; {@link cancel} clears it wholesale.
*/
readonly inbox = new Inbox()
/** Queued + steering FIFOs; native-private so setup cannot bypass driving verbs. */
readonly #inbox = new Inbox()
/**
* The agent's scope context ({@link Agent.ctx}), wired by the factory right
@@ -119,7 +161,7 @@ export class ReactLoopAgent implements Agent {
/**
* Resolve and clear all pending {@link whenIdle} waiters. Called on a
* running→idle transition (from {@link setStatus}) and on disposal (from the
* {@link start} disposer, which chains `done` for true loop-exit quiescence).
* internal driver disposer, which chains `done` for true loop-exit quiescence).
*/
private settleIdleWaiters(): void {
const waiters = this.idleWaiters
@@ -131,22 +173,31 @@ export class ReactLoopAgent implements Agent {
return options?.source ?? { kind: 'user' }
}
/** Reject every driving verb while creation setup still owns the agent. */
private assertDriveEnabled(action: string): void {
if (driveEnabledAgents.has(this)) return
throw new Error(`agent "${this.id}" cannot ${action} before creation setup completes`)
}
send(content: ContentBlock[], options?: SendOptions): void {
this.assertDriveEnabled('send')
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
const source = this.resolveSource(options)
this.inbox.enqueue({ content, source })
this.#inbox.enqueue({ content, source })
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: false })
}
steer(content: ContentBlock[], options?: SendOptions): void {
this.assertDriveEnabled('steer')
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
if (this._status !== 'running') { this.send(content, options); return }
const source = this.resolveSource(options)
this.inbox.steer({ content, source })
this.#inbox.steer({ content, source })
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: true })
}
inject(content: ContentBlock[], options?: SendOptions): void {
this.assertDriveEnabled('inject')
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
const source = this.resolveSource(options)
if (isTurnOpen(this.session)) {
@@ -220,6 +271,7 @@ export class ReactLoopAgent implements Agent {
}
cancel(reason?: string): void {
this.assertDriveEnabled('cancel')
// Arm-gate: only mark a cancellation when there is actually work to cancel —
// a running turn, an in-flight step, or queued/steering work. An idle cancel
// with nothing pending is a true no-op; arming the marker then would wrongly
@@ -229,7 +281,7 @@ export class ReactLoopAgent implements Agent {
// the pre-step window (a send() queued but the loop not yet flipped to
// running) has status `idle` with `hasQueued` true, and the marker exists
// precisely to cover it.
if (this._status === 'running' || this.currentAbort !== undefined || this.inbox.hasQueued || this.inbox.hasSteering) {
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
@@ -240,7 +292,7 @@ export class ReactLoopAgent implements Agent {
// cancelled turn's steering is not re-enqueued). Cleared directly even when
// the loop is parked in waitForQueued — there is no turn to stop and nothing
// left for the parked loop to run, so no wake is needed.
this.inbox.clear()
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).
@@ -263,7 +315,7 @@ export class ReactLoopAgent implements Agent {
*/
whenIdle(): Promise<void> {
if (this._status === 'disposed') return this.done
if (this._status !== 'running' && !this.inbox.hasQueued) return Promise.resolve()
if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve()
// Register an internal waiter (resolved by settleIdleWaiters on the next
// running→idle/disposed transition), NOT an effect-scoped `ctx.on` listener:
// a concurrent fiber disposal runs this agent's listener disposers, which
@@ -287,8 +339,9 @@ export class ReactLoopAgent implements Agent {
* @returns the disposer — idempotent and infallible (it runs inside the
* fiber's LIFO disposal chain, where a throw would skip later disposers).
*/
start(): () => void {
[startDriver](): () => void {
this.done = runLoop(this.loopCtx, this, {
inbox: this.#inbox,
setStatus: (status) => { this.setStatus(status) },
setAbort: controller => void (this.currentAbort = controller),
disposed: this.disposed,
+244 -135
View File
@@ -7,10 +7,11 @@
* @module @deepseek-ai/dsh-agent-loop
*/
import { Context, Service } from 'cordis'
import { Context, FiberState, Service } from 'cordis'
import { randomUUID } from 'node:crypto'
import z from 'schemastery'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-llm'
@@ -19,11 +20,9 @@ import type { Session } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { ReactLoopAgent } from './agent.ts'
import { prepareReactLoopAgent, ReactLoopAgent } from './agent.ts'
export { ReactLoopAgent } from './agent.ts'
export { Inbox, type InboxMessage } from './inbox.ts'
export { runLoop } from './loop.ts'
declare module 'cordis' {
interface Context {
@@ -70,6 +69,10 @@ export interface Config {
export class AgentLoop extends Service implements AgentFactory {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
/** IDs held by unpublished async creation transactions. */
private pendingAgentIds = new Set<AgentId>()
private pendingSessionIds = new Set<SessionId>()
// The schema validates plain strings (cordis.yml config values are untyped at
// runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId`
// because the config format is the boundary where an id enters. The brand is a
@@ -165,18 +168,28 @@ export class AgentLoop extends Service implements AgentFactory {
* and agent options.
* @returns the handle whose dispose tears down exactly this agent.
*/
createAgent(options: CreateAgentOptions): AgentHandle {
// Check the agent id BEFORE preparing the session: register() would reject a
// duplicate id only AFTER the session enters the store, leaving an orphaned
// live session (and lazy persistence state) that blocks reuse of that id.
this.assertAgentIdFree(options.agentId)
const session = this.ctx.sessions.prepare(options.sessionId, {
...options.seed !== undefined ? { seed: options.seed } : {},
meta: options.meta ?? {},
})
// A seeded (forked) create is still a fresh start, NOT a resume — `resume`
// is reserved for reloading a PERSISTED session via resume()/resumeWith().
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'startup', options.setup)
async createAgent(options: CreateAgentOptions): Promise<AgentHandle> {
// Snapshot every caller-owned field before the first async setup boundary.
// The callback itself is an identity capability; all data fields are
// detached so caller mutation cannot drift a reserved/published identity or
// the options the accepted agent observes.
const agentId = options.agentId
const sessionId = options.sessionId
const setup = options.setup
const agentOptions = structuredClone(options.agentOptions ?? {})
const seed = options.seed === undefined ? undefined : structuredClone(options.seed)
const meta = structuredClone(options.meta ?? {})
const release = this.reserve(agentId, sessionId)
try {
const session = this.ctx.sessions.prepare(sessionId, {
...seed !== undefined ? { seed } : {},
meta,
})
// A seeded (forked) create is still a fresh start, NOT a resume.
return await this.startOwned(agentId, agentOptions, session, 'startup', setup)
} finally {
release()
}
}
/**
@@ -226,31 +239,76 @@ export class AgentLoop extends Service implements AgentFactory {
* AgentLoop's static inject, so they resolve fine).
*/
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
this.assertAgentIdFree(options.agentId)
const { meta, events } = await persistence.load(options.resumeSessionId)
// Re-check the agent id AFTER the await: the pre-load check above can go
// stale while load() is pending (a concurrent resume/create may register the
// same id). Re-checking immediately before prepare()/start keeps the
// "no orphaned session on a duplicate id" guarantee under concurrency.
this.assertAgentIdFree(options.agentId)
// Reconstruct the live session with the FULL persisted header (createdAt,
// cwd, lineage) so resume preserves identity, not just the cwd. The seed
// events make lastTurnNumber/deriveMessages continue; the backend already
// has state (cursor) from the load above, so onCreated is a no-op and the
// seed is not re-persisted. prepare() (not create()) so the session
// lifecycle folds into the agent's composite effect (ordered teardown).
const session = this.ctx.sessions.prepare(options.resumeSessionId, {
seed: events,
meta: {
createdAt: meta.createdAt,
...meta.cwd !== undefined ? { cwd: meta.cwd } : {},
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
// Reconstruct the seed boundary from the persisted header, NOT from
// `events.length` (the resume seeds the WHOLE stored log).
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
},
})
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'resume')
// Persistence is an async trust boundary. Reserve, load, reconstruct, and
// publish only the identities/options accepted at entry—never fields
// reread from a caller-owned object after the await.
const agentId = options.agentId
const sessionId = options.resumeSessionId
const agentOptions = structuredClone(options.agentOptions ?? {})
const setup = options.setup
const { promise: ownerDisposed, resolve: markOwnerDisposed } = Promise.withResolvers<void>()
const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers<void>()
let observingOwner = true
// Resume must observe its caller from BEFORE persistence I/O begins. The
// full agent lifecycle does not exist until load returns, so without this
// sentinel a never-settling backend outlives owner disposal and holds both
// public identities forever. `this.ctx.effect` retains the traceable caller
// ownership used by startOwned's lifecycle effect. Install it before even
// reserving the ids: an inactive owner cannot leak a reservation if effect
// registration fails.
const disposeLoadSentinel = this.ctx.effect(() => () => {
if (!observingOwner) return
markOwnerDisposed()
// Owner-triggered teardown does not reach quiescence until the resume
// transaction has observed disposal and released both reservations.
return transactionSettled
}, `agentLoop.resumeLoad(${agentId})`)
try {
const release = this.reserve(agentId, sessionId)
try {
const loadTask = persistence.load(sessionId)
const { meta, events } = await Promise.race([
loadTask,
ownerDisposed.then(() => {
throw new Error(`agent "${agentId}" resume aborted: owner disposed during persistence load`)
}),
])
// An out-of-band direct registry/session insertion can still race this
// service's reservation, so the public enter primitives re-check exact
// liveness at publication.
const session = this.ctx.sessions.prepare(sessionId, {
seed: events,
meta: {
createdAt: meta.createdAt,
...meta.cwd !== undefined ? { cwd: meta.cwd } : {},
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
},
})
// Calling startOwned synchronously installs the complete lifecycle
// effect before it reaches its first setup await. Only then disarm the
// load sentinel: ownership passes directly from one effect to the other
// with no disposal gap.
const starting = this.startOwned(agentId, agentOptions, session, 'resume', setup)
observingOwner = false
await disposeLoadSentinel()
return await starting
} finally {
release()
}
} finally {
try {
// Manual handoff/removal must not return transactionSettled: awaiting
// that promise from inside this transaction would deadlock it. If the
// owner already triggered cleanup, this idempotent second disposal is a
// no-op and the owner's first cleanup remains parked on the shared
// settlement promise.
observingOwner = false
await disposeLoadSentinel()
} finally {
markTransactionSettled()
}
}
}
/**
@@ -260,109 +318,136 @@ export class AgentLoop extends Service implements AgentFactory {
* only after the session has already entered the store.
*/
private assertAgentIdFree(id: AgentId): void {
if (this.ctx.agents.get(id) !== undefined) {
if (this.ctx.agents.get(id) !== undefined || this.pendingAgentIds.has(id)) {
throw new Error(`agent "${id}" is already registered`)
}
}
/** Reserve both public identities for one unpublished async transaction. */
private reserve(agentId: AgentId, sessionId: SessionId): () => void {
this.assertAgentIdFree(agentId)
if (this.ctx.sessions.get(sessionId) !== undefined || this.pendingSessionIds.has(sessionId)) {
throw new Error(`session "${sessionId}" already exists`)
}
this.pendingAgentIds.add(agentId)
this.pendingSessionIds.add(sessionId)
return () => {
this.pendingAgentIds.delete(agentId)
this.pendingSessionIds.delete(sessionId)
}
}
/**
* Shared: construct a ReactLoopAgent over a PREPARED (not-yet-entered)
* session, then build the ONE composite effect that owns the whole agent
* lifecycle — session entry, registry registration, and the loop. Keeping all
* three in a SINGLE effect (not sibling effects) is load-bearing: a fiber
* unload disposes sibling effects CONCURRENTLY (`Promise.all`), which would
* race the session detach against the loop's closing flush and drop the
* closing `turn/end`. Inside one effect the disposers run as an ORDERED LIFO
* chain — the runtime awaits each disposer's returned promise before the next:
*
* yield session-detach (disposed LAST — detach onAppend + remove entry)
* yield register (disposed 2nd — unregister)
* yield stop-and-drain (disposed FIRST — request loop stop, await agent.done)
*
* So on teardown: the loop is stopped and AWAITED to exit (its final
* `session/flush` + `turn/end` fire through the still-attached `onAppend`),
* THEN the agent is unregistered, THEN the session is detached — capturing the
* closing events before detach, whether the trigger is the handle's `dispose()`
* OR a fiber unload. Rollback safety: each yield runs before the next mutation,
* so a throwing `session/created`/`agent/created` listener unwinds the
* already-yielded disposers instead of leaking.
*
* `source` says why the session began ({@link SessionStartSource}); it is
* emitted as `agent/session-start` once, AFTER the agent is registered (so a
* listener can resolve the agent via `ctx.agents.get(id)` and `inject()` into
* it) and BEFORE the loop starts its first turn. The emit is contained: a
* throwing session-start listener must not abort agent construction — it is
* logged, and the agent still starts. (Unlike a turn-boundary throw, there is
* no open turn here to balance; the durable evidence of a session-start hook
* is whatever it `inject()`ed.)
*
* Returns the agent plus the composite effect's disposer (`disposeAgent`).
* Construct an unpublished agent and synchronously install its complete
* teardown skeleton before any setup await. The closures are assigned their
* session/registry/loop disposers only at publication, while the exact scope
* disposer is nested immediately. Therefore owner unload during setup flips
* `active`, unwinds the scope, and wins the race without any late Cordis
* effect collection.
*/
private start(
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
setup?: (agentCtx: Context) => void,
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
const agent = new ReactLoopAgent(this.ctx, id, options, session)
// The ONE quiescence boundary every disposal path observes. Cordis effect
// disposers are single-shot but not await-idempotent: when the OWNING
// fiber's unload invokes the raw wrapper first, a concurrent
// `handle.dispose()` calling the same wrapper gets an immediate undefined
// (epoch already cleared) — so the handle path must await THIS promise,
// resolved by the teardown chain's final disposer, not the wrapper's
// return. Every disposer in the chain is deliberately infallible (stop()
// is infallible by contract, unregister/detach contain their listeners,
// the scope unwind is cordis-contained), so the final disposer always
// runs — a throwing link would skip the rest of a cordis dispose chain.
private prepareLifecycle(id: AgentId, options: AgentOptions, session: Session): {
agent: ReactLoopAgent
active: () => boolean
deactivated: Promise<void>
publish: (source: SessionStartSource) => void
disposeAgent: () => Promise<void>
} {
// When creation is invoked through an agent scope (subagents), the owner
// agent's disposed status flips synchronously at handle teardown—earlier
// than Cordis reaches nested scope effects. Include that signal in the
// pre-publication liveness check so a same-turn parent dispose cannot race
// an already-fulfilled setup promise into briefly publishing a child.
const ownerAgent = this.ctx.agent
const ownerFiber = this.ctx.fiber
const driver = prepareReactLoopAgent(this.ctx, id, options, session)
const { agent } = driver
const scope: Scope = createScope(this.ctx, agent)
agent.ctx = scope.ctx.extend({ agent })
let active = true
let detachSession: (() => void) | undefined
let detachAgent: (() => void) | undefined
let stop: (() => void) | undefined
const { promise: deactivated, resolve: markDeactivated } = Promise.withResolvers<void>()
const { promise: torndown, resolve: markTorndown } = Promise.withResolvers<void>()
const dispose = this.ctx.effect(function* (this: AgentLoop) {
// First-yielded ⇒ disposed LAST: marks true teardown completion.
const dispose = this.ctx.effect(function* () {
// First yielded, disposed last: every preceding teardown stage settled.
yield () => { markTorndown() }
// Mint the agent's scope (key = the agent) and wire the two-phase
// reference: the scope context tags registrations + filters dispatch;
// the extend adds the `ctx.agent` DX own-property on top. The raw
// disposer is yielded IMMEDIATELY (exact function identity nests the
// scope fiber out of the loop fiber's concurrent sibling list), so
// there is no window in which a throw leaves the scope un-nested.
//
// Yield order is the REVERSE of teardown (LIFO). Teardown runs:
// stop/drain → unregister → detach session → unwind scope
// Detach BEFORE the scope unwind is deliberate: the scope fiber's
// unload is asynchronous (fiber inertia), and every disposer chained
// after an async one waits for it — detaching first keeps the
// store/registry rollback SYNCHRONOUS on every failure path (a caller
// that catches a throwing create() observes no half-created agent or
// session, and the ids are immediately reusable), at the cost that a
// scoped listener's own disposer runs after the session left the store
// (it heard the final stop/drain flush while still attached, so
// nothing durable is lost).
const scope = createScope(this.ctx, agent)
agent.ctx = scope.ctx.extend({ agent })
// Exact identity moves the scope fiber out of the owner's concurrent
// sibling list and into this ordered transaction.
yield scope.rawDispose
// Enter the session THROUGH agent.ctx so the store captures the agent's
// scope as the session's dispatch carrier.
yield agent.ctx.sessions.enter(session)
yield () => {
detachSession?.()
detachSession = undefined
}
yield () => {
detachAgent?.()
detachAgent = undefined
}
// Last yielded, disposed first. Keep the pre-publication path
// synchronous: returning a Promise only after the loop actually began
// lets a failed announcement roll back registry/store before create's
// rejection is observed.
yield () => {
active = false
markDeactivated()
if (stop === undefined) return
stop()
return agent.done
}
}, 'agentLoop.lifecycle()')
let disposing: Promise<void> | undefined
const disposeAgent = (): Promise<void> => (disposing ??= (async () => {
await dispose()
await torndown
})())
const publish = (source: SessionStartSource): void => {
// Publication is one synchronous, rollback-covered sequence. Setup has
// already completed, so its scoped listeners observe both announcements.
detachSession = agent.ctx.sessions.enter(session)
detachAgent = this.ctx.agents.enter(agent)
this.ctx.sessions.announce(session)
yield this.ctx.agents.register(agent)
// The creator's scoped composition, inside the rollback boundary: a
// throwing setup unwinds LIFO through register → scope → detach, so a
// half-created agent never leaks. Setup REGISTERS (through agent.ctx),
// it never drives — see CreateAgentOptions.setup.
setup?.(agent.ctx)
// Fire AFTER register (a listener can ctx.agents.get(id) + inject()) and
// BEFORE the loop's first turn. Contained: a throwing listener is logged,
// never aborts construction (no open turn to balance here).
this.ctx.agents.announce(agent)
// Setup is over and both entries are live. Open the driving surface just
// before session-start so its listeners retain their supported ability to
// inject/queue, while setup itself can never drive an unpublished agent.
driver.enableDrive()
try {
agentEvents(this.ctx, agent).emit('agent/session-start', source)
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`)
}
const stop = agent.start()
// Disposed FIRST (LIFO): request loop stop (sync), then AWAIT the loop's
// actual exit so its closing flush lands while onAppend (yielded above,
// disposed later) is still attached.
yield async () => { stop(); await agent.done }
}.bind(this), 'agentLoop.start()')
return { agent, disposeAgent: async () => { await dispose(); await torndown } }
stop = driver.startDriver()
}
return {
agent,
active: () => active
&& ownerFiber.state !== FiberState.UNLOADING
&& ownerFiber.state !== FiberState.DISPOSED
&& ownerFiber.state !== FiberState.FAILED
&& ownerAgent?.status !== 'disposed',
deactivated,
publish,
disposeAgent,
}
}
/** Publish a no-setup config agent synchronously. */
private start(
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
const lifecycle = this.prepareLifecycle(id, options, session)
try {
lifecycle.publish(source)
return { agent: lifecycle.agent, disposeAgent: lifecycle.disposeAgent }
} catch (error: unknown) {
void lifecycle.disposeAgent()
throw error
}
}
/**
@@ -382,13 +467,37 @@ export class AgentLoop extends Service implements AgentFactory {
* `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()`
* helper).
*/
private startOwned(
private async startOwned(
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
setup?: (agentCtx: Context) => void,
): AgentHandle {
const { agent, disposeAgent } = this.start(id, options, session, source, setup)
let disposing: Promise<void> | undefined
return { agent, dispose: () => (disposing ??= disposeAgent()) }
setup?: (agentCtx: Context) => Promise<void> | void,
): Promise<AgentHandle> {
const lifecycle = this.prepareLifecycle(id, options, session)
try {
// The owner-disposal branch makes a never-settling setup unable to hold
// the transaction or its ID reservations forever. Promise.race installs
// rejection observation on setup even if owner disposal wins first.
const setupTask = Promise.resolve(setup?.(lifecycle.agent.ctx))
await Promise.race([
setupTask,
lifecycle.deactivated.then(() => {
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
}),
])
// Cordis begins a fiber unload synchronously but invokes nested effect
// disposers from its next microtask. Give that already-started unload one
// checkpoint to deactivate this lifecycle before publication; otherwise
// an immediately fulfilled setup continuation can outrun its owner's
// same-turn dispose and briefly publish an already-doomed child.
await Promise.resolve()
if (!lifecycle.active()) {
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
}
lifecycle.publish(source)
return { agent: lifecycle.agent, dispose: lifecycle.disposeAgent }
} catch (error: unknown) {
await lifecycle.disposeAgent()
throw error
}
}
}
+70 -19
View File
@@ -11,7 +11,7 @@ import type { Context } from 'cordis'
import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, ContinuationStop, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
@@ -20,6 +20,7 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { ReactLoopAgent } from './agent.ts'
import type { Inbox } from './inbox.ts'
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
type CodedError = Error & { code?: string }
@@ -35,6 +36,20 @@ function toError(error: unknown): CodedError {
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
}
/**
* Validate the runtime result of the terminal-stop serial event. Event types
* protect TypeScript listeners, but JavaScript and casts can still return an
* arbitrary bail value; accepting one as an implicit stop would hide a broken
* policy plugin.
*/
function assertContinuationStop(value: unknown): asserts value is ContinuationStop | undefined {
if (value === undefined) return
const candidate = Object(value) as { action?: unknown }
if (candidate.action !== 'stop') {
throw new Error('agent/turn-stop returned an invalid result; expected { action: \'stop\' } or undefined')
}
}
/**
* Map a model-call {@link FinishReason} to the step error it should raise, or
* `undefined` when the step completed normally.
@@ -108,6 +123,8 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
* loop testable without a real agent.
*/
export interface LoopHandle {
/** Native-private agent inbox handed to the driver only at internal startup. */
readonly inbox: Inbox
setStatus(status: 'idle' | 'running'): void
setAbort(controller: AbortController | undefined): void
/** Resolves when the agent is disposed — unblocks the idle wait. */
@@ -185,6 +202,9 @@ export interface LoopHandle {
* {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is
* recorded as next-step steering
* if action==stop && steering arrived (step/end/continuation listeners): continue anyway
* terminal = serial agent/turn-stop ⟵ stop or abstain; after all ordinary
* continuation and steering folding
* if terminal: discard pending steering and break
* if action==stop: break
* session('turn/end') ⟵ durable turn boundary (no agent/* mirror)
* await ctx.sessions.flush(session) ⟵ durability checkpoint (store-owned carrier)
@@ -210,7 +230,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
const events = agentEvents(ctx, agent)
while (!handle.isDisposed()) {
await agent.inbox.waitForQueued(handle.disposed)
await handle.inbox.waitForQueued(handle.disposed)
if (handle.isDisposed()) break
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the
@@ -228,7 +248,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
// resolve before it runs (the quiescence contract).
if (handle.isCancelled()) {
handle.clearCancel()
if (!agent.inbox.hasQueued) {
if (!handle.inbox.hasQueued) {
handle.settleIdle()
continue
}
@@ -250,7 +270,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
// is still queued and unrun (the same early-resolve race window 1 fixes).
if (handle.isCancelled()) {
handle.clearCancel()
if (!agent.inbox.hasQueued) {
if (!handle.inbox.hasQueued) {
handle.setStatus('idle')
continue
}
@@ -261,8 +281,9 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
// the loop waits above, so the next real turn must continue from whatever
// turn number is actually last in the log — a stale counter would collide.
const turn = lastTurnNumber(session) + 1
let terminalStopped = false
try {
await runTurn(ctx, events, agent, handle, turn, transmission)
terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission)
} catch (error: unknown) {
// Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard
// before turn/start) — no turn/start was appended, so no turn is open and
@@ -286,27 +307,30 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
// cancelled.
handle.clearCancel()
// Steering that arrived too late to join this turn (turn-end listeners,
// flush) becomes a queued message — it must never be stranded. (A cancelled
// turn already cleared its steering, so there is nothing to re-enqueue.)
for (const message of agent.inbox.drainSteering()) {
agent.inbox.enqueue(message)
// Steering that arrived too late to join an ordinary turn (turn-end
// listeners, flush) becomes queued input so it is never stranded. A
// terminal-stop owner is the deliberate exception: discard the steering
// again after the close + flush window so terminal policy cannot be undone
// after its in-turn drain. Ordinary queued sends live in a separate FIFO and
// remain untouched.
for (const message of handle.inbox.drainSteering()) {
if (!terminalStopped) handle.inbox.enqueue(message)
}
if (!agent.inbox.hasQueued) handle.setStatus('idle')
if (!handle.inbox.hasQueued) handle.setStatus('idle')
}
}
async function runTurn(
ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog,
): Promise<void> {
): Promise<boolean> {
const { session } = agent
// --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end —
// turn/start has not been appended — so it propagates to runLoop's backstop
// untouched. The queued messages are drained here but appended AFTER
// turn/start (below), so every event in the log lives inside a turn.
const queued = agent.inbox.drainQueued()
const queued = handle.inbox.drainQueued()
const first = queued[0]
/* 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')
@@ -316,6 +340,7 @@ async function runTurn(
let step = 0
let stepOpen = false
let errorReported = false
let terminalStopped = false
// Close the open step exactly once (idempotent via stepOpen). Step boundaries
// are durable session events only — there is no agent/* step emit to mirror
@@ -449,7 +474,7 @@ async function runTurn(
// Steering from the previous round's continuation listeners joins before
// the request.
drainSteering(agent, turn)
drainSteering(agent, handle.inbox, turn)
// The step's AbortController exists BEFORE any async pre-step work so a
// dispose() or cancel() — in a synchronous turn-start listener or an
@@ -616,7 +641,7 @@ async function runTurn(
if (stepReason) reason = stepReason
// Steering that arrived during streaming/tool execution.
const steered = drainSteering(agent, turn)
const steered = drainSteering(agent, handle.inbox, turn)
if (closeStep()) break
@@ -638,14 +663,39 @@ async function runTurn(
// iteration drains it before its request — the typed twin of the /goal
// step/end-steer pattern.
if (decision.action === 'continue' && decision.reason) {
agent.inbox.steer({ content: decision.reason.content, source: decision.reason.source })
handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source })
}
let shouldContinue = decision.action === 'continue'
// Steering from step/end session-event or continuation listeners (the
// /goal pattern) demands the model see it — it overrides a stop decision;
// the next iteration's drain records it.
if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true
if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true
// Terminal policy runs only AFTER the extensible continuation waterfall,
// its optional reason, and late steering have all been folded. Unlike the
// waterfall, this serial seam is monotonic: the first stop bail wins, and
// no later listener or steering override can resurrect the turn.
let terminalStop = false
try {
const stop = await events.strictSerial('agent/turn-stop', turn)
assertContinuationStop(stop)
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))
break
}
if (terminalStop) {
terminalStopped = true
// A continuation reason or listener may have queued steering before the
// terminal checkpoint. Discard only steering (never ordinary queued
// prompts) so it cannot become a next step or be re-enqueued as a fresh
// turn by runLoop's late-steering fallback.
handle.inbox.drainSteering()
shouldContinue = false
}
// A cancel that landed during the continuation window — after the step's
// AbortController was cleared (setAbort(undefined)) but before the next
@@ -720,11 +770,12 @@ async function runTurn(
// contained: a throwing agent/error listener must not escape the loop.
}
}
return terminalStopped
}
/** Drain the steering queue into the session. Returns whether any arrived. */
function drainSteering(agent: ReactLoopAgent, turn: number): boolean {
const messages = agent.inbox.drainSteering()
function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boolean {
const messages = inbox.drainSteering()
for (const message of messages) {
agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
}
+13 -7
View File
@@ -7,6 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
@@ -226,16 +227,19 @@ describe('ReactLoopAgent', () => {
})
it('disposer is idempotent (double-stop)', async () => {
// Create a bare ReactLoopAgent and call start() directly to get the disposer.
// Then call it twice — the second call hits the early-return branch.
// Create a bare ReactLoopAgent and start it through the package-internal
// test seam. Then call its disposer twice — the second call hits the
// early-return branch.
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('test'))
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const { agent } = prepared
// Start the loop to get the disposer; the agent waits for messages
// (idle, never-resolving cancel), so it will stay idle.
const dispose = agent.start()
prepared.enableDrive()
const dispose = prepared.startDriver()
// First dispose
dispose()
@@ -324,7 +328,7 @@ describe('ReactLoopAgent', () => {
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
// while running (not the fast path), then the disposer settles it and chains
// `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct
// start() disposer keeps the emit synchronous.
// internal driver disposer keeps the emit synchronous.
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -334,8 +338,10 @@ describe('ReactLoopAgent', () => {
const adapter = new MockAdapter(['hang'])
ctx.llm.registerAdapter(['mock'], adapter)
const session = ctx.sessions.create(SessionId('bare'))
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const dispose = agent.start()
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const { agent } = prepared
prepared.enableDrive()
const dispose = prepared.startDriver()
agent.send([{ type: 'text', text: 'go' }])
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
@@ -202,7 +202,7 @@ describe('Agent.cancel()', () => {
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
const handle = ctx.agents.create({
const handle = await ctx.agents.create({
agentId: AgentId('a-dispose-prefix'),
sessionId: SessionId('dispose-prefix-session'),
agentOptions: { model: 'mock' },
@@ -333,7 +333,7 @@ describe('Agent.cancel()', () => {
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
const handle = ctx.agents.create({
const handle = await ctx.agents.create({
agentId: AgentId('a-dispose-step-start'),
sessionId: SessionId('dispose-step-start-session'),
agentOptions: { model: 'mock' },
@@ -78,7 +78,7 @@ describe('config-driven session id', () => {
await ctx1.plugin(AgentLoop, { agents: [] })
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') }).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Inbox } from '@deepseek-ai/dsh-agent-loop'
import { Inbox } from '../src/inbox.ts'
function resolverPair() {
let r!: () => void
+39 -1
View File
@@ -168,7 +168,7 @@ describe('agent loop', () => {
it('resolves {{cwd}} from the agent session workspace (factory create with meta.cwd)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter, 'Working in {{cwd}}.')
const handle = ctx.agents.create({
const handle = await ctx.agents.create({
agentId: AgentId('a-cwd'),
sessionId: SessionId('s-cwd'),
meta: { cwd: '/work/space' },
@@ -243,6 +243,44 @@ describe('agent loop', () => {
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou run on mock.')
})
it.each([
['BigInt', { n: 1n }],
['Map', new Map([['key', 'value']])],
['class instance', new (class ResultMeta { x = 1 })()],
])('normalizes non-JSON tool meta (%s) before the durable result commit', async (_kind, meta) => {
const adapter = new MockAdapter([
toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'),
textResponse('recovered'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'bad-meta',
description: 'returns invalid durable metadata',
parameters: {},
execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }),
}))
const agent = ctx.agentLoop.create(AgentId('bad-meta-agent'), { model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
const result = agent.session.events.find(event => event.type === 'tool/result')
expect(result?.type).toBe('tool/result')
if (result?.type === 'tool/result') {
expect(result.data.callId).toBe('bad-meta-call')
expect(result.data.isError).toBe(true)
expect(result.data.meta).toBeUndefined()
expect(result.data.content).toEqual([{
type: 'text',
text: 'Error: tools/execute must return a losslessly JSON-serializable ToolExecutionResult',
}])
}
// The normalized failure was durably logged and fed back to the model; the
// turn continued normally instead of failing after an apparent success.
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('losslessly JSON-serializable')
})
it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => {
// The documented escape valve: a deployment that must drop the harness
// openers short-circuits the assemble waterfall; the request then carries
@@ -222,7 +222,7 @@ describe('request stability across the loop', () => {
// one's full log (the resume/fork path).
const adapter2 = new MockAdapter([textResponse('two')])
const ctx2 = await harness(adapter2)
const handle = ctx2.agents.create({
const handle = await ctx2.agents.create({
agentId: AgentId('gen2'),
sessionId: SessionId('gen2-session'),
seed: [...agent.session.events],
+258 -10
View File
@@ -19,6 +19,10 @@ afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive:
async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context; root: string }> {
const root = await mkdtemp(join(tmpdir(), 'dsh-resume-'))
dirs.push(root)
return { ctx: await mountPersistentHarness(root, adapter), root }
}
async function mountPersistentHarness(root: string, adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -28,7 +32,22 @@ async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context;
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root })
ctx.llm.registerAdapter(['mock'], adapter)
return { ctx, root }
return ctx
}
async function persistSession(sessionId: SessionId): Promise<string> {
const { ctx, root } = await persistentHarness(new MockAdapter([textResponse('seed')]))
// Persistence deliberately has no artifact for a truly empty session. A
// balanced completed turn is the smallest resumable log and avoids running
// the model merely to construct this lifecycle fixture.
const seed: SessionEvent[] = [
{ 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: 'completed' } } },
]
const session = ctx.sessions.create(sessionId, { seed })
await ctx.sessions.flush(session)
await ctx.fiber.dispose()
return root
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
@@ -39,11 +58,22 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
/** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */
async function promptly<T>(task: Promise<T>): Promise<T> {
const timeout = Promise.withResolvers<never>()
const timer = setTimeout(() => { timeout.reject(new Error('lifecycle task did not settle promptly')) }, 1000)
try {
return await Promise.race([task, timeout.promise])
} finally {
clearTimeout(timer)
}
}
describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
const { agent } = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
const { agent } = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
expect(agent.session.id).toBe('custom-session')
expect(agent.session.header.cwd).toBe('/w')
await ctx.fiber.dispose()
@@ -52,10 +82,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
await ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
// A second create with the SAME agent id but a fresh session id must reject
// up front — and must NOT leave an orphaned 'sess-b' session behind.
expect(() => ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).toThrow(/already registered/)
await expect(ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).rejects.toThrow(/already registered/)
expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -63,7 +93,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('createAgent works without meta (no cwd)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
const { agent } = ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
const { agent } = await ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
expect(agent.session.id).toBe('nometa-session')
expect(agent.session.header.cwd).toBeUndefined()
await ctx.fiber.dispose()
@@ -73,7 +103,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// Lifecycle 1: create a no-cwd session and run a turn.
const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') }).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -100,7 +130,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const sources1: string[] = []
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
const a1 = ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') }).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') })).agent as ReactLoopAgent
expect(sources1).toEqual(['startup'])
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
@@ -124,6 +154,224 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.fiber.dispose()
})
it('resume awaits setup while unpublished, then publishes a fully composed world in order', async () => {
const sessionId = SessionId('resume-setup-success')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const gate = Promise.withResolvers<undefined>()
const setupStarted = Promise.withResolvers<undefined>()
const order: string[] = []
ctx.on('session/created', (session) => {
expect(ctx.sessions.get(session.id)).toBe(session)
expect(ctx.agents.get(AgentId('resumed-atomic'))?.session).toBe(session)
order.push('session/created')
})
ctx.on('agent/created', (agent) => {
expect(() => { agent.cancel('too early') }).toThrow(/cannot cancel before creation setup completes/)
order.push('agent/created')
})
ctx.on('agent/session-start', (agent) => {
expect(() => { agent.cancel('now live') }).not.toThrow()
order.push('agent/session-start')
})
const resuming = ctx.agents.resume({
agentId: AgentId('resumed-atomic'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
setup: async (agentCtx) => {
expect(agentCtx.agent?.id).toBe(AgentId('resumed-atomic'))
expect(agentCtx.agent?.session.events).toHaveLength(2)
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
order.push('setup:start')
setupStarted.resolve(undefined)
await gate.promise
order.push('setup:end')
},
})
await setupStarted.promise
expect(ctx.agents.get(AgentId('resumed-atomic'))).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
expect(order).toEqual(['setup:start'])
gate.resolve(undefined)
const handle = await resuming
expect(order).toEqual([
'setup:start',
'setup:end',
'session/created',
'setup-listener:session/created',
'agent/created',
'setup-listener:agent/created',
'agent/session-start',
])
await handle.dispose()
await ctx.fiber.dispose()
})
it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => {
const sessionId = SessionId('resume-setup-reject')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
await expect(ctx.agents.resume({
agentId: AgentId('resume-reject'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
setup: async () => {
await Promise.resolve()
throw new Error('resume setup failed')
},
})).rejects.toThrow('resume setup failed')
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('resume-reject'))).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
const retry = await ctx.agents.resume({
agentId: AgentId('resume-reject'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
})
await retry.dispose()
await ctx.fiber.dispose()
})
it('owner unload aborts resume setup and cannot publish after the callback settles', async () => {
const sessionId = SessionId('resume-setup-owner-unload')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const gate = Promise.withResolvers<undefined>()
const setupStarted = Promise.withResolvers<undefined>()
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
let resuming!: ReturnType<typeof ctx.agents.resume>
const owner = await ctx.plugin(Object.assign((inner: Context) => {
resuming = inner.agents.resume({
agentId: AgentId('resume-owner-race'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
setup: async () => {
setupStarted.resolve(undefined)
await gate.promise
},
})
}, { inject: ['agents'] }))
await setupStarted.promise
await owner.dispose()
await expect(resuming).rejects.toThrow(/owner disposed during setup/)
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('resume-owner-race'))).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
gate.resolve(undefined)
await Promise.resolve()
expect(published).toEqual([])
await ctx.fiber.dispose()
})
it('owner unload aborts a never-settling persistence load, releases identities, and blocks late publication', async () => {
const sessionId = SessionId('resume-load-owner-unload')
const agentId = AgentId('resume-load-race')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const snapshot = await ctx.sessionPersistence.load(sessionId)
const lateLoad = Promise.withResolvers<typeof snapshot>()
const loadStarted = Promise.withResolvers<undefined>()
let loads = 0
ctx.sessionPersistence.load = (id) => {
expect(id).toBe(sessionId)
loads += 1
if (loads === 1) {
loadStarted.resolve(undefined)
return lateLoad.promise
}
return Promise.resolve(structuredClone(snapshot))
}
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
let resuming!: ReturnType<typeof ctx.agents.resume>
const owner = await ctx.plugin(Object.assign((inner: Context) => {
resuming = inner.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
}, { inject: ['agents'] }))
await loadStarted.promise
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during persistence load/)
await promptly(owner.dispose())
expect(published).toEqual([])
expect(ctx.agents.get(agentId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
// owner.dispose() itself awaited transaction settlement and reservation
// release: reuse the same identities BEFORE awaiting the resume rejection.
const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }))
await rejection
expect(loads).toBe(2)
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
// Settlement of the abandoned backend promise cannot resume the old
// transaction or emit a second publication after the retry owns the ids.
lateLoad.resolve(structuredClone(snapshot))
await Promise.resolve()
await Promise.resolve()
expect(ctx.agents.get(agentId)).toBe(retry.agent)
expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session)
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
await retry.dispose()
await ctx.fiber.dispose()
})
it('snapshots resume identities and agent options before persistence load', async () => {
const sessionId = SessionId('resume-snapshot-source')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const loaded = await ctx.sessionPersistence.load(sessionId)
const loadGate = Promise.withResolvers<typeof loaded>()
ctx.sessionPersistence.load = () => loadGate.promise
const occupied = await ctx.agents.create({
agentId: AgentId('occupied-agent'),
sessionId: SessionId('occupied-session'),
agentOptions: { model: 'mock' },
})
const options = {
agentId: AgentId('accepted-agent'),
resumeSessionId: sessionId,
agentOptions: { model: 'mock' },
}
const resuming = ctx.agents.resume(options)
options.agentId = AgentId('occupied-agent')
options.resumeSessionId = SessionId('occupied-session')
options.agentOptions.model = 'mutated-model'
loadGate.resolve(structuredClone(loaded))
const resumed = await resuming
expect(resumed.agent.id).toBe(AgentId('accepted-agent'))
expect(resumed.agent.session.id).toBe(sessionId)
expect(resumed.agent.options.model).toBe('mock')
expect(ctx.agents.get(AgentId('occupied-agent'))).toBe(occupied.agent)
expect(ctx.sessions.get(SessionId('occupied-session'))).toBe(occupied.agent.session)
await resumed.dispose()
await occupied.dispose()
await ctx.fiber.dispose()
})
it('resume of a forked session preserves the parentSession lineage and seed boundary 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
@@ -170,7 +418,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// disk, since a crash before the next turn would otherwise lose it.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
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' } })
@@ -195,7 +443,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// drop it on reload (the bug this guards).
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
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' } })
@@ -223,7 +471,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// Lifecycle 1: run one full turn, persisting it.
const adapter1 = new MockAdapter([textResponse('first answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
const events1 = [...a1.session.events]
@@ -6,6 +6,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -458,8 +459,10 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
ctx2.llm.registerAdapter(['mock'], second)
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
const forked = new ReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
ctx2.effect(() => forked.start())
const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
const forked = prepared.agent
prepared.enableDrive()
ctx2.effect(() => prepared.startDriver())
const turns: number[] = []
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
@@ -8,6 +8,7 @@ import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepse
import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as concreteAgentModule from '../src/agent.ts'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -49,7 +50,7 @@ describe('agent scope lifecycle', () => {
it('scoped registrations live in the agent world and die with the agent', async () => {
const ctx = await harness()
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
const { agent } = handle
agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
agent.ctx.tools.register({
@@ -104,12 +105,13 @@ describe('agent scope lifecycle', () => {
})
})
const handle = ctx.agents.create({
const handle = await ctx.agents.create({
agentId: AgentId('child'),
sessionId: SessionId('child-s'),
agentOptions: { model: 'mock' },
setup: (agentCtx) => {
setup: async (agentCtx) => {
order.push('setup')
await Promise.resolve()
agentCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You are the child.' })
},
})
@@ -118,42 +120,233 @@ describe('agent scope lifecycle', () => {
await handle.dispose()
})
it('a throwing setup unwinds the half-created agent completely', async () => {
it('keeps both identities unpublished until async setup completes, then announces in order', async () => {
const ctx = await harness()
expect(() => ctx.agents.create({
const gate = Promise.withResolvers<undefined>()
const setupStarted = Promise.withResolvers<undefined>()
const order: string[] = []
ctx.on('session/created', (session) => {
expect(ctx.sessions.get(session.id)).toBe(session)
expect(ctx.agents.get(AgentId('atomic'))?.session).toBe(session)
order.push('session/created')
})
ctx.on('agent/created', () => void order.push('agent/created'))
ctx.on('agent/session-start', () => void order.push('agent/session-start'))
const acceptedOptions = { model: 'mock' }
const creating = ctx.agents.create({
agentId: AgentId('atomic'),
sessionId: SessionId('atomic-s'),
agentOptions: acceptedOptions,
setup: async (agentCtx) => {
expect(agentCtx.agent?.id).toBe(AgentId('atomic'))
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
order.push('setup:start')
setupStarted.resolve(undefined)
await gate.promise
order.push('setup:end')
},
})
await setupStarted.promise
expect(ctx.agents.get(AgentId('atomic'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined()
expect(order).toEqual(['setup:start'])
acceptedOptions.model = 'mutated while setup was pending'
gate.resolve(undefined)
const handle = await creating
expect(handle.agent.options.model).toBe('mock')
expect(order).toEqual([
'setup:start',
'setup:end',
'session/created',
'setup-listener:session/created',
'agent/created',
'setup-listener:agent/created',
'agent/session-start',
])
await handle.dispose()
})
it('reserves agent and session ids across concurrent async setup', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<undefined>()
const first = ctx.agents.create({
agentId: AgentId('reserved'),
sessionId: SessionId('reserved-s'),
agentOptions: { model: 'mock' },
setup: () => gate.promise,
})
await expect(ctx.agents.create({
agentId: AgentId('reserved'),
sessionId: SessionId('other-s'),
agentOptions: { model: 'mock' },
})).rejects.toThrow(/already registered/)
await expect(ctx.agents.create({
agentId: AgentId('other'),
sessionId: SessionId('reserved-s'),
agentOptions: { model: 'mock' },
})).rejects.toThrow(/already exists/)
expect(ctx.agents.list()).toEqual([])
expect(ctx.sessions.list()).toEqual([])
gate.resolve(undefined)
const handle = await first
await handle.dispose()
})
it('structurally rejects every driving verb during setup', async () => {
const ctx = await harness()
const handle = await ctx.agents.create({
agentId: AgentId('no-drive'),
sessionId: SessionId('no-drive-s'),
agentOptions: { model: 'mock' },
setup: (agentCtx) => {
const agent = agentCtx.agent!
// Even JavaScript or a cast to the exported concrete class cannot name
// a public start method. Driver startup is behind a module-private
// symbol used only by AgentLoop after rollback-covered publication.
expect(Reflect.get(agent as ReactLoopAgent, 'start')).toBeUndefined()
expect(Reflect.get(concreteAgentModule, 'enableAgentDrive')).toBeUndefined()
expect(Reflect.get(concreteAgentModule, 'startAgentDriver')).toBeUndefined()
expect(() => concreteAgentModule.prepareReactLoopAgent(
agentCtx, agent.id, agent.options, agent.session,
)).toThrow(/already has a concrete agent driver/)
expect(Reflect.get(agent as ReactLoopAgent, 'inbox')).toBeUndefined()
expect(() => { agent.send(text('queued too soon')) }).toThrow(/cannot send before creation setup completes/)
expect(() => { agent.steer(text('steered too soon')) }).toThrow(/cannot steer before creation setup completes/)
expect(() => { agent.inject(text('injected too soon')) }).toThrow(/cannot inject before creation setup completes/)
expect(() => { agent.cancel('cancel too soon') }).toThrow(/cannot cancel before creation setup completes/)
expect(agent.session.events).toEqual([])
},
})
expect(handle.agent.session.events).toEqual([])
await handle.dispose()
})
it('owner unload aborts a pending setup and publishes nothing', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<undefined>()
const setupStarted = Promise.withResolvers<undefined>()
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
let creating!: ReturnType<typeof ctx.agents.create>
const owner = await ctx.plugin(Object.assign((inner: Context) => {
creating = inner.agents.create({
agentId: AgentId('owner-race'),
sessionId: SessionId('owner-race-s'),
agentOptions: { model: 'mock' },
setup: async () => {
setupStarted.resolve(undefined)
await gate.promise
},
})
}, { inject: ['agents'] }))
await setupStarted.promise
await owner.dispose()
await expect(creating).rejects.toThrow(/owner disposed during setup/)
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('owner-race'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('owner-race-s'))).toBeUndefined()
// Let the losing callback settle; Promise.race already observes it.
gate.resolve(undefined)
await Promise.resolve()
// The other ordering in the same race: setup resolves first (its reaction
// is queued), then owner disposal flips active before that continuation can
// publish. The post-race active check must still reject.
const gate2 = Promise.withResolvers<undefined>()
const setupStarted2 = Promise.withResolvers<undefined>()
let creating2!: ReturnType<typeof ctx.agents.create>
const owner2 = await ctx.plugin(Object.assign((inner: Context) => {
creating2 = inner.agents.create({
agentId: AgentId('owner-race-2'),
sessionId: SessionId('owner-race-s-2'),
agentOptions: { model: 'mock' },
setup: async () => {
setupStarted2.resolve(undefined)
await gate2.promise
},
})
}, { inject: ['agents'] }))
await setupStarted2.promise
gate2.resolve(undefined)
const unload2 = owner2.dispose()
await expect(creating2).rejects.toThrow(/owner disposed during setup/)
await unload2
expect(ctx.agents.get(AgentId('owner-race-2'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined()
})
it('a rejecting setup publishes nothing and unwinds the unpublished scope', async () => {
const ctx = await harness()
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
await expect(ctx.agents.create({
agentId: AgentId('bad'),
sessionId: SessionId('bad-s'),
agentOptions: { model: 'mock' },
setup: () => { throw new Error('boom setup') },
})).toThrow('boom setup')
setup: async () => {
await Promise.resolve()
throw new Error('boom setup')
},
})).rejects.toThrow('boom setup')
// Nothing leaked: no agent, no session, and the ids are reusable.
expect(published).toEqual([])
expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
const retry = ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
await retry.dispose()
})
it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => {
const ctx = await harness()
let boom = true
const disposed: string[] = []
ctx.on('agent/disposed', agent => void disposed.push(agent.id))
ctx.on('session/created', () => {
if (boom) { boom = false; throw new Error('boom created') }
})
expect(() => ctx.agents.create({
await expect(ctx.agents.create({
agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' },
})).toThrow('boom created')
})).rejects.toThrow('boom created')
expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge
// The rollback also disposed the scope fiber: re-creating works cleanly.
const retry = ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
expect(scopeOf(retry.agent.ctx)).toBe(retry.agent)
await retry.dispose()
})
it('the synchronous config helper rolls back when publication throws', async () => {
const ctx = await harness()
const sessionsBefore = ctx.sessions.list().length
let boom = true
ctx.on('session/created', () => {
if (boom) {
boom = false
throw new Error('config publish failed')
}
})
expect(() => ctx.agentLoop.create(AgentId('config-bad'), { model: 'mock' }))
.toThrow('config publish failed')
expect(ctx.agents.get(AgentId('config-bad'))).toBeUndefined()
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
})
it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
const ctx = await harness()
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
await handle.dispose()
expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/)
})
@@ -195,9 +388,9 @@ describe('agent scope lifecycle', () => {
it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => {
const ctx = await harness()
let handle!: ReturnType<typeof ctx.agents.create>
const owner = await ctx.plugin(Object.assign((inner: Context) => {
handle = inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } })
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
handle = await inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } })
}, { inject: ['agents'] }))
const { agent } = handle
@@ -229,9 +422,9 @@ describe('agent scope lifecycle', () => {
it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => {
const ctx = await harness()
let handle!: ReturnType<typeof ctx.agents.create>
const owner = await ctx.plugin(Object.assign((inner: Context) => {
handle = inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } })
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
handle = await inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } })
}, { inject: ['agents'] }))
const teardownDone: string[] = []
@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId, type ContinuationStop } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function send(agent: ReactLoopAgent, text = 'go'): Promise<void> {
agent.send([{ type: 'text', text }])
return agent.whenIdle()
}
function registerEcho(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'echo',
description: 'echo',
parameters: { text: { type: 'string' } },
async execute(args) {
return [{ type: 'text', text: String(args.text) }]
},
}))
}
describe('agent/turn-stop', () => {
it('runs after steering folding and discards terminal steering instead of creating another step or turn', async () => {
const adapter = new MockAdapter([
textResponse('the ordinary decision is stop'),
textResponse('must not be requested'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('terminal-steering'), { model: 'mock' })
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
let steered = false
ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
const downstream = await next()
if (subject === agent && !steered) {
steered = true
subject.steer([{ type: 'text', text: 'late continuation steering' }])
}
return downstream
}, { prepend: true })
await send(agent)
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
})
it('discards steering that arrives from session/flush after the terminal checkpoint', async () => {
const adapter = new MockAdapter([
textResponse('terminal answer'),
textResponse('must not become a late-steering turn'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('terminal-flush-steering'), { model: 'mock' })
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
let injected = false
ctx.on('session/flush', (session) => {
if (session !== agent.session || injected) return
injected = true
agent.steer([{ type: 'text', text: 'steering from flush' }])
})
await send(agent)
expect(injected).toBe(true)
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
})
it('preserves an ordinary queued send that arrives during terminal flush', async () => {
const adapter = new MockAdapter([
textResponse('first terminal answer'),
textResponse('queued follow-up answer'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('terminal-flush-send'), { model: 'mock' })
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
let queued = false
ctx.on('session/flush', (session) => {
if (session !== agent.session || queued) return
queued = true
agent.send([{ type: 'text', text: 'ordinary queued follow-up' }])
})
await send(agent)
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(2)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
})
it('filters a scoped terminal listener to its own agent', async () => {
const adapter = new MockAdapter([
toolCallResponse('a1', 'echo', { text: 'a' }),
toolCallResponse('b1', 'echo', { text: 'b' }),
textResponse('b continues normally'),
])
const ctx = await harness(adapter)
registerEcho(ctx)
const stopped = ctx.agentLoop.create(AgentId('stopped'), { model: 'mock' })
const ordinary = ctx.agentLoop.create(AgentId('ordinary'), { model: 'mock' })
stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
await send(stopped)
expect(adapter.requests).toHaveLength(1)
await send(ordinary)
expect(adapter.requests).toHaveLength(3)
expect(stopped.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
expect(ordinary.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
})
it('unregisters with its scoped owner disposer', async () => {
const adapter = new MockAdapter([
toolCallResponse('first', 'echo', { text: 'first' }),
toolCallResponse('second', 'echo', { text: 'second' }),
textResponse('continued after listener disposal'),
])
const ctx = await harness(adapter)
registerEcho(ctx)
const agent = ctx.agentLoop.create(AgentId('owned-listener'), { model: 'mock' })
const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
await send(agent, 'first turn')
expect(adapter.requests).toHaveLength(1)
disposeStop()
await send(agent, 'second turn')
expect(adapter.requests).toHaveLength(3)
})
it('fails throwing and malformed terminal policies closed while the driver survives', async () => {
const adapter = new MockAdapter([
textResponse('throwing policy'),
textResponse('malformed continue policy'),
textResponse('malformed false policy'),
textResponse('malformed null policy'),
textResponse('healthy later turn'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('bad-policy'), { model: 'mock' })
const reasons: TurnEndReason[] = []
const errors: string[] = []
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
})
agent.ctx.on('agent/error', (_subject, _turn, _step, error) => { errors.push(error.message) })
const disposeThrowing = agent.ctx.on('agent/turn-stop', () => {
throw new Error('terminal policy exploded')
})
await send(agent, 'first')
disposeThrowing()
for (const [index, malformed] of [
{ action: 'continue' },
false,
null,
].entries()) {
const disposeMalformed = agent.ctx.on('agent/turn-stop', () => malformed as unknown as ContinuationStop)
await send(agent, `malformed ${index}`)
disposeMalformed()
}
await send(agent, 'healthy')
expect(reasons.map(reason => reason.kind)).toEqual(['error', 'error', 'error', 'error', 'completed'])
expect(errors).toContain('terminal policy exploded')
expect(errors).toContain("agent/turn-stop returned an invalid result; expected { action: 'stop' } or undefined")
expect(adapter.requests).toHaveLength(5)
})
})
+27 -1
View File
@@ -57,6 +57,16 @@ export interface AgentEventDispatch {
* @returns the serial chain's result (the first bail value, if any).
*/
serial<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>>
/**
* Await listeners in order and return the first value other than `undefined`.
* Unlike Cordis `serial`, this does not silently treat `null` or `false` as
* abstentions. Use it for a runtime-validated public boundary whose declared
* abstention is exactly `undefined` (currently `agent/turn-stop`).
* @param name - the agent-subject event to dispatch.
* @param rest - the event's arguments after the injected agent.
* @returns the first non-undefined listener result, or undefined.
*/
strictSerial<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>>
/**
* Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The
* declared event parameters already end with the `next` callback, so `rest`
@@ -79,7 +89,7 @@ export interface AgentEventDispatch {
*/
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
const carrier: Scoped<Agent> = scopeTarget(agent, agent)
// The three dispatch methods forward through cordis' variadic mixins. The
// The ordinary dispatch methods forward through Cordis' variadic mixins. The
// fused (carrier, name, agent, ...rest) tuple is provably a valid argument
// list for the matching thisArg overload, but TypeScript cannot relate the
// generic Tail<K> spread back to that overload's conditional parameter
@@ -95,6 +105,22 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
const serial = ctx.serial as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => Promise<never>
return await serial(carrier, name, agent, ...rest)
},
strictSerial(name, ...rest) {
return (async (): Promise<unknown> => {
// EventsService.dispatch applies the carrier filter and emits the same
// internal/dispatch instrumentation as ctx.serial, then mutates `args`
// down to the actual listener parameters. Invoke those callbacks in order
// ourselves so every non-undefined value reaches the caller's validator;
// Cordis serial would discard null/false before validation could see them.
const args: unknown[] = [carrier, name, agent, ...rest]
const callbacks = ctx.events.dispatch('serial', args)
for (const callback of callbacks) {
const result: unknown = await callback(...args)
if (result !== undefined) return result
}
return undefined
})() as Promise<Awaited<Return<Events[typeof name]>>>
},
waterfall(name, ...rest) {
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
const waterfall = ctx.waterfall as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => never
+113 -69
View File
@@ -18,14 +18,13 @@ declare module 'cordis' {
interface Context {
agents: AgentRegistry
/**
* The agent whose scope this context belongs to, or `undefined` on any
* context not derived from an agent scope. Pure DX sugar over the
* `dsh-scope` tag: the agent loop sets it as an own property on each
* `Agent.ctx`, and {@link AgentRegistry} registers a root accessor
* defaulting to `undefined` so the read is safe on every context (a plain
* plugin context answers `undefined` instead of throwing the Cordis
* unknown-property error). Core packages below the agent layer read the
* `dsh-scope` tag (`scopeOf`) instead, never this field.
* The agent association installed as an own property on `Agent.ctx`, or
* `undefined` on a plain context. Contexts derived from `Agent.ctx` inherit
* the association; a deliberately nested scope may carry a nearer
* `dsh-scope` tag while retaining it, so this field is DX context rather
* than the scope resolver. {@link AgentRegistry} registers a root accessor
* defaulting to `undefined`, and core packages below the agent layer use
* `scopeOf()` for layer selection instead of reading this field.
*/
agent?: Agent
}
@@ -66,19 +65,20 @@ export interface CreateAgentOptions {
/** Per-agent options (model, …). */
agentOptions?: AgentOptions
/**
* Creation-time composition of the agent's scoped world. The factory runs it
* inside the agent's composite lifecycle effect — after the scope is minted
* and the agent registered, before `agent/session-start` fires and the loop
* starts — so everything it registers through `agentCtx` (scoped tools,
* prompt sections/variables, `restrict()`, listeners, `agentCtx.plugin(…)`
* profiles) exists before the first prompt assembly, and a THROWING setup
* unwinds inside the rollback boundary instead of leaking a half-created
* agent. **Setup registers, it never drives**: calling
* `send`/`steer`/`inject` here would open a turn before `agent/session-start`
* (the dev invariants flag a `turn/start` logged before session-start as a
* teaching error) — drive the agent after creation returns.
* Creation-time composition of the agent's scoped world. The factory awaits
* setup after minting `agentCtx` but BEFORE inserting or announcing either
* the session or agent, so observers can never see a partially configured
* world. Everything registered through `agentCtx` (scoped tools, prompt
* sections/variables, `restrict()`, listeners, awaited child plugins) exists
* before `session/created`, `agent/created`, `agent/session-start`, and the
* first prompt assembly. A throw/rejection or owner disposal rolls the scope
* back without publishing either id.
*
* **Setup composes, it never drives**: calling `send`/`steer`/`inject` here
* would run an unpublished agent and violate the session-start boundary.
* Drive the agent only after the creation promise resolves.
*/
setup?: (agentCtx: Context) => void
setup?: (agentCtx: Context) => Promise<void> | void
}
/**
@@ -92,15 +92,26 @@ export interface ResumeAgentOptions {
resumeSessionId: SessionId
/** Per-agent options (model, …). */
agentOptions?: AgentOptions
/**
* Resume-time composition of the agent's fresh scoped world. Persistence is
* loaded first; the factory then mints `agentCtx` and awaits setup while the
* reconstructed session and agent remain unpublished. The callback has the
* same composition-only contract as {@link CreateAgentOptions.setup}: all
* registrations exist before either creation announcement, driving verbs are
* unavailable until the session-start boundary, and rejection or owner
* disposal rolls the transaction back without publishing either id.
*/
setup?: (agentCtx: Context) => Promise<void> | void
}
/**
* An owned agent plus its disposer, returned by {@link AgentRegistry.create} /
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder
* can tear this agent down. `dispose()` unregisters the agent, stops its loop,
* awaits the loop's exit (quiescence — NOT just the `disposed` status flip), and
* removes the agent's session from the store, in an order that captures the
* loop's final `session/flush` before the session is detached.
* can tear this agent down. `dispose()` stops the loop, awaits its exit
* (quiescence — NOT just the `disposed` status flip), unregisters the agent,
* removes its session from the store, and finally unwinds its scoped world.
* This order captures the loop's final `session/flush` before the session is
* detached and keeps scoped listeners alive through that flush.
*
* `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is only
* for the OWNER that created it. Config-created agents (the loop's own startup)
@@ -119,15 +130,27 @@ export interface AgentHandle {
*/
export interface AgentFactory {
/**
* Create, start, and register a new agent on a caller-supplied session id.
* Returns an {@link AgentHandle} — the owner disposes it to tear down exactly
* this agent (unregister + stop loop + await quiescence + remove session).
* Create a new agent on a caller-supplied session id. Async because creation
* awaits unpublished setup, inserts both session and agent, emits their
* creation notifications in order, unlocks driving at
* `agent/session-start`, and only then starts the loop. The sequence is
* rollback-covered, but notifications delivered before a later listener
* failure remain observable; if agent announcement began, rollback emits
* `agent/disposed`, while the session entry is removed without a separate
* disposal event. The owner disposes the resolved handle to stop/drain,
* unregister, remove the session, and unwind the scope.
* @param options - agent/session identity, configuration, and optional setup.
* @returns the owned handle after setup, both announcements, and loop start complete.
*/
createAgent(options: CreateAgentOptions): AgentHandle
createAgent(options: CreateAgentOptions): Promise<AgentHandle>
/**
* Load a persisted session and resume an agent on it. Async because it awaits
* `ctx.sessionPersistence.load`; must be called after that service exists
* (consumers inject `sessionPersistence`). Returns an {@link AgentHandle}.
* both `ctx.sessionPersistence.load` and the optional unpublished setup
* transaction; must be called after that service exists (consumers inject
* `sessionPersistence`). Publication and drive unlocking follow the same
* ordered boundary as {@link createAgent}.
* @param options - persisted identity, configuration, and optional setup.
* @returns the owned handle after setup, both announcements, and loop start complete.
*/
resume(options: ResumeAgentOptions): Promise<AgentHandle>
}
@@ -139,11 +162,13 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug
* Agent registry (`ctx.agents`): tracks live agents so UI, hook, and
* orchestrator plugins can find them without depending on the concrete loop
* package. Agent *creation* is provided by whichever plugin implements the
* {@link AgentFactory} (phase 1: `@deepseek-ai/dsh-agent-loop`), registered via
* {@link AgentFactory} (`@deepseek-ai/dsh-agent-loop`), registered via
* {@link setFactory}.
*/
export class AgentRegistry extends Service {
private store = new Map<AgentId, Agent>()
/** Entries whose `agent/created` announcement phase began. */
private announced = new WeakSet<Agent>()
private factory: AgentFactory | undefined
constructor(ctx: Context) {
@@ -180,15 +205,15 @@ export class AgentRegistry extends Service {
}
/**
* Create, start, and register a new agent through the registered factory.
* Create and publish a new agent through the registered factory.
* Distinct from {@link register} (which records an already-constructed
* agent): this constructs the agent and its session. Throws if no factory is
* registered. Returns an {@link AgentHandle} — the owner disposes it to tear
* down exactly this agent.
* agent): this constructs the agent and its session. Rejects if no factory is
* registered or creation/setup fails. The resolved {@link AgentHandle} lets
* the owner tear down exactly this agent.
* @param options - agent id, session id/seed/metadata, and agent options.
* @returns the handle whose dispose tears down exactly this agent.
* @returns the handle after setup, rollback-covered publication, and loop start complete.
*/
create(options: CreateAgentOptions): AgentHandle {
async create(options: CreateAgentOptions): Promise<AgentHandle> {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
return this.factory.createAgent(options)
}
@@ -196,9 +221,9 @@ export class AgentRegistry extends Service {
/**
* Load a persisted session and resume an agent on it through the registered
* factory. Rejects if no factory is registered; the factory rejects if
* session persistence is not configured. Returns an {@link AgentHandle}.
* @param options - the persisted session id plus agent id and options.
* @returns the handle for the resumed agent.
* session persistence is not configured or persistence/setup fails.
* @param options - persisted identity, configuration, and optional setup.
* @returns the handle after setup, rollback-covered publication, and loop start complete.
*/
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
@@ -225,39 +250,58 @@ export class AgentRegistry extends Service {
*/
register(agent: Agent): () => Promise<void> | void {
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
if (this.store.has(agent.id)) {
throw new Error(`agent "${agent.id}" is already registered`)
}
this.store.set(agent.id, agent)
// Yield the rollback BEFORE emitting `agent/created`: a generator effect
// collects each yielded disposer before the next step runs, so a
// throwing `agent/created` listener rolls the entry back instead of
// leaking it (a leak would wedge the duplicate-id check until restart).
// The duplicate throw above fires before any mutation — it leaks nothing.
yield () => {
this.store.delete(agent.id)
// CONTAIN a throwing `agent/disposed` listener: this disposer runs as
// one link in the owning fiber/effect's disposal chain, and Cordis
// chains later disposers with `task.then(next)` — so an UNCAUGHT throw
// here rejects the chain and SKIPS every later disposer. When this
// registration shares a composite effect with a session (the agent
// factory's `AgentLoop.start`, where the session-detach disposer runs
// AFTER this one), a swallowed-less throw would strand the session in
// the store with `onAppend` attached — a leak AND a durability hole.
// The store entry is already removed above (the useful state), so
// logging the listener bug and continuing is correct (mirrors the
// guarded `agent/status` emit in dsh-agent-loop's ReactLoopAgent).
try {
this.ctx.emit(scopeTarget(agent, agent), 'agent/disposed', agent)
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`)
}
}
this.ctx.emit(scopeTarget(agent, agent), 'agent/created', agent)
yield this.enter(agent)
this.announce(agent)
}.bind(this), 'agents.register()')
return dispose
}
/**
* Insert an already-constructed agent without announcing it. This is the
* advanced ordered-lifecycle primitive used by the async agent factory: it
* first completes setup while the agent is unpublished, then assigns the
* returned detach closure into its pre-installed composite teardown before
* calling {@link announce}. Ordinary callers use {@link register}.
* @param agent - the prepared, unpublished agent.
* @returns an idempotent closure that removes this exact entry and emits
* `agent/disposed` with listener failures contained.
*/
enter(agent: Agent): () => void {
if (this.store.has(agent.id)) {
throw new Error(`agent "${agent.id}" is already registered`)
}
this.store.set(agent.id, agent)
let entered = true
return () => {
if (!entered) return
entered = false
this.store.delete(agent.id)
// An insertion rolled back before announce was never externally created,
// so emitting disposed would invent an impossible lifecycle edge. Marking
// happens before the created emit: if a later created listener throws,
// earlier listeners may already have observed it and must see disposal.
if (!this.announced.delete(agent)) return
try {
this.ctx.emit(scopeTarget(agent, agent), 'agent/disposed', agent)
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`)
}
}
}
/**
* Announce an agent previously inserted with {@link enter}.
* @param agent - the live inserted agent to announce.
* @throws if `agent` is not the exact live registry entry for its id.
*/
announce(agent: Agent): void {
if (this.store.get(agent.id) !== agent) {
throw new Error(`agent "${agent.id}" is not live in this registry`)
}
this.announced.add(agent)
this.ctx.emit(scopeTarget(agent, agent), 'agent/created', agent)
}
/**
* Look up a live agent.
* @param id - the agent id to look up.
+42 -10
View File
@@ -18,8 +18,8 @@
* - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the
* live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/
* `agent/request`/`agent/session-prefix`/`agent/step-result`/
* `agent/turn-continuation` waterfalls and
* the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits
* `agent/turn-continuation` waterfalls and the serial `agent/pre-step` /
* `agent/turn-stop` checkpoints) that mutate/veto, and TRANSIENT emits
* (`agent/status`, `agent/error`, `agent/created`/
* `agent/disposed`, `agent/queued`, `agent/session-start`)
* that notify with the `Agent` in hand. Turn/step boundaries are NOT here —
@@ -37,8 +37,9 @@
* and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`.
*
* The interception waterfalls here (`agent/prompt-submit`, `agent/request`,
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision
* the convention pinned by
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision;
* the terminal serial `agent/turn-stop` returns the stop-only subset. The
* convention is pinned by
* `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`.
*
* @module @deepseek-ai/dsh-agent/types
@@ -161,6 +162,13 @@ export type ContinuationDecision =
| { action: 'stop' }
| { action: 'continue'; reason?: HookContext }
/**
* The terminal subset of {@link ContinuationDecision}. A listener on
* `agent/turn-stop` returns this to make the already-composed continuation
* outcome terminal; `undefined` abstains.
*/
export type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }>
/**
* Why an agent's session lifecycle began, carried by `agent/session-start`. A
* bridge keys its SessionStart hook's matcher on this (Claude Code's
@@ -274,9 +282,12 @@ declare module 'cordis' {
interface Events {
// ---- lifecycle (emit) ----
/**
* An agent was registered in the {@link AgentRegistry} and is ready to
* receive messages.
* @param agent - the newly registered agent, already resolvable in the registry.
* An agent's fully composed scoped world was published in the
* {@link AgentRegistry}. Its session is already live in the session store,
* but concrete factories may keep driving verbs locked until the subsequent
* `agent/session-start` boundary; that event is the first supported place
* to inject or queue work during startup.
* @param agent - the newly registered agent with its live session and completed setup.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
@@ -286,9 +297,11 @@ declare module 'cordis' {
*/
'agent/created'(this: Scoped<Agent>, agent: Agent): void
/**
* An agent was disposed and removed from the registry; its fiber and any
* in-flight turn have been torn down.
* @param agent - the agent that was torn down; its handle is now inert.
* An agent was removed from the registry after its driver and any in-flight
* turn reached quiescence. Ordered teardown may still be detaching the
* session and unwinding the agent's scoped registrations when this
* notification runs.
* @param agent - the deregistered agent; its driving handle is now inert.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
@@ -534,6 +547,25 @@ declare module 'cordis' {
* @mode waterfall
*/
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
/**
* Serial terminal-stop checkpoint after the ordinary
* `agent/turn-continuation` waterfall, any `continue.reason`, and the
* pending-steering continuation override have been folded. A listener
* returns `{ action: 'stop' }` to make this turn terminal, or `undefined`
* to abstain. Terminal stop is monotonic: listener order and steering
* cannot resume the turn, and pending steering is discarded rather than
* becoming another step or turn. A malformed non-undefined result fails
* the turn closed.
* @param agent - the agent whose composed continuation outcome may be stopped.
* @param turn - the turn at its terminal-stop checkpoint.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
* plain plugin context fires for every agent. The dispatch `this` is the
* scope carrier (`Scoped<Agent>`), built by the emitting side via
* `scopeTarget`/`agentEvents`.
* @mode serial
*/
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
// ---- error notifications (emit) ----
/**
+35 -5
View File
@@ -77,6 +77,36 @@ describe('AgentRegistry', () => {
await dispose()
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
})
it('splits insertion from announcement and makes the detach exact/idempotent', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const created: Agent[] = []
const disposed: Agent[] = []
ctx.on('agent/created', agent => void created.push(agent))
ctx.on('agent/disposed', agent => void disposed.push(agent))
const first = stubAgent('split')
const detachFirst = ctx.agents.enter(first)
expect(ctx.agents.get(first.id)).toBe(first)
expect(created).toEqual([])
ctx.agents.announce(first)
expect(created).toEqual([first])
detachFirst()
detachFirst()
expect(disposed).toEqual([first])
const replacement = stubAgent('split')
const detachReplacement = ctx.agents.enter(replacement)
// A stale repeated detach cannot remove the replacement.
detachFirst()
expect(ctx.agents.get(replacement.id)).toBe(replacement)
expect(() => { ctx.agents.announce(first) }).toThrow(/not live/)
detachReplacement()
// The replacement was inserted but never announced, so rollback produces
// no disposed-without-created notification.
expect(disposed).toEqual([first])
})
})
describe('AgentRegistry factory seam', () => {
@@ -84,7 +114,7 @@ describe('AgentRegistry factory seam', () => {
function stubFactory() {
const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] }
const factory: import('@deepseek-ai/dsh-agent').AgentFactory = {
createAgent(options) {
async createAgent(options) {
calls.create.push(options)
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
},
@@ -99,7 +129,7 @@ describe('AgentRegistry factory seam', () => {
it('create()/resume() throw when no factory is registered', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).toThrow(/no agent factory/)
await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
await expect(ctx.agents.resume({ agentId: AgentId('a'), resumeSessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
})
@@ -109,7 +139,7 @@ describe('AgentRegistry factory seam', () => {
const { factory, calls } = stubFactory()
ctx.agents.setFactory(factory)
const created = ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
const created = await ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
expect(created.agent.id).toBe('c1')
expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }])
@@ -132,10 +162,10 @@ describe('AgentRegistry factory seam', () => {
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
dispose = inner.agents.setFactory(stubFactory().factory)
}, { inject: ['agents'] }))
expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).not.toThrow()
await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).resolves.toBeDefined()
void dispose
await fiber.dispose()
// factory slot cleared → create throws again
expect(() => ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).toThrow(/no agent factory/)
await expect(ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).rejects.toThrow(/no agent factory/)
})
})
+56 -19
View File
@@ -22,7 +22,7 @@
* @module @deepseek-ai/dsh-scope
*/
import type { Context } from 'cordis'
import type { Context, Fiber } from 'cordis'
import { Context as CordisContext } from 'cordis'
/**
@@ -78,21 +78,30 @@ export interface Scope {
rawDispose: () => Promise<void> | void
/**
* Unwind the scope: dispose the backing fiber, running every collected
* registration disposer. Idempotent and always awaitable — a repeat call
* resolves immediately (the underlying Cordis disposer is single-shot and
* returns undefined the second time; this wrapper Promise-normalizes it).
* registration disposer. Idempotent and always awaitable: repeat and racing
* calls share one completion even though the underlying Cordis disposer is
* single-shot and returns undefined after its first invocation.
* After disposal the scoped context is inert — a further registration
* through it throws Cordis's INACTIVE_EFFECT.
* @returns for the call that initiates teardown: resolves when every
* registration's disposer has settled. A repeat/racing call resolves
* immediately WITHOUT awaiting the in-flight teardown (the underlying
* Cordis disposer is single-shot) — a caller needing a shared quiescence
* boundary across racing disposers keeps its own completion promise (the
* agent factory's pattern).
* registration's disposer has settled. Every repeat/racing call awaits
* that same quiescence boundary, including when {@link rawDispose} claimed
* the underlying single-shot Cordis disposer first.
*/
dispose(): Promise<void>
}
/**
* Dispose a Cordis fiber and await its lifecycle inertia even when some other
* caller claimed the single-shot raw disposer first. `Fiber.dispose()` returns
* `undefined` on a repeat call, but the fiber's `inertia` remains the
* authoritative promise while its async unload is running.
*/
async function quiesceFiber(fiber: Fiber): Promise<void> {
await Promise.resolve(fiber.dispose())
while (fiber.inertia !== undefined) await fiber.inertia
}
/**
* The shared no-op plugin every scope fiber mounts: named so diagnostics read
* `scope` and shared so all scopes join ONE plugin runtime (Cordis deletes the
@@ -127,21 +136,22 @@ export function createScope(ctx: Context, key: ScopeKey): Scope {
// Runtime guard behind the ScopeKey type: callers outside the typechecker
// (yml-configured plugins, JS consumers) can still pass a primitive.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (typeof key !== 'object' || key === null) {
throw new TypeError('createScope: key must be an object (scope keys are identity-compared)')
if ((typeof key !== 'object' && typeof key !== 'function') || key === null) {
throw new TypeError('createScope: key must be a non-null object or function (scope keys are identity-compared)')
}
const fiber = ctx.plugin(scope)
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
let disposing: Promise<void> | undefined
return {
ctx: scoped,
// fiber.dispose IS the disposer Cordis pushed onto the minting fiber's
// disposable list — the identity a composite effect must yield (see
// Scope.rawDispose).
rawDispose: fiber.dispose,
// Promise.resolve-normalized: a cordis fiber's dispose returns undefined
// on a repeat call (the epoch is already cleared), and Scope.dispose
// promises an awaitable on every call.
dispose: () => Promise.resolve(fiber.dispose()),
// Memoize the public boundary and explicitly follow fiber inertia: the raw
// disposer must remain the exact Cordis function for ordered composition,
// so it cannot itself be wrapped to record a raw-first invocation.
dispose: () => (disposing ??= quiesceFiber(fiber)),
}
}
@@ -293,8 +303,10 @@ export interface ScopeHost {
mint(key: ScopeKey): Scope
/**
* Dispose the host fiber and with it every scope minted through it.
* @returns resolves when all collected disposers have settled (first call;
* a repeat call resolves immediately — single-shot, like Scope.dispose).
* Every racing/repeat caller observes the same completion, including when a
* child's raw disposer started before host disposal.
* @returns resolves when the host and every minted scope have reached
* quiescence.
*/
dispose(): Promise<void>
}
@@ -334,8 +346,33 @@ export async function scopeHost(ctx: Context, services: string[]): Promise<Scope
throw new Error(`scopeHost: service${missing.length === 1 ? '' : 's'} ${named} not available on this context — load the providing plugin(s) before minting scopes`)
}
const host = hostCtx
const scopes = new Set<Scope>()
let disposing: Promise<void> | undefined
const dispose = async (): Promise<void> => {
// Start every boundary before awaiting any one of them. A child whose raw
// disposer already ran is still followed through Scope.dispose(); a child
// the host unload claims first is followed through the same fiber inertia.
const tasks = [quiesceFiber(fiber), ...[...scopes].map(scope => scope.dispose())]
const results = await Promise.allSettled(tasks)
scopes.clear()
const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : [])
if (errors.length === 1) throw errors[0]
if (errors.length > 1) throw new AggregateError(errors, 'scopeHost: disposal failed')
}
return {
mint: (key: ScopeKey) => createScope(host, key),
dispose: () => Promise.resolve(fiber.dispose()),
mint: (key: ScopeKey) => {
const minted = createScope(host, key)
let disposing: Promise<void> | undefined
const tracked: Scope = {
ctx: minted.ctx,
// Preserve the exact Cordis identity: only the public shared boundary
// is wrapped to retire this child from the host's tracking set.
rawDispose: minted.rawDispose,
dispose: () => (disposing ??= minted.dispose().finally(() => { scopes.delete(tracked) })),
}
scopes.add(tracked)
return tracked
},
dispose: () => (disposing ??= dispose()),
}
}
+76 -2
View File
@@ -30,14 +30,19 @@ async function mintScope(ctx: Context, key: object): Promise<Scope> {
}
describe('createScope', () => {
it('rejects a primitive key at runtime (identity-compared keys must be objects)', () => {
it('rejects primitive keys but accepts callable objects (matching ScopeKey)', async () => {
const ctx = new Context()
// Typed through `unknown` so the ScopeKey type cannot argue the assertion
// away: this test exercises exactly the callers the typechecker misses.
const badKeys: unknown[] = ['k', null]
for (const bad of badKeys) {
expect(() => createScope(ctx, bad as ScopeKey)).toThrow(/must be an object/)
expect(() => createScope(ctx, bad as ScopeKey)).toThrow(/must be a non-null object or function/)
}
const callable = Object.assign(() => {}, { nameForTest: 'callable-key' })
const scope = await mintScope(ctx, callable)
expect(scopeOf(scope.ctx)).toBe(callable)
await scope.dispose()
})
it('tags the scoped context, readable through derivations (nearest tag wins)', async () => {
@@ -91,6 +96,29 @@ describe('createScope', () => {
expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/)
})
it('dispose() follows a rawDispose-first race through async quiescence', async () => {
const ctx = new Context()
const scope = await mintScope(ctx, { name: 'raw-first' })
const gate = Promise.withResolvers<undefined>()
let cleanupFinished = false
scope.ctx.effect(() => async () => {
await gate.promise
cleanupFinished = true
})
const raw = Promise.resolve(scope.rawDispose())
let publicSettled = false
const publicDispose = scope.dispose().then(() => { publicSettled = true })
await Promise.resolve()
expect(publicSettled).toBe(false)
expect(cleanupFinished).toBe(false)
gate.resolve(undefined)
await Promise.all([raw, publicDispose])
expect(cleanupFinished).toBe(true)
await expect(scope.dispose()).resolves.toBeUndefined()
})
it('rawDispose is the exact cordis disposer: yielding it nests the scope at its position', async () => {
const ctx = new Context()
const order: string[] = []
@@ -287,6 +315,52 @@ describe('scopeHost', () => {
expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/)
})
it('dispose waits for a child whose raw disposer won the race', async () => {
const ctx = new Context()
ctx.provide('answers', { value: 42 })
const host = await scopeHost(ctx, ['answers'])
const scope = host.mint({ name: 'raw-first-child' })
const gate = Promise.withResolvers<undefined>()
let cleanupFinished = false
scope.ctx.effect(() => async () => {
await gate.promise
cleanupFinished = true
})
const raw = Promise.resolve(scope.rawDispose())
let hostSettled = false
const hostDispose = host.dispose().then(() => { hostSettled = true })
await Promise.resolve()
expect(hostSettled).toBe(false)
gate.resolve(undefined)
await Promise.all([raw, hostDispose])
expect(cleanupFinished).toBe(true)
await expect(host.dispose()).resolves.toBeUndefined()
})
it('reaches every child before surfacing one or multiple disposal failures', async () => {
const oneCtx = new Context()
oneCtx.provide('answers', { value: 42 })
const oneHost = await scopeHost(oneCtx, ['answers'])
const one = oneHost.mint({ name: 'one' })
one.dispose = () => Promise.reject(new Error('one failed'))
await expect(oneHost.dispose()).rejects.toThrow('one failed')
const manyCtx = new Context()
manyCtx.provide('answers', { value: 42 })
const manyHost = await scopeHost(manyCtx, ['answers'])
const a = manyHost.mint({ name: 'a' })
const b = manyHost.mint({ name: 'b' })
a.dispose = () => Promise.reject(new Error('a failed'))
b.dispose = () => Promise.reject(new Error('b failed'))
await expect(manyHost.dispose()).rejects.toMatchObject({
name: 'AggregateError',
message: 'scopeHost: disposal failed',
errors: [expect.objectContaining({ message: 'a failed' }), expect.objectContaining({ message: 'b failed' })],
})
})
it('fails LOUD naming absent services instead of resolving as a silent no-op host', async () => {
const ctx = new Context()
await expect(scopeHost(ctx, ['tools', 'systemPrompt']))
+20 -6
View File
@@ -538,8 +538,12 @@ export class SessionStore extends Service {
const emitCtx = this.ctx
session.onAppend = (event) => { emitCtx.emit(carrier, 'session/event', session, event) }
this.store.set(session.id, session)
let entered = true
return () => {
if (!entered) return
entered = false
session.onAppend = undefined
this.carriers.delete(session)
this.store.delete(session.id)
}
}
@@ -549,7 +553,7 @@ export class SessionStore extends Service {
* yield the detach disposer first (rollback safety — see {@link enter}).
* @param session - the entered session to announce to listeners. */
announce(session: Session): void {
this.ctx.emit(this.carrierFor(session), 'session/created', session)
this.ctx.emit(this.liveCarrierFor(session), 'session/created', session)
}
/**
@@ -563,13 +567,23 @@ export class SessionStore extends Service {
* @returns resolves when every flush listener has settled; rejects if one rejects.
*/
async flush(session: Session): Promise<void> {
await this.ctx.parallel(this.carrierFor(session), 'session/flush', session)
await this.ctx.parallel(this.liveCarrierFor(session), 'session/flush', session)
}
/** The carrier {@link enter} captured, or a subject-less one for a session
* never entered (defensive: dispatch stays filtered either way). */
private carrierFor(session: Session): Scoped<Session> {
return this.carriers.get(session) ?? scopeTarget(session, undefined)
/** Return the exact live session's carrier; detached/prepared objects reject. */
private liveCarrierFor(session: Session): Scoped<Session> {
if (this.store.get(session.id) !== session) {
throw new Error(`session "${session.id}" is not live in this store`)
}
const carrier = this.carriers.get(session)
// enter() installs store + carrier in one synchronous sequence; a live
// session without one is an internal invariant violation, never fallback
// to subject-less dispatch (that would silently cross scope boundaries).
/* v8 ignore next -- enter installs store and carrier in one synchronous sequence */
if (carrier === undefined) {
throw new Error(`session "${session.id}" has no dispatch carrier`)
}
return carrier
}
/**
+21 -4
View File
@@ -91,16 +91,33 @@ describe('sessions.flush()', () => {
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
})
it('flushes a never-entered session with a subject-less carrier (defensive path)', async () => {
it('rejects a never-entered session instead of inventing a carrier', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'owner')
const flushed: string[] = []
ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`))
scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
const detached = ctx.sessions.prepare()
await ctx.sessions.flush(detached)
expect(flushed).toEqual([`global:${detached.id}`])
const prepared = ctx.sessions.prepare()
await expect(ctx.sessions.flush(prepared)).rejects.toThrow(/not live/)
expect(flushed).toEqual([])
})
it('clears a detached carrier and rejects stale flushes', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'owner')
const flushed: string[] = []
ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`))
scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
const session = scope.ctx.sessions.prepare()
const detach = scope.ctx.sessions.enter(session)
await ctx.sessions.flush(session)
expect(flushed.sort()).toEqual([`global:${session.id}`, `owner:${session.id}`])
detach()
await expect(ctx.sessions.flush(session)).rejects.toThrow(/not live/)
expect(flushed).toHaveLength(2)
})
it('keyOf sanity: distinct scopes carry distinct keys', async () => {
@@ -289,6 +289,7 @@ describe('SessionStore', () => {
expect(created).toEqual([session])
// The detach disposer removes the entry + stops notification.
detach()
detach() // idempotent: cannot disturb a later same-id lifecycle
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
})
+171 -20
View File
@@ -1,8 +1,9 @@
/**
* System prompt assembly registry. Plugins contribute ordered text sections,
* tool schema providers, and named prompt variables; `assemble(context)`
* collates them through a waterfall that runs once per step, and
* `renderPrompt` interpolates `{{variable}}` references into the final text.
* tool schema providers, named prompt variables, and authoritative named
* protections; `assemble(context)` collates them through a waterfall that
* runs once per step, restores protected contributions, and `renderPrompt`
* interpolates `{{variable}}` references into the final text.
*
* The harness-owned prompt openers live here too: this plugin registers the
* static `harness:identity` section (order 100) and the deployment's
@@ -43,8 +44,8 @@ declare module 'cordis' {
*/
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
/**
* A section, tool provider, or variable provider was registered or
* unregistered (the assembly inputs changed — possibly for one scope
* A section, tool provider, variable provider, or protection was registered
* or unregistered (the assembly inputs changed — possibly for one scope
* only). An UNFILTERED registry-subject notification, deliberately not
* scope-filtered dispatch: a global change concerns every agent's next
* assembly, so a scoped listener subscribing here sees every change, not
@@ -121,6 +122,27 @@ export interface ToolProviderResult {
knownNames?: readonly string[]
}
/**
* Canonical prompt contributions that survive the assembly waterfall.
*
* Protection is declarative by contribution name rather than an ordered
* callback: after every `system-prompt/assemble` listener has finished, the
* service restores each protected name to the exact presence and definition
* produced by its registries before the waterfall. Restored entries keep
* canonical order with one another and anchor before their first surviving
* later unprotected canonical neighbor (or at the end); the service does not
* undo a listener's reordering of unprotected entries. A name absent from that
* canonical assembly is removed from the result. This makes mode-dependent
* absence protectable too (for example, a native tool that intentionally stays
* off the wire in Code Mode).
*/
export interface PromptProtection {
/** Section names whose canonical registry output is authoritative. */
sections?: readonly string[]
/** Tool names whose canonical provider output is authoritative. */
tools?: readonly string[]
}
/**
* The assembled prompt.
*
@@ -211,6 +233,28 @@ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownN
name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
}
/** Restore protected named entries from `canonical`, anchored before their next unprotected canonical neighbor. */
function restoreProtected<T extends { name: string }>(
canonical: readonly T[], result: readonly T[], protectedNames: ReadonlySet<string>,
): T[] {
const restored = result.filter(entry => !protectedNames.has(entry.name))
for (const [index, entry] of canonical.entries()) {
if (!protectedNames.has(entry.name)) continue
// Protected entries are inserted in canonical order. Anchor each one
// before the first later UNPROTECTED canonical neighbor that survived the
// waterfall; if none survived, it belongs at the end. Looking only at
// unprotected neighbors avoids reversing adjacent protected entries.
const following = new Set(
canonical.slice(index + 1)
.filter(candidate => !protectedNames.has(candidate.name))
.map(candidate => candidate.name),
)
const next = restored.findIndex(candidate => following.has(candidate.name))
restored.splice(next < 0 ? restored.length : next, 0, structuredClone(entry))
}
return restored
}
/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
function compareToolNames(a: ToolSchema, b: ToolSchema): number {
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
@@ -327,10 +371,10 @@ function interpolate(section: AssembledSection, variables: Record<string, string
/**
* Registry service (`ctx.systemPrompt`): plugins contribute ordered text
* sections, tool-schema providers, and named prompt variables; the agent loop
* calls `assemble(context)` once per step. Registers the harness-owned
* `harness:identity` and `deployment:persona` sections itself (see
* {@link Config.persona}).
* sections, tool-schema providers, named prompt variables, and authoritative
* contribution protections; the agent loop calls `assemble(context)` once per
* step. Registers the harness-owned `harness:identity` and
* `deployment:persona` sections itself (see {@link Config.persona}).
*/
export class SystemPrompt extends Service {
static Config: z<Config> = z.object({
@@ -347,10 +391,12 @@ export class SystemPrompt extends Service {
private sections: PromptSection[] = []
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
private protections: PromptProtection[] = []
/** 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 scopedProtections = new Map<ScopeKey, PromptProtection[]>()
private readonly toolOrder: string[] | undefined
constructor(ctx: Context, public config: Config) {
@@ -383,7 +429,12 @@ export class SystemPrompt extends Service {
* scoped context (`agent.ctx`) contributes to that scope alone — and a
* scoped section SHADOWS a same-named global section for that scope's
* assemblies (most-specific-wins; this is how a per-agent persona overrides
* `deployment:persona`). Throws if the SAME layer already has the name (a
* `deployment:persona`) unless that global name is protected: global
* protection reserves its section name against scoped shadows so the
* registration owner—not a later scope—defines the canonical value. The
* registry snapshots `name`, `order`, and `text` before checking/storing, so
* later caller-object mutation cannot rename a contribution. Throws
* if the SAME layer already has the name (a
* duplicate would silently double prompt text — e.g. a double-loaded tool
* plugin; the global-duplicate message names `agent.ctx` as the per-agent
* alternative). Removed when the calling fiber is disposed. Emits
@@ -395,6 +446,14 @@ export class SystemPrompt extends Service {
*/
section(section: PromptSection): () => Promise<void> | void {
const scope = scopeOf(this.ctx)
const snapshot: PromptSection = {
name: section.name,
order: section.order,
text: section.text,
}
if (scope !== undefined && this.protections.some(record => record.sections?.includes(snapshot.name))) {
throw new Error(`prompt section "${snapshot.name}" is globally protected and cannot be shadowed in an agent scope`)
}
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
? this.sections
@@ -403,18 +462,18 @@ export class SystemPrompt extends Service {
this.scopedSections.set(scope, created)
return created
})()
if (layer.some(existing => existing.name === section.name)) {
if (layer.some(existing => existing.name === snapshot.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`)
? `prompt section "${snapshot.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${snapshot.name}" is already registered in this scope`)
}
layer.push(section)
layer.push(snapshot)
// Yield the rollback BEFORE emitting `system-prompt/change`: a generator
// effect collects each yielded disposer before the next step runs, so a
// throwing change listener removes the section instead of leaking it into
// every future assembly.
yield () => {
const index = layer.indexOf(section)
const index = layer.indexOf(snapshot)
/* 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)
@@ -531,6 +590,73 @@ export class SystemPrompt extends Service {
return dispose
}
/**
* Protect named section/tool contributions from the assembly waterfall.
* The layer is decided by the calling context: a global protection applies
* to every assembly, while one registered through `agent.ctx` applies only
* to that agent's scope. The name's canonical registry/provider output is
* restored AFTER the whole waterfall, so listener registration order cannot
* strip, replace, duplicate, or fabricate it. Canonical absence is restored
* too: if the protected name is intentionally absent for an assembly, a
* listener-injected entry with that name is removed. The input arrays are
* snapshotted; an empty protection throws because it cannot affect output.
* Removed with the calling fiber and emits `system-prompt/change` on
* registration/unregistration. A global section protection also reserves the
* name against scoped section shadows; registering protection when such a
* shadow already exists fails loudly instead of protecting the wrong owner.
* @param protection - section and/or tool names whose canonical presence and definitions are authoritative.
* @returns the exact Cordis effect disposer that removes the protection.
*/
protect(protection: PromptProtection): () => Promise<void> | void {
const scope = scopeOf(this.ctx)
const snapshot: PromptProtection = {
...protection.sections !== undefined ? { sections: [...new Set(protection.sections)] } : {},
...protection.tools !== undefined ? { tools: [...new Set(protection.tools)] } : {},
}
if ((snapshot.sections?.length ?? 0) === 0 && (snapshot.tools?.length ?? 0) === 0) {
throw new Error('systemPrompt.protect() requires at least one section or tool name')
}
if (scope === undefined && snapshot.sections !== undefined) {
const protectedSections = new Set(snapshot.sections)
const conflicts = [...this.scopedSections.values()]
.flatMap(layer => layer.filter(section => protectedSections.has(section.name)).map(section => section.name))
if (conflicts.length > 0) {
throw new Error(`systemPrompt.protect() cannot globally protect section${conflicts.length > 1 ? 's' : ''} ${[...new Set(conflicts)].map(name => `"${name}"`).join(', ')} while scoped shadows are registered`)
}
}
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
const layer = scope === undefined
? this.protections
: this.scopedProtections.get(scope) ?? (() => {
const created: PromptProtection[] = []
this.scopedProtections.set(scope, created)
return created
})()
layer.push(snapshot)
yield () => {
const index = layer.indexOf(snapshot)
/* v8 ignore next 3 -- defensive: protection was registered, so indexOf is guaranteed >= 0 */
if (index >= 0) layer.splice(index, 1)
if (scope !== undefined && layer.length === 0) this.scopedProtections.delete(scope)
this.ctx.emit('system-prompt/change')
}
this.ctx.emit('system-prompt/change')
}.bind(this), 'systemPrompt.protect()')
return dispose
}
/** Resolve the authoritative names registered for one assembly scope. */
private protectedNames(scope: ScopeKey | undefined): { sections: Set<string>; tools: Set<string> } {
const records = [
...this.protections,
...(scope === undefined ? [] : this.scopedProtections.get(scope)) ?? [],
]
return {
sections: new Set(records.flatMap(record => record.sections ?? [])),
tools: new Set(records.flatMap(record => record.tools ?? [])),
}
}
/**
* Assemble the current prompt for one caller: the global layer merged with
* {@link AssembleContext.scope}'s layer (scoped sections/variables SHADOW
@@ -546,10 +672,11 @@ export class SystemPrompt extends Service {
* Tool schemas are deep-cloned because adapters and request waterfalls may
* mutate schema objects. Runs through the `system-prompt/assemble`
* waterfall, giving listeners the opportunity to mutate or replace the
* assembly before it reaches the model — like the sections' `order` sort,
* tool canonicalization happens on the initial assembly, and a listener
* owns the determinism of whatever it emits. Await the result before
* reading the assembly values — waterfall listeners may be async.
* assembly, then restores every visible {@link PromptProtection} from the
* pre-waterfall canonical assembly. Like the sections' `order` sort, tool
* canonicalization happens on the initial assembly; unprotected listener
* output owns its own determinism. Await the result before reading the
* assembly values — waterfall listeners may be async.
* Interpolation happens later, in {@link renderPrompt}.
* @param context - what this assembly is for (defaults to an empty context;
* see {@link AssembleContext}).
@@ -560,6 +687,10 @@ export class SystemPrompt extends Service {
// (`assemble().catch(...)` would miss it).
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
const scope = context.scope
// Protection is a registry input too: snapshot which names are protected
// at assembly start. Registrations that land while an async waterfall is
// in flight affect the NEXT assembly, matching the other registries.
const protectedNames = this.protectedNames(scope)
// Variables: global layer first, then the scope's layer OVERWRITES
// same-named entries (shadowing — a per-agent value wins for that agent).
const variables: Record<string, string | undefined> = {}
@@ -611,7 +742,27 @@ export class SystemPrompt extends Service {
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}
return this.ctx.waterfall(scopeTarget(this, scope), 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))
// Snapshot only the fields protection can restore. The waterfall receives
// `assembly` by reference and may mutate it or return a replacement; these
// independent snapshots remain the authoritative registry product.
const canonicalSections = protectedNames.sections.size > 0 ? structuredClone(assembly.sections) : undefined
const canonicalTools = protectedNames.tools.size > 0 ? structuredClone(assembly.tools) : undefined
const result = await this.ctx.waterfall(
scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
() => Promise.resolve(assembly),
)
// Build a replacement instead of mutating the waterfall result: a
// listener may legitimately return a frozen assembly. Merge-extensible
// fields ride through the spread untouched.
return {
...result,
...canonicalSections !== undefined
? { sections: restoreProtected(canonicalSections, result.sections, protectedNames.sections) }
: {},
...canonicalTools !== undefined
? { tools: restoreProtected(canonicalTools, result.tools, protectedNames.tools) }
: {},
}
}
}
@@ -62,6 +62,21 @@ describe('scoped sections', () => {
scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'a' })
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
})
it.each([
[['reserved'], 'section "reserved"'],
[['first', 'second'], 'sections "first", "second"'],
])('rejects global protection added after scoped shadows (%j)', async (names, message) => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
for (const name of names) {
scope.ctx.systemPrompt.section({ name, order: 1, text: `scoped ${name}` })
}
expect(() => ctx.systemPrompt.protect({ sections: names })).toThrow(message)
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })))
.toContain(`scoped ${names[0]}`)
})
})
describe('scoped variables', () => {
@@ -148,4 +163,31 @@ describe('scoped assemble dispatch', () => {
expect(global.sections.some(s => s.name === 'listener:extra')).toBe(false)
expect(shaped).toHaveLength(1)
})
it('a scoped protection finalizes only its own assemblies and disappears with the scope', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
const key = scopeKeyOf(scope)
ctx.systemPrompt.section({ name: 'required', order: 10, text: 'required' })
ctx.systemPrompt.tools(() => ({ schemas: [schema('required')] }))
scope.ctx.systemPrompt.protect({ sections: ['required'], tools: ['required'] })
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections = result.sections.filter(section => section.name !== 'required')
result.tools = result.tools.filter(tool => tool.name !== 'required')
return result
}, { prepend: true })
const scoped = await ctx.systemPrompt.assemble({ scope: key })
const global = await ctx.systemPrompt.assemble()
expect(scoped.sections.some(section => section.name === 'required')).toBe(true)
expect(scoped.tools.some(tool => tool.name === 'required')).toBe(true)
expect(global.sections.some(section => section.name === 'required')).toBe(false)
expect(global.tools.some(tool => tool.name === 'required')).toBe(false)
await scope.dispose()
const disposed = await ctx.systemPrompt.assemble({ scope: key })
expect(disposed.sections.some(section => section.name === 'required')).toBe(false)
expect(disposed.tools.some(tool => tool.name === 'required')).toBe(false)
})
})
@@ -205,6 +205,103 @@ describe('SystemPrompt', () => {
expect(assembly.sections).toHaveLength(0)
})
describe('canonical contribution protection', () => {
it('restores exact protected definitions after every listener, in canonical relative order', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'before', order: 10, text: 'before' })
ctx.systemPrompt.section({ name: 'protected', order: 20, text: 'canonical section' })
ctx.systemPrompt.section({ name: 'after', order: 30, text: 'after' })
ctx.systemPrompt.tools(() => ({ schemas: [
{ name: 'alpha', description: 'alpha', parameters: {} },
{ name: 'protected', description: 'canonical tool', parameters: { type: 'object', properties: { answer: { type: 'number' } } } },
{ name: 'zulu', description: 'zulu', parameters: {} },
] }))
const protection = { sections: ['protected'], tools: ['protected'] }
ctx.systemPrompt.protect(protection)
// Registration snapshots its arrays; caller mutation cannot change what
// the service makes authoritative.
protection.sections[0] = 'after'
protection.tools[0] = 'zulu'
// Registered AFTER the protection and prepended: it is outside every
// ordinary listener that existed when protect() ran, but service-level
// finalization still restores the canonical entries after it returns.
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
return Object.freeze({
sections: [
...result.sections.filter(section => section.name !== 'protected'),
{ name: 'protected', order: -999, text: 'wrong section' },
{ name: 'protected', order: 999, text: 'duplicate section' },
],
tools: [
...result.tools.filter(tool => tool.name !== 'protected'),
{ name: 'protected', description: 'wrong tool', parameters: {} },
{ name: 'protected', description: 'duplicate tool', parameters: {} },
],
variables: result.variables,
})
}, { prepend: true })
const assembly = await ctx.systemPrompt.assemble()
const protectedSections = assembly.sections.filter(section => section.name === 'protected')
const protectedTools = assembly.tools.filter(tool => tool.name === 'protected')
expect(protectedSections).toEqual([{ name: 'protected', order: 20, text: 'canonical section' }])
expect(protectedTools).toEqual([{
name: 'protected',
description: 'canonical tool',
parameters: { type: 'object', properties: { answer: { type: 'number' } } },
}])
expect(assembly.sections.map(section => section.name).indexOf('protected'))
.toBeLessThan(assembly.sections.map(section => section.name).indexOf('after'))
expect(assembly.tools.map(tool => tool.name)).toEqual(['alpha', 'protected', 'zulu'])
})
it('protects canonical absence and rejects an empty protection', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
// Separate registrations exercise the set-union contract: protections
// may name only sections or only tools and still compose.
ctx.systemPrompt.protect({ sections: ['mode-hidden'] })
ctx.systemPrompt.protect({ tools: ['mode-hidden'] })
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections.push({ name: 'mode-hidden', order: 100, text: 'fabricated' })
result.tools.push({ name: 'mode-hidden', description: 'fabricated', parameters: {} })
return result
})
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.some(section => section.name === 'mode-hidden')).toBe(false)
expect(assembly.tools.some(tool => tool.name === 'mode-hidden')).toBe(false)
expect(() => ctx.systemPrompt.protect({})).toThrow(/at least one section or tool name/)
expect(() => ctx.systemPrompt.protect({ sections: [], tools: [] })).toThrow(/at least one section or tool name/)
})
it('removes a protection with its contributing fiber (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical' })
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
result.sections = result.sections.filter(section => section.name !== 'protected')
return result
})
let changes = 0
ctx.on('system-prompt/change', () => { changes++ })
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.systemPrompt.protect({ sections: ['protected'] })
}, { inject: ['systemPrompt'] }))
expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(true)
expect(changes).toBe(1)
await fiber.dispose()
expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(false)
expect(changes).toBe(2)
})
})
it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
+10 -7
View File
@@ -1,12 +1,14 @@
/**
* Code Mode: the `run_code` tool and its dispatch bridge. The model writes a
* TypeScript program; the bridge hands it to `ctx.codeRuntime` with one
* async binding per registered tool, serializes every binding call through a
* per-run queue onto `ToolRegistry.execute()` (so `tools/pre-execute` /
* `tools/post-execute` gate sub-calls exactly like native ones), logs each
* sub-dispatch as a `tool/code-dispatch` session event, and returns only the
* program's curated output. The registry itself decides WHEN this tool
* exists (its `mode` config); this module owns only the tool and the bridge.
* TypeScript program; the bridge hands it to `ctx.codeRuntime` with one async
* binding per end capability visible to the calling agent, then serializes
* every binding call through a per-run queue onto `ToolRegistry.execute()`.
* Sub-calls therefore traverse the complete pre/guard/around/post/final-result
* pipeline exactly like native calls and carry the outer execution's opaque
* token for correlation. The bridge logs each sub-dispatch as a
* `tool/code-dispatch` session event and returns only the program's curated
* output. The registry itself decides WHEN this tool exists (its `mode`
* config); this module owns only the tool and the bridge.
*
* @module @deepseek-ai/dsh-tools/src/code-mode
*/
@@ -205,6 +207,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
name,
arguments: normalized.dispatched,
...exec.agent ? { agent: exec.agent } : {},
parent: exec.token,
signal: runController.signal,
})
const text = textOf(result.content)
+467 -121
View File
@@ -1,15 +1,15 @@
/**
* Tool registry and execution pipeline. Plugins register tools; the registry
* feeds schemas into the system prompt, and `execute()` dispatches each call
* through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an
* around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute`
* (inspect/replace the result, attach context) for sandbox, permission, and hook
* plugins to gate or transform a call.
* through `tools/pre-execute` (the extensible allow/deny gate) → monotonic
* registered guards → `tools/execute` (an around-dispatch wrapper for
* timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the
* result, attach context) → the observe-only `tools/result` notification.
*
* The registry also owns HOW its tools are presented to the model — its
* `mode` config: `'native'` (every tool as a wire function definition,
* today's behavior and the default), `'code'` (the wire carries exactly one
* tool, `run_code`, plus a generated TypeScript SDK prompt section), or
* today's behavior and the default), `'code'` (the registry's canonical wire
* contribution is one tool, `run_code`, plus a generated TypeScript SDK prompt section), or
* `'both'`. See `code-mode.ts` (the tool + dispatch bridge) and
* `ts-types.ts` (the SDK codegen); design in the Code Mode RFC.
*
@@ -21,8 +21,9 @@ import z from 'schemastery'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import { isJsonValue } from '@deepseek-ai/dsh-session'
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { ToolCallView, ToolResultView } from './presentation.ts'
@@ -105,10 +106,13 @@ declare module 'cordis' {
* unknown tool) is already normalized to an `isError` result by the time a
* listener's `await next()` returns, so a wrapper never sees a raw throw from
* the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can
* mutate `exec` (e.g. replace `exec.signal` with a per-call deadline) BEFORE
* `next()` and inspect the result AFTER. (Cordis `next()` ignores any passed
* arguments and re-invokes downstream with the shared payload, so a wrapper
* mutates `exec` in place rather than passing a new object to `next()`.)
* set or replace the one mutable field, `exec.signal` (e.g. with a per-call
* deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity
* (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the
* pipeline so a wrapper cannot change which capability or scope was
* authorized. (Cordis `next()` ignores passed arguments and re-invokes
* downstream with the shared payload, so a wrapper changes `exec.signal` in
* place rather than passing a new object to `next()`.)
* Multiple listeners compose by registration order — an outer one wraps the
* inner ones plus dispatch.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
@@ -139,6 +143,21 @@ declare module 'cordis' {
* @mode waterfall
*/
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
/**
* Awaited notification of the authoritative FINAL tool outcome, after the
* complete pre/execute/post pipeline, final lossless-JSON validation, and
* outer error normalization.
* Unlike the three waterfalls, this seam cannot transform the result: each
* listener receives the now-frozen execution object and a deep-frozen result
* snapshot; listener failures are contained and logged, and
* {@link ToolRegistry.execute} still returns the outcome.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by
* `exec.agent`, using the same carrier as the pipeline.
* @param exec - the execution object that traversed the pipeline.
* @param result - a deep-frozen snapshot of the final returned result.
* @mode parallel
*/
'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void
/**
* A tool was registered or unregistered, or a scoped restriction changed
* (the available tool set changed — possibly for one scope only). An
@@ -152,10 +171,8 @@ declare module 'cordis' {
}
}
// TODO(review): revisit these shapes when the first real tools and
// sandbox/permission plugins land (e.g. a concurrency-safety hint for
// parallel execution — Claude Code partitions read-only tools; phase 1
// executes sequentially).
// TODO(review): revisit these shapes when concurrency metadata becomes useful
// (for example, a read-only hint that would permit safe parallel execution).
/**
* What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the
@@ -214,17 +231,54 @@ export interface ToolResult {
meta?: unknown
}
/** One pending tool call, as it flows through the execution pipeline (`tools/pre-execute` → dispatch → `tools/post-execute`). */
export interface ToolExecution {
callId: CallId
name: string
/** Parsed JSON arguments (unknown — tools validate their own input). */
arguments: unknown
declare const toolExecutionTokenBrand: unique symbol
/** Tokens minted in this module; a cast or JavaScript object cannot forge membership. */
const executionTokens = new WeakSet<object>()
/**
* Opaque, immutable identity for one trip through the tool pipeline. Nested
* transports carry the enclosing execution's token instead of its live object,
* so observe-only result listeners can correlate calls without gaining a
* mutation path into an outer around-dispatch wrapper.
*/
export interface ToolExecutionToken {
readonly [toolExecutionTokenBrand]: true
}
/**
* Caller-supplied description of one tool call. {@link ToolRegistry.execute}
* snapshots this input into a pipeline-owned {@link ToolExecution}; callers do
* not choose the execution token.
*/
export interface ToolExecutionInput {
readonly callId: CallId
readonly name: string
/** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */
readonly arguments: unknown
/** The agent on whose behalf the call runs (set by the agent loop). */
agent?: Agent
readonly agent?: Agent
/**
* Opaque token of the enclosing transport execution, when one exists. Code
* Mode sets this on SDK sub-dispatches so commit-style observers can wait for
* the outer `run_code` outcome without receiving its live mutable execution.
*/
readonly parent?: ToolExecutionToken
signal?: AbortSignal
}
/**
* One pending tool call inside the registry pipeline. Call identity, the
* registry-assigned {@link token}, and a lossless-JSON-validated, deep-frozen
* clone of the parsed arguments are immutable from the first policy listener onward, while an
* around-dispatch wrapper may set, replace, or remove only `signal`. 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
}
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
export interface ToolErrorInfo {
name: string
@@ -255,7 +309,6 @@ export interface ToolExecutionResult {
* text in `content` is always present; this is extra structure for code.
*/
error?: ToolErrorInfo
/**
/**
* Extra model-facing context a `tools/post-execute` listener attached for the
* NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part
@@ -318,17 +371,28 @@ export type PostToolDecision =
* is stringified.
*/
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === 'object' && error !== null
&& 'message' in error && typeof error.message === 'string') {
return error.message
try {
if (error instanceof Error) return error.message
if (typeof error === 'object' && error !== null
&& 'message' in error && typeof error.message === 'string') {
return error.message
}
return String(error)
} catch {
// A hostile thrown value can trap `instanceof`, property access, or string
// coercion. Error normalization is the outermost safety boundary, so its
// fallback must itself be total.
return '<unprintable thrown value>'
}
return String(error)
}
/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
function errorInfo(error: unknown): ToolErrorInfo | undefined {
return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
try {
return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
} catch {
return undefined
}
}
/** How the registry presents its tools to the model (see {@link Config.mode}). */
@@ -338,9 +402,9 @@ export type ToolPresentationMode = 'native' | 'code' | 'both'
export interface Config {
/**
* The presentation mode. `'native'` (the default) contributes every
* registered tool as a wire function definition — byte-for-byte today's
* behavior. `'code'` contributes exactly ONE wire tool, `run_code`, plus
* the generated `tools:sdk` prompt section declaring every other tool as a
* visible end capability as a native wire function definition. Under
* `'code'` this registry contributes exactly ONE wire tool,
* `run_code`, plus the generated `tools:sdk` prompt section declaring every other tool as a
* TypeScript API the program calls. `'both'` contributes every native
* definition AND `run_code` + the SDK section. Non-native modes require a
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
@@ -371,11 +435,27 @@ export interface ToolRestriction {
deny?: string[]
}
/**
* A monotonic execution guard evaluated after every `tools/pre-execute`
* listener and before the tool body. Returning a reason denies the call;
* returning `undefined` leaves it unchanged. Because guards have no allow
* result, listener ordering cannot turn a denial back into permission.
* @param execution - the identity-protected call after extensible pre-execute policy completed.
* @returns a final denial reason, or `undefined` to leave the call allowed.
*/
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
/** One guard registration; the wrapper preserves independent duplicate registrations. */
interface ToolGuardRegistration {
guard: ToolGuard
}
/**
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
* loop executes calls through the `tools/pre-execute` → `tools/execute`
* `tools/post-execute` pipeline. The registry contributes its schemas into the
* system-prompt assembly — WHICH schemas is governed by its `mode` config
* loop executes calls through the `tools/pre-execute` → guards
* `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The
* registry contributes its schemas into the system-prompt assembly — WHICH
* schemas is governed by its `mode` config
* (see {@link Config.mode}); under a non-native mode it also owns the reserved
* `run_code` presentation transport and the `tools:sdk` prompt section.
*
@@ -402,6 +482,9 @@ export class ToolRegistry extends Service {
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
/** Snapshot-at-registration restriction filters, per scope (see {@link restrict}). */
private restrictions = new Map<ScopeKey, ToolRestriction[]>()
/** Monotonic post-policy guards, split into global and per-agent layers. */
private globalGuards = new Set<ToolGuardRegistration>()
private scopedGuards = new Map<ScopeKey, Set<ToolGuardRegistration>>()
private readonly mode: ToolPresentationMode
/** Reserved presentation transport, kept outside the filterable registration layers. */
private readonly codeTransport: ToolDefinition | undefined
@@ -418,7 +501,7 @@ export class ToolRegistry extends Service {
// the filterable global/scoped capability layers.
this.codeTransport = this.mode === 'native'
? undefined
: createRunCodeTool(this, () => this.requireCodeRuntime())
: deepFreeze(createRunCodeTool(this, () => this.requireCodeRuntime()))
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
if (this.mode !== 'native') {
ctx.systemPrompt.section({
@@ -436,6 +519,11 @@ export class ToolRegistry extends Service {
return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME))
},
})
// These are presentation infrastructure, not optional end capabilities.
// Protect them at their owner: assembly listeners may still transform
// ordinary tools and prose, but cannot silently leave Code Mode without
// its only wire transport or the SDK that tells the model how to use it.
ctx.systemPrompt.protect({ sections: ['tools:sdk'], tools: [RUN_CODE_NAME] })
}
}
@@ -495,8 +583,12 @@ export class ToolRegistry extends Service {
* the shadowing feature, not an error; the global-duplicate message names
* `agent.ctx` as the per-agent alternative), or if a non-native mode reserves
* the `run_code` name for its presentation transport. The visible schema set
* flows into prompt assembly automatically. Disposed with the calling
* fiber. Emits `tools/change` on register/unregister.
* flows into prompt assembly automatically. Registration validates and
* clones the JSON parameters, copies scalar fields, binds each callback once
* to the caller's definition as its method receiver, and freezes the stored
* snapshot; later mutation or callback replacement on the input object does
* not rewrite the registry. Disposed with the calling fiber. Emits
* `tools/change` on register/unregister.
* @param definition - the tool's schema plus its execute (and optional
* presentation) functions.
* @returns the disposer that unregisters the tool. The exact
@@ -505,24 +597,52 @@ export class ToolRegistry extends Service {
*/
register(definition: ToolDefinition): () => Promise<void> | void {
const scope = scopeOf(this.ctx)
if (this.codeTransport !== undefined && definition.name === RUN_CODE_NAME) {
// A schema crosses the same model/log boundary as execution arguments.
// Validate BEFORE cloning because structuredClone silently turns some
// forbidden values (for example class instances) into plain records, then
// validate the detached value again to contain hostile getters that change
// between inspection and snapshotting. A frozen Map is still mutable, so
// deepFreeze alone is not a sufficient registration boundary.
if (!isJsonValue(definition.parameters)) {
throw new TypeError('tool parameters must be losslessly JSON-serializable')
}
const parameters = structuredClone(definition.parameters)
if (!isJsonValue(parameters)) {
throw new TypeError('tool parameters must be stable losslessly JSON-serializable data')
}
// Bind once so replacing a callback on the caller-owned definition after
// registration cannot change dispatch, while preserving the historical
// method receiver (`this === definition`) for callbacks that use it.
const execute = definition.execute.bind(definition)
const presentCall = definition.presentCall?.bind(definition)
const presentResult = definition.presentResult?.bind(definition)
const snapshot: ToolDefinition = deepFreeze({
name: definition.name,
description: definition.description,
parameters,
execute,
...definition.timeoutMs !== undefined ? { timeoutMs: definition.timeoutMs } : {},
...presentCall !== undefined ? { presentCall } : {},
...presentResult !== undefined ? { presentResult } : {},
})
if (this.codeTransport !== undefined && snapshot.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(definition.name)) {
if (layer.has(snapshot.name)) {
throw new Error(scope === undefined
? `tool "${definition.name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
: `tool "${definition.name}" is already registered in this scope`)
? `tool "${snapshot.name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
: `tool "${snapshot.name}" is already registered in this scope`)
}
layer.set(definition.name, definition)
layer.set(snapshot.name, snapshot)
// Yield the rollback BEFORE emitting `tools/change`: a generator effect
// collects each yielded disposer before the next step runs, so a throwing
// `tools/change` listener removes the tool instead of leaking it (a leak
// would wedge the duplicate-name check until restart). The duplicate
// throw above fires before any mutation — it leaks nothing.
yield () => {
layer.delete(definition.name)
layer.delete(snapshot.name)
// An emptied scope layer is dropped so a disposed scope leaves no
// residue keyed by its (dead) key.
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
@@ -604,6 +724,30 @@ export class ToolRegistry extends Service {
return dispose
}
/**
* Register a monotonic guard after the extensible `tools/pre-execute`
* waterfall. A plain-context guard applies globally; one registered through
* `agent.ctx` applies only to that agent. Any matching guard may deny by
* returning a reason, while no guard can force-allow a call another guard
* denied. The exact effect disposer is returned for ordered ownership and
* HMR cleanup.
* @param guard - synchronous check; a returned string denies the execution.
* @returns the exact disposer that unregisters the guard.
*/
guard(guard: ToolGuard): () => Promise<void> | 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()')
return dispose
}
/** The (created-on-demand) scoped layer for `scope`. */
private layerFor(scope: ScopeKey): Map<string, ToolDefinition> {
let layer = this.scoped.get(scope)
@@ -614,6 +758,43 @@ export class ToolRegistry extends Service {
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
}
/** First monotonic denial from the global then matching scoped guard layers. */
private guardReason(exec: ToolExecution): string | undefined {
// Guards are policy, not another transform seam. The pipeline execution's
// identity and arguments are already protected; freeze a detached view so
// an untyped guard cannot replace the wrapper-mutable signal either.
const view: Readonly<ToolExecution> = Object.freeze({ ...exec })
for (const { guard } of this.globalGuards) {
const reason = guard(view)
if (reason !== undefined) return this.assertGuardReason(reason)
}
if (exec.agent !== undefined) {
for (const { guard } of this.scopedGuards.get(exec.agent) ?? []) {
const reason = guard(view)
if (reason !== undefined) return this.assertGuardReason(reason)
}
}
return undefined
}
/** Runtime boundary for JavaScript/casted guards: only strings can deny. */
private assertGuardReason(reason: unknown): string {
if (typeof reason !== 'string') {
throw new TypeError(`tools.guard() must return a denial string or undefined, got ${typeof reason}`)
}
return reason
}
/** 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
@@ -707,8 +888,9 @@ export class ToolRegistry extends Service {
}
/**
* Execute one tool call through the `tools/pre-execute` → `tools/execute`
* (around dispatch) → `tools/post-execute` pipeline. `pre-execute` is the gate
* Execute one tool call through the `tools/pre-execute` → guards →
* `tools/execute` (around dispatch) → `tools/post-execute` → `tools/result`
* pipeline. `pre-execute` is the extensible gate
* (allow/deny), `tools/execute` wraps core dispatch (a timeout/retry/metrics
* seam), and `post-execute` is the inspect/transform seam; core dispatch sits
* as the base `next()` of the `tools/execute` waterfall. The whole thing is
@@ -719,74 +901,177 @@ export class ToolRegistry extends Service {
* tool is not registered (or not visible to the calling agent — a
* restricted-away global is exactly as absent as a nonexistent one), the
* result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown
* {@link HarnessError} surfaces its `{ name, code }` on the result.
* @param exec - the call to run (name, parsed arguments, caller agent, signal).
* {@link HarnessError} surfaces its `{ name, code }` on the result. Before
* the final observe-only notification, the authoritative outcome must survive
* a lossless JSON round trip; an invalid outcome is normalized to an error.
* Caller-owned arguments must survive lossless-JSON validation before and
* after cloning; a violation normalizes to an error before policy or dispatch.
* @param exec - the single-use call input; its identity is snapshotted and
* protected before policy runs.
* @returns the final result after every waterfall; failures resolve as
* `isError` results, never rejections.
*/
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
let execution: ToolExecution
try {
// --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
// until the permission system lands) skips dispatch entirely. The
// carrier keys the dispatch by exec.agent, so an `agent.ctx` listener
// gates only its own agent's calls (agent-less calls are subject-less).
const carrier = scopeTarget(this, exec.agent)
const decision = await this.ctx.waterfall(
carrier, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)
if (decision.kind !== 'allow') {
// deny → isError. ask has no permission UI yet, so degrade to deny
// (FIXME(permissions)): a forthcoming permission system turns `ask` into
// a real prompt; today it is the conservative "not allowed".
const reason = decision.kind === 'deny'
? decision.reason
: decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)`
const denied: ToolExecutionResult = {
callId: exec.callId,
content: [{ type: 'text', text: `Error: ${reason}` }],
isError: true,
}
return await this.postExecute(exec, denied)
}
// --- Around-dispatch: tools/execute. The base `next` is the dispatch-
// with-normalization thunk — the tool body's own try/catch turns a throw
// into an isError result so a wrapper (and post-execute) can inspect it;
// an unknown tool routes through the same catch. A `tools/execute` listener
// (e.g. a timeout plugin) wraps this thunk: it may mutate `exec` before
// delegating and inspect the normalized result after. Dispatched with the
// same carrier as the gate, so an `agent.ctx` wrapper wraps only its own
// agent's calls. ---
const result = await this.ctx.waterfall(
carrier, 'tools/execute', exec,
async (): Promise<ToolExecutionResult> => {
try {
// Resolve through the CALLER's visible view ({@link get}): a scoped
// tool shadows its global name-twin for that agent, and a
// restricted-away global tool is exactly as absent as a nonexistent
// one — same UNKNOWN_TOOL result, no capability leak in the error.
const tool = this.get(exec.name, exec.agent)
if (!tool) throw new ToolNotFoundError(exec.name)
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
// meta) or a { content, meta } object (a tool attaching a private
// presentation payload). An array IS the content; the object carries it.
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 { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
return toolErrorResult(exec.callId, error)
}
},
)
return await this.postExecute(exec, result)
execution = this.prepareExecution(exec)
} catch (error: unknown) {
// Outer backstop: a throwing pre/post-execute listener (or the waterfall
// machinery) becomes an isError result, never a turn failure.
return toolErrorResult(exec.callId, error)
// Contract-violating non-JSON or non-cloneable arguments cannot enter a
// pipeline whose logged and executed forms must agree. Still publish one
// scoped final outcome, using an immutable identity shell, so result
// observers retain their every-call guarantee without seeing the invalid
// value.
execution = Object.freeze({
token: createExecutionToken(),
callId: exec.callId,
name: exec.name,
arguments: undefined,
...exec.agent !== undefined ? { agent: exec.agent } : {},
...isExecutionToken(exec.parent) ? { parent: exec.parent } : {},
...exec.signal !== undefined ? { signal: exec.signal } : {},
})
const result = toolErrorResult(execution.callId, error)
await this.notifyResult(execution, result)
return result
}
let result: ToolExecutionResult
try {
// Validate the authoritative FINAL result, not merely the tool body's
// intermediate return. Post-policy may replace content or attach context,
// and every one of these fields is session-bound. Reject anything that
// cannot round-trip losslessly through the durable JSON log before the
// observe-only `tools/result` commit point sees success.
result = this.snapshotExecutionResult(execution, await this.executePipeline(execution))
} catch (error: unknown) {
// Outer backstop: a throwing pre/post-execute listener, guard, or the
// waterfall machinery becomes an isError result, never a turn failure.
result = toolErrorResult(execution.callId, error)
}
await this.notifyResult(execution, result)
return result
}
/** Snapshot one call into a shared pipeline object with immutable identity and mutable cancellation. */
private prepareExecution(input: ToolExecutionInput): ToolExecution {
if (input.parent !== undefined && !isExecutionToken(input.parent)) {
throw new TypeError('tool execution parent must be a registry-minted opaque token')
}
if (!isJsonValue(input.arguments)) {
throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
}
const args = structuredClone(input.arguments)
if (!isJsonValue(args)) {
throw new TypeError('tool execution arguments must be stable losslessly JSON-serializable data')
}
const execution: ToolExecution = {
token: createExecutionToken(),
callId: input.callId,
name: input.name,
arguments: deepFreeze(args),
...input.agent !== undefined ? { agent: input.agent } : {},
...input.parent !== undefined ? { parent: input.parent } : {},
...input.signal !== undefined ? { signal: input.signal } : {},
}
Object.defineProperties(execution, {
token: { value: execution.token, enumerable: true, writable: false, configurable: false },
callId: { value: execution.callId, enumerable: true, writable: false, configurable: false },
name: { value: execution.name, enumerable: true, writable: false, configurable: false },
arguments: { value: execution.arguments, enumerable: true, writable: false, configurable: false },
agent: { value: input.agent, enumerable: true, writable: false, configurable: false },
parent: { value: input.parent, enumerable: true, writable: false, configurable: false },
})
if (input.signal !== undefined) {
Object.defineProperty(execution, 'signal', {
value: input.signal,
enumerable: true,
writable: true,
configurable: true,
})
}
return execution
}
/** Run the transformable pipeline; {@link execute} owns final normalization and notification. */
private async executePipeline(exec: ToolExecution): Promise<ToolExecutionResult> {
// --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
// until the permission system lands) skips dispatch entirely. The
// carrier keys the dispatch by exec.agent, so an `agent.ctx` listener
// gates only its own agent's calls (agent-less calls are subject-less).
const carrier = scopeTarget(this, exec.agent)
const decision = await this.ctx.waterfall(
carrier, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)
const denialReason = decision.kind === 'allow'
? this.guardReason(exec)
: decision.kind === 'deny'
? decision.reason
: decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)`
if (denialReason !== undefined) {
// deny → isError. ask has no permission UI yet, so degrade to deny
// (FIXME(permissions)): a forthcoming permission system turns `ask` into
// a real prompt; today it is the conservative "not allowed".
const denied: ToolExecutionResult = {
callId: exec.callId,
content: [{ type: 'text', text: `Error: ${denialReason}` }],
isError: true,
}
return await this.postExecute(exec, denied)
}
// --- Around-dispatch: tools/execute. The base `next` is the dispatch-
// with-normalization thunk — the tool body's own try/catch turns a throw
// into an isError result so a wrapper (and post-execute) can inspect it;
// an unknown tool routes through the same catch. A `tools/execute` listener
// (e.g. a timeout plugin) wraps this thunk: it may replace `exec.signal`
// before delegating and inspect the normalized result after. Dispatched with the
// same carrier as the gate, so an `agent.ctx` wrapper wraps only its own
// agent's calls. ---
const result = this.snapshotExecutionResult(exec, await this.ctx.waterfall(
carrier, 'tools/execute', exec,
async (): Promise<ToolExecutionResult> => {
try {
// Resolve through the CALLER's visible view ({@link get}): a scoped
// tool shadows its global name-twin for that agent, and a
// restricted-away global tool is exactly as absent as a nonexistent
// one — same UNKNOWN_TOOL result, no capability leak in the error.
const tool = this.get(exec.name, exec.agent)
if (!tool) throw new ToolNotFoundError(exec.name)
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
// meta) or a { content, meta } object (a tool attaching a private
// presentation payload). An array IS the content; the object carries it.
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 { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
return toolErrorResult(exec.callId, error)
}
},
))
return await this.postExecute(exec, result)
}
/** Notify final-result observers without giving them a mutation/error channel into the outcome. */
private async notifyResult(exec: ToolExecution, result: ToolExecutionResult): Promise<void> {
// The pipeline is over: freeze the remaining mutable signal slot so every
// observer sees the SAME WeakMap-keyable execution without a mutation race.
Object.freeze(exec)
// postExecute clones every accepted result/decision before rebuilding the
// outcome; all error paths construct plain data. The final result is thus
// structurally cloneable before it reaches this observe-only boundary.
const snapshot = deepFreeze(structuredClone(result))
const callbacks = this.ctx.events.dispatch('parallel', [
scopeTarget(this, exec.agent), 'tools/result', exec, snapshot,
])
await Promise.all(callbacks.map(async (callback) => {
try {
await callback(exec, snapshot)
} catch (error: unknown) {
this.ctx.logger.warn(`tool "${exec.name}" (${exec.callId}): tools/result observer failed: ${errorMessage(error)}`)
}
}))
}
/**
@@ -804,21 +1089,14 @@ export class ToolRegistry extends Service {
// authoritative-call-id requirement and the "preserve the dispatched
// isError/error" contract. The decision is the ONLY sanctioned channel for a
// listener to change the outcome (block, or accept-with-replacement); the
// call id is always the authoritative `exec.callId`. `content` is copied into
// a fresh array so a listener's in-place `push`/`splice` on `result.content`
// cannot leak into the returned content either (the elements are the same
// references — the snapshot guards the array structure, not deep immutability).
const dispatched = {
callId: exec.callId,
content: [...result.content],
isError: result.isError,
...result.error ? { error: result.error } : {},
...result.meta !== undefined ? { meta: result.meta } : {},
}
const decision = await this.ctx.waterfall(
// call id is always the authoritative `exec.callId`. Deep cloning protects
// nested content, error, and meta data from in-place listener mutation.
const dispatched = this.snapshotExecutionResult(exec, result)
const decision = structuredClone(await this.ctx.waterfall(
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
)
))
this.assertPostDecision(decision)
const additionalContext = decision.additionalContext
if (decision.kind === 'block') {
return {
@@ -835,6 +1113,74 @@ export class ToolRegistry extends Service {
...additionalContext ? { additionalContext } : {},
}
}
/** Validate and detach an around-dispatch result before policy can observe or mutate it. */
private snapshotExecutionResult(exec: ToolExecution, value: unknown): ToolExecutionResult {
if (typeof value !== 'object' || value === null) {
throw new TypeError('tools/execute must return a ToolExecutionResult object')
}
const result = value as Partial<ToolExecutionResult>
if (!Array.isArray(result.content) || typeof result.isError !== 'boolean') {
throw new TypeError('tools/execute must return a ToolExecutionResult with content[] and boolean isError')
}
if (result.callId !== exec.callId) {
throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`)
}
const candidate = {
callId: exec.callId,
content: result.content,
isError: result.isError,
...result.error !== undefined ? { error: result.error } : {},
...result.additionalContext !== undefined ? { additionalContext: result.additionalContext } : {},
...result.meta !== undefined ? { meta: result.meta } : {},
}
// Validate BEFORE cloning: structuredClone turns some forbidden exotic or
// class instances into plain objects, which would hide a lossy JSON
// boundary violation. Validate the detached clone again to contain hostile
// getters whose value changes between inspection and snapshotting.
if (!isJsonValue(candidate)) {
throw new TypeError('tools/execute must return a losslessly JSON-serializable ToolExecutionResult')
}
const snapshot = structuredClone(candidate)
if (!isJsonValue(snapshot)) {
throw new TypeError('tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult')
}
return snapshot
}
/** Reject malformed JavaScript/casted post decisions at the public event boundary. */
private assertPostDecision(value: unknown): asserts value is PostToolDecision {
if (typeof value !== 'object' || value === null) {
throw new TypeError('tools/post-execute must return a PostToolDecision object')
}
const decision = value as Partial<PostToolDecision>
switch (decision.kind) {
case 'accept':
if (decision.content !== undefined && !Array.isArray(decision.content)) {
throw new TypeError('tools/post-execute accept content must be an array')
}
return
case 'block':
if (!Array.isArray(decision.feedback)) {
throw new TypeError('tools/post-execute block feedback must be an array')
}
return
default:
throw new TypeError('tools/post-execute must return an accept or block decision')
}
}
}
/** Mint a frozen, property-free correlation token whose identity is its value. */
function createExecutionToken(): ToolExecutionToken {
const token = Object.freeze(Object.create(null)) as ToolExecutionToken
executionTokens.add(token)
return token
}
/** Runtime counterpart of the opaque token type, including `undefined` input. */
function isExecutionToken(value: unknown): value is ToolExecutionToken {
return typeof value === 'object' && value !== null && executionTokens.has(value)
}
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {
+80 -4
View File
@@ -123,6 +123,23 @@ describe('mode-aware wire contribution', () => {
expect(sdk?.text).not.toContain('run_code(args:')
})
it.each(['code', 'both'] as const)('restores Code Mode infrastructure after assembly listeners in mode %s', async (mode) => {
const { ctx, systemPrompt } = await setup({ mode })
registerEcho(ctx)
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const assembly = await next()
return {
...assembly,
sections: assembly.sections.filter(section => section.name !== 'tools:sdk'),
tools: assembly.tools.filter(tool => tool.name !== RUN_CODE_NAME),
}
}, { prepend: true })
const assembly = await systemPrompt.assemble()
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(true)
expect(assembly.tools.some(tool => tool.name === RUN_CODE_NAME)).toBe(true)
})
it("mode 'both' contributes every native schema plus run_code, and the SDK section", async () => {
const { ctx, systemPrompt } = await setup({ mode: 'both' })
registerEcho(ctx)
@@ -197,13 +214,40 @@ describe('mode-aware wire contribution', () => {
expect(() => scope.ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
expect(() => ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
expect(() => scope.ctx.systemPrompt.section({ name: 'tools:sdk', order: -999, text: 'malicious SDK' }))
.toThrow(/globally protected and cannot be shadowed/)
expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
const transport = ctx.tools.get(RUN_CODE_NAME)!
expect(Object.isFrozen(transport)).toBe(true)
expect(Object.isFrozen(transport.parameters)).toBe(true)
expect(() => { transport.name = 'mutated_transport' }).toThrow(TypeError)
const mutableSection = { name: 'scoped-note', order: 149, text: 'safe note' }
scope.ctx.systemPrompt.section(mutableSection)
mutableSection.name = 'tools:sdk'
mutableSection.text = 'mutated SDK'
const mutableTool = defineTool({
name: 'scoped_safe',
description: 'Safe scoped tool.',
parameters: {},
execute: () => Promise.resolve([{ type: 'text' as const, text: 'safe' }]),
})
scope.ctx.tools.register(mutableTool)
mutableTool.name = RUN_CODE_NAME
mutableTool.description = 'Mutated transport impostor.'
const stored = ctx.tools.get('scoped_safe', agent)!
expect(Object.isFrozen(stored)).toBe(true)
expect(Object.isFrozen(stored.parameters)).toBe(true)
expect(() => { stored.name = RUN_CODE_NAME }).toThrow(TypeError)
const assembly = await systemPrompt.assemble({ scope: agent })
const transports = assembly.tools.filter(tool => tool.name === RUN_CODE_NAME)
expect(transports).toHaveLength(1)
expect(transports[0]?.description).toContain('Execute a TypeScript program')
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).not.toContain('mutated SDK')
expect(assembly.sections.find(section => section.name === 'scoped-note')?.text).toBe('safe note')
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe(args:')
expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME))
expect(ctx.tools.knownNames(agent)).not.toContain(RUN_CODE_NAME)
const result = await runCode(ctx, 'return 1', { agent })
@@ -306,6 +350,36 @@ describe('the run_code dispatch bridge', () => {
expect(result.meta).toEqual({ logs: [{ source: 'console', level: 'log', text: 'saw echo:one' }], dispatches: 2 })
})
it('exposes only an opaque parent token to nested result observers', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
registerEcho(ctx)
runtime.behavior = async (request) => {
await request.bindings[0]!.functions.echo!({ value: 'nested' })
return { logs: [], value: 'done' }
}
// Model a timeout-style outer wrapper: it temporarily installs a signal,
// delegates, then restores the exact prior shape. A nested result observer
// is observe-only and must not receive the live outer execution object;
// freezing the correlation value it sees therefore cannot break restore.
ctx.on('tools/execute', async (exec, next) => {
if (exec.name !== RUN_CODE_NAME) return next()
const previous = exec.signal
exec.signal = new AbortController().signal
const result = await next()
if (previous === undefined) delete exec.signal
else exec.signal = previous
return result
})
ctx.on('tools/result', (exec) => {
if (exec.parent !== undefined) Object.freeze(exec.parent)
})
const result = await runCode(ctx, 'await tools.echo({ value: "nested" })')
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'done' }])
})
it('serializes Promise.all dispatches: tool executions never overlap, in submission order', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const intervals: [string, string][] = []
@@ -639,16 +713,17 @@ describe('the run_code dispatch bridge', () => {
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
})
it('logs the value the tool RECEIVED even when the tool mutates its arguments', async () => {
it('gives the tool and durable log the same immutable argument value', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const { agent, events } = fakeAgent()
let mutationSucceeded: boolean | undefined
ctx.tools.register(defineTool({
name: 'mutator',
description: 'Mutates its own args object.',
description: 'Attempts to mutate its args object.',
parameters: { list: { type: 'array', required: true } },
execute(args) {
args.list.push('injected-by-tool')
return Promise.resolve([{ type: 'text' as const, text: 'mutated' }])
mutationSucceeded = Reflect.set(args.list, 1, 'injected-by-tool')
return Promise.resolve([{ type: 'text' as const, text: 'protected' }])
},
}))
runtime.behavior = async (request) => {
@@ -657,6 +732,7 @@ describe('the run_code dispatch bridge', () => {
}
const result = await runCode(ctx, 'program', { agent })
expect(result.isError).toBe(false)
expect(mutationSucceeded).toBe(false)
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
expect(dispatch.arguments).toEqual({ list: ['original'] })
})
+299 -2
View File
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
@@ -149,6 +149,11 @@ describe('restrict()', () => {
expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/)
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown tool "reall"; known tools for this scope: real/)
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown tools "ghost", "wraith"/)
const emptyCtx = await mount()
const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty')
expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] }))
.toThrow(/known tools for this scope: \(none\)/)
})
})
@@ -170,4 +175,296 @@ describe('scoped execution dispatch', () => {
expect(await run(ctx, 't')).toBe('ran:t')
expect(seen).toEqual(['a'])
})
it('applies scoped guards after pre-execute and unwinds duplicate registrations independently', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
const other = { id: 'other' as AgentId } as Agent
let bodyCalls = 0
ctx.tools.register({
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
},
})
let guardViewFrozen = false
const guard = (execution: Readonly<ToolExecution>): string => {
guardViewFrozen = Object.isFrozen(execution) && Object.isFrozen(execution.arguments)
return 'terminal policy'
}
const liftFirst = scope.ctx.tools.guard(guard)
scope.ctx.tools.guard(guard)
// Registered later and prepended outside every existing waterfall listener:
// it can force the extensible pre decision to allow, but cannot bypass the
// owner-level monotonic guard that runs after the waterfall.
scope.ctx.on('tools/pre-execute', () => Promise.resolve({ kind: 'allow' }), { prepend: true })
expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
expect(guardViewFrozen).toBe(true)
expect(await run(ctx, 't', other)).toBe('ran:t')
expect(bodyCalls).toBe(1)
await liftFirst()
expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
await scope.dispose()
expect(await run(ctx, 't', key)).toBe('ran:t')
expect(bodyCalls).toBe(2)
})
it('composes global guards monotonically when one abstains and a later one denies', async () => {
const ctx = await mount()
let bodyCalls = 0
ctx.tools.register({
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([])
},
})
ctx.tools.guard(() => undefined)
ctx.tools.guard(() => 'global denial')
expect(await run(ctx, 't')).toBe('Error: global denial')
expect(bodyCalls).toBe(0)
})
it('protects call identity before policy and dispatch while leaving only signal mutable', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
let safeCalls = 0
let dangerCalls = 0
let scopedResults = 0
let safeArguments: unknown
ctx.tools.register({
...tool('safe'),
execute: (args) => {
safeCalls += 1
safeArguments = args
return Promise.resolve([{ type: 'text', text: 'safe' }])
},
})
ctx.tools.register({
...tool('danger'),
execute: () => {
dangerCalls += 1
return Promise.resolve([{ type: 'text', text: 'danger' }])
},
})
scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined)
ctx.on('tools/pre-execute', (exec, next) => {
expect(Reflect.set(exec, 'agent', undefined)).toBe(false)
expect(Reflect.set(exec, 'name', 'safe')).toBe(false)
expect(Reflect.set(exec.arguments as object, 'injected', true)).toBe(false)
return next()
})
ctx.on('tools/execute', (exec, next) => {
expect(Reflect.set(exec, 'name', 'danger')).toBe(false)
return next()
})
ctx.on('tools/post-execute', (exec, _result, next) => {
expect(Reflect.set(exec, 'agent', undefined)).toBe(false)
return next()
})
scope.ctx.on('tools/result', () => { scopedResults += 1 })
expect(await run(ctx, 'danger', key)).toBe('Error: danger denied')
const callerArguments = { source: true }
const safeResult = await ctx.tools.execute({
callId: CallId('safe-call'),
name: 'safe',
arguments: callerArguments,
agent: key,
})
expect(safeResult.content[0]).toMatchObject({ text: 'safe' })
expect(Object.isFrozen(callerArguments)).toBe(false)
expect(safeArguments).not.toBe(callerArguments)
expect(Object.isFrozen(safeArguments)).toBe(true)
expect(callerArguments).toEqual({ source: true })
expect({ safeCalls, dangerCalls, scopedResults }).toEqual({
safeCalls: 1,
dangerCalls: 0,
scopedResults: 2,
})
})
it('normalizes non-cloneable arguments and still publishes one scoped final outcome', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
let policyCalls = 0
let bodyCalls = 0
let scopedObserved = 0
let globalObserved = 0
ctx.tools.register({
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([])
},
})
ctx.on('tools/pre-execute', (_exec, next) => {
policyCalls += 1
return next()
})
let parent!: ToolExecutionToken
ctx.tools.register(tool('parent'))
const stopCapture = ctx.on('tools/pre-execute', (exec, next) => {
if (exec.name === 'parent') parent = exec.token
return next()
})
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
stopCapture()
policyCalls = 0
const signal = new AbortController().signal
scope.ctx.on('tools/result', (exec, result) => {
scopedObserved += 1
expect(exec.arguments).toBeUndefined()
expect(exec.parent).toBe(parent)
expect(exec.signal).toBe(signal)
expect(Object.isFrozen(exec)).toBe(true)
expect(result.isError).toBe(true)
})
ctx.on('tools/result', () => { globalObserved += 1 })
const callerArguments = { invalid: () => undefined }
const scopedResult = await ctx.tools.execute({
callId: CallId('non-cloneable'),
name: 't',
arguments: callerArguments,
agent: key,
parent,
signal,
})
const subjectlessResult = await ctx.tools.execute({
callId: CallId('non-cloneable-subjectless'),
name: 't',
arguments: { invalid: () => undefined },
})
expect(scopedResult.isError).toBe(true)
expect(scopedResult.content[0]?.type === 'text' && scopedResult.content[0].text).toContain('losslessly JSON-serializable')
expect(subjectlessResult.isError).toBe(true)
expect({ policyCalls, bodyCalls, scopedObserved, globalObserved }).toEqual({
policyCalls: 0,
bodyCalls: 0,
scopedObserved: 1,
globalObserved: 2,
})
expect(Object.isFrozen(callerArguments)).toBe(false)
expect(callerArguments.invalid).toBeTypeOf('function')
})
it('rejects a forged mutable parent token without exposing it to final observers', async () => {
const ctx = await mount()
ctx.tools.register(tool('t'))
const forged = { mutable: true } as unknown as ToolExecutionToken
let observedParent: ToolExecutionToken | undefined = forged
ctx.on('tools/result', (exec) => { observedParent = exec.parent })
const result = await ctx.tools.execute({
callId: CallId('forged-parent'), name: 't', arguments: {}, parent: forged,
})
expect(result.isError).toBe(true)
expect(result.content).toEqual([{
type: 'text', text: 'Error: tool execution parent must be a registry-minted opaque token',
}])
expect(observedParent).toBeUndefined()
expect(Object.isFrozen(forged)).toBe(false)
})
it.each([
['Map', new Map([['mutable', true]])],
['class instance', new (class Arguments { value = 1 })()],
])('rejects cloneable non-JSON arguments (%s) before policy or dispatch', async (_kind, argumentsValue) => {
const ctx = await mount()
let policyCalls = 0
let bodyCalls = 0
let observed = 0
ctx.tools.register({
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([])
},
})
ctx.on('tools/pre-execute', (_exec, next) => {
policyCalls += 1
return next()
})
ctx.on('tools/result', (exec, result) => {
observed += 1
expect(exec.arguments).toBeUndefined()
expect(result.isError).toBe(true)
})
const result = await ctx.tools.execute({
callId: CallId('bad-arguments'), name: 't', arguments: argumentsValue,
})
expect(result.isError).toBe(true)
expect(result.content).toEqual([{
type: 'text', text: 'Error: tool execution arguments must be losslessly JSON-serializable',
}])
expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 })
})
it('rejects arguments that change to non-JSON data while being snapshotted', async () => {
const ctx = await mount()
ctx.tools.register(tool('t'))
let reads = 0
const argumentsValue = Object.defineProperty({}, 'value', {
enumerable: true,
get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]),
})
const result = await ctx.tools.execute({
callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue,
})
expect(result).toEqual({
callId: CallId('unstable-arguments'),
content: [{
type: 'text', text: 'Error: tool execution arguments must be stable losslessly JSON-serializable data',
}],
isError: true,
})
})
it('notifies every tools/result observer with the frozen final outcome and contains failures', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('t'))
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger)
const seen: boolean[] = []
const dispatchModes: string[] = []
ctx.on('internal/dispatch', (mode, name) => {
if (name === 'tools/result') dispatchModes.push(mode)
})
ctx.on('tools/execute', async (exec, next) => {
await next()
return {
callId: exec.callId,
content: [{ type: 'text', text: 'outer failure' }],
isError: true,
}
}, { prepend: true })
scope.ctx.on('tools/result', (_exec, result) => {
expect(Object.isFrozen(_exec)).toBe(true)
expect(Object.isFrozen(_exec.arguments)).toBe(true)
expect(Object.isFrozen(result)).toBe(true)
expect(Object.isFrozen(result.content)).toBe(true)
seen.push(result.isError)
})
ctx.on('tools/result', () => {
throw { toString: () => { throw new Error('coercion trap') } }
})
ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) })
const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key })
expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] })
expect(seen).toEqual([true, true])
expect(dispatchModes).toEqual(['parallel'])
expect(warn).toHaveBeenCalledOnce()
expect(String(warn.mock.calls[0]?.[0])).toContain('<unprintable thrown value>')
})
})
+276 -8
View File
@@ -5,7 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult,
type ToolExecution, type ToolExecutionResult, type ToolGuard,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -113,6 +113,53 @@ describe('ToolRegistry', () => {
expect('meta' in result).toBe(false)
})
it('normalizes a contract-violating non-cloneable result before final notification', async () => {
const ctx = await setup()
let observedError: boolean | undefined
ctx.on('tools/result', (_exec, result) => { observedError = result.isError })
ctx.tools.register({
...echoTool,
name: 'bad-meta',
async execute() {
return { content: [], meta: () => undefined }
},
})
const result = await ctx.tools.execute({
callId: CallId('bad-meta'), name: 'bad-meta', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]?.type === 'text' && result.content[0].text).toContain('Error:')
expect(observedError).toBe(true)
})
it('normalizes a result that changes to non-JSON data while being snapshotted', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let reads = 0
const hostileBlock = Object.defineProperty({ type: 'text' }, 'text', {
enumerable: true,
get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]),
})
ctx.on('tools/execute', async exec => ({
callId: exec.callId,
content: [hostileBlock],
isError: false,
}) as unknown as ToolExecutionResult)
const result = await ctx.tools.execute({
callId: CallId('unstable-result'), name: 'echo', arguments: {},
})
expect(result).toEqual({
callId: CallId('unstable-result'),
content: [{
type: 'text', text: 'Error: tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult',
}],
isError: true,
})
})
it('returns isError results for unknown tools and throwing tools', async () => {
const ctx = await setup()
ctx.tools.register({
@@ -134,6 +181,28 @@ describe('ToolRegistry', () => {
expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' })
})
it('normalizes a hostile thrown value whose inspection and coercion both throw', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'hostile-throw',
async execute() {
throw new Proxy({}, {
getPrototypeOf: () => { throw new Error('prototype trap') },
has: () => { throw new Error('has trap') },
get: () => { throw new Error('get trap') },
})
},
})
await expect(ctx.tools.execute({
callId: CallId('hostile'), name: 'hostile-throw', arguments: {},
})).resolves.toMatchObject({
isError: true,
content: [{ type: 'text', text: 'Error: <unprintable thrown value>' }],
})
})
it('ToolNotFoundError carries the tool name and a stable code', async () => {
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
const err = new ToolNotFoundError('ghost')
@@ -158,6 +227,25 @@ describe('ToolRegistry', () => {
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
})
it('rejects a JavaScript guard that returns an async/non-string decision', async () => {
const ctx = await setup()
let bodyCalls = 0
ctx.tools.register({
...echoTool,
async execute() {
bodyCalls += 1
return []
},
})
ctx.tools.guard((() => Promise.resolve('late denial')) as unknown as ToolGuard)
const result = await ctx.tools.execute({ callId: CallId('bad-guard'), name: 'echo', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]?.type === 'text' && result.content[0].text)
.toContain('tools.guard() must return')
expect(bodyCalls).toBe(0)
})
it('an ask decision degrades to deny until the permission system lands', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -233,7 +321,7 @@ describe('ToolRegistry', () => {
expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
})
it('a post-execute listener mutating the result object cannot corrupt callId/isError/error', async () => {
it('a post-execute listener cannot mutate any nested part of the dispatched result', async () => {
// The decision is the ONLY sanctioned channel to change the outcome. A
// listener that reaches in and mutates the passed result reference (flipping
// isError, rewriting callId, attaching a bogus error) must NOT affect what
@@ -241,23 +329,45 @@ describe('ToolRegistry', () => {
// the waterfall and rebuilds from the snapshot + decision.
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async (_exec, next) => {
await next()
return {
callId: CallId('c1'),
content: [{ type: 'text', text: 'original' }],
isError: true,
error: { name: 'OriginalError', code: 'ORIGINAL' },
meta: { nested: { label: 'original' } },
}
})
ctx.on('tools/post-execute', async (_exec, result, next) => {
const mutable = result as { callId: string; isError: boolean; error?: unknown; content: unknown[] }
const mutable = result as {
callId: string
isError: boolean
error?: { name: string; code: string }
content: { type: 'text'; text: string }[]
meta?: { nested: { label: string } }
}
mutable.callId = 'hijacked'
mutable.isError = true
mutable.error = { name: 'Evil', code: 'EVIL' }
mutable.isError = false
if (mutable.error) {
mutable.error.name = 'Evil'
mutable.error.code = 'EVIL'
}
mutable.content[0]!.text = 'MUTATED'
mutable.content.push({ type: 'text', text: 'INJECTED' }) // in-place array mutation
if (mutable.meta) mutable.meta.nested.label = 'MUTATED'
return next() // delegate to the default accept — no decision-level override
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked'
expect(result.isError).toBe(false) // the real (successful) dispatch outcome
expect(result.error).toBeUndefined() // no listener-injected error
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'OriginalError', code: 'ORIGINAL' })
expect(result.content).toHaveLength(1) // the in-place push did not leak in
expect(result.content[0]).toMatchObject({ text: 'hi' })
expect(result.content[0]).toMatchObject({ text: 'original' })
expect(result.content.some(b => (b as { text?: string }).text === 'INJECTED')).toBe(false)
expect(result.meta).toEqual({ nested: { label: 'original' } })
})
it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
@@ -416,6 +526,109 @@ describe('ToolRegistry', () => {
expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
})
it('preserves additionalContext supplied by an around-dispatch result', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async exec => ({
callId: exec.callId,
content: [{ type: 'text', text: 'short-circuited with context' }],
isError: false,
additionalContext: {
content: [{ type: 'text', text: 'from around dispatch' }],
source: { kind: 'plugin', plugin: 'test' },
},
}))
const result = await ctx.tools.execute({
callId: CallId('around-context'), name: 'echo', arguments: {},
})
expect(result.additionalContext).toEqual({
content: [{ type: 'text', text: 'from around dispatch' }],
source: { kind: 'plugin', plugin: 'test' },
})
})
it('normalizes malformed tools/execute results instead of treating them as success', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let observedError: boolean | undefined
ctx.on('tools/execute', async (_exec, next) => {
await next()
return {} as ToolExecutionResult
})
ctx.on('tools/result', (_exec, result) => { observedError = result.isError })
const result = await ctx.tools.execute({ callId: CallId('malformed'), name: 'echo', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({
text: 'Error: tools/execute must return a ToolExecutionResult with content[] and boolean isError',
})
expect(observedError).toBe(true)
})
it.each([
{
name: 'non-object result',
replacement: null,
message: 'tools/execute must return a ToolExecutionResult object',
},
{
name: 'wrong call id',
replacement: { callId: CallId('other'), content: [], isError: false },
message: 'tools/execute returned callId "other" for authoritative call "malformed-shape"',
},
])('normalizes a tools/execute $name', async ({ replacement, message }) => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => replacement as ToolExecutionResult)
const result = await ctx.tools.execute({
callId: CallId('malformed-shape'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: `Error: ${message}` })
})
it('normalizes malformed tools/post-execute decisions', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => ({ kind: 'accept', content: 'not blocks' }) as unknown as PostToolDecision)
const result = await ctx.tools.execute({ callId: CallId('malformed-post'), name: 'echo', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({
text: 'Error: tools/post-execute accept content must be an array',
})
})
it.each([
{
name: 'non-object decision',
replacement: null,
message: 'tools/post-execute must return a PostToolDecision object',
},
{
name: 'block without feedback blocks',
replacement: { kind: 'block', feedback: 'not blocks' },
message: 'tools/post-execute block feedback must be an array',
},
{
name: 'unknown decision kind',
replacement: { kind: 'defer' },
message: 'tools/post-execute must return an accept or block decision',
},
])('normalizes a tools/post-execute $name', async ({ replacement, message }) => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => replacement as unknown as PostToolDecision)
const result = await ctx.tools.execute({
callId: CallId('malformed-post-shape'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: `Error: ${message}` })
})
it('returns an isError result when a tools/execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -493,6 +706,61 @@ describe('ToolRegistry', () => {
}])
})
it.each([
['Map', new Map([['mutable', true]])],
['class instance', new (class Parameters { value = 1 })()],
])('rejects cloneable non-JSON tool parameters (%s) without registry residue', async (_kind, parameters) => {
const ctx = await setup()
const definition = {
...echoTool,
name: 'invalid-parameters',
parameters,
} as unknown as typeof echoTool
expect(() => ctx.tools.register(definition)).toThrow(
'tool parameters must be losslessly JSON-serializable',
)
expect(ctx.tools.get('invalid-parameters')).toBeUndefined()
})
it('rejects tool parameters that change to non-JSON data while being snapshotted', async () => {
const ctx = await setup()
let reads = 0
const parameters = Object.defineProperty({}, 'properties', {
enumerable: true,
get: () => ++reads === 1 ? {} : new Map([['mutable', true]]),
})
expect(() => ctx.tools.register({
...echoTool,
name: 'unstable-parameters',
parameters,
})).toThrow('tool parameters must be stable losslessly JSON-serializable data')
expect(ctx.tools.get('unstable-parameters')).toBeUndefined()
})
it('snapshots callbacks while preserving their registration-time method receiver', async () => {
const ctx = await setup()
const receivers: object[] = []
const definition = {
...echoTool,
name: 'callback-snapshot',
async execute() {
receivers.push(this)
return [{ type: 'text' as const, text: 'original' }]
},
}
ctx.tools.register(definition)
definition.execute = async () => [{ type: 'text' as const, text: 'replacement' }]
const result = await ctx.tools.execute({
callId: CallId('callback-snapshot'), name: definition.name, arguments: {},
})
expect(receivers).toEqual([definition])
expect(result.content).toEqual([{ type: 'text', text: 'original' }])
})
it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
+1 -1
View File
@@ -64,7 +64,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-session-'))
try {
ctx = await fsHarness(configDir, SYSTEM)
const handle = ctx.agents.create({
const handle = await ctx.agents.create({
agentId: AgentId('fs-e2e-cwd'),
sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`),
meta: { cwd: sessionDir },
+8 -2
View File
@@ -164,11 +164,10 @@ describe('read tool', () => {
expect(text(result)).toContain('offset must be a positive integer')
})
it('rejects a fractional or NaN offset, and a zero/negative limit', async () => {
it('rejects a fractional offset and a zero/negative limit', async () => {
const { ctx } = await setup()
for (const args of [
{ file_path: 'a.txt', offset: 1.5 },
{ file_path: 'a.txt', offset: Number.NaN },
{ file_path: 'a.txt', limit: 0 },
{ file_path: 'a.txt', limit: -3 },
]) {
@@ -178,6 +177,13 @@ describe('read tool', () => {
}
})
it('rejects a non-JSON numeric offset before tool-specific validation', async () => {
const { ctx } = await setup()
const result = await call(ctx, 'read', { file_path: 'a.txt', offset: Number.NaN })
expect(result.isError).toBe(true)
expect(text(result)).toContain('tool execution arguments must be losslessly JSON-serializable')
})
it('rejects a limit above the cap', async () => {
const { ctx } = await setup()
const result = await call(ctx, 'read', { file_path: 'a.txt', limit: 99999 })
@@ -448,7 +448,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
const ctx = await harness(path, adapter) // NB: no projectDir
// The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } })
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message'
@@ -613,7 +613,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
@@ -649,7 +649,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server
// Register a live child on its own session cwd; emit subagent/end with its id.
const { SessionId } = await import('@deepseek-ai/dsh-session')
const childHandle = ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } })
const childHandle = await ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } })
ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' })
await waitFor(() => existsSync(marker))
@@ -553,7 +553,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
ctx.llm.registerAdapter(['mock'], adapter)
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
expect(existsSync(marker)).toBe(true)
+1 -2
View File
@@ -32,7 +32,7 @@ export const name = 'subagent-fork'
// per-run structured runtime gates its capture-tool registration on `tools`
// itself, so this backend's apply timing (and the delegation tool's position
// in the model-visible tool list) is unchanged by structured output.
export const inject = ['subagents', 'agents']
export const inject = ['subagents']
/** Config: the registry name to register the provider under. */
export interface Config {
@@ -77,7 +77,6 @@ class ForkProvider implements SubagentProvider {
start(request: SubagentStartRequest) {
const seed = completedTurnPrefix(request.parent)
return startInProcessRun(this.ctx, request, {
providerName: this.name,
// Only pass a seed when there's a completed turn to inherit; an empty seed
// is equivalent to a fresh child, so omit it to keep the session unseeded.
...seed.length > 0 ? { seed } : {},
@@ -200,12 +200,12 @@ describe('dsh-subagent-fork', () => {
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in fork).toBe(false)
expect(fork.name).toBe('subagent-fork')
expect(fork.inject).toEqual(['subagents', 'agents'])
expect(fork.inject).toEqual(['subagents'])
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(fork) as Record<string, unknown>
expect(unwrapped).toBe(fork)
expect(unwrapped.name).toBe('subagent-fork')
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
expect(unwrapped.inject).toEqual(['subagents'])
expect(typeof unwrapped.apply).toBe('function')
})
})
+153 -74
View File
@@ -7,16 +7,18 @@
* a prefix of the parent's log); everything downstream — drive the child, read
* its final output, map the stop reason, dispose — is identical and lives here.
*
* This package owns no provider and registers nothing; it is a pure library the
* backend packages depend on, so neither backend needs to know about the other.
* This package declares no provider and performs no import-time registration;
* it is a library the backend packages depend on, so neither backend needs to
* know about the other. Each accepted run does install one provider-owned
* effect for structured-concurrency cleanup.
*
* @module @deepseek-ai/dsh-subagent-inprocess
*/
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import type { Context, Fiber } from 'cordis'
import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import { SessionId, isJsonValue, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
@@ -86,8 +88,6 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
/** Extra inputs the spawn/fork backends supply to {@link startInProcessRun}. */
export interface InProcessRunOptions {
/** The provider name (`spawn`/`fork`), for error context only. */
readonly providerName: string
/**
* The child session's seed: a balanced, contiguous-from-0 prefix of the
* parent's log (FORK), or `undefined` for a fresh child (SPAWN).
@@ -95,6 +95,12 @@ export interface InProcessRunOptions {
readonly seed?: SessionEvent[]
}
/** Dispose a run-owner fiber and follow an already-started unload to quiescence. */
async function quiesceFiber(fiber: Fiber): Promise<void> {
await Promise.resolve(fiber.dispose())
while (fiber.inertia !== undefined) await fiber.inertia
}
/**
* Start an in-process child agent for `request` and return a {@link SubagentRun}.
*
@@ -108,9 +114,10 @@ export interface InProcessRunOptions {
*
* Throws {@link SubagentDepthError} before creating anything when the child's
* depth (parent depth + 1) would exceed `request.maxDepth`.
* @param ctx - the context whose `agents` factory creates and owns the child.
* @param ctx - the provider context that owns the live run as a second
* structured-concurrency boundary alongside the parent agent.
* @param request - the start request (prompt, parent, signal, per-child options).
* @param options - the backend's inputs: provider name plus the optional seed.
* @param options - the backend's optional child-session seed.
* @returns the live run handle for the child agent.
*/
export function startInProcessRun(
@@ -118,7 +125,15 @@ export function startInProcessRun(
request: SubagentStartRequest,
options: InProcessRunOptions,
): SubagentRun {
const childDepth = depthOf(request.parent) + 1
// Snapshot the accepted request synchronously. The parent and signal are
// identity capabilities (kept live but never reread from the mutable request
// record); every data field is detached before asynchronous owner setup.
const parent = request.parent
const signal = request.signal
const persona = request.persona
const toolFilter = request.toolFilter === undefined ? undefined : structuredClone(request.toolFilter)
const seed = options.seed === undefined ? undefined : structuredClone(options.seed)
const childDepth = depthOf(parent) + 1
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
throw new SubagentDepthError(childDepth, request.maxDepth)
}
@@ -134,6 +149,17 @@ export function startInProcessRun(
// validateStructuredValue to one isolation-immutable value.
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema)
// The accepted request owns a value snapshot, not the caller's mutable
// content array. Validate the same lossless-JSON contract Session.append
// enforces before any child exists, then detach it synchronously so mutation
// during async creation cannot change what is logged or sent to the model.
if (!isJsonValue(request.prompt)) {
throw new TypeError('subagent prompt must be losslessly JSON-serializable')
}
const prompt = structuredClone(request.prompt)
if (!isJsonValue(prompt)) {
throw new TypeError('subagent prompt must be stable losslessly JSON-serializable data')
}
const childId = AgentId(randomUUID())
// The child's OWN events begin after the seed (fork seeds the parent's
@@ -141,76 +167,43 @@ export function startInProcessRun(
// boundary so a child that produces no message of its own never returns the
// SEEDED parent's last assistant message as its result.
const seedLength = options.seed?.length ?? 0
const parentHeader = request.parent.session.header
const parentHeader = parent.session.header
// Inherit the parent's model by default (a child with no model cannot run);
// an explicit `request.agentOptions.model` overrides it. The deployment
// persona needs no inheritance (a context-wide section both render); a
// per-child `request.persona` becomes a SCOPED section of the same name in
// the setup below, shadowing the deployment's for this child alone.
const agentOptions: AgentOptions = {
...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {},
const agentOptions: AgentOptions = structuredClone({
...parent.options.model !== undefined ? { model: parent.options.model } : {},
...request.agentOptions,
subagentDepth: childDepth,
}
})
// The child's scoped world, composed in the factory's setup window (after
// the child's scope exists and it is registered, before agent/session-start
// and the first prompt assembly; a throw here unwinds the half-created
// child inside the factory's rollback boundary):
// The child's scoped world, composed in the factory's unpublished setup
// window. The factory awaits it before inserting or announcing the child, so
// a throw/rejection exposes neither id and every first assembly sees it:
// - persona: a scoped `deployment:persona` section shadowing the global one;
// - toolFilter: a scoped restrict() masking the global tool surface
// (loud unknown-name validation lives in the registry);
// - outputSchema: the structured runtime, attached as scoped registrations.
let structured: StructuredAttachment | undefined
const setup = (childCtx: Context): void => {
if (request.persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona })
if (persona !== undefined) {
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: persona })
}
if (request.toolFilter !== undefined) {
childCtx.tools.restrict(request.toolFilter)
if (toolFilter !== undefined) {
childCtx.tools.restrict(toolFilter)
}
if (schema !== undefined) {
structured = attachStructuredRuntime(childCtx, schema)
}
}
const handle: AgentHandle = ctx.agents.create({
agentId: childId,
sessionId: SessionId(randomUUID()),
meta: {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
// Record the seed boundary so a reload (and a replay harness) can tell the
// inherited prefix from the child's OWN events. 0 for a fresh spawn.
...seedLength > 0 ? { seedLength } : {},
},
...options.seed !== undefined ? { seed: options.seed } : {},
agentOptions,
setup,
})
const child = handle.agent
// Structured-concurrency link: the child's teardown rides the PARENT's
// scope, so a disposed parent reaches its whole subtree even if the
// delegating tool's `finally` never runs — through the MEMOIZED handle, so
// every path (tool finally, parent teardown, owner unload) observes the
// same quiescence boundary. Registered AFTER the child exists; if the
// parent began disposing in between, the registration throws
// INACTIVE_EFFECT — dispose the fresh child before rethrowing (no orphan).
// Definite assignment: the catch rethrows, so past this block the unlink
// disposer always exists.
let unlink!: () => Promise<void> | void
try {
unlink = request.parent.ctx.effect(() => () => handle.dispose())
} catch (error: unknown) {
// Fire-and-forget: start() must rethrow synchronously; the child's
// teardown (stop → unregister → detach) reaches quiescence on its own.
void handle.dispose()
throw error
}
// Bridge the request's abort signal to the child (the consumer also bridges
// its own exec.signal, but a backend-level bridge keeps the contract local).
// Install it after provider ownership succeeds but BEFORE awaiting creation,
// so an inactive provider cannot leave an orphaned listener and abort/dispose
// during async setup is still recorded and applied the moment a child exists.
// `cancelled` records that a cancel was requested at all, so the pre-turn
// cancel window — where the child clears the queued prompt before any
// `turn/end` is logged — settles as `aborted` (honoring the cancel contract)
@@ -220,31 +213,104 @@ export function startInProcessRun(
// abort listener, run.cancel), which control-flow narrowing cannot see — an
// inline read at the result mapping would narrow to the initializer.
const isCancelled = (): boolean => cancelled
let child: Agent | undefined
let handle: AgentHandle | undefined
let disposeRequested = false
const isDisposeRequested = (): boolean => disposeRequested
const requestCancel = (reason: string): void => {
cancelled = true
child.cancel(reason)
child?.cancel(reason)
}
const onAbort = (): void => { requestCancel('subagent cancelled') }
request.signal?.addEventListener('abort', onAbort, { once: true })
// One run-owned Cordis fiber is the common ownership node. Install the
// provider effect FIRST: a start racing an already-unloading provider fails
// before it can mint anything under the parent. The owner fiber is then
// nested under the parent scope, and the provider/run handle both dispose
// this exact fiber. AgentFactory binds its lifecycle to `ownerCtx`, so any of
// the three owners moves the fiber out of ACTIVE synchronously and setup
// cannot publish afterward.
let ownerCtx: Context | undefined
function subagentRunOwner(inner: Context): void { ownerCtx = inner }
let ownerFiber: (Fiber & PromiseLike<Fiber>) | undefined
let ownerSetupError: unknown
let ownerDisposing: Promise<void> | undefined
const disposeOwner = (): Promise<void> => (ownerDisposing ??= ownerFiber === undefined
? Promise.resolve()
: quiesceFiber(ownerFiber))
let manualDisposeRequested = false
const isManualDisposeRequested = (): boolean => manualDisposeRequested
const unlinkProvider = ctx.effect(() => () => {
requestCancel('subagent provider disposed')
return disposeOwner()
}, 'subagent-inprocess.run()')
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) requestCancel('subagent cancelled')
try {
ownerFiber = parent.ctx.plugin(Object.assign(subagentRunOwner, {
inject: ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'],
}))
} catch (error: unknown) {
ownerSetupError = error
}
const creation: Promise<Agent> = (async () => {
if (ownerSetupError !== undefined) {
throw ownerSetupError instanceof Error
? ownerSetupError
: new Error('subagent run owner setup failed with a non-Error value', { cause: ownerSetupError })
}
await ownerFiber
if (ownerCtx === undefined) {
throw new Error('subagent run owner became inactive before child creation')
}
// Invoke the factory THROUGH the parent scope. Cordis binds the factory's
// lifecycle effect to the accessing context, so parent ownership exists
// before persistence/setup and publication—not as a fallible link added
// after the child is already visible. A disposed parent therefore rejects
// before any session/agent notification, and disposal during async setup
// wins the unpublished transaction.
const created = await ownerCtx.agents.create({
agentId: childId,
sessionId: SessionId(randomUUID()),
meta: {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
...seedLength > 0 ? { seedLength } : {},
},
...seed !== undefined ? { seed } : {},
agentOptions,
setup,
})
handle = created
child = created.agent
if (isCancelled()) created.agent.cancel('subagent cancelled')
return created.agent
})()
const result: Promise<SubagentResult> = (async () => {
try {
// A signal already aborted BEFORE the run starts never fires an `abort`
// event (`addEventListener` only fires on the transition), so the listener
// above won't catch it — settle `aborted` without running the child rather
// than completing an already-cancelled request.
if (request.signal?.aborted) return { output: [], stopReason: 'aborted' }
child.send(request.prompt)
await child.whenIdle()
let liveChild: Agent
try {
liveChild = await creation
} catch (error: unknown) {
if (isManualDisposeRequested()) return { output: [], stopReason: 'aborted' }
throw error instanceof Error ? error : new Error('subagent child creation failed with a non-Error value', { cause: error })
}
if (isCancelled() || isDisposeRequested()) return { output: [], stopReason: 'aborted' }
liveChild.send(prompt)
await liveChild.whenIdle()
// Deliberately NO re-prompt when a structured child finishes cleanly
// without calling structured_output: readResult maps that to `error` —
// the shortfall goes to the parent instead of buying extra model turns.
return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined)
return readResult(liveChild, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined)
} finally {
request.signal?.removeEventListener('abort', onAbort)
signal?.removeEventListener('abort', onAbort)
}
})()
let disposing: Promise<void> | undefined
return {
id: childId,
result,
@@ -252,13 +318,26 @@ export function startInProcessRun(
requestCancel(reason ?? 'subagent cancelled')
},
async dispose(): Promise<void> {
request.signal?.removeEventListener('abort', onAbort)
// Through the parent-scope unlink when the parent is still live (one
// disposal path, and the dead effect leaves the parent's list); the
// memoized handle keeps a direct dispose equivalent if the parent's
// teardown already ran the unlink.
await unlink()
await handle.dispose()
return (disposing ??= (async () => {
signal?.removeEventListener('abort', onAbort)
disposeRequested = true
manualDisposeRequested = true
requestCancel('subagent disposed during creation')
// Removing provider ownership and disposing the common run-owner fiber
// are the same quiescence transaction; parent disposal may already have
// claimed it, in which case disposeOwner follows fiber inertia.
await unlinkProvider()
try {
await creation
} catch {
// Creation rollback already reached quiescence; there is no handle
// left to dispose, and dispose must not mask result's infrastructure
// rejection with the same error from a finally block.
return
}
await disposeOwner()
await handle?.dispose()
})())
},
}
}
@@ -14,44 +14,39 @@
* a disposed child leaves no residue — no placeholder schema,
* strip-for-everyone-else pass, or refcounted global runtime.
*
* Four listeners enforce the contract:
* The child scope's registrations enforce the contract:
*
* - `system-prompt/assemble` (prepend, scoped): assembly re-assert — the
* listener post-processes its downstream chain so a listener inside that
* chain cannot leave the child's capture tool or instruction stripped or
* replaced. Tools are replaced in place and the section is re-inserted at
* its ascending-order position, so the untampered path keeps the registry's
* ordering (up to intra-band section order, which carries no contract). A
* listener prepended later can still wrap and transform this result; this is
* an ordinary waterfall listener, not a service-level finalizer. The loop
* logs the rendered assembly as the request header, so the demand is
* reconstructable log state, never a wire-only mutation.
* - `agent/turn-continuation` (prepend, scoped): stop the child's turn once
* its output is captured — the loop's default "had tool calls ⇒ continue"
* would buy a wasted extra model step per structured child.
* - `tools/pre-execute` (prepend, scoped): terminal means terminal WITHIN the
* step — deny every call arriving after the capture, so a response that
* lists `structured_output` before further tool calls cannot run side
* effects after the final answer was accepted.
* - `tools/post-execute` (prepend, scoped): the capture COMMIT. The tool body
* only STAGES the validated value, KEYED BY THE EXECUTION OBJECT in a
* WeakMap; it becomes the run's captured result when this listener's
* downstream post-execute decision accepts THAT SAME pipeline trip. A
* later-prepended wrapper remains outside that decision. Execution-keyed
* staging makes the stale-stage class structurally impossible: a value
* orphaned by an outer short-circuiting listener (a post-execute block, or
* a pre-execute deny whose call never dispatched) can never match another
* execution's lookup — whatever call id that execution carries — and is
* reclaimed with the execution object itself.
* - `systemPrompt.protect()` declaratively protects the capture tool and its
* instruction. The service restores their canonical pre-waterfall state
* after EVERY assembly listener. Canonical absence is protected too: pure
* Code Mode keeps `structured_output` in the SDK only and never grows a
* second native wire tool. Code Mode's owner independently protects its SDK
* and `run_code` transport. The loop logs the finalized assembly as the
* request header, so the demand is reconstructable log state, never a
* wire-only mutation.
* - `agent/turn-stop` (serial, scoped): stop the child's turn once its output
* is captured. This terminal checkpoint runs after the ordinary continuation
* waterfall and steering folding, so listener order cannot resurrect a
* completed structured run or carry terminal steering into another turn.
* - `tools.guard()` is the monotonic terminal gate after the extensible
* pre-execute waterfall: once capture commits, no later listener can turn
* the denial back into a dispatched side effect.
* - `tools/result` is the capture COMMIT point. The tool body only STAGES the
* validated value in a WeakMap keyed by the execution object; the awaited,
* non-transforming notification promotes it only when the authoritative
* result after the whole pre/execute/post pipeline succeeds. For a Code Mode
* sub-dispatch, promotion waits again for the enclosing `run_code` result, so
* a runtime failure or outer post-policy block cannot report structured
* success. Execution identity makes call-id reuse and orphaned stages
* irrelevant.
*
* @module @deepseek-ai/dsh-subagent-inprocess/structured
*/
import type { Context } from 'cordis'
import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
import type { ContinuationStop } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
/** The model-facing tool name a structured child must call to finish. */
@@ -71,7 +66,7 @@ export const STRUCTURED_OUTPUT_INSTRUCTION
export interface StructuredAttachment {
/**
* The captured value, once the child called the tool with valid arguments
* and the final post-execute decision accepted that call.
* and the authoritative final tool result accepted that call.
* @returns the committed value, or undefined while none was accepted.
*/
captured(): { value: unknown } | undefined
@@ -80,7 +75,7 @@ export interface StructuredAttachment {
/**
* Attach the structured-output runtime to a child for `schema`: register the
* scoped capture tool (real schema), the scoped instruction section, and the
* four scoped enforcement listeners (see the module doc). Call from the
* scoped enforcement registrations (see the module doc). Call from the
* agent-creation `setup` window with the child's scope context — every
* registration rides the child's fiber and unwinds with the child.
* @param childCtx - the child agent's scope context (`setup`'s argument).
@@ -91,19 +86,16 @@ export interface StructuredAttachment {
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
/**
* Validated values staged by the capture tool body, awaiting THEIR OWN
* call's post-execute verdict — keyed by the {@link ToolExecution} OBJECT,
* the one token that provably ties a stage to one trip through the
* pipeline. A call id cannot key this: ids are adapter-minted and may
* repeat across steps. Keying by execution makes the stale-stage class
* structurally impossible — an entry orphaned by an outer short-circuiting
* listener can never match a different execution's lookup, needs no drop
* bookkeeping (the WeakMap reclaims it with the execution object), and two
* in-flight captures can never cross-clobber each other's STAGE should
* tool execution ever go parallel (the loop's documented TODO). Staging is
* the only layer this future-proofs: a parallel-execution cut would still
* owe its own single-accept rule for `captured` itself.
* authoritative `tools/result` notification. The execution object's identity
* uniquely identifies a trip through the pipeline: adapter call ids may
* repeat across steps, but another execution can never reach this WeakMap
* entry. This is distinct from the opaque `ToolExecutionToken` used to
* correlate nested transports. The final notification always deletes its own
* stage, whether the result succeeded or failed.
*/
const staged = new WeakMap<ToolExecution, { value: unknown }>()
/** Successful nested capture waiting for its enclosing transport to commit. */
let pending: { parent: ToolExecution['token']; value: unknown } | undefined
let captured: { value: unknown } | undefined
const schemaEntry: ToolSchema = {
@@ -123,10 +115,10 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
// ToolArgsError → isError result with INVALID_ARGS: the model retries
// within the same turn, exactly like a schema-validated defineTool call.
if (violations.length > 0) throw new ToolArgsError(violations)
// Two-phase commit, KEYED BY THIS EXECUTION: the body only stages; the
// post-execute listener promotes exactly this pipeline trip's entry
// when its downstream decision accepts it.
staged.set(exec, { value: args })
// Two-phase commit, keyed by THIS execution: later transformable
// waterfalls may still turn the success into an error. Snapshot the
// validated value independently of the already-frozen pipeline arguments.
staged.set(exec, { value: structuredClone(args) })
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
},
})
@@ -137,105 +129,58 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
text: STRUCTURED_OUTPUT_INSTRUCTION,
})
// PREPENDED assembly re-assert: scoped dispatch means this fires only for the
// child's assemblies; `await next()` returns whatever this listener's
// downstream chain produced, and the capture tool + instruction are
// re-asserted onto it if anything stripped them. A listener prepended later
// can still wrap and transform the returned assembly; this is not a
// service-level finalizer.
childCtx.on('system-prompt/assemble', async function (
this: unknown, _assembly: PromptAssembly, _context: AssembleContext, next: () => Promise<PromptAssembly>,
): Promise<PromptAssembly> {
const final = await next()
// REPLACE, not merely ensure-present: a downstream listener may have
// mutated or injected a same-named entry with the WRONG schema/text, and
// the model-visible demand must be exactly this run's own — the same
// schema validateStructuredValue enforces. Placement-preserving on both
// arrays: the untampered path keeps the registry's ordering (tool order
// is the `toolOrder`/lexicographic contract, section order the ascending
// contract `renderPrompt` trusts), so this never reorders what it only
// re-asserts — up to intra-band section order, which carries no contract
// (a 190-order section registered AFTER this runtime sorts before the
// instruction in the registry but after it here).
const freshTool: ToolSchema = { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) }
// Tools: replace the first same-named entry IN PLACE (its position is the
// chain's product; a tool's list position carries no semantic band to
// restore), drop any duplicates, append only when stripped entirely.
const tools: ToolSchema[] = []
let toolReplaced = false
for (const tool of final.tools) {
if (tool.name !== STRUCTURED_OUTPUT_TOOL) {
tools.push(tool)
} else if (!toolReplaced) {
tools.push(freshTool)
toolReplaced = true
// Service-owned finalization, not waterfall ordering. The canonical
// assembly determines both presence and absence: native/both modes restore
// the capture schema on the wire, while pure Code Mode removes any injected
// native entry. ToolRegistry's own protection independently restores the SDK
// section and run_code transport that carry the same schema.
childCtx.systemPrompt.protect({
sections: [`tool:${STRUCTURED_OUTPUT_TOOL}`],
tools: [STRUCTURED_OUTPUT_TOOL],
})
// Stop the child's turn once its output is captured. This monotonic serial
// checkpoint runs after the ordinary continuation waterfall, its reason,
// and late-steering folding, so no ordering trick can resume a finished run.
childCtx.on('agent/turn-stop', function (this: unknown): ContinuationStop | undefined {
return captured === undefined ? undefined : { action: 'stop' }
})
// Terminal WITHIN the step. Guards run after the whole pre-execute
// waterfall and compose monotonically (deny or abstain, never allow), so a
// later prepended listener cannot resurrect dispatch. Calls that precede
// capture in the same response remain untouched.
childCtx.tools.guard(exec => captured === undefined && pending === undefined
? undefined
: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`)
// The capture COMMIT observes the immutable, authoritative result after the
// complete pipeline and outer error normalization. This notification cannot
// transform the outcome, so there is no wrapper outside the commit verdict.
childCtx.on('tools/result', function (this: unknown, exec, result): void {
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
const entry = staged.get(exec)
if (entry === undefined) return
staged.delete(exec)
if (result.isError) return
if (exec.parent === undefined) {
/* v8 ignore else -- sequential agent-loop dispatch lets the guard block every later supported call */
if (captured === undefined) captured = { value: entry.value }
} else {
/* v8 ignore else -- Code Mode serializes sub-dispatches, so the guard blocks every later supported call */
if (captured === undefined && pending === undefined) {
pending = { parent: exec.parent, value: entry.value }
}
}
return
}
if (!toolReplaced) tools.push(freshTool)
final.tools = tools
// Sections: remove every same-named entry and re-insert at the
// ascending-correct position (the first entry above order 190) — sections
// DO carry an order contract, and the renderer reads array order, so a
// stripped-or-moved instruction is restored to its band, not appended
// after unrelated higher-order sections. On the untampered path this
// lands at the end of the 190 band — where the registry's stable sort
// put it too, unless another 190-order section registered later.
const sectionName = `tool:${STRUCTURED_OUTPUT_TOOL}`
const sections = final.sections.filter(section => section.name !== sectionName)
const insertAt = sections.findIndex(section => section.order > 190)
sections.splice(insertAt === -1 ? sections.length : insertAt, 0, { name: sectionName, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION })
final.sections = sections
return final
}, { prepend: true })
// Stop the child's turn once its output is captured. `prepend: true` puts
// the veto OUTERMOST — an earlier-registered listener that short-circuits
// the chain (a goal-style force-continue returning without `next()`) would
// otherwise decide the turn before this listener ever ran, and no
// downstream decision may resurrect a structured turn that is finished.
childCtx.on('agent/turn-continuation', function (
this: unknown, _agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
): Promise<ContinuationDecision> {
if (captured) return Promise.resolve({ action: 'stop' })
return next()
}, { prepend: true })
// Terminal WITHIN the step: deny every call after the capture. Calls that
// PRECEDE the capture in the same response ran before `captured` was set
// and are untouched; a second `structured_output` is denied like any other.
childCtx.on('tools/pre-execute', function (
this: unknown, exec: ToolExecution, next: () => Promise<PreToolDecision>,
): Promise<PreToolDecision> {
if (captured) {
return Promise.resolve({
kind: 'deny',
reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`,
})
}
return next()
}, { prepend: true })
// The capture COMMIT: promote a staged value only when the final
// post-execute decision accepts THE SAME EXECUTION that staged it — the
// lookup key IS the execution, so a stale entry from a different pipeline
// trip (its own chain short-circuited past this commit by an outer
// post-execute block, or an outer pre-execute deny whose call never
// dispatched) is unreachable here by construction, whatever the current
// call's id.
childCtx.on('tools/post-execute', async function (
this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise<PostToolDecision>,
): Promise<PostToolDecision> {
if (exec.name !== STRUCTURED_OUTPUT_TOOL) return next()
const entry = staged.get(exec)
if (entry === undefined) return next()
// Single-shot per execution: this trip's verdict is decided by the chain
// below, never revisited (the WeakMap would reclaim the entry either way;
// deleting states the intent).
staged.delete(exec)
const decision = await next()
if (decision.kind === 'accept') captured = { value: entry.value }
return decision
}, { prepend: true })
if (pending?.parent !== exec.token) return
const entry = pending
pending = undefined
if (result.isError) return
/* v8 ignore else -- Code Mode serializes outer executions, so the guard blocks every later supported call */
if (captured === undefined) captured = { value: entry.value }
})
return { captured: () => captured }
}
@@ -9,7 +9,8 @@ import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import type { Config as ToolConfig, StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
import {
@@ -19,6 +20,15 @@ import {
type Script = ConstructorParameters<typeof MockAdapter>[0]
interface CodeRunRequestLike {
bindings: { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }[]
}
interface SetupOptions {
toolMode?: ToolConfig['mode']
codeRun?: (request: CodeRunRequestLike) => Promise<{ logs: never[]; value?: unknown }>
}
const SCHEMA: StructuredOutputSchema = {
type: 'object',
properties: { answer: { type: 'number' }, note: { type: 'string' } },
@@ -33,13 +43,20 @@ const SCHEMA: StructuredOutputSchema = {
* coverage lives in the spawn/fork specs. The mock model script drives the
* child's structured_output calls.
*/
async function setup(script: Script) {
async function setup(script: Script, options: SetupOptions = {}) {
const ctx = new Context()
const adapter = new MockAdapter(script)
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRegistry, { mode: options.toolMode ?? 'native' })
if (options.toolMode === 'code' || options.toolMode === 'both') {
ctx.provide('codeRuntime', {
language: 'typescript',
isolation: 'test',
run: options.codeRun ?? (() => Promise.resolve({ logs: [] })),
} as never)
}
await ctx.plugin(AgentRegistry)
await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] })
@@ -48,7 +65,7 @@ async function setup(script: Script) {
name: 'spawn',
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
inheritsParentContext: false,
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, { providerName: 'spawn' }),
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, {}),
})
ctx.llm.registerAdapter(['mock'], adapter)
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
@@ -121,6 +138,44 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('a later prepended pre-execute listener cannot resurrect dispatch after capture', async () => {
const response = [
...toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }).slice(0, -2),
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'side_effect', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
] as Script[number]
const { ctx, parent } = await setup([response])
let sideEffectRan = false
ctx.tools.register({
name: 'side_effect',
description: 'probe',
parameters: { type: 'object', properties: {} },
execute(): Promise<ContentBlock[]> {
sideEffectRan = true
return Promise.resolve([{ type: 'text', text: 'ran' }])
},
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
// Registered after the child and prepended: this listener returns allow
// after every downstream pre-execute decision. The service-owned guard
// runs after the waterfall and can only deny, so the body still cannot run.
ctx.on('tools/pre-execute', async (_exec, next) => {
await next()
return { kind: 'allow' as const }
}, { prepend: true })
const result = await run.result
expect(result.structured).toEqual({ answer: 5 })
expect(sideEffectRan).toBe(false)
const child = ctx.agents.get(run.id)
const sideEffectResult = child?.session.events.find(event =>
event.type === 'tool/result' && event.data.callId === 'c2')
expect(sideEffectResult?.type === 'tool/result' && sideEffectResult.data.isError).toBe(true)
await run.dispose()
})
it('leaves tool calls that PRECEDE the capture in the same response untouched', async () => {
const response = [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
@@ -173,23 +228,67 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('the captured-turn veto is prepend: an EARLIER force-continue listener cannot short-circuit it', async () => {
// A goal-style listener registered BEFORE the child exists, returning a
// forced continue WITHOUT calling next(). Without prepend on the scoped
// veto, this would decide the turn first and buy a wasted model step —
// the one-response script would then throw on the second request.
it('a later-prepended continuation wrapper cannot resurrect a captured turn', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
textResponse('MUST NOT BE CONSUMED'),
])
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'continue' }))
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
const run = ctx.subagents.start('spawn', structuredRequest(parent))
let wrapperInstalled = false
// Register this observer only after start() returns. The child session-start
// boundary is after its unpublished setup attached structured output but
// before the loop can run; install a prepended wrapper there. It awaits the
// explicit downstream stop above, then overwrites that result with continue.
// The later terminal checkpoint still wins.
ctx.on('agent/session-start', (child) => {
if (child.id !== run.id) return
wrapperInstalled = true
child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, next): Promise<ContinuationDecision> => {
const downstream = await next()
expect(downstream).toEqual({ action: 'stop' })
return { action: 'continue' }
}, { prepend: true })
})
const result = await run.result
expect(wrapperInstalled).toBe(true)
expect(result.structured).toEqual({ answer: 7 })
expect(result.stopReason).toBe('completed')
expect(adapter.requests).toHaveLength(1)
await run.dispose()
})
it('a continuation wrapper cannot carry steering past a captured terminal stop', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
textResponse('MUST NOT BE CONSUMED'),
])
// The downstream ordinary policy says stop. A wrapper registered after
// start() delegates to that stop, then queues steering; ordinary folding
// would turn the stop back into continue. The terminal checkpoint runs
// afterwards and discards that steering.
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
const run = ctx.subagents.start('spawn', structuredRequest(parent))
ctx.on('agent/session-start', (child) => {
if (child.id !== run.id) return
child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, next): Promise<ContinuationDecision> => {
const downstream = await next()
expect(downstream).toEqual({ action: 'stop' })
subject.steer([{ type: 'text', text: 'late steering after downstream stop' }])
return downstream
}, { prepend: true })
})
const result = await run.result
const child = ctx.agents.get(run.id)
expect(result.structured).toEqual({ answer: 9 })
expect(adapter.requests).toHaveLength(1)
expect(child?.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(child?.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
await run.dispose()
})
it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),
@@ -236,11 +335,11 @@ describe('in-process structured output', () => {
it('a cancel landing after a clean capture-less turn settles aborted, not error', async () => {
const { ctx, parent } = await setup([textResponse('prose, no capture')])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const child = ctx.agents.get(run.id)!
// Cancel synchronously inside the turn's end recording: the cancel
// contract outranks the schema shortfall, so the result maps to aborted.
ctx.on('session/event', (session, event) => {
if (session === child.session && event.type === 'turn/end') run.cancel('cancelled at turn end')
const child = ctx.agents.get(run.id)
if (session === child?.session && event.type === 'turn/end') run.cancel('cancelled at turn end')
})
const result = await run.result
expect(result.stopReason).toBe('aborted')
@@ -270,8 +369,8 @@ describe('in-process structured output', () => {
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
textResponse('continues after the blocked capture'),
])
// A PostToolUse-style hook, registered AFTER the runtime (so the runtime's
// prepend commit listener stays outermost and composes this verdict).
// A PostToolUse-style hook turns the tool body's provisional success into
// the authoritative final error observed by the commit notification.
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'capture rejected by hook' }] })
@@ -311,6 +410,31 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('commits only after a later prepended post-execute wrapper returns the authoritative result', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
textResponse('capture was rejected'),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
// Registered after attachment and prepended, so it wraps every listener
// the child installed. It delegates first, then converts the apparent
// capture success into the pipeline's authoritative failure.
ctx.on('tools/post-execute', async (exec, _result, next) => {
const downstream = await next()
if (exec.name !== STRUCTURED_OUTPUT_TOOL) return downstream
return { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected after downstream' }] }
}, { prepend: true })
const result = await run.result
expect(result.structured).toBeUndefined()
expect(result.stopReason).toBe('error')
const child = ctx.agents.get(run.id)
const captureResult = child?.session.events.find(event =>
event.type === 'tool/result' && event.data.callId === 'c1')
expect(captureResult?.type === 'tool/result' && captureResult.data.isError).toBe(true)
await run.dispose()
})
it('appends the structured instruction to the child REQUEST\'s system text (base prompt preserved)', async () => {
const { ctx, parent, adapter } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })])
// A context-wide section stands in for the deployment persona: the
@@ -327,6 +451,100 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('keeps pure Code Mode at one wire tool and exposes structured capture through the SDK only', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })' }),
], {
toolMode: 'code',
codeRun: async (request) => {
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
if (!capture) throw new Error('structured_output binding missing')
await capture({ answer: 12 })
return { logs: [], value: 'captured' }
},
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
// This listener is registered after the child's protection and prepended.
// Service finalization still restores the stripped transport and prompt
// parts, while removing the fabricated native capture tool.
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
return {
sections: result.sections.filter(section =>
section.name !== 'tools:sdk' && section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`),
tools: [
...result.tools.filter(tool => tool.name !== RUN_CODE_NAME),
{ name: STRUCTURED_OUTPUT_TOOL, description: 'wrong native duplicate', parameters: {} },
],
variables: result.variables,
}
}, { prepend: true })
const result = await run.result
expect(result.structured).toEqual({ answer: 12 })
const request = adapter.requests[0]!
expect(toolNames(request)).toEqual([RUN_CODE_NAME])
expect(request.system).toContain('declare const tools:')
expect(request.system).toContain('structured_output(args:')
expect(request.system).toContain(STRUCTURED_OUTPUT_INSTRUCTION)
await run.dispose()
})
it('discards a nested capture when the enclosing run_code execution fails', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', RUN_CODE_NAME, { code: 'await tools.structured_output({ answer: 12 }); throw new Error("boom")' }),
textResponse('outer code failed'),
], {
toolMode: 'code',
codeRun: async (request) => {
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
if (!capture) throw new Error('structured_output binding missing')
await capture({ answer: 12 })
return {
logs: [],
error: { kind: 'runtime', message: 'boom after capture' },
} as never
},
})
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toBeUndefined()
expect(result.stopReason).toBe('error')
expect(adapter.requests).toHaveLength(2)
const child = ctx.agents.get(run.id)!
const outer = child.session.events.find(event =>
event.type === 'tool/result' && event.data.callId === CallId('c1'))
expect(outer?.type === 'tool/result' && outer.data.isError).toBe(true)
await run.dispose()
})
it('discards a nested capture when post-policy blocks the enclosing run_code result', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })' }),
textResponse('outer code was blocked'),
], {
toolMode: 'code',
codeRun: async (request) => {
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
if (!capture) throw new Error('structured_output binding missing')
await capture({ answer: 12 })
return { logs: [], value: 'captured' }
},
})
ctx.on('tools/post-execute', (exec, _result, next) => exec.name === RUN_CODE_NAME
? Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer blocked' }] })
: next())
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(result.structured).toBeUndefined()
expect(result.stopReason).toBe('error')
expect(adapter.requests).toHaveLength(2)
await run.dispose()
})
it('the instruction rides ONLY structured requests: appended for the child, absent for a plain agent', async () => {
const { ctx, parent, adapter } = await setup([
textResponse('parent answer'),
@@ -412,12 +630,12 @@ describe('in-process structured output', () => {
await runB.dispose()
})
it('the re-assert REPLACES a conflicting injected schema, not merely ensures presence', async () => {
it('protection replaces a conflicting injected schema, not merely ensuring presence', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
])
// A global listener that INJECTS a wrong-schema structured_output entry:
// the child's re-assert must replace it with the run's own schema.
// protection restores the run's own canonical schema.
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const replaced = await next()
return {
@@ -438,14 +656,14 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('the re-assert wins against a downstream listener that REPLACES the assembly object', async () => {
it('protection wins against a listener that replaces the assembly object', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
])
// A global (every-assembly) listener that returns a brand-new assembly
// WITHOUT the capture tool or instruction — the composition caveat that
// erases cooperative mutations. The child's prepend re-assert runs
// OUTERMOST and restores both.
// erases cooperative mutations. Service finalization restores both
// after the complete waterfall.
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const replaced = await next()
return {
@@ -465,13 +683,13 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('the re-assert preserves the untampered assembly: tool position and section band are the registry\'s own', async () => {
it('protection preserves the canonical tool position and section band', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
])
// A global tool sorting lexicographically AFTER structured_output and a
// global section ABOVE the 190 band: the re-assert must leave both
// exactly where the registry's ordering put them (no move-to-end).
// global section above the 190 band: protection leaves both exactly
// where the canonical registry ordering put them.
ctx.tools.register({
name: 'zz_probe',
description: 'probe',
@@ -498,7 +716,7 @@ describe('in-process structured output', () => {
])
ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' })
// Strip the instruction section entirely AND add a wrong-schema
// duplicate tool entry ALONGSIDE the registry's own: the re-assert must
// duplicate tool entry alongside the registry's own: protection must
// restore the section INTO its band (before the order-200 section, not
// appended after it) and collapse the tools to exactly one entry
// carrying the run's schema.
@@ -578,15 +796,14 @@ describe('in-process structured output', () => {
expect(result.error?.code).toBe('UNKNOWN_TOOL')
})
it('a stale stage from a short-circuited chain is never promoted by a later call (execution-keyed commit)', async () => {
it('a failed execution stage is discarded and never promoted by a later call', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const child = ctx.agents.get(run.id)!
// An OUTER post-execute listener (registered after attach, prepend ⇒
// outermost) that BLOCKS the first capture WITHOUT delegating: the commit
// listener never runs for c1, so its staged value would linger.
// A prepended post-execute listener blocks the first capture without
// delegating. The final-result notification discards that execution's
// stage when it observes the error.
let blocks = 1
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
@@ -596,11 +813,12 @@ describe('in-process structured output', () => {
return next()
}, { prepend: true })
const result = await run.result
const child = ctx.agents.get(run.id)!
// The blocked capture must NOT surface as structured success…
expect(result.stopReason).toBe('error')
expect(result.structured).toBeUndefined()
// …and a LATER invalid call (its own body staged nothing) must not
// resurrect c1's orphaned value: drive the pipeline directly.
// resurrect c1's discarded value: drive the pipeline directly.
const invalid = await ctx.tools.execute({
callId: 'c2' as never,
name: STRUCTURED_OUTPUT_TOOL,
@@ -619,14 +837,13 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('a later capture call REUSING a stale stage\'s call id never promotes it (unconditional commit safety)', async () => {
it('reusing a failed execution\'s call id never promotes its discarded stage', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const child = ctx.agents.get(run.id)!
// Orphan a stage: an outer short-circuiting post-execute BLOCK on the
// first capture (its chain never reaches the commit listener).
// Block the first capture after its body stages a value. Its final error
// discards that execution's stage.
let blocks = 1
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
@@ -636,8 +853,9 @@ describe('in-process structured output', () => {
return next()
}, { prepend: true })
await run.result
const child = ctx.agents.get(run.id)!
// A SECOND capture call with the SAME call id whose body never stages
// (invalid args throw before the stage): the stale value must not ride
// (invalid args throw before the stage): the discarded value must not ride
// its acceptance.
const reused = await ctx.tools.execute({
callId: 'c1' as never,
@@ -657,13 +875,12 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('an outer pre-execute deny with call-id reuse cannot promote an orphaned stage either', async () => {
it('a pre-execute deny with call-id reuse cannot promote another execution\'s stage', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
])
const run = ctx.subagents.start('spawn', structuredRequest(parent))
const child = ctx.agents.get(run.id)!
// Orphan a stage via an outer post-execute BLOCK on the first capture.
// Discard the first capture's stage via a final post-execute block.
let blocks = 1
ctx.on('tools/post-execute', (exec, _result, next) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
@@ -673,9 +890,9 @@ describe('in-process structured output', () => {
return next()
}, { prepend: true })
await run.result
// An OUTERMOST prepend pre-execute deny: the structured runtime's own
// pre-execute never runs for this call, and the denied call still goes
// through post-execute — with the SAME call id as the orphaned stage.
const child = ctx.agents.get(run.id)!
// A prepended pre-execute deny skips the body, while the denied call still
// reaches the final notification with the same adapter-minted call id.
const offDeny = ctx.on('tools/pre-execute', (exec) => {
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
return Promise.resolve({ kind: 'deny' as const, reason: 'outer veto' })
@@ -690,7 +907,7 @@ describe('in-process structured output', () => {
})
expect(denied.isError).toBe(true)
offDeny()
// The orphan was never promoted: a fresh valid call is still required
// The discarded value was never promoted: a fresh valid call is required
// (and succeeds, proving the runtime is not wedged).
const valid = await ctx.tools.execute({
callId: 'c1' as never,
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -49,9 +49,166 @@ describe('depthOf', () => {
})
describe('startInProcessRun', () => {
it('rejects a non-JSON prompt before acquiring any run ownership', async () => {
const { ctx, parent } = await setup([])
expect(() => startInProcessRun(ctx, {
prompt: [{ type: 'text', text: Number.NaN as unknown as string }],
parent,
}, {})).toThrow('subagent prompt must be losslessly JSON-serializable')
})
it('rejects a prompt whose getter becomes non-JSON while it is snapshotted', async () => {
const { ctx, parent } = await setup([])
let reads = 0
const prompt = [{
type: 'text' as const,
get text(): string {
reads += 1
return reads === 1 ? 'valid during pre-check' : Number.NaN as unknown as string
},
}]
expect(() => startInProcessRun(ctx, { prompt, parent }, {}))
.toThrow('subagent prompt must be stable losslessly JSON-serializable data')
expect(reads).toBe(2)
})
it('rejects when the run-owner fiber settles without installing its context', async () => {
const { ctx, parent } = await setup([])
function inertOwner(): void {}
const inertFiber = ctx.plugin(inertOwner)
await inertFiber
const parentWithoutOwnerContext = {
options: parent.options,
session: parent.session,
ctx: { plugin: () => inertFiber },
} as unknown as Agent
const run = startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent: parentWithoutOwnerContext,
}, {})
await expect(run.result).rejects.toThrow('subagent run owner became inactive before child creation')
await run.dispose()
})
it('normalizes a non-Error thrown while installing the run-owner fiber', async () => {
const { ctx, parent } = await setup([])
const setupFailure = 'non-Error owner setup failure'
const parentWithFailingOwnerSetup = {
options: parent.options,
session: parent.session,
ctx: { plugin: () => { throw setupFailure } },
} as unknown as Agent
const run = startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent: parentWithFailingOwnerSetup,
}, {})
await expect(run.result).rejects.toMatchObject({
message: 'subagent run owner setup failed with a non-Error value',
cause: setupFailure,
})
await run.dispose()
})
it('normalizes a non-Error rejected by asynchronous child creation', async () => {
const { ctx, parent } = await setup([])
const creationFailure = 'non-Error child creation failure'
function inertOwner(): void {}
const ownerFiber = ctx.plugin(inertOwner)
await ownerFiber
const rejectWithNonError = (): Promise<never> => {
// Deliberately violate the promise contract to exercise boundary normalization.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
return Promise.reject(creationFailure)
}
const rejectingOwnerCtx = {
agents: { create: rejectWithNonError },
} as unknown as Context
const parentWithRejectingFactory = {
options: parent.options,
session: parent.session,
ctx: {
plugin(plugin: (inner: Context) => void) {
plugin(rejectingOwnerCtx)
return ownerFiber
},
},
} as unknown as Agent
const run = startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent: parentWithRejectingFactory,
}, {})
await expect(run.result).rejects.toMatchObject({
message: 'subagent child creation failed with a non-Error value',
cause: creationFailure,
})
await run.dispose()
})
it('follows owner-fiber inertia when raw teardown was already in flight', async () => {
const { ctx, parent } = await setup([])
const gate = Promise.withResolvers<undefined>()
let inertia: Promise<undefined> | undefined = gate.promise
const fakeFiber = {
dispose: vi.fn(() => undefined),
get inertia() { return inertia },
} as unknown as Fiber & PromiseLike<Fiber>
const rejectingOwnerCtx = {
agents: { create: () => Promise.reject(new Error('creation stopped by teardown')) },
} as unknown as Context
const parentWithDisposingOwner = {
options: parent.options,
session: parent.session,
ctx: {
plugin(plugin: (inner: Context) => void) {
plugin(rejectingOwnerCtx)
return fakeFiber
},
},
} as unknown as Agent
const run = startInProcessRun(ctx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent: parentWithDisposingOwner,
}, {})
let settled = false
const disposing = run.dispose().then(() => { settled = true })
await Promise.resolve()
await Promise.resolve()
expect(fakeFiber.dispose).toHaveBeenCalledOnce()
expect(settled).toBe(false)
inertia = undefined
gate.resolve(undefined)
await disposing
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
})
it('does not attach an abort listener when provider ownership is already inactive', async () => {
const { ctx, parent } = await setup([])
let providerCtx: Context | undefined
function provider(inner: Context): void { providerCtx = inner }
const providerFiber = await ctx.plugin(provider)
await providerFiber.dispose()
if (providerCtx === undefined) throw new Error('provider context was not captured')
const inactiveProviderCtx = providerCtx
const controller = new AbortController()
const addListener = vi.spyOn(controller.signal, 'addEventListener')
expect(() => startInProcessRun(inactiveProviderCtx, {
prompt: [{ type: 'text', text: 'must never start' }],
parent,
signal: controller.signal,
}, {})).toThrow(/inactive context/)
expect(addListener).not.toHaveBeenCalled()
})
it('drives a fresh child (no seed) to completion and returns its output', async () => {
const { ctx, parent } = await setup([textResponse('driver child answer')])
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn' })
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, {})
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('driver child answer')
@@ -59,9 +216,25 @@ describe('startInProcessRun', () => {
await run.dispose()
})
it('snapshots the prompt before asynchronous child creation', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const prompt = [{ type: 'text' as const, text: 'original prompt' }]
const run = startInProcessRun(ctx, { prompt, parent }, {})
prompt[0]!.text = 'mutated after start'
prompt.push({ type: 'text', text: 'also injected' })
await run.result
const child = ctx.agents.get(run.id)!
const userMessage = child.session.events.find(event => event.type === 'user/message')
expect(userMessage?.type === 'user/message' && userMessage.data.content)
.toEqual([{ type: 'text', text: 'original prompt' }])
await run.dispose()
})
it('throws SubagentDepthError when the child would exceed maxDepth', async () => {
const { ctx, parent } = await setup([])
expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn' }))
expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, {}))
.toThrow(SubagentDepthError)
})
@@ -73,7 +246,7 @@ describe('startInProcessRun', () => {
parent.send([{ type: 'text', text: 'parent q' }])
await parent.whenIdle()
const seed = parent.session.events.slice()
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', seed })
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { seed })
const result = await run.result
expect(result.stopReason).toBe('completed')
expect(text(result.output)).toBe('seeded child reply')
@@ -29,7 +29,7 @@ export const name = 'subagent-spawn'
// output through the child's creation context, whose factory already requires
// the tool service. Keeping it out of this backend's inject list preserves the
// provider's independent apply timing.
export const inject = ['subagents', 'agents']
export const inject = ['subagents']
/** Config: the registry name to register the provider under. */
export interface Config {
@@ -59,7 +59,7 @@ class SpawnProvider implements SubagentProvider {
// Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
// depth, drives the one-shot (including the structured capture when the
// request carries an outputSchema), and maps the result.
return startInProcessRun(this.ctx, request, { providerName: this.name })
return startInProcessRun(this.ctx, request, {})
}
}
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import LlmService from '@deepseek-ai/dsh-llm'
@@ -160,6 +160,38 @@ describe('dsh-subagent-spawn', () => {
await run.dispose()
})
it('a cancel from agent/queued maps a no-turn child log to aborted', async () => {
const { ctx, parent } = await setup([])
ctx.on('agent/queued', (agent) => {
if (agent.id === run.id) run.cancel('queued-window')
})
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
const result = await run.result
expect(result).toMatchObject({ stopReason: 'aborted', output: [] })
const child = ctx.agents.get(run.id)!
expect(child.session.events.some(event => event.type === 'turn/end')).toBe(false)
await run.dispose()
})
it('dispose during async child creation waits for rollback and leaves no orphan', async () => {
const { ctx, parent } = await setup([])
const beforeAgents = ctx.agents.list().length
const beforeSessions = ctx.sessions.list().length
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
// Same tick: the factory has reserved ids and entered its async setup
// transaction, but has not published the child yet.
await run.dispose()
await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted', output: [] })
expect(ctx.agents.list()).toHaveLength(beforeAgents)
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
expect(published).toEqual([])
})
it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => {
// 'hang' makes the child's model stream one chunk then wait until aborted.
const controller = new AbortController()
@@ -206,7 +238,7 @@ describe('dsh-subagent-spawn', () => {
it('inherits the parent cwd into the child session', async () => {
const { ctx } = await setup([textResponse('x')])
// A parent WITH a cwd (config agents have none, so create one explicitly).
const parentHandle = ctx.agents.create({
const parentHandle = await ctx.agents.create({
agentId: AgentId('cwd-parent'),
sessionId: SessionId('cwd-parent-session'),
meta: { cwd: '/tmp/parent-workspace' },
@@ -223,7 +255,7 @@ describe('dsh-subagent-spawn', () => {
it('uses request.agentOptions.model when the parent has no model of its own', async () => {
const { ctx } = await setup([textResponse('explicit model child')])
// A parent with NO model (its own turns would need one supplied per-request).
const parentHandle = ctx.agents.create({
const parentHandle = await ctx.agents.create({
agentId: AgentId('modelless-parent'),
sessionId: SessionId('modelless-parent-session'),
agentOptions: {},
@@ -305,15 +337,70 @@ describe('dsh-subagent-spawn', () => {
await run.dispose()
})
it('a backend unload during child creation prevents every publication notification', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(Invariants)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'must never run' }], parent,
})
await fiber.dispose()
await run.result.catch(() => undefined)
await run.dispose()
expect(ctx.agents.get(run.id)).toBeUndefined()
expect(published).toEqual([])
})
it('a start racing an already-unloading backend cannot mint a run-owner fiber', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
const parentEffects = parent.ctx.fiber.getEffects().length
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
const unloading = fiber.dispose()
expect(() => ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'must never start' }], parent,
})).toThrow(/inactive context/)
await unloading
expect(parent.ctx.fiber.getEffects()).toHaveLength(parentEffects)
expect(published).toEqual([])
})
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in spawn).toBe(false)
expect(spawn.name).toBe('subagent-spawn')
expect(spawn.inject).toEqual(['subagents', 'agents'])
expect(spawn.inject).toEqual(['subagents'])
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(spawn) as Record<string, unknown>
expect(unwrapped).toBe(spawn)
expect(unwrapped.name).toBe('subagent-spawn')
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
expect(unwrapped.inject).toEqual(['subagents'])
expect(typeof unwrapped.apply).toBe('function')
})
@@ -369,11 +456,13 @@ describe('dsh-subagent-spawn', () => {
it('an unknown toolFilter name fails the spawn loudly with no orphaned child', async () => {
const { ctx, parent } = await setup([])
const before = ctx.agents.list().length
expect(() => ctx.subagents.start('spawn', {
const run = ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent,
toolFilter: { deny: ['no_such_tool'] },
})).toThrow(/unknown tool "no_such_tool"/)
})
await expect(run.result).rejects.toThrow(/unknown tool "no_such_tool"/)
await run.dispose()
expect(ctx.agents.list().length).toBe(before)
})
})
@@ -381,19 +470,53 @@ describe('dsh-subagent-spawn', () => {
it('spawning from a DISPOSING parent fails loud with no orphaned child (INACTIVE_EFFECT teaching error)', async () => {
const { ctx } = await setup([])
// A handle-owned parent we can dispose (config agents dispose with the loop fiber).
const parentHandle = ctx.agents.create({
const parentHandle = await ctx.agents.create({
agentId: AgentId('doomed-parent'),
sessionId: SessionId('doomed-s'),
agentOptions: { model: 'mock' },
})
await parentHandle.dispose()
const before = ctx.agents.list().length
expect(() => ctx.subagents.start('spawn', {
const sessionsBefore = ctx.sessions.list().length
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'do X' }],
parent: parentHandle.agent,
})).toThrow(/inactive context/)
// The freshly created child's disposal was initiated before the rethrow
// (fire-and-forget — start() throws synchronously); quiescence follows.
await vi.waitFor(() => { expect(ctx.agents.list().length).toBe(before) })
})
await expect(run.result).rejects.toThrow(/inactive context/)
await run.dispose()
expect(ctx.agents.list().length).toBe(before)
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
expect(published).toEqual([])
})
it('parent disposal during the child setup transaction prevents every publication notification', async () => {
const { ctx } = await setup([])
const parentHandle = await ctx.agents.create({
agentId: AgentId('setup-race-parent'),
sessionId: SessionId('setup-race-parent-session'),
agentOptions: { model: 'mock' },
})
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
const run = ctx.subagents.start('spawn', {
prompt: [{ type: 'text', text: 'must never run' }],
parent: parentHandle.agent,
})
// The factory has entered its awaited unpublished setup transaction. Parent
// ownership was installed before that await, so disposal wins without an
// observer ever seeing the child.
await parentHandle.dispose()
await expect(run.result).rejects.toThrow(/owner disposed during setup|inactive context/)
await run.dispose()
expect(ctx.agents.get(run.id)).toBeUndefined()
expect(published).toEqual([])
})
})
+27 -6
View File
@@ -34,6 +34,8 @@
import { Context, Service } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
@@ -93,7 +95,7 @@ declare module 'cordis' {
* @param info - which provider started which child agent.
* @mode emit
*/
'subagent/start'(info: SubagentRunInfo): void
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
/**
* A subagent run settled — emitted when {@link SubagentRun.result}
* resolves (any stop reason). Paired with {@link Events['subagent/start']}.
@@ -104,7 +106,7 @@ declare module 'cordis' {
* @param info - the run identity plus stop reason and final output.
* @mode emit
*/
'subagent/end'(info: SubagentRunEndInfo): void
'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
}
}
@@ -224,13 +226,32 @@ export class SubagentService extends Service {
* @returns the live run (its `result` resolves when the child settles).
*/
start(name: string, request: SubagentStartRequest): SubagentRun {
// Parent is the lifecycle scope identity accepted at start. Never reread it
// from the caller-owned request after the provider/result async boundary,
// or start/end could be dispatched into different agent scopes.
const parent = request.parent
const provider = this.providers.get(name)
if (!provider) {
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
}
this.assertCapabilities(provider, request)
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
const run = provider.start(request)
// Detach every data field before crossing into a provider. Parent/signal
// are live identity capabilities and stay exact; the mutable request record
// and its arrays/objects are never retained, so every backend (including an
// async out-of-process one) observes the request accepted at start.
const accepted: SubagentStartRequest = {
prompt: structuredClone(request.prompt),
parent,
...request.signal !== undefined ? { signal: request.signal } : {},
...request.agentOptions !== undefined ? { agentOptions: structuredClone(request.agentOptions) } : {},
...request.outputSchema !== undefined ? { outputSchema: structuredClone(request.outputSchema) } : {},
...request.maxDepth !== undefined ? { maxDepth: request.maxDepth } : {},
...request.toolFilter !== undefined ? { toolFilter: structuredClone(request.toolFilter) } : {},
...request.persona !== undefined ? { persona: request.persona } : {},
}
const run = provider.start(accepted)
// Emit `subagent/start` with PER-LISTENER containment (see {@link emitLifecycle}):
// the run is already live, so neither a throwing subscriber escaping
// `start()` (the caller would never receive the run to dispose it — a leaked
@@ -238,7 +259,7 @@ export class SubagentService extends Service {
// acceptable. `ctx.emit` halts the dispatch on the first throw, so a single
// surrounding try/catch is not enough — each listener is invoked and
// contained individually.
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, request.parent)
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent)
// Emit `subagent/end` when the run settles. The result promise does not
// reject on a child-level failure (it resolves with stopReason 'error'),
// so a rejection here is an infrastructure fault — surface its stop reason
@@ -268,9 +289,9 @@ export class SubagentService extends Service {
} catch (error: unknown) {
this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`)
}
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} }, request.parent)
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} }, parent)
},
() => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, request.parent) },
() => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent) },
)
return run
}
+5 -3
View File
@@ -120,9 +120,11 @@ export interface SubagentResult {
/** The child's final assistant output (the last assistant message's content). */
output: ContentBlock[]
/**
* The structured result, present IFF the request carried an `outputSchema`
* AND the provider honored it. Shape is validated against the request schema
* by the provider; `unknown` here because the seam is schema-agnostic.
* The structured result after a requested `outputSchema` was successfully
* satisfied. Requesting a schema does not guarantee presence: a provider can
* end with `stopReason: 'error'` when the child fails or finishes without a
* valid capture. Shape is validated against the request schema by the
* provider; `unknown` here because the seam is schema-agnostic.
*/
structured?: unknown
/** Why the run ended. A non-`completed` reason means `output` may be partial. */
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
import SubagentService, {
SubagentError,
type SubagentCapabilities,
@@ -227,6 +228,45 @@ describe('SubagentService', () => {
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' }))
})
it('pins start and end to the parent accepted at start despite caller mutation', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
const gate = Promise.withResolvers<SubagentResult>()
let acceptedRequest: SubagentStartRequest | undefined
ctx.subagents.registerProvider({
name: 'deferred',
capabilities: NO_CAPS,
inheritsParentContext: false,
start: (accepted) => {
acceptedRequest = accepted
return {
id: AgentId('deferred-child'),
result: gate.promise,
cancel() {},
async dispose() {},
}
},
})
const accepted = fakeParent('accepted-parent')
const replacement = fakeParent('replacement-parent')
const keys: unknown[] = []
ctx.on('subagent/start', function () { keys.push(carrierKeyOf(this)) })
ctx.on('subagent/end', function () { keys.push(carrierKeyOf(this)) })
const request = baseRequest({ parent: accepted })
const run = ctx.subagents.start('deferred', request)
request.parent = replacement
request.prompt[0] = { type: 'text', text: 'mutated prompt' }
expect(acceptedRequest?.parent).toBe(accepted)
expect(acceptedRequest?.prompt).toEqual([{ type: 'text', text: 'do a thing' }])
expect(acceptedRequest?.prompt).not.toBe(request.prompt)
gate.resolve({ output: [], stopReason: 'completed' })
await run.result
await Promise.resolve()
expect(keys).toEqual([accepted, accepted])
})
it('carries lastAssistantMessage (the child output) onto the end event', async () => {
const ctx = new Context()
await ctx.plugin(SubagentService)
+8 -6
View File
@@ -390,10 +390,12 @@ export function apply(ctx: Context, config: Config = {}): void {
'agent/session-prefix': args => args[0],
'agent/step-result': args => args[0],
'agent/turn-continuation': args => args[0],
'agent/turn-stop': args => args[0],
'agent/error': args => args[0],
'tools/pre-execute': args => (args[0] as ToolExecution).agent,
'tools/execute': args => (args[0] as ToolExecution).agent,
'tools/post-execute': args => (args[0] as ToolExecution).agent,
'tools/result': args => (args[0] as ToolExecution).agent,
'system-prompt/assemble': args => (args[1] as AssembleContext).scope,
'session/created': null,
'session/event': null,
@@ -429,11 +431,11 @@ export function apply(ctx: Context, config: Config = {}): void {
// --- Setup-drives invariant ---------------------------------------------
//
// CreateAgentOptions.setup REGISTERS the agent's scoped world; it must not
// DRIVE the agent — an inject() there opens a turn before
// `agent/session-start`, inverting the "session-start fires before the
// first turn" contract every bridge keys on. A turn/start appended to a
// live agent's session before its agent/session-start fired is therefore a
// CreateAgentOptions.setup COMPOSES the agent's scoped world; it must not
// DRIVE the agent. ReactLoopAgent rejects every driving verb structurally
// until rollback-covered publication reaches the session-start boundary; this event-level invariant remains the
// cross-implementation backstop for alternate Agent implementations and raw
// session writes. A turn/start appended before agent/session-start is a
// creation-time misuse, reported at the appending call site. Sessions of
// agents that exist BEFORE this plugin applies are marked started (their
// ordering is unknowable after the fact — never a false positive on HMR).
@@ -450,7 +452,7 @@ export function apply(ctx: Context, config: Config = {}): void {
if (owner === undefined) return
throw new InvariantError(
`agent "${owner.id}": a turn opened before agent/session-start fired — `
+ 'CreateAgentOptions.setup registers the scoped world, it must not drive the agent '
+ 'CreateAgentOptions.setup composes the scoped world, it must not drive the agent '
+ '(send/steer/inject belong after creation returns)')
})
@@ -828,11 +828,15 @@ describe('scoped-dispatch invariants', () => {
['agent/pre-step', [agent, 1, 1, '', new AbortController().signal]],
['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]],
['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]],
['agent/session-prefix', [agent, [], new AbortController().signal, () => Promise.resolve([])]],
['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]],
['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]],
['agent/turn-stop', [agent, 1]],
['agent/error', [agent, 1, 0, new Error('x')]],
['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ callId: 'c', content: [], isError: false })]],
['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }]],
]
for (const [event, args] of rows) {
const subject = event.startsWith('tools/') ? agent : agent
@@ -870,7 +874,7 @@ describe('scoped-dispatch invariants', () => {
}).not.toThrow()
})
it('rejects a turn opened before agent/session-start (setup drives the agent)', async () => {
it('backstops alternate agents that open a turn before agent/session-start', async () => {
const ctx = await scopedCtx()
// A live agent whose session is in the store but whose session-start has
// not fired: appending turn/start must throw the teaching error.
@@ -11,7 +11,7 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type ToolExecution, type ToolExecutionResult, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineTool, type ToolExecutionInput, type ToolExecutionResult, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
import { TOOL_TIMEOUT, toolTimeoutResult } from '@deepseek-ai/dsh-timeout-policy'
@@ -193,7 +193,7 @@ describe('dsh-timeout-policy real-load-path guard', () => {
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters<Context['plugin']>[0]
const fiber = await ctx.plugin(unwrapped)
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecution)
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput)
expect(result.isError).toBe(false)
await fiber.dispose()
})
+13 -3
View File
@@ -591,17 +591,27 @@ export function apply(ctx: Context, config: AcpConfig): void {
return Promise.resolve()
},
newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
assertOpen()
validateWorkspaceParams(params)
validateMcpServers(params)
const sessionId = SessionId(randomUUID())
const handle = agents.create({
const handle = await agents.create({
agentId: AgentId(sessionId),
sessionId,
meta: { cwd: params.cwd },
agentOptions: agentOptions(config),
})
// Creation is now asynchronous because it awaits the unpublished setup
// transaction. A client disconnect can therefore close this bridge
// after the entry check but before the handle resolves; never install a
// post-close record that quiesce() could not have seen.
/* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC
immediately on close; real stdio may let the handler resume */
if (closed) {
await handle.dispose()
throw internalError('connection closed during session/new')
}
bySession.set(handle.agent, sessionId)
sessions.set(sessionId, {
sessionId,
@@ -611,7 +621,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
terminalEnabled: terminalOutputCap,
inflight: undefined,
})
return Promise.resolve({ sessionId })
return { sessionId }
},
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
+4 -4
View File
@@ -229,10 +229,10 @@ describe('acp bridge — disposal & HMR safety', () => {
// dispose one handle, and assert the other survives, registered and
// queryable, with its session still in the store.
const harness = await makeBridgeHarness({ storageDir, script: [] })
const handleA = harness.ctx.agents.create({
const handleA = await harness.ctx.agents.create({
agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' },
})
const handleB = harness.ctx.agents.create({
const handleB = await harness.ctx.agents.create({
agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' },
})
expect(harness.ctx.agents.get(AgentId('sib-a'))).toBe(handleA.agent)
@@ -261,7 +261,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// a clean turn, dispose, and assert the session was STILL removed.
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
const handle = harness.ctx.agents.create({
const handle = await harness.ctx.agents.create({
agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' },
})
handle.agent.send([{ type: 'text', text: 'go' }])
@@ -282,7 +282,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// first call's await agent.done + final flush finished. Every caller must
// observe the same quiescence boundary.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
const handle = harness.ctx.agents.create({
const handle = await harness.ctx.agents.create({
agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' },
})
// Drive a turn that hangs in the model stream, so the loop is mid-turn when
+1 -1
View File
@@ -27,7 +27,7 @@ describe('acp bridge — demux & config edges', () => {
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const before = harness.updates.length
const { agent: foreign } = harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } })
const { agent: foreign } = await harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } })
foreign.send([{ type: 'text', text: 'hi' }])
await foreign.whenIdle()
await new Promise(r => setTimeout(r, 10))
@@ -61,7 +61,7 @@ return { prose, containsFour: judged === null ? null : judged.containsFour }`
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key e2e', () => {
it('runs a two-phase script in a worker thread over real children, one through the structured runtime', async () => {
ctx = await harness()
const parentHandle = ctx.agents.create({
const parentHandle = await ctx.agents.create({
agentId: AgentId('wf-worker-e2e-parent'),
sessionId: 'wf-worker-e2e-session' as never,
agentOptions: { model: 'deepseek-v4-flash' },
+2
View File
@@ -91,7 +91,9 @@ export const LINK_MAP: Record<string, string> = {
TurnEndReason: 'session.md',
ToolDefinition: 'tools.md',
ToolExecution: 'tools.md',
ToolExecutionInput: 'tools.md',
ToolExecutionResult: 'tools.md',
ToolExecutionToken: 'tools.md',
BashExecRequest: 'bash.md',
BashExecSpec: 'bash.md',
BashRunResult: 'bash.md',
+27 -16
View File
@@ -120,10 +120,10 @@ const SERVICE_ROLES: ServiceRole[] = [
{
key: 'tools',
pkg: 'tools',
title: 'Tool registry and execution waterfall',
title: 'Tool registry and guarded execution pipeline',
mode: 'core',
consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
note: 'Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute.',
note: 'Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation.',
},
{
key: 'userInteraction',
@@ -217,6 +217,9 @@ const SERVICE_ROLES: ServiceRole[] = [
]
const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: string }> = [
// tools/result uses ctx.events.dispatch directly so the registry can await
// every observer while containing each callback independently.
{ event: 'tools/result', pkg: 'tools', method: 'events.dispatch' },
// Subagent lifecycle events intentionally bypass ctx.emit and call
// ctx.events.dispatch directly so one throwing listener cannot starve later
// listeners or strand an already-started child run.
@@ -534,12 +537,12 @@ function collectEventRelations(): Map<string, EventRelation> {
if (method === 'on') {
const event = eventArg(node.arguments, method)
if (event) ensure(event).listeners.add(leaf)
} else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
} else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'strictSerial' || method === 'waterfall') {
const event = eventArg(node.arguments, method)
if (event) {
const relation = ensure(event)
const methods = relation.dispatchers.get(leaf) ?? new Set<string>()
methods.add(method)
methods.add(method === 'strictSerial' ? 'strictSerial (serial)' : method)
relation.dispatchers.set(leaf, methods)
}
}
@@ -566,13 +569,13 @@ function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.Sourc
const target = expr.expression.getText(sf)
if (target === 'ctx' || target === 'this.ctx') return true
// Scoped-dispatch spellings (the agent-scoping seam): the loop's fused
// dispatcher (`events` from `agentEvents(ctx, agent)`), the agent's own
// context handle (`this.loopCtx`), and the session store's captured
// dispatch context (`emitCtx`). Conventional receiver names, pinned by the
// fused-dispatch convention; a rename here must update this list (the
// producer/consumer matrix silently losing a dispatcher is the failure
// mode this list exists to prevent).
return target === 'events' || target === 'this.loopCtx' || target === 'emitCtx'
// dispatcher (`events` from `agentEvents(ctx, agent)`), an agent's setup
// context (`childCtx`), the agent's own context handle (`this.loopCtx`), and
// the session store's captured dispatch context (`emitCtx`). Conventional
// receiver names, pinned by the fused-dispatch convention; a rename here
// must update this list (the producer/consumer matrix silently losing a
// dispatcher or listener is the failure mode this list exists to prevent).
return target === 'events' || target === 'childCtx' || target === 'this.loopCtx' || target === 'emitCtx'
}
function eventArg(args: ts.NodeArray<ts.Expression>, method: string): string | undefined {
@@ -690,6 +693,7 @@ function renderLifecycle(): string {
' Tools-->>Session: tool-owned events when applicable',
` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`,
` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`,
` Driver->>Hooks: ${mermaidCode('agent/turn-stop')} serial terminal checkpoint`,
` Driver->>Session: ${mermaidCode('turn/end')}`,
` Driver->>Persistence: ${mermaidCode('session/flush')} parallel checkpoint`,
` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
@@ -705,7 +709,7 @@ function renderToolPipeline(): string {
const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'
return [
...generatedHeader('Tool Execution Pipeline'),
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, and UI rendering fit without changing the loop. The key extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls.',
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards and `tools/result` are the owner-enforced boundaries around them.',
'',
'```mermaid',
'flowchart TD',
@@ -713,19 +717,24 @@ function renderToolPipeline(): string {
` toolCall["Session event: ${mermaidCode('tool/call')}<br/>logged before execution"]`,
' presentCall["UI pending card<br/>presentCall(args)"]',
` pre["${mermaidCode('tools/pre-execute')} waterfall<br/>hooks, permission, sandbox"]`,
' guards["Registered monotonic guards<br/>deny or abstain; identity protected"]',
' denied["deny or ask<br/>tool body skipped"]',
` around["${mermaidCode('tools/execute')} waterfall<br/>timeout, retry, metrics (around dispatch)"]`,
' toolBody["Registered tool execute() body"]',
` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
` final["${mermaidCode('tools/result')} parallel notification<br/>frozen authoritative outcome"]`,
' context["Buffered additionalContext<br/>context/message after all tool results"]',
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
' allResults["All calls in the step settled<br/>and tool/result events recorded"]',
' presentResult["UI completed card<br/>presentResult(args, result)"]',
' model --> toolCall',
' toolCall --> presentCall',
' toolCall --> pre',
' pre -->|allow| around',
' pre -->|allow| guards',
' guards -->|allow| around',
' guards -->|deny| denied',
' around --> toolBody',
' pre -->|deny or ask| denied',
' denied --> post',
@@ -734,12 +743,14 @@ function renderToolPipeline(): string {
' toolBody --> owned',
' toolBody --> around',
' around --> post',
' post --> context',
' post --> toolResult',
' post --> final',
' final --> toolResult',
' toolResult --> presentResult',
' toolResult --> allResults',
' allResults --> context',
'```',
'',
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the same pipeline twice over: `run_code` is itself a registered tool body, and each tool call its program makes re-enters `ctx.tools.execute()` through BOTH waterfalls — serialized one at a time, logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).',
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The awaited `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution\'s opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).',
'',
...maintenanceFooter(maintenance),
].join('\n')
+1 -1
View File
@@ -137,7 +137,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
toolsConfig: { mode: 'code' },
async mount() {},
note:
'Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time.',
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry\'s only canonical wire contribution; the other visible capabilities are declared in a protected TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
},
{
pkg: '@deepseek-ai/dsh-tool-bash',
+4
View File
@@ -14,6 +14,7 @@
{ "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationStop", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" },
@@ -39,7 +40,10 @@
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" },