From 3263dab822a3d7f6dfd4d34b397b0a92d3e55ba7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:55:26 +0800 Subject: [PATCH] fix(core): enforce agent-scoped ownership boundaries --- examples/coding-agent/tests/resume.e2e.ts | 4 +- .../bash/tool-bash/tests/integration.spec.ts | 23 +- packages/bash/tool-bash/tests/tools.spec.ts | 10 +- .../cordis/tool-cordis/src/api-catalog.ts | 60 +- packages/cordis/tool-cordis/src/guard.ts | 4 +- packages/core/agent-loop/package.json | 1 - packages/core/agent-loop/src/agent.ts | 77 ++- packages/core/agent-loop/src/index.ts | 379 +++++++---- packages/core/agent-loop/src/loop.ts | 89 ++- packages/core/agent-loop/tests/agent.spec.ts | 20 +- packages/core/agent-loop/tests/cancel.spec.ts | 4 +- .../tests/config-session-id.spec.ts | 2 +- packages/core/agent-loop/tests/inbox.spec.ts | 2 +- packages/core/agent-loop/tests/loop.spec.ts | 40 +- .../tests/request-reconstruction.spec.ts | 2 +- packages/core/agent-loop/tests/resume.spec.ts | 268 +++++++- .../agent-loop/tests/review-fixes.spec.ts | 7 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 229 ++++++- .../core/agent-loop/tests/turn-stop.spec.ts | 199 ++++++ packages/core/agent/src/dispatch.ts | 28 +- packages/core/agent/src/index.ts | 182 ++++-- packages/core/agent/src/types.ts | 52 +- packages/core/agent/tests/agent.spec.ts | 40 +- packages/core/scope/src/index.ts | 75 ++- packages/core/scope/tests/scope.spec.ts | 78 ++- packages/core/session/src/index.ts | 26 +- packages/core/session/tests/scoped.spec.ts | 25 +- packages/core/session/tests/session.spec.ts | 1 + packages/core/system-prompt/src/index.ts | 191 +++++- .../core/system-prompt/tests/scoped.spec.ts | 42 ++ .../system-prompt/tests/system-prompt.spec.ts | 97 +++ packages/core/tools/src/code-mode.ts | 17 +- packages/core/tools/src/index.ts | 588 ++++++++++++++---- packages/core/tools/tests/code-mode.spec.ts | 84 ++- packages/core/tools/tests/scoped.spec.ts | 301 ++++++++- packages/core/tools/tests/tools.spec.ts | 284 ++++++++- packages/fs/tool-fs/tests/fs-tools.e2e.ts | 2 +- packages/fs/tool-fs/tests/tools.spec.ts | 10 +- .../hooks/hooks-claude/tests/coverage.spec.ts | 6 +- .../hooks/hooks-codex/tests/coverage.spec.ts | 2 +- packages/subagent/subagent-fork/src/index.ts | 3 +- .../subagent-fork/tests/subagent-fork.spec.ts | 4 +- .../subagent/subagent-inprocess/src/index.ts | 227 ++++--- .../subagent-inprocess/src/structured.ts | 235 +++---- .../tests/structured.spec.ts | 299 +++++++-- .../tests/subagent-inprocess.spec.ts | 183 +++++- packages/subagent/subagent-spawn/src/index.ts | 4 +- .../tests/subagent-spawn.spec.ts | 149 ++++- packages/subagent/subagent/src/index.ts | 33 +- packages/subagent/subagent/src/types.ts | 8 +- .../subagent/subagent/tests/service.spec.ts | 40 ++ packages/support/invariants/src/index.ts | 14 +- .../invariants/tests/invariants.spec.ts | 6 +- .../tests/timeout-policy.spec.ts | 4 +- packages/ui/acp/src/index.ts | 16 +- packages/ui/acp/tests/dispose.spec.ts | 8 +- packages/ui/acp/tests/edges.spec.ts | 2 +- .../tests/workflow-workerthread.e2e.ts | 2 +- scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 43 +- scripts/gen-tool-catalog.ts | 2 +- scripts/type-equiv.manifest.json | 4 + 62 files changed, 3982 insertions(+), 857 deletions(-) create mode 100644 packages/core/agent-loop/tests/turn-stop.spec.ts diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index fc216a9848..70382b4beb 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -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() diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index b3d6bb3f77..a22e86bd99 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -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`) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 6299aea7d3..fb2c3bf7a8 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -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) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 31ebdabc5d..ecb30c7a80 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -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', 'async resume(options: ResumeAgentOptions): Promise', ], }, @@ -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', - 'create(options: CreateAgentOptions): AgentHandle', + 'async create(options: CreateAgentOptions): Promise', 'async resume(options: ResumeAgentOptions): Promise', 'register(agent: Agent): () => Promise | 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', 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise | void', 'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise | void', + 'protect(protection: PromptProtection): () => Promise | void', 'async assemble(context: AssembleContext = {}): Promise', ], }, { 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', 'restrict(filter: ToolRestriction): () => Promise | void', + 'guard(guard: ToolGuard): () => Promise | 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', + 'async execute(exec: ToolExecutionInput): Promise', ], }, { @@ -215,13 +219,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/created', mode: 'emit', signature: '\'agent/created\'(this: Scoped, 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): 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, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise', 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, turn: number): Promise | 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, 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, 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, exec: ToolExecution, next: () => Promise): Promise', 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, exec: Readonly, result: Readonly): Promise | 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;\n}', + declaration: 'export interface AgentFactory {\n createAgent(options: CreateAgentOptions): Promise;\n resume(options: ResumeAgentOptions): Promise;\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;\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;\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;\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) => string | undefined;', + }, { name: 'ToolProviderResult', declaration: 'export interface ToolProviderResult {\n schemas: ToolSchema[];\n knownNames?: readonly string[];\n}', diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index 33d647db2b..10c2255141 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -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 { diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 03acc7e17f..5d180bd08c 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -11,7 +11,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 5f56cdc8a7..fb00f4ad76 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -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() + +/** Sessions already claimed by a concrete driver construction. */ +const claimedDriverSessions = new WeakSet() + +/** 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 { 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, diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 47b2607881..aa7e68d00e 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -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() + private pendingSessionIds = new Set() + // 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 { + // 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 { - 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() + const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers() + 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 } { - 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 + publish: (source: SessionStartSource) => void + disposeAgent: () => Promise + } { + // 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() const { promise: torndown, resolve: markTorndown } = Promise.withResolvers() - 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 | undefined + const disposeAgent = (): Promise => (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 } { + 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` 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 | undefined - return { agent, dispose: () => (disposing ??= disposeAgent()) } + setup?: (agentCtx: Context) => Promise | void, + ): Promise { + 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 + } } } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index efceb101fa..b49c38f5da 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -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 { +): Promise { 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' }) } diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index ed163bf4c2..d4f9d1e3d4 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -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') diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 63a75447ca..56376b69a7 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -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' }, diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index a645fb3553..97f04cbbca 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -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() diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 5406197b75..79a518ba2b 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -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 diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index eebf56ee82..734d4a73a9 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -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 diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index f1d6b02ac4..bb6d613954 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -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], diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 805f61d392..2a4ba7e904 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -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 { 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 { + 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 { @@ -39,11 +58,22 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } +/** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */ +async function promptly(task: Promise): Promise { + const timeout = Promise.withResolvers() + 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() + const setupStarted = Promise.withResolvers() + 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() + const setupStarted = Promise.withResolvers() + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + + let resuming!: ReturnType + 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() + const loadStarted = Promise.withResolvers() + 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 + 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() + 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] diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index e487a2c428..e527099a88 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -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) }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index b89f06727d..085629db64 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -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() + const setupStarted = Promise.withResolvers() + 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() + 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() + const setupStarted = Promise.withResolvers() + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + + let creating!: ReturnType + 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() + const setupStarted2 = Promise.withResolvers() + let creating2!: ReturnType + 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 - 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> + 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 - 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> + 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[] = [] diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts new file mode 100644 index 0000000000..c4ead42040 --- /dev/null +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -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 { + 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 { + 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) + }) +}) diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index bed4981135..b02dfeab57 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -57,6 +57,16 @@ export interface AgentEventDispatch { * @returns the serial chain's result (the first bail value, if any). */ serial(name: K, ...rest: Tail): Promise>> + /** + * 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(name: K, ...rest: Tail): Promise>> /** * 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 = 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 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, name: string, ...args: unknown[]) => Promise return await serial(carrier, name, agent, ...rest) }, + strictSerial(name, ...rest) { + return (async (): Promise => { + // 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>> + }, 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, name: string, ...args: unknown[]) => never diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 588a3c8ee1..5bd3dd80d7 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -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 } /** @@ -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 } /** * 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 /** * 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 } @@ -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() + /** Entries whose `agent/created` announcement phase began. */ + private announced = new WeakSet() 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 { 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 { if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE) @@ -225,39 +250,58 @@ export class AgentRegistry extends Service { */ register(agent: Agent): () => Promise | 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. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index a0ef52e735..1017a2df46 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -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 + /** * 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): 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, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise + /** + * 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`), built by the emitting side via + * `scopeTarget`/`agentEvents`. + * @mode serial + */ + 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): Promise | ContinuationStop | undefined // ---- error notifications (emit) ---- /** diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 08623549ec..4e89dc0e84 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -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/) }) }) diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 61b46978bd..7a7032b0b6 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -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 /** * 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 } +/** + * 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 { + 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 | 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 } @@ -334,8 +346,33 @@ export async function scopeHost(ctx: Context, services: string[]): Promise() + let disposing: Promise | undefined + const dispose = async (): Promise => { + // 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 | 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()), } } diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 664ae00d7e..44b9cf4442 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -30,14 +30,19 @@ async function mintScope(ctx: Context, key: object): Promise { } 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() + 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() + 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'])) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 0bbd957a79..d3f4ca4890 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -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 { - 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 { - return this.carriers.get(session) ?? scopeTarget(session, undefined) + /** Return the exact live session's carrier; detached/prepared objects reject. */ + private liveCarrierFor(session: Session): Scoped { + 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 } /** diff --git a/packages/core/session/tests/scoped.spec.ts b/packages/core/session/tests/scoped.spec.ts index 743b72def3..ee1e710397 100644 --- a/packages/core/session/tests/scoped.spec.ts +++ b/packages/core/session/tests/scoped.spec.ts @@ -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 () => { diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index f338b635f3..f63353af9b 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -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() }) diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index f89370447f..6d3ed3af58 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -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, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise /** - * 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( + canonical: readonly T[], result: readonly T[], protectedNames: ReadonlySet, +): 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 = z.object({ @@ -347,10 +391,12 @@ export class SystemPrompt extends Service { private sections: PromptSection[] = [] private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = [] private variableProviders = new Map 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() private scopedToolProviders = new Map ToolProviderResult)[]>() private scopedVariableProviders = new Map string | undefined>>() + private scopedProtections = new Map() 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 { 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 { + 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; tools: Set } { + 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 { 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 = {} @@ -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) } + : {}, + } } } diff --git a/packages/core/system-prompt/tests/scoped.spec.ts b/packages/core/system-prompt/tests/scoped.spec.ts index 729ec52b1c..d59e3a234a 100644 --- a/packages/core/system-prompt/tests/scoped.spec.ts +++ b/packages/core/system-prompt/tests/scoped.spec.ts @@ -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) + }) }) diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 3d4cb9d02c..95d46b705e 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -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) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index c985eb852c..323fbeed2b 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -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) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index c3b708ecb8..0428eaef9c 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -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, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise): Promise + /** + * 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, exec: Readonly, result: Readonly): Promise | 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() + +/** + * 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 '' } - 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) => 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>() /** Snapshot-at-registration restriction filters, per scope (see {@link restrict}). */ private restrictions = new Map() + /** Monotonic post-policy guards, split into global and per-agent layers. */ + private globalGuards = new Set() + private scopedGuards = new Map>() 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 { 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 { + 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 { 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 { + 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 = 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 { + async execute(exec: ToolExecutionInput): Promise { + 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({ 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 => { - 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 { + // --- 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({ 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 => { + 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 { + // 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({ 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 + 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 + 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 { diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 782447e7ec..da5725f10e 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -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'] }) }) diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 86bdd1414a..cac4a7e29b 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -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): 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('') + }) }) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 4cea334552..d32b349f93 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -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: ' }], + }) + }) + 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) diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index da5412530d..a270e7c6d8 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -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 }, diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 53f912cce2..c17bd875ba 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -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 }) diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index f06cdcf6b4..7feb46df05 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -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)) diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index c83137df53..774b308fa1 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -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) diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index de650fa4cd..3b9243aa2e 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -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 } : {}, diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 5356eba6e0..31a4f4c895 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -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 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') }) }) diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index cd0202f1bb..61867e1b39 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -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 { + 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 - 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) | undefined + let ownerSetupError: unknown + let ownerDisposing: Promise | undefined + const disposeOwner = (): Promise => (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 = (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 = (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 | undefined return { id: childId, result, @@ -252,13 +318,26 @@ export function startInProcessRun( requestCancel(reason ?? 'subagent cancelled') }, async dispose(): Promise { - 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() + })()) }, } } diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 1b1bef839b..dff63486ed 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -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() + /** 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, - ): Promise { - 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, - ): Promise { - 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, - ): Promise { - 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, - ): Promise { - 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 } } diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 3a024c7e66..1e63617154 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -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[0] +interface CodeRunRequestLike { + bindings: { global: string; functions: Record Promise> }[] +} + +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 { + 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({ action: 'continue' })) + ctx.on('agent/turn-continuation', () => Promise.resolve({ 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 => { + 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({ 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 => { + 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, diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 7219e03988..648ee5935f 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -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 => { + // 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() + let inertia: Promise | undefined = gate.promise + const fakeFiber = { + dispose: vi.fn(() => undefined), + get inertia() { return inertia }, + } as unknown as Fiber & PromiseLike + 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') diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 61da4eb560..835cf34aae 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -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, {}) } } diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 88f02070a6..a8dde937c0 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -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 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([]) }) }) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 0a7cea32e1..45287271c6 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -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, 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, 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 } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 5e6553c48a..a95fbae623 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -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. */ diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index afacd48a9b..3a16b19e22 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -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() + 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) diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 8fd425392d..e634ec9cc0 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -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)') }) diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 0186aaf31a..baf23d3d46 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -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. diff --git a/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts b/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts index ef5c52030f..30a7307515 100644 --- a/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts +++ b/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts @@ -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[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() }) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index b5e03d772d..f6ac2e27f7 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -591,17 +591,27 @@ export function apply(ctx: Context, config: AcpConfig): void { return Promise.resolve() }, - newSession(params: NewSessionRequest): Promise { + async newSession(params: NewSessionRequest): Promise { 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 { diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index ac092d9d16..037d00f17e 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -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 diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index 69c935139d..143bd7193c 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -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)) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts index 6353868a02..1d6f61432a 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts @@ -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' }, diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 9b8141807e..ccf10f712a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -91,7 +91,9 @@ export const LINK_MAP: Record = { 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', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 70fe8f2a85..1acdd5f5ff 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -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 { 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() - 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, 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')}
logged before execution"]`, ' presentCall["UI pending card
presentCall(args)"]', ` pre["${mermaidCode('tools/pre-execute')} waterfall
hooks, permission, sandbox"]`, + ' guards["Registered monotonic guards
deny or abstain; identity protected"]', ' denied["deny or ask
tool body skipped"]', ` around["${mermaidCode('tools/execute')} waterfall
timeout, retry, metrics (around dispatch)"]`, ' toolBody["Registered tool execute() body"]', ` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}
tool-fs mutations only"]`, ` owned["Tool-owned session events
${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`, ` post["${mermaidCode('tools/post-execute')} waterfall
accept, block, replace, add context"]`, + ` final["${mermaidCode('tools/result')} parallel notification
frozen authoritative outcome"]`, ' context["Buffered additionalContext
context/message after all tool results"]', ` toolResult["Session event: ${mermaidCode('tool/result')}
single model-facing outcome"]`, + ' allResults["All calls in the step settled
and tool/result events recorded"]', ' presentResult["UI completed card
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') diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 62e40907b8..3f96976a03 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -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', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 881ce06578..b8882f96e8 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -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" },