fix(core): enforce agent-scoped ownership boundaries
This commit is contained in:
@@ -39,11 +39,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
|
|||||||
// dispose the whole context (simulating process exit) so only the JSONL
|
// dispose the whole context (simulating process exit) so only the JSONL
|
||||||
// log on disk survives.
|
// log on disk survives.
|
||||||
ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root })
|
ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root })
|
||||||
const first = ctx.agents.create({
|
const first = (await ctx.agents.create({
|
||||||
agentId: AgentId('resume-1'),
|
agentId: AgentId('resume-1'),
|
||||||
sessionId: SESSION_ID,
|
sessionId: SESSION_ID,
|
||||||
agentOptions: { model: 'deepseek-v4-flash' },
|
agentOptions: { model: 'deepseek-v4-flash' },
|
||||||
}).agent as ReactLoopAgent
|
})).agent as ReactLoopAgent
|
||||||
first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }])
|
first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }])
|
||||||
await waitForIdle(ctx, first)
|
await waitForIdle(ctx, first)
|
||||||
await ctx.fiber.dispose()
|
await ctx.fiber.dispose()
|
||||||
|
|||||||
@@ -119,37 +119,30 @@ describe('bash tool through the agent loop', () => {
|
|||||||
it('background: start → poll → completion notice lands as context/message', async () => {
|
it('background: start → poll → completion notice lands as context/message', async () => {
|
||||||
const adapter = new MockAdapter([
|
const adapter = new MockAdapter([
|
||||||
toolCallResponse('call-1', 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true }),
|
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.'),
|
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 = ''
|
let taskId = ''
|
||||||
|
|
||||||
const ctx = await harness(adapter)
|
const ctx = await harness(adapter)
|
||||||
const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' })
|
const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' })
|
||||||
|
|
||||||
// Intercept the first tool result to capture the generated task id, then
|
// Capture the generated id so the deterministic fixture is checked against
|
||||||
// rewrite the second scripted call's arguments to use it.
|
// the real executor instead of silently assuming it.
|
||||||
ctx.on('session/event', (_session, event) => {
|
ctx.on('session/event', (_session, event) => {
|
||||||
if (event.type === 'tool/result' && taskId === '') {
|
if (event.type === 'tool/result' && taskId === '') {
|
||||||
const match = /task (bash-\d+)/.exec(resultText(event))
|
const match = /task (bash-\d+)/.exec(resultText(event))
|
||||||
if (match) taskId = match[1]!
|
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' }])
|
agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
|
|
||||||
|
expect(taskId).toBe('bash-1')
|
||||||
|
|
||||||
// Wait for the background task itself (completion may race turn end).
|
// Wait for the background task itself (completion may race turn end).
|
||||||
const task = ctx.bash.get(BashTaskId(taskId))
|
const task = ctx.bash.get(BashTaskId(taskId))
|
||||||
if (!task) throw new Error(`task ${taskId} not registered`)
|
if (!task) throw new Error(`task ${taskId} not registered`)
|
||||||
|
|||||||
@@ -242,7 +242,6 @@ describe('bash tool', () => {
|
|||||||
[{ command: ' ', description: 'd' }, /invalid command/],
|
[{ command: ' ', description: 'd' }, /invalid command/],
|
||||||
[{ command: 'x', description: ' ' }, /invalid description/],
|
[{ command: 'x', description: ' ' }, /invalid description/],
|
||||||
[{ command: 'x', description: 'd', timeoutMs: -1 }, /invalid timeoutMs/],
|
[{ 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) => {
|
])('rejects value-invalid args %j', async (args, pattern) => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
const result = await call(ctx, 'bash', args)
|
const result = await call(ctx, 'bash', args)
|
||||||
@@ -250,6 +249,15 @@ describe('bash tool', () => {
|
|||||||
expect(text(result)).toMatch(pattern)
|
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 () => {
|
it('registers all three schemas in the system prompt assembly', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
const names = ctx.tools.schemas().map(schema => schema.name)
|
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`.',
|
summary: 'The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loops, and registers them in `ctx.agents`.',
|
||||||
methods: [
|
methods: [
|
||||||
'create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent',
|
'create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent',
|
||||||
'createAgent(options: CreateAgentOptions): AgentHandle',
|
'async createAgent(options: CreateAgentOptions): Promise<AgentHandle>',
|
||||||
'async resume(options: ResumeAgentOptions): 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.',
|
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: [
|
methods: [
|
||||||
'setFactory(factory: AgentFactory): () => Promise<void> | void',
|
'setFactory(factory: AgentFactory): () => Promise<void> | void',
|
||||||
'create(options: CreateAgentOptions): AgentHandle',
|
'async create(options: CreateAgentOptions): Promise<AgentHandle>',
|
||||||
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
|
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',
|
||||||
'register(agent: Agent): () => Promise<void> | void',
|
'register(agent: Agent): () => Promise<void> | void',
|
||||||
|
'enter(agent: Agent): () => void',
|
||||||
|
'announce(agent: Agent): void',
|
||||||
'get(id: AgentId): Agent | undefined',
|
'get(id: AgentId): Agent | undefined',
|
||||||
'list(): Agent[]',
|
'list(): Agent[]',
|
||||||
],
|
],
|
||||||
@@ -161,25 +163,27 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'systemPrompt',
|
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: [
|
methods: [
|
||||||
'section(section: PromptSection): () => Promise<void> | void',
|
'section(section: PromptSection): () => Promise<void> | void',
|
||||||
'tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void',
|
'tools(provider: (context: AssembleContext) => ToolProviderResult): () => Promise<void> | void',
|
||||||
'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => 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>',
|
'async assemble(context: AssembleContext = {}): Promise<PromptAssembly>',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'tools',
|
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: [
|
methods: [
|
||||||
'register(definition: ToolDefinition): () => Promise<void> | void',
|
'register(definition: ToolDefinition): () => Promise<void> | void',
|
||||||
'restrict(filter: ToolRestriction): () => Promise<void> | void',
|
'restrict(filter: ToolRestriction): () => Promise<void> | void',
|
||||||
|
'guard(guard: ToolGuard): () => Promise<void> | void',
|
||||||
'visible(scope?: ScopeKey): ToolDefinition[]',
|
'visible(scope?: ScopeKey): ToolDefinition[]',
|
||||||
'get(name: string, scope?: ScopeKey): ToolDefinition | undefined',
|
'get(name: string, scope?: ScopeKey): ToolDefinition | undefined',
|
||||||
'schemas(scope?: ScopeKey): ToolSchema[]',
|
'schemas(scope?: ScopeKey): ToolSchema[]',
|
||||||
'knownNames(scope?: ScopeKey): string[]',
|
'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',
|
name: 'agent/created',
|
||||||
mode: 'emit',
|
mode: 'emit',
|
||||||
signature: '\'agent/created\'(this: Scoped<Agent>, agent: Agent): void',
|
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',
|
name: 'agent/disposed',
|
||||||
mode: 'emit',
|
mode: 'emit',
|
||||||
signature: '\'agent/disposed\'(this: Scoped<Agent>, agent: Agent): void',
|
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',
|
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>',
|
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.',
|
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',
|
name: 'fs/edit-intent',
|
||||||
mode: 'waterfall',
|
mode: 'waterfall',
|
||||||
@@ -328,7 +338,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
|||||||
{
|
{
|
||||||
name: 'subagent/end',
|
name: 'subagent/end',
|
||||||
mode: 'emit',
|
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).',
|
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',
|
name: 'subagent/start',
|
||||||
mode: 'emit',
|
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.',
|
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',
|
name: 'system-prompt/change',
|
||||||
mode: 'emit',
|
mode: 'emit',
|
||||||
signature: '\'system-prompt/change\'(): void',
|
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',
|
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>',
|
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`).',
|
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',
|
name: 'workflow/agent-end',
|
||||||
mode: 'emit',
|
mode: 'emit',
|
||||||
@@ -431,7 +447,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'AgentFactory',
|
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',
|
name: 'AgentHandle',
|
||||||
@@ -563,7 +579,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'CreateAgentOptions',
|
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',
|
name: 'CreateSessionOptions',
|
||||||
@@ -665,6 +681,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
name: 'PromptAssembly',
|
name: 'PromptAssembly',
|
||||||
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
|
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',
|
name: 'PromptSection',
|
||||||
declaration: 'export interface PromptSection {\n name: string;\n order: number;\n text: string | ((context: AssembleContext) => string);\n}',
|
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',
|
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',
|
name: 'ScopeKey',
|
||||||
@@ -807,12 +827,24 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'ToolExecution',
|
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',
|
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}',
|
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',
|
name: 'ToolProviderResult',
|
||||||
declaration: 'export interface ToolProviderResult {\n schemas: ToolSchema[];\n knownNames?: readonly string[];\n}',
|
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
|
* metadata (`schemas`, and `get` returning a schema view, never the live
|
||||||
* `ToolDefinition`). Exposing the raw definition would hand mount code the
|
* `ToolDefinition`). Exposing the raw definition would hand mount code the
|
||||||
* tool's `execute` function, letting it call another tool directly and bypass
|
* tool's `execute` function, letting it call another tool directly and bypass
|
||||||
* `ToolRegistry.execute` — the pre/post-execute waterfall (permission gates,
|
* `ToolRegistry.execute` — identity protection, pre-policy, monotonic guards,
|
||||||
* accounting) and result normalization. So `get` returns the same
|
* around dispatch, post-policy, final observation, and result normalization. So `get` returns the same
|
||||||
* name/description/parameters view as `schemas()`, and nothing invocable.
|
* name/description/parameters view as `schemas()`, and nothing invocable.
|
||||||
*/
|
*/
|
||||||
function sandboxTools(ctx: Context): Record<string, unknown> {
|
function sandboxTools(ctx: Context): Record<string, unknown> {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
"types": "./lib/types/index.d.ts",
|
"types": "./lib/types/index.d.ts",
|
||||||
"default": "./lib/index.js"
|
"default": "./lib/index.js"
|
||||||
},
|
},
|
||||||
"./src/*": "./src/*",
|
|
||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -16,6 +16,51 @@ import type { Session } from '@deepseek-ai/dsh-session'
|
|||||||
import { Inbox } from './inbox.ts'
|
import { Inbox } from './inbox.ts'
|
||||||
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.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.
|
* 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.
|
* the agent/* event taxonomy — plugins never need this class.
|
||||||
*/
|
*/
|
||||||
export class ReactLoopAgent implements Agent {
|
export class ReactLoopAgent implements Agent {
|
||||||
/**
|
/** Queued + steering FIFOs; native-private so setup cannot bypass driving verbs. */
|
||||||
* The queued + steering FIFOs behind {@link send}/{@link steer}. Public so
|
readonly #inbox = new Inbox()
|
||||||
* the driver loop can drain it; {@link cancel} clears it wholesale.
|
|
||||||
*/
|
|
||||||
readonly inbox = new Inbox()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The agent's scope context ({@link Agent.ctx}), wired by the factory right
|
* 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
|
* Resolve and clear all pending {@link whenIdle} waiters. Called on a
|
||||||
* running→idle transition (from {@link setStatus}) and on disposal (from the
|
* 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 {
|
private settleIdleWaiters(): void {
|
||||||
const waiters = this.idleWaiters
|
const waiters = this.idleWaiters
|
||||||
@@ -131,22 +173,31 @@ export class ReactLoopAgent implements Agent {
|
|||||||
return options?.source ?? { kind: 'user' }
|
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 {
|
send(content: ContentBlock[], options?: SendOptions): void {
|
||||||
|
this.assertDriveEnabled('send')
|
||||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||||
const source = this.resolveSource(options)
|
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 })
|
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
steer(content: ContentBlock[], options?: SendOptions): void {
|
steer(content: ContentBlock[], options?: SendOptions): void {
|
||||||
|
this.assertDriveEnabled('steer')
|
||||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||||
if (this._status !== 'running') { this.send(content, options); return }
|
if (this._status !== 'running') { this.send(content, options); return }
|
||||||
const source = this.resolveSource(options)
|
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 })
|
this.loopCtx.emit(this.carrier, 'agent/queued', this, content, { source, steering: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
inject(content: ContentBlock[], options?: SendOptions): void {
|
inject(content: ContentBlock[], options?: SendOptions): void {
|
||||||
|
this.assertDriveEnabled('inject')
|
||||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||||
const source = this.resolveSource(options)
|
const source = this.resolveSource(options)
|
||||||
if (isTurnOpen(this.session)) {
|
if (isTurnOpen(this.session)) {
|
||||||
@@ -220,6 +271,7 @@ export class ReactLoopAgent implements Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancel(reason?: string): void {
|
cancel(reason?: string): void {
|
||||||
|
this.assertDriveEnabled('cancel')
|
||||||
// Arm-gate: only mark a cancellation when there is actually work to 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
|
// 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
|
// 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
|
// 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
|
// running) has status `idle` with `hasQueued` true, and the marker exists
|
||||||
// precisely to cover it.
|
// 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
|
this.cancelRequested = true
|
||||||
// Capture the resolved reason for the marker-only windows (pre-step /
|
// Capture the resolved reason for the marker-only windows (pre-step /
|
||||||
// continuation). The mid-step path reads it from abort.signal.reason
|
// 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
|
// 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
|
// 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.
|
// 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
|
// Interrupt an in-flight step immediately (the running turn observes the
|
||||||
// abort and ends `aborted`). The marker covers the windows where no step is
|
// abort and ends `aborted`). The marker covers the windows where no step is
|
||||||
// running (pre-step, continuation).
|
// running (pre-step, continuation).
|
||||||
@@ -263,7 +315,7 @@ export class ReactLoopAgent implements Agent {
|
|||||||
*/
|
*/
|
||||||
whenIdle(): Promise<void> {
|
whenIdle(): Promise<void> {
|
||||||
if (this._status === 'disposed') return this.done
|
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
|
// Register an internal waiter (resolved by settleIdleWaiters on the next
|
||||||
// running→idle/disposed transition), NOT an effect-scoped `ctx.on` listener:
|
// running→idle/disposed transition), NOT an effect-scoped `ctx.on` listener:
|
||||||
// a concurrent fiber disposal runs this agent's listener disposers, which
|
// 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
|
* @returns the disposer — idempotent and infallible (it runs inside the
|
||||||
* fiber's LIFO disposal chain, where a throw would skip later disposers).
|
* fiber's LIFO disposal chain, where a throw would skip later disposers).
|
||||||
*/
|
*/
|
||||||
start(): () => void {
|
[startDriver](): () => void {
|
||||||
this.done = runLoop(this.loopCtx, this, {
|
this.done = runLoop(this.loopCtx, this, {
|
||||||
|
inbox: this.#inbox,
|
||||||
setStatus: (status) => { this.setStatus(status) },
|
setStatus: (status) => { this.setStatus(status) },
|
||||||
setAbort: controller => void (this.currentAbort = controller),
|
setAbort: controller => void (this.currentAbort = controller),
|
||||||
disposed: this.disposed,
|
disposed: this.disposed,
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
* @module @deepseek-ai/dsh-agent-loop
|
* @module @deepseek-ai/dsh-agent-loop
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Context, Service } from 'cordis'
|
import { Context, FiberState, Service } from 'cordis'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import z from 'schemastery'
|
import z from 'schemastery'
|
||||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||||
|
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||||
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } 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'
|
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-system-prompt'
|
||||||
import type {} from '@deepseek-ai/dsh-tools'
|
import type {} from '@deepseek-ai/dsh-tools'
|
||||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
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 { ReactLoopAgent } from './agent.ts'
|
||||||
export { Inbox, type InboxMessage } from './inbox.ts'
|
|
||||||
export { runLoop } from './loop.ts'
|
|
||||||
|
|
||||||
declare module 'cordis' {
|
declare module 'cordis' {
|
||||||
interface Context {
|
interface Context {
|
||||||
@@ -70,6 +69,10 @@ export interface Config {
|
|||||||
export class AgentLoop extends Service implements AgentFactory {
|
export class AgentLoop extends Service implements AgentFactory {
|
||||||
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
|
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
|
// The schema validates plain strings (cordis.yml config values are untyped at
|
||||||
// runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId`
|
// 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
|
// 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.
|
* and agent options.
|
||||||
* @returns the handle whose dispose tears down exactly this agent.
|
* @returns the handle whose dispose tears down exactly this agent.
|
||||||
*/
|
*/
|
||||||
createAgent(options: CreateAgentOptions): AgentHandle {
|
async createAgent(options: CreateAgentOptions): Promise<AgentHandle> {
|
||||||
// Check the agent id BEFORE preparing the session: register() would reject a
|
// Snapshot every caller-owned field before the first async setup boundary.
|
||||||
// duplicate id only AFTER the session enters the store, leaving an orphaned
|
// The callback itself is an identity capability; all data fields are
|
||||||
// live session (and lazy persistence state) that blocks reuse of that id.
|
// detached so caller mutation cannot drift a reserved/published identity or
|
||||||
this.assertAgentIdFree(options.agentId)
|
// the options the accepted agent observes.
|
||||||
const session = this.ctx.sessions.prepare(options.sessionId, {
|
const agentId = options.agentId
|
||||||
...options.seed !== undefined ? { seed: options.seed } : {},
|
const sessionId = options.sessionId
|
||||||
meta: options.meta ?? {},
|
const setup = options.setup
|
||||||
})
|
const agentOptions = structuredClone(options.agentOptions ?? {})
|
||||||
// A seeded (forked) create is still a fresh start, NOT a resume — `resume`
|
const seed = options.seed === undefined ? undefined : structuredClone(options.seed)
|
||||||
// is reserved for reloading a PERSISTED session via resume()/resumeWith().
|
const meta = structuredClone(options.meta ?? {})
|
||||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'startup', options.setup)
|
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).
|
* AgentLoop's static inject, so they resolve fine).
|
||||||
*/
|
*/
|
||||||
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
|
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||||
this.assertAgentIdFree(options.agentId)
|
// Persistence is an async trust boundary. Reserve, load, reconstruct, and
|
||||||
const { meta, events } = await persistence.load(options.resumeSessionId)
|
// publish only the identities/options accepted at entry—never fields
|
||||||
// Re-check the agent id AFTER the await: the pre-load check above can go
|
// reread from a caller-owned object after the await.
|
||||||
// stale while load() is pending (a concurrent resume/create may register the
|
const agentId = options.agentId
|
||||||
// same id). Re-checking immediately before prepare()/start keeps the
|
const sessionId = options.resumeSessionId
|
||||||
// "no orphaned session on a duplicate id" guarantee under concurrency.
|
const agentOptions = structuredClone(options.agentOptions ?? {})
|
||||||
this.assertAgentIdFree(options.agentId)
|
const setup = options.setup
|
||||||
// Reconstruct the live session with the FULL persisted header (createdAt,
|
const { promise: ownerDisposed, resolve: markOwnerDisposed } = Promise.withResolvers<void>()
|
||||||
// cwd, lineage) so resume preserves identity, not just the cwd. The seed
|
const { promise: transactionSettled, resolve: markTransactionSettled } = Promise.withResolvers<void>()
|
||||||
// events make lastTurnNumber/deriveMessages continue; the backend already
|
let observingOwner = true
|
||||||
// has state (cursor) from the load above, so onCreated is a no-op and the
|
// Resume must observe its caller from BEFORE persistence I/O begins. The
|
||||||
// seed is not re-persisted. prepare() (not create()) so the session
|
// full agent lifecycle does not exist until load returns, so without this
|
||||||
// lifecycle folds into the agent's composite effect (ordered teardown).
|
// sentinel a never-settling backend outlives owner disposal and holds both
|
||||||
const session = this.ctx.sessions.prepare(options.resumeSessionId, {
|
// public identities forever. `this.ctx.effect` retains the traceable caller
|
||||||
seed: events,
|
// ownership used by startOwned's lifecycle effect. Install it before even
|
||||||
meta: {
|
// reserving the ids: an inactive owner cannot leak a reservation if effect
|
||||||
createdAt: meta.createdAt,
|
// registration fails.
|
||||||
...meta.cwd !== undefined ? { cwd: meta.cwd } : {},
|
const disposeLoadSentinel = this.ctx.effect(() => () => {
|
||||||
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
|
if (!observingOwner) return
|
||||||
// Reconstruct the seed boundary from the persisted header, NOT from
|
markOwnerDisposed()
|
||||||
// `events.length` (the resume seeds the WHOLE stored log).
|
// Owner-triggered teardown does not reach quiescence until the resume
|
||||||
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
|
// transaction has observed disposal and released both reservations.
|
||||||
},
|
return transactionSettled
|
||||||
})
|
}, `agentLoop.resumeLoad(${agentId})`)
|
||||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'resume')
|
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.
|
* only after the session has already entered the store.
|
||||||
*/
|
*/
|
||||||
private assertAgentIdFree(id: AgentId): void {
|
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`)
|
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)
|
* Construct an unpublished agent and synchronously install its complete
|
||||||
* session, then build the ONE composite effect that owns the whole agent
|
* teardown skeleton before any setup await. The closures are assigned their
|
||||||
* lifecycle — session entry, registry registration, and the loop. Keeping all
|
* session/registry/loop disposers only at publication, while the exact scope
|
||||||
* three in a SINGLE effect (not sibling effects) is load-bearing: a fiber
|
* disposer is nested immediately. Therefore owner unload during setup flips
|
||||||
* unload disposes sibling effects CONCURRENTLY (`Promise.all`), which would
|
* `active`, unwinds the scope, and wins the race without any late Cordis
|
||||||
* race the session detach against the loop's closing flush and drop the
|
* effect collection.
|
||||||
* 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`).
|
|
||||||
*/
|
*/
|
||||||
private start(
|
private prepareLifecycle(id: AgentId, options: AgentOptions, session: Session): {
|
||||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
agent: ReactLoopAgent
|
||||||
setup?: (agentCtx: Context) => void,
|
active: () => boolean
|
||||||
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
|
deactivated: Promise<void>
|
||||||
const agent = new ReactLoopAgent(this.ctx, id, options, session)
|
publish: (source: SessionStartSource) => void
|
||||||
// The ONE quiescence boundary every disposal path observes. Cordis effect
|
disposeAgent: () => Promise<void>
|
||||||
// disposers are single-shot but not await-idempotent: when the OWNING
|
} {
|
||||||
// fiber's unload invokes the raw wrapper first, a concurrent
|
// When creation is invoked through an agent scope (subagents), the owner
|
||||||
// `handle.dispose()` calling the same wrapper gets an immediate undefined
|
// agent's disposed status flips synchronously at handle teardown—earlier
|
||||||
// (epoch already cleared) — so the handle path must await THIS promise,
|
// than Cordis reaches nested scope effects. Include that signal in the
|
||||||
// resolved by the teardown chain's final disposer, not the wrapper's
|
// pre-publication liveness check so a same-turn parent dispose cannot race
|
||||||
// return. Every disposer in the chain is deliberately infallible (stop()
|
// an already-fulfilled setup promise into briefly publishing a child.
|
||||||
// is infallible by contract, unregister/detach contain their listeners,
|
const ownerAgent = this.ctx.agent
|
||||||
// the scope unwind is cordis-contained), so the final disposer always
|
const ownerFiber = this.ctx.fiber
|
||||||
// runs — a throwing link would skip the rest of a cordis dispose chain.
|
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 { 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() }
|
yield () => { markTorndown() }
|
||||||
// Mint the agent's scope (key = the agent) and wire the two-phase
|
// Exact identity moves the scope fiber out of the owner's concurrent
|
||||||
// reference: the scope context tags registrations + filters dispatch;
|
// sibling list and into this ordered transaction.
|
||||||
// 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 })
|
|
||||||
yield scope.rawDispose
|
yield scope.rawDispose
|
||||||
// Enter the session THROUGH agent.ctx so the store captures the agent's
|
yield () => {
|
||||||
// scope as the session's dispatch carrier.
|
detachSession?.()
|
||||||
yield agent.ctx.sessions.enter(session)
|
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)
|
this.ctx.sessions.announce(session)
|
||||||
yield this.ctx.agents.register(agent)
|
this.ctx.agents.announce(agent)
|
||||||
// The creator's scoped composition, inside the rollback boundary: a
|
// Setup is over and both entries are live. Open the driving surface just
|
||||||
// throwing setup unwinds LIFO through register → scope → detach, so a
|
// before session-start so its listeners retain their supported ability to
|
||||||
// half-created agent never leaks. Setup REGISTERS (through agent.ctx),
|
// inject/queue, while setup itself can never drive an unpublished agent.
|
||||||
// it never drives — see CreateAgentOptions.setup.
|
driver.enableDrive()
|
||||||
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).
|
|
||||||
try {
|
try {
|
||||||
agentEvents(this.ctx, agent).emit('agent/session-start', source)
|
agentEvents(this.ctx, agent).emit('agent/session-start', source)
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`)
|
this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`)
|
||||||
}
|
}
|
||||||
const stop = agent.start()
|
stop = driver.startDriver()
|
||||||
// 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.
|
return {
|
||||||
yield async () => { stop(); await agent.done }
|
agent,
|
||||||
}.bind(this), 'agentLoop.start()')
|
active: () => active
|
||||||
return { agent, disposeAgent: async () => { await dispose(); await torndown } }
|
&& 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()`
|
* `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()`
|
||||||
* helper).
|
* helper).
|
||||||
*/
|
*/
|
||||||
private startOwned(
|
private async startOwned(
|
||||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
||||||
setup?: (agentCtx: Context) => void,
|
setup?: (agentCtx: Context) => Promise<void> | void,
|
||||||
): AgentHandle {
|
): Promise<AgentHandle> {
|
||||||
const { agent, disposeAgent } = this.start(id, options, session, source, setup)
|
const lifecycle = this.prepareLifecycle(id, options, session)
|
||||||
let disposing: Promise<void> | undefined
|
try {
|
||||||
return { agent, dispose: () => (disposing ??= disposeAgent()) }
|
// 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 type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
|
||||||
import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
|
import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||||
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
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 { canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||||
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||||
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
|
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 { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import type {} from '@deepseek-ai/dsh-tools'
|
import type {} from '@deepseek-ai/dsh-tools'
|
||||||
import type { ReactLoopAgent } from './agent.ts'
|
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). */
|
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
|
||||||
type CodedError = Error & { code?: string }
|
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 })
|
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
|
* Map a model-call {@link FinishReason} to the step error it should raise, or
|
||||||
* `undefined` when the step completed normally.
|
* `undefined` when the step completed normally.
|
||||||
@@ -108,6 +123,8 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
|
|||||||
* loop testable without a real agent.
|
* loop testable without a real agent.
|
||||||
*/
|
*/
|
||||||
export interface LoopHandle {
|
export interface LoopHandle {
|
||||||
|
/** Native-private agent inbox handed to the driver only at internal startup. */
|
||||||
|
readonly inbox: Inbox
|
||||||
setStatus(status: 'idle' | 'running'): void
|
setStatus(status: 'idle' | 'running'): void
|
||||||
setAbort(controller: AbortController | undefined): void
|
setAbort(controller: AbortController | undefined): void
|
||||||
/** Resolves when the agent is disposed — unblocks the idle wait. */
|
/** 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
|
* {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is
|
||||||
* recorded as next-step steering
|
* recorded as next-step steering
|
||||||
* if action==stop && steering arrived (step/end/continuation listeners): continue anyway
|
* 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
|
* if action==stop: break
|
||||||
* session('turn/end') ⟵ durable turn boundary (no agent/* mirror)
|
* session('turn/end') ⟵ durable turn boundary (no agent/* mirror)
|
||||||
* await ctx.sessions.flush(session) ⟵ durability checkpoint (store-owned carrier)
|
* 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)
|
const events = agentEvents(ctx, agent)
|
||||||
|
|
||||||
while (!handle.isDisposed()) {
|
while (!handle.isDisposed()) {
|
||||||
await agent.inbox.waitForQueued(handle.disposed)
|
await handle.inbox.waitForQueued(handle.disposed)
|
||||||
if (handle.isDisposed()) break
|
if (handle.isDisposed()) break
|
||||||
|
|
||||||
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the
|
// 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).
|
// resolve before it runs (the quiescence contract).
|
||||||
if (handle.isCancelled()) {
|
if (handle.isCancelled()) {
|
||||||
handle.clearCancel()
|
handle.clearCancel()
|
||||||
if (!agent.inbox.hasQueued) {
|
if (!handle.inbox.hasQueued) {
|
||||||
handle.settleIdle()
|
handle.settleIdle()
|
||||||
continue
|
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).
|
// is still queued and unrun (the same early-resolve race window 1 fixes).
|
||||||
if (handle.isCancelled()) {
|
if (handle.isCancelled()) {
|
||||||
handle.clearCancel()
|
handle.clearCancel()
|
||||||
if (!agent.inbox.hasQueued) {
|
if (!handle.inbox.hasQueued) {
|
||||||
handle.setStatus('idle')
|
handle.setStatus('idle')
|
||||||
continue
|
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
|
// 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.
|
// turn number is actually last in the log — a stale counter would collide.
|
||||||
const turn = lastTurnNumber(session) + 1
|
const turn = lastTurnNumber(session) + 1
|
||||||
|
let terminalStopped = false
|
||||||
try {
|
try {
|
||||||
await runTurn(ctx, events, agent, handle, turn, transmission)
|
terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission)
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard
|
// 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
|
// 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.
|
// cancelled.
|
||||||
handle.clearCancel()
|
handle.clearCancel()
|
||||||
|
|
||||||
// Steering that arrived too late to join this turn (turn-end listeners,
|
// Steering that arrived too late to join an ordinary turn (turn-end
|
||||||
// flush) becomes a queued message — it must never be stranded. (A cancelled
|
// listeners, flush) becomes queued input so it is never stranded. A
|
||||||
// turn already cleared its steering, so there is nothing to re-enqueue.)
|
// terminal-stop owner is the deliberate exception: discard the steering
|
||||||
for (const message of agent.inbox.drainSteering()) {
|
// again after the close + flush window so terminal policy cannot be undone
|
||||||
agent.inbox.enqueue(message)
|
// 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(
|
async function runTurn(
|
||||||
ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog,
|
ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog,
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
const { session } = agent
|
const { session } = agent
|
||||||
|
|
||||||
// --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end —
|
// --- 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
|
// turn/start has not been appended — so it propagates to runLoop's backstop
|
||||||
// untouched. The queued messages are drained here but appended AFTER
|
// untouched. The queued messages are drained here but appended AFTER
|
||||||
// turn/start (below), so every event in the log lives inside a turn.
|
// 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]
|
const first = queued[0]
|
||||||
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
|
/* 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')
|
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 step = 0
|
||||||
let stepOpen = false
|
let stepOpen = false
|
||||||
let errorReported = false
|
let errorReported = false
|
||||||
|
let terminalStopped = false
|
||||||
|
|
||||||
// Close the open step exactly once (idempotent via stepOpen). Step boundaries
|
// Close the open step exactly once (idempotent via stepOpen). Step boundaries
|
||||||
// are durable session events only — there is no agent/* step emit to mirror
|
// 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
|
// Steering from the previous round's continuation listeners joins before
|
||||||
// the request.
|
// the request.
|
||||||
drainSteering(agent, turn)
|
drainSteering(agent, handle.inbox, turn)
|
||||||
|
|
||||||
// The step's AbortController exists BEFORE any async pre-step work so a
|
// The step's AbortController exists BEFORE any async pre-step work so a
|
||||||
// dispose() or cancel() — in a synchronous turn-start listener or an
|
// dispose() or cancel() — in a synchronous turn-start listener or an
|
||||||
@@ -616,7 +641,7 @@ async function runTurn(
|
|||||||
if (stepReason) reason = stepReason
|
if (stepReason) reason = stepReason
|
||||||
|
|
||||||
// Steering that arrived during streaming/tool execution.
|
// Steering that arrived during streaming/tool execution.
|
||||||
const steered = drainSteering(agent, turn)
|
const steered = drainSteering(agent, handle.inbox, turn)
|
||||||
|
|
||||||
if (closeStep()) break
|
if (closeStep()) break
|
||||||
|
|
||||||
@@ -638,14 +663,39 @@ async function runTurn(
|
|||||||
// iteration drains it before its request — the typed twin of the /goal
|
// iteration drains it before its request — the typed twin of the /goal
|
||||||
// step/end-steer pattern.
|
// step/end-steer pattern.
|
||||||
if (decision.action === 'continue' && decision.reason) {
|
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'
|
let shouldContinue = decision.action === 'continue'
|
||||||
|
|
||||||
// Steering from step/end session-event or continuation listeners (the
|
// Steering from step/end session-event or continuation listeners (the
|
||||||
// /goal pattern) demands the model see it — it overrides a stop decision;
|
// /goal pattern) demands the model see it — it overrides a stop decision;
|
||||||
// the next iteration's drain records it.
|
// 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
|
// A cancel that landed during the continuation window — after the step's
|
||||||
// AbortController was cleared (setAbort(undefined)) but before the next
|
// 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.
|
// contained: a throwing agent/error listener must not escape the loop.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return terminalStopped
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drain the steering queue into the session. Returns whether any arrived. */
|
/** Drain the steering queue into the session. Returns whether any arrived. */
|
||||||
function drainSteering(agent: ReactLoopAgent, turn: number): boolean {
|
function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boolean {
|
||||||
const messages = agent.inbox.drainSteering()
|
const messages = inbox.drainSteering()
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
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 ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
|
import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||||
|
|
||||||
async function harness(adapter: MockAdapter) {
|
async function harness(adapter: MockAdapter) {
|
||||||
@@ -226,16 +227,19 @@ describe('ReactLoopAgent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('disposer is idempotent (double-stop)', async () => {
|
it('disposer is idempotent (double-stop)', async () => {
|
||||||
// Create a bare ReactLoopAgent and call start() directly to get the disposer.
|
// Create a bare ReactLoopAgent and start it through the package-internal
|
||||||
// Then call it twice — the second call hits the early-return branch.
|
// test seam. Then call its disposer twice — the second call hits the
|
||||||
|
// early-return branch.
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
const session = ctx.sessions.create(SessionId('test'))
|
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
|
// Start the loop to get the disposer; the agent waits for messages
|
||||||
// (idle, never-resolving cancel), so it will stay idle.
|
// (idle, never-resolving cancel), so it will stay idle.
|
||||||
const dispose = agent.start()
|
prepared.enableDrive()
|
||||||
|
const dispose = prepared.startDriver()
|
||||||
|
|
||||||
// First dispose
|
// First dispose
|
||||||
dispose()
|
dispose()
|
||||||
@@ -324,7 +328,7 @@ describe('ReactLoopAgent', () => {
|
|||||||
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
|
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
|
||||||
// while running (not the fast path), then the disposer settles it and chains
|
// while running (not the fast path), then the disposer settles it and chains
|
||||||
// `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct
|
// `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()
|
const ctx = new Context()
|
||||||
await ctx.plugin(LlmService)
|
await ctx.plugin(LlmService)
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
@@ -334,8 +338,10 @@ describe('ReactLoopAgent', () => {
|
|||||||
const adapter = new MockAdapter(['hang'])
|
const adapter = new MockAdapter(['hang'])
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
ctx.llm.registerAdapter(['mock'], adapter)
|
||||||
const session = ctx.sessions.create(SessionId('bare'))
|
const session = ctx.sessions.create(SessionId('bare'))
|
||||||
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||||
const dispose = agent.start()
|
const { agent } = prepared
|
||||||
|
prepared.enableDrive()
|
||||||
|
const dispose = prepared.startDriver()
|
||||||
agent.send([{ type: 'text', text: 'go' }])
|
agent.send([{ type: 'text', text: 'go' }])
|
||||||
await new Promise(r => setTimeout(r, 30))
|
await new Promise(r => setTimeout(r, 30))
|
||||||
expect(agent.status).toBe('running')
|
expect(agent.status).toBe('running')
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ describe('Agent.cancel()', () => {
|
|||||||
await ctx.plugin(AgentLoop, { agents: [] })
|
await ctx.plugin(AgentLoop, { agents: [] })
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
ctx.llm.registerAdapter(['mock'], adapter)
|
||||||
|
|
||||||
const handle = ctx.agents.create({
|
const handle = await ctx.agents.create({
|
||||||
agentId: AgentId('a-dispose-prefix'),
|
agentId: AgentId('a-dispose-prefix'),
|
||||||
sessionId: SessionId('dispose-prefix-session'),
|
sessionId: SessionId('dispose-prefix-session'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
@@ -333,7 +333,7 @@ describe('Agent.cancel()', () => {
|
|||||||
await ctx.plugin(AgentLoop, { agents: [] })
|
await ctx.plugin(AgentLoop, { agents: [] })
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
ctx.llm.registerAdapter(['mock'], adapter)
|
||||||
|
|
||||||
const handle = ctx.agents.create({
|
const handle = await ctx.agents.create({
|
||||||
agentId: AgentId('a-dispose-step-start'),
|
agentId: AgentId('a-dispose-step-start'),
|
||||||
sessionId: SessionId('dispose-step-start-session'),
|
sessionId: SessionId('dispose-step-start-session'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ describe('config-driven session id', () => {
|
|||||||
await ctx1.plugin(AgentLoop, { agents: [] })
|
await ctx1.plugin(AgentLoop, { agents: [] })
|
||||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
|
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' } })
|
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||||
await waitForIdle(ctx1, a1)
|
await waitForIdle(ctx1, a1)
|
||||||
await ctx1.fiber.dispose()
|
await ctx1.fiber.dispose()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Inbox } from '@deepseek-ai/dsh-agent-loop'
|
import { Inbox } from '../src/inbox.ts'
|
||||||
|
|
||||||
function resolverPair() {
|
function resolverPair() {
|
||||||
let r!: () => void
|
let r!: () => void
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ describe('agent loop', () => {
|
|||||||
it('resolves {{cwd}} from the agent session workspace (factory create with meta.cwd)', async () => {
|
it('resolves {{cwd}} from the agent session workspace (factory create with meta.cwd)', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('ok')])
|
const adapter = new MockAdapter([textResponse('ok')])
|
||||||
const ctx = await harness(adapter, 'Working in {{cwd}}.')
|
const ctx = await harness(adapter, 'Working in {{cwd}}.')
|
||||||
const handle = ctx.agents.create({
|
const handle = await ctx.agents.create({
|
||||||
agentId: AgentId('a-cwd'),
|
agentId: AgentId('a-cwd'),
|
||||||
sessionId: SessionId('s-cwd'),
|
sessionId: SessionId('s-cwd'),
|
||||||
meta: { cwd: '/work/space' },
|
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.')
|
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 () => {
|
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
|
// The documented escape valve: a deployment that must drop the harness
|
||||||
// openers short-circuits the assemble waterfall; the request then carries
|
// 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).
|
// one's full log (the resume/fork path).
|
||||||
const adapter2 = new MockAdapter([textResponse('two')])
|
const adapter2 = new MockAdapter([textResponse('two')])
|
||||||
const ctx2 = await harness(adapter2)
|
const ctx2 = await harness(adapter2)
|
||||||
const handle = ctx2.agents.create({
|
const handle = await ctx2.agents.create({
|
||||||
agentId: AgentId('gen2'),
|
agentId: AgentId('gen2'),
|
||||||
sessionId: SessionId('gen2-session'),
|
sessionId: SessionId('gen2-session'),
|
||||||
seed: [...agent.session.events],
|
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 }> {
|
async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context; root: string }> {
|
||||||
const root = await mkdtemp(join(tmpdir(), 'dsh-resume-'))
|
const root = await mkdtemp(join(tmpdir(), 'dsh-resume-'))
|
||||||
dirs.push(root)
|
dirs.push(root)
|
||||||
|
return { ctx: await mountPersistentHarness(root, adapter), root }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPersistentHarness(root: string, adapter: MockAdapter): Promise<Context> {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(LlmService)
|
await ctx.plugin(LlmService)
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
@@ -28,7 +32,22 @@ async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context;
|
|||||||
await ctx.plugin(AgentLoop, { agents: [] })
|
await ctx.plugin(AgentLoop, { agents: [] })
|
||||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
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> {
|
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', () => {
|
describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||||
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
|
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('hi')])
|
const adapter = new MockAdapter([textResponse('hi')])
|
||||||
const { ctx } = await persistentHarness(adapter)
|
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.id).toBe('custom-session')
|
||||||
expect(agent.session.header.cwd).toBe('/w')
|
expect(agent.session.header.cwd).toBe('/w')
|
||||||
await ctx.fiber.dispose()
|
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 () => {
|
it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('hi')])
|
const adapter = new MockAdapter([textResponse('hi')])
|
||||||
const { ctx } = await persistentHarness(adapter)
|
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
|
// 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.
|
// 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()
|
expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined()
|
||||||
await ctx.fiber.dispose()
|
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 () => {
|
it('createAgent works without meta (no cwd)', async () => {
|
||||||
const adapter = new MockAdapter([textResponse('hi')])
|
const adapter = new MockAdapter([textResponse('hi')])
|
||||||
const { ctx } = await persistentHarness(adapter)
|
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.id).toBe('nometa-session')
|
||||||
expect(agent.session.header.cwd).toBeUndefined()
|
expect(agent.session.header.cwd).toBeUndefined()
|
||||||
await ctx.fiber.dispose()
|
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.
|
// Lifecycle 1: create a no-cwd session and run a turn.
|
||||||
const adapter1 = new MockAdapter([textResponse('a')])
|
const adapter1 = new MockAdapter([textResponse('a')])
|
||||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
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' } })
|
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||||
await waitForIdle(ctx1, a1)
|
await waitForIdle(ctx1, a1)
|
||||||
await ctx1.fiber.dispose()
|
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 { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||||
const sources1: string[] = []
|
const sources1: string[] = []
|
||||||
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
|
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'])
|
expect(sources1).toEqual(['startup'])
|
||||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||||
await waitForIdle(ctx1, a1)
|
await waitForIdle(ctx1, a1)
|
||||||
@@ -124,6 +154,224 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
|||||||
await ctx2.fiber.dispose()
|
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 () => {
|
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
|
// Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
|
||||||
// in its header) by creating it with a complete-turn seed — the write path
|
// 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.
|
// disk, since a crash before the next turn would otherwise lose it.
|
||||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
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' } })
|
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||||
await waitForIdle(ctx1, a1)
|
await waitForIdle(ctx1, a1)
|
||||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
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).
|
// drop it on reload (the bug this guards).
|
||||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
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' } })
|
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||||
await waitForIdle(ctx1, a1)
|
await waitForIdle(ctx1, a1)
|
||||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
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.
|
// Lifecycle 1: run one full turn, persisting it.
|
||||||
const adapter1 = new MockAdapter([textResponse('first answer')])
|
const adapter1 = new MockAdapter([textResponse('first answer')])
|
||||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
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' } })
|
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
|
||||||
await waitForIdle(ctx1, a1)
|
await waitForIdle(ctx1, a1)
|
||||||
const events1 = [...a1.session.events]
|
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 ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||||
import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||||
|
import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
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)
|
ctx2.llm.registerAdapter(['mock'], second)
|
||||||
|
|
||||||
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
|
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
|
||||||
const forked = new ReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
|
const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
|
||||||
ctx2.effect(() => forked.start())
|
const forked = prepared.agent
|
||||||
|
prepared.enableDrive()
|
||||||
|
ctx2.effect(() => prepared.startDriver())
|
||||||
|
|
||||||
const turns: number[] = []
|
const turns: number[] = []
|
||||||
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
|
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 type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
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 type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
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 () => {
|
it('scoped registrations live in the agent world and die with the agent', async () => {
|
||||||
const ctx = await harness()
|
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
|
const { agent } = handle
|
||||||
agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
|
agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
|
||||||
agent.ctx.tools.register({
|
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'),
|
agentId: AgentId('child'),
|
||||||
sessionId: SessionId('child-s'),
|
sessionId: SessionId('child-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup: (agentCtx) => {
|
setup: async (agentCtx) => {
|
||||||
order.push('setup')
|
order.push('setup')
|
||||||
|
await Promise.resolve()
|
||||||
agentCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You are the child.' })
|
agentCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You are the child.' })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -118,42 +120,233 @@ describe('agent scope lifecycle', () => {
|
|||||||
await handle.dispose()
|
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()
|
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'),
|
agentId: AgentId('bad'),
|
||||||
sessionId: SessionId('bad-s'),
|
sessionId: SessionId('bad-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
setup: () => { throw new Error('boom setup') },
|
setup: async () => {
|
||||||
})).toThrow('boom setup')
|
await Promise.resolve()
|
||||||
|
throw new Error('boom setup')
|
||||||
|
},
|
||||||
|
})).rejects.toThrow('boom setup')
|
||||||
|
|
||||||
// Nothing leaked: no agent, no session, and the ids are reusable.
|
// Nothing leaked: no agent, no session, and the ids are reusable.
|
||||||
|
expect(published).toEqual([])
|
||||||
expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
|
expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
|
||||||
expect(ctx.sessions.get(SessionId('bad-s'))).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()
|
await retry.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => {
|
it('a throwing session/created listener disposes the scope (pre-nesting rollback window)', async () => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
let boom = true
|
let boom = true
|
||||||
|
const disposed: string[] = []
|
||||||
|
ctx.on('agent/disposed', agent => void disposed.push(agent.id))
|
||||||
ctx.on('session/created', () => {
|
ctx.on('session/created', () => {
|
||||||
if (boom) { boom = false; throw new Error('boom 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' },
|
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.agents.get(AgentId('bad'))).toBeUndefined()
|
||||||
expect(ctx.sessions.get(SessionId('bad-s'))).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.
|
// 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)
|
expect(scopeOf(retry.agent.ctx)).toBe(retry.agent)
|
||||||
await retry.dispose()
|
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 () => {
|
it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
|
||||||
const ctx = await harness()
|
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()
|
await handle.dispose()
|
||||||
expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/)
|
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 () => {
|
it('owner unload honors the documented teardown order: unregistration AFTER the drain, before detach', async () => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
let handle!: ReturnType<typeof ctx.agents.create>
|
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
||||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||||
handle = inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } })
|
handle = await inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } })
|
||||||
}, { inject: ['agents'] }))
|
}, { inject: ['agents'] }))
|
||||||
const { agent } = handle
|
const { agent } = handle
|
||||||
|
|
||||||
@@ -229,9 +422,9 @@ describe('agent scope lifecycle', () => {
|
|||||||
|
|
||||||
it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => {
|
it('handle.dispose() during owner unload still awaits true quiescence (shared boundary)', async () => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
let handle!: ReturnType<typeof ctx.agents.create>
|
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
||||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||||
handle = inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } })
|
handle = await inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } })
|
||||||
}, { inject: ['agents'] }))
|
}, { inject: ['agents'] }))
|
||||||
|
|
||||||
const teardownDone: string[] = []
|
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).
|
* @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]>>>
|
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
|
* Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The
|
||||||
* declared event parameters already end with the `next` callback, so `rest`
|
* 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 {
|
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
|
||||||
const carrier: Scoped<Agent> = scopeTarget(agent, agent)
|
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
|
// fused (carrier, name, agent, ...rest) tuple is provably a valid argument
|
||||||
// list for the matching thisArg overload, but TypeScript cannot relate the
|
// list for the matching thisArg overload, but TypeScript cannot relate the
|
||||||
// generic Tail<K> spread back to that overload's conditional parameter
|
// 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>
|
const serial = ctx.serial as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => Promise<never>
|
||||||
return await serial(carrier, name, agent, ...rest)
|
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) {
|
waterfall(name, ...rest) {
|
||||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
|
// 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
|
const waterfall = ctx.waterfall as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => never
|
||||||
|
|||||||
@@ -18,14 +18,13 @@ declare module 'cordis' {
|
|||||||
interface Context {
|
interface Context {
|
||||||
agents: AgentRegistry
|
agents: AgentRegistry
|
||||||
/**
|
/**
|
||||||
* The agent whose scope this context belongs to, or `undefined` on any
|
* The agent association installed as an own property on `Agent.ctx`, or
|
||||||
* context not derived from an agent scope. Pure DX sugar over the
|
* `undefined` on a plain context. Contexts derived from `Agent.ctx` inherit
|
||||||
* `dsh-scope` tag: the agent loop sets it as an own property on each
|
* the association; a deliberately nested scope may carry a nearer
|
||||||
* `Agent.ctx`, and {@link AgentRegistry} registers a root accessor
|
* `dsh-scope` tag while retaining it, so this field is DX context rather
|
||||||
* defaulting to `undefined` so the read is safe on every context (a plain
|
* than the scope resolver. {@link AgentRegistry} registers a root accessor
|
||||||
* plugin context answers `undefined` instead of throwing the Cordis
|
* defaulting to `undefined`, and core packages below the agent layer use
|
||||||
* unknown-property error). Core packages below the agent layer read the
|
* `scopeOf()` for layer selection instead of reading this field.
|
||||||
* `dsh-scope` tag (`scopeOf`) instead, never this field.
|
|
||||||
*/
|
*/
|
||||||
agent?: Agent
|
agent?: Agent
|
||||||
}
|
}
|
||||||
@@ -66,19 +65,20 @@ export interface CreateAgentOptions {
|
|||||||
/** Per-agent options (model, …). */
|
/** Per-agent options (model, …). */
|
||||||
agentOptions?: AgentOptions
|
agentOptions?: AgentOptions
|
||||||
/**
|
/**
|
||||||
* Creation-time composition of the agent's scoped world. The factory runs it
|
* Creation-time composition of the agent's scoped world. The factory awaits
|
||||||
* inside the agent's composite lifecycle effect — after the scope is minted
|
* setup after minting `agentCtx` but BEFORE inserting or announcing either
|
||||||
* and the agent registered, before `agent/session-start` fires and the loop
|
* the session or agent, so observers can never see a partially configured
|
||||||
* starts — so everything it registers through `agentCtx` (scoped tools,
|
* world. Everything registered through `agentCtx` (scoped tools, prompt
|
||||||
* prompt sections/variables, `restrict()`, listeners, `agentCtx.plugin(…)`
|
* sections/variables, `restrict()`, listeners, awaited child plugins) exists
|
||||||
* profiles) exists before the first prompt assembly, and a THROWING setup
|
* before `session/created`, `agent/created`, `agent/session-start`, and the
|
||||||
* unwinds inside the rollback boundary instead of leaking a half-created
|
* first prompt assembly. A throw/rejection or owner disposal rolls the scope
|
||||||
* agent. **Setup registers, it never drives**: calling
|
* back without publishing either id.
|
||||||
* `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
|
* **Setup composes, it never drives**: calling `send`/`steer`/`inject` here
|
||||||
* teaching error) — drive the agent after creation returns.
|
* 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
|
resumeSessionId: SessionId
|
||||||
/** Per-agent options (model, …). */
|
/** Per-agent options (model, …). */
|
||||||
agentOptions?: AgentOptions
|
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} /
|
* An owned agent plus its disposer, returned by {@link AgentRegistry.create} /
|
||||||
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder
|
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder
|
||||||
* can tear this agent down. `dispose()` unregisters the agent, stops its loop,
|
* can tear this agent down. `dispose()` stops the loop, awaits its exit
|
||||||
* awaits the loop's exit (quiescence — NOT just the `disposed` status flip), and
|
* (quiescence — NOT just the `disposed` status flip), unregisters the agent,
|
||||||
* removes the agent's session from the store, in an order that captures the
|
* removes its session from the store, and finally unwinds its scoped world.
|
||||||
* loop's final `session/flush` before the session is detached.
|
* 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
|
* `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)
|
* for the OWNER that created it. Config-created agents (the loop's own startup)
|
||||||
@@ -119,15 +130,27 @@ export interface AgentHandle {
|
|||||||
*/
|
*/
|
||||||
export interface AgentFactory {
|
export interface AgentFactory {
|
||||||
/**
|
/**
|
||||||
* Create, start, and register a new agent on a caller-supplied session id.
|
* Create a new agent on a caller-supplied session id. Async because creation
|
||||||
* Returns an {@link AgentHandle} — the owner disposes it to tear down exactly
|
* awaits unpublished setup, inserts both session and agent, emits their
|
||||||
* this agent (unregister + stop loop + await quiescence + remove session).
|
* 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
|
* Load a persisted session and resume an agent on it. Async because it awaits
|
||||||
* `ctx.sessionPersistence.load`; must be called after that service exists
|
* both `ctx.sessionPersistence.load` and the optional unpublished setup
|
||||||
* (consumers inject `sessionPersistence`). Returns an {@link AgentHandle}.
|
* 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>
|
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
|
* Agent registry (`ctx.agents`): tracks live agents so UI, hook, and
|
||||||
* orchestrator plugins can find them without depending on the concrete loop
|
* orchestrator plugins can find them without depending on the concrete loop
|
||||||
* package. Agent *creation* is provided by whichever plugin implements the
|
* 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}.
|
* {@link setFactory}.
|
||||||
*/
|
*/
|
||||||
export class AgentRegistry extends Service {
|
export class AgentRegistry extends Service {
|
||||||
private store = new Map<AgentId, Agent>()
|
private store = new Map<AgentId, Agent>()
|
||||||
|
/** Entries whose `agent/created` announcement phase began. */
|
||||||
|
private announced = new WeakSet<Agent>()
|
||||||
private factory: AgentFactory | undefined
|
private factory: AgentFactory | undefined
|
||||||
|
|
||||||
constructor(ctx: Context) {
|
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
|
* Distinct from {@link register} (which records an already-constructed
|
||||||
* agent): this constructs the agent and its session. Throws if no factory is
|
* agent): this constructs the agent and its session. Rejects if no factory is
|
||||||
* registered. Returns an {@link AgentHandle} — the owner disposes it to tear
|
* registered or creation/setup fails. The resolved {@link AgentHandle} lets
|
||||||
* down exactly this agent.
|
* the owner tear down exactly this agent.
|
||||||
* @param options - agent id, session id/seed/metadata, and agent options.
|
* @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)
|
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||||
return this.factory.createAgent(options)
|
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
|
* Load a persisted session and resume an agent on it through the registered
|
||||||
* factory. Rejects if no factory is registered; the factory rejects if
|
* factory. Rejects if no factory is registered; the factory rejects if
|
||||||
* session persistence is not configured. Returns an {@link AgentHandle}.
|
* session persistence is not configured or persistence/setup fails.
|
||||||
* @param options - the persisted session id plus agent id and options.
|
* @param options - persisted identity, configuration, and optional setup.
|
||||||
* @returns the handle for the resumed agent.
|
* @returns the handle after setup, rollback-covered publication, and loop start complete.
|
||||||
*/
|
*/
|
||||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
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 {
|
register(agent: Agent): () => Promise<void> | void {
|
||||||
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
|
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
|
||||||
if (this.store.has(agent.id)) {
|
yield this.enter(agent)
|
||||||
throw new Error(`agent "${agent.id}" is already registered`)
|
this.announce(agent)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}.bind(this), 'agents.register()')
|
}.bind(this), 'agents.register()')
|
||||||
return dispose
|
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.
|
* Look up a live agent.
|
||||||
* @param id - the agent id to look up.
|
* @param id - the agent id to look up.
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
* - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the
|
* - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the
|
||||||
* live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/
|
* live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/
|
||||||
* `agent/request`/`agent/session-prefix`/`agent/step-result`/
|
* `agent/request`/`agent/session-prefix`/`agent/step-result`/
|
||||||
* `agent/turn-continuation` waterfalls and
|
* `agent/turn-continuation` waterfalls and the serial `agent/pre-step` /
|
||||||
* the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits
|
* `agent/turn-stop` checkpoints) that mutate/veto, and TRANSIENT emits
|
||||||
* (`agent/status`, `agent/error`, `agent/created`/
|
* (`agent/status`, `agent/error`, `agent/created`/
|
||||||
* `agent/disposed`, `agent/queued`, `agent/session-start`)
|
* `agent/disposed`, `agent/queued`, `agent/session-start`)
|
||||||
* that notify with the `Agent` in hand. Turn/step boundaries are NOT here —
|
* 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`.
|
* and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`.
|
||||||
*
|
*
|
||||||
* The interception waterfalls here (`agent/prompt-submit`, `agent/request`,
|
* The interception waterfalls here (`agent/prompt-submit`, `agent/request`,
|
||||||
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision —
|
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision;
|
||||||
* the convention pinned by
|
* 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`.
|
* `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`.
|
||||||
*
|
*
|
||||||
* @module @deepseek-ai/dsh-agent/types
|
* @module @deepseek-ai/dsh-agent/types
|
||||||
@@ -161,6 +162,13 @@ export type ContinuationDecision =
|
|||||||
| { action: 'stop' }
|
| { action: 'stop' }
|
||||||
| { action: 'continue'; reason?: HookContext }
|
| { 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
|
* 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
|
* bridge keys its SessionStart hook's matcher on this (Claude Code's
|
||||||
@@ -274,9 +282,12 @@ declare module 'cordis' {
|
|||||||
interface Events {
|
interface Events {
|
||||||
// ---- lifecycle (emit) ----
|
// ---- lifecycle (emit) ----
|
||||||
/**
|
/**
|
||||||
* An agent was registered in the {@link AgentRegistry} and is ready to
|
* An agent's fully composed scoped world was published in the
|
||||||
* receive messages.
|
* {@link AgentRegistry}. Its session is already live in the session store,
|
||||||
* @param agent - the newly registered agent, already resolvable in the registry.
|
* 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
|
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
* 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
|
* 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
|
'agent/created'(this: Scoped<Agent>, agent: Agent): void
|
||||||
/**
|
/**
|
||||||
* An agent was disposed and removed from the registry; its fiber and any
|
* An agent was removed from the registry after its driver and any in-flight
|
||||||
* in-flight turn have been torn down.
|
* turn reached quiescence. Ordered teardown may still be detaching the
|
||||||
* @param agent - the agent that was torn down; its handle is now inert.
|
* 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
|
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
* 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
|
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||||
@@ -534,6 +547,25 @@ declare module 'cordis' {
|
|||||||
* @mode waterfall
|
* @mode waterfall
|
||||||
*/
|
*/
|
||||||
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, 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) ----
|
// ---- error notifications (emit) ----
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -77,6 +77,36 @@ describe('AgentRegistry', () => {
|
|||||||
await dispose()
|
await dispose()
|
||||||
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
|
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', () => {
|
describe('AgentRegistry factory seam', () => {
|
||||||
@@ -84,7 +114,7 @@ describe('AgentRegistry factory seam', () => {
|
|||||||
function stubFactory() {
|
function stubFactory() {
|
||||||
const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] }
|
const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] }
|
||||||
const factory: import('@deepseek-ai/dsh-agent').AgentFactory = {
|
const factory: import('@deepseek-ai/dsh-agent').AgentFactory = {
|
||||||
createAgent(options) {
|
async createAgent(options) {
|
||||||
calls.create.push(options)
|
calls.create.push(options)
|
||||||
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
|
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 () => {
|
it('create()/resume() throw when no factory is registered', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(AgentRegistry)
|
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/)
|
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()
|
const { factory, calls } = stubFactory()
|
||||||
ctx.agents.setFactory(factory)
|
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(created.agent.id).toBe('c1')
|
||||||
expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }])
|
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) => {
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||||
dispose = inner.agents.setFactory(stubFactory().factory)
|
dispose = inner.agents.setFactory(stubFactory().factory)
|
||||||
}, { inject: ['agents'] }))
|
}, { 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
|
void dispose
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
// factory slot cleared → create throws again
|
// 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
|
* @module @deepseek-ai/dsh-scope
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context } from 'cordis'
|
import type { Context, Fiber } from 'cordis'
|
||||||
import { Context as CordisContext } from 'cordis'
|
import { Context as CordisContext } from 'cordis'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,21 +78,30 @@ export interface Scope {
|
|||||||
rawDispose: () => Promise<void> | void
|
rawDispose: () => Promise<void> | void
|
||||||
/**
|
/**
|
||||||
* Unwind the scope: dispose the backing fiber, running every collected
|
* Unwind the scope: dispose the backing fiber, running every collected
|
||||||
* registration disposer. Idempotent and always awaitable — a repeat call
|
* registration disposer. Idempotent and always awaitable: repeat and racing
|
||||||
* resolves immediately (the underlying Cordis disposer is single-shot and
|
* calls share one completion even though the underlying Cordis disposer is
|
||||||
* returns undefined the second time; this wrapper Promise-normalizes it).
|
* single-shot and returns undefined after its first invocation.
|
||||||
* After disposal the scoped context is inert — a further registration
|
* After disposal the scoped context is inert — a further registration
|
||||||
* through it throws Cordis's INACTIVE_EFFECT.
|
* through it throws Cordis's INACTIVE_EFFECT.
|
||||||
* @returns for the call that initiates teardown: resolves when every
|
* @returns for the call that initiates teardown: resolves when every
|
||||||
* registration's disposer has settled. A repeat/racing call resolves
|
* registration's disposer has settled. Every repeat/racing call awaits
|
||||||
* immediately WITHOUT awaiting the in-flight teardown (the underlying
|
* that same quiescence boundary, including when {@link rawDispose} claimed
|
||||||
* Cordis disposer is single-shot) — a caller needing a shared quiescence
|
* the underlying single-shot Cordis disposer first.
|
||||||
* boundary across racing disposers keeps its own completion promise (the
|
|
||||||
* agent factory's pattern).
|
|
||||||
*/
|
*/
|
||||||
dispose(): Promise<void>
|
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
|
* 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
|
* `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
|
// Runtime guard behind the ScopeKey type: callers outside the typechecker
|
||||||
// (yml-configured plugins, JS consumers) can still pass a primitive.
|
// (yml-configured plugins, JS consumers) can still pass a primitive.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
if (typeof key !== 'object' || key === null) {
|
if ((typeof key !== 'object' && typeof key !== 'function') || key === null) {
|
||||||
throw new TypeError('createScope: key must be an object (scope keys are identity-compared)')
|
throw new TypeError('createScope: key must be a non-null object or function (scope keys are identity-compared)')
|
||||||
}
|
}
|
||||||
const fiber = ctx.plugin(scope)
|
const fiber = ctx.plugin(scope)
|
||||||
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
|
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
|
||||||
|
let disposing: Promise<void> | undefined
|
||||||
return {
|
return {
|
||||||
ctx: scoped,
|
ctx: scoped,
|
||||||
// fiber.dispose IS the disposer Cordis pushed onto the minting fiber's
|
// fiber.dispose IS the disposer Cordis pushed onto the minting fiber's
|
||||||
// disposable list — the identity a composite effect must yield (see
|
// disposable list — the identity a composite effect must yield (see
|
||||||
// Scope.rawDispose).
|
// Scope.rawDispose).
|
||||||
rawDispose: fiber.dispose,
|
rawDispose: fiber.dispose,
|
||||||
// Promise.resolve-normalized: a cordis fiber's dispose returns undefined
|
// Memoize the public boundary and explicitly follow fiber inertia: the raw
|
||||||
// on a repeat call (the epoch is already cleared), and Scope.dispose
|
// disposer must remain the exact Cordis function for ordered composition,
|
||||||
// promises an awaitable on every call.
|
// so it cannot itself be wrapped to record a raw-first invocation.
|
||||||
dispose: () => Promise.resolve(fiber.dispose()),
|
dispose: () => (disposing ??= quiesceFiber(fiber)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,8 +303,10 @@ export interface ScopeHost {
|
|||||||
mint(key: ScopeKey): Scope
|
mint(key: ScopeKey): Scope
|
||||||
/**
|
/**
|
||||||
* Dispose the host fiber and with it every scope minted through it.
|
* Dispose the host fiber and with it every scope minted through it.
|
||||||
* @returns resolves when all collected disposers have settled (first call;
|
* Every racing/repeat caller observes the same completion, including when a
|
||||||
* a repeat call resolves immediately — single-shot, like Scope.dispose).
|
* child's raw disposer started before host disposal.
|
||||||
|
* @returns resolves when the host and every minted scope have reached
|
||||||
|
* quiescence.
|
||||||
*/
|
*/
|
||||||
dispose(): Promise<void>
|
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`)
|
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 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 {
|
return {
|
||||||
mint: (key: ScopeKey) => createScope(host, key),
|
mint: (key: ScopeKey) => {
|
||||||
dispose: () => Promise.resolve(fiber.dispose()),
|
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', () => {
|
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()
|
const ctx = new Context()
|
||||||
// Typed through `unknown` so the ScopeKey type cannot argue the assertion
|
// Typed through `unknown` so the ScopeKey type cannot argue the assertion
|
||||||
// away: this test exercises exactly the callers the typechecker misses.
|
// away: this test exercises exactly the callers the typechecker misses.
|
||||||
const badKeys: unknown[] = ['k', null]
|
const badKeys: unknown[] = ['k', null]
|
||||||
for (const bad of badKeys) {
|
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 () => {
|
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/)
|
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 () => {
|
it('rawDispose is the exact cordis disposer: yielding it nests the scope at its position', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
const order: string[] = []
|
const order: string[] = []
|
||||||
@@ -287,6 +315,52 @@ describe('scopeHost', () => {
|
|||||||
expect(() => scope.ctx.effect(() => () => {})).toThrow(/inactive context/)
|
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 () => {
|
it('fails LOUD naming absent services instead of resolving as a silent no-op host', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await expect(scopeHost(ctx, ['tools', 'systemPrompt']))
|
await expect(scopeHost(ctx, ['tools', 'systemPrompt']))
|
||||||
|
|||||||
@@ -538,8 +538,12 @@ export class SessionStore extends Service {
|
|||||||
const emitCtx = this.ctx
|
const emitCtx = this.ctx
|
||||||
session.onAppend = (event) => { emitCtx.emit(carrier, 'session/event', session, event) }
|
session.onAppend = (event) => { emitCtx.emit(carrier, 'session/event', session, event) }
|
||||||
this.store.set(session.id, session)
|
this.store.set(session.id, session)
|
||||||
|
let entered = true
|
||||||
return () => {
|
return () => {
|
||||||
|
if (!entered) return
|
||||||
|
entered = false
|
||||||
session.onAppend = undefined
|
session.onAppend = undefined
|
||||||
|
this.carriers.delete(session)
|
||||||
this.store.delete(session.id)
|
this.store.delete(session.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -549,7 +553,7 @@ export class SessionStore extends Service {
|
|||||||
* yield the detach disposer first (rollback safety — see {@link enter}).
|
* yield the detach disposer first (rollback safety — see {@link enter}).
|
||||||
* @param session - the entered session to announce to listeners. */
|
* @param session - the entered session to announce to listeners. */
|
||||||
announce(session: Session): void {
|
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.
|
* @returns resolves when every flush listener has settled; rejects if one rejects.
|
||||||
*/
|
*/
|
||||||
async flush(session: Session): Promise<void> {
|
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
|
/** Return the exact live session's carrier; detached/prepared objects reject. */
|
||||||
* never entered (defensive: dispatch stays filtered either way). */
|
private liveCarrierFor(session: Session): Scoped<Session> {
|
||||||
private carrierFor(session: Session): Scoped<Session> {
|
if (this.store.get(session.id) !== session) {
|
||||||
return this.carriers.get(session) ?? scopeTarget(session, undefined)
|
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')
|
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 ctx = await mount()
|
||||||
const scope = await mintScope(ctx, 'owner')
|
const scope = await mintScope(ctx, 'owner')
|
||||||
const flushed: string[] = []
|
const flushed: string[] = []
|
||||||
ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`))
|
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}`))
|
scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
|
||||||
|
|
||||||
const detached = ctx.sessions.prepare()
|
const prepared = ctx.sessions.prepare()
|
||||||
await ctx.sessions.flush(detached)
|
await expect(ctx.sessions.flush(prepared)).rejects.toThrow(/not live/)
|
||||||
expect(flushed).toEqual([`global:${detached.id}`])
|
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 () => {
|
it('keyOf sanity: distinct scopes carry distinct keys', async () => {
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ describe('SessionStore', () => {
|
|||||||
expect(created).toEqual([session])
|
expect(created).toEqual([session])
|
||||||
// The detach disposer removes the entry + stops notification.
|
// The detach disposer removes the entry + stops notification.
|
||||||
detach()
|
detach()
|
||||||
|
detach() // idempotent: cannot disturb a later same-id lifecycle
|
||||||
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
|
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* System prompt assembly registry. Plugins contribute ordered text sections,
|
* System prompt assembly registry. Plugins contribute ordered text sections,
|
||||||
* tool schema providers, and named prompt variables; `assemble(context)`
|
* tool schema providers, named prompt variables, and authoritative named
|
||||||
* collates them through a waterfall that runs once per step, and
|
* protections; `assemble(context)` collates them through a waterfall that
|
||||||
* `renderPrompt` interpolates `{{variable}}` references into the final text.
|
* 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
|
* The harness-owned prompt openers live here too: this plugin registers the
|
||||||
* static `harness:identity` section (order −100) and the deployment's
|
* 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>
|
'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
|
* A section, tool provider, variable provider, or protection was registered
|
||||||
* unregistered (the assembly inputs changed — possibly for one scope
|
* or unregistered (the assembly inputs changed — possibly for one scope
|
||||||
* only). An UNFILTERED registry-subject notification, deliberately not
|
* only). An UNFILTERED registry-subject notification, deliberately not
|
||||||
* scope-filtered dispatch: a global change concerns every agent's next
|
* scope-filtered dispatch: a global change concerns every agent's next
|
||||||
* assembly, so a scoped listener subscribing here sees every change, not
|
* assembly, so a scoped listener subscribing here sees every change, not
|
||||||
@@ -121,6 +122,27 @@ export interface ToolProviderResult {
|
|||||||
knownNames?: readonly string[]
|
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.
|
* 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))
|
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. */
|
/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */
|
||||||
function compareToolNames(a: ToolSchema, b: ToolSchema): number {
|
function compareToolNames(a: ToolSchema, b: ToolSchema): number {
|
||||||
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
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
|
* Registry service (`ctx.systemPrompt`): plugins contribute ordered text
|
||||||
* sections, tool-schema providers, and named prompt variables; the agent loop
|
* sections, tool-schema providers, named prompt variables, and authoritative
|
||||||
* calls `assemble(context)` once per step. Registers the harness-owned
|
* contribution protections; the agent loop calls `assemble(context)` once per
|
||||||
* `harness:identity` and `deployment:persona` sections itself (see
|
* step. Registers the harness-owned `harness:identity` and
|
||||||
* {@link Config.persona}).
|
* `deployment:persona` sections itself (see {@link Config.persona}).
|
||||||
*/
|
*/
|
||||||
export class SystemPrompt extends Service {
|
export class SystemPrompt extends Service {
|
||||||
static Config: z<Config> = z.object({
|
static Config: z<Config> = z.object({
|
||||||
@@ -347,10 +391,12 @@ export class SystemPrompt extends Service {
|
|||||||
private sections: PromptSection[] = []
|
private sections: PromptSection[] = []
|
||||||
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
|
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
|
||||||
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
|
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. */
|
/** 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 scopedSections = new Map<ScopeKey, PromptSection[]>()
|
||||||
private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
|
private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
|
||||||
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
|
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
|
||||||
|
private scopedProtections = new Map<ScopeKey, PromptProtection[]>()
|
||||||
private readonly toolOrder: string[] | undefined
|
private readonly toolOrder: string[] | undefined
|
||||||
|
|
||||||
constructor(ctx: Context, public config: Config) {
|
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 context (`agent.ctx`) contributes to that scope alone — and a
|
||||||
* scoped section SHADOWS a same-named global section for that scope's
|
* scoped section SHADOWS a same-named global section for that scope's
|
||||||
* assemblies (most-specific-wins; this is how a per-agent persona overrides
|
* 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
|
* duplicate would silently double prompt text — e.g. a double-loaded tool
|
||||||
* plugin; the global-duplicate message names `agent.ctx` as the per-agent
|
* plugin; the global-duplicate message names `agent.ctx` as the per-agent
|
||||||
* alternative). Removed when the calling fiber is disposed. Emits
|
* alternative). Removed when the calling fiber is disposed. Emits
|
||||||
@@ -395,6 +446,14 @@ export class SystemPrompt extends Service {
|
|||||||
*/
|
*/
|
||||||
section(section: PromptSection): () => Promise<void> | void {
|
section(section: PromptSection): () => Promise<void> | void {
|
||||||
const scope = scopeOf(this.ctx)
|
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 dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||||
const layer = scope === undefined
|
const layer = scope === undefined
|
||||||
? this.sections
|
? this.sections
|
||||||
@@ -403,18 +462,18 @@ export class SystemPrompt extends Service {
|
|||||||
this.scopedSections.set(scope, created)
|
this.scopedSections.set(scope, created)
|
||||||
return created
|
return created
|
||||||
})()
|
})()
|
||||||
if (layer.some(existing => existing.name === section.name)) {
|
if (layer.some(existing => existing.name === snapshot.name)) {
|
||||||
throw new Error(scope === undefined
|
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 "${snapshot.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 in this scope`)
|
||||||
}
|
}
|
||||||
layer.push(section)
|
layer.push(snapshot)
|
||||||
// Yield the rollback BEFORE emitting `system-prompt/change`: a generator
|
// Yield the rollback BEFORE emitting `system-prompt/change`: a generator
|
||||||
// effect collects each yielded disposer before the next step runs, so a
|
// effect collects each yielded disposer before the next step runs, so a
|
||||||
// throwing change listener removes the section instead of leaking it into
|
// throwing change listener removes the section instead of leaking it into
|
||||||
// every future assembly.
|
// every future assembly.
|
||||||
yield () => {
|
yield () => {
|
||||||
const index = layer.indexOf(section)
|
const index = layer.indexOf(snapshot)
|
||||||
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
|
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
|
||||||
if (index >= 0) layer.splice(index, 1)
|
if (index >= 0) layer.splice(index, 1)
|
||||||
if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope)
|
if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope)
|
||||||
@@ -531,6 +590,73 @@ export class SystemPrompt extends Service {
|
|||||||
return dispose
|
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
|
* Assemble the current prompt for one caller: the global layer merged with
|
||||||
* {@link AssembleContext.scope}'s layer (scoped sections/variables SHADOW
|
* {@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
|
* Tool schemas are deep-cloned because adapters and request waterfalls may
|
||||||
* mutate schema objects. Runs through the `system-prompt/assemble`
|
* mutate schema objects. Runs through the `system-prompt/assemble`
|
||||||
* waterfall, giving listeners the opportunity to mutate or replace the
|
* waterfall, giving listeners the opportunity to mutate or replace the
|
||||||
* assembly before it reaches the model — like the sections' `order` sort,
|
* assembly, then restores every visible {@link PromptProtection} from the
|
||||||
* tool canonicalization happens on the initial assembly, and a listener
|
* pre-waterfall canonical assembly. Like the sections' `order` sort, tool
|
||||||
* owns the determinism of whatever it emits. Await the result before
|
* canonicalization happens on the initial assembly; unprotected listener
|
||||||
* reading the assembly values — waterfall listeners may be async.
|
* output owns its own determinism. Await the result before reading the
|
||||||
|
* assembly values — waterfall listeners may be async.
|
||||||
* Interpolation happens later, in {@link renderPrompt}.
|
* Interpolation happens later, in {@link renderPrompt}.
|
||||||
* @param context - what this assembly is for (defaults to an empty context;
|
* @param context - what this assembly is for (defaults to an empty context;
|
||||||
* see {@link AssembleContext}).
|
* see {@link AssembleContext}).
|
||||||
@@ -560,6 +687,10 @@ export class SystemPrompt extends Service {
|
|||||||
// (`assemble().catch(...)` would miss it).
|
// (`assemble().catch(...)` would miss it).
|
||||||
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
|
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
|
||||||
const scope = context.scope
|
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
|
// Variables: global layer first, then the scope's layer OVERWRITES
|
||||||
// same-named entries (shadowing — a per-agent value wins for that agent).
|
// same-named entries (shadowing — a per-agent value wins for that agent).
|
||||||
const variables: Record<string, string | undefined> = {}
|
const variables: Record<string, string | undefined> = {}
|
||||||
@@ -611,7 +742,27 @@ export class SystemPrompt extends Service {
|
|||||||
tools: orderTools(collected, this.toolOrder, knownNames),
|
tools: orderTools(collected, this.toolOrder, knownNames),
|
||||||
variables,
|
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' })
|
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/)
|
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', () => {
|
describe('scoped variables', () => {
|
||||||
@@ -148,4 +163,31 @@ describe('scoped assemble dispatch', () => {
|
|||||||
expect(global.sections.some(s => s.name === 'listener:extra')).toBe(false)
|
expect(global.sections.some(s => s.name === 'listener:extra')).toBe(false)
|
||||||
expect(shaped).toHaveLength(1)
|
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)
|
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 () => {
|
it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SystemPrompt)
|
await ctx.plugin(SystemPrompt)
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Code Mode: the `run_code` tool and its dispatch bridge. The model writes a
|
* 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
|
* TypeScript program; the bridge hands it to `ctx.codeRuntime` with one async
|
||||||
* async binding per registered tool, serializes every binding call through a
|
* binding per end capability visible to the calling agent, then serializes
|
||||||
* per-run queue onto `ToolRegistry.execute()` (so `tools/pre-execute` /
|
* every binding call through a per-run queue onto `ToolRegistry.execute()`.
|
||||||
* `tools/post-execute` gate sub-calls exactly like native ones), logs each
|
* Sub-calls therefore traverse the complete pre/guard/around/post/final-result
|
||||||
* sub-dispatch as a `tool/code-dispatch` session event, and returns only the
|
* pipeline exactly like native calls and carry the outer execution's opaque
|
||||||
* program's curated output. The registry itself decides WHEN this tool
|
* token for correlation. The bridge logs each sub-dispatch as a
|
||||||
* exists (its `mode` config); this module owns only the tool and the bridge.
|
* `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
|
* @module @deepseek-ai/dsh-tools/src/code-mode
|
||||||
*/
|
*/
|
||||||
@@ -205,6 +207,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
|||||||
name,
|
name,
|
||||||
arguments: normalized.dispatched,
|
arguments: normalized.dispatched,
|
||||||
...exec.agent ? { agent: exec.agent } : {},
|
...exec.agent ? { agent: exec.agent } : {},
|
||||||
|
parent: exec.token,
|
||||||
signal: runController.signal,
|
signal: runController.signal,
|
||||||
})
|
})
|
||||||
const text = textOf(result.content)
|
const text = textOf(result.content)
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Tool registry and execution pipeline. Plugins register tools; the registry
|
* Tool registry and execution pipeline. Plugins register tools; the registry
|
||||||
* feeds schemas into the system prompt, and `execute()` dispatches each call
|
* feeds schemas into the system prompt, and `execute()` dispatches each call
|
||||||
* through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an
|
* through `tools/pre-execute` (the extensible allow/deny gate) → monotonic
|
||||||
* around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute`
|
* registered guards → `tools/execute` (an around-dispatch wrapper for
|
||||||
* (inspect/replace the result, attach context) for sandbox, permission, and hook
|
* timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the
|
||||||
* plugins to gate or transform a call.
|
* result, attach context) → the observe-only `tools/result` notification.
|
||||||
*
|
*
|
||||||
* The registry also owns HOW its tools are presented to the model — its
|
* The registry also owns HOW its tools are presented to the model — its
|
||||||
* `mode` config: `'native'` (every tool as a wire function definition,
|
* `mode` config: `'native'` (every tool as a wire function definition,
|
||||||
* today's behavior and the default), `'code'` (the wire carries exactly one
|
* today's behavior and the default), `'code'` (the registry's canonical wire
|
||||||
* tool, `run_code`, plus a generated TypeScript SDK prompt section), or
|
* contribution is one tool, `run_code`, plus a generated TypeScript SDK prompt section), or
|
||||||
* `'both'`. See `code-mode.ts` (the tool + dispatch bridge) and
|
* `'both'`. See `code-mode.ts` (the tool + dispatch bridge) and
|
||||||
* `ts-types.ts` (the SDK codegen); design in the Code Mode RFC.
|
* `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 { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||||
import type { ScopeKey, Scoped } 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 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 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 { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
|
||||||
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||||
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
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
|
* 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
|
* 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
|
* 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
|
* set or replace the one mutable field, `exec.signal` (e.g. with a per-call
|
||||||
* `next()` and inspect the result AFTER. (Cordis `next()` ignores any passed
|
* deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity
|
||||||
* arguments and re-invokes downstream with the shared payload, so a wrapper
|
* (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the
|
||||||
* mutates `exec` in place rather than passing a new object to `next()`.)
|
* 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
|
* Multiple listeners compose by registration order — an outer one wraps the
|
||||||
* inner ones plus dispatch.
|
* inner ones plus dispatch.
|
||||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
|
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
|
||||||
@@ -139,6 +143,21 @@ declare module 'cordis' {
|
|||||||
* @mode waterfall
|
* @mode waterfall
|
||||||
*/
|
*/
|
||||||
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
'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
|
* A tool was registered or unregistered, or a scoped restriction changed
|
||||||
* (the available tool set changed — possibly for one scope only). An
|
* (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
|
// TODO(review): revisit these shapes when concurrency metadata becomes useful
|
||||||
// sandbox/permission plugins land (e.g. a concurrency-safety hint for
|
// (for example, a read-only hint that would permit safe parallel execution).
|
||||||
// parallel execution — Claude Code partitions read-only tools; phase 1
|
|
||||||
// executes sequentially).
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the
|
* What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the
|
||||||
@@ -214,17 +231,54 @@ export interface ToolResult {
|
|||||||
meta?: unknown
|
meta?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One pending tool call, as it flows through the execution pipeline (`tools/pre-execute` → dispatch → `tools/post-execute`). */
|
declare const toolExecutionTokenBrand: unique symbol
|
||||||
export interface ToolExecution {
|
|
||||||
callId: CallId
|
/** Tokens minted in this module; a cast or JavaScript object cannot forge membership. */
|
||||||
name: string
|
const executionTokens = new WeakSet<object>()
|
||||||
/** Parsed JSON arguments (unknown — tools validate their own input). */
|
|
||||||
arguments: unknown
|
/**
|
||||||
|
* 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). */
|
/** 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
|
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). */
|
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
|
||||||
export interface ToolErrorInfo {
|
export interface ToolErrorInfo {
|
||||||
name: string
|
name: string
|
||||||
@@ -255,7 +309,6 @@ export interface ToolExecutionResult {
|
|||||||
* text in `content` is always present; this is extra structure for code.
|
* text in `content` is always present; this is extra structure for code.
|
||||||
*/
|
*/
|
||||||
error?: ToolErrorInfo
|
error?: ToolErrorInfo
|
||||||
/**
|
|
||||||
/**
|
/**
|
||||||
* Extra model-facing context a `tools/post-execute` listener attached for the
|
* Extra model-facing context a `tools/post-execute` listener attached for the
|
||||||
* NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part
|
* NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part
|
||||||
@@ -318,17 +371,28 @@ export type PostToolDecision =
|
|||||||
* is stringified.
|
* is stringified.
|
||||||
*/
|
*/
|
||||||
function errorMessage(error: unknown): string {
|
function errorMessage(error: unknown): string {
|
||||||
if (error instanceof Error) return error.message
|
try {
|
||||||
if (typeof error === 'object' && error !== null
|
if (error instanceof Error) return error.message
|
||||||
&& 'message' in error && typeof error.message === 'string') {
|
if (typeof error === 'object' && error !== null
|
||||||
return error.message
|
&& '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. */
|
/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
|
||||||
function errorInfo(error: unknown): ToolErrorInfo | 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}). */
|
/** 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 {
|
export interface Config {
|
||||||
/**
|
/**
|
||||||
* The presentation mode. `'native'` (the default) contributes every
|
* The presentation mode. `'native'` (the default) contributes every
|
||||||
* registered tool as a wire function definition — byte-for-byte today's
|
* visible end capability as a native wire function definition. Under
|
||||||
* behavior. `'code'` contributes exactly ONE wire tool, `run_code`, plus
|
* `'code'` this registry contributes exactly ONE wire tool,
|
||||||
* the generated `tools:sdk` prompt section declaring every other tool as a
|
* `run_code`, plus the generated `tools:sdk` prompt section declaring every other tool as a
|
||||||
* TypeScript API the program calls. `'both'` contributes every native
|
* TypeScript API the program calls. `'both'` contributes every native
|
||||||
* definition AND `run_code` + the SDK section. Non-native modes require a
|
* definition AND `run_code` + the SDK section. Non-native modes require a
|
||||||
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
|
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
|
||||||
@@ -371,11 +435,27 @@ export interface ToolRestriction {
|
|||||||
deny?: string[]
|
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
|
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
|
||||||
* loop executes calls through the `tools/pre-execute` → `tools/execute` →
|
* loop executes calls through the `tools/pre-execute` → guards →
|
||||||
* `tools/post-execute` pipeline. The registry contributes its schemas into the
|
* `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The
|
||||||
* system-prompt assembly — WHICH schemas is governed by its `mode` config
|
* 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
|
* (see {@link Config.mode}); under a non-native mode it also owns the reserved
|
||||||
* `run_code` presentation transport and the `tools:sdk` prompt section.
|
* `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>>()
|
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
|
||||||
/** Snapshot-at-registration restriction filters, per scope (see {@link restrict}). */
|
/** Snapshot-at-registration restriction filters, per scope (see {@link restrict}). */
|
||||||
private restrictions = new Map<ScopeKey, ToolRestriction[]>()
|
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
|
private readonly mode: ToolPresentationMode
|
||||||
/** Reserved presentation transport, kept outside the filterable registration layers. */
|
/** Reserved presentation transport, kept outside the filterable registration layers. */
|
||||||
private readonly codeTransport: ToolDefinition | undefined
|
private readonly codeTransport: ToolDefinition | undefined
|
||||||
@@ -418,7 +501,7 @@ export class ToolRegistry extends Service {
|
|||||||
// the filterable global/scoped capability layers.
|
// the filterable global/scoped capability layers.
|
||||||
this.codeTransport = this.mode === 'native'
|
this.codeTransport = this.mode === 'native'
|
||||||
? undefined
|
? undefined
|
||||||
: createRunCodeTool(this, () => this.requireCodeRuntime())
|
: deepFreeze(createRunCodeTool(this, () => this.requireCodeRuntime()))
|
||||||
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
|
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
|
||||||
if (this.mode !== 'native') {
|
if (this.mode !== 'native') {
|
||||||
ctx.systemPrompt.section({
|
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))
|
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
|
* 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
|
* `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
|
* the `run_code` name for its presentation transport. The visible schema set
|
||||||
* flows into prompt assembly automatically. Disposed with the calling
|
* flows into prompt assembly automatically. Registration validates and
|
||||||
* fiber. Emits `tools/change` on register/unregister.
|
* 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
|
* @param definition - the tool's schema plus its execute (and optional
|
||||||
* presentation) functions.
|
* presentation) functions.
|
||||||
* @returns the disposer that unregisters the tool. The exact
|
* @returns the disposer that unregisters the tool. The exact
|
||||||
@@ -505,24 +597,52 @@ export class ToolRegistry extends Service {
|
|||||||
*/
|
*/
|
||||||
register(definition: ToolDefinition): () => Promise<void> | void {
|
register(definition: ToolDefinition): () => Promise<void> | void {
|
||||||
const scope = scopeOf(this.ctx)
|
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`)
|
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 dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||||
const layer = scope === undefined ? this.global : this.layerFor(scope)
|
const layer = scope === undefined ? this.global : this.layerFor(scope)
|
||||||
if (layer.has(definition.name)) {
|
if (layer.has(snapshot.name)) {
|
||||||
throw new Error(scope === undefined
|
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 "${snapshot.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 in this scope`)
|
||||||
}
|
}
|
||||||
layer.set(definition.name, definition)
|
layer.set(snapshot.name, snapshot)
|
||||||
// Yield the rollback BEFORE emitting `tools/change`: a generator effect
|
// Yield the rollback BEFORE emitting `tools/change`: a generator effect
|
||||||
// collects each yielded disposer before the next step runs, so a throwing
|
// collects each yielded disposer before the next step runs, so a throwing
|
||||||
// `tools/change` listener removes the tool instead of leaking it (a leak
|
// `tools/change` listener removes the tool instead of leaking it (a leak
|
||||||
// would wedge the duplicate-name check until restart). The duplicate
|
// would wedge the duplicate-name check until restart). The duplicate
|
||||||
// throw above fires before any mutation — it leaks nothing.
|
// throw above fires before any mutation — it leaks nothing.
|
||||||
yield () => {
|
yield () => {
|
||||||
layer.delete(definition.name)
|
layer.delete(snapshot.name)
|
||||||
// An emptied scope layer is dropped so a disposed scope leaves no
|
// An emptied scope layer is dropped so a disposed scope leaves no
|
||||||
// residue keyed by its (dead) key.
|
// residue keyed by its (dead) key.
|
||||||
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
|
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
|
||||||
@@ -604,6 +724,30 @@ export class ToolRegistry extends Service {
|
|||||||
return dispose
|
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`. */
|
/** The (created-on-demand) scoped layer for `scope`. */
|
||||||
private layerFor(scope: ScopeKey): Map<string, ToolDefinition> {
|
private layerFor(scope: ScopeKey): Map<string, ToolDefinition> {
|
||||||
let layer = this.scoped.get(scope)
|
let layer = this.scoped.get(scope)
|
||||||
@@ -614,6 +758,43 @@ export class ToolRegistry extends Service {
|
|||||||
return layer
|
return layer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get or create the guard layer for one agent scope. */
|
||||||
|
private guardLayerFor(scope: ScopeKey): Set<ToolGuardRegistration> {
|
||||||
|
let layer = this.scopedGuards.get(scope)
|
||||||
|
if (layer === undefined) {
|
||||||
|
layer = new Set()
|
||||||
|
this.scopedGuards.set(scope, layer)
|
||||||
|
}
|
||||||
|
return layer
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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). */
|
/** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */
|
||||||
private admits(scope: ScopeKey | undefined, name: string): boolean {
|
private admits(scope: ScopeKey | undefined, name: string): boolean {
|
||||||
if (scope === undefined) return true
|
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`
|
* Execute one tool call through the `tools/pre-execute` → guards →
|
||||||
* (around dispatch) → `tools/post-execute` pipeline. `pre-execute` is the gate
|
* `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
|
* (allow/deny), `tools/execute` wraps core dispatch (a timeout/retry/metrics
|
||||||
* seam), and `post-execute` is the inspect/transform seam; core dispatch sits
|
* 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
|
* 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
|
* tool is not registered (or not visible to the calling agent — a
|
||||||
* restricted-away global is exactly as absent as a nonexistent one), the
|
* restricted-away global is exactly as absent as a nonexistent one), the
|
||||||
* result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown
|
* result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown
|
||||||
* {@link HarnessError} surfaces its `{ name, code }` on the result.
|
* {@link HarnessError} surfaces its `{ name, code }` on the result. Before
|
||||||
* @param exec - the call to run (name, parsed arguments, caller agent, signal).
|
* 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
|
* @returns the final result after every waterfall; failures resolve as
|
||||||
* `isError` results, never rejections.
|
* `isError` results, never rejections.
|
||||||
*/
|
*/
|
||||||
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
|
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
|
||||||
|
let execution: ToolExecution
|
||||||
try {
|
try {
|
||||||
// --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
|
execution = this.prepareExecution(exec)
|
||||||
// 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)
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Outer backstop: a throwing pre/post-execute listener (or the waterfall
|
// Contract-violating non-JSON or non-cloneable arguments cannot enter a
|
||||||
// machinery) becomes an isError result, never a turn failure.
|
// pipeline whose logged and executed forms must agree. Still publish one
|
||||||
return toolErrorResult(exec.callId, error)
|
// 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
|
// authoritative-call-id requirement and the "preserve the dispatched
|
||||||
// isError/error" contract. The decision is the ONLY sanctioned channel for a
|
// isError/error" contract. The decision is the ONLY sanctioned channel for a
|
||||||
// listener to change the outcome (block, or accept-with-replacement); the
|
// listener to change the outcome (block, or accept-with-replacement); the
|
||||||
// call id is always the authoritative `exec.callId`. `content` is copied into
|
// call id is always the authoritative `exec.callId`. Deep cloning protects
|
||||||
// a fresh array so a listener's in-place `push`/`splice` on `result.content`
|
// nested content, error, and meta data from in-place listener mutation.
|
||||||
// cannot leak into the returned content either (the elements are the same
|
const dispatched = this.snapshotExecutionResult(exec, result)
|
||||||
// references — the snapshot guards the array structure, not deep immutability).
|
const decision = structuredClone(await this.ctx.waterfall(
|
||||||
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(
|
|
||||||
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
|
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
|
||||||
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
|
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
|
||||||
)
|
))
|
||||||
|
this.assertPostDecision(decision)
|
||||||
const additionalContext = decision.additionalContext
|
const additionalContext = decision.additionalContext
|
||||||
if (decision.kind === 'block') {
|
if (decision.kind === 'block') {
|
||||||
return {
|
return {
|
||||||
@@ -835,6 +1113,74 @@ export class ToolRegistry extends Service {
|
|||||||
...additionalContext ? { additionalContext } : {},
|
...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 {
|
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:')
|
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 () => {
|
it("mode 'both' contributes every native schema plus run_code, and the SDK section", async () => {
|
||||||
const { ctx, systemPrompt } = await setup({ mode: 'both' })
|
const { ctx, systemPrompt } = await setup({ mode: 'both' })
|
||||||
registerEcho(ctx)
|
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(() => 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(() => 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({ 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/)
|
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 assembly = await systemPrompt.assemble({ scope: agent })
|
||||||
const transports = assembly.tools.filter(tool => tool.name === RUN_CODE_NAME)
|
const transports = assembly.tools.filter(tool => tool.name === RUN_CODE_NAME)
|
||||||
expect(transports).toHaveLength(1)
|
expect(transports).toHaveLength(1)
|
||||||
expect(transports[0]?.description).toContain('Execute a TypeScript program')
|
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.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME))
|
||||||
expect(ctx.tools.knownNames(agent)).not.toContain(RUN_CODE_NAME)
|
expect(ctx.tools.knownNames(agent)).not.toContain(RUN_CODE_NAME)
|
||||||
const result = await runCode(ctx, 'return 1', { agent })
|
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 })
|
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 () => {
|
it('serializes Promise.all dispatches: tool executions never overlap, in submission order', async () => {
|
||||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||||
const intervals: [string, string][] = []
|
const intervals: [string, string][] = []
|
||||||
@@ -639,16 +713,17 @@ describe('the run_code dispatch bridge', () => {
|
|||||||
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
|
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 { ctx, runtime } = await setup({ mode: 'code' })
|
||||||
const { agent, events } = fakeAgent()
|
const { agent, events } = fakeAgent()
|
||||||
|
let mutationSucceeded: boolean | undefined
|
||||||
ctx.tools.register(defineTool({
|
ctx.tools.register(defineTool({
|
||||||
name: 'mutator',
|
name: 'mutator',
|
||||||
description: 'Mutates its own args object.',
|
description: 'Attempts to mutate its args object.',
|
||||||
parameters: { list: { type: 'array', required: true } },
|
parameters: { list: { type: 'array', required: true } },
|
||||||
execute(args) {
|
execute(args) {
|
||||||
args.list.push('injected-by-tool')
|
mutationSucceeded = Reflect.set(args.list, 1, 'injected-by-tool')
|
||||||
return Promise.resolve([{ type: 'text' as const, text: 'mutated' }])
|
return Promise.resolve([{ type: 'text' as const, text: 'protected' }])
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
runtime.behavior = async (request) => {
|
runtime.behavior = async (request) => {
|
||||||
@@ -657,6 +732,7 @@ describe('the run_code dispatch bridge', () => {
|
|||||||
}
|
}
|
||||||
const result = await runCode(ctx, 'program', { agent })
|
const result = await runCode(ctx, 'program', { agent })
|
||||||
expect(result.isError).toBe(false)
|
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']
|
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
|
||||||
expect(dispatch.arguments).toEqual({ list: ['original'] })
|
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 { Context } from 'cordis'
|
||||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||||
import type { ContentBlock } 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({})).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({ 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"/)
|
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(await run(ctx, 't')).toBe('ran:t')
|
||||||
expect(seen).toEqual(['a'])
|
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, {
|
import ToolRegistry, {
|
||||||
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
|
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
|
||||||
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
|
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
|
||||||
type ToolExecution, type ToolExecutionResult,
|
type ToolExecution, type ToolExecutionResult, type ToolGuard,
|
||||||
} from '@deepseek-ai/dsh-tools'
|
} from '@deepseek-ai/dsh-tools'
|
||||||
|
|
||||||
async function setup() {
|
async function setup() {
|
||||||
@@ -113,6 +113,53 @@ describe('ToolRegistry', () => {
|
|||||||
expect('meta' in result).toBe(false)
|
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 () => {
|
it('returns isError results for unknown tools and throwing tools', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
ctx.tools.register({
|
ctx.tools.register({
|
||||||
@@ -134,6 +181,28 @@ describe('ToolRegistry', () => {
|
|||||||
expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' })
|
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 () => {
|
it('ToolNotFoundError carries the tool name and a stable code', async () => {
|
||||||
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
|
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||||
const err = new ToolNotFoundError('ghost')
|
const err = new ToolNotFoundError('ghost')
|
||||||
@@ -158,6 +227,25 @@ describe('ToolRegistry', () => {
|
|||||||
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
|
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 () => {
|
it('an ask decision degrades to deny until the permission system lands', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
ctx.tools.register(echoTool)
|
ctx.tools.register(echoTool)
|
||||||
@@ -233,7 +321,7 @@ describe('ToolRegistry', () => {
|
|||||||
expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
|
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
|
// The decision is the ONLY sanctioned channel to change the outcome. A
|
||||||
// listener that reaches in and mutates the passed result reference (flipping
|
// listener that reaches in and mutates the passed result reference (flipping
|
||||||
// isError, rewriting callId, attaching a bogus error) must NOT affect what
|
// 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.
|
// the waterfall and rebuilds from the snapshot + decision.
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
ctx.tools.register(echoTool)
|
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) => {
|
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.callId = 'hijacked'
|
||||||
mutable.isError = true
|
mutable.isError = false
|
||||||
mutable.error = { name: 'Evil', code: 'EVIL' }
|
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
|
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
|
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' } })
|
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.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked'
|
||||||
expect(result.isError).toBe(false) // the real (successful) dispatch outcome
|
expect(result.isError).toBe(true)
|
||||||
expect(result.error).toBeUndefined() // no listener-injected error
|
expect(result.error).toEqual({ name: 'OriginalError', code: 'ORIGINAL' })
|
||||||
expect(result.content).toHaveLength(1) // the in-place push did not leak in
|
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.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 () => {
|
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' })
|
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 () => {
|
it('returns an isError result when a tools/execute listener throws', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
ctx.tools.register(echoTool)
|
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 () => {
|
it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
ctx.tools.register(echoTool)
|
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-'))
|
const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-session-'))
|
||||||
try {
|
try {
|
||||||
ctx = await fsHarness(configDir, SYSTEM)
|
ctx = await fsHarness(configDir, SYSTEM)
|
||||||
const handle = ctx.agents.create({
|
const handle = await ctx.agents.create({
|
||||||
agentId: AgentId('fs-e2e-cwd'),
|
agentId: AgentId('fs-e2e-cwd'),
|
||||||
sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`),
|
sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`),
|
||||||
meta: { cwd: sessionDir },
|
meta: { cwd: sessionDir },
|
||||||
|
|||||||
@@ -164,11 +164,10 @@ describe('read tool', () => {
|
|||||||
expect(text(result)).toContain('offset must be a positive integer')
|
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()
|
const { ctx } = await setup()
|
||||||
for (const args of [
|
for (const args of [
|
||||||
{ file_path: 'a.txt', offset: 1.5 },
|
{ 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: 0 },
|
||||||
{ file_path: 'a.txt', limit: -3 },
|
{ 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 () => {
|
it('rejects a limit above the cap', async () => {
|
||||||
const { ctx } = await setup()
|
const { ctx } = await setup()
|
||||||
const result = await call(ctx, 'read', { file_path: 'a.txt', limit: 99999 })
|
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
|
const ctx = await harness(path, adapter) // NB: no projectDir
|
||||||
// The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
|
// The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
|
||||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
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' }])
|
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
||||||
expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message'
|
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' }] } }))
|
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 { 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' }])
|
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
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.
|
// Register a live child on its own session cwd; emit subagent/end with its id.
|
||||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
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' })
|
ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' })
|
||||||
|
|
||||||
await waitFor(() => existsSync(marker))
|
await waitFor(() => existsSync(marker))
|
||||||
|
|||||||
@@ -553,7 +553,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
|||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
ctx.llm.registerAdapter(['mock'], adapter)
|
||||||
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
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 { 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' }])
|
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||||
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
||||||
expect(existsSync(marker)).toBe(true)
|
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`
|
// per-run structured runtime gates its capture-tool registration on `tools`
|
||||||
// itself, so this backend's apply timing (and the delegation tool's position
|
// itself, so this backend's apply timing (and the delegation tool's position
|
||||||
// in the model-visible tool list) is unchanged by structured output.
|
// 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. */
|
/** Config: the registry name to register the provider under. */
|
||||||
export interface Config {
|
export interface Config {
|
||||||
@@ -77,7 +77,6 @@ class ForkProvider implements SubagentProvider {
|
|||||||
start(request: SubagentStartRequest) {
|
start(request: SubagentStartRequest) {
|
||||||
const seed = completedTurnPrefix(request.parent)
|
const seed = completedTurnPrefix(request.parent)
|
||||||
return startInProcessRun(this.ctx, request, {
|
return startInProcessRun(this.ctx, request, {
|
||||||
providerName: this.name,
|
|
||||||
// Only pass a seed when there's a completed turn to inherit; an empty seed
|
// 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.
|
// is equivalent to a fresh child, so omit it to keep the session unseeded.
|
||||||
...seed.length > 0 ? { seed } : {},
|
...seed.length > 0 ? { seed } : {},
|
||||||
|
|||||||
@@ -200,12 +200,12 @@ describe('dsh-subagent-fork', () => {
|
|||||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||||
expect('default' in fork).toBe(false)
|
expect('default' in fork).toBe(false)
|
||||||
expect(fork.name).toBe('subagent-fork')
|
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 loader = Object.create(Loader.prototype) as Loader
|
||||||
const unwrapped = loader.unwrapExports(fork) as Record<string, unknown>
|
const unwrapped = loader.unwrapExports(fork) as Record<string, unknown>
|
||||||
expect(unwrapped).toBe(fork)
|
expect(unwrapped).toBe(fork)
|
||||||
expect(unwrapped.name).toBe('subagent-fork')
|
expect(unwrapped.name).toBe('subagent-fork')
|
||||||
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
|
expect(unwrapped.inject).toEqual(['subagents'])
|
||||||
expect(typeof unwrapped.apply).toBe('function')
|
expect(typeof unwrapped.apply).toBe('function')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,16 +7,18 @@
|
|||||||
* a prefix of the parent's log); everything downstream — drive the child, read
|
* 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.
|
* 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
|
* This package declares no provider and performs no import-time registration;
|
||||||
* backend packages depend on, so neither backend needs to know about the other.
|
* 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
|
* @module @deepseek-ai/dsh-subagent-inprocess
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto'
|
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 { 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 type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
|
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||||
import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
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}. */
|
/** Extra inputs the spawn/fork backends supply to {@link startInProcessRun}. */
|
||||||
export interface InProcessRunOptions {
|
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
|
* The child session's seed: a balanced, contiguous-from-0 prefix of the
|
||||||
* parent's log (FORK), or `undefined` for a fresh child (SPAWN).
|
* parent's log (FORK), or `undefined` for a fresh child (SPAWN).
|
||||||
@@ -95,6 +95,12 @@ export interface InProcessRunOptions {
|
|||||||
readonly seed?: SessionEvent[]
|
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}.
|
* 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
|
* Throws {@link SubagentDepthError} before creating anything when the child's
|
||||||
* depth (parent depth + 1) would exceed `request.maxDepth`.
|
* 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 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.
|
* @returns the live run handle for the child agent.
|
||||||
*/
|
*/
|
||||||
export function startInProcessRun(
|
export function startInProcessRun(
|
||||||
@@ -118,7 +125,15 @@ export function startInProcessRun(
|
|||||||
request: SubagentStartRequest,
|
request: SubagentStartRequest,
|
||||||
options: InProcessRunOptions,
|
options: InProcessRunOptions,
|
||||||
): SubagentRun {
|
): 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) {
|
if (request.maxDepth !== undefined && childDepth > request.maxDepth) {
|
||||||
throw new SubagentDepthError(childDepth, request.maxDepth)
|
throw new SubagentDepthError(childDepth, request.maxDepth)
|
||||||
}
|
}
|
||||||
@@ -134,6 +149,17 @@ export function startInProcessRun(
|
|||||||
// validateStructuredValue to one isolation-immutable value.
|
// validateStructuredValue to one isolation-immutable value.
|
||||||
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
|
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
|
||||||
const schema = request.outputSchema === undefined ? undefined : structuredClone(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())
|
const childId = AgentId(randomUUID())
|
||||||
// The child's OWN events begin after the seed (fork seeds the parent's
|
// 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
|
// boundary so a child that produces no message of its own never returns the
|
||||||
// SEEDED parent's last assistant message as its result.
|
// SEEDED parent's last assistant message as its result.
|
||||||
const seedLength = options.seed?.length ?? 0
|
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);
|
// Inherit the parent's model by default (a child with no model cannot run);
|
||||||
// an explicit `request.agentOptions.model` overrides it. The deployment
|
// an explicit `request.agentOptions.model` overrides it. The deployment
|
||||||
// persona needs no inheritance (a context-wide section both render); a
|
// persona needs no inheritance (a context-wide section both render); a
|
||||||
// per-child `request.persona` becomes a SCOPED section of the same name in
|
// per-child `request.persona` becomes a SCOPED section of the same name in
|
||||||
// the setup below, shadowing the deployment's for this child alone.
|
// the setup below, shadowing the deployment's for this child alone.
|
||||||
const agentOptions: AgentOptions = {
|
const agentOptions: AgentOptions = structuredClone({
|
||||||
...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {},
|
...parent.options.model !== undefined ? { model: parent.options.model } : {},
|
||||||
...request.agentOptions,
|
...request.agentOptions,
|
||||||
subagentDepth: childDepth,
|
subagentDepth: childDepth,
|
||||||
}
|
})
|
||||||
|
|
||||||
// The child's scoped world, composed in the factory's setup window (after
|
// The child's scoped world, composed in the factory's unpublished setup
|
||||||
// the child's scope exists and it is registered, before agent/session-start
|
// window. The factory awaits it before inserting or announcing the child, so
|
||||||
// and the first prompt assembly; a throw here unwinds the half-created
|
// a throw/rejection exposes neither id and every first assembly sees it:
|
||||||
// child inside the factory's rollback boundary):
|
|
||||||
// - persona: a scoped `deployment:persona` section shadowing the global one;
|
// - persona: a scoped `deployment:persona` section shadowing the global one;
|
||||||
// - toolFilter: a scoped restrict() masking the global tool surface
|
// - toolFilter: a scoped restrict() masking the global tool surface
|
||||||
// (loud unknown-name validation lives in the registry);
|
// (loud unknown-name validation lives in the registry);
|
||||||
// - outputSchema: the structured runtime, attached as scoped registrations.
|
// - outputSchema: the structured runtime, attached as scoped registrations.
|
||||||
let structured: StructuredAttachment | undefined
|
let structured: StructuredAttachment | undefined
|
||||||
const setup = (childCtx: Context): void => {
|
const setup = (childCtx: Context): void => {
|
||||||
if (request.persona !== undefined) {
|
if (persona !== undefined) {
|
||||||
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: request.persona })
|
childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: persona })
|
||||||
}
|
}
|
||||||
if (request.toolFilter !== undefined) {
|
if (toolFilter !== undefined) {
|
||||||
childCtx.tools.restrict(request.toolFilter)
|
childCtx.tools.restrict(toolFilter)
|
||||||
}
|
}
|
||||||
if (schema !== undefined) {
|
if (schema !== undefined) {
|
||||||
structured = attachStructuredRuntime(childCtx, schema)
|
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
|
// 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).
|
// 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
|
// `cancelled` records that a cancel was requested at all, so the pre-turn
|
||||||
// cancel window — where the child clears the queued prompt before any
|
// cancel window — where the child clears the queued prompt before any
|
||||||
// `turn/end` is logged — settles as `aborted` (honoring the cancel contract)
|
// `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
|
// abort listener, run.cancel), which control-flow narrowing cannot see — an
|
||||||
// inline read at the result mapping would narrow to the initializer.
|
// inline read at the result mapping would narrow to the initializer.
|
||||||
const isCancelled = (): boolean => cancelled
|
const isCancelled = (): boolean => cancelled
|
||||||
|
let child: Agent | undefined
|
||||||
|
let handle: AgentHandle | undefined
|
||||||
|
let disposeRequested = false
|
||||||
|
const isDisposeRequested = (): boolean => disposeRequested
|
||||||
const requestCancel = (reason: string): void => {
|
const requestCancel = (reason: string): void => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
child.cancel(reason)
|
child?.cancel(reason)
|
||||||
}
|
}
|
||||||
const onAbort = (): void => { requestCancel('subagent cancelled') }
|
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 () => {
|
const result: Promise<SubagentResult> = (async () => {
|
||||||
try {
|
try {
|
||||||
// A signal already aborted BEFORE the run starts never fires an `abort`
|
let liveChild: Agent
|
||||||
// event (`addEventListener` only fires on the transition), so the listener
|
try {
|
||||||
// above won't catch it — settle `aborted` without running the child rather
|
liveChild = await creation
|
||||||
// than completing an already-cancelled request.
|
} catch (error: unknown) {
|
||||||
if (request.signal?.aborted) return { output: [], stopReason: 'aborted' }
|
if (isManualDisposeRequested()) return { output: [], stopReason: 'aborted' }
|
||||||
child.send(request.prompt)
|
throw error instanceof Error ? error : new Error('subagent child creation failed with a non-Error value', { cause: error })
|
||||||
await child.whenIdle()
|
}
|
||||||
|
if (isCancelled() || isDisposeRequested()) return { output: [], stopReason: 'aborted' }
|
||||||
|
liveChild.send(prompt)
|
||||||
|
await liveChild.whenIdle()
|
||||||
// Deliberately NO re-prompt when a structured child finishes cleanly
|
// Deliberately NO re-prompt when a structured child finishes cleanly
|
||||||
// without calling structured_output: readResult maps that to `error` —
|
// without calling structured_output: readResult maps that to `error` —
|
||||||
// the shortfall goes to the parent instead of buying extra model turns.
|
// 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 {
|
} finally {
|
||||||
request.signal?.removeEventListener('abort', onAbort)
|
signal?.removeEventListener('abort', onAbort)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
let disposing: Promise<void> | undefined
|
||||||
return {
|
return {
|
||||||
id: childId,
|
id: childId,
|
||||||
result,
|
result,
|
||||||
@@ -252,13 +318,26 @@ export function startInProcessRun(
|
|||||||
requestCancel(reason ?? 'subagent cancelled')
|
requestCancel(reason ?? 'subagent cancelled')
|
||||||
},
|
},
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
request.signal?.removeEventListener('abort', onAbort)
|
return (disposing ??= (async () => {
|
||||||
// Through the parent-scope unlink when the parent is still live (one
|
signal?.removeEventListener('abort', onAbort)
|
||||||
// disposal path, and the dead effect leaves the parent's list); the
|
disposeRequested = true
|
||||||
// memoized handle keeps a direct dispose equivalent if the parent's
|
manualDisposeRequested = true
|
||||||
// teardown already ran the unlink.
|
requestCancel('subagent disposed during creation')
|
||||||
await unlink()
|
// Removing provider ownership and disposing the common run-owner fiber
|
||||||
await handle.dispose()
|
// 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,
|
* a disposed child leaves no residue — no placeholder schema,
|
||||||
* strip-for-everyone-else pass, or refcounted global runtime.
|
* 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
|
* - `systemPrompt.protect()` declaratively protects the capture tool and its
|
||||||
* listener post-processes its downstream chain so a listener inside that
|
* instruction. The service restores their canonical pre-waterfall state
|
||||||
* chain cannot leave the child's capture tool or instruction stripped or
|
* after EVERY assembly listener. Canonical absence is protected too: pure
|
||||||
* replaced. Tools are replaced in place and the section is re-inserted at
|
* Code Mode keeps `structured_output` in the SDK only and never grows a
|
||||||
* its ascending-order position, so the untampered path keeps the registry's
|
* second native wire tool. Code Mode's owner independently protects its SDK
|
||||||
* ordering (up to intra-band section order, which carries no contract). A
|
* and `run_code` transport. The loop logs the finalized assembly as the
|
||||||
* listener prepended later can still wrap and transform this result; this is
|
* request header, so the demand is reconstructable log state, never a
|
||||||
* an ordinary waterfall listener, not a service-level finalizer. The loop
|
* wire-only mutation.
|
||||||
* logs the rendered assembly as the request header, so the demand is
|
* - `agent/turn-stop` (serial, scoped): stop the child's turn once its output
|
||||||
* reconstructable log state, never a wire-only mutation.
|
* is captured. This terminal checkpoint runs after the ordinary continuation
|
||||||
* - `agent/turn-continuation` (prepend, scoped): stop the child's turn once
|
* waterfall and steering folding, so listener order cannot resurrect a
|
||||||
* its output is captured — the loop's default "had tool calls ⇒ continue"
|
* completed structured run or carry terminal steering into another turn.
|
||||||
* would buy a wasted extra model step per structured child.
|
* - `tools.guard()` is the monotonic terminal gate after the extensible
|
||||||
* - `tools/pre-execute` (prepend, scoped): terminal means terminal WITHIN the
|
* pre-execute waterfall: once capture commits, no later listener can turn
|
||||||
* step — deny every call arriving after the capture, so a response that
|
* the denial back into a dispatched side effect.
|
||||||
* lists `structured_output` before further tool calls cannot run side
|
* - `tools/result` is the capture COMMIT point. The tool body only STAGES the
|
||||||
* effects after the final answer was accepted.
|
* validated value in a WeakMap keyed by the execution object; the awaited,
|
||||||
* - `tools/post-execute` (prepend, scoped): the capture COMMIT. The tool body
|
* non-transforming notification promotes it only when the authoritative
|
||||||
* only STAGES the validated value, KEYED BY THE EXECUTION OBJECT in a
|
* result after the whole pre/execute/post pipeline succeeds. For a Code Mode
|
||||||
* WeakMap; it becomes the run's captured result when this listener's
|
* sub-dispatch, promotion waits again for the enclosing `run_code` result, so
|
||||||
* downstream post-execute decision accepts THAT SAME pipeline trip. A
|
* a runtime failure or outer post-policy block cannot report structured
|
||||||
* later-prepended wrapper remains outside that decision. Execution-keyed
|
* success. Execution identity makes call-id reuse and orphaned stages
|
||||||
* staging makes the stale-stage class structurally impossible: a value
|
* irrelevant.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* @module @deepseek-ai/dsh-subagent-inprocess/structured
|
* @module @deepseek-ai/dsh-subagent-inprocess/structured
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context } from 'cordis'
|
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 { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||||
import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||||
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
|
||||||
import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } 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. */
|
/** The model-facing tool name a structured child must call to finish. */
|
||||||
@@ -71,7 +66,7 @@ export const STRUCTURED_OUTPUT_INSTRUCTION
|
|||||||
export interface StructuredAttachment {
|
export interface StructuredAttachment {
|
||||||
/**
|
/**
|
||||||
* The captured value, once the child called the tool with valid arguments
|
* 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.
|
* @returns the committed value, or undefined while none was accepted.
|
||||||
*/
|
*/
|
||||||
captured(): { value: unknown } | undefined
|
captured(): { value: unknown } | undefined
|
||||||
@@ -80,7 +75,7 @@ export interface StructuredAttachment {
|
|||||||
/**
|
/**
|
||||||
* Attach the structured-output runtime to a child for `schema`: register the
|
* Attach the structured-output runtime to a child for `schema`: register the
|
||||||
* scoped capture tool (real schema), the scoped instruction section, and 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
|
* agent-creation `setup` window with the child's scope context — every
|
||||||
* registration rides the child's fiber and unwinds with the child.
|
* registration rides the child's fiber and unwinds with the child.
|
||||||
* @param childCtx - the child agent's scope context (`setup`'s argument).
|
* @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 {
|
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
|
||||||
/**
|
/**
|
||||||
* Validated values staged by the capture tool body, awaiting THEIR OWN
|
* Validated values staged by the capture tool body, awaiting THEIR OWN
|
||||||
* call's post-execute verdict — keyed by the {@link ToolExecution} OBJECT,
|
* authoritative `tools/result` notification. The execution object's identity
|
||||||
* the one token that provably ties a stage to one trip through the
|
* uniquely identifies a trip through the pipeline: adapter call ids may
|
||||||
* pipeline. A call id cannot key this: ids are adapter-minted and may
|
* repeat across steps, but another execution can never reach this WeakMap
|
||||||
* repeat across steps. Keying by execution makes the stale-stage class
|
* entry. This is distinct from the opaque `ToolExecutionToken` used to
|
||||||
* structurally impossible — an entry orphaned by an outer short-circuiting
|
* correlate nested transports. The final notification always deletes its own
|
||||||
* listener can never match a different execution's lookup, needs no drop
|
* stage, whether the result succeeded or failed.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
const staged = new WeakMap<ToolExecution, { value: unknown }>()
|
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
|
let captured: { value: unknown } | undefined
|
||||||
|
|
||||||
const schemaEntry: ToolSchema = {
|
const schemaEntry: ToolSchema = {
|
||||||
@@ -123,10 +115,10 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
|||||||
// ToolArgsError → isError result with INVALID_ARGS: the model retries
|
// ToolArgsError → isError result with INVALID_ARGS: the model retries
|
||||||
// within the same turn, exactly like a schema-validated defineTool call.
|
// within the same turn, exactly like a schema-validated defineTool call.
|
||||||
if (violations.length > 0) throw new ToolArgsError(violations)
|
if (violations.length > 0) throw new ToolArgsError(violations)
|
||||||
// Two-phase commit, KEYED BY THIS EXECUTION: the body only stages; the
|
// Two-phase commit, keyed by THIS execution: later transformable
|
||||||
// post-execute listener promotes exactly this pipeline trip's entry
|
// waterfalls may still turn the success into an error. Snapshot the
|
||||||
// when its downstream decision accepts it.
|
// validated value independently of the already-frozen pipeline arguments.
|
||||||
staged.set(exec, { value: args })
|
staged.set(exec, { value: structuredClone(args) })
|
||||||
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
|
return Promise.resolve([{ type: 'text', text: 'Structured output recorded.' }])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -137,105 +129,58 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
|
|||||||
text: STRUCTURED_OUTPUT_INSTRUCTION,
|
text: STRUCTURED_OUTPUT_INSTRUCTION,
|
||||||
})
|
})
|
||||||
|
|
||||||
// PREPENDED assembly re-assert: scoped dispatch means this fires only for the
|
// Service-owned finalization, not waterfall ordering. The canonical
|
||||||
// child's assemblies; `await next()` returns whatever this listener's
|
// assembly determines both presence and absence: native/both modes restore
|
||||||
// downstream chain produced, and the capture tool + instruction are
|
// the capture schema on the wire, while pure Code Mode removes any injected
|
||||||
// re-asserted onto it if anything stripped them. A listener prepended later
|
// native entry. ToolRegistry's own protection independently restores the SDK
|
||||||
// can still wrap and transform the returned assembly; this is not a
|
// section and run_code transport that carry the same schema.
|
||||||
// service-level finalizer.
|
childCtx.systemPrompt.protect({
|
||||||
childCtx.on('system-prompt/assemble', async function (
|
sections: [`tool:${STRUCTURED_OUTPUT_TOOL}`],
|
||||||
this: unknown, _assembly: PromptAssembly, _context: AssembleContext, next: () => Promise<PromptAssembly>,
|
tools: [STRUCTURED_OUTPUT_TOOL],
|
||||||
): Promise<PromptAssembly> {
|
})
|
||||||
const final = await next()
|
|
||||||
// REPLACE, not merely ensure-present: a downstream listener may have
|
// Stop the child's turn once its output is captured. This monotonic serial
|
||||||
// mutated or injected a same-named entry with the WRONG schema/text, and
|
// checkpoint runs after the ordinary continuation waterfall, its reason,
|
||||||
// the model-visible demand must be exactly this run's own — the same
|
// and late-steering folding, so no ordering trick can resume a finished run.
|
||||||
// schema validateStructuredValue enforces. Placement-preserving on both
|
childCtx.on('agent/turn-stop', function (this: unknown): ContinuationStop | undefined {
|
||||||
// arrays: the untampered path keeps the registry's ordering (tool order
|
return captured === undefined ? undefined : { action: 'stop' }
|
||||||
// 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
|
// Terminal WITHIN the step. Guards run after the whole pre-execute
|
||||||
// (a 190-order section registered AFTER this runtime sorts before the
|
// waterfall and compose monotonically (deny or abstain, never allow), so a
|
||||||
// instruction in the registry but after it here).
|
// later prepended listener cannot resurrect dispatch. Calls that precede
|
||||||
const freshTool: ToolSchema = { ...schemaEntry, parameters: structuredClone(schemaEntry.parameters) }
|
// capture in the same response remain untouched.
|
||||||
// Tools: replace the first same-named entry IN PLACE (its position is the
|
childCtx.tools.guard(exec => captured === undefined && pending === undefined
|
||||||
// chain's product; a tool's list position carries no semantic band to
|
? undefined
|
||||||
// restore), drop any duplicates, append only when stripped entirely.
|
: `structured output already recorded: the run is complete, so \`${exec.name}\` is not executed`)
|
||||||
const tools: ToolSchema[] = []
|
|
||||||
let toolReplaced = false
|
// The capture COMMIT observes the immutable, authoritative result after the
|
||||||
for (const tool of final.tools) {
|
// complete pipeline and outer error normalization. This notification cannot
|
||||||
if (tool.name !== STRUCTURED_OUTPUT_TOOL) {
|
// transform the outcome, so there is no wrapper outside the commit verdict.
|
||||||
tools.push(tool)
|
childCtx.on('tools/result', function (this: unknown, exec, result): void {
|
||||||
} else if (!toolReplaced) {
|
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
||||||
tools.push(freshTool)
|
const entry = staged.get(exec)
|
||||||
toolReplaced = true
|
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)
|
if (pending?.parent !== exec.token) return
|
||||||
final.tools = tools
|
const entry = pending
|
||||||
// Sections: remove every same-named entry and re-insert at the
|
pending = undefined
|
||||||
// ascending-correct position (the first entry above order 190) — sections
|
if (result.isError) return
|
||||||
// DO carry an order contract, and the renderer reads array order, so a
|
/* v8 ignore else -- Code Mode serializes outer executions, so the guard blocks every later supported call */
|
||||||
// stripped-or-moved instruction is restored to its band, not appended
|
if (captured === undefined) captured = { value: entry.value }
|
||||||
// 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 })
|
|
||||||
|
|
||||||
return { captured: () => captured }
|
return { captured: () => captured }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
|||||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||||
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
|
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 { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||||
import { startInProcessRun } from '../src/index.ts'
|
import { startInProcessRun } from '../src/index.ts'
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +20,15 @@ import {
|
|||||||
|
|
||||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
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 = {
|
const SCHEMA: StructuredOutputSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { answer: { type: 'number' }, note: { type: 'string' } },
|
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
|
* coverage lives in the spawn/fork specs. The mock model script drives the
|
||||||
* child's structured_output calls.
|
* child's structured_output calls.
|
||||||
*/
|
*/
|
||||||
async function setup(script: Script) {
|
async function setup(script: Script, options: SetupOptions = {}) {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
const adapter = new MockAdapter(script)
|
const adapter = new MockAdapter(script)
|
||||||
await ctx.plugin(LlmService)
|
await ctx.plugin(LlmService)
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
await ctx.plugin(SystemPrompt)
|
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(AgentRegistry)
|
||||||
await ctx.plugin(Invariants)
|
await ctx.plugin(Invariants)
|
||||||
await ctx.plugin(AgentLoop, { agents: [] })
|
await ctx.plugin(AgentLoop, { agents: [] })
|
||||||
@@ -48,7 +65,7 @@ async function setup(script: Script) {
|
|||||||
name: 'spawn',
|
name: 'spawn',
|
||||||
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
|
capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false },
|
||||||
inheritsParentContext: false,
|
inheritsParentContext: false,
|
||||||
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, { providerName: 'spawn' }),
|
start: (request: SubagentStartRequest) => startInProcessRun(ctx, request, {}),
|
||||||
})
|
})
|
||||||
ctx.llm.registerAdapter(['mock'], adapter)
|
ctx.llm.registerAdapter(['mock'], adapter)
|
||||||
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' })
|
||||||
@@ -121,6 +138,44 @@ describe('in-process structured output', () => {
|
|||||||
await run.dispose()
|
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 () => {
|
it('leaves tool calls that PRECEDE the capture in the same response untouched', async () => {
|
||||||
const response = [
|
const response = [
|
||||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||||
@@ -173,23 +228,67 @@ describe('in-process structured output', () => {
|
|||||||
await run.dispose()
|
await run.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('the captured-turn veto is prepend: an EARLIER force-continue listener cannot short-circuit it', async () => {
|
it('a later-prepended continuation wrapper cannot resurrect a captured turn', 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.
|
|
||||||
const { ctx, parent, adapter } = await setup([
|
const { ctx, parent, adapter } = await setup([
|
||||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
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))
|
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
|
const result = await run.result
|
||||||
|
expect(wrapperInstalled).toBe(true)
|
||||||
expect(result.structured).toEqual({ answer: 7 })
|
expect(result.structured).toEqual({ answer: 7 })
|
||||||
expect(result.stopReason).toBe('completed')
|
expect(result.stopReason).toBe('completed')
|
||||||
expect(adapter.requests).toHaveLength(1)
|
expect(adapter.requests).toHaveLength(1)
|
||||||
await run.dispose()
|
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 () => {
|
it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
|
||||||
const { ctx, parent } = await setup([
|
const { ctx, parent } = await setup([
|
||||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),
|
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 () => {
|
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 { ctx, parent } = await setup([textResponse('prose, no capture')])
|
||||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
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
|
// Cancel synchronously inside the turn's end recording: the cancel
|
||||||
// contract outranks the schema shortfall, so the result maps to aborted.
|
// contract outranks the schema shortfall, so the result maps to aborted.
|
||||||
ctx.on('session/event', (session, event) => {
|
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
|
const result = await run.result
|
||||||
expect(result.stopReason).toBe('aborted')
|
expect(result.stopReason).toBe('aborted')
|
||||||
@@ -270,8 +369,8 @@ describe('in-process structured output', () => {
|
|||||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||||
textResponse('continues after the blocked capture'),
|
textResponse('continues after the blocked capture'),
|
||||||
])
|
])
|
||||||
// A PostToolUse-style hook, registered AFTER the runtime (so the runtime's
|
// A PostToolUse-style hook turns the tool body's provisional success into
|
||||||
// prepend commit listener stays outermost and composes this verdict).
|
// the authoritative final error observed by the commit notification.
|
||||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||||
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
||||||
return Promise.resolve({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'capture rejected by hook' }] })
|
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()
|
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 () => {
|
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 })])
|
const { ctx, parent, adapter } = await setup([toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 })])
|
||||||
// A context-wide section stands in for the deployment persona: the
|
// A context-wide section stands in for the deployment persona: the
|
||||||
@@ -327,6 +451,100 @@ describe('in-process structured output', () => {
|
|||||||
await run.dispose()
|
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 () => {
|
it('the instruction rides ONLY structured requests: appended for the child, absent for a plain agent', async () => {
|
||||||
const { ctx, parent, adapter } = await setup([
|
const { ctx, parent, adapter } = await setup([
|
||||||
textResponse('parent answer'),
|
textResponse('parent answer'),
|
||||||
@@ -412,12 +630,12 @@ describe('in-process structured output', () => {
|
|||||||
await runB.dispose()
|
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([
|
const { ctx, parent, adapter } = await setup([
|
||||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
|
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
|
||||||
])
|
])
|
||||||
// A global listener that INJECTS a wrong-schema structured_output entry:
|
// 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) => {
|
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||||
const replaced = await next()
|
const replaced = await next()
|
||||||
return {
|
return {
|
||||||
@@ -438,14 +656,14 @@ describe('in-process structured output', () => {
|
|||||||
await run.dispose()
|
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([
|
const { ctx, parent, adapter } = await setup([
|
||||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
|
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 5 }),
|
||||||
])
|
])
|
||||||
// A global (every-assembly) listener that returns a brand-new assembly
|
// A global (every-assembly) listener that returns a brand-new assembly
|
||||||
// WITHOUT the capture tool or instruction — the composition caveat that
|
// WITHOUT the capture tool or instruction — the composition caveat that
|
||||||
// erases cooperative mutations. The child's prepend re-assert runs
|
// erases cooperative mutations. Service finalization restores both
|
||||||
// OUTERMOST and restores both.
|
// after the complete waterfall.
|
||||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||||
const replaced = await next()
|
const replaced = await next()
|
||||||
return {
|
return {
|
||||||
@@ -465,13 +683,13 @@ describe('in-process structured output', () => {
|
|||||||
await run.dispose()
|
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([
|
const { ctx, parent, adapter } = await setup([
|
||||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
|
||||||
])
|
])
|
||||||
// A global tool sorting lexicographically AFTER structured_output and a
|
// A global tool sorting lexicographically AFTER structured_output and a
|
||||||
// global section ABOVE the 190 band: the re-assert must leave both
|
// global section above the 190 band: protection leaves both exactly
|
||||||
// exactly where the registry's ordering put them (no move-to-end).
|
// where the canonical registry ordering put them.
|
||||||
ctx.tools.register({
|
ctx.tools.register({
|
||||||
name: 'zz_probe',
|
name: 'zz_probe',
|
||||||
description: 'probe',
|
description: 'probe',
|
||||||
@@ -498,7 +716,7 @@ describe('in-process structured output', () => {
|
|||||||
])
|
])
|
||||||
ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' })
|
ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' })
|
||||||
// Strip the instruction section entirely AND add a wrong-schema
|
// 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
|
// restore the section INTO its band (before the order-200 section, not
|
||||||
// appended after it) and collapse the tools to exactly one entry
|
// appended after it) and collapse the tools to exactly one entry
|
||||||
// carrying the run's schema.
|
// carrying the run's schema.
|
||||||
@@ -578,15 +796,14 @@ describe('in-process structured output', () => {
|
|||||||
expect(result.error?.code).toBe('UNKNOWN_TOOL')
|
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([
|
const { ctx, parent } = await setup([
|
||||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||||
])
|
])
|
||||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||||
const child = ctx.agents.get(run.id)!
|
// A prepended post-execute listener blocks the first capture without
|
||||||
// An OUTER post-execute listener (registered after attach, prepend ⇒
|
// delegating. The final-result notification discards that execution's
|
||||||
// outermost) that BLOCKS the first capture WITHOUT delegating: the commit
|
// stage when it observes the error.
|
||||||
// listener never runs for c1, so its staged value would linger.
|
|
||||||
let blocks = 1
|
let blocks = 1
|
||||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||||
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
||||||
@@ -596,11 +813,12 @@ describe('in-process structured output', () => {
|
|||||||
return next()
|
return next()
|
||||||
}, { prepend: true })
|
}, { prepend: true })
|
||||||
const result = await run.result
|
const result = await run.result
|
||||||
|
const child = ctx.agents.get(run.id)!
|
||||||
// The blocked capture must NOT surface as structured success…
|
// The blocked capture must NOT surface as structured success…
|
||||||
expect(result.stopReason).toBe('error')
|
expect(result.stopReason).toBe('error')
|
||||||
expect(result.structured).toBeUndefined()
|
expect(result.structured).toBeUndefined()
|
||||||
// …and a LATER invalid call (its own body staged nothing) must not
|
// …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({
|
const invalid = await ctx.tools.execute({
|
||||||
callId: 'c2' as never,
|
callId: 'c2' as never,
|
||||||
name: STRUCTURED_OUTPUT_TOOL,
|
name: STRUCTURED_OUTPUT_TOOL,
|
||||||
@@ -619,14 +837,13 @@ describe('in-process structured output', () => {
|
|||||||
await run.dispose()
|
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([
|
const { ctx, parent } = await setup([
|
||||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||||
])
|
])
|
||||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||||
const child = ctx.agents.get(run.id)!
|
// Block the first capture after its body stages a value. Its final error
|
||||||
// Orphan a stage: an outer short-circuiting post-execute BLOCK on the
|
// discards that execution's stage.
|
||||||
// first capture (its chain never reaches the commit listener).
|
|
||||||
let blocks = 1
|
let blocks = 1
|
||||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||||
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
||||||
@@ -636,8 +853,9 @@ describe('in-process structured output', () => {
|
|||||||
return next()
|
return next()
|
||||||
}, { prepend: true })
|
}, { prepend: true })
|
||||||
await run.result
|
await run.result
|
||||||
|
const child = ctx.agents.get(run.id)!
|
||||||
// A SECOND capture call with the SAME call id whose body never stages
|
// 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.
|
// its acceptance.
|
||||||
const reused = await ctx.tools.execute({
|
const reused = await ctx.tools.execute({
|
||||||
callId: 'c1' as never,
|
callId: 'c1' as never,
|
||||||
@@ -657,13 +875,12 @@ describe('in-process structured output', () => {
|
|||||||
await run.dispose()
|
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([
|
const { ctx, parent } = await setup([
|
||||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
|
||||||
])
|
])
|
||||||
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
const run = ctx.subagents.start('spawn', structuredRequest(parent))
|
||||||
const child = ctx.agents.get(run.id)!
|
// Discard the first capture's stage via a final post-execute block.
|
||||||
// Orphan a stage via an outer post-execute BLOCK on the first capture.
|
|
||||||
let blocks = 1
|
let blocks = 1
|
||||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||||
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
if (exec.name === STRUCTURED_OUTPUT_TOOL && blocks > 0) {
|
||||||
@@ -673,9 +890,9 @@ describe('in-process structured output', () => {
|
|||||||
return next()
|
return next()
|
||||||
}, { prepend: true })
|
}, { prepend: true })
|
||||||
await run.result
|
await run.result
|
||||||
// An OUTERMOST prepend pre-execute deny: the structured runtime's own
|
const child = ctx.agents.get(run.id)!
|
||||||
// pre-execute never runs for this call, and the denied call still goes
|
// A prepended pre-execute deny skips the body, while the denied call still
|
||||||
// through post-execute — with the SAME call id as the orphaned stage.
|
// reaches the final notification with the same adapter-minted call id.
|
||||||
const offDeny = ctx.on('tools/pre-execute', (exec) => {
|
const offDeny = ctx.on('tools/pre-execute', (exec) => {
|
||||||
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
if (exec.name === STRUCTURED_OUTPUT_TOOL) {
|
||||||
return Promise.resolve({ kind: 'deny' as const, reason: 'outer veto' })
|
return Promise.resolve({ kind: 'deny' as const, reason: 'outer veto' })
|
||||||
@@ -690,7 +907,7 @@ describe('in-process structured output', () => {
|
|||||||
})
|
})
|
||||||
expect(denied.isError).toBe(true)
|
expect(denied.isError).toBe(true)
|
||||||
offDeny()
|
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).
|
// (and succeeds, proving the runtime is not wedged).
|
||||||
const valid = await ctx.tools.execute({
|
const valid = await ctx.tools.execute({
|
||||||
callId: 'c1' as never,
|
callId: 'c1' as never,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context, type Fiber } from 'cordis'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
import LlmService from '@deepseek-ai/dsh-llm'
|
||||||
import SessionStore from '@deepseek-ai/dsh-session'
|
import SessionStore from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
@@ -49,9 +49,166 @@ describe('depthOf', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('startInProcessRun', () => {
|
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 () => {
|
it('drives a fresh child (no seed) to completion and returns its output', async () => {
|
||||||
const { ctx, parent } = await setup([textResponse('driver child answer')])
|
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
|
const result = await run.result
|
||||||
expect(result.stopReason).toBe('completed')
|
expect(result.stopReason).toBe('completed')
|
||||||
expect(text(result.output)).toBe('driver child answer')
|
expect(text(result.output)).toBe('driver child answer')
|
||||||
@@ -59,9 +216,25 @@ describe('startInProcessRun', () => {
|
|||||||
await run.dispose()
|
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 () => {
|
it('throws SubagentDepthError when the child would exceed maxDepth', async () => {
|
||||||
const { ctx, parent } = await setup([])
|
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)
|
.toThrow(SubagentDepthError)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -73,7 +246,7 @@ describe('startInProcessRun', () => {
|
|||||||
parent.send([{ type: 'text', text: 'parent q' }])
|
parent.send([{ type: 'text', text: 'parent q' }])
|
||||||
await parent.whenIdle()
|
await parent.whenIdle()
|
||||||
const seed = parent.session.events.slice()
|
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
|
const result = await run.result
|
||||||
expect(result.stopReason).toBe('completed')
|
expect(result.stopReason).toBe('completed')
|
||||||
expect(text(result.output)).toBe('seeded child reply')
|
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
|
// 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
|
// the tool service. Keeping it out of this backend's inject list preserves the
|
||||||
// provider's independent apply timing.
|
// provider's independent apply timing.
|
||||||
export const inject = ['subagents', 'agents']
|
export const inject = ['subagents']
|
||||||
|
|
||||||
/** Config: the registry name to register the provider under. */
|
/** Config: the registry name to register the provider under. */
|
||||||
export interface Config {
|
export interface Config {
|
||||||
@@ -59,7 +59,7 @@ class SpawnProvider implements SubagentProvider {
|
|||||||
// Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
|
// Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/
|
||||||
// depth, drives the one-shot (including the structured capture when the
|
// depth, drives the one-shot (including the structured capture when the
|
||||||
// request carries an outputSchema), and maps the result.
|
// 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 { Context } from 'cordis'
|
||||||
import Loader from '@cordisjs/plugin-loader'
|
import Loader from '@cordisjs/plugin-loader'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
import LlmService from '@deepseek-ai/dsh-llm'
|
||||||
@@ -160,6 +160,38 @@ describe('dsh-subagent-spawn', () => {
|
|||||||
await run.dispose()
|
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 () => {
|
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.
|
// 'hang' makes the child's model stream one chunk then wait until aborted.
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -206,7 +238,7 @@ describe('dsh-subagent-spawn', () => {
|
|||||||
it('inherits the parent cwd into the child session', async () => {
|
it('inherits the parent cwd into the child session', async () => {
|
||||||
const { ctx } = await setup([textResponse('x')])
|
const { ctx } = await setup([textResponse('x')])
|
||||||
// A parent WITH a cwd (config agents have none, so create one explicitly).
|
// 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'),
|
agentId: AgentId('cwd-parent'),
|
||||||
sessionId: SessionId('cwd-parent-session'),
|
sessionId: SessionId('cwd-parent-session'),
|
||||||
meta: { cwd: '/tmp/parent-workspace' },
|
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 () => {
|
it('uses request.agentOptions.model when the parent has no model of its own', async () => {
|
||||||
const { ctx } = await setup([textResponse('explicit model child')])
|
const { ctx } = await setup([textResponse('explicit model child')])
|
||||||
// A parent with NO model (its own turns would need one supplied per-request).
|
// 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'),
|
agentId: AgentId('modelless-parent'),
|
||||||
sessionId: SessionId('modelless-parent-session'),
|
sessionId: SessionId('modelless-parent-session'),
|
||||||
agentOptions: {},
|
agentOptions: {},
|
||||||
@@ -305,15 +337,70 @@ describe('dsh-subagent-spawn', () => {
|
|||||||
await run.dispose()
|
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)', () => {
|
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||||
expect('default' in spawn).toBe(false)
|
expect('default' in spawn).toBe(false)
|
||||||
expect(spawn.name).toBe('subagent-spawn')
|
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 loader = Object.create(Loader.prototype) as Loader
|
||||||
const unwrapped = loader.unwrapExports(spawn) as Record<string, unknown>
|
const unwrapped = loader.unwrapExports(spawn) as Record<string, unknown>
|
||||||
expect(unwrapped).toBe(spawn)
|
expect(unwrapped).toBe(spawn)
|
||||||
expect(unwrapped.name).toBe('subagent-spawn')
|
expect(unwrapped.name).toBe('subagent-spawn')
|
||||||
expect(unwrapped.inject).toEqual(['subagents', 'agents'])
|
expect(unwrapped.inject).toEqual(['subagents'])
|
||||||
expect(typeof unwrapped.apply).toBe('function')
|
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 () => {
|
it('an unknown toolFilter name fails the spawn loudly with no orphaned child', async () => {
|
||||||
const { ctx, parent } = await setup([])
|
const { ctx, parent } = await setup([])
|
||||||
const before = ctx.agents.list().length
|
const before = ctx.agents.list().length
|
||||||
expect(() => ctx.subagents.start('spawn', {
|
const run = ctx.subagents.start('spawn', {
|
||||||
prompt: [{ type: 'text', text: 'do X' }],
|
prompt: [{ type: 'text', text: 'do X' }],
|
||||||
parent,
|
parent,
|
||||||
toolFilter: { deny: ['no_such_tool'] },
|
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)
|
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 () => {
|
it('spawning from a DISPOSING parent fails loud with no orphaned child (INACTIVE_EFFECT teaching error)', async () => {
|
||||||
const { ctx } = await setup([])
|
const { ctx } = await setup([])
|
||||||
// A handle-owned parent we can dispose (config agents dispose with the loop fiber).
|
// 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'),
|
agentId: AgentId('doomed-parent'),
|
||||||
sessionId: SessionId('doomed-s'),
|
sessionId: SessionId('doomed-s'),
|
||||||
agentOptions: { model: 'mock' },
|
agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
await parentHandle.dispose()
|
await parentHandle.dispose()
|
||||||
const before = ctx.agents.list().length
|
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' }],
|
prompt: [{ type: 'text', text: 'do X' }],
|
||||||
parent: parentHandle.agent,
|
parent: parentHandle.agent,
|
||||||
})).toThrow(/inactive context/)
|
})
|
||||||
// The freshly created child's disposal was initiated before the rethrow
|
await expect(run.result).rejects.toThrow(/inactive context/)
|
||||||
// (fire-and-forget — start() throws synchronously); quiescence follows.
|
await run.dispose()
|
||||||
await vi.waitFor(() => { expect(ctx.agents.list().length).toBe(before) })
|
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 { Context, Service } from 'cordis'
|
||||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
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 { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||||
@@ -93,7 +95,7 @@ declare module 'cordis' {
|
|||||||
* @param info - which provider started which child agent.
|
* @param info - which provider started which child agent.
|
||||||
* @mode emit
|
* @mode emit
|
||||||
*/
|
*/
|
||||||
'subagent/start'(info: SubagentRunInfo): void
|
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
|
||||||
/**
|
/**
|
||||||
* A subagent run settled — emitted when {@link SubagentRun.result}
|
* A subagent run settled — emitted when {@link SubagentRun.result}
|
||||||
* resolves (any stop reason). Paired with {@link Events['subagent/start']}.
|
* 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.
|
* @param info - the run identity plus stop reason and final output.
|
||||||
* @mode emit
|
* @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).
|
* @returns the live run (its `result` resolves when the child settles).
|
||||||
*/
|
*/
|
||||||
start(name: string, request: SubagentStartRequest): SubagentRun {
|
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)
|
const provider = this.providers.get(name)
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
|
throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER')
|
||||||
}
|
}
|
||||||
this.assertCapabilities(provider, request)
|
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}):
|
// Emit `subagent/start` with PER-LISTENER containment (see {@link emitLifecycle}):
|
||||||
// the run is already live, so neither a throwing subscriber escaping
|
// the run is already live, so neither a throwing subscriber escaping
|
||||||
// `start()` (the caller would never receive the run to dispose it — a leaked
|
// `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
|
// acceptable. `ctx.emit` halts the dispatch on the first throw, so a single
|
||||||
// surrounding try/catch is not enough — each listener is invoked and
|
// surrounding try/catch is not enough — each listener is invoked and
|
||||||
// contained individually.
|
// 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
|
// Emit `subagent/end` when the run settles. The result promise does not
|
||||||
// reject on a child-level failure (it resolves with stopReason 'error'),
|
// reject on a child-level failure (it resolves with stopReason 'error'),
|
||||||
// so a rejection here is an infrastructure fault — surface its stop reason
|
// so a rejection here is an infrastructure fault — surface its stop reason
|
||||||
@@ -268,9 +289,9 @@ export class SubagentService extends Service {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.ctx.logger.warn(`subagent: could not clone ${name} output for subagent/end: ${String(error)}`)
|
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
|
return run
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,9 +120,11 @@ export interface SubagentResult {
|
|||||||
/** The child's final assistant output (the last assistant message's content). */
|
/** The child's final assistant output (the last assistant message's content). */
|
||||||
output: ContentBlock[]
|
output: ContentBlock[]
|
||||||
/**
|
/**
|
||||||
* The structured result, present IFF the request carried an `outputSchema`
|
* The structured result after a requested `outputSchema` was successfully
|
||||||
* AND the provider honored it. Shape is validated against the request schema
|
* satisfied. Requesting a schema does not guarantee presence: a provider can
|
||||||
* by the provider; `unknown` here because the seam is schema-agnostic.
|
* 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
|
structured?: unknown
|
||||||
/** Why the run ended. A non-`completed` reason means `output` may be partial. */
|
/** 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 { Context } from 'cordis'
|
||||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||||
|
import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
|
||||||
import SubagentService, {
|
import SubagentService, {
|
||||||
SubagentError,
|
SubagentError,
|
||||||
type SubagentCapabilities,
|
type SubagentCapabilities,
|
||||||
@@ -227,6 +228,45 @@ describe('SubagentService', () => {
|
|||||||
expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' }))
|
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 () => {
|
it('carries lastAssistantMessage (the child output) onto the end event', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SubagentService)
|
await ctx.plugin(SubagentService)
|
||||||
|
|||||||
@@ -390,10 +390,12 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|||||||
'agent/session-prefix': args => args[0],
|
'agent/session-prefix': args => args[0],
|
||||||
'agent/step-result': args => args[0],
|
'agent/step-result': args => args[0],
|
||||||
'agent/turn-continuation': args => args[0],
|
'agent/turn-continuation': args => args[0],
|
||||||
|
'agent/turn-stop': args => args[0],
|
||||||
'agent/error': args => args[0],
|
'agent/error': args => args[0],
|
||||||
'tools/pre-execute': args => (args[0] as ToolExecution).agent,
|
'tools/pre-execute': args => (args[0] as ToolExecution).agent,
|
||||||
'tools/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/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,
|
'system-prompt/assemble': args => (args[1] as AssembleContext).scope,
|
||||||
'session/created': null,
|
'session/created': null,
|
||||||
'session/event': null,
|
'session/event': null,
|
||||||
@@ -429,11 +431,11 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|||||||
|
|
||||||
// --- Setup-drives invariant ---------------------------------------------
|
// --- Setup-drives invariant ---------------------------------------------
|
||||||
//
|
//
|
||||||
// CreateAgentOptions.setup REGISTERS the agent's scoped world; it must not
|
// CreateAgentOptions.setup COMPOSES the agent's scoped world; it must not
|
||||||
// DRIVE the agent — an inject() there opens a turn before
|
// DRIVE the agent. ReactLoopAgent rejects every driving verb structurally
|
||||||
// `agent/session-start`, inverting the "session-start fires before the
|
// until rollback-covered publication reaches the session-start boundary; this event-level invariant remains the
|
||||||
// first turn" contract every bridge keys on. A turn/start appended to a
|
// cross-implementation backstop for alternate Agent implementations and raw
|
||||||
// live agent's session before its agent/session-start fired is therefore a
|
// session writes. A turn/start appended before agent/session-start is a
|
||||||
// creation-time misuse, reported at the appending call site. Sessions of
|
// creation-time misuse, reported at the appending call site. Sessions of
|
||||||
// agents that exist BEFORE this plugin applies are marked started (their
|
// agents that exist BEFORE this plugin applies are marked started (their
|
||||||
// ordering is unknowable after the fact — never a false positive on HMR).
|
// 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
|
if (owner === undefined) return
|
||||||
throw new InvariantError(
|
throw new InvariantError(
|
||||||
`agent "${owner.id}": a turn opened before agent/session-start fired — `
|
`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)')
|
+ '(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/pre-step', [agent, 1, 1, '', new AbortController().signal]],
|
||||||
['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]],
|
['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]],
|
||||||
['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]],
|
['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/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-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]],
|
||||||
|
['agent/turn-stop', [agent, 1]],
|
||||||
['agent/error', [agent, 1, 0, new Error('x')]],
|
['agent/error', [agent, 1, 0, new Error('x')]],
|
||||||
['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
|
['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/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) {
|
for (const [event, args] of rows) {
|
||||||
const subject = event.startsWith('tools/') ? agent : agent
|
const subject = event.startsWith('tools/') ? agent : agent
|
||||||
@@ -870,7 +874,7 @@ describe('scoped-dispatch invariants', () => {
|
|||||||
}).not.toThrow()
|
}).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()
|
const ctx = await scopedCtx()
|
||||||
// A live agent whose session is in the store but whose session-start has
|
// A live agent whose session is in the store but whose session-start has
|
||||||
// not fired: appending turn/start must throw the teaching error.
|
// 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 Loader from '@cordisjs/plugin-loader'
|
||||||
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
|
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
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 * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
|
||||||
import { TOOL_TIMEOUT, toolTimeoutResult } 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 loader = Object.create(Loader.prototype) as Loader
|
||||||
const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters<Context['plugin']>[0]
|
const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters<Context['plugin']>[0]
|
||||||
const fiber = await ctx.plugin(unwrapped)
|
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)
|
expect(result.isError).toBe(false)
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -591,17 +591,27 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
},
|
},
|
||||||
|
|
||||||
newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||||
assertOpen()
|
assertOpen()
|
||||||
validateWorkspaceParams(params)
|
validateWorkspaceParams(params)
|
||||||
validateMcpServers(params)
|
validateMcpServers(params)
|
||||||
const sessionId = SessionId(randomUUID())
|
const sessionId = SessionId(randomUUID())
|
||||||
const handle = agents.create({
|
const handle = await agents.create({
|
||||||
agentId: AgentId(sessionId),
|
agentId: AgentId(sessionId),
|
||||||
sessionId,
|
sessionId,
|
||||||
meta: { cwd: params.cwd },
|
meta: { cwd: params.cwd },
|
||||||
agentOptions: agentOptions(config),
|
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)
|
bySession.set(handle.agent, sessionId)
|
||||||
sessions.set(sessionId, {
|
sessions.set(sessionId, {
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -611,7 +621,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|||||||
terminalEnabled: terminalOutputCap,
|
terminalEnabled: terminalOutputCap,
|
||||||
inflight: undefined,
|
inflight: undefined,
|
||||||
})
|
})
|
||||||
return Promise.resolve({ sessionId })
|
return { sessionId }
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
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
|
// dispose one handle, and assert the other survives, registered and
|
||||||
// queryable, with its session still in the store.
|
// queryable, with its session still in the store.
|
||||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
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' },
|
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' },
|
agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
expect(harness.ctx.agents.get(AgentId('sib-a'))).toBe(handleA.agent)
|
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.
|
// a clean turn, dispose, and assert the session was STILL removed.
|
||||||
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
||||||
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
|
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' },
|
agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' },
|
||||||
})
|
})
|
||||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
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
|
// first call's await agent.done + final flush finished. Every caller must
|
||||||
// observe the same quiescence boundary.
|
// observe the same quiescence boundary.
|
||||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
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' },
|
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
|
// 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: [] })
|
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||||
const before = harness.updates.length
|
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' }])
|
foreign.send([{ type: 'text', text: 'hi' }])
|
||||||
await foreign.whenIdle()
|
await foreign.whenIdle()
|
||||||
await new Promise(r => setTimeout(r, 10))
|
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', () => {
|
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 () => {
|
it('runs a two-phase script in a worker thread over real children, one through the structured runtime', async () => {
|
||||||
ctx = await harness()
|
ctx = await harness()
|
||||||
const parentHandle = ctx.agents.create({
|
const parentHandle = await ctx.agents.create({
|
||||||
agentId: AgentId('wf-worker-e2e-parent'),
|
agentId: AgentId('wf-worker-e2e-parent'),
|
||||||
sessionId: 'wf-worker-e2e-session' as never,
|
sessionId: 'wf-worker-e2e-session' as never,
|
||||||
agentOptions: { model: 'deepseek-v4-flash' },
|
agentOptions: { model: 'deepseek-v4-flash' },
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ export const LINK_MAP: Record<string, string> = {
|
|||||||
TurnEndReason: 'session.md',
|
TurnEndReason: 'session.md',
|
||||||
ToolDefinition: 'tools.md',
|
ToolDefinition: 'tools.md',
|
||||||
ToolExecution: 'tools.md',
|
ToolExecution: 'tools.md',
|
||||||
|
ToolExecutionInput: 'tools.md',
|
||||||
ToolExecutionResult: 'tools.md',
|
ToolExecutionResult: 'tools.md',
|
||||||
|
ToolExecutionToken: 'tools.md',
|
||||||
BashExecRequest: 'bash.md',
|
BashExecRequest: 'bash.md',
|
||||||
BashExecSpec: 'bash.md',
|
BashExecSpec: 'bash.md',
|
||||||
BashRunResult: 'bash.md',
|
BashRunResult: 'bash.md',
|
||||||
|
|||||||
@@ -120,10 +120,10 @@ const SERVICE_ROLES: ServiceRole[] = [
|
|||||||
{
|
{
|
||||||
key: 'tools',
|
key: 'tools',
|
||||||
pkg: 'tools',
|
pkg: 'tools',
|
||||||
title: 'Tool registry and execution waterfall',
|
title: 'Tool registry and guarded execution pipeline',
|
||||||
mode: 'core',
|
mode: 'core',
|
||||||
consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
|
consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
|
||||||
note: 'Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute.',
|
note: 'Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'userInteraction',
|
key: 'userInteraction',
|
||||||
@@ -217,6 +217,9 @@ const SERVICE_ROLES: ServiceRole[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: string }> = [
|
const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: string }> = [
|
||||||
|
// tools/result uses ctx.events.dispatch directly so the registry can await
|
||||||
|
// every observer while containing each callback independently.
|
||||||
|
{ event: 'tools/result', pkg: 'tools', method: 'events.dispatch' },
|
||||||
// Subagent lifecycle events intentionally bypass ctx.emit and call
|
// Subagent lifecycle events intentionally bypass ctx.emit and call
|
||||||
// ctx.events.dispatch directly so one throwing listener cannot starve later
|
// ctx.events.dispatch directly so one throwing listener cannot starve later
|
||||||
// listeners or strand an already-started child run.
|
// listeners or strand an already-started child run.
|
||||||
@@ -534,12 +537,12 @@ function collectEventRelations(): Map<string, EventRelation> {
|
|||||||
if (method === 'on') {
|
if (method === 'on') {
|
||||||
const event = eventArg(node.arguments, method)
|
const event = eventArg(node.arguments, method)
|
||||||
if (event) ensure(event).listeners.add(leaf)
|
if (event) ensure(event).listeners.add(leaf)
|
||||||
} else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
|
} else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'strictSerial' || method === 'waterfall') {
|
||||||
const event = eventArg(node.arguments, method)
|
const event = eventArg(node.arguments, method)
|
||||||
if (event) {
|
if (event) {
|
||||||
const relation = ensure(event)
|
const relation = ensure(event)
|
||||||
const methods = relation.dispatchers.get(leaf) ?? new Set<string>()
|
const methods = relation.dispatchers.get(leaf) ?? new Set<string>()
|
||||||
methods.add(method)
|
methods.add(method === 'strictSerial' ? 'strictSerial (serial)' : method)
|
||||||
relation.dispatchers.set(leaf, methods)
|
relation.dispatchers.set(leaf, methods)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,13 +569,13 @@ function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.Sourc
|
|||||||
const target = expr.expression.getText(sf)
|
const target = expr.expression.getText(sf)
|
||||||
if (target === 'ctx' || target === 'this.ctx') return true
|
if (target === 'ctx' || target === 'this.ctx') return true
|
||||||
// Scoped-dispatch spellings (the agent-scoping seam): the loop's fused
|
// Scoped-dispatch spellings (the agent-scoping seam): the loop's fused
|
||||||
// dispatcher (`events` from `agentEvents(ctx, agent)`), the agent's own
|
// dispatcher (`events` from `agentEvents(ctx, agent)`), an agent's setup
|
||||||
// context handle (`this.loopCtx`), and the session store's captured
|
// context (`childCtx`), the agent's own context handle (`this.loopCtx`), and
|
||||||
// dispatch context (`emitCtx`). Conventional receiver names, pinned by the
|
// the session store's captured dispatch context (`emitCtx`). Conventional
|
||||||
// fused-dispatch convention; a rename here must update this list (the
|
// receiver names, pinned by the fused-dispatch convention; a rename here
|
||||||
// producer/consumer matrix silently losing a dispatcher is the failure
|
// must update this list (the producer/consumer matrix silently losing a
|
||||||
// mode this list exists to prevent).
|
// dispatcher or listener is the failure mode this list exists to prevent).
|
||||||
return target === 'events' || target === 'this.loopCtx' || target === 'emitCtx'
|
return target === 'events' || target === 'childCtx' || target === 'this.loopCtx' || target === 'emitCtx'
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventArg(args: ts.NodeArray<ts.Expression>, method: string): string | undefined {
|
function eventArg(args: ts.NodeArray<ts.Expression>, method: string): string | undefined {
|
||||||
@@ -690,6 +693,7 @@ function renderLifecycle(): string {
|
|||||||
' Tools-->>Session: tool-owned events when applicable',
|
' Tools-->>Session: tool-owned events when applicable',
|
||||||
` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`,
|
` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`,
|
||||||
` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`,
|
` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`,
|
||||||
|
` Driver->>Hooks: ${mermaidCode('agent/turn-stop')} serial terminal checkpoint`,
|
||||||
` Driver->>Session: ${mermaidCode('turn/end')}`,
|
` Driver->>Session: ${mermaidCode('turn/end')}`,
|
||||||
` Driver->>Persistence: ${mermaidCode('session/flush')} parallel checkpoint`,
|
` Driver->>Persistence: ${mermaidCode('session/flush')} parallel checkpoint`,
|
||||||
` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
|
` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
|
||||||
@@ -705,7 +709,7 @@ function renderToolPipeline(): string {
|
|||||||
const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'
|
const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'
|
||||||
return [
|
return [
|
||||||
...generatedHeader('Tool Execution Pipeline'),
|
...generatedHeader('Tool Execution Pipeline'),
|
||||||
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, and UI rendering fit without changing the loop. The key extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls.',
|
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards and `tools/result` are the owner-enforced boundaries around them.',
|
||||||
'',
|
'',
|
||||||
'```mermaid',
|
'```mermaid',
|
||||||
'flowchart TD',
|
'flowchart TD',
|
||||||
@@ -713,19 +717,24 @@ function renderToolPipeline(): string {
|
|||||||
` toolCall["Session event: ${mermaidCode('tool/call')}<br/>logged before execution"]`,
|
` toolCall["Session event: ${mermaidCode('tool/call')}<br/>logged before execution"]`,
|
||||||
' presentCall["UI pending card<br/>presentCall(args)"]',
|
' presentCall["UI pending card<br/>presentCall(args)"]',
|
||||||
` pre["${mermaidCode('tools/pre-execute')} waterfall<br/>hooks, permission, sandbox"]`,
|
` pre["${mermaidCode('tools/pre-execute')} waterfall<br/>hooks, permission, sandbox"]`,
|
||||||
|
' guards["Registered monotonic guards<br/>deny or abstain; identity protected"]',
|
||||||
' denied["deny or ask<br/>tool body skipped"]',
|
' denied["deny or ask<br/>tool body skipped"]',
|
||||||
` around["${mermaidCode('tools/execute')} waterfall<br/>timeout, retry, metrics (around dispatch)"]`,
|
` around["${mermaidCode('tools/execute')} waterfall<br/>timeout, retry, metrics (around dispatch)"]`,
|
||||||
' toolBody["Registered tool execute() body"]',
|
' toolBody["Registered tool execute() body"]',
|
||||||
` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
|
` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
|
||||||
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
|
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
|
||||||
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
|
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
|
||||||
|
` final["${mermaidCode('tools/result')} parallel notification<br/>frozen authoritative outcome"]`,
|
||||||
' context["Buffered additionalContext<br/>context/message after all tool results"]',
|
' context["Buffered additionalContext<br/>context/message after all tool results"]',
|
||||||
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
|
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
|
||||||
|
' allResults["All calls in the step settled<br/>and tool/result events recorded"]',
|
||||||
' presentResult["UI completed card<br/>presentResult(args, result)"]',
|
' presentResult["UI completed card<br/>presentResult(args, result)"]',
|
||||||
' model --> toolCall',
|
' model --> toolCall',
|
||||||
' toolCall --> presentCall',
|
' toolCall --> presentCall',
|
||||||
' toolCall --> pre',
|
' toolCall --> pre',
|
||||||
' pre -->|allow| around',
|
' pre -->|allow| guards',
|
||||||
|
' guards -->|allow| around',
|
||||||
|
' guards -->|deny| denied',
|
||||||
' around --> toolBody',
|
' around --> toolBody',
|
||||||
' pre -->|deny or ask| denied',
|
' pre -->|deny or ask| denied',
|
||||||
' denied --> post',
|
' denied --> post',
|
||||||
@@ -734,12 +743,14 @@ function renderToolPipeline(): string {
|
|||||||
' toolBody --> owned',
|
' toolBody --> owned',
|
||||||
' toolBody --> around',
|
' toolBody --> around',
|
||||||
' around --> post',
|
' around --> post',
|
||||||
' post --> context',
|
' post --> final',
|
||||||
' post --> toolResult',
|
' final --> toolResult',
|
||||||
' toolResult --> presentResult',
|
' toolResult --> presentResult',
|
||||||
|
' toolResult --> allResults',
|
||||||
|
' allResults --> context',
|
||||||
'```',
|
'```',
|
||||||
'',
|
'',
|
||||||
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the same pipeline twice over: `run_code` is itself a registered tool body, and each tool call its program makes re-enters `ctx.tools.execute()` through BOTH waterfalls — serialized one at a time, logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).',
|
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The awaited `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution\'s opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).',
|
||||||
'',
|
'',
|
||||||
...maintenanceFooter(maintenance),
|
...maintenanceFooter(maintenance),
|
||||||
].join('\n')
|
].join('\n')
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
|||||||
toolsConfig: { mode: 'code' },
|
toolsConfig: { mode: 'code' },
|
||||||
async mount() {},
|
async mount() {},
|
||||||
note:
|
note:
|
||||||
'Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time.',
|
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry\'s only canonical wire contribution; the other visible capabilities are declared in a protected TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pkg: '@deepseek-ai/dsh-tool-bash',
|
pkg: '@deepseek-ai/dsh-tool-bash',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" },
|
{ "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" },
|
||||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" },
|
{ "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" },
|
||||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" },
|
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" },
|
||||||
|
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationStop", "source": "packages/core/agent/src/types.ts" },
|
||||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" },
|
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" },
|
||||||
|
|
||||||
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" },
|
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" },
|
||||||
@@ -39,7 +40,10 @@
|
|||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" },
|
||||||
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" },
|
||||||
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" },
|
||||||
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" },
|
||||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" },
|
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" },
|
||||||
|
|||||||
Reference in New Issue
Block a user