fix(core): enforce agent-scoped ownership boundaries
This commit is contained in:
@@ -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`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -57,7 +57,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
summary: 'The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`.',
|
||||
methods: [
|
||||
'create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent',
|
||||
'createAgent(options: CreateAgentOptions): AgentHandle',
|
||||
'async createAgent(options: CreateAgentOptions): Promise<AgentHandle>',
|
||||
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
|
||||
],
|
||||
},
|
||||
@@ -66,9 +66,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
summary: 'Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package.',
|
||||
methods: [
|
||||
'setFactory(factory: AgentFactory): () => Promise<void> | void',
|
||||
'create(options: CreateAgentOptions): AgentHandle',
|
||||
'async create(options: CreateAgentOptions): Promise<AgentHandle>',
|
||||
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
|
||||
'register(agent: Agent): () => Promise<void> | void',
|
||||
'enter(agent: Agent): () => void',
|
||||
'announce(agent: Agent): void',
|
||||
'get(id: AgentId): Agent | undefined',
|
||||
'list(): Agent[]',
|
||||
],
|
||||
@@ -161,25 +163,27 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
key: 'systemPrompt',
|
||||
summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step.',
|
||||
summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, named prompt variables, and authoritative contribution protections; the agent loop calls `assemble(context)` once per step.',
|
||||
methods: [
|
||||
'section(section: PromptSection): () => Promise<void> | void',
|
||||
'tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void',
|
||||
'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => Promise<void> | void',
|
||||
'protect(protection: PromptProtection): () => Promise<void> | void',
|
||||
'async assemble(context: AssembleContext = {}): Promise<PromptAssembly>',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'tools',
|
||||
summary: 'Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline.',
|
||||
summary: 'Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result` pipeline.',
|
||||
methods: [
|
||||
'register(definition: ToolDefinition): () => Promise<void> | void',
|
||||
'restrict(filter: ToolRestriction): () => Promise<void> | void',
|
||||
'guard(guard: ToolGuard): () => Promise<void> | void',
|
||||
'visible(scope?: ScopeKey): ToolDefinition[]',
|
||||
'get(name: string, scope?: ScopeKey): ToolDefinition | undefined',
|
||||
'schemas(scope?: ScopeKey): ToolSchema[]',
|
||||
'knownNames(scope?: ScopeKey): string[]',
|
||||
'async execute(exec: ToolExecution): Promise<ToolExecutionResult>',
|
||||
'async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -215,13 +219,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
name: 'agent/created',
|
||||
mode: 'emit',
|
||||
signature: '\'agent/created\'(this: Scoped<Agent>, agent: Agent): void',
|
||||
summary: 'An agent was registered in the AgentRegistry and is ready to receive messages.',
|
||||
summary: 'An agent\'s fully composed scoped world was published in the AgentRegistry.',
|
||||
},
|
||||
{
|
||||
name: 'agent/disposed',
|
||||
mode: 'emit',
|
||||
signature: '\'agent/disposed\'(this: Scoped<Agent>, agent: Agent): void',
|
||||
summary: 'An agent was disposed and removed from the registry; its fiber and any in-flight turn have been torn down.',
|
||||
summary: 'An agent was removed from the registry after its driver and any in-flight turn reached quiescence.',
|
||||
},
|
||||
{
|
||||
name: 'agent/error',
|
||||
@@ -283,6 +287,12 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
signature: '\'agent/turn-continuation\'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>',
|
||||
summary: 'Waterfall: override the turn-continuation decision via a typed ContinuationDecision.',
|
||||
},
|
||||
{
|
||||
name: 'agent/turn-stop',
|
||||
mode: 'serial',
|
||||
signature: '\'agent/turn-stop\'(this: Scoped<Agent>, agent: Agent, turn: number): Promise<ContinuationStop | undefined> | ContinuationStop | undefined',
|
||||
summary: 'Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded.',
|
||||
},
|
||||
{
|
||||
name: 'fs/edit-intent',
|
||||
mode: 'waterfall',
|
||||
@@ -328,7 +338,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
{
|
||||
name: 'subagent/end',
|
||||
mode: 'emit',
|
||||
signature: '\'subagent/end\'(info: SubagentRunEndInfo): void',
|
||||
signature: '\'subagent/end\'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void',
|
||||
summary: 'A subagent run settled — emitted when SubagentRun.result resolves (any stop reason).',
|
||||
},
|
||||
{
|
||||
@@ -346,7 +356,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
{
|
||||
name: 'subagent/start',
|
||||
mode: 'emit',
|
||||
signature: '\'subagent/start\'(info: SubagentRunInfo): void',
|
||||
signature: '\'subagent/start\'(this: Scoped<SubagentService>, info: SubagentRunInfo): void',
|
||||
summary: 'A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins.',
|
||||
},
|
||||
{
|
||||
@@ -359,7 +369,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
name: 'system-prompt/change',
|
||||
mode: 'emit',
|
||||
signature: '\'system-prompt/change\'(): void',
|
||||
summary: 'A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed — possibly for one scope only).',
|
||||
summary: 'A section, tool provider, variable provider, or protection was registered or unregistered (the assembly inputs changed — possibly for one scope only).',
|
||||
},
|
||||
{
|
||||
name: 'tools/change',
|
||||
@@ -385,6 +395,12 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
signature: '\'tools/pre-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>',
|
||||
summary: 'Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code\'s `PreToolUse`).',
|
||||
},
|
||||
{
|
||||
name: 'tools/result',
|
||||
mode: 'parallel',
|
||||
signature: '\'tools/result\'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void',
|
||||
summary: 'Awaited notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.',
|
||||
},
|
||||
{
|
||||
name: 'workflow/agent-end',
|
||||
mode: 'emit',
|
||||
@@ -431,7 +447,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'AgentFactory',
|
||||
declaration: 'export interface AgentFactory {\n createAgent(options: CreateAgentOptions): AgentHandle;\n resume(options: ResumeAgentOptions): Promise<AgentHandle>;\n}',
|
||||
declaration: 'export interface AgentFactory {\n createAgent(options: CreateAgentOptions): Promise<AgentHandle>;\n resume(options: ResumeAgentOptions): Promise<AgentHandle>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AgentHandle',
|
||||
@@ -563,7 +579,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CreateAgentOptions',
|
||||
declaration: 'export interface CreateAgentOptions {\n agentId: AgentId;\n sessionId: SessionId;\n meta?: {\n cwd?: string;\n parentSession?: SessionId;\n seedLength?: number;\n };\n seed?: SessionEvent[];\n agentOptions?: AgentOptions;\n setup?: (agentCtx: Context) => void;\n}',
|
||||
declaration: 'export interface CreateAgentOptions {\n agentId: AgentId;\n sessionId: SessionId;\n meta?: {\n cwd?: string;\n parentSession?: SessionId;\n seedLength?: number;\n };\n seed?: SessionEvent[];\n agentOptions?: AgentOptions;\n setup?: (agentCtx: Context) => Promise<void> | void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CreateSessionOptions',
|
||||
@@ -665,6 +681,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'PromptAssembly',
|
||||
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PromptProtection',
|
||||
declaration: 'export interface PromptProtection {\n sections?: readonly string[];\n tools?: readonly string[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'PromptSection',
|
||||
declaration: 'export interface PromptSection {\n name: string;\n order: number;\n text: string | ((context: AssembleContext) => string);\n}',
|
||||
@@ -675,7 +695,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ResumeAgentOptions',
|
||||
declaration: 'export interface ResumeAgentOptions {\n agentId: AgentId;\n resumeSessionId: SessionId;\n agentOptions?: AgentOptions;\n}',
|
||||
declaration: 'export interface ResumeAgentOptions {\n agentId: AgentId;\n resumeSessionId: SessionId;\n agentOptions?: AgentOptions;\n setup?: (agentCtx: Context) => Promise<void> | void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ScopeKey',
|
||||
@@ -807,12 +827,24 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ToolExecution',
|
||||
declaration: 'export interface ToolExecution {\n callId: CallId;\n name: string;\n arguments: unknown;\n agent?: Agent;\n signal?: AbortSignal;\n}',
|
||||
declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolExecutionInput',
|
||||
declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolExecutionResult',
|
||||
declaration: 'export interface ToolExecutionResult {\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContext?: HookContext;\n meta?: unknown;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolExecutionToken',
|
||||
declaration: 'export interface ToolExecutionToken {\n readonly [toolExecutionTokenBrand]: true;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ToolGuard',
|
||||
declaration: 'export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined;',
|
||||
},
|
||||
{
|
||||
name: 'ToolProviderResult',
|
||||
declaration: 'export interface ToolProviderResult {\n schemas: ToolSchema[];\n knownNames?: readonly string[];\n}',
|
||||
|
||||
@@ -253,8 +253,8 @@ const CTX_VERBS = new Set(['on', 'once', 'provide', 'timeout', 'interval', 'setT
|
||||
* metadata (`schemas`, and `get` returning a schema view, never the live
|
||||
* `ToolDefinition`). Exposing the raw definition would hand mount code the
|
||||
* tool's `execute` function, letting it call another tool directly and bypass
|
||||
* `ToolRegistry.execute` — the pre/post-execute waterfall (permission gates,
|
||||
* accounting) and result normalization. So `get` returns the same
|
||||
* `ToolRegistry.execute` — identity protection, pre-policy, monotonic guards,
|
||||
* around dispatch, post-policy, final observation, and result normalization. So `get` returns the same
|
||||
* name/description/parameters view as `schemas()`, and nothing invocable.
|
||||
*/
|
||||
function sandboxTools(ctx: Context): Record<string, unknown> {
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -16,6 +16,51 @@ import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { Inbox } from './inbox.ts'
|
||||
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
|
||||
/** Agents whose rollback-covered publication enabled driving. */
|
||||
const driveEnabledAgents = new WeakSet<ReactLoopAgent>()
|
||||
|
||||
/** Sessions already claimed by a concrete driver construction. */
|
||||
const claimedDriverSessions = new WeakSet<Session>()
|
||||
|
||||
/** Module-private driver entry: its symbol is absent from the package surface. */
|
||||
const startDriver = Symbol('dsh.agent-loop.start-driver')
|
||||
|
||||
/** Factory-owned controls that can operate only on the agent created with them. */
|
||||
export interface PreparedReactLoopAgent {
|
||||
/** The unpublished concrete agent. */
|
||||
agent: ReactLoopAgent
|
||||
/** Open its driving verbs at the rollback-covered publication boundary. */
|
||||
enableDrive(): void
|
||||
/** Start its driver after publication and session-start notification. */
|
||||
startDriver(): () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct one concrete agent together with unforgeable, instance-bound
|
||||
* lifecycle controls. The package surface deliberately exposes neither source
|
||||
* subpaths nor this helper: setup code may identify the concrete class, but it
|
||||
* cannot enable or start the factory's unpublished instance.
|
||||
* @param ctx - the agent-loop service context used for driving and events.
|
||||
* @param id - the concrete agent identity.
|
||||
* @param options - loop options for the agent.
|
||||
* @param session - the prepared session the agent will own.
|
||||
* @returns the agent and closures bound only to that exact instance.
|
||||
*/
|
||||
export function prepareReactLoopAgent(
|
||||
ctx: Context, id: AgentId, options: AgentOptions, session: Session,
|
||||
): PreparedReactLoopAgent {
|
||||
if (claimedDriverSessions.has(session)) {
|
||||
throw new Error(`session "${session.id}" already has a concrete agent driver`)
|
||||
}
|
||||
claimedDriverSessions.add(session)
|
||||
const agent = new ReactLoopAgent(ctx, id, options, session)
|
||||
return {
|
||||
agent,
|
||||
enableDrive: () => { driveEnabledAgents.add(agent) },
|
||||
startDriver: () => agent[startDriver](),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
|
||||
*
|
||||
@@ -24,11 +69,8 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
* the agent/* event taxonomy — plugins never need this class.
|
||||
*/
|
||||
export class ReactLoopAgent implements Agent {
|
||||
/**
|
||||
* The queued + steering FIFOs behind {@link send}/{@link steer}. Public so
|
||||
* the driver loop can drain it; {@link cancel} clears it wholesale.
|
||||
*/
|
||||
readonly inbox = new Inbox()
|
||||
/** Queued + steering FIFOs; native-private so setup cannot bypass driving verbs. */
|
||||
readonly #inbox = new Inbox()
|
||||
|
||||
/**
|
||||
* The agent's scope context ({@link Agent.ctx}), wired by the factory right
|
||||
@@ -119,7 +161,7 @@ export class ReactLoopAgent implements Agent {
|
||||
/**
|
||||
* Resolve and clear all pending {@link whenIdle} waiters. Called on a
|
||||
* running→idle transition (from {@link setStatus}) and on disposal (from the
|
||||
* {@link start} disposer, which chains `done` for true loop-exit quiescence).
|
||||
* internal driver disposer, which chains `done` for true loop-exit quiescence).
|
||||
*/
|
||||
private settleIdleWaiters(): void {
|
||||
const waiters = this.idleWaiters
|
||||
@@ -131,22 +173,31 @@ export class ReactLoopAgent implements Agent {
|
||||
return options?.source ?? { kind: 'user' }
|
||||
}
|
||||
|
||||
/** Reject every driving verb while creation setup still owns the agent. */
|
||||
private assertDriveEnabled(action: string): void {
|
||||
if (driveEnabledAgents.has(this)) return
|
||||
throw new Error(`agent "${this.id}" cannot ${action} before creation setup completes`)
|
||||
}
|
||||
|
||||
send(content: ContentBlock[], options?: SendOptions): void {
|
||||
this.assertDriveEnabled('send')
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
const source = this.resolveSource(options)
|
||||
this.inbox.enqueue({ content, source })
|
||||
this.#inbox.enqueue({ content, source })
|
||||
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: false })
|
||||
}
|
||||
|
||||
steer(content: ContentBlock[], options?: SendOptions): void {
|
||||
this.assertDriveEnabled('steer')
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
if (this._status !== 'running') { this.send(content, options); return }
|
||||
const source = this.resolveSource(options)
|
||||
this.inbox.steer({ content, source })
|
||||
this.#inbox.steer({ content, source })
|
||||
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: true })
|
||||
}
|
||||
|
||||
inject(content: ContentBlock[], options?: SendOptions): void {
|
||||
this.assertDriveEnabled('inject')
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
const source = this.resolveSource(options)
|
||||
if (isTurnOpen(this.session)) {
|
||||
@@ -220,6 +271,7 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
cancel(reason?: string): void {
|
||||
this.assertDriveEnabled('cancel')
|
||||
// Arm-gate: only mark a cancellation when there is actually work to cancel —
|
||||
// a running turn, an in-flight step, or queued/steering work. An idle cancel
|
||||
// with nothing pending is a true no-op; arming the marker then would wrongly
|
||||
@@ -229,7 +281,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// the pre-step window (a send() queued but the loop not yet flipped to
|
||||
// running) has status `idle` with `hasQueued` true, and the marker exists
|
||||
// precisely to cover it.
|
||||
if (this._status === 'running' || this.currentAbort !== undefined || this.inbox.hasQueued || this.inbox.hasSteering) {
|
||||
if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) {
|
||||
this.cancelRequested = true
|
||||
// Capture the resolved reason for the marker-only windows (pre-step /
|
||||
// continuation). The mid-step path reads it from abort.signal.reason
|
||||
@@ -240,7 +292,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// cancelled turn's steering is not re-enqueued). Cleared directly even when
|
||||
// the loop is parked in waitForQueued — there is no turn to stop and nothing
|
||||
// left for the parked loop to run, so no wake is needed.
|
||||
this.inbox.clear()
|
||||
this.#inbox.clear()
|
||||
// Interrupt an in-flight step immediately (the running turn observes the
|
||||
// abort and ends `aborted`). The marker covers the windows where no step is
|
||||
// running (pre-step, continuation).
|
||||
@@ -263,7 +315,7 @@ export class ReactLoopAgent implements Agent {
|
||||
*/
|
||||
whenIdle(): Promise<void> {
|
||||
if (this._status === 'disposed') return this.done
|
||||
if (this._status !== 'running' && !this.inbox.hasQueued) return Promise.resolve()
|
||||
if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve()
|
||||
// Register an internal waiter (resolved by settleIdleWaiters on the next
|
||||
// running→idle/disposed transition), NOT an effect-scoped `ctx.on` listener:
|
||||
// a concurrent fiber disposal runs this agent's listener disposers, which
|
||||
@@ -287,8 +339,9 @@ export class ReactLoopAgent implements Agent {
|
||||
* @returns the disposer — idempotent and infallible (it runs inside the
|
||||
* fiber's LIFO disposal chain, where a throw would skip later disposers).
|
||||
*/
|
||||
start(): () => void {
|
||||
[startDriver](): () => void {
|
||||
this.done = runLoop(this.loopCtx, this, {
|
||||
inbox: this.#inbox,
|
||||
setStatus: (status) => { this.setStatus(status) },
|
||||
setAbort: controller => void (this.currentAbort = controller),
|
||||
disposed: this.disposed,
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
* @module @deepseek-ai/dsh-agent-loop
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { Context, FiberState, Service } from 'cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import z from 'schemastery'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
@@ -19,11 +20,9 @@ import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { ReactLoopAgent } from './agent.ts'
|
||||
import { prepareReactLoopAgent, ReactLoopAgent } from './agent.ts'
|
||||
|
||||
export { ReactLoopAgent } from './agent.ts'
|
||||
export { Inbox, type InboxMessage } from './inbox.ts'
|
||||
export { runLoop } from './loop.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -70,6 +69,10 @@ export interface Config {
|
||||
export class AgentLoop extends Service implements AgentFactory {
|
||||
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
|
||||
|
||||
/** IDs held by unpublished async creation transactions. */
|
||||
private pendingAgentIds = new Set<AgentId>()
|
||||
private pendingSessionIds = new Set<SessionId>()
|
||||
|
||||
// The schema validates plain strings (cordis.yml config values are untyped at
|
||||
// runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId`
|
||||
// because the config format is the boundary where an id enters. The brand is a
|
||||
@@ -165,18 +168,28 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* and agent options.
|
||||
* @returns the handle whose dispose tears down exactly this agent.
|
||||
*/
|
||||
createAgent(options: CreateAgentOptions): AgentHandle {
|
||||
// Check the agent id BEFORE preparing the session: register() would reject a
|
||||
// duplicate id only AFTER the session enters the store, leaving an orphaned
|
||||
// live session (and lazy persistence state) that blocks reuse of that id.
|
||||
this.assertAgentIdFree(options.agentId)
|
||||
const session = this.ctx.sessions.prepare(options.sessionId, {
|
||||
...options.seed !== undefined ? { seed: options.seed } : {},
|
||||
meta: options.meta ?? {},
|
||||
})
|
||||
// A seeded (forked) create is still a fresh start, NOT a resume — `resume`
|
||||
// is reserved for reloading a PERSISTED session via resume()/resumeWith().
|
||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'startup', options.setup)
|
||||
async createAgent(options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
// Snapshot every caller-owned field before the first async setup boundary.
|
||||
// The callback itself is an identity capability; all data fields are
|
||||
// detached so caller mutation cannot drift a reserved/published identity or
|
||||
// the options the accepted agent observes.
|
||||
const agentId = options.agentId
|
||||
const sessionId = options.sessionId
|
||||
const setup = options.setup
|
||||
const agentOptions = structuredClone(options.agentOptions ?? {})
|
||||
const seed = options.seed === undefined ? undefined : structuredClone(options.seed)
|
||||
const meta = structuredClone(options.meta ?? {})
|
||||
const release = this.reserve(agentId, sessionId)
|
||||
try {
|
||||
const session = this.ctx.sessions.prepare(sessionId, {
|
||||
...seed !== undefined ? { seed } : {},
|
||||
meta,
|
||||
})
|
||||
// A seeded (forked) create is still a fresh start, NOT a resume.
|
||||
return await this.startOwned(agentId, agentOptions, session, 'startup', setup)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,31 +239,76 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* AgentLoop's static inject, so they resolve fine).
|
||||
*/
|
||||
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
this.assertAgentIdFree(options.agentId)
|
||||
const { meta, events } = await persistence.load(options.resumeSessionId)
|
||||
// Re-check the agent id AFTER the await: the pre-load check above can go
|
||||
// stale while load() is pending (a concurrent resume/create may register the
|
||||
// same id). Re-checking immediately before prepare()/start keeps the
|
||||
// "no orphaned session on a duplicate id" guarantee under concurrency.
|
||||
this.assertAgentIdFree(options.agentId)
|
||||
// Reconstruct the live session with the FULL persisted header (createdAt,
|
||||
// cwd, lineage) so resume preserves identity, not just the cwd. The seed
|
||||
// events make lastTurnNumber/deriveMessages continue; the backend already
|
||||
// has state (cursor) from the load above, so onCreated is a no-op and the
|
||||
// seed is not re-persisted. prepare() (not create()) so the session
|
||||
// lifecycle folds into the agent's composite effect (ordered teardown).
|
||||
const session = this.ctx.sessions.prepare(options.resumeSessionId, {
|
||||
seed: events,
|
||||
meta: {
|
||||
createdAt: meta.createdAt,
|
||||
...meta.cwd !== undefined ? { cwd: meta.cwd } : {},
|
||||
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
|
||||
// Reconstruct the seed boundary from the persisted header, NOT from
|
||||
// `events.length` (the resume seeds the WHOLE stored log).
|
||||
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
|
||||
},
|
||||
})
|
||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'resume')
|
||||
// Persistence is an async trust boundary. Reserve, load, reconstruct, and
|
||||
// publish only the identities/options accepted at entry—never fields
|
||||
// reread from a caller-owned object after the await.
|
||||
const agentId = options.agentId
|
||||
const sessionId = options.resumeSessionId
|
||||
const agentOptions = structuredClone(options.agentOptions ?? {})
|
||||
const setup = options.setup
|
||||
const { promise: ownerDisposed, resolve: markOwnerDisposed } = Promise.withResolvers<void>()
|
||||
const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers<void>()
|
||||
let observingOwner = true
|
||||
// Resume must observe its caller from BEFORE persistence I/O begins. The
|
||||
// full agent lifecycle does not exist until load returns, so without this
|
||||
// sentinel a never-settling backend outlives owner disposal and holds both
|
||||
// public identities forever. `this.ctx.effect` retains the traceable caller
|
||||
// ownership used by startOwned's lifecycle effect. Install it before even
|
||||
// reserving the ids: an inactive owner cannot leak a reservation if effect
|
||||
// registration fails.
|
||||
const disposeLoadSentinel = this.ctx.effect(() => () => {
|
||||
if (!observingOwner) return
|
||||
markOwnerDisposed()
|
||||
// Owner-triggered teardown does not reach quiescence until the resume
|
||||
// transaction has observed disposal and released both reservations.
|
||||
return transactionSettled
|
||||
}, `agentLoop.resumeLoad(${agentId})`)
|
||||
try {
|
||||
const release = this.reserve(agentId, sessionId)
|
||||
try {
|
||||
const loadTask = persistence.load(sessionId)
|
||||
const { meta, events } = await Promise.race([
|
||||
loadTask,
|
||||
ownerDisposed.then(() => {
|
||||
throw new Error(`agent "${agentId}" resume aborted: owner disposed during persistence load`)
|
||||
}),
|
||||
])
|
||||
// An out-of-band direct registry/session insertion can still race this
|
||||
// service's reservation, so the public enter primitives re-check exact
|
||||
// liveness at publication.
|
||||
const session = this.ctx.sessions.prepare(sessionId, {
|
||||
seed: events,
|
||||
meta: {
|
||||
createdAt: meta.createdAt,
|
||||
...meta.cwd !== undefined ? { cwd: meta.cwd } : {},
|
||||
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
|
||||
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
|
||||
},
|
||||
})
|
||||
// Calling startOwned synchronously installs the complete lifecycle
|
||||
// effect before it reaches its first setup await. Only then disarm the
|
||||
// load sentinel: ownership passes directly from one effect to the other
|
||||
// with no disposal gap.
|
||||
const starting = this.startOwned(agentId, agentOptions, session, 'resume', setup)
|
||||
observingOwner = false
|
||||
await disposeLoadSentinel()
|
||||
return await starting
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
// Manual handoff/removal must not return transactionSettled: awaiting
|
||||
// that promise from inside this transaction would deadlock it. If the
|
||||
// owner already triggered cleanup, this idempotent second disposal is a
|
||||
// no-op and the owner's first cleanup remains parked on the shared
|
||||
// settlement promise.
|
||||
observingOwner = false
|
||||
await disposeLoadSentinel()
|
||||
} finally {
|
||||
markTransactionSettled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,109 +318,136 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* only after the session has already entered the store.
|
||||
*/
|
||||
private assertAgentIdFree(id: AgentId): void {
|
||||
if (this.ctx.agents.get(id) !== undefined) {
|
||||
if (this.ctx.agents.get(id) !== undefined || this.pendingAgentIds.has(id)) {
|
||||
throw new Error(`agent "${id}" is already registered`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reserve both public identities for one unpublished async transaction. */
|
||||
private reserve(agentId: AgentId, sessionId: SessionId): () => void {
|
||||
this.assertAgentIdFree(agentId)
|
||||
if (this.ctx.sessions.get(sessionId) !== undefined || this.pendingSessionIds.has(sessionId)) {
|
||||
throw new Error(`session "${sessionId}" already exists`)
|
||||
}
|
||||
this.pendingAgentIds.add(agentId)
|
||||
this.pendingSessionIds.add(sessionId)
|
||||
return () => {
|
||||
this.pendingAgentIds.delete(agentId)
|
||||
this.pendingSessionIds.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared: construct a ReactLoopAgent over a PREPARED (not-yet-entered)
|
||||
* session, then build the ONE composite effect that owns the whole agent
|
||||
* lifecycle — session entry, registry registration, and the loop. Keeping all
|
||||
* three in a SINGLE effect (not sibling effects) is load-bearing: a fiber
|
||||
* unload disposes sibling effects CONCURRENTLY (`Promise.all`), which would
|
||||
* race the session detach against the loop's closing flush and drop the
|
||||
* closing `turn/end`. Inside one effect the disposers run as an ORDERED LIFO
|
||||
* chain — the runtime awaits each disposer's returned promise before the next:
|
||||
*
|
||||
* yield session-detach (disposed LAST — detach onAppend + remove entry)
|
||||
* yield register (disposed 2nd — unregister)
|
||||
* yield stop-and-drain (disposed FIRST — request loop stop, await agent.done)
|
||||
*
|
||||
* So on teardown: the loop is stopped and AWAITED to exit (its final
|
||||
* `session/flush` + `turn/end` fire through the still-attached `onAppend`),
|
||||
* THEN the agent is unregistered, THEN the session is detached — capturing the
|
||||
* closing events before detach, whether the trigger is the handle's `dispose()`
|
||||
* OR a fiber unload. Rollback safety: each yield runs before the next mutation,
|
||||
* so a throwing `session/created`/`agent/created` listener unwinds the
|
||||
* already-yielded disposers instead of leaking.
|
||||
*
|
||||
* `source` says why the session began ({@link SessionStartSource}); it is
|
||||
* emitted as `agent/session-start` once, AFTER the agent is registered (so a
|
||||
* listener can resolve the agent via `ctx.agents.get(id)` and `inject()` into
|
||||
* it) and BEFORE the loop starts its first turn. The emit is contained: a
|
||||
* throwing session-start listener must not abort agent construction — it is
|
||||
* logged, and the agent still starts. (Unlike a turn-boundary throw, there is
|
||||
* no open turn here to balance; the durable evidence of a session-start hook
|
||||
* is whatever it `inject()`ed.)
|
||||
*
|
||||
* Returns the agent plus the composite effect's disposer (`disposeAgent`).
|
||||
* Construct an unpublished agent and synchronously install its complete
|
||||
* teardown skeleton before any setup await. The closures are assigned their
|
||||
* session/registry/loop disposers only at publication, while the exact scope
|
||||
* disposer is nested immediately. Therefore owner unload during setup flips
|
||||
* `active`, unwinds the scope, and wins the race without any late Cordis
|
||||
* effect collection.
|
||||
*/
|
||||
private start(
|
||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
||||
setup?: (agentCtx: Context) => void,
|
||||
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
|
||||
const agent = new ReactLoopAgent(this.ctx, id, options, session)
|
||||
// The ONE quiescence boundary every disposal path observes. Cordis effect
|
||||
// disposers are single-shot but not await-idempotent: when the OWNING
|
||||
// fiber's unload invokes the raw wrapper first, a concurrent
|
||||
// `handle.dispose()` calling the same wrapper gets an immediate undefined
|
||||
// (epoch already cleared) — so the handle path must await THIS promise,
|
||||
// resolved by the teardown chain's final disposer, not the wrapper's
|
||||
// return. Every disposer in the chain is deliberately infallible (stop()
|
||||
// is infallible by contract, unregister/detach contain their listeners,
|
||||
// the scope unwind is cordis-contained), so the final disposer always
|
||||
// runs — a throwing link would skip the rest of a cordis dispose chain.
|
||||
private prepareLifecycle(id: AgentId, options: AgentOptions, session: Session): {
|
||||
agent: ReactLoopAgent
|
||||
active: () => boolean
|
||||
deactivated: Promise<void>
|
||||
publish: (source: SessionStartSource) => void
|
||||
disposeAgent: () => Promise<void>
|
||||
} {
|
||||
// When creation is invoked through an agent scope (subagents), the owner
|
||||
// agent's disposed status flips synchronously at handle teardown—earlier
|
||||
// than Cordis reaches nested scope effects. Include that signal in the
|
||||
// pre-publication liveness check so a same-turn parent dispose cannot race
|
||||
// an already-fulfilled setup promise into briefly publishing a child.
|
||||
const ownerAgent = this.ctx.agent
|
||||
const ownerFiber = this.ctx.fiber
|
||||
const driver = prepareReactLoopAgent(this.ctx, id, options, session)
|
||||
const { agent } = driver
|
||||
const scope: Scope = createScope(this.ctx, agent)
|
||||
agent.ctx = scope.ctx.extend({ agent })
|
||||
|
||||
let active = true
|
||||
let detachSession: (() => void) | undefined
|
||||
let detachAgent: (() => void) | undefined
|
||||
let stop: (() => void) | undefined
|
||||
const { promise: deactivated, resolve: markDeactivated } = Promise.withResolvers<void>()
|
||||
const { promise: torndown, resolve: markTorndown } = Promise.withResolvers<void>()
|
||||
const dispose = this.ctx.effect(function* (this: AgentLoop) {
|
||||
// First-yielded ⇒ disposed LAST: marks true teardown completion.
|
||||
|
||||
const dispose = this.ctx.effect(function* () {
|
||||
// First yielded, disposed last: every preceding teardown stage settled.
|
||||
yield () => { markTorndown() }
|
||||
// Mint the agent's scope (key = the agent) and wire the two-phase
|
||||
// reference: the scope context tags registrations + filters dispatch;
|
||||
// the extend adds the `ctx.agent` DX own-property on top. The raw
|
||||
// disposer is yielded IMMEDIATELY (exact function identity nests the
|
||||
// scope fiber out of the loop fiber's concurrent sibling list), so
|
||||
// there is no window in which a throw leaves the scope un-nested.
|
||||
//
|
||||
// Yield order is the REVERSE of teardown (LIFO). Teardown runs:
|
||||
// stop/drain → unregister → detach session → unwind scope
|
||||
// Detach BEFORE the scope unwind is deliberate: the scope fiber's
|
||||
// unload is asynchronous (fiber inertia), and every disposer chained
|
||||
// after an async one waits for it — detaching first keeps the
|
||||
// store/registry rollback SYNCHRONOUS on every failure path (a caller
|
||||
// that catches a throwing create() observes no half-created agent or
|
||||
// session, and the ids are immediately reusable), at the cost that a
|
||||
// scoped listener's own disposer runs after the session left the store
|
||||
// (it heard the final stop/drain flush while still attached, so
|
||||
// nothing durable is lost).
|
||||
const scope = createScope(this.ctx, agent)
|
||||
agent.ctx = scope.ctx.extend({ agent })
|
||||
// Exact identity moves the scope fiber out of the owner's concurrent
|
||||
// sibling list and into this ordered transaction.
|
||||
yield scope.rawDispose
|
||||
// Enter the session THROUGH agent.ctx so the store captures the agent's
|
||||
// scope as the session's dispatch carrier.
|
||||
yield agent.ctx.sessions.enter(session)
|
||||
yield () => {
|
||||
detachSession?.()
|
||||
detachSession = undefined
|
||||
}
|
||||
yield () => {
|
||||
detachAgent?.()
|
||||
detachAgent = undefined
|
||||
}
|
||||
// Last yielded, disposed first. Keep the pre-publication path
|
||||
// synchronous: returning a Promise only after the loop actually began
|
||||
// lets a failed announcement roll back registry/store before create's
|
||||
// rejection is observed.
|
||||
yield () => {
|
||||
active = false
|
||||
markDeactivated()
|
||||
if (stop === undefined) return
|
||||
stop()
|
||||
return agent.done
|
||||
}
|
||||
}, 'agentLoop.lifecycle()')
|
||||
|
||||
let disposing: Promise<void> | undefined
|
||||
const disposeAgent = (): Promise<void> => (disposing ??= (async () => {
|
||||
await dispose()
|
||||
await torndown
|
||||
})())
|
||||
|
||||
const publish = (source: SessionStartSource): void => {
|
||||
// Publication is one synchronous, rollback-covered sequence. Setup has
|
||||
// already completed, so its scoped listeners observe both announcements.
|
||||
detachSession = agent.ctx.sessions.enter(session)
|
||||
detachAgent = this.ctx.agents.enter(agent)
|
||||
this.ctx.sessions.announce(session)
|
||||
yield this.ctx.agents.register(agent)
|
||||
// The creator's scoped composition, inside the rollback boundary: a
|
||||
// throwing setup unwinds LIFO through register → scope → detach, so a
|
||||
// half-created agent never leaks. Setup REGISTERS (through agent.ctx),
|
||||
// it never drives — see CreateAgentOptions.setup.
|
||||
setup?.(agent.ctx)
|
||||
// Fire AFTER register (a listener can ctx.agents.get(id) + inject()) and
|
||||
// BEFORE the loop's first turn. Contained: a throwing listener is logged,
|
||||
// never aborts construction (no open turn to balance here).
|
||||
this.ctx.agents.announce(agent)
|
||||
// Setup is over and both entries are live. Open the driving surface just
|
||||
// before session-start so its listeners retain their supported ability to
|
||||
// inject/queue, while setup itself can never drive an unpublished agent.
|
||||
driver.enableDrive()
|
||||
try {
|
||||
agentEvents(this.ctx, agent).emit('agent/session-start', source)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`)
|
||||
}
|
||||
const stop = agent.start()
|
||||
// Disposed FIRST (LIFO): request loop stop (sync), then AWAIT the loop's
|
||||
// actual exit so its closing flush lands while onAppend (yielded above,
|
||||
// disposed later) is still attached.
|
||||
yield async () => { stop(); await agent.done }
|
||||
}.bind(this), 'agentLoop.start()')
|
||||
return { agent, disposeAgent: async () => { await dispose(); await torndown } }
|
||||
stop = driver.startDriver()
|
||||
}
|
||||
|
||||
return {
|
||||
agent,
|
||||
active: () => active
|
||||
&& ownerFiber.state !== FiberState.UNLOADING
|
||||
&& ownerFiber.state !== FiberState.DISPOSED
|
||||
&& ownerFiber.state !== FiberState.FAILED
|
||||
&& ownerAgent?.status !== 'disposed',
|
||||
deactivated,
|
||||
publish,
|
||||
disposeAgent,
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish a no-setup config agent synchronously. */
|
||||
private start(
|
||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
||||
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
|
||||
const lifecycle = this.prepareLifecycle(id, options, session)
|
||||
try {
|
||||
lifecycle.publish(source)
|
||||
return { agent: lifecycle.agent, disposeAgent: lifecycle.disposeAgent }
|
||||
} catch (error: unknown) {
|
||||
void lifecycle.disposeAgent()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,13 +467,37 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()`
|
||||
* helper).
|
||||
*/
|
||||
private startOwned(
|
||||
private async startOwned(
|
||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
||||
setup?: (agentCtx: Context) => void,
|
||||
): AgentHandle {
|
||||
const { agent, disposeAgent } = this.start(id, options, session, source, setup)
|
||||
let disposing: Promise<void> | undefined
|
||||
return { agent, dispose: () => (disposing ??= disposeAgent()) }
|
||||
setup?: (agentCtx: Context) => Promise<void> | void,
|
||||
): Promise<AgentHandle> {
|
||||
const lifecycle = this.prepareLifecycle(id, options, session)
|
||||
try {
|
||||
// The owner-disposal branch makes a never-settling setup unable to hold
|
||||
// the transaction or its ID reservations forever. Promise.race installs
|
||||
// rejection observation on setup even if owner disposal wins first.
|
||||
const setupTask = Promise.resolve(setup?.(lifecycle.agent.ctx))
|
||||
await Promise.race([
|
||||
setupTask,
|
||||
lifecycle.deactivated.then(() => {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}),
|
||||
])
|
||||
// Cordis begins a fiber unload synchronously but invokes nested effect
|
||||
// disposers from its next microtask. Give that already-started unload one
|
||||
// checkpoint to deactivate this lifecycle before publication; otherwise
|
||||
// an immediately fulfilled setup continuation can outrun its owner's
|
||||
// same-turn dispose and briefly publish an already-doomed child.
|
||||
await Promise.resolve()
|
||||
if (!lifecycle.active()) {
|
||||
throw new Error(`agent "${id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
lifecycle.publish(source)
|
||||
return { agent: lifecycle.agent, dispose: lifecycle.disposeAgent }
|
||||
} catch (error: unknown) {
|
||||
await lifecycle.disposeAgent()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Context } from 'cordis'
|
||||
import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, ContinuationStop, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
|
||||
@@ -20,6 +20,7 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type { ReactLoopAgent } from './agent.ts'
|
||||
import type { Inbox } from './inbox.ts'
|
||||
|
||||
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
|
||||
type CodedError = Error & { code?: string }
|
||||
@@ -35,6 +36,20 @@ function toError(error: unknown): CodedError {
|
||||
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the runtime result of the terminal-stop serial event. Event types
|
||||
* protect TypeScript listeners, but JavaScript and casts can still return an
|
||||
* arbitrary bail value; accepting one as an implicit stop would hide a broken
|
||||
* policy plugin.
|
||||
*/
|
||||
function assertContinuationStop(value: unknown): asserts value is ContinuationStop | undefined {
|
||||
if (value === undefined) return
|
||||
const candidate = Object(value) as { action?: unknown }
|
||||
if (candidate.action !== 'stop') {
|
||||
throw new Error('agent/turn-stop returned an invalid result; expected { action: \'stop\' } or undefined')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a model-call {@link FinishReason} to the step error it should raise, or
|
||||
* `undefined` when the step completed normally.
|
||||
@@ -108,6 +123,8 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
|
||||
* loop testable without a real agent.
|
||||
*/
|
||||
export interface LoopHandle {
|
||||
/** Native-private agent inbox handed to the driver only at internal startup. */
|
||||
readonly inbox: Inbox
|
||||
setStatus(status: 'idle' | 'running'): void
|
||||
setAbort(controller: AbortController | undefined): void
|
||||
/** Resolves when the agent is disposed — unblocks the idle wait. */
|
||||
@@ -185,6 +202,9 @@ export interface LoopHandle {
|
||||
* {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is
|
||||
* recorded as next-step steering
|
||||
* if action==stop && steering arrived (step/end/continuation listeners): continue anyway
|
||||
* terminal = serial agent/turn-stop ⟵ stop or abstain; after all ordinary
|
||||
* continuation and steering folding
|
||||
* if terminal: discard pending steering and break
|
||||
* if action==stop: break
|
||||
* session('turn/end') ⟵ durable turn boundary (no agent/* mirror)
|
||||
* await ctx.sessions.flush(session) ⟵ durability checkpoint (store-owned carrier)
|
||||
@@ -210,7 +230,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
const events = agentEvents(ctx, agent)
|
||||
|
||||
while (!handle.isDisposed()) {
|
||||
await agent.inbox.waitForQueued(handle.disposed)
|
||||
await handle.inbox.waitForQueued(handle.disposed)
|
||||
if (handle.isDisposed()) break
|
||||
|
||||
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the
|
||||
@@ -228,7 +248,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
// resolve before it runs (the quiescence contract).
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (!agent.inbox.hasQueued) {
|
||||
if (!handle.inbox.hasQueued) {
|
||||
handle.settleIdle()
|
||||
continue
|
||||
}
|
||||
@@ -250,7 +270,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
// is still queued and unrun (the same early-resolve race window 1 fixes).
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (!agent.inbox.hasQueued) {
|
||||
if (!handle.inbox.hasQueued) {
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
}
|
||||
@@ -261,8 +281,9 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
// the loop waits above, so the next real turn must continue from whatever
|
||||
// turn number is actually last in the log — a stale counter would collide.
|
||||
const turn = lastTurnNumber(session) + 1
|
||||
let terminalStopped = false
|
||||
try {
|
||||
await runTurn(ctx, events, agent, handle, turn, transmission)
|
||||
terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission)
|
||||
} catch (error: unknown) {
|
||||
// Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard
|
||||
// before turn/start) — no turn/start was appended, so no turn is open and
|
||||
@@ -286,27 +307,30 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
// cancelled.
|
||||
handle.clearCancel()
|
||||
|
||||
// Steering that arrived too late to join this turn (turn-end listeners,
|
||||
// flush) becomes a queued message — it must never be stranded. (A cancelled
|
||||
// turn already cleared its steering, so there is nothing to re-enqueue.)
|
||||
for (const message of agent.inbox.drainSteering()) {
|
||||
agent.inbox.enqueue(message)
|
||||
// Steering that arrived too late to join an ordinary turn (turn-end
|
||||
// listeners, flush) becomes queued input so it is never stranded. A
|
||||
// terminal-stop owner is the deliberate exception: discard the steering
|
||||
// again after the close + flush window so terminal policy cannot be undone
|
||||
// after its in-turn drain. Ordinary queued sends live in a separate FIFO and
|
||||
// remain untouched.
|
||||
for (const message of handle.inbox.drainSteering()) {
|
||||
if (!terminalStopped) handle.inbox.enqueue(message)
|
||||
}
|
||||
|
||||
if (!agent.inbox.hasQueued) handle.setStatus('idle')
|
||||
if (!handle.inbox.hasQueued) handle.setStatus('idle')
|
||||
}
|
||||
}
|
||||
|
||||
async function runTurn(
|
||||
ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog,
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
const { session } = agent
|
||||
|
||||
// --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end —
|
||||
// turn/start has not been appended — so it propagates to runLoop's backstop
|
||||
// untouched. The queued messages are drained here but appended AFTER
|
||||
// turn/start (below), so every event in the log lives inside a turn.
|
||||
const queued = agent.inbox.drainQueued()
|
||||
const queued = handle.inbox.drainQueued()
|
||||
const first = queued[0]
|
||||
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
|
||||
if (!first) throw new Error('runTurn invariant violated: no queued message at turn start')
|
||||
@@ -316,6 +340,7 @@ async function runTurn(
|
||||
let step = 0
|
||||
let stepOpen = false
|
||||
let errorReported = false
|
||||
let terminalStopped = false
|
||||
|
||||
// Close the open step exactly once (idempotent via stepOpen). Step boundaries
|
||||
// are durable session events only — there is no agent/* step emit to mirror
|
||||
@@ -449,7 +474,7 @@ async function runTurn(
|
||||
|
||||
// Steering from the previous round's continuation listeners joins before
|
||||
// the request.
|
||||
drainSteering(agent, turn)
|
||||
drainSteering(agent, handle.inbox, turn)
|
||||
|
||||
// The step's AbortController exists BEFORE any async pre-step work so a
|
||||
// dispose() or cancel() — in a synchronous turn-start listener or an
|
||||
@@ -616,7 +641,7 @@ async function runTurn(
|
||||
if (stepReason) reason = stepReason
|
||||
|
||||
// Steering that arrived during streaming/tool execution.
|
||||
const steered = drainSteering(agent, turn)
|
||||
const steered = drainSteering(agent, handle.inbox, turn)
|
||||
|
||||
if (closeStep()) break
|
||||
|
||||
@@ -638,14 +663,39 @@ async function runTurn(
|
||||
// iteration drains it before its request — the typed twin of the /goal
|
||||
// step/end-steer pattern.
|
||||
if (decision.action === 'continue' && decision.reason) {
|
||||
agent.inbox.steer({ content: decision.reason.content, source: decision.reason.source })
|
||||
handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source })
|
||||
}
|
||||
let shouldContinue = decision.action === 'continue'
|
||||
|
||||
// Steering from step/end session-event or continuation listeners (the
|
||||
// /goal pattern) demands the model see it — it overrides a stop decision;
|
||||
// the next iteration's drain records it.
|
||||
if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true
|
||||
if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true
|
||||
|
||||
// Terminal policy runs only AFTER the extensible continuation waterfall,
|
||||
// its optional reason, and late steering have all been folded. Unlike the
|
||||
// waterfall, this serial seam is monotonic: the first stop bail wins, and
|
||||
// no later listener or steering override can resurrect the turn.
|
||||
let terminalStop = false
|
||||
try {
|
||||
const stop = await events.strictSerial('agent/turn-stop', turn)
|
||||
assertContinuationStop(stop)
|
||||
terminalStop = stop !== undefined
|
||||
} catch (error: unknown) {
|
||||
// A broken terminal policy is an ordinary continuation failure: fail
|
||||
// this turn closed while leaving the driver alive for later turns.
|
||||
failTurn(toError(error))
|
||||
break
|
||||
}
|
||||
if (terminalStop) {
|
||||
terminalStopped = true
|
||||
// A continuation reason or listener may have queued steering before the
|
||||
// terminal checkpoint. Discard only steering (never ordinary queued
|
||||
// prompts) so it cannot become a next step or be re-enqueued as a fresh
|
||||
// turn by runLoop's late-steering fallback.
|
||||
handle.inbox.drainSteering()
|
||||
shouldContinue = false
|
||||
}
|
||||
|
||||
// A cancel that landed during the continuation window — after the step's
|
||||
// AbortController was cleared (setAbort(undefined)) but before the next
|
||||
@@ -720,11 +770,12 @@ async function runTurn(
|
||||
// contained: a throwing agent/error listener must not escape the loop.
|
||||
}
|
||||
}
|
||||
return terminalStopped
|
||||
}
|
||||
|
||||
/** Drain the steering queue into the session. Returns whether any arrived. */
|
||||
function drainSteering(agent: ReactLoopAgent, turn: number): boolean {
|
||||
const messages = agent.inbox.drainSteering()
|
||||
function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boolean {
|
||||
const messages = inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
@@ -226,16 +227,19 @@ describe('ReactLoopAgent', () => {
|
||||
})
|
||||
|
||||
it('disposer is idempotent (double-stop)', async () => {
|
||||
// Create a bare ReactLoopAgent and call start() directly to get the disposer.
|
||||
// Then call it twice — the second call hits the early-return branch.
|
||||
// Create a bare ReactLoopAgent and start it through the package-internal
|
||||
// test seam. Then call its disposer twice — the second call hits the
|
||||
// early-return branch.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('test'))
|
||||
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const { agent } = prepared
|
||||
|
||||
// Start the loop to get the disposer; the agent waits for messages
|
||||
// (idle, never-resolving cancel), so it will stay idle.
|
||||
const dispose = agent.start()
|
||||
prepared.enableDrive()
|
||||
const dispose = prepared.startDriver()
|
||||
|
||||
// First dispose
|
||||
dispose()
|
||||
@@ -324,7 +328,7 @@ describe('ReactLoopAgent', () => {
|
||||
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
|
||||
// while running (not the fast path), then the disposer settles it and chains
|
||||
// `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct
|
||||
// start() disposer keeps the emit synchronous.
|
||||
// internal driver disposer keeps the emit synchronous.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -334,8 +338,10 @@ describe('ReactLoopAgent', () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const session = ctx.sessions.create(SessionId('bare'))
|
||||
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const dispose = agent.start()
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const { agent } = prepared
|
||||
prepared.enableDrive()
|
||||
const dispose = prepared.startDriver()
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
@@ -202,7 +202,7 @@ describe('Agent.cancel()', () => {
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const handle = ctx.agents.create({
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('a-dispose-prefix'),
|
||||
sessionId: SessionId('dispose-prefix-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
@@ -333,7 +333,7 @@ describe('Agent.cancel()', () => {
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const handle = ctx.agents.create({
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('a-dispose-step-start'),
|
||||
sessionId: SessionId('dispose-step-start-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('config-driven session id', () => {
|
||||
await ctx1.plugin(AgentLoop, { agents: [] })
|
||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
@@ -1,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
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('agent loop', () => {
|
||||
it('resolves {{cwd}} from the agent session workspace (factory create with meta.cwd)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter, 'Working in {{cwd}}.')
|
||||
const handle = ctx.agents.create({
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('a-cwd'),
|
||||
sessionId: SessionId('s-cwd'),
|
||||
meta: { cwd: '/work/space' },
|
||||
@@ -243,6 +243,44 @@ describe('agent loop', () => {
|
||||
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou run on mock.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['BigInt', { n: 1n }],
|
||||
['Map', new Map([['key', 'value']])],
|
||||
['class instance', new (class ResultMeta { x = 1 })()],
|
||||
])('normalizes non-JSON tool meta (%s) before the durable result commit', async (_kind, meta) => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'),
|
||||
textResponse('recovered'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'bad-meta',
|
||||
description: 'returns invalid durable metadata',
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }),
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('bad-meta-agent'), { model: 'mock' })
|
||||
|
||||
send(agent, 'use the tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const result = agent.session.events.find(event => event.type === 'tool/result')
|
||||
expect(result?.type).toBe('tool/result')
|
||||
if (result?.type === 'tool/result') {
|
||||
expect(result.data.callId).toBe('bad-meta-call')
|
||||
expect(result.data.isError).toBe(true)
|
||||
expect(result.data.meta).toBeUndefined()
|
||||
expect(result.data.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Error: tools/execute must return a losslessly JSON-serializable ToolExecutionResult',
|
||||
}])
|
||||
}
|
||||
// The normalized failure was durably logged and fed back to the model; the
|
||||
// turn continued normally instead of failing after an apparent success.
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('losslessly JSON-serializable')
|
||||
})
|
||||
|
||||
it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => {
|
||||
// The documented escape valve: a deployment that must drop the harness
|
||||
// openers short-circuits the assemble waterfall; the request then carries
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('request stability across the loop', () => {
|
||||
// one's full log (the resume/fork path).
|
||||
const adapter2 = new MockAdapter([textResponse('two')])
|
||||
const ctx2 = await harness(adapter2)
|
||||
const handle = ctx2.agents.create({
|
||||
const handle = await ctx2.agents.create({
|
||||
agentId: AgentId('gen2'),
|
||||
sessionId: SessionId('gen2-session'),
|
||||
seed: [...agent.session.events],
|
||||
|
||||
@@ -19,6 +19,10 @@ afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive:
|
||||
async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context; root: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-resume-'))
|
||||
dirs.push(root)
|
||||
return { ctx: await mountPersistentHarness(root, adapter), root }
|
||||
}
|
||||
|
||||
async function mountPersistentHarness(root: string, adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -28,7 +32,22 @@ async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context;
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return { ctx, root }
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function persistSession(sessionId: SessionId): Promise<string> {
|
||||
const { ctx, root } = await persistentHarness(new MockAdapter([textResponse('seed')]))
|
||||
// Persistence deliberately has no artifact for a truly empty session. A
|
||||
// balanced completed turn is the smallest resumable log and avoids running
|
||||
// the model merely to construct this lifecycle fixture.
|
||||
const seed: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const session = ctx.sessions.create(sessionId, { seed })
|
||||
await ctx.sessions.flush(session)
|
||||
await ctx.fiber.dispose()
|
||||
return root
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
@@ -39,11 +58,22 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
/** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */
|
||||
async function promptly<T>(task: Promise<T>): Promise<T> {
|
||||
const timeout = Promise.withResolvers<never>()
|
||||
const timer = setTimeout(() => { timeout.reject(new Error('lifecycle task did not settle promptly')) }, 1000)
|
||||
try {
|
||||
return await Promise.race([task, timeout.promise])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
const { agent } = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
|
||||
const { agent } = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
|
||||
expect(agent.session.id).toBe('custom-session')
|
||||
expect(agent.session.header.cwd).toBe('/w')
|
||||
await ctx.fiber.dispose()
|
||||
@@ -52,10 +82,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
|
||||
await ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
|
||||
// A second create with the SAME agent id but a fresh session id must reject
|
||||
// up front — and must NOT leave an orphaned 'sess-b' session behind.
|
||||
expect(() => ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).toThrow(/already registered/)
|
||||
await expect(ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).rejects.toThrow(/already registered/)
|
||||
expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -63,7 +93,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
it('createAgent works without meta (no cwd)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
const { agent } = ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
|
||||
const { agent } = await ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
|
||||
expect(agent.session.id).toBe('nometa-session')
|
||||
expect(agent.session.header.cwd).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
@@ -73,7 +103,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// Lifecycle 1: create a no-cwd session and run a turn.
|
||||
const adapter1 = new MockAdapter([textResponse('a')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
@@ -100,7 +130,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const sources1: string[] = []
|
||||
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') })).agent as ReactLoopAgent
|
||||
expect(sources1).toEqual(['startup'])
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
@@ -124,6 +154,224 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume awaits setup while unpublished, then publishes a fully composed world in order', async () => {
|
||||
const sessionId = SessionId('resume-setup-success')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const setupStarted = Promise.withResolvers<undefined>()
|
||||
const order: string[] = []
|
||||
|
||||
ctx.on('session/created', (session) => {
|
||||
expect(ctx.sessions.get(session.id)).toBe(session)
|
||||
expect(ctx.agents.get(AgentId('resumed-atomic'))?.session).toBe(session)
|
||||
order.push('session/created')
|
||||
})
|
||||
ctx.on('agent/created', (agent) => {
|
||||
expect(() => { agent.cancel('too early') }).toThrow(/cannot cancel before creation setup completes/)
|
||||
order.push('agent/created')
|
||||
})
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
expect(() => { agent.cancel('now live') }).not.toThrow()
|
||||
order.push('agent/session-start')
|
||||
})
|
||||
|
||||
const resuming = ctx.agents.resume({
|
||||
agentId: AgentId('resumed-atomic'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: async (agentCtx) => {
|
||||
expect(agentCtx.agent?.id).toBe(AgentId('resumed-atomic'))
|
||||
expect(agentCtx.agent?.session.events).toHaveLength(2)
|
||||
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
|
||||
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
|
||||
order.push('setup:start')
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
order.push('setup:end')
|
||||
},
|
||||
})
|
||||
|
||||
await setupStarted.promise
|
||||
expect(ctx.agents.get(AgentId('resumed-atomic'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
expect(order).toEqual(['setup:start'])
|
||||
|
||||
gate.resolve(undefined)
|
||||
const handle = await resuming
|
||||
expect(order).toEqual([
|
||||
'setup:start',
|
||||
'setup:end',
|
||||
'session/created',
|
||||
'setup-listener:session/created',
|
||||
'agent/created',
|
||||
'setup-listener:agent/created',
|
||||
'agent/session-start',
|
||||
])
|
||||
await handle.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => {
|
||||
const sessionId = SessionId('resume-setup-reject')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
|
||||
await expect(ctx.agents.resume({
|
||||
agentId: AgentId('resume-reject'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: async () => {
|
||||
await Promise.resolve()
|
||||
throw new Error('resume setup failed')
|
||||
},
|
||||
})).rejects.toThrow('resume setup failed')
|
||||
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('resume-reject'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
const retry = await ctx.agents.resume({
|
||||
agentId: AgentId('resume-reject'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
await retry.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('owner unload aborts resume setup and cannot publish after the callback settles', async () => {
|
||||
const sessionId = SessionId('resume-setup-owner-unload')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const setupStarted = Promise.withResolvers<undefined>()
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
let resuming!: ReturnType<typeof ctx.agents.resume>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
resuming = inner.agents.resume({
|
||||
agentId: AgentId('resume-owner-race'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: async () => {
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
},
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
await setupStarted.promise
|
||||
|
||||
await owner.dispose()
|
||||
await expect(resuming).rejects.toThrow(/owner disposed during setup/)
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('resume-owner-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
|
||||
gate.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
expect(published).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('owner unload aborts a never-settling persistence load, releases identities, and blocks late publication', async () => {
|
||||
const sessionId = SessionId('resume-load-owner-unload')
|
||||
const agentId = AgentId('resume-load-race')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
||||
const lateLoad = Promise.withResolvers<typeof snapshot>()
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
let loads = 0
|
||||
ctx.sessionPersistence.load = (id) => {
|
||||
expect(id).toBe(sessionId)
|
||||
loads += 1
|
||||
if (loads === 1) {
|
||||
loadStarted.resolve(undefined)
|
||||
return lateLoad.promise
|
||||
}
|
||||
return Promise.resolve(structuredClone(snapshot))
|
||||
}
|
||||
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
|
||||
let resuming!: ReturnType<typeof ctx.agents.resume>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
resuming = inner.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
|
||||
}, { inject: ['agents'] }))
|
||||
await loadStarted.promise
|
||||
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during persistence load/)
|
||||
await promptly(owner.dispose())
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
|
||||
// owner.dispose() itself awaited transaction settlement and reservation
|
||||
// release: reuse the same identities BEFORE awaiting the resume rejection.
|
||||
const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }))
|
||||
await rejection
|
||||
expect(loads).toBe(2)
|
||||
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
|
||||
|
||||
// Settlement of the abandoned backend promise cannot resume the old
|
||||
// transaction or emit a second publication after the retry owns the ids.
|
||||
lateLoad.resolve(structuredClone(snapshot))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(ctx.agents.get(agentId)).toBe(retry.agent)
|
||||
expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session)
|
||||
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
|
||||
|
||||
await retry.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('snapshots resume identities and agent options before persistence load', async () => {
|
||||
const sessionId = SessionId('resume-snapshot-source')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const loaded = await ctx.sessionPersistence.load(sessionId)
|
||||
const loadGate = Promise.withResolvers<typeof loaded>()
|
||||
ctx.sessionPersistence.load = () => loadGate.promise
|
||||
|
||||
const occupied = await ctx.agents.create({
|
||||
agentId: AgentId('occupied-agent'),
|
||||
sessionId: SessionId('occupied-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
const options = {
|
||||
agentId: AgentId('accepted-agent'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
}
|
||||
const resuming = ctx.agents.resume(options)
|
||||
|
||||
options.agentId = AgentId('occupied-agent')
|
||||
options.resumeSessionId = SessionId('occupied-session')
|
||||
options.agentOptions.model = 'mutated-model'
|
||||
loadGate.resolve(structuredClone(loaded))
|
||||
|
||||
const resumed = await resuming
|
||||
expect(resumed.agent.id).toBe(AgentId('accepted-agent'))
|
||||
expect(resumed.agent.session.id).toBe(sessionId)
|
||||
expect(resumed.agent.options.model).toBe('mock')
|
||||
expect(ctx.agents.get(AgentId('occupied-agent'))).toBe(occupied.agent)
|
||||
expect(ctx.sessions.get(SessionId('occupied-session'))).toBe(occupied.agent.session)
|
||||
|
||||
await resumed.dispose()
|
||||
await occupied.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
|
||||
// Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
|
||||
// in its header) by creating it with a complete-turn seed — the write path
|
||||
@@ -170,7 +418,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// disk, since a crash before the next turn would otherwise lose it.
|
||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
||||
@@ -195,7 +443,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// drop it on reload (the bug this guards).
|
||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
||||
@@ -223,7 +471,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// Lifecycle 1: run one full turn, persisting it.
|
||||
const adapter1 = new MockAdapter([textResponse('first answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
const events1 = [...a1.session.events]
|
||||
|
||||
@@ -6,6 +6,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
@@ -458,8 +459,10 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
|
||||
ctx2.llm.registerAdapter(['mock'], second)
|
||||
|
||||
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
|
||||
const forked = new ReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
|
||||
ctx2.effect(() => forked.start())
|
||||
const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
|
||||
const forked = prepared.agent
|
||||
prepared.enableDrive()
|
||||
ctx2.effect(() => prepared.startDriver())
|
||||
|
||||
const turns: number[] = []
|
||||
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
|
||||
|
||||
@@ -8,6 +8,7 @@ import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepse
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as concreteAgentModule from '../src/agent.ts'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
@@ -49,7 +50,7 @@ describe('agent scope lifecycle', () => {
|
||||
|
||||
it('scoped registrations live in the agent world and die with the agent', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
|
||||
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
|
||||
const { agent } = handle
|
||||
agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
|
||||
agent.ctx.tools.register({
|
||||
@@ -104,12 +105,13 @@ describe('agent scope lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const handle = ctx.agents.create({
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('child'),
|
||||
sessionId: SessionId('child-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: (agentCtx) => {
|
||||
setup: async (agentCtx) => {
|
||||
order.push('setup')
|
||||
await Promise.resolve()
|
||||
agentCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You are the child.' })
|
||||
},
|
||||
})
|
||||
@@ -118,42 +120,233 @@ describe('agent scope lifecycle', () => {
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('a throwing setup unwinds the half-created agent completely', async () => {
|
||||
it('keeps both identities unpublished until async setup completes, then announces in order', async () => {
|
||||
const ctx = await harness()
|
||||
expect(() => ctx.agents.create({
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const setupStarted = Promise.withResolvers<undefined>()
|
||||
const order: string[] = []
|
||||
ctx.on('session/created', (session) => {
|
||||
expect(ctx.sessions.get(session.id)).toBe(session)
|
||||
expect(ctx.agents.get(AgentId('atomic'))?.session).toBe(session)
|
||||
order.push('session/created')
|
||||
})
|
||||
ctx.on('agent/created', () => void order.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void order.push('agent/session-start'))
|
||||
const acceptedOptions = { model: 'mock' }
|
||||
|
||||
const creating = ctx.agents.create({
|
||||
agentId: AgentId('atomic'),
|
||||
sessionId: SessionId('atomic-s'),
|
||||
agentOptions: acceptedOptions,
|
||||
setup: async (agentCtx) => {
|
||||
expect(agentCtx.agent?.id).toBe(AgentId('atomic'))
|
||||
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
|
||||
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
|
||||
order.push('setup:start')
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
order.push('setup:end')
|
||||
},
|
||||
})
|
||||
await setupStarted.promise
|
||||
expect(ctx.agents.get(AgentId('atomic'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined()
|
||||
expect(order).toEqual(['setup:start'])
|
||||
acceptedOptions.model = 'mutated while setup was pending'
|
||||
|
||||
gate.resolve(undefined)
|
||||
const handle = await creating
|
||||
expect(handle.agent.options.model).toBe('mock')
|
||||
expect(order).toEqual([
|
||||
'setup:start',
|
||||
'setup:end',
|
||||
'session/created',
|
||||
'setup-listener:session/created',
|
||||
'agent/created',
|
||||
'setup-listener:agent/created',
|
||||
'agent/session-start',
|
||||
])
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('reserves agent and session ids across concurrent async setup', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const first = ctx.agents.create({
|
||||
agentId: AgentId('reserved'),
|
||||
sessionId: SessionId('reserved-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: () => gate.promise,
|
||||
})
|
||||
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('reserved'),
|
||||
sessionId: SessionId('other-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})).rejects.toThrow(/already registered/)
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('other'),
|
||||
sessionId: SessionId('reserved-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})).rejects.toThrow(/already exists/)
|
||||
expect(ctx.agents.list()).toEqual([])
|
||||
expect(ctx.sessions.list()).toEqual([])
|
||||
|
||||
gate.resolve(undefined)
|
||||
const handle = await first
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('structurally rejects every driving verb during setup', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('no-drive'),
|
||||
sessionId: SessionId('no-drive-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: (agentCtx) => {
|
||||
const agent = agentCtx.agent!
|
||||
// Even JavaScript or a cast to the exported concrete class cannot name
|
||||
// a public start method. Driver startup is behind a module-private
|
||||
// symbol used only by AgentLoop after rollback-covered publication.
|
||||
expect(Reflect.get(agent as ReactLoopAgent, 'start')).toBeUndefined()
|
||||
expect(Reflect.get(concreteAgentModule, 'enableAgentDrive')).toBeUndefined()
|
||||
expect(Reflect.get(concreteAgentModule, 'startAgentDriver')).toBeUndefined()
|
||||
expect(() => concreteAgentModule.prepareReactLoopAgent(
|
||||
agentCtx, agent.id, agent.options, agent.session,
|
||||
)).toThrow(/already has a concrete agent driver/)
|
||||
expect(Reflect.get(agent as ReactLoopAgent, 'inbox')).toBeUndefined()
|
||||
expect(() => { agent.send(text('queued too soon')) }).toThrow(/cannot send before creation setup completes/)
|
||||
expect(() => { agent.steer(text('steered too soon')) }).toThrow(/cannot steer before creation setup completes/)
|
||||
expect(() => { agent.inject(text('injected too soon')) }).toThrow(/cannot inject before creation setup completes/)
|
||||
expect(() => { agent.cancel('cancel too soon') }).toThrow(/cannot cancel before creation setup completes/)
|
||||
expect(agent.session.events).toEqual([])
|
||||
},
|
||||
})
|
||||
expect(handle.agent.session.events).toEqual([])
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('owner unload aborts a pending setup and publishes nothing', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const setupStarted = Promise.withResolvers<undefined>()
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('owner-race'),
|
||||
sessionId: SessionId('owner-race-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: async () => {
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
},
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
await setupStarted.promise
|
||||
|
||||
await owner.dispose()
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('owner-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('owner-race-s'))).toBeUndefined()
|
||||
// Let the losing callback settle; Promise.race already observes it.
|
||||
gate.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
|
||||
// The other ordering in the same race: setup resolves first (its reaction
|
||||
// is queued), then owner disposal flips active before that continuation can
|
||||
// publish. The post-race active check must still reject.
|
||||
const gate2 = Promise.withResolvers<undefined>()
|
||||
const setupStarted2 = Promise.withResolvers<undefined>()
|
||||
let creating2!: ReturnType<typeof ctx.agents.create>
|
||||
const owner2 = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
creating2 = inner.agents.create({
|
||||
agentId: AgentId('owner-race-2'),
|
||||
sessionId: SessionId('owner-race-s-2'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: async () => {
|
||||
setupStarted2.resolve(undefined)
|
||||
await gate2.promise
|
||||
},
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
await setupStarted2.promise
|
||||
gate2.resolve(undefined)
|
||||
const unload2 = owner2.dispose()
|
||||
await expect(creating2).rejects.toThrow(/owner disposed during setup/)
|
||||
await unload2
|
||||
expect(ctx.agents.get(AgentId('owner-race-2'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a rejecting setup publishes nothing and unwinds the unpublished scope', async () => {
|
||||
const ctx = await harness()
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('bad'),
|
||||
sessionId: SessionId('bad-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: () => { throw new Error('boom setup') },
|
||||
})).toThrow('boom setup')
|
||||
setup: async () => {
|
||||
await Promise.resolve()
|
||||
throw new Error('boom setup')
|
||||
},
|
||||
})).rejects.toThrow('boom setup')
|
||||
|
||||
// Nothing leaked: no agent, no session, and the ids are reusable.
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
|
||||
const retry = ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
|
||||
const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
|
||||
await retry.dispose()
|
||||
})
|
||||
|
||||
it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => {
|
||||
const ctx = await harness()
|
||||
let boom = true
|
||||
const disposed: string[] = []
|
||||
ctx.on('agent/disposed', agent => void disposed.push(agent.id))
|
||||
ctx.on('session/created', () => {
|
||||
if (boom) { boom = false; throw new Error('boom created') }
|
||||
})
|
||||
expect(() => ctx.agents.create({
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' },
|
||||
})).toThrow('boom created')
|
||||
})).rejects.toThrow('boom created')
|
||||
expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
|
||||
expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge
|
||||
// The rollback also disposed the scope fiber: re-creating works cleanly.
|
||||
const retry = ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
|
||||
const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } })
|
||||
expect(scopeOf(retry.agent.ctx)).toBe(retry.agent)
|
||||
await retry.dispose()
|
||||
})
|
||||
|
||||
it('the synchronous config helper rolls back when publication throws', async () => {
|
||||
const ctx = await harness()
|
||||
const sessionsBefore = ctx.sessions.list().length
|
||||
let boom = true
|
||||
ctx.on('session/created', () => {
|
||||
if (boom) {
|
||||
boom = false
|
||||
throw new Error('config publish failed')
|
||||
}
|
||||
})
|
||||
|
||||
expect(() => ctx.agentLoop.create(AgentId('config-bad'), { model: 'mock' }))
|
||||
.toThrow('config publish failed')
|
||||
expect(ctx.agents.get(AgentId('config-bad'))).toBeUndefined()
|
||||
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
|
||||
})
|
||||
|
||||
it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
|
||||
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } })
|
||||
await handle.dispose()
|
||||
expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/)
|
||||
})
|
||||
@@ -195,9 +388,9 @@ describe('agent scope lifecycle', () => {
|
||||
|
||||
it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => {
|
||||
const ctx = await harness()
|
||||
let handle!: ReturnType<typeof ctx.agents.create>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
handle = inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } })
|
||||
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
||||
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
handle = await inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } })
|
||||
}, { inject: ['agents'] }))
|
||||
const { agent } = handle
|
||||
|
||||
@@ -229,9 +422,9 @@ describe('agent scope lifecycle', () => {
|
||||
|
||||
it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => {
|
||||
const ctx = await harness()
|
||||
let handle!: ReturnType<typeof ctx.agents.create>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
handle = inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } })
|
||||
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
||||
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
handle = await inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } })
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
const teardownDone: string[] = []
|
||||
|
||||
199
packages/core/agent-loop/tests/turn-stop.spec.ts
Normal file
199
packages/core/agent-loop/tests/turn-stop.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId, type ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text = 'go'): Promise<void> {
|
||||
agent.send([{ type: 'text', text }])
|
||||
return agent.whenIdle()
|
||||
}
|
||||
|
||||
function registerEcho(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: 'echo',
|
||||
parameters: { text: { type: 'string' } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: String(args.text) }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
describe('agent/turn-stop', () => {
|
||||
it('runs after steering folding and discards terminal steering instead of creating another step or turn', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('the ordinary decision is stop'),
|
||||
textResponse('must not be requested'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('terminal-steering'), { model: 'mock' })
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let steered = false
|
||||
ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
|
||||
const downstream = await next()
|
||||
if (subject === agent && !steered) {
|
||||
steered = true
|
||||
subject.steer([{ type: 'text', text: 'late continuation steering' }])
|
||||
}
|
||||
return downstream
|
||||
}, { prepend: true })
|
||||
|
||||
await send(agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('discards steering that arrives from session/flush after the terminal checkpoint', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('terminal answer'),
|
||||
textResponse('must not become a late-steering turn'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('terminal-flush-steering'), { model: 'mock' })
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let injected = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== agent.session || injected) return
|
||||
injected = true
|
||||
agent.steer([{ type: 'text', text: 'steering from flush' }])
|
||||
})
|
||||
|
||||
await send(agent)
|
||||
|
||||
expect(injected).toBe(true)
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('preserves an ordinary queued send that arrives during terminal flush', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('first terminal answer'),
|
||||
textResponse('queued follow-up answer'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('terminal-flush-send'), { model: 'mock' })
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let queued = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== agent.session || queued) return
|
||||
queued = true
|
||||
agent.send([{ type: 'text', text: 'ordinary queued follow-up' }])
|
||||
})
|
||||
|
||||
await send(agent)
|
||||
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('filters a scoped terminal listener to its own agent', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('a1', 'echo', { text: 'a' }),
|
||||
toolCallResponse('b1', 'echo', { text: 'b' }),
|
||||
textResponse('b continues normally'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
registerEcho(ctx)
|
||||
const stopped = ctx.agentLoop.create(AgentId('stopped'), { model: 'mock' })
|
||||
const ordinary = ctx.agentLoop.create(AgentId('ordinary'), { model: 'mock' })
|
||||
stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
await send(stopped)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
await send(ordinary)
|
||||
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
expect(stopped.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
expect(ordinary.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('unregisters with its scoped owner disposer', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('first', 'echo', { text: 'first' }),
|
||||
toolCallResponse('second', 'echo', { text: 'second' }),
|
||||
textResponse('continued after listener disposal'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
registerEcho(ctx)
|
||||
const agent = ctx.agentLoop.create(AgentId('owned-listener'), { model: 'mock' })
|
||||
const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
await send(agent, 'first turn')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
|
||||
disposeStop()
|
||||
await send(agent, 'second turn')
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('fails throwing and malformed terminal policies closed while the driver survives', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('throwing policy'),
|
||||
textResponse('malformed continue policy'),
|
||||
textResponse('malformed false policy'),
|
||||
textResponse('malformed null policy'),
|
||||
textResponse('healthy later turn'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('bad-policy'), { model: 'mock' })
|
||||
const reasons: TurnEndReason[] = []
|
||||
const errors: string[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
|
||||
})
|
||||
agent.ctx.on('agent/error', (_subject, _turn, _step, error) => { errors.push(error.message) })
|
||||
|
||||
const disposeThrowing = agent.ctx.on('agent/turn-stop', () => {
|
||||
throw new Error('terminal policy exploded')
|
||||
})
|
||||
await send(agent, 'first')
|
||||
disposeThrowing()
|
||||
|
||||
for (const [index, malformed] of [
|
||||
{ action: 'continue' },
|
||||
false,
|
||||
null,
|
||||
].entries()) {
|
||||
const disposeMalformed = agent.ctx.on('agent/turn-stop', () => malformed as unknown as ContinuationStop)
|
||||
await send(agent, `malformed ${index}`)
|
||||
disposeMalformed()
|
||||
}
|
||||
|
||||
await send(agent, 'healthy')
|
||||
|
||||
expect(reasons.map(reason => reason.kind)).toEqual(['error', 'error', 'error', 'error', 'completed'])
|
||||
expect(errors).toContain('terminal policy exploded')
|
||||
expect(errors).toContain("agent/turn-stop returned an invalid result; expected { action: 'stop' } or undefined")
|
||||
expect(adapter.requests).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
@@ -57,6 +57,16 @@ export interface AgentEventDispatch {
|
||||
* @returns the serial chain's result (the first bail value, if any).
|
||||
*/
|
||||
serial<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>>
|
||||
/**
|
||||
* Await listeners in order and return the first value other than `undefined`.
|
||||
* Unlike Cordis `serial`, this does not silently treat `null` or `false` as
|
||||
* abstentions. Use it for a runtime-validated public boundary whose declared
|
||||
* abstention is exactly `undefined` (currently `agent/turn-stop`).
|
||||
* @param name - the agent-subject event to dispatch.
|
||||
* @param rest - the event's arguments after the injected agent.
|
||||
* @returns the first non-undefined listener result, or undefined.
|
||||
*/
|
||||
strictSerial<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>>
|
||||
/**
|
||||
* Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The
|
||||
* declared event parameters already end with the `next` callback, so `rest`
|
||||
@@ -79,7 +89,7 @@ export interface AgentEventDispatch {
|
||||
*/
|
||||
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
|
||||
const carrier: Scoped<Agent> = scopeTarget(agent, agent)
|
||||
// The three dispatch methods forward through cordis' variadic mixins. The
|
||||
// The ordinary dispatch methods forward through Cordis' variadic mixins. The
|
||||
// fused (carrier, name, agent, ...rest) tuple is provably a valid argument
|
||||
// list for the matching thisArg overload, but TypeScript cannot relate the
|
||||
// generic Tail<K> spread back to that overload's conditional parameter
|
||||
@@ -95,6 +105,22 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
|
||||
const serial = ctx.serial as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => Promise<never>
|
||||
return await serial(carrier, name, agent, ...rest)
|
||||
},
|
||||
strictSerial(name, ...rest) {
|
||||
return (async (): Promise<unknown> => {
|
||||
// EventsService.dispatch applies the carrier filter and emits the same
|
||||
// internal/dispatch instrumentation as ctx.serial, then mutates `args`
|
||||
// down to the actual listener parameters. Invoke those callbacks in order
|
||||
// ourselves so every non-undefined value reaches the caller's validator;
|
||||
// Cordis serial would discard null/false before validation could see them.
|
||||
const args: unknown[] = [carrier, name, agent, ...rest]
|
||||
const callbacks = ctx.events.dispatch('serial', args)
|
||||
for (const callback of callbacks) {
|
||||
const result: unknown = await callback(...args)
|
||||
if (result !== undefined) return result
|
||||
}
|
||||
return undefined
|
||||
})() as Promise<Awaited<Return<Events[typeof name]>>>
|
||||
},
|
||||
waterfall(name, ...rest) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
|
||||
const waterfall = ctx.waterfall as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => never
|
||||
|
||||
@@ -18,14 +18,13 @@ declare module 'cordis' {
|
||||
interface Context {
|
||||
agents: AgentRegistry
|
||||
/**
|
||||
* The agent whose scope this context belongs to, or `undefined` on any
|
||||
* context not derived from an agent scope. Pure DX sugar over the
|
||||
* `dsh-scope` tag: the agent loop sets it as an own property on each
|
||||
* `Agent.ctx`, and {@link AgentRegistry} registers a root accessor
|
||||
* defaulting to `undefined` so the read is safe on every context (a plain
|
||||
* plugin context answers `undefined` instead of throwing the Cordis
|
||||
* unknown-property error). Core packages below the agent layer read the
|
||||
* `dsh-scope` tag (`scopeOf`) instead, never this field.
|
||||
* The agent association installed as an own property on `Agent.ctx`, or
|
||||
* `undefined` on a plain context. Contexts derived from `Agent.ctx` inherit
|
||||
* the association; a deliberately nested scope may carry a nearer
|
||||
* `dsh-scope` tag while retaining it, so this field is DX context rather
|
||||
* than the scope resolver. {@link AgentRegistry} registers a root accessor
|
||||
* defaulting to `undefined`, and core packages below the agent layer use
|
||||
* `scopeOf()` for layer selection instead of reading this field.
|
||||
*/
|
||||
agent?: Agent
|
||||
}
|
||||
@@ -66,19 +65,20 @@ export interface CreateAgentOptions {
|
||||
/** Per-agent options (model, …). */
|
||||
agentOptions?: AgentOptions
|
||||
/**
|
||||
* Creation-time composition of the agent's scoped world. The factory runs it
|
||||
* inside the agent's composite lifecycle effect — after the scope is minted
|
||||
* and the agent registered, before `agent/session-start` fires and the loop
|
||||
* starts — so everything it registers through `agentCtx` (scoped tools,
|
||||
* prompt sections/variables, `restrict()`, listeners, `agentCtx.plugin(…)`
|
||||
* profiles) exists before the first prompt assembly, and a THROWING setup
|
||||
* unwinds inside the rollback boundary instead of leaking a half-created
|
||||
* agent. **Setup registers, it never drives**: calling
|
||||
* `send`/`steer`/`inject` here would open a turn before `agent/session-start`
|
||||
* (the dev invariants flag a `turn/start` logged before session-start as a
|
||||
* teaching error) — drive the agent after creation returns.
|
||||
* Creation-time composition of the agent's scoped world. The factory awaits
|
||||
* setup after minting `agentCtx` but BEFORE inserting or announcing either
|
||||
* the session or agent, so observers can never see a partially configured
|
||||
* world. Everything registered through `agentCtx` (scoped tools, prompt
|
||||
* sections/variables, `restrict()`, listeners, awaited child plugins) exists
|
||||
* before `session/created`, `agent/created`, `agent/session-start`, and the
|
||||
* first prompt assembly. A throw/rejection or owner disposal rolls the scope
|
||||
* back without publishing either id.
|
||||
*
|
||||
* **Setup composes, it never drives**: calling `send`/`steer`/`inject` here
|
||||
* would run an unpublished agent and violate the session-start boundary.
|
||||
* Drive the agent only after the creation promise resolves.
|
||||
*/
|
||||
setup?: (agentCtx: Context) => void
|
||||
setup?: (agentCtx: Context) => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,15 +92,26 @@ export interface ResumeAgentOptions {
|
||||
resumeSessionId: SessionId
|
||||
/** Per-agent options (model, …). */
|
||||
agentOptions?: AgentOptions
|
||||
/**
|
||||
* Resume-time composition of the agent's fresh scoped world. Persistence is
|
||||
* loaded first; the factory then mints `agentCtx` and awaits setup while the
|
||||
* reconstructed session and agent remain unpublished. The callback has the
|
||||
* same composition-only contract as {@link CreateAgentOptions.setup}: all
|
||||
* registrations exist before either creation announcement, driving verbs are
|
||||
* unavailable until the session-start boundary, and rejection or owner
|
||||
* disposal rolls the transaction back without publishing either id.
|
||||
*/
|
||||
setup?: (agentCtx: Context) => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
* An owned agent plus its disposer, returned by {@link AgentRegistry.create} /
|
||||
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder
|
||||
* can tear this agent down. `dispose()` unregisters the agent, stops its loop,
|
||||
* awaits the loop's exit (quiescence — NOT just the `disposed` status flip), and
|
||||
* removes the agent's session from the store, in an order that captures the
|
||||
* loop's final `session/flush` before the session is detached.
|
||||
* can tear this agent down. `dispose()` stops the loop, awaits its exit
|
||||
* (quiescence — NOT just the `disposed` status flip), unregisters the agent,
|
||||
* removes its session from the store, and finally unwinds its scoped world.
|
||||
* This order captures the loop's final `session/flush` before the session is
|
||||
* detached and keeps scoped listeners alive through that flush.
|
||||
*
|
||||
* `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is only
|
||||
* for the OWNER that created it. Config-created agents (the loop's own startup)
|
||||
@@ -119,15 +130,27 @@ export interface AgentHandle {
|
||||
*/
|
||||
export interface AgentFactory {
|
||||
/**
|
||||
* Create, start, and register a new agent on a caller-supplied session id.
|
||||
* Returns an {@link AgentHandle} — the owner disposes it to tear down exactly
|
||||
* this agent (unregister + stop loop + await quiescence + remove session).
|
||||
* Create a new agent on a caller-supplied session id. Async because creation
|
||||
* awaits unpublished setup, inserts both session and agent, emits their
|
||||
* creation notifications in order, unlocks driving at
|
||||
* `agent/session-start`, and only then starts the loop. The sequence is
|
||||
* rollback-covered, but notifications delivered before a later listener
|
||||
* failure remain observable; if agent announcement began, rollback emits
|
||||
* `agent/disposed`, while the session entry is removed without a separate
|
||||
* disposal event. The owner disposes the resolved handle to stop/drain,
|
||||
* unregister, remove the session, and unwind the scope.
|
||||
* @param options - agent/session identity, configuration, and optional setup.
|
||||
* @returns the owned handle after setup, both announcements, and loop start complete.
|
||||
*/
|
||||
createAgent(options: CreateAgentOptions): AgentHandle
|
||||
createAgent(options: CreateAgentOptions): Promise<AgentHandle>
|
||||
/**
|
||||
* Load a persisted session and resume an agent on it. Async because it awaits
|
||||
* `ctx.sessionPersistence.load`; must be called after that service exists
|
||||
* (consumers inject `sessionPersistence`). Returns an {@link AgentHandle}.
|
||||
* both `ctx.sessionPersistence.load` and the optional unpublished setup
|
||||
* transaction; must be called after that service exists (consumers inject
|
||||
* `sessionPersistence`). Publication and drive unlocking follow the same
|
||||
* ordered boundary as {@link createAgent}.
|
||||
* @param options - persisted identity, configuration, and optional setup.
|
||||
* @returns the owned handle after setup, both announcements, and loop start complete.
|
||||
*/
|
||||
resume(options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
}
|
||||
@@ -139,11 +162,13 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug
|
||||
* Agent registry (`ctx.agents`): tracks live agents so UI, hook, and
|
||||
* orchestrator plugins can find them without depending on the concrete loop
|
||||
* package. Agent *creation* is provided by whichever plugin implements the
|
||||
* {@link AgentFactory} (phase 1: `@deepseek-ai/dsh-agent-loop`), registered via
|
||||
* {@link AgentFactory} (`@deepseek-ai/dsh-agent-loop`), registered via
|
||||
* {@link setFactory}.
|
||||
*/
|
||||
export class AgentRegistry extends Service {
|
||||
private store = new Map<AgentId, Agent>()
|
||||
/** Entries whose `agent/created` announcement phase began. */
|
||||
private announced = new WeakSet<Agent>()
|
||||
private factory: AgentFactory | undefined
|
||||
|
||||
constructor(ctx: Context) {
|
||||
@@ -180,15 +205,15 @@ export class AgentRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create, start, and register a new agent through the registered factory.
|
||||
* Create and publish a new agent through the registered factory.
|
||||
* Distinct from {@link register} (which records an already-constructed
|
||||
* agent): this constructs the agent and its session. Throws if no factory is
|
||||
* registered. Returns an {@link AgentHandle} — the owner disposes it to tear
|
||||
* down exactly this agent.
|
||||
* agent): this constructs the agent and its session. Rejects if no factory is
|
||||
* registered or creation/setup fails. The resolved {@link AgentHandle} lets
|
||||
* the owner tear down exactly this agent.
|
||||
* @param options - agent id, session id/seed/metadata, and agent options.
|
||||
* @returns the handle whose dispose tears down exactly this agent.
|
||||
* @returns the handle after setup, rollback-covered publication, and loop start complete.
|
||||
*/
|
||||
create(options: CreateAgentOptions): AgentHandle {
|
||||
async create(options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||
return this.factory.createAgent(options)
|
||||
}
|
||||
@@ -196,9 +221,9 @@ export class AgentRegistry extends Service {
|
||||
/**
|
||||
* Load a persisted session and resume an agent on it through the registered
|
||||
* factory. Rejects if no factory is registered; the factory rejects if
|
||||
* session persistence is not configured. Returns an {@link AgentHandle}.
|
||||
* @param options - the persisted session id plus agent id and options.
|
||||
* @returns the handle for the resumed agent.
|
||||
* session persistence is not configured or persistence/setup fails.
|
||||
* @param options - persisted identity, configuration, and optional setup.
|
||||
* @returns the handle after setup, rollback-covered publication, and loop start complete.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||
@@ -225,39 +250,58 @@ export class AgentRegistry extends Service {
|
||||
*/
|
||||
register(agent: Agent): () => Promise<void> | void {
|
||||
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
|
||||
if (this.store.has(agent.id)) {
|
||||
throw new Error(`agent "${agent.id}" is already registered`)
|
||||
}
|
||||
this.store.set(agent.id, agent)
|
||||
// Yield the rollback BEFORE emitting `agent/created`: a generator effect
|
||||
// collects each yielded disposer before the next step runs, so a
|
||||
// throwing `agent/created` listener rolls the entry back instead of
|
||||
// leaking it (a leak would wedge the duplicate-id check until restart).
|
||||
// The duplicate throw above fires before any mutation — it leaks nothing.
|
||||
yield () => {
|
||||
this.store.delete(agent.id)
|
||||
// CONTAIN a throwing `agent/disposed` listener: this disposer runs as
|
||||
// one link in the owning fiber/effect's disposal chain, and Cordis
|
||||
// chains later disposers with `task.then(next)` — so an UNCAUGHT throw
|
||||
// here rejects the chain and SKIPS every later disposer. When this
|
||||
// registration shares a composite effect with a session (the agent
|
||||
// factory's `AgentLoop.start`, where the session-detach disposer runs
|
||||
// AFTER this one), a swallowed-less throw would strand the session in
|
||||
// the store with `onAppend` attached — a leak AND a durability hole.
|
||||
// The store entry is already removed above (the useful state), so
|
||||
// logging the listener bug and continuing is correct (mirrors the
|
||||
// guarded `agent/status` emit in dsh-agent-loop's ReactLoopAgent).
|
||||
try {
|
||||
this.ctx.emit(scopeTarget(agent, agent), 'agent/disposed', agent)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
this.ctx.emit(scopeTarget(agent, agent), 'agent/created', agent)
|
||||
yield this.enter(agent)
|
||||
this.announce(agent)
|
||||
}.bind(this), 'agents.register()')
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an already-constructed agent without announcing it. This is the
|
||||
* advanced ordered-lifecycle primitive used by the async agent factory: it
|
||||
* first completes setup while the agent is unpublished, then assigns the
|
||||
* returned detach closure into its pre-installed composite teardown before
|
||||
* calling {@link announce}. Ordinary callers use {@link register}.
|
||||
* @param agent - the prepared, unpublished agent.
|
||||
* @returns an idempotent closure that removes this exact entry and emits
|
||||
* `agent/disposed` with listener failures contained.
|
||||
*/
|
||||
enter(agent: Agent): () => void {
|
||||
if (this.store.has(agent.id)) {
|
||||
throw new Error(`agent "${agent.id}" is already registered`)
|
||||
}
|
||||
this.store.set(agent.id, agent)
|
||||
let entered = true
|
||||
return () => {
|
||||
if (!entered) return
|
||||
entered = false
|
||||
this.store.delete(agent.id)
|
||||
// An insertion rolled back before announce was never externally created,
|
||||
// so emitting disposed would invent an impossible lifecycle edge. Marking
|
||||
// happens before the created emit: if a later created listener throws,
|
||||
// earlier listeners may already have observed it and must see disposal.
|
||||
if (!this.announced.delete(agent)) return
|
||||
try {
|
||||
this.ctx.emit(scopeTarget(agent, agent), 'agent/disposed', agent)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce an agent previously inserted with {@link enter}.
|
||||
* @param agent - the live inserted agent to announce.
|
||||
* @throws if `agent` is not the exact live registry entry for its id.
|
||||
*/
|
||||
announce(agent: Agent): void {
|
||||
if (this.store.get(agent.id) !== agent) {
|
||||
throw new Error(`agent "${agent.id}" is not live in this registry`)
|
||||
}
|
||||
this.announced.add(agent)
|
||||
this.ctx.emit(scopeTarget(agent, agent), 'agent/created', agent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a live agent.
|
||||
* @param id - the agent id to look up.
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
* - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the
|
||||
* live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/
|
||||
* `agent/request`/`agent/session-prefix`/`agent/step-result`/
|
||||
* `agent/turn-continuation` waterfalls and
|
||||
* the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits
|
||||
* `agent/turn-continuation` waterfalls and the serial `agent/pre-step` /
|
||||
* `agent/turn-stop` checkpoints) that mutate/veto, and TRANSIENT emits
|
||||
* (`agent/status`, `agent/error`, `agent/created`/
|
||||
* `agent/disposed`, `agent/queued`, `agent/session-start`)
|
||||
* that notify with the `Agent` in hand. Turn/step boundaries are NOT here —
|
||||
@@ -37,8 +37,9 @@
|
||||
* and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`.
|
||||
*
|
||||
* The interception waterfalls here (`agent/prompt-submit`, `agent/request`,
|
||||
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision —
|
||||
* the convention pinned by
|
||||
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision;
|
||||
* the terminal serial `agent/turn-stop` returns the stop-only subset. The
|
||||
* convention is pinned by
|
||||
* `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent/types
|
||||
@@ -161,6 +162,13 @@ export type ContinuationDecision =
|
||||
| { action: 'stop' }
|
||||
| { action: 'continue'; reason?: HookContext }
|
||||
|
||||
/**
|
||||
* The terminal subset of {@link ContinuationDecision}. A listener on
|
||||
* `agent/turn-stop` returns this to make the already-composed continuation
|
||||
* outcome terminal; `undefined` abstains.
|
||||
*/
|
||||
export type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }>
|
||||
|
||||
/**
|
||||
* Why an agent's session lifecycle began, carried by `agent/session-start`. A
|
||||
* bridge keys its SessionStart hook's matcher on this (Claude Code's
|
||||
@@ -274,9 +282,12 @@ declare module 'cordis' {
|
||||
interface Events {
|
||||
// ---- lifecycle (emit) ----
|
||||
/**
|
||||
* An agent was registered in the {@link AgentRegistry} and is ready to
|
||||
* receive messages.
|
||||
* @param agent - the newly registered agent, already resolvable in the registry.
|
||||
* An agent's fully composed scoped world was published in the
|
||||
* {@link AgentRegistry}. Its session is already live in the session store,
|
||||
* but concrete factories may keep driving verbs locked until the subsequent
|
||||
* `agent/session-start` boundary; that event is the first supported place
|
||||
* to inject or queue work during startup.
|
||||
* @param agent - the newly registered agent with its live session and completed setup.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
@@ -286,9 +297,11 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/created'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* An agent was disposed and removed from the registry; its fiber and any
|
||||
* in-flight turn have been torn down.
|
||||
* @param agent - the agent that was torn down; its handle is now inert.
|
||||
* An agent was removed from the registry after its driver and any in-flight
|
||||
* turn reached quiescence. Ordered teardown may still be detaching the
|
||||
* session and unwinding the agent's scoped registrations when this
|
||||
* notification runs.
|
||||
* @param agent - the deregistered agent; its driving handle is now inert.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
@@ -534,6 +547,25 @@ declare module 'cordis' {
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
/**
|
||||
* Serial terminal-stop checkpoint after the ordinary
|
||||
* `agent/turn-continuation` waterfall, any `continue.reason`, and the
|
||||
* pending-steering continuation override have been folded. A listener
|
||||
* returns `{ action: 'stop' }` to make this turn terminal, or `undefined`
|
||||
* to abstain. Terminal stop is monotonic: listener order and steering
|
||||
* cannot resume the turn, and pending steering is discarded rather than
|
||||
* becoming another step or turn. A malformed non-undefined result fails
|
||||
* the turn closed.
|
||||
* @param agent - the agent whose composed continuation outcome may be stopped.
|
||||
* @param turn - the turn at its terminal-stop checkpoint.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
|
||||
|
||||
// ---- error notifications (emit) ----
|
||||
/**
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* @module @deepseek-ai/dsh-scope
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
|
||||
/**
|
||||
@@ -78,21 +78,30 @@ export interface Scope {
|
||||
rawDispose: () => Promise<void> | void
|
||||
/**
|
||||
* Unwind the scope: dispose the backing fiber, running every collected
|
||||
* registration disposer. Idempotent and always awaitable — a repeat call
|
||||
* resolves immediately (the underlying Cordis disposer is single-shot and
|
||||
* returns undefined the second time; this wrapper Promise-normalizes it).
|
||||
* registration disposer. Idempotent and always awaitable: repeat and racing
|
||||
* calls share one completion even though the underlying Cordis disposer is
|
||||
* single-shot and returns undefined after its first invocation.
|
||||
* After disposal the scoped context is inert — a further registration
|
||||
* through it throws Cordis's INACTIVE_EFFECT.
|
||||
* @returns for the call that initiates teardown: resolves when every
|
||||
* registration's disposer has settled. A repeat/racing call resolves
|
||||
* immediately WITHOUT awaiting the in-flight teardown (the underlying
|
||||
* Cordis disposer is single-shot) — a caller needing a shared quiescence
|
||||
* boundary across racing disposers keeps its own completion promise (the
|
||||
* agent factory's pattern).
|
||||
* registration's disposer has settled. Every repeat/racing call awaits
|
||||
* that same quiescence boundary, including when {@link rawDispose} claimed
|
||||
* the underlying single-shot Cordis disposer first.
|
||||
*/
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose a Cordis fiber and await its lifecycle inertia even when some other
|
||||
* caller claimed the single-shot raw disposer first. `Fiber.dispose()` returns
|
||||
* `undefined` on a repeat call, but the fiber's `inertia` remains the
|
||||
* authoritative promise while its async unload is running.
|
||||
*/
|
||||
async function quiesceFiber(fiber: Fiber): Promise<void> {
|
||||
await Promise.resolve(fiber.dispose())
|
||||
while (fiber.inertia !== undefined) await fiber.inertia
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared no-op plugin every scope fiber mounts: named so diagnostics read
|
||||
* `scope` and shared so all scopes join ONE plugin runtime (Cordis deletes the
|
||||
@@ -127,21 +136,22 @@ export function createScope(ctx: Context, key: ScopeKey): Scope {
|
||||
// Runtime guard behind the ScopeKey type: callers outside the typechecker
|
||||
// (yml-configured plugins, JS consumers) can still pass a primitive.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (typeof key !== 'object' || key === null) {
|
||||
throw new TypeError('createScope: key must be an object (scope keys are identity-compared)')
|
||||
if ((typeof key !== 'object' && typeof key !== 'function') || key === null) {
|
||||
throw new TypeError('createScope: key must be a non-null object or function (scope keys are identity-compared)')
|
||||
}
|
||||
const fiber = ctx.plugin(scope)
|
||||
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
|
||||
let disposing: Promise<void> | undefined
|
||||
return {
|
||||
ctx: scoped,
|
||||
// fiber.dispose IS the disposer Cordis pushed onto the minting fiber's
|
||||
// disposable list — the identity a composite effect must yield (see
|
||||
// Scope.rawDispose).
|
||||
rawDispose: fiber.dispose,
|
||||
// Promise.resolve-normalized: a cordis fiber's dispose returns undefined
|
||||
// on a repeat call (the epoch is already cleared), and Scope.dispose
|
||||
// promises an awaitable on every call.
|
||||
dispose: () => Promise.resolve(fiber.dispose()),
|
||||
// Memoize the public boundary and explicitly follow fiber inertia: the raw
|
||||
// disposer must remain the exact Cordis function for ordered composition,
|
||||
// so it cannot itself be wrapped to record a raw-first invocation.
|
||||
dispose: () => (disposing ??= quiesceFiber(fiber)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,8 +303,10 @@ export interface ScopeHost {
|
||||
mint(key: ScopeKey): Scope
|
||||
/**
|
||||
* Dispose the host fiber and with it every scope minted through it.
|
||||
* @returns resolves when all collected disposers have settled (first call;
|
||||
* a repeat call resolves immediately — single-shot, like Scope.dispose).
|
||||
* Every racing/repeat caller observes the same completion, including when a
|
||||
* child's raw disposer started before host disposal.
|
||||
* @returns resolves when the host and every minted scope have reached
|
||||
* quiescence.
|
||||
*/
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
@@ -334,8 +346,33 @@ export async function scopeHost(ctx: Context, services: string[]): Promise<Scope
|
||||
throw new Error(`scopeHost: service${missing.length === 1 ? '' : 's'} ${named} not available on this context — load the providing plugin(s) before minting scopes`)
|
||||
}
|
||||
const host = hostCtx
|
||||
const scopes = new Set<Scope>()
|
||||
let disposing: Promise<void> | undefined
|
||||
const dispose = async (): Promise<void> => {
|
||||
// Start every boundary before awaiting any one of them. A child whose raw
|
||||
// disposer already ran is still followed through Scope.dispose(); a child
|
||||
// the host unload claims first is followed through the same fiber inertia.
|
||||
const tasks = [quiesceFiber(fiber), ...[...scopes].map(scope => scope.dispose())]
|
||||
const results = await Promise.allSettled(tasks)
|
||||
scopes.clear()
|
||||
const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason as unknown] : [])
|
||||
if (errors.length === 1) throw errors[0]
|
||||
if (errors.length > 1) throw new AggregateError(errors, 'scopeHost: disposal failed')
|
||||
}
|
||||
return {
|
||||
mint: (key: ScopeKey) => createScope(host, key),
|
||||
dispose: () => Promise.resolve(fiber.dispose()),
|
||||
mint: (key: ScopeKey) => {
|
||||
const minted = createScope(host, key)
|
||||
let disposing: Promise<void> | undefined
|
||||
const tracked: Scope = {
|
||||
ctx: minted.ctx,
|
||||
// Preserve the exact Cordis identity: only the public shared boundary
|
||||
// is wrapped to retire this child from the host's tracking set.
|
||||
rawDispose: minted.rawDispose,
|
||||
dispose: () => (disposing ??= minted.dispose().finally(() => { scopes.delete(tracked) })),
|
||||
}
|
||||
scopes.add(tracked)
|
||||
return tracked
|
||||
},
|
||||
dispose: () => (disposing ??= dispose()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,14 +30,19 @@ async function mintScope(ctx: Context, key: object): Promise<Scope> {
|
||||
}
|
||||
|
||||
describe('createScope', () => {
|
||||
it('rejects a primitive key at runtime (identity-compared keys must be objects)', () => {
|
||||
it('rejects primitive keys but accepts callable objects (matching ScopeKey)', async () => {
|
||||
const ctx = new Context()
|
||||
// Typed through `unknown` so the ScopeKey type cannot argue the assertion
|
||||
// away: this test exercises exactly the callers the typechecker misses.
|
||||
const badKeys: unknown[] = ['k', null]
|
||||
for (const bad of badKeys) {
|
||||
expect(() => createScope(ctx, bad as ScopeKey)).toThrow(/must be an object/)
|
||||
expect(() => createScope(ctx, bad as ScopeKey)).toThrow(/must be a non-null object or function/)
|
||||
}
|
||||
|
||||
const callable = Object.assign(() => {}, { nameForTest: 'callable-key' })
|
||||
const scope = await mintScope(ctx, callable)
|
||||
expect(scopeOf(scope.ctx)).toBe(callable)
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('tags the scoped context, readable through derivations (nearest tag wins)', async () => {
|
||||
@@ -91,6 +96,29 @@ describe('createScope', () => {
|
||||
expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/)
|
||||
})
|
||||
|
||||
it('dispose() follows a rawDispose-first race through async quiescence', async () => {
|
||||
const ctx = new Context()
|
||||
const scope = await mintScope(ctx, { name: 'raw-first' })
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
let cleanupFinished = false
|
||||
scope.ctx.effect(() => async () => {
|
||||
await gate.promise
|
||||
cleanupFinished = true
|
||||
})
|
||||
|
||||
const raw = Promise.resolve(scope.rawDispose())
|
||||
let publicSettled = false
|
||||
const publicDispose = scope.dispose().then(() => { publicSettled = true })
|
||||
await Promise.resolve()
|
||||
expect(publicSettled).toBe(false)
|
||||
expect(cleanupFinished).toBe(false)
|
||||
|
||||
gate.resolve(undefined)
|
||||
await Promise.all([raw, publicDispose])
|
||||
expect(cleanupFinished).toBe(true)
|
||||
await expect(scope.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rawDispose is the exact cordis disposer: yielding it nests the scope at its position', async () => {
|
||||
const ctx = new Context()
|
||||
const order: string[] = []
|
||||
@@ -287,6 +315,52 @@ describe('scopeHost', () => {
|
||||
expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/)
|
||||
})
|
||||
|
||||
it('dispose waits for a child whose raw disposer won the race', async () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('answers', { value: 42 })
|
||||
const host = await scopeHost(ctx, ['answers'])
|
||||
const scope = host.mint({ name: 'raw-first-child' })
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
let cleanupFinished = false
|
||||
scope.ctx.effect(() => async () => {
|
||||
await gate.promise
|
||||
cleanupFinished = true
|
||||
})
|
||||
|
||||
const raw = Promise.resolve(scope.rawDispose())
|
||||
let hostSettled = false
|
||||
const hostDispose = host.dispose().then(() => { hostSettled = true })
|
||||
await Promise.resolve()
|
||||
expect(hostSettled).toBe(false)
|
||||
|
||||
gate.resolve(undefined)
|
||||
await Promise.all([raw, hostDispose])
|
||||
expect(cleanupFinished).toBe(true)
|
||||
await expect(host.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('reaches every child before surfacing one or multiple disposal failures', async () => {
|
||||
const oneCtx = new Context()
|
||||
oneCtx.provide('answers', { value: 42 })
|
||||
const oneHost = await scopeHost(oneCtx, ['answers'])
|
||||
const one = oneHost.mint({ name: 'one' })
|
||||
one.dispose = () => Promise.reject(new Error('one failed'))
|
||||
await expect(oneHost.dispose()).rejects.toThrow('one failed')
|
||||
|
||||
const manyCtx = new Context()
|
||||
manyCtx.provide('answers', { value: 42 })
|
||||
const manyHost = await scopeHost(manyCtx, ['answers'])
|
||||
const a = manyHost.mint({ name: 'a' })
|
||||
const b = manyHost.mint({ name: 'b' })
|
||||
a.dispose = () => Promise.reject(new Error('a failed'))
|
||||
b.dispose = () => Promise.reject(new Error('b failed'))
|
||||
await expect(manyHost.dispose()).rejects.toMatchObject({
|
||||
name: 'AggregateError',
|
||||
message: 'scopeHost: disposal failed',
|
||||
errors: [expect.objectContaining({ message: 'a failed' }), expect.objectContaining({ message: 'b failed' })],
|
||||
})
|
||||
})
|
||||
|
||||
it('fails LOUD naming absent services instead of resolving as a silent no-op host', async () => {
|
||||
const ctx = new Context()
|
||||
await expect(scopeHost(ctx, ['tools', 'systemPrompt']))
|
||||
|
||||
@@ -538,8 +538,12 @@ export class SessionStore extends Service {
|
||||
const emitCtx = this.ctx
|
||||
session.onAppend = (event) => { emitCtx.emit(carrier, 'session/event', session, event) }
|
||||
this.store.set(session.id, session)
|
||||
let entered = true
|
||||
return () => {
|
||||
if (!entered) return
|
||||
entered = false
|
||||
session.onAppend = undefined
|
||||
this.carriers.delete(session)
|
||||
this.store.delete(session.id)
|
||||
}
|
||||
}
|
||||
@@ -549,7 +553,7 @@ export class SessionStore extends Service {
|
||||
* yield the detach disposer first (rollback safety — see {@link enter}).
|
||||
* @param session - the entered session to announce to listeners. */
|
||||
announce(session: Session): void {
|
||||
this.ctx.emit(this.carrierFor(session), 'session/created', session)
|
||||
this.ctx.emit(this.liveCarrierFor(session), 'session/created', session)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -563,13 +567,23 @@ export class SessionStore extends Service {
|
||||
* @returns resolves when every flush listener has settled; rejects if one rejects.
|
||||
*/
|
||||
async flush(session: Session): Promise<void> {
|
||||
await this.ctx.parallel(this.carrierFor(session), 'session/flush', session)
|
||||
await this.ctx.parallel(this.liveCarrierFor(session), 'session/flush', session)
|
||||
}
|
||||
|
||||
/** The carrier {@link enter} captured, or a subject-less one for a session
|
||||
* never entered (defensive: dispatch stays filtered either way). */
|
||||
private carrierFor(session: Session): Scoped<Session> {
|
||||
return this.carriers.get(session) ?? scopeTarget(session, undefined)
|
||||
/** Return the exact live session's carrier; detached/prepared objects reject. */
|
||||
private liveCarrierFor(session: Session): Scoped<Session> {
|
||||
if (this.store.get(session.id) !== session) {
|
||||
throw new Error(`session "${session.id}" is not live in this store`)
|
||||
}
|
||||
const carrier = this.carriers.get(session)
|
||||
// enter() installs store + carrier in one synchronous sequence; a live
|
||||
// session without one is an internal invariant violation, never fallback
|
||||
// to subject-less dispatch (that would silently cross scope boundaries).
|
||||
/* v8 ignore next -- enter installs store and carrier in one synchronous sequence */
|
||||
if (carrier === undefined) {
|
||||
throw new Error(`session "${session.id}" has no dispatch carrier`)
|
||||
}
|
||||
return carrier
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -91,16 +91,33 @@ describe('sessions.flush()', () => {
|
||||
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
|
||||
})
|
||||
|
||||
it('flushes a never-entered session with a subject-less carrier (defensive path)', async () => {
|
||||
it('rejects a never-entered session instead of inventing a carrier', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'owner')
|
||||
const flushed: string[] = []
|
||||
ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`))
|
||||
scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
|
||||
|
||||
const detached = ctx.sessions.prepare()
|
||||
await ctx.sessions.flush(detached)
|
||||
expect(flushed).toEqual([`global:${detached.id}`])
|
||||
const prepared = ctx.sessions.prepare()
|
||||
await expect(ctx.sessions.flush(prepared)).rejects.toThrow(/not live/)
|
||||
expect(flushed).toEqual([])
|
||||
})
|
||||
|
||||
it('clears a detached carrier and rejects stale flushes', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'owner')
|
||||
const flushed: string[] = []
|
||||
ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`))
|
||||
scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
|
||||
|
||||
const session = scope.ctx.sessions.prepare()
|
||||
const detach = scope.ctx.sessions.enter(session)
|
||||
await ctx.sessions.flush(session)
|
||||
expect(flushed.sort()).toEqual([`global:${session.id}`, `owner:${session.id}`])
|
||||
|
||||
detach()
|
||||
await expect(ctx.sessions.flush(session)).rejects.toThrow(/not live/)
|
||||
expect(flushed).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keyOf sanity: distinct scopes carry distinct keys', async () => {
|
||||
|
||||
@@ -289,6 +289,7 @@ describe('SessionStore', () => {
|
||||
expect(created).toEqual([session])
|
||||
// The detach disposer removes the entry + stops notification.
|
||||
detach()
|
||||
detach() // idempotent: cannot disturb a later same-id lifecycle
|
||||
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* System prompt assembly registry. Plugins contribute ordered text sections,
|
||||
* tool schema providers, and named prompt variables; `assemble(context)`
|
||||
* collates them through a waterfall that runs once per step, and
|
||||
* `renderPrompt` interpolates `{{variable}}` references into the final text.
|
||||
* tool schema providers, named prompt variables, and authoritative named
|
||||
* protections; `assemble(context)` collates them through a waterfall that
|
||||
* runs once per step, restores protected contributions, and `renderPrompt`
|
||||
* interpolates `{{variable}}` references into the final text.
|
||||
*
|
||||
* The harness-owned prompt openers live here too: this plugin registers the
|
||||
* static `harness:identity` section (order −100) and the deployment's
|
||||
@@ -43,8 +44,8 @@ declare module 'cordis' {
|
||||
*/
|
||||
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||||
/**
|
||||
* A section, tool provider, or variable provider was registered or
|
||||
* unregistered (the assembly inputs changed — possibly for one scope
|
||||
* A section, tool provider, variable provider, or protection was registered
|
||||
* or unregistered (the assembly inputs changed — possibly for one scope
|
||||
* only). An UNFILTERED registry-subject notification, deliberately not
|
||||
* scope-filtered dispatch: a global change concerns every agent's next
|
||||
* assembly, so a scoped listener subscribing here sees every change, not
|
||||
@@ -121,6 +122,27 @@ export interface ToolProviderResult {
|
||||
knownNames?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical prompt contributions that survive the assembly waterfall.
|
||||
*
|
||||
* Protection is declarative by contribution name rather than an ordered
|
||||
* callback: after every `system-prompt/assemble` listener has finished, the
|
||||
* service restores each protected name to the exact presence and definition
|
||||
* produced by its registries before the waterfall. Restored entries keep
|
||||
* canonical order with one another and anchor before their first surviving
|
||||
* later unprotected canonical neighbor (or at the end); the service does not
|
||||
* undo a listener's reordering of unprotected entries. A name absent from that
|
||||
* canonical assembly is removed from the result. This makes mode-dependent
|
||||
* absence protectable too (for example, a native tool that intentionally stays
|
||||
* off the wire in Code Mode).
|
||||
*/
|
||||
export interface PromptProtection {
|
||||
/** Section names whose canonical registry output is authoritative. */
|
||||
sections?: readonly string[]
|
||||
/** Tool names whose canonical provider output is authoritative. */
|
||||
tools?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The assembled prompt.
|
||||
*
|
||||
@@ -211,6 +233,28 @@ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownN
|
||||
name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name))
|
||||
}
|
||||
|
||||
/** Restore protected named entries from `canonical`, anchored before their next unprotected canonical neighbor. */
|
||||
function restoreProtected<T extends { name: string }>(
|
||||
canonical: readonly T[], result: readonly T[], protectedNames: ReadonlySet<string>,
|
||||
): T[] {
|
||||
const restored = result.filter(entry => !protectedNames.has(entry.name))
|
||||
for (const [index, entry] of canonical.entries()) {
|
||||
if (!protectedNames.has(entry.name)) continue
|
||||
// Protected entries are inserted in canonical order. Anchor each one
|
||||
// before the first later UNPROTECTED canonical neighbor that survived the
|
||||
// waterfall; if none survived, it belongs at the end. Looking only at
|
||||
// unprotected neighbors avoids reversing adjacent protected entries.
|
||||
const following = new Set(
|
||||
canonical.slice(index + 1)
|
||||
.filter(candidate => !protectedNames.has(candidate.name))
|
||||
.map(candidate => candidate.name),
|
||||
)
|
||||
const next = restored.findIndex(candidate => following.has(candidate.name))
|
||||
restored.splice(next < 0 ? restored.length : next, 0, structuredClone(entry))
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
|
||||
function compareToolNames(a: ToolSchema, b: ToolSchema): number {
|
||||
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
||||
@@ -327,10 +371,10 @@ function interpolate(section: AssembledSection, variables: Record<string, string
|
||||
|
||||
/**
|
||||
* Registry service (`ctx.systemPrompt`): plugins contribute ordered text
|
||||
* sections, tool-schema providers, and named prompt variables; the agent loop
|
||||
* calls `assemble(context)` once per step. Registers the harness-owned
|
||||
* `harness:identity` and `deployment:persona` sections itself (see
|
||||
* {@link Config.persona}).
|
||||
* sections, tool-schema providers, named prompt variables, and authoritative
|
||||
* contribution protections; the agent loop calls `assemble(context)` once per
|
||||
* step. Registers the harness-owned `harness:identity` and
|
||||
* `deployment:persona` sections itself (see {@link Config.persona}).
|
||||
*/
|
||||
export class SystemPrompt extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -347,10 +391,12 @@ export class SystemPrompt extends Service {
|
||||
private sections: PromptSection[] = []
|
||||
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
|
||||
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
|
||||
private protections: PromptProtection[] = []
|
||||
/** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */
|
||||
private scopedSections = new Map<ScopeKey, PromptSection[]>()
|
||||
private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
|
||||
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
|
||||
private scopedProtections = new Map<ScopeKey, PromptProtection[]>()
|
||||
private readonly toolOrder: string[] | undefined
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
@@ -383,7 +429,12 @@ export class SystemPrompt extends Service {
|
||||
* scoped context (`agent.ctx`) contributes to that scope alone — and a
|
||||
* scoped section SHADOWS a same-named global section for that scope's
|
||||
* assemblies (most-specific-wins; this is how a per-agent persona overrides
|
||||
* `deployment:persona`). Throws if the SAME layer already has the name (a
|
||||
* `deployment:persona`) unless that global name is protected: global
|
||||
* protection reserves its section name against scoped shadows so the
|
||||
* registration owner—not a later scope—defines the canonical value. The
|
||||
* registry snapshots `name`, `order`, and `text` before checking/storing, so
|
||||
* later caller-object mutation cannot rename a contribution. Throws
|
||||
* if the SAME layer already has the name (a
|
||||
* duplicate would silently double prompt text — e.g. a double-loaded tool
|
||||
* plugin; the global-duplicate message names `agent.ctx` as the per-agent
|
||||
* alternative). Removed when the calling fiber is disposed. Emits
|
||||
@@ -395,6 +446,14 @@ export class SystemPrompt extends Service {
|
||||
*/
|
||||
section(section: PromptSection): () => Promise<void> | void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const snapshot: PromptSection = {
|
||||
name: section.name,
|
||||
order: section.order,
|
||||
text: section.text,
|
||||
}
|
||||
if (scope !== undefined && this.protections.some(record => record.sections?.includes(snapshot.name))) {
|
||||
throw new Error(`prompt section "${snapshot.name}" is globally protected and cannot be shadowed in an agent scope`)
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
const layer = scope === undefined
|
||||
? this.sections
|
||||
@@ -403,18 +462,18 @@ export class SystemPrompt extends Service {
|
||||
this.scopedSections.set(scope, created)
|
||||
return created
|
||||
})()
|
||||
if (layer.some(existing => existing.name === section.name)) {
|
||||
if (layer.some(existing => existing.name === snapshot.name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt section "${section.name}" is already registered in this scope`)
|
||||
? `prompt section "${snapshot.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt section "${snapshot.name}" is already registered in this scope`)
|
||||
}
|
||||
layer.push(section)
|
||||
layer.push(snapshot)
|
||||
// Yield the rollback BEFORE emitting `system-prompt/change`: a generator
|
||||
// effect collects each yielded disposer before the next step runs, so a
|
||||
// throwing change listener removes the section instead of leaking it into
|
||||
// every future assembly.
|
||||
yield () => {
|
||||
const index = layer.indexOf(section)
|
||||
const index = layer.indexOf(snapshot)
|
||||
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
|
||||
if (index >= 0) layer.splice(index, 1)
|
||||
if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope)
|
||||
@@ -531,6 +590,73 @@ export class SystemPrompt extends Service {
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Protect named section/tool contributions from the assembly waterfall.
|
||||
* The layer is decided by the calling context: a global protection applies
|
||||
* to every assembly, while one registered through `agent.ctx` applies only
|
||||
* to that agent's scope. The name's canonical registry/provider output is
|
||||
* restored AFTER the whole waterfall, so listener registration order cannot
|
||||
* strip, replace, duplicate, or fabricate it. Canonical absence is restored
|
||||
* too: if the protected name is intentionally absent for an assembly, a
|
||||
* listener-injected entry with that name is removed. The input arrays are
|
||||
* snapshotted; an empty protection throws because it cannot affect output.
|
||||
* Removed with the calling fiber and emits `system-prompt/change` on
|
||||
* registration/unregistration. A global section protection also reserves the
|
||||
* name against scoped section shadows; registering protection when such a
|
||||
* shadow already exists fails loudly instead of protecting the wrong owner.
|
||||
* @param protection - section and/or tool names whose canonical presence and definitions are authoritative.
|
||||
* @returns the exact Cordis effect disposer that removes the protection.
|
||||
*/
|
||||
protect(protection: PromptProtection): () => Promise<void> | void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const snapshot: PromptProtection = {
|
||||
...protection.sections !== undefined ? { sections: [...new Set(protection.sections)] } : {},
|
||||
...protection.tools !== undefined ? { tools: [...new Set(protection.tools)] } : {},
|
||||
}
|
||||
if ((snapshot.sections?.length ?? 0) === 0 && (snapshot.tools?.length ?? 0) === 0) {
|
||||
throw new Error('systemPrompt.protect() requires at least one section or tool name')
|
||||
}
|
||||
if (scope === undefined && snapshot.sections !== undefined) {
|
||||
const protectedSections = new Set(snapshot.sections)
|
||||
const conflicts = [...this.scopedSections.values()]
|
||||
.flatMap(layer => layer.filter(section => protectedSections.has(section.name)).map(section => section.name))
|
||||
if (conflicts.length > 0) {
|
||||
throw new Error(`systemPrompt.protect() cannot globally protect section${conflicts.length > 1 ? 's' : ''} ${[...new Set(conflicts)].map(name => `"${name}"`).join(', ')} while scoped shadows are registered`)
|
||||
}
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
const layer = scope === undefined
|
||||
? this.protections
|
||||
: this.scopedProtections.get(scope) ?? (() => {
|
||||
const created: PromptProtection[] = []
|
||||
this.scopedProtections.set(scope, created)
|
||||
return created
|
||||
})()
|
||||
layer.push(snapshot)
|
||||
yield () => {
|
||||
const index = layer.indexOf(snapshot)
|
||||
/* v8 ignore next 3 -- defensive: protection was registered, so indexOf is guaranteed >= 0 */
|
||||
if (index >= 0) layer.splice(index, 1)
|
||||
if (scope !== undefined && layer.length === 0) this.scopedProtections.delete(scope)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.protect()')
|
||||
return dispose
|
||||
}
|
||||
|
||||
/** Resolve the authoritative names registered for one assembly scope. */
|
||||
private protectedNames(scope: ScopeKey | undefined): { sections: Set<string>; tools: Set<string> } {
|
||||
const records = [
|
||||
...this.protections,
|
||||
...(scope === undefined ? [] : this.scopedProtections.get(scope)) ?? [],
|
||||
]
|
||||
return {
|
||||
sections: new Set(records.flatMap(record => record.sections ?? [])),
|
||||
tools: new Set(records.flatMap(record => record.tools ?? [])),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the current prompt for one caller: the global layer merged with
|
||||
* {@link AssembleContext.scope}'s layer (scoped sections/variables SHADOW
|
||||
@@ -546,10 +672,11 @@ export class SystemPrompt extends Service {
|
||||
* Tool schemas are deep-cloned because adapters and request waterfalls may
|
||||
* mutate schema objects. Runs through the `system-prompt/assemble`
|
||||
* waterfall, giving listeners the opportunity to mutate or replace the
|
||||
* assembly before it reaches the model — like the sections' `order` sort,
|
||||
* tool canonicalization happens on the initial assembly, and a listener
|
||||
* owns the determinism of whatever it emits. Await the result before
|
||||
* reading the assembly values — waterfall listeners may be async.
|
||||
* assembly, then restores every visible {@link PromptProtection} from the
|
||||
* pre-waterfall canonical assembly. Like the sections' `order` sort, tool
|
||||
* canonicalization happens on the initial assembly; unprotected listener
|
||||
* output owns its own determinism. Await the result before reading the
|
||||
* assembly values — waterfall listeners may be async.
|
||||
* Interpolation happens later, in {@link renderPrompt}.
|
||||
* @param context - what this assembly is for (defaults to an empty context;
|
||||
* see {@link AssembleContext}).
|
||||
@@ -560,6 +687,10 @@ export class SystemPrompt extends Service {
|
||||
// (`assemble().catch(...)` would miss it).
|
||||
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
|
||||
const scope = context.scope
|
||||
// Protection is a registry input too: snapshot which names are protected
|
||||
// at assembly start. Registrations that land while an async waterfall is
|
||||
// in flight affect the NEXT assembly, matching the other registries.
|
||||
const protectedNames = this.protectedNames(scope)
|
||||
// Variables: global layer first, then the scope's layer OVERWRITES
|
||||
// same-named entries (shadowing — a per-agent value wins for that agent).
|
||||
const variables: Record<string, string | undefined> = {}
|
||||
@@ -611,7 +742,27 @@ export class SystemPrompt extends Service {
|
||||
tools: orderTools(collected, this.toolOrder, knownNames),
|
||||
variables,
|
||||
}
|
||||
return this.ctx.waterfall(scopeTarget(this, scope), 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))
|
||||
// Snapshot only the fields protection can restore. The waterfall receives
|
||||
// `assembly` by reference and may mutate it or return a replacement; these
|
||||
// independent snapshots remain the authoritative registry product.
|
||||
const canonicalSections = protectedNames.sections.size > 0 ? structuredClone(assembly.sections) : undefined
|
||||
const canonicalTools = protectedNames.tools.size > 0 ? structuredClone(assembly.tools) : undefined
|
||||
const result = await this.ctx.waterfall(
|
||||
scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
|
||||
() => Promise.resolve(assembly),
|
||||
)
|
||||
// Build a replacement instead of mutating the waterfall result: a
|
||||
// listener may legitimately return a frozen assembly. Merge-extensible
|
||||
// fields ride through the spread untouched.
|
||||
return {
|
||||
...result,
|
||||
...canonicalSections !== undefined
|
||||
? { sections: restoreProtected(canonicalSections, result.sections, protectedNames.sections) }
|
||||
: {},
|
||||
...canonicalTools !== undefined
|
||||
? { tools: restoreProtected(canonicalTools, result.tools, protectedNames.tools) }
|
||||
: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,21 @@ describe('scoped sections', () => {
|
||||
scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'a' })
|
||||
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[['reserved'], 'section "reserved"'],
|
||||
[['first', 'second'], 'sections "first", "second"'],
|
||||
])('rejects global protection added after scoped shadows (%j)', async (names, message) => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
for (const name of names) {
|
||||
scope.ctx.systemPrompt.section({ name, order: 1, text: `scoped ${name}` })
|
||||
}
|
||||
|
||||
expect(() => ctx.systemPrompt.protect({ sections: names })).toThrow(message)
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })))
|
||||
.toContain(`scoped ${names[0]}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoped variables', () => {
|
||||
@@ -148,4 +163,31 @@ describe('scoped assemble dispatch', () => {
|
||||
expect(global.sections.some(s => s.name === 'listener:extra')).toBe(false)
|
||||
expect(shaped).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a scoped protection finalizes only its own assemblies and disappears with the scope', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
const key = scopeKeyOf(scope)
|
||||
ctx.systemPrompt.section({ name: 'required', order: 10, text: 'required' })
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [schema('required')] }))
|
||||
scope.ctx.systemPrompt.protect({ sections: ['required'], tools: ['required'] })
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
result.sections = result.sections.filter(section => section.name !== 'required')
|
||||
result.tools = result.tools.filter(tool => tool.name !== 'required')
|
||||
return result
|
||||
}, { prepend: true })
|
||||
|
||||
const scoped = await ctx.systemPrompt.assemble({ scope: key })
|
||||
const global = await ctx.systemPrompt.assemble()
|
||||
expect(scoped.sections.some(section => section.name === 'required')).toBe(true)
|
||||
expect(scoped.tools.some(tool => tool.name === 'required')).toBe(true)
|
||||
expect(global.sections.some(section => section.name === 'required')).toBe(false)
|
||||
expect(global.tools.some(tool => tool.name === 'required')).toBe(false)
|
||||
|
||||
await scope.dispose()
|
||||
const disposed = await ctx.systemPrompt.assemble({ scope: key })
|
||||
expect(disposed.sections.some(section => section.name === 'required')).toBe(false)
|
||||
expect(disposed.tools.some(tool => tool.name === 'required')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -205,6 +205,103 @@ describe('SystemPrompt', () => {
|
||||
expect(assembly.sections).toHaveLength(0)
|
||||
})
|
||||
|
||||
describe('canonical contribution protection', () => {
|
||||
it('restores exact protected definitions after every listener, in canonical relative order', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'before', order: 10, text: 'before' })
|
||||
ctx.systemPrompt.section({ name: 'protected', order: 20, text: 'canonical section' })
|
||||
ctx.systemPrompt.section({ name: 'after', order: 30, text: 'after' })
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [
|
||||
{ name: 'alpha', description: 'alpha', parameters: {} },
|
||||
{ name: 'protected', description: 'canonical tool', parameters: { type: 'object', properties: { answer: { type: 'number' } } } },
|
||||
{ name: 'zulu', description: 'zulu', parameters: {} },
|
||||
] }))
|
||||
const protection = { sections: ['protected'], tools: ['protected'] }
|
||||
ctx.systemPrompt.protect(protection)
|
||||
// Registration snapshots its arrays; caller mutation cannot change what
|
||||
// the service makes authoritative.
|
||||
protection.sections[0] = 'after'
|
||||
protection.tools[0] = 'zulu'
|
||||
|
||||
// Registered AFTER the protection and prepended: it is outside every
|
||||
// ordinary listener that existed when protect() ran, but service-level
|
||||
// finalization still restores the canonical entries after it returns.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
return Object.freeze({
|
||||
sections: [
|
||||
...result.sections.filter(section => section.name !== 'protected'),
|
||||
{ name: 'protected', order: -999, text: 'wrong section' },
|
||||
{ name: 'protected', order: 999, text: 'duplicate section' },
|
||||
],
|
||||
tools: [
|
||||
...result.tools.filter(tool => tool.name !== 'protected'),
|
||||
{ name: 'protected', description: 'wrong tool', parameters: {} },
|
||||
{ name: 'protected', description: 'duplicate tool', parameters: {} },
|
||||
],
|
||||
variables: result.variables,
|
||||
})
|
||||
}, { prepend: true })
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
const protectedSections = assembly.sections.filter(section => section.name === 'protected')
|
||||
const protectedTools = assembly.tools.filter(tool => tool.name === 'protected')
|
||||
expect(protectedSections).toEqual([{ name: 'protected', order: 20, text: 'canonical section' }])
|
||||
expect(protectedTools).toEqual([{
|
||||
name: 'protected',
|
||||
description: 'canonical tool',
|
||||
parameters: { type: 'object', properties: { answer: { type: 'number' } } },
|
||||
}])
|
||||
expect(assembly.sections.map(section => section.name).indexOf('protected'))
|
||||
.toBeLessThan(assembly.sections.map(section => section.name).indexOf('after'))
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['alpha', 'protected', 'zulu'])
|
||||
})
|
||||
|
||||
it('protects canonical absence and rejects an empty protection', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
// Separate registrations exercise the set-union contract: protections
|
||||
// may name only sections or only tools and still compose.
|
||||
ctx.systemPrompt.protect({ sections: ['mode-hidden'] })
|
||||
ctx.systemPrompt.protect({ tools: ['mode-hidden'] })
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
result.sections.push({ name: 'mode-hidden', order: 100, text: 'fabricated' })
|
||||
result.tools.push({ name: 'mode-hidden', description: 'fabricated', parameters: {} })
|
||||
return result
|
||||
})
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.some(section => section.name === 'mode-hidden')).toBe(false)
|
||||
expect(assembly.tools.some(tool => tool.name === 'mode-hidden')).toBe(false)
|
||||
expect(() => ctx.systemPrompt.protect({})).toThrow(/at least one section or tool name/)
|
||||
expect(() => ctx.systemPrompt.protect({ sections: [], tools: [] })).toThrow(/at least one section or tool name/)
|
||||
})
|
||||
|
||||
it('removes a protection with its contributing fiber (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'protected', order: 10, text: 'canonical' })
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
result.sections = result.sections.filter(section => section.name !== 'protected')
|
||||
return result
|
||||
})
|
||||
let changes = 0
|
||||
ctx.on('system-prompt/change', () => { changes++ })
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.systemPrompt.protect({ sections: ['protected'] })
|
||||
}, { inject: ['systemPrompt'] }))
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(true)
|
||||
expect(changes).toBe(1)
|
||||
await fiber.dispose()
|
||||
expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'protected')).toBe(false)
|
||||
expect(changes).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* Tool registry and execution pipeline. Plugins register tools; the registry
|
||||
* feeds schemas into the system prompt, and `execute()` dispatches each call
|
||||
* through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an
|
||||
* around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute`
|
||||
* (inspect/replace the result, attach context) for sandbox, permission, and hook
|
||||
* plugins to gate or transform a call.
|
||||
* through `tools/pre-execute` (the extensible allow/deny gate) → monotonic
|
||||
* registered guards → `tools/execute` (an around-dispatch wrapper for
|
||||
* timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the
|
||||
* result, attach context) → the observe-only `tools/result` notification.
|
||||
*
|
||||
* The registry also owns HOW its tools are presented to the model — its
|
||||
* `mode` config: `'native'` (every tool as a wire function definition,
|
||||
* today's behavior and the default), `'code'` (the wire carries exactly one
|
||||
* tool, `run_code`, plus a generated TypeScript SDK prompt section), or
|
||||
* today's behavior and the default), `'code'` (the registry's canonical wire
|
||||
* contribution is one tool, `run_code`, plus a generated TypeScript SDK prompt section), or
|
||||
* `'both'`. See `code-mode.ts` (the tool + dispatch bridge) and
|
||||
* `ts-types.ts` (the SDK codegen); design in the Code Mode RFC.
|
||||
*
|
||||
@@ -21,8 +21,9 @@ import z from 'schemastery'
|
||||
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import { isJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||
@@ -105,10 +106,13 @@ declare module 'cordis' {
|
||||
* unknown tool) is already normalized to an `isError` result by the time a
|
||||
* listener's `await next()` returns, so a wrapper never sees a raw throw from
|
||||
* the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can
|
||||
* mutate `exec` (e.g. replace `exec.signal` with a per-call deadline) BEFORE
|
||||
* `next()` and inspect the result AFTER. (Cordis `next()` ignores any passed
|
||||
* arguments and re-invokes downstream with the shared payload, so a wrapper
|
||||
* mutates `exec` in place rather than passing a new object to `next()`.)
|
||||
* set or replace the one mutable field, `exec.signal` (e.g. with a per-call
|
||||
* deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity
|
||||
* (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the
|
||||
* pipeline so a wrapper cannot change which capability or scope was
|
||||
* authorized. (Cordis `next()` ignores passed arguments and re-invokes
|
||||
* downstream with the shared payload, so a wrapper changes `exec.signal` in
|
||||
* place rather than passing a new object to `next()`.)
|
||||
* Multiple listeners compose by registration order — an outer one wraps the
|
||||
* inner ones plus dispatch.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
|
||||
@@ -139,6 +143,21 @@ declare module 'cordis' {
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
||||
/**
|
||||
* Awaited notification of the authoritative FINAL tool outcome, after the
|
||||
* complete pre/execute/post pipeline, final lossless-JSON validation, and
|
||||
* outer error normalization.
|
||||
* Unlike the three waterfalls, this seam cannot transform the result: each
|
||||
* listener receives the now-frozen execution object and a deep-frozen result
|
||||
* snapshot; listener failures are contained and logged, and
|
||||
* {@link ToolRegistry.execute} still returns the outcome.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by
|
||||
* `exec.agent`, using the same carrier as the pipeline.
|
||||
* @param exec - the execution object that traversed the pipeline.
|
||||
* @param result - a deep-frozen snapshot of the final returned result.
|
||||
* @mode parallel
|
||||
*/
|
||||
'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): Promise<void> | void
|
||||
/**
|
||||
* A tool was registered or unregistered, or a scoped restriction changed
|
||||
* (the available tool set changed — possibly for one scope only). An
|
||||
@@ -152,10 +171,8 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(review): revisit these shapes when the first real tools and
|
||||
// sandbox/permission plugins land (e.g. a concurrency-safety hint for
|
||||
// parallel execution — Claude Code partitions read-only tools; phase 1
|
||||
// executes sequentially).
|
||||
// TODO(review): revisit these shapes when concurrency metadata becomes useful
|
||||
// (for example, a read-only hint that would permit safe parallel execution).
|
||||
|
||||
/**
|
||||
* What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the
|
||||
@@ -214,17 +231,54 @@ export interface ToolResult {
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/** One pending tool call, as it flows through the execution pipeline (`tools/pre-execute` → dispatch → `tools/post-execute`). */
|
||||
export interface ToolExecution {
|
||||
callId: CallId
|
||||
name: string
|
||||
/** Parsed JSON arguments (unknown — tools validate their own input). */
|
||||
arguments: unknown
|
||||
declare const toolExecutionTokenBrand: unique symbol
|
||||
|
||||
/** Tokens minted in this module; a cast or JavaScript object cannot forge membership. */
|
||||
const executionTokens = new WeakSet<object>()
|
||||
|
||||
/**
|
||||
* Opaque, immutable identity for one trip through the tool pipeline. Nested
|
||||
* transports carry the enclosing execution's token instead of its live object,
|
||||
* so observe-only result listeners can correlate calls without gaining a
|
||||
* mutation path into an outer around-dispatch wrapper.
|
||||
*/
|
||||
export interface ToolExecutionToken {
|
||||
readonly [toolExecutionTokenBrand]: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Caller-supplied description of one tool call. {@link ToolRegistry.execute}
|
||||
* snapshots this input into a pipeline-owned {@link ToolExecution}; callers do
|
||||
* not choose the execution token.
|
||||
*/
|
||||
export interface ToolExecutionInput {
|
||||
readonly callId: CallId
|
||||
readonly name: string
|
||||
/** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */
|
||||
readonly arguments: unknown
|
||||
/** The agent on whose behalf the call runs (set by the agent loop). */
|
||||
agent?: Agent
|
||||
readonly agent?: Agent
|
||||
/**
|
||||
* Opaque token of the enclosing transport execution, when one exists. Code
|
||||
* Mode sets this on SDK sub-dispatches so commit-style observers can wait for
|
||||
* the outer `run_code` outcome without receiving its live mutable execution.
|
||||
*/
|
||||
readonly parent?: ToolExecutionToken
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* One pending tool call inside the registry pipeline. Call identity, the
|
||||
* registry-assigned {@link token}, and a lossless-JSON-validated, deep-frozen
|
||||
* clone of the parsed arguments are immutable from the first policy listener onward, while an
|
||||
* around-dispatch wrapper may set, replace, or remove only `signal`. The
|
||||
* registry freezes the complete object before `tools/result` observers run.
|
||||
*/
|
||||
export interface ToolExecution extends ToolExecutionInput {
|
||||
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
|
||||
readonly token: ToolExecutionToken
|
||||
}
|
||||
|
||||
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
|
||||
export interface ToolErrorInfo {
|
||||
name: string
|
||||
@@ -255,7 +309,6 @@ export interface ToolExecutionResult {
|
||||
* text in `content` is always present; this is extra structure for code.
|
||||
*/
|
||||
error?: ToolErrorInfo
|
||||
/**
|
||||
/**
|
||||
* Extra model-facing context a `tools/post-execute` listener attached for the
|
||||
* NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part
|
||||
@@ -318,17 +371,28 @@ export type PostToolDecision =
|
||||
* is stringified.
|
||||
*/
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'object' && error !== null
|
||||
&& 'message' in error && typeof error.message === 'string') {
|
||||
return error.message
|
||||
try {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'object' && error !== null
|
||||
&& 'message' in error && typeof error.message === 'string') {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
} catch {
|
||||
// A hostile thrown value can trap `instanceof`, property access, or string
|
||||
// coercion. Error normalization is the outermost safety boundary, so its
|
||||
// fallback must itself be total.
|
||||
return '<unprintable thrown value>'
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
|
||||
function errorInfo(error: unknown): ToolErrorInfo | undefined {
|
||||
return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
|
||||
try {
|
||||
return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** How the registry presents its tools to the model (see {@link Config.mode}). */
|
||||
@@ -338,9 +402,9 @@ export type ToolPresentationMode = 'native' | 'code' | 'both'
|
||||
export interface Config {
|
||||
/**
|
||||
* The presentation mode. `'native'` (the default) contributes every
|
||||
* registered tool as a wire function definition — byte-for-byte today's
|
||||
* behavior. `'code'` contributes exactly ONE wire tool, `run_code`, plus
|
||||
* the generated `tools:sdk` prompt section declaring every other tool as a
|
||||
* visible end capability as a native wire function definition. Under
|
||||
* `'code'` this registry contributes exactly ONE wire tool,
|
||||
* `run_code`, plus the generated `tools:sdk` prompt section declaring every other tool as a
|
||||
* TypeScript API the program calls. `'both'` contributes every native
|
||||
* definition AND `run_code` + the SDK section. Non-native modes require a
|
||||
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
|
||||
@@ -371,11 +435,27 @@ export interface ToolRestriction {
|
||||
deny?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A monotonic execution guard evaluated after every `tools/pre-execute`
|
||||
* listener and before the tool body. Returning a reason denies the call;
|
||||
* returning `undefined` leaves it unchanged. Because guards have no allow
|
||||
* result, listener ordering cannot turn a denial back into permission.
|
||||
* @param execution - the identity-protected call after extensible pre-execute policy completed.
|
||||
* @returns a final denial reason, or `undefined` to leave the call allowed.
|
||||
*/
|
||||
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
|
||||
|
||||
/** One guard registration; the wrapper preserves independent duplicate registrations. */
|
||||
interface ToolGuardRegistration {
|
||||
guard: ToolGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
|
||||
* loop executes calls through the `tools/pre-execute` → `tools/execute` →
|
||||
* `tools/post-execute` pipeline. The registry contributes its schemas into the
|
||||
* system-prompt assembly — WHICH schemas is governed by its `mode` config
|
||||
* loop executes calls through the `tools/pre-execute` → guards →
|
||||
* `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The
|
||||
* registry contributes its schemas into the system-prompt assembly — WHICH
|
||||
* schemas is governed by its `mode` config
|
||||
* (see {@link Config.mode}); under a non-native mode it also owns the reserved
|
||||
* `run_code` presentation transport and the `tools:sdk` prompt section.
|
||||
*
|
||||
@@ -402,6 +482,9 @@ export class ToolRegistry extends Service {
|
||||
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
|
||||
/** Snapshot-at-registration restriction filters, per scope (see {@link restrict}). */
|
||||
private restrictions = new Map<ScopeKey, ToolRestriction[]>()
|
||||
/** Monotonic post-policy guards, split into global and per-agent layers. */
|
||||
private globalGuards = new Set<ToolGuardRegistration>()
|
||||
private scopedGuards = new Map<ScopeKey, Set<ToolGuardRegistration>>()
|
||||
private readonly mode: ToolPresentationMode
|
||||
/** Reserved presentation transport, kept outside the filterable registration layers. */
|
||||
private readonly codeTransport: ToolDefinition | undefined
|
||||
@@ -418,7 +501,7 @@ export class ToolRegistry extends Service {
|
||||
// the filterable global/scoped capability layers.
|
||||
this.codeTransport = this.mode === 'native'
|
||||
? undefined
|
||||
: createRunCodeTool(this, () => this.requireCodeRuntime())
|
||||
: deepFreeze(createRunCodeTool(this, () => this.requireCodeRuntime()))
|
||||
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
|
||||
if (this.mode !== 'native') {
|
||||
ctx.systemPrompt.section({
|
||||
@@ -436,6 +519,11 @@ export class ToolRegistry extends Service {
|
||||
return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME))
|
||||
},
|
||||
})
|
||||
// These are presentation infrastructure, not optional end capabilities.
|
||||
// Protect them at their owner: assembly listeners may still transform
|
||||
// ordinary tools and prose, but cannot silently leave Code Mode without
|
||||
// its only wire transport or the SDK that tells the model how to use it.
|
||||
ctx.systemPrompt.protect({ sections: ['tools:sdk'], tools: [RUN_CODE_NAME] })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,8 +583,12 @@ export class ToolRegistry extends Service {
|
||||
* the shadowing feature, not an error; the global-duplicate message names
|
||||
* `agent.ctx` as the per-agent alternative), or if a non-native mode reserves
|
||||
* the `run_code` name for its presentation transport. The visible schema set
|
||||
* flows into prompt assembly automatically. Disposed with the calling
|
||||
* fiber. Emits `tools/change` on register/unregister.
|
||||
* flows into prompt assembly automatically. Registration validates and
|
||||
* clones the JSON parameters, copies scalar fields, binds each callback once
|
||||
* to the caller's definition as its method receiver, and freezes the stored
|
||||
* snapshot; later mutation or callback replacement on the input object does
|
||||
* not rewrite the registry. Disposed with the calling fiber. Emits
|
||||
* `tools/change` on register/unregister.
|
||||
* @param definition - the tool's schema plus its execute (and optional
|
||||
* presentation) functions.
|
||||
* @returns the disposer that unregisters the tool. The exact
|
||||
@@ -505,24 +597,52 @@ export class ToolRegistry extends Service {
|
||||
*/
|
||||
register(definition: ToolDefinition): () => Promise<void> | void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
if (this.codeTransport !== undefined && definition.name === RUN_CODE_NAME) {
|
||||
// A schema crosses the same model/log boundary as execution arguments.
|
||||
// Validate BEFORE cloning because structuredClone silently turns some
|
||||
// forbidden values (for example class instances) into plain records, then
|
||||
// validate the detached value again to contain hostile getters that change
|
||||
// between inspection and snapshotting. A frozen Map is still mutable, so
|
||||
// deepFreeze alone is not a sufficient registration boundary.
|
||||
if (!isJsonValue(definition.parameters)) {
|
||||
throw new TypeError('tool parameters must be losslessly JSON-serializable')
|
||||
}
|
||||
const parameters = structuredClone(definition.parameters)
|
||||
if (!isJsonValue(parameters)) {
|
||||
throw new TypeError('tool parameters must be stable losslessly JSON-serializable data')
|
||||
}
|
||||
// Bind once so replacing a callback on the caller-owned definition after
|
||||
// registration cannot change dispatch, while preserving the historical
|
||||
// method receiver (`this === definition`) for callbacks that use it.
|
||||
const execute = definition.execute.bind(definition)
|
||||
const presentCall = definition.presentCall?.bind(definition)
|
||||
const presentResult = definition.presentResult?.bind(definition)
|
||||
const snapshot: ToolDefinition = deepFreeze({
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
parameters,
|
||||
execute,
|
||||
...definition.timeoutMs !== undefined ? { timeoutMs: definition.timeoutMs } : {},
|
||||
...presentCall !== undefined ? { presentCall } : {},
|
||||
...presentResult !== undefined ? { presentResult } : {},
|
||||
})
|
||||
if (this.codeTransport !== undefined && snapshot.name === RUN_CODE_NAME) {
|
||||
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
const layer = scope === undefined ? this.global : this.layerFor(scope)
|
||||
if (layer.has(definition.name)) {
|
||||
if (layer.has(snapshot.name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `tool "${definition.name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
|
||||
: `tool "${definition.name}" is already registered in this scope`)
|
||||
? `tool "${snapshot.name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
|
||||
: `tool "${snapshot.name}" is already registered in this scope`)
|
||||
}
|
||||
layer.set(definition.name, definition)
|
||||
layer.set(snapshot.name, snapshot)
|
||||
// Yield the rollback BEFORE emitting `tools/change`: a generator effect
|
||||
// collects each yielded disposer before the next step runs, so a throwing
|
||||
// `tools/change` listener removes the tool instead of leaking it (a leak
|
||||
// would wedge the duplicate-name check until restart). The duplicate
|
||||
// throw above fires before any mutation — it leaks nothing.
|
||||
yield () => {
|
||||
layer.delete(definition.name)
|
||||
layer.delete(snapshot.name)
|
||||
// An emptied scope layer is dropped so a disposed scope leaves no
|
||||
// residue keyed by its (dead) key.
|
||||
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
|
||||
@@ -604,6 +724,30 @@ export class ToolRegistry extends Service {
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a monotonic guard after the extensible `tools/pre-execute`
|
||||
* waterfall. A plain-context guard applies globally; one registered through
|
||||
* `agent.ctx` applies only to that agent. Any matching guard may deny by
|
||||
* returning a reason, while no guard can force-allow a call another guard
|
||||
* denied. The exact effect disposer is returned for ordered ownership and
|
||||
* HMR cleanup.
|
||||
* @param guard - synchronous check; a returned string denies the execution.
|
||||
* @returns the exact disposer that unregisters the guard.
|
||||
*/
|
||||
guard(guard: ToolGuard): () => Promise<void> | void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const registration = { guard }
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
const layer = scope === undefined ? this.globalGuards : this.guardLayerFor(scope)
|
||||
layer.add(registration)
|
||||
yield () => {
|
||||
layer.delete(registration)
|
||||
if (scope !== undefined && layer.size === 0) this.scopedGuards.delete(scope)
|
||||
}
|
||||
}.bind(this), 'tools.guard()')
|
||||
return dispose
|
||||
}
|
||||
|
||||
/** The (created-on-demand) scoped layer for `scope`. */
|
||||
private layerFor(scope: ScopeKey): Map<string, ToolDefinition> {
|
||||
let layer = this.scoped.get(scope)
|
||||
@@ -614,6 +758,43 @@ export class ToolRegistry extends Service {
|
||||
return layer
|
||||
}
|
||||
|
||||
/** Get or create the guard layer for one agent scope. */
|
||||
private guardLayerFor(scope: ScopeKey): Set<ToolGuardRegistration> {
|
||||
let layer = this.scopedGuards.get(scope)
|
||||
if (layer === undefined) {
|
||||
layer = new Set()
|
||||
this.scopedGuards.set(scope, layer)
|
||||
}
|
||||
return layer
|
||||
}
|
||||
|
||||
/** First monotonic denial from the global then matching scoped guard layers. */
|
||||
private guardReason(exec: ToolExecution): string | undefined {
|
||||
// Guards are policy, not another transform seam. The pipeline execution's
|
||||
// identity and arguments are already protected; freeze a detached view so
|
||||
// an untyped guard cannot replace the wrapper-mutable signal either.
|
||||
const view: Readonly<ToolExecution> = Object.freeze({ ...exec })
|
||||
for (const { guard } of this.globalGuards) {
|
||||
const reason = guard(view)
|
||||
if (reason !== undefined) return this.assertGuardReason(reason)
|
||||
}
|
||||
if (exec.agent !== undefined) {
|
||||
for (const { guard } of this.scopedGuards.get(exec.agent) ?? []) {
|
||||
const reason = guard(view)
|
||||
if (reason !== undefined) return this.assertGuardReason(reason)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Runtime boundary for JavaScript/casted guards: only strings can deny. */
|
||||
private assertGuardReason(reason: unknown): string {
|
||||
if (typeof reason !== 'string') {
|
||||
throw new TypeError(`tools.guard() must return a denial string or undefined, got ${typeof reason}`)
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
/** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */
|
||||
private admits(scope: ScopeKey | undefined, name: string): boolean {
|
||||
if (scope === undefined) return true
|
||||
@@ -707,8 +888,9 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one tool call through the `tools/pre-execute` → `tools/execute`
|
||||
* (around dispatch) → `tools/post-execute` pipeline. `pre-execute` is the gate
|
||||
* Execute one tool call through the `tools/pre-execute` → guards →
|
||||
* `tools/execute` (around dispatch) → `tools/post-execute` → `tools/result`
|
||||
* pipeline. `pre-execute` is the extensible gate
|
||||
* (allow/deny), `tools/execute` wraps core dispatch (a timeout/retry/metrics
|
||||
* seam), and `post-execute` is the inspect/transform seam; core dispatch sits
|
||||
* as the base `next()` of the `tools/execute` waterfall. The whole thing is
|
||||
@@ -719,74 +901,177 @@ export class ToolRegistry extends Service {
|
||||
* tool is not registered (or not visible to the calling agent — a
|
||||
* restricted-away global is exactly as absent as a nonexistent one), the
|
||||
* result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown
|
||||
* {@link HarnessError} surfaces its `{ name, code }` on the result.
|
||||
* @param exec - the call to run (name, parsed arguments, caller agent, signal).
|
||||
* {@link HarnessError} surfaces its `{ name, code }` on the result. Before
|
||||
* the final observe-only notification, the authoritative outcome must survive
|
||||
* a lossless JSON round trip; an invalid outcome is normalized to an error.
|
||||
* Caller-owned arguments must survive lossless-JSON validation before and
|
||||
* after cloning; a violation normalizes to an error before policy or dispatch.
|
||||
* @param exec - the single-use call input; its identity is snapshotted and
|
||||
* protected before policy runs.
|
||||
* @returns the final result after every waterfall; failures resolve as
|
||||
* `isError` results, never rejections.
|
||||
*/
|
||||
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
|
||||
let execution: ToolExecution
|
||||
try {
|
||||
// --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
|
||||
// until the permission system lands) skips dispatch entirely. The
|
||||
// carrier keys the dispatch by exec.agent, so an `agent.ctx` listener
|
||||
// gates only its own agent's calls (agent-less calls are subject-less).
|
||||
const carrier = scopeTarget(this, exec.agent)
|
||||
const decision = await this.ctx.waterfall(
|
||||
carrier, 'tools/pre-execute', exec,
|
||||
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
|
||||
)
|
||||
if (decision.kind !== 'allow') {
|
||||
// deny → isError. ask has no permission UI yet, so degrade to deny
|
||||
// (FIXME(permissions)): a forthcoming permission system turns `ask` into
|
||||
// a real prompt; today it is the conservative "not allowed".
|
||||
const reason = decision.kind === 'deny'
|
||||
? decision.reason
|
||||
: decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)`
|
||||
const denied: ToolExecutionResult = {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: `Error: ${reason}` }],
|
||||
isError: true,
|
||||
}
|
||||
return await this.postExecute(exec, denied)
|
||||
}
|
||||
|
||||
// --- Around-dispatch: tools/execute. The base `next` is the dispatch-
|
||||
// with-normalization thunk — the tool body's own try/catch turns a throw
|
||||
// into an isError result so a wrapper (and post-execute) can inspect it;
|
||||
// an unknown tool routes through the same catch. A `tools/execute` listener
|
||||
// (e.g. a timeout plugin) wraps this thunk: it may mutate `exec` before
|
||||
// delegating and inspect the normalized result after. Dispatched with the
|
||||
// same carrier as the gate, so an `agent.ctx` wrapper wraps only its own
|
||||
// agent's calls. ---
|
||||
const result = await this.ctx.waterfall(
|
||||
carrier, 'tools/execute', exec,
|
||||
async (): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
// Resolve through the CALLER's visible view ({@link get}): a scoped
|
||||
// tool shadows its global name-twin for that agent, and a
|
||||
// restricted-away global tool is exactly as absent as a nonexistent
|
||||
// one — same UNKNOWN_TOOL result, no capability leak in the error.
|
||||
const tool = this.get(exec.name, exec.agent)
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
|
||||
// meta) or a { content, meta } object (a tool attaching a private
|
||||
// presentation payload). An array IS the content; the object carries it.
|
||||
const returned = await tool.execute(exec.arguments, exec)
|
||||
const content = Array.isArray(returned) ? returned : returned.content
|
||||
const meta = Array.isArray(returned) ? undefined : returned.meta
|
||||
return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(exec.callId, error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return await this.postExecute(exec, result)
|
||||
execution = this.prepareExecution(exec)
|
||||
} catch (error: unknown) {
|
||||
// Outer backstop: a throwing pre/post-execute listener (or the waterfall
|
||||
// machinery) becomes an isError result, never a turn failure.
|
||||
return toolErrorResult(exec.callId, error)
|
||||
// Contract-violating non-JSON or non-cloneable arguments cannot enter a
|
||||
// pipeline whose logged and executed forms must agree. Still publish one
|
||||
// scoped final outcome, using an immutable identity shell, so result
|
||||
// observers retain their every-call guarantee without seeing the invalid
|
||||
// value.
|
||||
execution = Object.freeze({
|
||||
token: createExecutionToken(),
|
||||
callId: exec.callId,
|
||||
name: exec.name,
|
||||
arguments: undefined,
|
||||
...exec.agent !== undefined ? { agent: exec.agent } : {},
|
||||
...isExecutionToken(exec.parent) ? { parent: exec.parent } : {},
|
||||
...exec.signal !== undefined ? { signal: exec.signal } : {},
|
||||
})
|
||||
const result = toolErrorResult(execution.callId, error)
|
||||
await this.notifyResult(execution, result)
|
||||
return result
|
||||
}
|
||||
let result: ToolExecutionResult
|
||||
try {
|
||||
// Validate the authoritative FINAL result, not merely the tool body's
|
||||
// intermediate return. Post-policy may replace content or attach context,
|
||||
// and every one of these fields is session-bound. Reject anything that
|
||||
// cannot round-trip losslessly through the durable JSON log before the
|
||||
// observe-only `tools/result` commit point sees success.
|
||||
result = this.snapshotExecutionResult(execution, await this.executePipeline(execution))
|
||||
} catch (error: unknown) {
|
||||
// Outer backstop: a throwing pre/post-execute listener, guard, or the
|
||||
// waterfall machinery becomes an isError result, never a turn failure.
|
||||
result = toolErrorResult(execution.callId, error)
|
||||
}
|
||||
await this.notifyResult(execution, result)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Snapshot one call into a shared pipeline object with immutable identity and mutable cancellation. */
|
||||
private prepareExecution(input: ToolExecutionInput): ToolExecution {
|
||||
if (input.parent !== undefined && !isExecutionToken(input.parent)) {
|
||||
throw new TypeError('tool execution parent must be a registry-minted opaque token')
|
||||
}
|
||||
if (!isJsonValue(input.arguments)) {
|
||||
throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
|
||||
}
|
||||
const args = structuredClone(input.arguments)
|
||||
if (!isJsonValue(args)) {
|
||||
throw new TypeError('tool execution arguments must be stable losslessly JSON-serializable data')
|
||||
}
|
||||
const execution: ToolExecution = {
|
||||
token: createExecutionToken(),
|
||||
callId: input.callId,
|
||||
name: input.name,
|
||||
arguments: deepFreeze(args),
|
||||
...input.agent !== undefined ? { agent: input.agent } : {},
|
||||
...input.parent !== undefined ? { parent: input.parent } : {},
|
||||
...input.signal !== undefined ? { signal: input.signal } : {},
|
||||
}
|
||||
Object.defineProperties(execution, {
|
||||
token: { value: execution.token, enumerable: true, writable: false, configurable: false },
|
||||
callId: { value: execution.callId, enumerable: true, writable: false, configurable: false },
|
||||
name: { value: execution.name, enumerable: true, writable: false, configurable: false },
|
||||
arguments: { value: execution.arguments, enumerable: true, writable: false, configurable: false },
|
||||
agent: { value: input.agent, enumerable: true, writable: false, configurable: false },
|
||||
parent: { value: input.parent, enumerable: true, writable: false, configurable: false },
|
||||
})
|
||||
if (input.signal !== undefined) {
|
||||
Object.defineProperty(execution, 'signal', {
|
||||
value: input.signal,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
return execution
|
||||
}
|
||||
|
||||
/** Run the transformable pipeline; {@link execute} owns final normalization and notification. */
|
||||
private async executePipeline(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
// --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
|
||||
// until the permission system lands) skips dispatch entirely. The
|
||||
// carrier keys the dispatch by exec.agent, so an `agent.ctx` listener
|
||||
// gates only its own agent's calls (agent-less calls are subject-less).
|
||||
const carrier = scopeTarget(this, exec.agent)
|
||||
const decision = await this.ctx.waterfall(
|
||||
carrier, 'tools/pre-execute', exec,
|
||||
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
|
||||
)
|
||||
const denialReason = decision.kind === 'allow'
|
||||
? this.guardReason(exec)
|
||||
: decision.kind === 'deny'
|
||||
? decision.reason
|
||||
: decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)`
|
||||
if (denialReason !== undefined) {
|
||||
// deny → isError. ask has no permission UI yet, so degrade to deny
|
||||
// (FIXME(permissions)): a forthcoming permission system turns `ask` into
|
||||
// a real prompt; today it is the conservative "not allowed".
|
||||
const denied: ToolExecutionResult = {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: `Error: ${denialReason}` }],
|
||||
isError: true,
|
||||
}
|
||||
return await this.postExecute(exec, denied)
|
||||
}
|
||||
|
||||
// --- Around-dispatch: tools/execute. The base `next` is the dispatch-
|
||||
// with-normalization thunk — the tool body's own try/catch turns a throw
|
||||
// into an isError result so a wrapper (and post-execute) can inspect it;
|
||||
// an unknown tool routes through the same catch. A `tools/execute` listener
|
||||
// (e.g. a timeout plugin) wraps this thunk: it may replace `exec.signal`
|
||||
// before delegating and inspect the normalized result after. Dispatched with the
|
||||
// same carrier as the gate, so an `agent.ctx` wrapper wraps only its own
|
||||
// agent's calls. ---
|
||||
const result = this.snapshotExecutionResult(exec, await this.ctx.waterfall(
|
||||
carrier, 'tools/execute', exec,
|
||||
async (): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
// Resolve through the CALLER's visible view ({@link get}): a scoped
|
||||
// tool shadows its global name-twin for that agent, and a
|
||||
// restricted-away global tool is exactly as absent as a nonexistent
|
||||
// one — same UNKNOWN_TOOL result, no capability leak in the error.
|
||||
const tool = this.get(exec.name, exec.agent)
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
|
||||
// meta) or a { content, meta } object (a tool attaching a private
|
||||
// presentation payload). An array IS the content; the object carries it.
|
||||
const returned = await tool.execute(exec.arguments, exec)
|
||||
const content = Array.isArray(returned) ? returned : returned.content
|
||||
const meta = Array.isArray(returned) ? undefined : returned.meta
|
||||
return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(exec.callId, error)
|
||||
}
|
||||
},
|
||||
))
|
||||
|
||||
return await this.postExecute(exec, result)
|
||||
}
|
||||
|
||||
/** Notify final-result observers without giving them a mutation/error channel into the outcome. */
|
||||
private async notifyResult(exec: ToolExecution, result: ToolExecutionResult): Promise<void> {
|
||||
// The pipeline is over: freeze the remaining mutable signal slot so every
|
||||
// observer sees the SAME WeakMap-keyable execution without a mutation race.
|
||||
Object.freeze(exec)
|
||||
// postExecute clones every accepted result/decision before rebuilding the
|
||||
// outcome; all error paths construct plain data. The final result is thus
|
||||
// structurally cloneable before it reaches this observe-only boundary.
|
||||
const snapshot = deepFreeze(structuredClone(result))
|
||||
const callbacks = this.ctx.events.dispatch('parallel', [
|
||||
scopeTarget(this, exec.agent), 'tools/result', exec, snapshot,
|
||||
])
|
||||
await Promise.all(callbacks.map(async (callback) => {
|
||||
try {
|
||||
await callback(exec, snapshot)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`tool "${exec.name}" (${exec.callId}): tools/result observer failed: ${errorMessage(error)}`)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -804,21 +1089,14 @@ export class ToolRegistry extends Service {
|
||||
// authoritative-call-id requirement and the "preserve the dispatched
|
||||
// isError/error" contract. The decision is the ONLY sanctioned channel for a
|
||||
// listener to change the outcome (block, or accept-with-replacement); the
|
||||
// call id is always the authoritative `exec.callId`. `content` is copied into
|
||||
// a fresh array so a listener's in-place `push`/`splice` on `result.content`
|
||||
// cannot leak into the returned content either (the elements are the same
|
||||
// references — the snapshot guards the array structure, not deep immutability).
|
||||
const dispatched = {
|
||||
callId: exec.callId,
|
||||
content: [...result.content],
|
||||
isError: result.isError,
|
||||
...result.error ? { error: result.error } : {},
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
}
|
||||
const decision = await this.ctx.waterfall(
|
||||
// call id is always the authoritative `exec.callId`. Deep cloning protects
|
||||
// nested content, error, and meta data from in-place listener mutation.
|
||||
const dispatched = this.snapshotExecutionResult(exec, result)
|
||||
const decision = structuredClone(await this.ctx.waterfall(
|
||||
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
|
||||
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
|
||||
)
|
||||
))
|
||||
this.assertPostDecision(decision)
|
||||
const additionalContext = decision.additionalContext
|
||||
if (decision.kind === 'block') {
|
||||
return {
|
||||
@@ -835,6 +1113,74 @@ export class ToolRegistry extends Service {
|
||||
...additionalContext ? { additionalContext } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate and detach an around-dispatch result before policy can observe or mutate it. */
|
||||
private snapshotExecutionResult(exec: ToolExecution, value: unknown): ToolExecutionResult {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new TypeError('tools/execute must return a ToolExecutionResult object')
|
||||
}
|
||||
const result = value as Partial<ToolExecutionResult>
|
||||
if (!Array.isArray(result.content) || typeof result.isError !== 'boolean') {
|
||||
throw new TypeError('tools/execute must return a ToolExecutionResult with content[] and boolean isError')
|
||||
}
|
||||
if (result.callId !== exec.callId) {
|
||||
throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`)
|
||||
}
|
||||
const candidate = {
|
||||
callId: exec.callId,
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
...result.error !== undefined ? { error: result.error } : {},
|
||||
...result.additionalContext !== undefined ? { additionalContext: result.additionalContext } : {},
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
}
|
||||
// Validate BEFORE cloning: structuredClone turns some forbidden exotic or
|
||||
// class instances into plain objects, which would hide a lossy JSON
|
||||
// boundary violation. Validate the detached clone again to contain hostile
|
||||
// getters whose value changes between inspection and snapshotting.
|
||||
if (!isJsonValue(candidate)) {
|
||||
throw new TypeError('tools/execute must return a losslessly JSON-serializable ToolExecutionResult')
|
||||
}
|
||||
const snapshot = structuredClone(candidate)
|
||||
if (!isJsonValue(snapshot)) {
|
||||
throw new TypeError('tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult')
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/** Reject malformed JavaScript/casted post decisions at the public event boundary. */
|
||||
private assertPostDecision(value: unknown): asserts value is PostToolDecision {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new TypeError('tools/post-execute must return a PostToolDecision object')
|
||||
}
|
||||
const decision = value as Partial<PostToolDecision>
|
||||
switch (decision.kind) {
|
||||
case 'accept':
|
||||
if (decision.content !== undefined && !Array.isArray(decision.content)) {
|
||||
throw new TypeError('tools/post-execute accept content must be an array')
|
||||
}
|
||||
return
|
||||
case 'block':
|
||||
if (!Array.isArray(decision.feedback)) {
|
||||
throw new TypeError('tools/post-execute block feedback must be an array')
|
||||
}
|
||||
return
|
||||
default:
|
||||
throw new TypeError('tools/post-execute must return an accept or block decision')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mint a frozen, property-free correlation token whose identity is its value. */
|
||||
function createExecutionToken(): ToolExecutionToken {
|
||||
const token = Object.freeze(Object.create(null)) as ToolExecutionToken
|
||||
executionTokens.add(token)
|
||||
return token
|
||||
}
|
||||
|
||||
/** Runtime counterpart of the opaque token type, including `undefined` input. */
|
||||
function isExecutionToken(value: unknown): value is ToolExecutionToken {
|
||||
return typeof value === 'object' && value !== null && executionTokens.has(value)
|
||||
}
|
||||
|
||||
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {
|
||||
|
||||
@@ -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'] })
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { PreToolDecision, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
@@ -149,6 +149,11 @@ describe('restrict()', () => {
|
||||
expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/)
|
||||
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown tool "reall"; known tools for this scope: real/)
|
||||
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown tools "ghost", "wraith"/)
|
||||
|
||||
const emptyCtx = await mount()
|
||||
const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty')
|
||||
expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] }))
|
||||
.toThrow(/known tools for this scope: \(none\)/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -170,4 +175,296 @@ describe('scoped execution dispatch', () => {
|
||||
expect(await run(ctx, 't')).toBe('ran:t')
|
||||
expect(seen).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('applies scoped guards after pre-execute and unwinds duplicate registrations independently', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
const other = { id: 'other' as AgentId } as Agent
|
||||
let bodyCalls = 0
|
||||
ctx.tools.register({
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
|
||||
},
|
||||
})
|
||||
let guardViewFrozen = false
|
||||
const guard = (execution: Readonly<ToolExecution>): string => {
|
||||
guardViewFrozen = Object.isFrozen(execution) && Object.isFrozen(execution.arguments)
|
||||
return 'terminal policy'
|
||||
}
|
||||
const liftFirst = scope.ctx.tools.guard(guard)
|
||||
scope.ctx.tools.guard(guard)
|
||||
// Registered later and prepended outside every existing waterfall listener:
|
||||
// it can force the extensible pre decision to allow, but cannot bypass the
|
||||
// owner-level monotonic guard that runs after the waterfall.
|
||||
scope.ctx.on('tools/pre-execute', () => Promise.resolve({ kind: 'allow' }), { prepend: true })
|
||||
|
||||
expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
|
||||
expect(guardViewFrozen).toBe(true)
|
||||
expect(await run(ctx, 't', other)).toBe('ran:t')
|
||||
expect(bodyCalls).toBe(1)
|
||||
|
||||
await liftFirst()
|
||||
expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
|
||||
await scope.dispose()
|
||||
expect(await run(ctx, 't', key)).toBe('ran:t')
|
||||
expect(bodyCalls).toBe(2)
|
||||
})
|
||||
|
||||
it('composes global guards monotonically when one abstains and a later one denies', async () => {
|
||||
const ctx = await mount()
|
||||
let bodyCalls = 0
|
||||
ctx.tools.register({
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([])
|
||||
},
|
||||
})
|
||||
ctx.tools.guard(() => undefined)
|
||||
ctx.tools.guard(() => 'global denial')
|
||||
|
||||
expect(await run(ctx, 't')).toBe('Error: global denial')
|
||||
expect(bodyCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('protects call identity before policy and dispatch while leaving only signal mutable', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
let safeCalls = 0
|
||||
let dangerCalls = 0
|
||||
let scopedResults = 0
|
||||
let safeArguments: unknown
|
||||
ctx.tools.register({
|
||||
...tool('safe'),
|
||||
execute: (args) => {
|
||||
safeCalls += 1
|
||||
safeArguments = args
|
||||
return Promise.resolve([{ type: 'text', text: 'safe' }])
|
||||
},
|
||||
})
|
||||
ctx.tools.register({
|
||||
...tool('danger'),
|
||||
execute: () => {
|
||||
dangerCalls += 1
|
||||
return Promise.resolve([{ type: 'text', text: 'danger' }])
|
||||
},
|
||||
})
|
||||
scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined)
|
||||
ctx.on('tools/pre-execute', (exec, next) => {
|
||||
expect(Reflect.set(exec, 'agent', undefined)).toBe(false)
|
||||
expect(Reflect.set(exec, 'name', 'safe')).toBe(false)
|
||||
expect(Reflect.set(exec.arguments as object, 'injected', true)).toBe(false)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/execute', (exec, next) => {
|
||||
expect(Reflect.set(exec, 'name', 'danger')).toBe(false)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
expect(Reflect.set(exec, 'agent', undefined)).toBe(false)
|
||||
return next()
|
||||
})
|
||||
scope.ctx.on('tools/result', () => { scopedResults += 1 })
|
||||
|
||||
expect(await run(ctx, 'danger', key)).toBe('Error: danger denied')
|
||||
const callerArguments = { source: true }
|
||||
const safeResult = await ctx.tools.execute({
|
||||
callId: CallId('safe-call'),
|
||||
name: 'safe',
|
||||
arguments: callerArguments,
|
||||
agent: key,
|
||||
})
|
||||
expect(safeResult.content[0]).toMatchObject({ text: 'safe' })
|
||||
expect(Object.isFrozen(callerArguments)).toBe(false)
|
||||
expect(safeArguments).not.toBe(callerArguments)
|
||||
expect(Object.isFrozen(safeArguments)).toBe(true)
|
||||
expect(callerArguments).toEqual({ source: true })
|
||||
expect({ safeCalls, dangerCalls, scopedResults }).toEqual({
|
||||
safeCalls: 1,
|
||||
dangerCalls: 0,
|
||||
scopedResults: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes non-cloneable arguments and still publishes one scoped final outcome', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
let policyCalls = 0
|
||||
let bodyCalls = 0
|
||||
let scopedObserved = 0
|
||||
let globalObserved = 0
|
||||
ctx.tools.register({
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([])
|
||||
},
|
||||
})
|
||||
ctx.on('tools/pre-execute', (_exec, next) => {
|
||||
policyCalls += 1
|
||||
return next()
|
||||
})
|
||||
let parent!: ToolExecutionToken
|
||||
ctx.tools.register(tool('parent'))
|
||||
const stopCapture = ctx.on('tools/pre-execute', (exec, next) => {
|
||||
if (exec.name === 'parent') parent = exec.token
|
||||
return next()
|
||||
})
|
||||
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
|
||||
stopCapture()
|
||||
policyCalls = 0
|
||||
const signal = new AbortController().signal
|
||||
scope.ctx.on('tools/result', (exec, result) => {
|
||||
scopedObserved += 1
|
||||
expect(exec.arguments).toBeUndefined()
|
||||
expect(exec.parent).toBe(parent)
|
||||
expect(exec.signal).toBe(signal)
|
||||
expect(Object.isFrozen(exec)).toBe(true)
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
ctx.on('tools/result', () => { globalObserved += 1 })
|
||||
const callerArguments = { invalid: () => undefined }
|
||||
|
||||
const scopedResult = await ctx.tools.execute({
|
||||
callId: CallId('non-cloneable'),
|
||||
name: 't',
|
||||
arguments: callerArguments,
|
||||
agent: key,
|
||||
parent,
|
||||
signal,
|
||||
})
|
||||
const subjectlessResult = await ctx.tools.execute({
|
||||
callId: CallId('non-cloneable-subjectless'),
|
||||
name: 't',
|
||||
arguments: { invalid: () => undefined },
|
||||
})
|
||||
expect(scopedResult.isError).toBe(true)
|
||||
expect(scopedResult.content[0]?.type === 'text' && scopedResult.content[0].text).toContain('losslessly JSON-serializable')
|
||||
expect(subjectlessResult.isError).toBe(true)
|
||||
expect({ policyCalls, bodyCalls, scopedObserved, globalObserved }).toEqual({
|
||||
policyCalls: 0,
|
||||
bodyCalls: 0,
|
||||
scopedObserved: 1,
|
||||
globalObserved: 2,
|
||||
})
|
||||
expect(Object.isFrozen(callerArguments)).toBe(false)
|
||||
expect(callerArguments.invalid).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('rejects a forged mutable parent token without exposing it to final observers', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.tools.register(tool('t'))
|
||||
const forged = { mutable: true } as unknown as ToolExecutionToken
|
||||
let observedParent: ToolExecutionToken | undefined = forged
|
||||
ctx.on('tools/result', (exec) => { observedParent = exec.parent })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('forged-parent'), name: 't', arguments: {}, parent: forged,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{
|
||||
type: 'text', text: 'Error: tool execution parent must be a registry-minted opaque token',
|
||||
}])
|
||||
expect(observedParent).toBeUndefined()
|
||||
expect(Object.isFrozen(forged)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Map', new Map([['mutable', true]])],
|
||||
['class instance', new (class Arguments { value = 1 })()],
|
||||
])('rejects cloneable non-JSON arguments (%s) before policy or dispatch', async (_kind, argumentsValue) => {
|
||||
const ctx = await mount()
|
||||
let policyCalls = 0
|
||||
let bodyCalls = 0
|
||||
let observed = 0
|
||||
ctx.tools.register({
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([])
|
||||
},
|
||||
})
|
||||
ctx.on('tools/pre-execute', (_exec, next) => {
|
||||
policyCalls += 1
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/result', (exec, result) => {
|
||||
observed += 1
|
||||
expect(exec.arguments).toBeUndefined()
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('bad-arguments'), name: 't', arguments: argumentsValue,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{
|
||||
type: 'text', text: 'Error: tool execution arguments must be losslessly JSON-serializable',
|
||||
}])
|
||||
expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 })
|
||||
})
|
||||
|
||||
it('rejects arguments that change to non-JSON data while being snapshotted', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.tools.register(tool('t'))
|
||||
let reads = 0
|
||||
const argumentsValue = Object.defineProperty({}, 'value', {
|
||||
enumerable: true,
|
||||
get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]),
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
callId: CallId('unstable-arguments'),
|
||||
content: [{
|
||||
type: 'text', text: 'Error: tool execution arguments must be stable losslessly JSON-serializable data',
|
||||
}],
|
||||
isError: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('notifies every tools/result observer with the frozen final outcome and contains failures', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
ctx.tools.register(tool('t'))
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger)
|
||||
const seen: boolean[] = []
|
||||
const dispatchModes: string[] = []
|
||||
ctx.on('internal/dispatch', (mode, name) => {
|
||||
if (name === 'tools/result') dispatchModes.push(mode)
|
||||
})
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
await next()
|
||||
return {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: 'outer failure' }],
|
||||
isError: true,
|
||||
}
|
||||
}, { prepend: true })
|
||||
scope.ctx.on('tools/result', (_exec, result) => {
|
||||
expect(Object.isFrozen(_exec)).toBe(true)
|
||||
expect(Object.isFrozen(_exec.arguments)).toBe(true)
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
expect(Object.isFrozen(result.content)).toBe(true)
|
||||
seen.push(result.isError)
|
||||
})
|
||||
ctx.on('tools/result', () => {
|
||||
throw { toString: () => { throw new Error('coercion trap') } }
|
||||
})
|
||||
ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) })
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key })
|
||||
expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] })
|
||||
expect(seen).toEqual([true, true])
|
||||
expect(dispatchModes).toEqual(['parallel'])
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(String(warn.mock.calls[0]?.[0])).toContain('<unprintable thrown value>')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, {
|
||||
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
|
||||
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
|
||||
type ToolExecution, type ToolExecutionResult,
|
||||
type ToolExecution, type ToolExecutionResult, type ToolGuard,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
async function setup() {
|
||||
@@ -113,6 +113,53 @@ describe('ToolRegistry', () => {
|
||||
expect('meta' in result).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes a contract-violating non-cloneable result before final notification', async () => {
|
||||
const ctx = await setup()
|
||||
let observedError: boolean | undefined
|
||||
ctx.on('tools/result', (_exec, result) => { observedError = result.isError })
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'bad-meta',
|
||||
async execute() {
|
||||
return { content: [], meta: () => undefined }
|
||||
},
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('bad-meta'), name: 'bad-meta', arguments: {},
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]?.type === 'text' && result.content[0].text).toContain('Error:')
|
||||
expect(observedError).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes a result that changes to non-JSON data while being snapshotted', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
let reads = 0
|
||||
const hostileBlock = Object.defineProperty({ type: 'text' }, 'text', {
|
||||
enumerable: true,
|
||||
get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]),
|
||||
})
|
||||
ctx.on('tools/execute', async exec => ({
|
||||
callId: exec.callId,
|
||||
content: [hostileBlock],
|
||||
isError: false,
|
||||
}) as unknown as ToolExecutionResult)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('unstable-result'), name: 'echo', arguments: {},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
callId: CallId('unstable-result'),
|
||||
content: [{
|
||||
type: 'text', text: 'Error: tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult',
|
||||
}],
|
||||
isError: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns isError results for unknown tools and throwing tools', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
@@ -134,6 +181,28 @@ describe('ToolRegistry', () => {
|
||||
expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' })
|
||||
})
|
||||
|
||||
it('normalizes a hostile thrown value whose inspection and coercion both throw', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'hostile-throw',
|
||||
async execute() {
|
||||
throw new Proxy({}, {
|
||||
getPrototypeOf: () => { throw new Error('prototype trap') },
|
||||
has: () => { throw new Error('has trap') },
|
||||
get: () => { throw new Error('get trap') },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
await expect(ctx.tools.execute({
|
||||
callId: CallId('hostile'), name: 'hostile-throw', arguments: {},
|
||||
})).resolves.toMatchObject({
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'Error: <unprintable thrown value>' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('ToolNotFoundError carries the tool name and a stable code', async () => {
|
||||
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||
const err = new ToolNotFoundError('ghost')
|
||||
@@ -158,6 +227,25 @@ describe('ToolRegistry', () => {
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
|
||||
})
|
||||
|
||||
it('rejects a JavaScript guard that returns an async/non-string decision', async () => {
|
||||
const ctx = await setup()
|
||||
let bodyCalls = 0
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
async execute() {
|
||||
bodyCalls += 1
|
||||
return []
|
||||
},
|
||||
})
|
||||
ctx.tools.guard((() => Promise.resolve('late denial')) as unknown as ToolGuard)
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('bad-guard'), name: 'echo', arguments: {} })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]?.type === 'text' && result.content[0].text)
|
||||
.toContain('tools.guard() must return')
|
||||
expect(bodyCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('an ask decision degrades to deny until the permission system lands', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
@@ -233,7 +321,7 @@ describe('ToolRegistry', () => {
|
||||
expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
})
|
||||
|
||||
it('a post-execute listener mutating the result object cannot corrupt callId/isError/error', async () => {
|
||||
it('a post-execute listener cannot mutate any nested part of the dispatched result', async () => {
|
||||
// The decision is the ONLY sanctioned channel to change the outcome. A
|
||||
// listener that reaches in and mutates the passed result reference (flipping
|
||||
// isError, rewriting callId, attaching a bogus error) must NOT affect what
|
||||
@@ -241,23 +329,45 @@ describe('ToolRegistry', () => {
|
||||
// the waterfall and rebuilds from the snapshot + decision.
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/execute', async (_exec, next) => {
|
||||
await next()
|
||||
return {
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
isError: true,
|
||||
error: { name: 'OriginalError', code: 'ORIGINAL' },
|
||||
meta: { nested: { label: 'original' } },
|
||||
}
|
||||
})
|
||||
|
||||
ctx.on('tools/post-execute', async (_exec, result, next) => {
|
||||
const mutable = result as { callId: string; isError: boolean; error?: unknown; content: unknown[] }
|
||||
const mutable = result as {
|
||||
callId: string
|
||||
isError: boolean
|
||||
error?: { name: string; code: string }
|
||||
content: { type: 'text'; text: string }[]
|
||||
meta?: { nested: { label: string } }
|
||||
}
|
||||
mutable.callId = 'hijacked'
|
||||
mutable.isError = true
|
||||
mutable.error = { name: 'Evil', code: 'EVIL' }
|
||||
mutable.isError = false
|
||||
if (mutable.error) {
|
||||
mutable.error.name = 'Evil'
|
||||
mutable.error.code = 'EVIL'
|
||||
}
|
||||
mutable.content[0]!.text = 'MUTATED'
|
||||
mutable.content.push({ type: 'text', text: 'INJECTED' }) // in-place array mutation
|
||||
if (mutable.meta) mutable.meta.nested.label = 'MUTATED'
|
||||
return next() // delegate to the default accept — no decision-level override
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked'
|
||||
expect(result.isError).toBe(false) // the real (successful) dispatch outcome
|
||||
expect(result.error).toBeUndefined() // no listener-injected error
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toEqual({ name: 'OriginalError', code: 'ORIGINAL' })
|
||||
expect(result.content).toHaveLength(1) // the in-place push did not leak in
|
||||
expect(result.content[0]).toMatchObject({ text: 'hi' })
|
||||
expect(result.content[0]).toMatchObject({ text: 'original' })
|
||||
expect(result.content.some(b => (b as { text?: string }).text === 'INJECTED')).toBe(false)
|
||||
expect(result.meta).toEqual({ nested: { label: 'original' } })
|
||||
})
|
||||
|
||||
it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
|
||||
@@ -416,6 +526,109 @@ describe('ToolRegistry', () => {
|
||||
expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
|
||||
})
|
||||
|
||||
it('preserves additionalContext supplied by an around-dispatch result', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/execute', async exec => ({
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: 'short-circuited with context' }],
|
||||
isError: false,
|
||||
additionalContext: {
|
||||
content: [{ type: 'text', text: 'from around dispatch' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
},
|
||||
}))
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('around-context'), name: 'echo', arguments: {},
|
||||
})
|
||||
expect(result.additionalContext).toEqual({
|
||||
content: [{ type: 'text', text: 'from around dispatch' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes malformed tools/execute results instead of treating them as success', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
let observedError: boolean | undefined
|
||||
ctx.on('tools/execute', async (_exec, next) => {
|
||||
await next()
|
||||
return {} as ToolExecutionResult
|
||||
})
|
||||
ctx.on('tools/result', (_exec, result) => { observedError = result.isError })
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('malformed'), name: 'echo', arguments: {} })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({
|
||||
text: 'Error: tools/execute must return a ToolExecutionResult with content[] and boolean isError',
|
||||
})
|
||||
expect(observedError).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'non-object result',
|
||||
replacement: null,
|
||||
message: 'tools/execute must return a ToolExecutionResult object',
|
||||
},
|
||||
{
|
||||
name: 'wrong call id',
|
||||
replacement: { callId: CallId('other'), content: [], isError: false },
|
||||
message: 'tools/execute returned callId "other" for authoritative call "malformed-shape"',
|
||||
},
|
||||
])('normalizes a tools/execute $name', async ({ replacement, message }) => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/execute', async () => replacement as ToolExecutionResult)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('malformed-shape'), name: 'echo', arguments: {},
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: `Error: ${message}` })
|
||||
})
|
||||
|
||||
it('normalizes malformed tools/post-execute decisions', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/post-execute', async () => ({ kind: 'accept', content: 'not blocks' }) as unknown as PostToolDecision)
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('malformed-post'), name: 'echo', arguments: {} })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({
|
||||
text: 'Error: tools/post-execute accept content must be an array',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'non-object decision',
|
||||
replacement: null,
|
||||
message: 'tools/post-execute must return a PostToolDecision object',
|
||||
},
|
||||
{
|
||||
name: 'block without feedback blocks',
|
||||
replacement: { kind: 'block', feedback: 'not blocks' },
|
||||
message: 'tools/post-execute block feedback must be an array',
|
||||
},
|
||||
{
|
||||
name: 'unknown decision kind',
|
||||
replacement: { kind: 'defer' },
|
||||
message: 'tools/post-execute must return an accept or block decision',
|
||||
},
|
||||
])('normalizes a tools/post-execute $name', async ({ replacement, message }) => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/post-execute', async () => replacement as unknown as PostToolDecision)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('malformed-post-shape'), name: 'echo', arguments: {},
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: `Error: ${message}` })
|
||||
})
|
||||
|
||||
it('returns an isError result when a tools/execute listener throws', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
@@ -493,6 +706,61 @@ describe('ToolRegistry', () => {
|
||||
}])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Map', new Map([['mutable', true]])],
|
||||
['class instance', new (class Parameters { value = 1 })()],
|
||||
])('rejects cloneable non-JSON tool parameters (%s) without registry residue', async (_kind, parameters) => {
|
||||
const ctx = await setup()
|
||||
const definition = {
|
||||
...echoTool,
|
||||
name: 'invalid-parameters',
|
||||
parameters,
|
||||
} as unknown as typeof echoTool
|
||||
|
||||
expect(() => ctx.tools.register(definition)).toThrow(
|
||||
'tool parameters must be losslessly JSON-serializable',
|
||||
)
|
||||
expect(ctx.tools.get('invalid-parameters')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects tool parameters that change to non-JSON data while being snapshotted', async () => {
|
||||
const ctx = await setup()
|
||||
let reads = 0
|
||||
const parameters = Object.defineProperty({}, 'properties', {
|
||||
enumerable: true,
|
||||
get: () => ++reads === 1 ? {} : new Map([['mutable', true]]),
|
||||
})
|
||||
|
||||
expect(() => ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'unstable-parameters',
|
||||
parameters,
|
||||
})).toThrow('tool parameters must be stable losslessly JSON-serializable data')
|
||||
expect(ctx.tools.get('unstable-parameters')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('snapshots callbacks while preserving their registration-time method receiver', async () => {
|
||||
const ctx = await setup()
|
||||
const receivers: object[] = []
|
||||
const definition = {
|
||||
...echoTool,
|
||||
name: 'callback-snapshot',
|
||||
async execute() {
|
||||
receivers.push(this)
|
||||
return [{ type: 'text' as const, text: 'original' }]
|
||||
},
|
||||
}
|
||||
ctx.tools.register(definition)
|
||||
definition.execute = async () => [{ type: 'text' as const, text: 'replacement' }]
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('callback-snapshot'), name: definition.name, arguments: {},
|
||||
})
|
||||
|
||||
expect(receivers).toEqual([definition])
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'original' }])
|
||||
})
|
||||
|
||||
it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -164,11 +164,10 @@ describe('read tool', () => {
|
||||
expect(text(result)).toContain('offset must be a positive integer')
|
||||
})
|
||||
|
||||
it('rejects a fractional or NaN offset, and a zero/negative limit', async () => {
|
||||
it('rejects a fractional offset and a zero/negative limit', async () => {
|
||||
const { ctx } = await setup()
|
||||
for (const args of [
|
||||
{ file_path: 'a.txt', offset: 1.5 },
|
||||
{ file_path: 'a.txt', offset: Number.NaN },
|
||||
{ file_path: 'a.txt', limit: 0 },
|
||||
{ file_path: 'a.txt', limit: -3 },
|
||||
]) {
|
||||
@@ -178,6 +177,13 @@ describe('read tool', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a non-JSON numeric offset before tool-specific validation', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt', offset: Number.NaN })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('tool execution arguments must be losslessly JSON-serializable')
|
||||
})
|
||||
|
||||
it('rejects a limit above the cap', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt', limit: 99999 })
|
||||
|
||||
@@ -448,7 +448,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
|
||||
const ctx = await harness(path, adapter) // NB: no projectDir
|
||||
// The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } })
|
||||
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } })
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
||||
expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message'
|
||||
@@ -613,7 +613,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server
|
||||
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
|
||||
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
||||
|
||||
@@ -649,7 +649,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server
|
||||
|
||||
// Register a live child on its own session cwd; emit subagent/end with its id.
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const childHandle = ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } })
|
||||
const childHandle = await ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } })
|
||||
ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' })
|
||||
|
||||
await waitFor(() => existsSync(marker))
|
||||
|
||||
@@ -553,7 +553,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
|
||||
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
|
||||
@@ -32,7 +32,7 @@ export const name = 'subagent-fork'
|
||||
// per-run structured runtime gates its capture-tool registration on `tools`
|
||||
// itself, so this backend's apply timing (and the delegation tool's position
|
||||
// in the model-visible tool list) is unchanged by structured output.
|
||||
export const inject = ['subagents', 'agents']
|
||||
export const inject = ['subagents']
|
||||
|
||||
/** Config: the registry name to register the provider under. */
|
||||
export interface Config {
|
||||
@@ -77,7 +77,6 @@ class ForkProvider implements SubagentProvider {
|
||||
start(request: SubagentStartRequest) {
|
||||
const seed = completedTurnPrefix(request.parent)
|
||||
return startInProcessRun(this.ctx, request, {
|
||||
providerName: this.name,
|
||||
// Only pass a seed when there's a completed turn to inherit; an empty seed
|
||||
// is equivalent to a fresh child, so omit it to keep the session unseeded.
|
||||
...seed.length > 0 ? { seed } : {},
|
||||
|
||||
@@ -200,12 +200,12 @@ describe('dsh-subagent-fork', () => {
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
expect('default' in fork).toBe(false)
|
||||
expect(fork.name).toBe('subagent-fork')
|
||||
expect(fork.inject).toEqual(['subagents', 'agents'])
|
||||
expect(fork.inject).toEqual(['subagents'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(fork) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(fork)
|
||||
expect(unwrapped.name).toBe('subagent-fork')
|
||||
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
|
||||
expect(unwrapped.inject).toEqual(['subagents'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,16 +7,18 @@
|
||||
* a prefix of the parent's log); everything downstream — drive the child, read
|
||||
* its final output, map the stop reason, dispose — is identical and lives here.
|
||||
*
|
||||
* This package owns no provider and registers nothing; it is a pure library the
|
||||
* backend packages depend on, so neither backend needs to know about the other.
|
||||
* This package declares no provider and performs no import-time registration;
|
||||
* it is a library the backend packages depend on, so neither backend needs to
|
||||
* know about the other. Each accepted run does install one provider-owned
|
||||
* effect for structured-concurrency cleanup.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-inprocess
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import { AgentId, type Agent, type AgentHandle, type AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId, isJsonValue, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
@@ -86,8 +88,6 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason {
|
||||
|
||||
/** Extra inputs the spawn/fork backends supply to {@link startInProcessRun}. */
|
||||
export interface InProcessRunOptions {
|
||||
/** The provider name (`spawn`/`fork`), for error context only. */
|
||||
readonly providerName: string
|
||||
/**
|
||||
* The child session's seed: a balanced, contiguous-from-0 prefix of the
|
||||
* parent's log (FORK), or `undefined` for a fresh child (SPAWN).
|
||||
@@ -95,6 +95,12 @@ export interface InProcessRunOptions {
|
||||
readonly seed?: SessionEvent[]
|
||||
}
|
||||
|
||||
/** Dispose a run-owner fiber and follow an already-started unload to quiescence. */
|
||||
async function quiesceFiber(fiber: Fiber): Promise<void> {
|
||||
await Promise.resolve(fiber.dispose())
|
||||
while (fiber.inertia !== undefined) await fiber.inertia
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an in-process child agent for `request` and return a {@link SubagentRun}.
|
||||
*
|
||||
@@ -108,9 +114,10 @@ export interface InProcessRunOptions {
|
||||
*
|
||||
* Throws {@link SubagentDepthError} before creating anything when the child's
|
||||
* depth (parent depth + 1) would exceed `request.maxDepth`.
|
||||
* @param ctx - the context whose `agents` factory creates and owns the child.
|
||||
* @param ctx - the provider context that owns the live run as a second
|
||||
* structured-concurrency boundary alongside the parent agent.
|
||||
* @param request - the start request (prompt, parent, signal, per-child options).
|
||||
* @param options - the backend's inputs: provider name plus the optional seed.
|
||||
* @param options - the backend's optional child-session seed.
|
||||
* @returns the live run handle for the child agent.
|
||||
*/
|
||||
export function startInProcessRun(
|
||||
@@ -118,7 +125,15 @@ export function startInProcessRun(
|
||||
request: SubagentStartRequest,
|
||||
options: InProcessRunOptions,
|
||||
): SubagentRun {
|
||||
const childDepth = depthOf(request.parent) + 1
|
||||
// Snapshot the accepted request synchronously. The parent and signal are
|
||||
// identity capabilities (kept live but never reread from the mutable request
|
||||
// record); every data field is detached before asynchronous owner setup.
|
||||
const parent = request.parent
|
||||
const signal = request.signal
|
||||
const persona = request.persona
|
||||
const toolFilter = request.toolFilter === undefined ? undefined : structuredClone(request.toolFilter)
|
||||
const seed = options.seed === undefined ? undefined : structuredClone(options.seed)
|
||||
const childDepth = depthOf(parent) + 1
|
||||
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
|
||||
throw new SubagentDepthError(childDepth, request.maxDepth)
|
||||
}
|
||||
@@ -134,6 +149,17 @@ export function startInProcessRun(
|
||||
// validateStructuredValue to one isolation-immutable value.
|
||||
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
|
||||
const schema = request.outputSchema === undefined ? undefined : structuredClone(request.outputSchema)
|
||||
// The accepted request owns a value snapshot, not the caller's mutable
|
||||
// content array. Validate the same lossless-JSON contract Session.append
|
||||
// enforces before any child exists, then detach it synchronously so mutation
|
||||
// during async creation cannot change what is logged or sent to the model.
|
||||
if (!isJsonValue(request.prompt)) {
|
||||
throw new TypeError('subagent prompt must be losslessly JSON-serializable')
|
||||
}
|
||||
const prompt = structuredClone(request.prompt)
|
||||
if (!isJsonValue(prompt)) {
|
||||
throw new TypeError('subagent prompt must be stable losslessly JSON-serializable data')
|
||||
}
|
||||
|
||||
const childId = AgentId(randomUUID())
|
||||
// The child's OWN events begin after the seed (fork seeds the parent's
|
||||
@@ -141,76 +167,43 @@ export function startInProcessRun(
|
||||
// boundary so a child that produces no message of its own never returns the
|
||||
// SEEDED parent's last assistant message as its result.
|
||||
const seedLength = options.seed?.length ?? 0
|
||||
const parentHeader = request.parent.session.header
|
||||
const parentHeader = parent.session.header
|
||||
// Inherit the parent's model by default (a child with no model cannot run);
|
||||
// an explicit `request.agentOptions.model` overrides it. The deployment
|
||||
// persona needs no inheritance (a context-wide section both render); a
|
||||
// per-child `request.persona` becomes a SCOPED section of the same name in
|
||||
// the setup below, shadowing the deployment's for this child alone.
|
||||
const agentOptions: AgentOptions = {
|
||||
...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {},
|
||||
const agentOptions: AgentOptions = structuredClone({
|
||||
...parent.options.model !== undefined ? { model: parent.options.model } : {},
|
||||
...request.agentOptions,
|
||||
subagentDepth: childDepth,
|
||||
}
|
||||
})
|
||||
|
||||
// The child's scoped world, composed in the factory's setup window (after
|
||||
// the child's scope exists and it is registered, before agent/session-start
|
||||
// and the first prompt assembly; a throw here unwinds the half-created
|
||||
// child inside the factory's rollback boundary):
|
||||
// The child's scoped world, composed in the factory's unpublished setup
|
||||
// window. The factory awaits it before inserting or announcing the child, so
|
||||
// a throw/rejection exposes neither id and every first assembly sees it:
|
||||
// - persona: a scoped `deployment:persona` section shadowing the global one;
|
||||
// - toolFilter: a scoped restrict() masking the global tool surface
|
||||
// (loud unknown-name validation lives in the registry);
|
||||
// - outputSchema: the structured runtime, attached as scoped registrations.
|
||||
let structured: StructuredAttachment | undefined
|
||||
const setup = (childCtx: Context): void => {
|
||||
if (request.persona !== undefined) {
|
||||
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona })
|
||||
if (persona !== undefined) {
|
||||
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: persona })
|
||||
}
|
||||
if (request.toolFilter !== undefined) {
|
||||
childCtx.tools.restrict(request.toolFilter)
|
||||
if (toolFilter !== undefined) {
|
||||
childCtx.tools.restrict(toolFilter)
|
||||
}
|
||||
if (schema !== undefined) {
|
||||
structured = attachStructuredRuntime(childCtx, schema)
|
||||
}
|
||||
}
|
||||
|
||||
const handle: AgentHandle = ctx.agents.create({
|
||||
agentId: childId,
|
||||
sessionId: SessionId(randomUUID()),
|
||||
meta: {
|
||||
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
|
||||
parentSession: parentHeader.id,
|
||||
// Record the seed boundary so a reload (and a replay harness) can tell the
|
||||
// inherited prefix from the child's OWN events. 0 for a fresh spawn.
|
||||
...seedLength > 0 ? { seedLength } : {},
|
||||
},
|
||||
...options.seed !== undefined ? { seed: options.seed } : {},
|
||||
agentOptions,
|
||||
setup,
|
||||
})
|
||||
const child = handle.agent
|
||||
|
||||
// Structured-concurrency link: the child's teardown rides the PARENT's
|
||||
// scope, so a disposed parent reaches its whole subtree even if the
|
||||
// delegating tool's `finally` never runs — through the MEMOIZED handle, so
|
||||
// every path (tool finally, parent teardown, owner unload) observes the
|
||||
// same quiescence boundary. Registered AFTER the child exists; if the
|
||||
// parent began disposing in between, the registration throws
|
||||
// INACTIVE_EFFECT — dispose the fresh child before rethrowing (no orphan).
|
||||
// Definite assignment: the catch rethrows, so past this block the unlink
|
||||
// disposer always exists.
|
||||
let unlink!: () => Promise<void> | void
|
||||
try {
|
||||
unlink = request.parent.ctx.effect(() => () => handle.dispose())
|
||||
} catch (error: unknown) {
|
||||
// Fire-and-forget: start() must rethrow synchronously; the child's
|
||||
// teardown (stop → unregister → detach) reaches quiescence on its own.
|
||||
void handle.dispose()
|
||||
throw error
|
||||
}
|
||||
|
||||
// Bridge the request's abort signal to the child (the consumer also bridges
|
||||
// its own exec.signal, but a backend-level bridge keeps the contract local).
|
||||
// Install it after provider ownership succeeds but BEFORE awaiting creation,
|
||||
// so an inactive provider cannot leave an orphaned listener and abort/dispose
|
||||
// during async setup is still recorded and applied the moment a child exists.
|
||||
// `cancelled` records that a cancel was requested at all, so the pre-turn
|
||||
// cancel window — where the child clears the queued prompt before any
|
||||
// `turn/end` is logged — settles as `aborted` (honoring the cancel contract)
|
||||
@@ -220,31 +213,104 @@ export function startInProcessRun(
|
||||
// abort listener, run.cancel), which control-flow narrowing cannot see — an
|
||||
// inline read at the result mapping would narrow to the initializer.
|
||||
const isCancelled = (): boolean => cancelled
|
||||
let child: Agent | undefined
|
||||
let handle: AgentHandle | undefined
|
||||
let disposeRequested = false
|
||||
const isDisposeRequested = (): boolean => disposeRequested
|
||||
const requestCancel = (reason: string): void => {
|
||||
cancelled = true
|
||||
child.cancel(reason)
|
||||
child?.cancel(reason)
|
||||
}
|
||||
const onAbort = (): void => { requestCancel('subagent cancelled') }
|
||||
request.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
// One run-owned Cordis fiber is the common ownership node. Install the
|
||||
// provider effect FIRST: a start racing an already-unloading provider fails
|
||||
// before it can mint anything under the parent. The owner fiber is then
|
||||
// nested under the parent scope, and the provider/run handle both dispose
|
||||
// this exact fiber. AgentFactory binds its lifecycle to `ownerCtx`, so any of
|
||||
// the three owners moves the fiber out of ACTIVE synchronously and setup
|
||||
// cannot publish afterward.
|
||||
let ownerCtx: Context | undefined
|
||||
function subagentRunOwner(inner: Context): void { ownerCtx = inner }
|
||||
let ownerFiber: (Fiber & PromiseLike<Fiber>) | undefined
|
||||
let ownerSetupError: unknown
|
||||
let ownerDisposing: Promise<void> | undefined
|
||||
const disposeOwner = (): Promise<void> => (ownerDisposing ??= ownerFiber === undefined
|
||||
? Promise.resolve()
|
||||
: quiesceFiber(ownerFiber))
|
||||
let manualDisposeRequested = false
|
||||
const isManualDisposeRequested = (): boolean => manualDisposeRequested
|
||||
const unlinkProvider = ctx.effect(() => () => {
|
||||
requestCancel('subagent provider disposed')
|
||||
return disposeOwner()
|
||||
}, 'subagent-inprocess.run()')
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal?.aborted) requestCancel('subagent cancelled')
|
||||
try {
|
||||
ownerFiber = parent.ctx.plugin(Object.assign(subagentRunOwner, {
|
||||
inject: ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'],
|
||||
}))
|
||||
} catch (error: unknown) {
|
||||
ownerSetupError = error
|
||||
}
|
||||
|
||||
const creation: Promise<Agent> = (async () => {
|
||||
if (ownerSetupError !== undefined) {
|
||||
throw ownerSetupError instanceof Error
|
||||
? ownerSetupError
|
||||
: new Error('subagent run owner setup failed with a non-Error value', { cause: ownerSetupError })
|
||||
}
|
||||
await ownerFiber
|
||||
if (ownerCtx === undefined) {
|
||||
throw new Error('subagent run owner became inactive before child creation')
|
||||
}
|
||||
// Invoke the factory THROUGH the parent scope. Cordis binds the factory's
|
||||
// lifecycle effect to the accessing context, so parent ownership exists
|
||||
// before persistence/setup and publication—not as a fallible link added
|
||||
// after the child is already visible. A disposed parent therefore rejects
|
||||
// before any session/agent notification, and disposal during async setup
|
||||
// wins the unpublished transaction.
|
||||
const created = await ownerCtx.agents.create({
|
||||
agentId: childId,
|
||||
sessionId: SessionId(randomUUID()),
|
||||
meta: {
|
||||
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
|
||||
parentSession: parentHeader.id,
|
||||
...seedLength > 0 ? { seedLength } : {},
|
||||
},
|
||||
...seed !== undefined ? { seed } : {},
|
||||
agentOptions,
|
||||
setup,
|
||||
})
|
||||
handle = created
|
||||
child = created.agent
|
||||
|
||||
if (isCancelled()) created.agent.cancel('subagent cancelled')
|
||||
return created.agent
|
||||
})()
|
||||
|
||||
const result: Promise<SubagentResult> = (async () => {
|
||||
try {
|
||||
// A signal already aborted BEFORE the run starts never fires an `abort`
|
||||
// event (`addEventListener` only fires on the transition), so the listener
|
||||
// above won't catch it — settle `aborted` without running the child rather
|
||||
// than completing an already-cancelled request.
|
||||
if (request.signal?.aborted) return { output: [], stopReason: 'aborted' }
|
||||
child.send(request.prompt)
|
||||
await child.whenIdle()
|
||||
let liveChild: Agent
|
||||
try {
|
||||
liveChild = await creation
|
||||
} catch (error: unknown) {
|
||||
if (isManualDisposeRequested()) return { output: [], stopReason: 'aborted' }
|
||||
throw error instanceof Error ? error : new Error('subagent child creation failed with a non-Error value', { cause: error })
|
||||
}
|
||||
if (isCancelled() || isDisposeRequested()) return { output: [], stopReason: 'aborted' }
|
||||
liveChild.send(prompt)
|
||||
await liveChild.whenIdle()
|
||||
// Deliberately NO re-prompt when a structured child finishes cleanly
|
||||
// without calling structured_output: readResult maps that to `error` —
|
||||
// the shortfall goes to the parent instead of buying extra model turns.
|
||||
return readResult(child, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined)
|
||||
return readResult(liveChild, seedLength, isCancelled(), structured ? { captured: structured.captured() } : undefined)
|
||||
} finally {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
})()
|
||||
|
||||
let disposing: Promise<void> | undefined
|
||||
return {
|
||||
id: childId,
|
||||
result,
|
||||
@@ -252,13 +318,26 @@ export function startInProcessRun(
|
||||
requestCancel(reason ?? 'subagent cancelled')
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
request.signal?.removeEventListener('abort', onAbort)
|
||||
// Through the parent-scope unlink when the parent is still live (one
|
||||
// disposal path, and the dead effect leaves the parent's list); the
|
||||
// memoized handle keeps a direct dispose equivalent if the parent's
|
||||
// teardown already ran the unlink.
|
||||
await unlink()
|
||||
await handle.dispose()
|
||||
return (disposing ??= (async () => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
disposeRequested = true
|
||||
manualDisposeRequested = true
|
||||
requestCancel('subagent disposed during creation')
|
||||
// Removing provider ownership and disposing the common run-owner fiber
|
||||
// are the same quiescence transaction; parent disposal may already have
|
||||
// claimed it, in which case disposeOwner follows fiber inertia.
|
||||
await unlinkProvider()
|
||||
try {
|
||||
await creation
|
||||
} catch {
|
||||
// Creation rollback already reached quiescence; there is no handle
|
||||
// left to dispose, and dispose must not mask result's infrastructure
|
||||
// rejection with the same error from a finally block.
|
||||
return
|
||||
}
|
||||
await disposeOwner()
|
||||
await handle?.dispose()
|
||||
})())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,44 +14,39 @@
|
||||
* a disposed child leaves no residue — no placeholder schema,
|
||||
* strip-for-everyone-else pass, or refcounted global runtime.
|
||||
*
|
||||
* Four listeners enforce the contract:
|
||||
* The child scope's registrations enforce the contract:
|
||||
*
|
||||
* - `system-prompt/assemble` (prepend, scoped): assembly re-assert — the
|
||||
* listener post-processes its downstream chain so a listener inside that
|
||||
* chain cannot leave the child's capture tool or instruction stripped or
|
||||
* replaced. Tools are replaced in place and the section is re-inserted at
|
||||
* its ascending-order position, so the untampered path keeps the registry's
|
||||
* ordering (up to intra-band section order, which carries no contract). A
|
||||
* listener prepended later can still wrap and transform this result; this is
|
||||
* an ordinary waterfall listener, not a service-level finalizer. The loop
|
||||
* logs the rendered assembly as the request header, so the demand is
|
||||
* reconstructable log state, never a wire-only mutation.
|
||||
* - `agent/turn-continuation` (prepend, scoped): stop the child's turn once
|
||||
* its output is captured — the loop's default "had tool calls ⇒ continue"
|
||||
* would buy a wasted extra model step per structured child.
|
||||
* - `tools/pre-execute` (prepend, scoped): terminal means terminal WITHIN the
|
||||
* step — deny every call arriving after the capture, so a response that
|
||||
* lists `structured_output` before further tool calls cannot run side
|
||||
* effects after the final answer was accepted.
|
||||
* - `tools/post-execute` (prepend, scoped): the capture COMMIT. The tool body
|
||||
* only STAGES the validated value, KEYED BY THE EXECUTION OBJECT in a
|
||||
* WeakMap; it becomes the run's captured result when this listener's
|
||||
* downstream post-execute decision accepts THAT SAME pipeline trip. A
|
||||
* later-prepended wrapper remains outside that decision. Execution-keyed
|
||||
* staging makes the stale-stage class structurally impossible: a value
|
||||
* orphaned by an outer short-circuiting listener (a post-execute block, or
|
||||
* a pre-execute deny whose call never dispatched) can never match another
|
||||
* execution's lookup — whatever call id that execution carries — and is
|
||||
* reclaimed with the execution object itself.
|
||||
* - `systemPrompt.protect()` declaratively protects the capture tool and its
|
||||
* instruction. The service restores their canonical pre-waterfall state
|
||||
* after EVERY assembly listener. Canonical absence is protected too: pure
|
||||
* Code Mode keeps `structured_output` in the SDK only and never grows a
|
||||
* second native wire tool. Code Mode's owner independently protects its SDK
|
||||
* and `run_code` transport. The loop logs the finalized assembly as the
|
||||
* request header, so the demand is reconstructable log state, never a
|
||||
* wire-only mutation.
|
||||
* - `agent/turn-stop` (serial, scoped): stop the child's turn once its output
|
||||
* is captured. This terminal checkpoint runs after the ordinary continuation
|
||||
* waterfall and steering folding, so listener order cannot resurrect a
|
||||
* completed structured run or carry terminal steering into another turn.
|
||||
* - `tools.guard()` is the monotonic terminal gate after the extensible
|
||||
* pre-execute waterfall: once capture commits, no later listener can turn
|
||||
* the denial back into a dispatched side effect.
|
||||
* - `tools/result` is the capture COMMIT point. The tool body only STAGES the
|
||||
* validated value in a WeakMap keyed by the execution object; the awaited,
|
||||
* non-transforming notification promotes it only when the authoritative
|
||||
* result after the whole pre/execute/post pipeline succeeds. For a Code Mode
|
||||
* sub-dispatch, promotion waits again for the enclosing `run_code` result, so
|
||||
* a runtime failure or outer post-policy block cannot report structured
|
||||
* success. Execution identity makes call-id reuse and orphaned stages
|
||||
* irrelevant.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-inprocess/structured
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** The model-facing tool name a structured child must call to finish. */
|
||||
@@ -71,7 +66,7 @@ export const STRUCTURED_OUTPUT_INSTRUCTION
|
||||
export interface StructuredAttachment {
|
||||
/**
|
||||
* The captured value, once the child called the tool with valid arguments
|
||||
* and the final post-execute decision accepted that call.
|
||||
* and the authoritative final tool result accepted that call.
|
||||
* @returns the committed value, or undefined while none was accepted.
|
||||
*/
|
||||
captured(): { value: unknown } | undefined
|
||||
@@ -80,7 +75,7 @@ export interface StructuredAttachment {
|
||||
/**
|
||||
* Attach the structured-output runtime to a child for `schema`: register the
|
||||
* scoped capture tool (real schema), the scoped instruction section, and the
|
||||
* four scoped enforcement listeners (see the module doc). Call from the
|
||||
* scoped enforcement registrations (see the module doc). Call from the
|
||||
* agent-creation `setup` window with the child's scope context — every
|
||||
* registration rides the child's fiber and unwinds with the child.
|
||||
* @param childCtx - the child agent's scope context (`setup`'s argument).
|
||||
@@ -91,19 +86,16 @@ export interface StructuredAttachment {
|
||||
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
|
||||
/**
|
||||
* Validated values staged by the capture tool body, awaiting THEIR OWN
|
||||
* call's post-execute verdict — keyed by the {@link ToolExecution} OBJECT,
|
||||
* the one token that provably ties a stage to one trip through the
|
||||
* pipeline. A call id cannot key this: ids are adapter-minted and may
|
||||
* repeat across steps. Keying by execution makes the stale-stage class
|
||||
* structurally impossible — an entry orphaned by an outer short-circuiting
|
||||
* listener can never match a different execution's lookup, needs no drop
|
||||
* bookkeeping (the WeakMap reclaims it with the execution object), and two
|
||||
* in-flight captures can never cross-clobber each other's STAGE should
|
||||
* tool execution ever go parallel (the loop's documented TODO). Staging is
|
||||
* the only layer this future-proofs: a parallel-execution cut would still
|
||||
* owe its own single-accept rule for `captured` itself.
|
||||
* authoritative `tools/result` notification. The execution object's identity
|
||||
* uniquely identifies a trip through the pipeline: adapter call ids may
|
||||
* repeat across steps, but another execution can never reach this WeakMap
|
||||
* entry. This is distinct from the opaque `ToolExecutionToken` used to
|
||||
* correlate nested transports. The final notification always deletes its own
|
||||
* stage, whether the result succeeded or failed.
|
||||
*/
|
||||
const staged = new WeakMap<ToolExecution, { value: unknown }>()
|
||||
/** Successful nested capture waiting for its enclosing transport to commit. */
|
||||
let pending: { parent: ToolExecution['token']; value: unknown } | undefined
|
||||
let captured: { value: unknown } | undefined
|
||||
|
||||
const schemaEntry: ToolSchema = {
|
||||
@@ -123,10 +115,10 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
||||
// ToolArgsError → isError result with INVALID_ARGS: the model retries
|
||||
// within the same turn, exactly like a schema-validated defineTool call.
|
||||
if (violations.length > 0) throw new ToolArgsError(violations)
|
||||
// Two-phase commit, KEYED BY THIS EXECUTION: the body only stages; the
|
||||
// post-execute listener promotes exactly this pipeline trip's entry
|
||||
// when its downstream decision accepts it.
|
||||
staged.set(exec, { value: args })
|
||||
// Two-phase commit, keyed by THIS execution: later transformable
|
||||
// waterfalls may still turn the success into an error. Snapshot the
|
||||
// validated value independently of the already-frozen pipeline arguments.
|
||||
staged.set(exec, { value: structuredClone(args) })
|
||||
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
|
||||
},
|
||||
})
|
||||
@@ -137,105 +129,58 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
||||
text: STRUCTURED_OUTPUT_INSTRUCTION,
|
||||
})
|
||||
|
||||
// PREPENDED assembly re-assert: scoped dispatch means this fires only for the
|
||||
// child's assemblies; `await next()` returns whatever this listener's
|
||||
// downstream chain produced, and the capture tool + instruction are
|
||||
// re-asserted onto it if anything stripped them. A listener prepended later
|
||||
// can still wrap and transform the returned assembly; this is not a
|
||||
// service-level finalizer.
|
||||
childCtx.on('system-prompt/assemble', async function (
|
||||
this: unknown, _assembly: PromptAssembly, _context: AssembleContext, next: () => Promise<PromptAssembly>,
|
||||
): Promise<PromptAssembly> {
|
||||
const final = await next()
|
||||
// REPLACE, not merely ensure-present: a downstream listener may have
|
||||
// mutated or injected a same-named entry with the WRONG schema/text, and
|
||||
// the model-visible demand must be exactly this run's own — the same
|
||||
// schema validateStructuredValue enforces. Placement-preserving on both
|
||||
// arrays: the untampered path keeps the registry's ordering (tool order
|
||||
// is the `toolOrder`/lexicographic contract, section order the ascending
|
||||
// contract `renderPrompt` trusts), so this never reorders what it only
|
||||
// re-asserts — up to intra-band section order, which carries no contract
|
||||
// (a 190-order section registered AFTER this runtime sorts before the
|
||||
// instruction in the registry but after it here).
|
||||
const freshTool: ToolSchema = { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) }
|
||||
// Tools: replace the first same-named entry IN PLACE (its position is the
|
||||
// chain's product; a tool's list position carries no semantic band to
|
||||
// restore), drop any duplicates, append only when stripped entirely.
|
||||
const tools: ToolSchema[] = []
|
||||
let toolReplaced = false
|
||||
for (const tool of final.tools) {
|
||||
if (tool.name !== STRUCTURED_OUTPUT_TOOL) {
|
||||
tools.push(tool)
|
||||
} else if (!toolReplaced) {
|
||||
tools.push(freshTool)
|
||||
toolReplaced = true
|
||||
// Service-owned finalization, not waterfall ordering. The canonical
|
||||
// assembly determines both presence and absence: native/both modes restore
|
||||
// the capture schema on the wire, while pure Code Mode removes any injected
|
||||
// native entry. ToolRegistry's own protection independently restores the SDK
|
||||
// section and run_code transport that carry the same schema.
|
||||
childCtx.systemPrompt.protect({
|
||||
sections: [`tool:${STRUCTURED_OUTPUT_TOOL}`],
|
||||
tools: [STRUCTURED_OUTPUT_TOOL],
|
||||
})
|
||||
|
||||
// Stop the child's turn once its output is captured. This monotonic serial
|
||||
// checkpoint runs after the ordinary continuation waterfall, its reason,
|
||||
// and late-steering folding, so no ordering trick can resume a finished run.
|
||||
childCtx.on('agent/turn-stop', function (this: unknown): ContinuationStop | undefined {
|
||||
return captured === undefined ? undefined : { action: 'stop' }
|
||||
})
|
||||
|
||||
// Terminal WITHIN the step. Guards run after the whole pre-execute
|
||||
// waterfall and compose monotonically (deny or abstain, never allow), so a
|
||||
// later prepended listener cannot resurrect dispatch. Calls that precede
|
||||
// capture in the same response remain untouched.
|
||||
childCtx.tools.guard(exec => captured === undefined && pending === undefined
|
||||
? undefined
|
||||
: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`)
|
||||
|
||||
// The capture COMMIT observes the immutable, authoritative result after the
|
||||
// complete pipeline and outer error normalization. This notification cannot
|
||||
// transform the outcome, so there is no wrapper outside the commit verdict.
|
||||
childCtx.on('tools/result', function (this: unknown, exec, result): void {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
||||
const entry = staged.get(exec)
|
||||
if (entry === undefined) return
|
||||
staged.delete(exec)
|
||||
if (result.isError) return
|
||||
if (exec.parent === undefined) {
|
||||
/* v8 ignore else -- sequential agent-loop dispatch lets the guard block every later supported call */
|
||||
if (captured === undefined) captured = { value: entry.value }
|
||||
} else {
|
||||
/* v8 ignore else -- Code Mode serializes sub-dispatches, so the guard blocks every later supported call */
|
||||
if (captured === undefined && pending === undefined) {
|
||||
pending = { parent: exec.parent, value: entry.value }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!toolReplaced) tools.push(freshTool)
|
||||
final.tools = tools
|
||||
// Sections: remove every same-named entry and re-insert at the
|
||||
// ascending-correct position (the first entry above order 190) — sections
|
||||
// DO carry an order contract, and the renderer reads array order, so a
|
||||
// stripped-or-moved instruction is restored to its band, not appended
|
||||
// after unrelated higher-order sections. On the untampered path this
|
||||
// lands at the end of the 190 band — where the registry's stable sort
|
||||
// put it too, unless another 190-order section registered later.
|
||||
const sectionName = `tool:${STRUCTURED_OUTPUT_TOOL}`
|
||||
const sections = final.sections.filter(section => section.name !== sectionName)
|
||||
const insertAt = sections.findIndex(section => section.order > 190)
|
||||
sections.splice(insertAt === -1 ? sections.length : insertAt, 0, { name: sectionName, order: 190, text: STRUCTURED_OUTPUT_INSTRUCTION })
|
||||
final.sections = sections
|
||||
return final
|
||||
}, { prepend: true })
|
||||
|
||||
// Stop the child's turn once its output is captured. `prepend: true` puts
|
||||
// the veto OUTERMOST — an earlier-registered listener that short-circuits
|
||||
// the chain (a goal-style force-continue returning without `next()`) would
|
||||
// otherwise decide the turn before this listener ever ran, and no
|
||||
// downstream decision may resurrect a structured turn that is finished.
|
||||
childCtx.on('agent/turn-continuation', function (
|
||||
this: unknown, _agent: Agent, _turn: number, _decision: ContinuationDecision, next: () => Promise<ContinuationDecision>,
|
||||
): Promise<ContinuationDecision> {
|
||||
if (captured) return Promise.resolve({ action: 'stop' })
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
|
||||
// Terminal WITHIN the step: deny every call after the capture. Calls that
|
||||
// PRECEDE the capture in the same response ran before `captured` was set
|
||||
// and are untouched; a second `structured_output` is denied like any other.
|
||||
childCtx.on('tools/pre-execute', function (
|
||||
this: unknown, exec: ToolExecution, next: () => Promise<PreToolDecision>,
|
||||
): Promise<PreToolDecision> {
|
||||
if (captured) {
|
||||
return Promise.resolve({
|
||||
kind: 'deny',
|
||||
reason: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`,
|
||||
})
|
||||
}
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
|
||||
// The capture COMMIT: promote a staged value only when the final
|
||||
// post-execute decision accepts THE SAME EXECUTION that staged it — the
|
||||
// lookup key IS the execution, so a stale entry from a different pipeline
|
||||
// trip (its own chain short-circuited past this commit by an outer
|
||||
// post-execute block, or an outer pre-execute deny whose call never
|
||||
// dispatched) is unreachable here by construction, whatever the current
|
||||
// call's id.
|
||||
childCtx.on('tools/post-execute', async function (
|
||||
this: unknown, exec: ToolExecution, _result: ToolExecutionResult, next: () => Promise<PostToolDecision>,
|
||||
): Promise<PostToolDecision> {
|
||||
if (exec.name !== STRUCTURED_OUTPUT_TOOL) return next()
|
||||
const entry = staged.get(exec)
|
||||
if (entry === undefined) return next()
|
||||
// Single-shot per execution: this trip's verdict is decided by the chain
|
||||
// below, never revisited (the WeakMap would reclaim the entry either way;
|
||||
// deleting states the intent).
|
||||
staged.delete(exec)
|
||||
const decision = await next()
|
||||
if (decision.kind === 'accept') captured = { value: entry.value }
|
||||
return decision
|
||||
}, { prepend: true })
|
||||
if (pending?.parent !== exec.token) return
|
||||
const entry = pending
|
||||
pending = undefined
|
||||
if (result.isError) return
|
||||
/* v8 ignore else -- Code Mode serializes outer executions, so the guard blocks every later supported call */
|
||||
if (captured === undefined) captured = { value: entry.value }
|
||||
})
|
||||
|
||||
return { captured: () => captured }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
||||
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { Config as ToolConfig, StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { startInProcessRun } from '../src/index.ts'
|
||||
import {
|
||||
@@ -19,6 +20,15 @@ import {
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
interface CodeRunRequestLike {
|
||||
bindings: { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }[]
|
||||
}
|
||||
|
||||
interface SetupOptions {
|
||||
toolMode?: ToolConfig['mode']
|
||||
codeRun?: (request: CodeRunRequestLike) => Promise<{ logs: never[]; value?: unknown }>
|
||||
}
|
||||
|
||||
const SCHEMA: StructuredOutputSchema = {
|
||||
type: 'object',
|
||||
properties: { answer: { type: 'number' }, note: { type: 'string' } },
|
||||
@@ -33,13 +43,20 @@ const SCHEMA: StructuredOutputSchema = {
|
||||
* coverage lives in the spawn/fork specs. The mock model script drives the
|
||||
* child's structured_output calls.
|
||||
*/
|
||||
async function setup(script: Script) {
|
||||
async function setup(script: Script, options: SetupOptions = {}) {
|
||||
const ctx = new Context()
|
||||
const adapter = new MockAdapter(script)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(ToolRegistry, { mode: options.toolMode ?? 'native' })
|
||||
if (options.toolMode === 'code' || options.toolMode === 'both') {
|
||||
ctx.provide('codeRuntime', {
|
||||
language: 'typescript',
|
||||
isolation: 'test',
|
||||
run: options.codeRun ?? (() => Promise.resolve({ logs: [] })),
|
||||
} as never)
|
||||
}
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
@@ -48,7 +65,7 @@ async function setup(script: Script) {
|
||||
name: 'spawn',
|
||||
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, { providerName: 'spawn' }),
|
||||
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, {}),
|
||||
})
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
@@ -121,6 +138,44 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a later prepended pre-execute listener cannot resurrect dispatch after capture', async () => {
|
||||
const response = [
|
||||
...toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }).slice(0, -2),
|
||||
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
||||
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'side_effect', arguments: '{}' } },
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
] as Script[number]
|
||||
const { ctx, parent } = await setup([response])
|
||||
let sideEffectRan = false
|
||||
ctx.tools.register({
|
||||
name: 'side_effect',
|
||||
description: 'probe',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
execute(): Promise<ContentBlock[]> {
|
||||
sideEffectRan = true
|
||||
return Promise.resolve([{ type: 'text', text: 'ran' }])
|
||||
},
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
// Registered after the child and prepended: this listener returns allow
|
||||
// after every downstream pre-execute decision. The service-owned guard
|
||||
// runs after the waterfall and can only deny, so the body still cannot run.
|
||||
ctx.on('tools/pre-execute', async (_exec, next) => {
|
||||
await next()
|
||||
return { kind: 'allow' as const }
|
||||
}, { prepend: true })
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 5 })
|
||||
expect(sideEffectRan).toBe(false)
|
||||
const child = ctx.agents.get(run.id)
|
||||
const sideEffectResult = child?.session.events.find(event =>
|
||||
event.type === 'tool/result' && event.data.callId === 'c2')
|
||||
expect(sideEffectResult?.type === 'tool/result' && sideEffectResult.data.isError).toBe(true)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('leaves tool calls that PRECEDE the capture in the same response untouched', async () => {
|
||||
const response = [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
@@ -173,23 +228,67 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('the captured-turn veto is prepend: an EARLIER force-continue listener cannot short-circuit it', async () => {
|
||||
// A goal-style listener registered BEFORE the child exists, returning a
|
||||
// forced continue WITHOUT calling next(). Without prepend on the scoped
|
||||
// veto, this would decide the turn first and buy a wasted model step —
|
||||
// the one-response script would then throw on the second request.
|
||||
it('a later-prepended continuation wrapper cannot resurrect a captured turn', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
textResponse('MUST NOT BE CONSUMED'),
|
||||
])
|
||||
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'continue' }))
|
||||
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
let wrapperInstalled = false
|
||||
// Register this observer only after start() returns. The child session-start
|
||||
// boundary is after its unpublished setup attached structured output but
|
||||
// before the loop can run; install a prepended wrapper there. It awaits the
|
||||
// explicit downstream stop above, then overwrites that result with continue.
|
||||
// The later terminal checkpoint still wins.
|
||||
ctx.on('agent/session-start', (child) => {
|
||||
if (child.id !== run.id) return
|
||||
wrapperInstalled = true
|
||||
child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, next): Promise<ContinuationDecision> => {
|
||||
const downstream = await next()
|
||||
expect(downstream).toEqual({ action: 'stop' })
|
||||
return { action: 'continue' }
|
||||
}, { prepend: true })
|
||||
})
|
||||
const result = await run.result
|
||||
expect(wrapperInstalled).toBe(true)
|
||||
expect(result.structured).toEqual({ answer: 7 })
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a continuation wrapper cannot carry steering past a captured terminal stop', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
|
||||
textResponse('MUST NOT BE CONSUMED'),
|
||||
])
|
||||
// The downstream ordinary policy says stop. A wrapper registered after
|
||||
// start() delegates to that stop, then queues steering; ordinary folding
|
||||
// would turn the stop back into continue. The terminal checkpoint runs
|
||||
// afterwards and discards that steering.
|
||||
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
ctx.on('agent/session-start', (child) => {
|
||||
if (child.id !== run.id) return
|
||||
child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, next): Promise<ContinuationDecision> => {
|
||||
const downstream = await next()
|
||||
expect(downstream).toEqual({ action: 'stop' })
|
||||
subject.steer([{ type: 'text', text: 'late steering after downstream stop' }])
|
||||
return downstream
|
||||
}, { prepend: true })
|
||||
})
|
||||
|
||||
const result = await run.result
|
||||
const child = ctx.agents.get(run.id)
|
||||
|
||||
expect(result.structured).toEqual({ answer: 9 })
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(child?.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(child?.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),
|
||||
@@ -236,11 +335,11 @@ describe('in-process structured output', () => {
|
||||
it('a cancel landing after a clean capture-less turn settles aborted, not error', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('prose, no capture')])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// Cancel synchronously inside the turn's end recording: the cancel
|
||||
// contract outranks the schema shortfall, so the result maps to aborted.
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === child.session && event.type === 'turn/end') run.cancel('cancelled at turn end')
|
||||
const child = ctx.agents.get(run.id)
|
||||
if (session === child?.session && event.type === 'turn/end') run.cancel('cancelled at turn end')
|
||||
})
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('aborted')
|
||||
@@ -270,8 +369,8 @@ describe('in-process structured output', () => {
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
textResponse('continues after the blocked capture'),
|
||||
])
|
||||
// A PostToolUse-style hook, registered AFTER the runtime (so the runtime's
|
||||
// prepend commit listener stays outermost and composes this verdict).
|
||||
// A PostToolUse-style hook turns the tool body's provisional success into
|
||||
// the authoritative final error observed by the commit notification.
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
||||
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'capture rejected by hook' }] })
|
||||
@@ -311,6 +410,31 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('commits only after a later prepended post-execute wrapper returns the authoritative result', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 8 }),
|
||||
textResponse('capture was rejected'),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
// Registered after attachment and prepended, so it wraps every listener
|
||||
// the child installed. It delegates first, then converts the apparent
|
||||
// capture success into the pipeline's authoritative failure.
|
||||
ctx.on('tools/post-execute', async (exec, _result, next) => {
|
||||
const downstream = await next()
|
||||
if (exec.name !== STRUCTURED_OUTPUT_TOOL) return downstream
|
||||
return { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'rejected after downstream' }] }
|
||||
}, { prepend: true })
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toBeUndefined()
|
||||
expect(result.stopReason).toBe('error')
|
||||
const child = ctx.agents.get(run.id)
|
||||
const captureResult = child?.session.events.find(event =>
|
||||
event.type === 'tool/result' && event.data.callId === 'c1')
|
||||
expect(captureResult?.type === 'tool/result' && captureResult.data.isError).toBe(true)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('appends the structured instruction to the child REQUEST\'s system text (base prompt preserved)', async () => {
|
||||
const { ctx, parent, adapter } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })])
|
||||
// A context-wide section stands in for the deployment persona: the
|
||||
@@ -327,6 +451,100 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('keeps pure Code Mode at one wire tool and exposes structured capture through the SDK only', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })' }),
|
||||
], {
|
||||
toolMode: 'code',
|
||||
codeRun: async (request) => {
|
||||
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
|
||||
if (!capture) throw new Error('structured_output binding missing')
|
||||
await capture({ answer: 12 })
|
||||
return { logs: [], value: 'captured' }
|
||||
},
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
|
||||
// This listener is registered after the child's protection and prepended.
|
||||
// Service finalization still restores the stripped transport and prompt
|
||||
// parts, while removing the fabricated native capture tool.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const result = await next()
|
||||
return {
|
||||
sections: result.sections.filter(section =>
|
||||
section.name !== 'tools:sdk' && section.name !== `tool:${STRUCTURED_OUTPUT_TOOL}`),
|
||||
tools: [
|
||||
...result.tools.filter(tool => tool.name !== RUN_CODE_NAME),
|
||||
{ name: STRUCTURED_OUTPUT_TOOL, description: 'wrong native duplicate', parameters: {} },
|
||||
],
|
||||
variables: result.variables,
|
||||
}
|
||||
}, { prepend: true })
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toEqual({ answer: 12 })
|
||||
const request = adapter.requests[0]!
|
||||
expect(toolNames(request)).toEqual([RUN_CODE_NAME])
|
||||
expect(request.system).toContain('declare const tools:')
|
||||
expect(request.system).toContain('structured_output(args:')
|
||||
expect(request.system).toContain(STRUCTURED_OUTPUT_INSTRUCTION)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('discards a nested capture when the enclosing run_code execution fails', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', RUN_CODE_NAME, { code: 'await tools.structured_output({ answer: 12 }); throw new Error("boom")' }),
|
||||
textResponse('outer code failed'),
|
||||
], {
|
||||
toolMode: 'code',
|
||||
codeRun: async (request) => {
|
||||
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
|
||||
if (!capture) throw new Error('structured_output binding missing')
|
||||
await capture({ answer: 12 })
|
||||
return {
|
||||
logs: [],
|
||||
error: { kind: 'runtime', message: 'boom after capture' },
|
||||
} as never
|
||||
},
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toBeUndefined()
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
const child = ctx.agents.get(run.id)!
|
||||
const outer = child.session.events.find(event =>
|
||||
event.type === 'tool/result' && event.data.callId === CallId('c1'))
|
||||
expect(outer?.type === 'tool/result' && outer.data.isError).toBe(true)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('discards a nested capture when post-policy blocks the enclosing run_code result', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })' }),
|
||||
textResponse('outer code was blocked'),
|
||||
], {
|
||||
toolMode: 'code',
|
||||
codeRun: async (request) => {
|
||||
const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL]
|
||||
if (!capture) throw new Error('structured_output binding missing')
|
||||
await capture({ answer: 12 })
|
||||
return { logs: [], value: 'captured' }
|
||||
},
|
||||
})
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => exec.name === RUN_CODE_NAME
|
||||
? Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer blocked' }] })
|
||||
: next())
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
|
||||
const result = await run.result
|
||||
expect(result.structured).toBeUndefined()
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('the instruction rides ONLY structured requests: appended for the child, absent for a plain agent', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
textResponse('parent answer'),
|
||||
@@ -412,12 +630,12 @@ describe('in-process structured output', () => {
|
||||
await runB.dispose()
|
||||
})
|
||||
|
||||
it('the re-assert REPLACES a conflicting injected schema, not merely ensures presence', async () => {
|
||||
it('protection replaces a conflicting injected schema, not merely ensuring presence', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
|
||||
])
|
||||
// A global listener that INJECTS a wrong-schema structured_output entry:
|
||||
// the child's re-assert must replace it with the run's own schema.
|
||||
// protection restores the run's own canonical schema.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const replaced = await next()
|
||||
return {
|
||||
@@ -438,14 +656,14 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('the re-assert wins against a downstream listener that REPLACES the assembly object', async () => {
|
||||
it('protection wins against a listener that replaces the assembly object', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
|
||||
])
|
||||
// A global (every-assembly) listener that returns a brand-new assembly
|
||||
// WITHOUT the capture tool or instruction — the composition caveat that
|
||||
// erases cooperative mutations. The child's prepend re-assert runs
|
||||
// OUTERMOST and restores both.
|
||||
// erases cooperative mutations. Service finalization restores both
|
||||
// after the complete waterfall.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const replaced = await next()
|
||||
return {
|
||||
@@ -465,13 +683,13 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('the re-assert preserves the untampered assembly: tool position and section band are the registry\'s own', async () => {
|
||||
it('protection preserves the canonical tool position and section band', async () => {
|
||||
const { ctx, parent, adapter } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||
])
|
||||
// A global tool sorting lexicographically AFTER structured_output and a
|
||||
// global section ABOVE the 190 band: the re-assert must leave both
|
||||
// exactly where the registry's ordering put them (no move-to-end).
|
||||
// global section above the 190 band: protection leaves both exactly
|
||||
// where the canonical registry ordering put them.
|
||||
ctx.tools.register({
|
||||
name: 'zz_probe',
|
||||
description: 'probe',
|
||||
@@ -498,7 +716,7 @@ describe('in-process structured output', () => {
|
||||
])
|
||||
ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' })
|
||||
// Strip the instruction section entirely AND add a wrong-schema
|
||||
// duplicate tool entry ALONGSIDE the registry's own: the re-assert must
|
||||
// duplicate tool entry alongside the registry's own: protection must
|
||||
// restore the section INTO its band (before the order-200 section, not
|
||||
// appended after it) and collapse the tools to exactly one entry
|
||||
// carrying the run's schema.
|
||||
@@ -578,15 +796,14 @@ describe('in-process structured output', () => {
|
||||
expect(result.error?.code).toBe('UNKNOWN_TOOL')
|
||||
})
|
||||
|
||||
it('a stale stage from a short-circuited chain is never promoted by a later call (execution-keyed commit)', async () => {
|
||||
it('a failed execution stage is discarded and never promoted by a later call', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// An OUTER post-execute listener (registered after attach, prepend ⇒
|
||||
// outermost) that BLOCKS the first capture WITHOUT delegating: the commit
|
||||
// listener never runs for c1, so its staged value would linger.
|
||||
// A prepended post-execute listener blocks the first capture without
|
||||
// delegating. The final-result notification discards that execution's
|
||||
// stage when it observes the error.
|
||||
let blocks = 1
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
||||
@@ -596,11 +813,12 @@ describe('in-process structured output', () => {
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
const result = await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// The blocked capture must NOT surface as structured success…
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.structured).toBeUndefined()
|
||||
// …and a LATER invalid call (its own body staged nothing) must not
|
||||
// resurrect c1's orphaned value: drive the pipeline directly.
|
||||
// resurrect c1's discarded value: drive the pipeline directly.
|
||||
const invalid = await ctx.tools.execute({
|
||||
callId: 'c2' as never,
|
||||
name: STRUCTURED_OUTPUT_TOOL,
|
||||
@@ -619,14 +837,13 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a later capture call REUSING a stale stage\'s call id never promotes it (unconditional commit safety)', async () => {
|
||||
it('reusing a failed execution\'s call id never promotes its discarded stage', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// Orphan a stage: an outer short-circuiting post-execute BLOCK on the
|
||||
// first capture (its chain never reaches the commit listener).
|
||||
// Block the first capture after its body stages a value. Its final error
|
||||
// discards that execution's stage.
|
||||
let blocks = 1
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
||||
@@ -636,8 +853,9 @@ describe('in-process structured output', () => {
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
await run.result
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// A SECOND capture call with the SAME call id whose body never stages
|
||||
// (invalid args throw before the stage): the stale value must not ride
|
||||
// (invalid args throw before the stage): the discarded value must not ride
|
||||
// its acceptance.
|
||||
const reused = await ctx.tools.execute({
|
||||
callId: 'c1' as never,
|
||||
@@ -657,13 +875,12 @@ describe('in-process structured output', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('an outer pre-execute deny with call-id reuse cannot promote an orphaned stage either', async () => {
|
||||
it('a pre-execute deny with call-id reuse cannot promote another execution\'s stage', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||
])
|
||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// Orphan a stage via an outer post-execute BLOCK on the first capture.
|
||||
// Discard the first capture's stage via a final post-execute block.
|
||||
let blocks = 1
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
||||
@@ -673,9 +890,9 @@ describe('in-process structured output', () => {
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
await run.result
|
||||
// An OUTERMOST prepend pre-execute deny: the structured runtime's own
|
||||
// pre-execute never runs for this call, and the denied call still goes
|
||||
// through post-execute — with the SAME call id as the orphaned stage.
|
||||
const child = ctx.agents.get(run.id)!
|
||||
// A prepended pre-execute deny skips the body, while the denied call still
|
||||
// reaches the final notification with the same adapter-minted call id.
|
||||
const offDeny = ctx.on('tools/pre-execute', (exec) => {
|
||||
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
||||
return Promise.resolve({ kind: 'deny' as const, reason: 'outer veto' })
|
||||
@@ -690,7 +907,7 @@ describe('in-process structured output', () => {
|
||||
})
|
||||
expect(denied.isError).toBe(true)
|
||||
offDeny()
|
||||
// The orphan was never promoted: a fresh valid call is still required
|
||||
// The discarded value was never promoted: a fresh valid call is required
|
||||
// (and succeeds, proving the runtime is not wedged).
|
||||
const valid = await ctx.tools.execute({
|
||||
callId: 'c1' as never,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -49,9 +49,166 @@ describe('depthOf', () => {
|
||||
})
|
||||
|
||||
describe('startInProcessRun', () => {
|
||||
it('rejects a non-JSON prompt before acquiring any run ownership', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
expect(() => startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: Number.NaN as unknown as string }],
|
||||
parent,
|
||||
}, {})).toThrow('subagent prompt must be losslessly JSON-serializable')
|
||||
})
|
||||
|
||||
it('rejects a prompt whose getter becomes non-JSON while it is snapshotted', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
let reads = 0
|
||||
const prompt = [{
|
||||
type: 'text' as const,
|
||||
get text(): string {
|
||||
reads += 1
|
||||
return reads === 1 ? 'valid during pre-check' : Number.NaN as unknown as string
|
||||
},
|
||||
}]
|
||||
|
||||
expect(() => startInProcessRun(ctx, { prompt, parent }, {}))
|
||||
.toThrow('subagent prompt must be stable losslessly JSON-serializable data')
|
||||
expect(reads).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects when the run-owner fiber settles without installing its context', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
function inertOwner(): void {}
|
||||
const inertFiber = ctx.plugin(inertOwner)
|
||||
await inertFiber
|
||||
const parentWithoutOwnerContext = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: { plugin: () => inertFiber },
|
||||
} as unknown as Agent
|
||||
|
||||
const run = startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent: parentWithoutOwnerContext,
|
||||
}, {})
|
||||
await expect(run.result).rejects.toThrow('subagent run owner became inactive before child creation')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('normalizes a non-Error thrown while installing the run-owner fiber', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const setupFailure = 'non-Error owner setup failure'
|
||||
const parentWithFailingOwnerSetup = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: { plugin: () => { throw setupFailure } },
|
||||
} as unknown as Agent
|
||||
|
||||
const run = startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent: parentWithFailingOwnerSetup,
|
||||
}, {})
|
||||
await expect(run.result).rejects.toMatchObject({
|
||||
message: 'subagent run owner setup failed with a non-Error value',
|
||||
cause: setupFailure,
|
||||
})
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('normalizes a non-Error rejected by asynchronous child creation', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const creationFailure = 'non-Error child creation failure'
|
||||
function inertOwner(): void {}
|
||||
const ownerFiber = ctx.plugin(inertOwner)
|
||||
await ownerFiber
|
||||
const rejectWithNonError = (): Promise<never> => {
|
||||
// Deliberately violate the promise contract to exercise boundary normalization.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
return Promise.reject(creationFailure)
|
||||
}
|
||||
const rejectingOwnerCtx = {
|
||||
agents: { create: rejectWithNonError },
|
||||
} as unknown as Context
|
||||
const parentWithRejectingFactory = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: {
|
||||
plugin(plugin: (inner: Context) => void) {
|
||||
plugin(rejectingOwnerCtx)
|
||||
return ownerFiber
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
|
||||
const run = startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent: parentWithRejectingFactory,
|
||||
}, {})
|
||||
await expect(run.result).rejects.toMatchObject({
|
||||
message: 'subagent child creation failed with a non-Error value',
|
||||
cause: creationFailure,
|
||||
})
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('follows owner-fiber inertia when raw teardown was already in flight', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
let inertia: Promise<undefined> | undefined = gate.promise
|
||||
const fakeFiber = {
|
||||
dispose: vi.fn(() => undefined),
|
||||
get inertia() { return inertia },
|
||||
} as unknown as Fiber & PromiseLike<Fiber>
|
||||
const rejectingOwnerCtx = {
|
||||
agents: { create: () => Promise.reject(new Error('creation stopped by teardown')) },
|
||||
} as unknown as Context
|
||||
const parentWithDisposingOwner = {
|
||||
options: parent.options,
|
||||
session: parent.session,
|
||||
ctx: {
|
||||
plugin(plugin: (inner: Context) => void) {
|
||||
plugin(rejectingOwnerCtx)
|
||||
return fakeFiber
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
const run = startInProcessRun(ctx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent: parentWithDisposingOwner,
|
||||
}, {})
|
||||
|
||||
let settled = false
|
||||
const disposing = run.dispose().then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(fakeFiber.dispose).toHaveBeenCalledOnce()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
inertia = undefined
|
||||
gate.resolve(undefined)
|
||||
await disposing
|
||||
await expect(run.result).resolves.toEqual({ output: [], stopReason: 'aborted' })
|
||||
})
|
||||
|
||||
it('does not attach an abort listener when provider ownership is already inactive', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
let providerCtx: Context | undefined
|
||||
function provider(inner: Context): void { providerCtx = inner }
|
||||
const providerFiber = await ctx.plugin(provider)
|
||||
await providerFiber.dispose()
|
||||
if (providerCtx === undefined) throw new Error('provider context was not captured')
|
||||
const inactiveProviderCtx = providerCtx
|
||||
|
||||
const controller = new AbortController()
|
||||
const addListener = vi.spyOn(controller.signal, 'addEventListener')
|
||||
expect(() => startInProcessRun(inactiveProviderCtx, {
|
||||
prompt: [{ type: 'text', text: 'must never start' }],
|
||||
parent,
|
||||
signal: controller.signal,
|
||||
}, {})).toThrow(/inactive context/)
|
||||
expect(addListener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drives a fresh child (no seed) to completion and returns its output', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('driver child answer')])
|
||||
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn' })
|
||||
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, {})
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('driver child answer')
|
||||
@@ -59,9 +216,25 @@ describe('startInProcessRun', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('snapshots the prompt before asynchronous child creation', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const prompt = [{ type: 'text' as const, text: 'original prompt' }]
|
||||
const run = startInProcessRun(ctx, { prompt, parent }, {})
|
||||
|
||||
prompt[0]!.text = 'mutated after start'
|
||||
prompt.push({ type: 'text', text: 'also injected' })
|
||||
await run.result
|
||||
|
||||
const child = ctx.agents.get(run.id)!
|
||||
const userMessage = child.session.events.find(event => event.type === 'user/message')
|
||||
expect(userMessage?.type === 'user/message' && userMessage.data.content)
|
||||
.toEqual([{ type: 'text', text: 'original prompt' }])
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('throws SubagentDepthError when the child would exceed maxDepth', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn' }))
|
||||
expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, {}))
|
||||
.toThrow(SubagentDepthError)
|
||||
})
|
||||
|
||||
@@ -73,7 +246,7 @@ describe('startInProcessRun', () => {
|
||||
parent.send([{ type: 'text', text: 'parent q' }])
|
||||
await parent.whenIdle()
|
||||
const seed = parent.session.events.slice()
|
||||
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', seed })
|
||||
const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { seed })
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(text(result.output)).toBe('seeded child reply')
|
||||
|
||||
@@ -29,7 +29,7 @@ export const name = 'subagent-spawn'
|
||||
// output through the child's creation context, whose factory already requires
|
||||
// the tool service. Keeping it out of this backend's inject list preserves the
|
||||
// provider's independent apply timing.
|
||||
export const inject = ['subagents', 'agents']
|
||||
export const inject = ['subagents']
|
||||
|
||||
/** Config: the registry name to register the provider under. */
|
||||
export interface Config {
|
||||
@@ -59,7 +59,7 @@ class SpawnProvider implements SubagentProvider {
|
||||
// Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
|
||||
// depth, drives the one-shot (including the structured capture when the
|
||||
// request carries an outputSchema), and maps the result.
|
||||
return startInProcessRun(this.ctx, request, { providerName: this.name })
|
||||
return startInProcessRun(this.ctx, request, {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
@@ -160,6 +160,38 @@ describe('dsh-subagent-spawn', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a cancel from agent/queued maps a no-turn child log to aborted', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
ctx.on('agent/queued', (agent) => {
|
||||
if (agent.id === run.id) run.cancel('queued-window')
|
||||
})
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
const result = await run.result
|
||||
expect(result).toMatchObject({ stopReason: 'aborted', output: [] })
|
||||
const child = ctx.agents.get(run.id)!
|
||||
expect(child.session.events.some(event => event.type === 'turn/end')).toBe(false)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('dispose during async child creation waits for rollback and leaves no orphan', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const beforeAgents = ctx.agents.list().length
|
||||
const beforeSessions = ctx.sessions.list().length
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent })
|
||||
|
||||
// Same tick: the factory has reserved ids and entered its async setup
|
||||
// transaction, but has not published the child yet.
|
||||
await run.dispose()
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted', output: [] })
|
||||
expect(ctx.agents.list()).toHaveLength(beforeAgents)
|
||||
expect(ctx.sessions.list()).toHaveLength(beforeSessions)
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
|
||||
it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => {
|
||||
// 'hang' makes the child's model stream one chunk then wait until aborted.
|
||||
const controller = new AbortController()
|
||||
@@ -206,7 +238,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
it('inherits the parent cwd into the child session', async () => {
|
||||
const { ctx } = await setup([textResponse('x')])
|
||||
// A parent WITH a cwd (config agents have none, so create one explicitly).
|
||||
const parentHandle = ctx.agents.create({
|
||||
const parentHandle = await ctx.agents.create({
|
||||
agentId: AgentId('cwd-parent'),
|
||||
sessionId: SessionId('cwd-parent-session'),
|
||||
meta: { cwd: '/tmp/parent-workspace' },
|
||||
@@ -223,7 +255,7 @@ describe('dsh-subagent-spawn', () => {
|
||||
it('uses request.agentOptions.model when the parent has no model of its own', async () => {
|
||||
const { ctx } = await setup([textResponse('explicit model child')])
|
||||
// A parent with NO model (its own turns would need one supplied per-request).
|
||||
const parentHandle = ctx.agents.create({
|
||||
const parentHandle = await ctx.agents.create({
|
||||
agentId: AgentId('modelless-parent'),
|
||||
sessionId: SessionId('modelless-parent-session'),
|
||||
agentOptions: {},
|
||||
@@ -305,15 +337,70 @@ describe('dsh-subagent-spawn', () => {
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('a backend unload during child creation prevents every publication notification', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'must never run' }], parent,
|
||||
})
|
||||
await fiber.dispose()
|
||||
await run.result.catch(() => undefined)
|
||||
await run.dispose()
|
||||
|
||||
expect(ctx.agents.get(run.id)).toBeUndefined()
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
|
||||
it('a start racing an already-unloading backend cannot mint a run-owner fiber', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||
const parentEffects = parent.ctx.fiber.getEffects().length
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
const unloading = fiber.dispose()
|
||||
expect(() => ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'must never start' }], parent,
|
||||
})).toThrow(/inactive context/)
|
||||
await unloading
|
||||
|
||||
expect(parent.ctx.fiber.getEffects()).toHaveLength(parentEffects)
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
expect('default' in spawn).toBe(false)
|
||||
expect(spawn.name).toBe('subagent-spawn')
|
||||
expect(spawn.inject).toEqual(['subagents', 'agents'])
|
||||
expect(spawn.inject).toEqual(['subagents'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(spawn) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(spawn)
|
||||
expect(unwrapped.name).toBe('subagent-spawn')
|
||||
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
|
||||
expect(unwrapped.inject).toEqual(['subagents'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
|
||||
@@ -369,11 +456,13 @@ describe('dsh-subagent-spawn', () => {
|
||||
it('an unknown toolFilter name fails the spawn loudly with no orphaned child', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const before = ctx.agents.list().length
|
||||
expect(() => ctx.subagents.start('spawn', {
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent,
|
||||
toolFilter: { deny: ['no_such_tool'] },
|
||||
})).toThrow(/unknown tool "no_such_tool"/)
|
||||
})
|
||||
await expect(run.result).rejects.toThrow(/unknown tool "no_such_tool"/)
|
||||
await run.dispose()
|
||||
expect(ctx.agents.list().length).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -381,19 +470,53 @@ describe('dsh-subagent-spawn', () => {
|
||||
it('spawning from a DISPOSING parent fails loud with no orphaned child (INACTIVE_EFFECT teaching error)', async () => {
|
||||
const { ctx } = await setup([])
|
||||
// A handle-owned parent we can dispose (config agents dispose with the loop fiber).
|
||||
const parentHandle = ctx.agents.create({
|
||||
const parentHandle = await ctx.agents.create({
|
||||
agentId: AgentId('doomed-parent'),
|
||||
sessionId: SessionId('doomed-s'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
await parentHandle.dispose()
|
||||
const before = ctx.agents.list().length
|
||||
expect(() => ctx.subagents.start('spawn', {
|
||||
const sessionsBefore = ctx.sessions.list().length
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'do X' }],
|
||||
parent: parentHandle.agent,
|
||||
})).toThrow(/inactive context/)
|
||||
// The freshly created child's disposal was initiated before the rethrow
|
||||
// (fire-and-forget — start() throws synchronously); quiescence follows.
|
||||
await vi.waitFor(() => { expect(ctx.agents.list().length).toBe(before) })
|
||||
})
|
||||
await expect(run.result).rejects.toThrow(/inactive context/)
|
||||
await run.dispose()
|
||||
expect(ctx.agents.list().length).toBe(before)
|
||||
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
|
||||
it('parent disposal during the child setup transaction prevents every publication notification', async () => {
|
||||
const { ctx } = await setup([])
|
||||
const parentHandle = await ctx.agents.create({
|
||||
agentId: AgentId('setup-race-parent'),
|
||||
sessionId: SessionId('setup-race-parent-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
|
||||
const run = ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'must never run' }],
|
||||
parent: parentHandle.agent,
|
||||
})
|
||||
// The factory has entered its awaited unpublished setup transaction. Parent
|
||||
// ownership was installed before that await, so disposal wins without an
|
||||
// observer ever seeing the child.
|
||||
await parentHandle.dispose()
|
||||
await expect(run.result).rejects.toThrow(/owner disposed during setup|inactive context/)
|
||||
await run.dispose()
|
||||
|
||||
expect(ctx.agents.get(run.id)).toBeUndefined()
|
||||
expect(published).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||
@@ -93,7 +95,7 @@ declare module 'cordis' {
|
||||
* @param info - which provider started which child agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'subagent/start'(info: SubagentRunInfo): void
|
||||
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
|
||||
/**
|
||||
* A subagent run settled — emitted when {@link SubagentRun.result}
|
||||
* resolves (any stop reason). Paired with {@link Events['subagent/start']}.
|
||||
@@ -104,7 +106,7 @@ declare module 'cordis' {
|
||||
* @param info - the run identity plus stop reason and final output.
|
||||
* @mode emit
|
||||
*/
|
||||
'subagent/end'(info: SubagentRunEndInfo): void
|
||||
'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,13 +226,32 @@ export class SubagentService extends Service {
|
||||
* @returns the live run (its `result` resolves when the child settles).
|
||||
*/
|
||||
start(name: string, request: SubagentStartRequest): SubagentRun {
|
||||
// Parent is the lifecycle scope identity accepted at start. Never reread it
|
||||
// from the caller-owned request after the provider/result async boundary,
|
||||
// or start/end could be dispatched into different agent scopes.
|
||||
const parent = request.parent
|
||||
const provider = this.providers.get(name)
|
||||
if (!provider) {
|
||||
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
|
||||
}
|
||||
this.assertCapabilities(provider, request)
|
||||
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
|
||||
|
||||
const run = provider.start(request)
|
||||
// Detach every data field before crossing into a provider. Parent/signal
|
||||
// are live identity capabilities and stay exact; the mutable request record
|
||||
// and its arrays/objects are never retained, so every backend (including an
|
||||
// async out-of-process one) observes the request accepted at start.
|
||||
const accepted: SubagentStartRequest = {
|
||||
prompt: structuredClone(request.prompt),
|
||||
parent,
|
||||
...request.signal !== undefined ? { signal: request.signal } : {},
|
||||
...request.agentOptions !== undefined ? { agentOptions: structuredClone(request.agentOptions) } : {},
|
||||
...request.outputSchema !== undefined ? { outputSchema: structuredClone(request.outputSchema) } : {},
|
||||
...request.maxDepth !== undefined ? { maxDepth: request.maxDepth } : {},
|
||||
...request.toolFilter !== undefined ? { toolFilter: structuredClone(request.toolFilter) } : {},
|
||||
...request.persona !== undefined ? { persona: request.persona } : {},
|
||||
}
|
||||
const run = provider.start(accepted)
|
||||
// Emit `subagent/start` with PER-LISTENER containment (see {@link emitLifecycle}):
|
||||
// the run is already live, so neither a throwing subscriber escaping
|
||||
// `start()` (the caller would never receive the run to dispose it — a leaked
|
||||
@@ -238,7 +259,7 @@ export class SubagentService extends Service {
|
||||
// acceptable. `ctx.emit` halts the dispatch on the first throw, so a single
|
||||
// surrounding try/catch is not enough — each listener is invoked and
|
||||
// contained individually.
|
||||
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, request.parent)
|
||||
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent)
|
||||
// Emit `subagent/end` when the run settles. The result promise does not
|
||||
// reject on a child-level failure (it resolves with stopReason 'error'),
|
||||
// so a rejection here is an infrastructure fault — surface its stop reason
|
||||
@@ -268,9 +289,9 @@ export class SubagentService extends Service {
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`)
|
||||
}
|
||||
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} }, request.parent)
|
||||
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason, ...lastAssistantMessage !== undefined ? { lastAssistantMessage } : {} }, parent)
|
||||
},
|
||||
() => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, request.parent) },
|
||||
() => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent) },
|
||||
)
|
||||
return run
|
||||
}
|
||||
|
||||
@@ -120,9 +120,11 @@ export interface SubagentResult {
|
||||
/** The child's final assistant output (the last assistant message's content). */
|
||||
output: ContentBlock[]
|
||||
/**
|
||||
* The structured result, present IFF the request carried an `outputSchema`
|
||||
* AND the provider honored it. Shape is validated against the request schema
|
||||
* by the provider; `unknown` here because the seam is schema-agnostic.
|
||||
* The structured result after a requested `outputSchema` was successfully
|
||||
* satisfied. Requesting a schema does not guarantee presence: a provider can
|
||||
* end with `stopReason: 'error'` when the child fails or finishes without a
|
||||
* valid capture. Shape is validated against the request schema by the
|
||||
* provider; `unknown` here because the seam is schema-agnostic.
|
||||
*/
|
||||
structured?: unknown
|
||||
/** Why the run ended. A non-`completed` reason means `output` may be partial. */
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
|
||||
import SubagentService, {
|
||||
SubagentError,
|
||||
type SubagentCapabilities,
|
||||
@@ -227,6 +228,45 @@ describe('SubagentService', () => {
|
||||
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' }))
|
||||
})
|
||||
|
||||
it('pins start and end to the parent accepted at start despite caller mutation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
const gate = Promise.withResolvers<SubagentResult>()
|
||||
let acceptedRequest: SubagentStartRequest | undefined
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'deferred',
|
||||
capabilities: NO_CAPS,
|
||||
inheritsParentContext: false,
|
||||
start: (accepted) => {
|
||||
acceptedRequest = accepted
|
||||
return {
|
||||
id: AgentId('deferred-child'),
|
||||
result: gate.promise,
|
||||
cancel() {},
|
||||
async dispose() {},
|
||||
}
|
||||
},
|
||||
})
|
||||
const accepted = fakeParent('accepted-parent')
|
||||
const replacement = fakeParent('replacement-parent')
|
||||
const keys: unknown[] = []
|
||||
ctx.on('subagent/start', function () { keys.push(carrierKeyOf(this)) })
|
||||
ctx.on('subagent/end', function () { keys.push(carrierKeyOf(this)) })
|
||||
const request = baseRequest({ parent: accepted })
|
||||
|
||||
const run = ctx.subagents.start('deferred', request)
|
||||
request.parent = replacement
|
||||
request.prompt[0] = { type: 'text', text: 'mutated prompt' }
|
||||
expect(acceptedRequest?.parent).toBe(accepted)
|
||||
expect(acceptedRequest?.prompt).toEqual([{ type: 'text', text: 'do a thing' }])
|
||||
expect(acceptedRequest?.prompt).not.toBe(request.prompt)
|
||||
gate.resolve({ output: [], stopReason: 'completed' })
|
||||
await run.result
|
||||
await Promise.resolve()
|
||||
|
||||
expect(keys).toEqual([accepted, accepted])
|
||||
})
|
||||
|
||||
it('carries lastAssistantMessage (the child output) onto the end event', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentService)
|
||||
|
||||
@@ -390,10 +390,12 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
'agent/session-prefix': args => args[0],
|
||||
'agent/step-result': args => args[0],
|
||||
'agent/turn-continuation': args => args[0],
|
||||
'agent/turn-stop': args => args[0],
|
||||
'agent/error': args => args[0],
|
||||
'tools/pre-execute': args => (args[0] as ToolExecution).agent,
|
||||
'tools/execute': args => (args[0] as ToolExecution).agent,
|
||||
'tools/post-execute': args => (args[0] as ToolExecution).agent,
|
||||
'tools/result': args => (args[0] as ToolExecution).agent,
|
||||
'system-prompt/assemble': args => (args[1] as AssembleContext).scope,
|
||||
'session/created': null,
|
||||
'session/event': null,
|
||||
@@ -429,11 +431,11 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
|
||||
// --- Setup-drives invariant ---------------------------------------------
|
||||
//
|
||||
// CreateAgentOptions.setup REGISTERS the agent's scoped world; it must not
|
||||
// DRIVE the agent — an inject() there opens a turn before
|
||||
// `agent/session-start`, inverting the "session-start fires before the
|
||||
// first turn" contract every bridge keys on. A turn/start appended to a
|
||||
// live agent's session before its agent/session-start fired is therefore a
|
||||
// CreateAgentOptions.setup COMPOSES the agent's scoped world; it must not
|
||||
// DRIVE the agent. ReactLoopAgent rejects every driving verb structurally
|
||||
// until rollback-covered publication reaches the session-start boundary; this event-level invariant remains the
|
||||
// cross-implementation backstop for alternate Agent implementations and raw
|
||||
// session writes. A turn/start appended before agent/session-start is a
|
||||
// creation-time misuse, reported at the appending call site. Sessions of
|
||||
// agents that exist BEFORE this plugin applies are marked started (their
|
||||
// ordering is unknowable after the fact — never a false positive on HMR).
|
||||
@@ -450,7 +452,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
if (owner === undefined) return
|
||||
throw new InvariantError(
|
||||
`agent "${owner.id}": a turn opened before agent/session-start fired — `
|
||||
+ 'CreateAgentOptions.setup registers the scoped world, it must not drive the agent '
|
||||
+ 'CreateAgentOptions.setup composes the scoped world, it must not drive the agent '
|
||||
+ '(send/steer/inject belong after creation returns)')
|
||||
})
|
||||
|
||||
|
||||
@@ -828,11 +828,15 @@ describe('scoped-dispatch invariants', () => {
|
||||
['agent/pre-step', [agent, 1, 1, '', new AbortController().signal]],
|
||||
['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]],
|
||||
['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]],
|
||||
['agent/session-prefix', [agent, [], new AbortController().signal, () => Promise.resolve([])]],
|
||||
['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]],
|
||||
['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]],
|
||||
['agent/turn-stop', [agent, 1]],
|
||||
['agent/error', [agent, 1, 0, new Error('x')]],
|
||||
['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
|
||||
['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ callId: 'c', content: [], isError: false })]],
|
||||
['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
|
||||
['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }]],
|
||||
]
|
||||
for (const [event, args] of rows) {
|
||||
const subject = event.startsWith('tools/') ? agent : agent
|
||||
@@ -870,7 +874,7 @@ describe('scoped-dispatch invariants', () => {
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a turn opened before agent/session-start (setup drives the agent)', async () => {
|
||||
it('backstops alternate agents that open a turn before agent/session-start', async () => {
|
||||
const ctx = await scopedCtx()
|
||||
// A live agent whose session is in the store but whose session-start has
|
||||
// not fired: appending turn/start must throw the teaching error.
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool, type ToolExecution, type ToolExecutionResult, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineTool, type ToolExecutionInput, type ToolExecutionResult, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
|
||||
import { TOOL_TIMEOUT, toolTimeoutResult } from '@deepseek-ai/dsh-timeout-policy'
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('dsh-timeout-policy real-load-path guard', () => {
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters<Context['plugin']>[0]
|
||||
const fiber = await ctx.plugin(unwrapped)
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecution)
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput)
|
||||
expect(result.isError).toBe(false)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -591,17 +591,27 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
return Promise.resolve()
|
||||
},
|
||||
|
||||
newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
assertOpen()
|
||||
validateWorkspaceParams(params)
|
||||
validateMcpServers(params)
|
||||
const sessionId = SessionId(randomUUID())
|
||||
const handle = agents.create({
|
||||
const handle = await agents.create({
|
||||
agentId: AgentId(sessionId),
|
||||
sessionId,
|
||||
meta: { cwd: params.cwd },
|
||||
agentOptions: agentOptions(config),
|
||||
})
|
||||
// Creation is now asynchronous because it awaits the unpublished setup
|
||||
// transaction. A client disconnect can therefore close this bridge
|
||||
// after the entry check but before the handle resolves; never install a
|
||||
// post-close record that quiesce() could not have seen.
|
||||
/* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC
|
||||
immediately on close; real stdio may let the handler resume */
|
||||
if (closed) {
|
||||
await handle.dispose()
|
||||
throw internalError('connection closed during session/new')
|
||||
}
|
||||
bySession.set(handle.agent, sessionId)
|
||||
sessions.set(sessionId, {
|
||||
sessionId,
|
||||
@@ -611,7 +621,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
terminalEnabled: terminalOutputCap,
|
||||
inflight: undefined,
|
||||
})
|
||||
return Promise.resolve({ sessionId })
|
||||
return { sessionId }
|
||||
},
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('acp bridge — demux & config edges', () => {
|
||||
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const before = harness.updates.length
|
||||
|
||||
const { agent: foreign } = harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } })
|
||||
const { agent: foreign } = await harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } })
|
||||
foreign.send([{ type: 'text', text: 'hi' }])
|
||||
await foreign.whenIdle()
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
@@ -61,7 +61,7 @@ return { prose, containsFour: judged === null ? null : judged.containsFour }`
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key e2e', () => {
|
||||
it('runs a two-phase script in a worker thread over real children, one through the structured runtime', async () => {
|
||||
ctx = await harness()
|
||||
const parentHandle = ctx.agents.create({
|
||||
const parentHandle = await ctx.agents.create({
|
||||
agentId: AgentId('wf-worker-e2e-parent'),
|
||||
sessionId: 'wf-worker-e2e-session' as never,
|
||||
agentOptions: { model: 'deepseek-v4-flash' },
|
||||
|
||||
Reference in New Issue
Block a user