feat: add canonical typed tool outputs

This commit is contained in:
Tianyi Cui
2026-07-21 03:08:35 +08:00
parent 8500974fd4
commit 66c36e7325
173 changed files with 3298 additions and 954 deletions

View File

@@ -219,7 +219,10 @@ function appendSkippedToolCall(session: Session, turn: number, step: number, blo
appendToolResult(session, turn, step, block, {
content: [{ type: 'text', text: 'Error: tool call skipped because the step was aborted before execution' }],
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: {
message: 'tool call skipped because the step was aborted before execution',
info: { name: 'AbortError', code: 'ABORTED' },
},
}, callSeq)
}

View File

@@ -6,7 +6,7 @@ import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
interface Harness {
@@ -156,7 +156,7 @@ describe('AgentLoop initiator scope', () => {
let parentWhileChildDriverActive: Agent | undefined
let child: Agent | undefined
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'spawn-child',
description: 'create one child agent',
parameters: {},
@@ -168,7 +168,7 @@ describe('AgentLoop initiator scope', () => {
setup: (agentCtx) => {
parentDuringSetup = ctx.agents.requireInitiator()
explicitChild = agentCtx.agent
agentCtx.tools.register(defineTool({
agentCtx.tools.register(defineContentToolFixture({
name: 'observe-child',
description: 'observe child execution identity',
parameters: {},
@@ -216,7 +216,7 @@ describe('AgentLoop initiator scope', () => {
let directAmbient: Agent | undefined
let captured: Agent | undefined
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'agentless-probe',
description: 'observe an agentless call',
parameters: {},
@@ -226,7 +226,7 @@ describe('AgentLoop initiator scope', () => {
return [{ type: 'text', text: 'ok' }]
},
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'capability-request',
description: 'call the test capability transport',
parameters: { path: { type: 'string' } },

View File

@@ -12,7 +12,7 @@ import { Context } from 'cordis'
import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -366,7 +366,7 @@ describe('Agent.cancel()', () => {
])
const ctx = await harness(adapter)
let executions = 0
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'danger',
description: 'must not run after cancellation',
parameters: {},
@@ -397,7 +397,7 @@ describe('Agent.cancel()', () => {
expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({
callId: 'c1',
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { info: { name: 'AbortError', code: 'ABORTED' } },
})
send(agent, 'continue safely')

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import LlmService, { CallId, ContentBlock, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
@@ -50,7 +50,7 @@ describe('session log records what agent/step-result actually produced', () => {
const adapter = new MockAdapter([original, textResponse('done')])
const ctx = await harness(adapter)
const executed: string[] = []
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'injected-tool',
description: '',
parameters: {},
@@ -219,7 +219,7 @@ describe('abort during tool execution ends the turn', () => {
const ctx = await harness(adapter)
const executed: string[] = []
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'aborter',
description: '',
parameters: {},
@@ -241,7 +241,7 @@ describe('abort during tool execution ends the turn', () => {
source: { kind: 'plugin', plugin: 'abort-test' },
}],
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'second',
description: '',
parameters: {},
@@ -259,7 +259,7 @@ describe('abort during tool execution ends the turn', () => {
case 'assistant/message': order.push('assistant/message'); break
case 'tool/call': order.push(`tool/call:${event.data.callId}`); break
case 'tool/result': {
const outcome = event.data.error?.code === 'ABORTED' ? 'synthetic-aborted' : 'real'
const outcome = event.data.error?.info?.code === 'ABORTED' ? 'synthetic-aborted' : 'real'
order.push(`tool/result:${event.data.callId}:${outcome}`)
break
}
@@ -308,7 +308,7 @@ describe('abort during tool execution ends the turn', () => {
expect(results[1]!.data).toMatchObject({
callId: CallId('c2'),
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { info: { name: 'AbortError', code: 'ABORTED' } },
})
})
@@ -316,7 +316,7 @@ describe('abort during tool execution ends the turn', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'aborter',
description: '',
parameters: {},
@@ -362,7 +362,7 @@ describe('abort during tool execution ends the turn', () => {
] satisfies StreamChunk[]])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'first',
description: '',
parameters: {},
@@ -370,7 +370,7 @@ describe('abort during tool execution ends the turn', () => {
return [{ type: 'text', text: 'first done' }]
},
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'aborter',
description: '',
parameters: {},
@@ -411,7 +411,7 @@ describe('abort during tool execution ends the turn', () => {
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'waiter',
description: '',
parameters: {},
@@ -463,7 +463,7 @@ describe('abort during tool execution ends the turn', () => {
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'aborter',
description: '',
parameters: {},
@@ -472,7 +472,7 @@ describe('abort during tool execution ends the turn', () => {
return [{ type: 'text', text: 'done' }]
},
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'second',
description: '',
parameters: {},
@@ -771,7 +771,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'noop',
description: '',
parameters: {},
@@ -837,7 +837,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
const agent = ctx.agentLoop.create(SessionId('owned-steer'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'gate',
description: '',
parameters: {},
@@ -1423,7 +1423,7 @@ describe('tool result call identity', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'echo',
parameters: { x: { type: 'number' } },

View File

@@ -4,7 +4,7 @@ import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -77,7 +77,7 @@ describe('tool JSON parse', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'echo tool',
parameters: { input: { type: 'string' } },
@@ -110,7 +110,7 @@ describe('tool JSON parse', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'noarg',
description: 'no-arg tool',
parameters: {},
@@ -259,7 +259,7 @@ describe('structured tool error propagation (the runtime-validation Agent Note,
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'boom',
description: 'always fails',
parameters: {},
@@ -274,6 +274,6 @@ describe('structured tool error propagation (the runtime-validation Agent Note,
const toolResult = agent.session.events.find(e => e.type === 'tool/result')
expect(toolResult?.type === 'tool/result' && toolResult.data.isError).toBe(true)
expect(toolResult?.type === 'tool/result' && toolResult.data.error)
.toEqual({ name: 'HarnessError', code: 'BOOM' })
.toEqual({ message: 'exploded', info: { name: 'HarnessError', code: 'BOOM' } })
})
})

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -347,7 +347,7 @@ describe('agent/session-prefix', () => {
textResponse('again'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
@@ -469,7 +469,7 @@ describe('agent/session-prefix', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
@@ -522,7 +522,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
it('a stop decision ends the turn even when the step had tool calls', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' })])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
@@ -552,7 +552,7 @@ describe('tool additionalContexts buffering across a step', () => {
]
const adapter = new MockAdapter([twoCalls, textResponse('done')])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
@@ -594,7 +594,7 @@ describe('tool additionalContexts buffering across a step', () => {
it('appends multiple contexts deferred by one composite tool after its outer result', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'composite', {}), textResponse('done')])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'composite', description: 'composite', parameters: {},
async execute(_args, exec) {
exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } })
@@ -625,7 +625,7 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t
const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('ok')])
const ctx = await harness(adapter)
let ran = false
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'danger', description: 'danger', parameters: {},
async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] },
}))
@@ -687,7 +687,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' }), textResponse('done')])
const ctx = await harness(adapter)
await ctx.plugin(NativeGuard)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))

View File

@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, TurnEndReason, type JsonValue } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -89,7 +89,7 @@ describe('agent loop', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'echo back',
parameters: { text: { type: 'string' } },
@@ -118,22 +118,27 @@ describe('agent loop', () => {
const types = agent.session.events.map(e => e.type)
expect(types).toContain('tool/call')
expect(types).toContain('tool/result')
const durableResult = agent.session.events.find(event => event.type === 'tool/result')
expect(durableResult?.type === 'tool/result' && 'value' in durableResult.data).toBe(false)
})
it('threads a tool-attached meta (execute object return) onto the tool/result event', async () => {
it('persists presentation metadata projected from the canonical value', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'writer', { path: 'a.txt' }, 'writing'),
textResponse('done'),
])
const ctx = await harness(adapter)
// A tool that returns the { content, meta } object form: the loop must
// persist `meta` on the tool/result event so a UI reproduces the card on replay.
ctx.tools.register(defineTool({
name: 'writer',
description: 'writes a file',
parameters: { path: { type: 'string' } },
output: {
schema: { type: 'string' },
render: () => [{ type: 'text', text: 'ok' }],
presentationMeta: (_args, value) => ({ diffs: [{ path: value, oldText: null, newText: 'x' }] }),
},
async execute() {
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } }
return 'a.txt'
},
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -152,7 +157,7 @@ describe('agent loop', () => {
// projecting this agent's configured model, so the model knows its own name.
const ctx = await harness(adapter, 'You are a test agent on {{model}}.')
ctx.systemPrompt.section({ name: 'tool:noop', order: 100, text: 'Use the noop tool wisely.' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'noop',
description: 'does nothing',
parameters: {},
@@ -248,7 +253,7 @@ describe('agent loop', () => {
['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) => {
])('normalizes non-JSON presentation metadata (%s) before the durable result commit', async (_kind, meta) => {
const adapter = new MockAdapter([
toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'),
textResponse('recovered'),
@@ -258,7 +263,12 @@ describe('agent loop', () => {
name: 'bad-meta',
description: 'returns invalid durable metadata',
parameters: {},
execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }),
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
presentationMeta: () => meta as unknown as JsonValue,
},
execute: () => Promise.resolve('apparent success'),
}))
const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { provider: 'mock', model: 'mock' })
@@ -326,7 +336,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'slow',
description: '',
parameters: {},
@@ -432,7 +442,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let visibleDuringTool = false
const meta = { kind: 'deferred-test', version: 1 }
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'noticer',
description: 'injects a notice',
parameters: {},
@@ -494,7 +504,7 @@ describe('agent loop', () => {
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'invalid-injector',
description: 'attempts an invalid context injection',
parameters: {},
@@ -541,7 +551,7 @@ describe('agent loop', () => {
it('agent/turn-continuation can veto continuation despite tool calls (budget-guard pattern)', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'x' })])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
@@ -589,7 +599,7 @@ describe('agent loop', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: {},
async execute() { return [{ type: 'text', text: 'echoed' }] },
}))
@@ -781,7 +791,7 @@ describe('agent loop', () => {
]])
const ctx = await harness(adapter)
let executions = 0
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
@@ -821,7 +831,7 @@ describe('agent loop', () => {
{ type: 'finish', reason: { kind: 'max-tokens' } },
]])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
@@ -908,7 +918,7 @@ describe('agent loop', () => {
textResponse('continued after tool call'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
@@ -1240,7 +1250,7 @@ describe('agent loop', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -45,7 +45,7 @@ async function loopHarness(): Promise<Context> {
await created.plugin(AgentRegistry)
await created.plugin(AgentLoop, { agents: [] })
await created.plugin(LlmDeepSeek)
created.tools.register(defineTool({
created.tools.register(defineContentToolFixture({
name: 'lookup',
description: 'Look up the stored value for a key.',
parameters: { key: { type: 'string', description: 'The key to look up.' } },

View File

@@ -11,7 +11,7 @@ import LlmService from '@deepseek-ai/dsh-llm'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -53,7 +53,7 @@ function expectPrefixExtension(previous: GenerateOptions, current: GenerateOptio
}
function registerEcho(ctx: Context) {
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'echo back',
parameters: { text: { type: 'string' } },

View File

@@ -11,7 +11,7 @@ import LlmService, {
import type { GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -133,7 +133,7 @@ describe('agent post-step and request-error lifecycle', () => {
]
const adapter = new FailureScriptAdapter([twoCalls, textResponse('done')])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'work',
description: 'do work',
parameters: {},
@@ -222,7 +222,7 @@ describe('agent post-step and request-error lifecycle', () => {
textResponse('must not continue'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'work',
description: 'do work',
parameters: {},
@@ -535,7 +535,7 @@ describe('agent post-step and request-error lifecycle', () => {
contextError('later overflow'),
])
const resetCtx = await harness(reset)
resetCtx.tools.register(defineTool({
resetCtx.tools.register(defineContentToolFixture({
name: 'work',
description: 'continue',
parameters: {},

View File

@@ -3,7 +3,7 @@ import { Context, symbols, type EffectMeta, type Fiber } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -172,10 +172,10 @@ describe('agent scope lifecycle', () => {
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } })
const { agent } = handle
agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
agent.ctx.tools.register({
agent.ctx.tools.register(defineContentToolFixture({
name: 'mine', description: 'scoped', parameters: {},
execute: () => Promise.resolve(text('ran')),
})
}))
const scopedAssembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
expect(scopedAssembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You run tests.')
@@ -587,12 +587,12 @@ describe('agent scope lifecycle', () => {
sessionId: SessionId('dependency-origin-s'),
agentOptions: { provider: 'mock', model: 'mock' },
setup: (agentCtx) => {
agentCtx.tools.register({
agentCtx.tools.register(defineContentToolFixture({
name: 'dependency-origin-tool',
description: 'proves AgentLoop dependency origin',
parameters: {},
execute: () => Promise.resolve(text('ok')),
})
}))
agentCtx.systemPrompt.section({
name: 'dependency-origin-section',
order: 1,

View File

@@ -9,7 +9,7 @@ import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import LlmService from '@deepseek-ai/dsh-llm'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -61,7 +61,7 @@ function multiCall(calls: { id: string; name: string; args: object }[]): StreamC
function gatedTool(name: string, parallel: boolean) {
const gates = new Map<string, () => void>()
const started: string[] = []
const tool = defineTool({
const tool = defineContentToolFixture({
name,
description: `gated ${name}`,
parameters: { id: { type: 'string', required: true } },
@@ -123,12 +123,12 @@ describe('tool-call scheduler: grouping and barriers', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'r', description: 'read', parameters: { id: { type: 'string', required: true } },
isConcurrencySafe: () => true,
async execute(args) { order.push(`r-start-${args.id}`); order.push(`r-end-${args.id}`); return [{ type: 'text', text: 'r' }] },
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } },
async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] },
}))
@@ -150,14 +150,14 @@ describe('tool-call scheduler: grouping and barriers', () => {
])
const ctx = await harness(adapter)
const replacement = gatedExclusiveTool('x')
const disposeSafe = ctx.tools.register(defineTool({
const disposeSafe = ctx.tools.register(defineContentToolFixture({
name: 'x',
description: 'initially safe',
parameters: { id: { type: 'string', required: true } },
isConcurrencySafe: () => true,
async execute(args) { return [{ type: 'text', text: `old-${args.id}` }] },
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'replace',
description: 'replace x',
parameters: { id: { type: 'string', required: true } },
@@ -476,8 +476,22 @@ describe('tool-call scheduler: abort handling', () => {
isError: e.data.isError,
error: e.data.error,
}))).toEqual([
{ callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } },
{ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } },
{
callId: CallId('c1'),
isError: true,
error: {
message: 'tool call skipped because the step was aborted before execution',
info: { name: 'AbortError', code: 'ABORTED' },
},
},
{
callId: CallId('c2'),
isError: true,
error: {
message: 'tool call skipped because the step was aborted before execution',
info: { name: 'AbortError', code: 'ABORTED' },
},
},
])
})
@@ -509,7 +523,7 @@ describe('tool-call scheduler: abort handling', () => {
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2')])
expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data)
.toMatchObject({ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } })
.toMatchObject({ callId: CallId('c2'), isError: true, error: { info: { name: 'AbortError', code: 'ABORTED' } } })
})
it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => {
@@ -538,10 +552,14 @@ describe('tool-call scheduler: abort handling', () => {
.toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')])
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')])
expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => e.data))
expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => ({
callId: e.data.callId,
isError: e.data.isError,
errorInfo: e.data.error?.info,
})))
.toEqual([
expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }),
expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }),
{ callId: CallId('c3'), isError: true, errorInfo: { name: 'AbortError', code: 'ABORTED' } },
{ callId: CallId('c4'), isError: true, errorInfo: { name: 'AbortError', code: 'ABORTED' } },
])
const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message')
expect(settled.map(e => e.type))
@@ -564,7 +582,7 @@ describe('tool-call scheduler: abort handling', () => {
const gated = gatedParallelTool('p')
const exclusive: string[] = []
ctx.tools.register(gated.tool)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'x',
description: 'exclusive',
parameters: { id: { type: 'string', required: true } },
@@ -583,6 +601,6 @@ describe('tool-call scheduler: abort handling', () => {
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2'), CallId('c3')])
expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data)
.toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } })
.toMatchObject({ callId: CallId('c3'), isError: true, error: { info: { name: 'AbortError', code: 'ABORTED' } } })
})
})

View File

@@ -12,7 +12,7 @@ import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function registerNamed(ctx: Context, name: string) {
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name,
description: `the ${name} tool`,
parameters: {},

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -29,7 +29,7 @@ function send(agent: Agent, text = 'go'): Promise<void> {
}
function registerEcho(ctx: Context): void {
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'echo',
parameters: { text: { type: 'string' } },

View File

@@ -58,6 +58,8 @@ Durable values need one accepted representation, not a check followed by a secon
`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`.
`tool/result` persists the model-facing content, canonical failure detail, and optional presentation metadata. A tool's successful canonical `value` is deliberately execution-local and never enters the session event, so replay reconstructs the Native/model presentation but cannot recover intermediate programmatic values. This does not change `SESSION_FORMAT_VERSION`: the persisted projection remains authoritative.
### Session event vocabulary (`types.ts`)
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure.

View File

@@ -92,7 +92,10 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
callId,
content: [{ type: 'text', text: 'Tool call interrupted by a crash; no result was recorded.' }],
isError: true,
error: { name: 'InterruptedError', code: 'interrupted' },
error: {
message: 'Tool call interrupted by a crash; no result was recorded.',
info: { name: 'InterruptedError', code: 'interrupted' },
},
},
surfaceOp: 'append',
...callSeq !== undefined ? { sourceEventSeqs: [callSeq] } : {},

View File

@@ -243,15 +243,24 @@ export interface SessionEventMap {
*/
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
/**
* A completed tool call's model-facing result, plus an optional tool-private
* `meta` presentation payload. `meta` is opaque to the core (`unknown` — the
* producing tool owns its shape and reads it back in `presentResult`) but MUST
* be JSON-serializable: `Session.append` runtime-validates all event data with
* `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the
* durable log reproduces the identical card on replay. Absent unless the tool
* attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here).
* A completed tool call's model-facing result, canonical failure detail, and
* optional tool-private `meta` presentation payload. `meta` is opaque to the
* core (the producing tool owns its shape and reads it back in `presentResult`)
* but MUST be JSON-serializable: `Session.append` runtime-validates all event
* data with `isJsonValue`, so a non-serializable `meta` is rejected at the
* source, and the durable log reproduces the identical card on replay. Absent
* unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time
* contextual diff here).
*/
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown }
'tool/result': {
turn: number
step: number
callId: CallId
content: ContentBlock[]
isError: boolean
error?: { message: string; info?: { name: string; code: string } }
meta?: JsonValue
}
/** Steering content injected between steps of a running turn. */
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */

View File

@@ -64,7 +64,7 @@ describe('interruptedTurnClosers', () => {
expect(closers.map(e => e.seq)).toEqual([3, 4, 5])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data).toMatchObject({
turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: 'interrupted' },
turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { info: { code: 'interrupted' } },
})
})

View File

@@ -15,7 +15,7 @@ tools:
### Public API
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. Disposed with the calling fiber.
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition with a mandatory canonical `output` declaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Missing/unsupported output declarations and a non-positive/non-finite `timeoutMs` fail at registration. Disposed with the calling fiber.
- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)).
@@ -33,14 +33,14 @@ The live registry pipeline has three transformable waterfalls followed by the ob
### Key types
- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification.
- `ToolDefinition` — `ToolSchema` + mandatory `output { schema, render, presentationMeta? }` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. A body returns only the canonical JSON value declared by the output schema.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
- `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute `HookContext` for the loop's post-result FIFO.
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
- `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
- `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
- `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.
- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
@@ -48,8 +48,8 @@ The live registry pipeline has three transformable waterfalls followed by the ob
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
- `tools/pre-execute` is the reorderable allow/deny/ask gate; `ctx.tools.guard()` adds monotonic owner policy after it.
- `tools/execute` wraps normalized core dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal.
- `tools/post-execute` may replace content, block with feedback, or attach ordered contexts; `tools/result` observes the immutable final outcome.
- `tools/execute` wraps normalized canonical dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal; a wrapper-authored success is normalized through the resolved tool's output declaration.
- `tools/post-execute` may replace presentation content, replace the canonical value, block with feedback, or attach ordered contexts; `tools/result` observes the immutable final outcome. Content replacement is not a confidentiality boundary: block or replace the value when programmatic consumers must not receive it.
- Exact signatures and ordering live in the generated [event catalog](../../../docs/cordis-catalog/events.md) and [pipeline](../../../docs/tool-execution-pipeline.md).
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
@@ -72,17 +72,20 @@ ctx.tools.register(defineTool({
offset: { type: 'number' },
limit: { type: 'number' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
// args is typed: { path: string; offset?: number; limit?: number }
const text = await readFile(args.path, 'utf8')
return [{ type: 'text', text }]
return readFile(args.path, 'utf8')
},
}))
```
The unified schema DSL uses `ParameterSchemaSpec` for the implicit open parameter object and `ValueSchemaSpec` for any JSON-value root. It supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum`/`const` values are type-correct. Every explicit DSL object declares `additionalProperties: true | false`, while the implicit parameter root and raw JSON Schema keep the standard open default.
A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. Extra keys are allowed, defaults are not applied, and object or array fields without `properties` or `items` receive only a type check. Raw-registered tools own their validation.
A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. It also infers the body return and pure output projectors from `output.schema`; the registry snapshots and validates the returned lossless JSON before presentation. Extra parameter keys are allowed, defaults are not applied, and object or array fields without `properties` or `items` receive only a type check. Raw-registered tools own input validation but still declare and receive registry-enforced output.
See `defineTool`, `validateArgs`, `ToolArgsError`, `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, `InferArgs`, `valueSchemaSpecToJsonSchema`, and `parameterSchemaSpecToJsonSchema` in the public API for details.
@@ -101,7 +104,7 @@ Tools optionally own pure `presentCall()` and `presentResult()` render intents,
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`.
Returning `undefined` selects generic fallback. Presenters depend only on their arguments because UIs call them during live streaming and log replay. Result presentation may read JSON-serializable `result.meta`, which persists with the result; `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the rationale.
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.
### Code Mode

View File

@@ -10,7 +10,7 @@ import { inspect } from 'node:util'
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type {} from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import { defineTool } from './schema.ts'
import type { ToolDefinition, ToolRegistry } from './index.ts'
@@ -111,9 +111,8 @@ function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unkno
return { dispatched: JSON.parse(text) as unknown, logged: JSON.parse(text) as unknown }
}
/** Render the program's completion value for the model-facing result text (`''` when the program returned nothing). */
function renderValue(value: unknown): string {
if (value === undefined) return ''
/** Render one present program completion value for the model-facing result text. */
function renderValue(value: JsonValue): string {
return typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS)
}
@@ -122,6 +121,9 @@ interface RunCodeMeta {
logs: CodeRunResult['logs']
}
/** Canonical value returned by the outer Code Mode transport. */
type RunCodeOutput = { logs: string[]; result?: JsonValue }
/** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */
function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
if (typeof meta !== 'object' || meta === null) return undefined
@@ -152,7 +154,23 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
parameters: {
code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' },
},
async execute(args, exec) {
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
logs: { type: 'array', required: true, items: { type: 'string' } },
result: { type: 'json' },
},
},
render: (_args, value) => {
const rendered = value.result === undefined ? '' : renderValue(value.result)
const parts = [value.logs.join('\n'), rendered].filter(part => part.length > 0)
return [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }]
},
presentationMeta: (_args, value) => ({ logs: value.logs }),
},
async execute(args, exec): Promise<RunCodeOutput> {
const runtime = requireRuntime()
// The run-scoped abort: follows the outer signal in, and fires when the
@@ -265,12 +283,12 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : ''
throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`)
}
const rendered = renderValue(result.value)
const parts = [result.logs.join('\n'), rendered].filter(part => part.length > 0)
const meta: RunCodeMeta = { logs: result.logs }
// The runtime seam is wider than JSON until PR 3 makes this boundary
// lossless. The registry immediately snapshots and rejects any value
// that does not satisfy the declared JSON output.
return {
content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }],
meta,
logs: result.logs,
...result.value !== undefined ? { result: result.value as JsonValue } : {},
}
} finally {
exec.signal?.removeEventListener('abort', onOuterAbort)

View File

@@ -12,12 +12,15 @@ import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
// augmentation. The seam stays optional at runtime — see `serviceAsk`.
import type {} from '@deepseek-ai/dsh-user-approval'
import type { ToolCallView, ToolResultView } from './presentation.ts'
import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts'
import type { JsonSchemaNode } from './json-schema.ts'
import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
import { renderToolsSdk } from './ts-types.ts'
@@ -61,6 +64,7 @@ export type { JsonValue } from '@deepseek-ai/dsh-session'
export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts'
export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'
export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts'
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
@@ -132,12 +136,22 @@ declare module 'cordis' {
}
}
/** Tool output, optionally with lossless-JSON presentation metadata persisted for replay. */
export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
/** Tool-owned canonical output contract used after the body returns a JSON value. */
export interface ToolOutputDefinition {
/** Raw supported JSON Schema enforced against every successful canonical value. */
readonly schema: JsonSchemaNode
/** Pure projection from validated arguments and value to Native/model content. */
render(args: unknown, value: JsonValue): ContentBlock[]
/** Pure replayable presentation projection, computed only for surface calls. */
presentationMeta?(args: unknown, value: JsonValue): JsonValue
}
/** A registered tool: its schema plus the execution function. */
export interface ToolDefinition extends ToolSchema {
execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>
/** Mandatory canonical output declaration. */
readonly output: ToolOutputDefinition
/** Execute the tool and return only its canonical lossless-JSON value. */
execute(args: unknown, exec: ToolRunContext): Promise<unknown>
/**
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
* Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
@@ -172,7 +186,7 @@ export interface ToolDefinition extends ToolSchema {
presentCall?(args: unknown): ToolCallView | undefined
/**
* Optional: how to present the COMPLETED state, given the same `args` and the
* `result` (`execute`'s content + whether it errored). Returns a
* durable result projection (`content`, failure state, and optional `meta`). Returns a
* {@link ToolResultView}, or `undefined` (or omit the method) to keep the
* pending title and render the raw result content. Pure and side-effect-free
* for the same replay reason.
@@ -182,17 +196,16 @@ export interface ToolDefinition extends ToolSchema {
/** The completed outcome handed to {@link ToolDefinition.presentResult}. */
export interface ToolResult {
/** The model-facing content `execute` returned (or the error text on failure). */
/** The final model-facing content (or the rendered error text on failure). */
content: ContentBlock[]
/** Whether the call failed. */
isError: boolean
/**
* The tool-private presentation payload the tool attached from `execute` (via
* the object return form), threaded verbatim from the `tool/result` event.
* Opaque (`unknown`); the tool narrows it back to its own shape. Absent when
* the tool attached none.
* The tool-private presentation payload projected by its output declaration
* and threaded verbatim from the `tool/result` event. Absent when the tool
* declared no projector or the call was nested under a composite transport.
*/
meta?: unknown
meta?: JsonValue
}
declare const toolExecutionTokenBrand: unique symbol
@@ -303,6 +316,14 @@ export interface ToolErrorInfo {
code: string
}
/** Canonical failure detail; internal routing information remains optional. */
export interface ToolFailure {
/** Human-readable failure message without the Native `Error: ` envelope. */
message: string
/** Internal error class/code used by policy and durable diagnostics. */
info?: ToolErrorInfo
}
/**
* Thrown (internally) when the model requests a tool that isn't registered.
* Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool
@@ -316,30 +337,42 @@ export class ToolNotFoundError extends HarnessError {
}
}
/** The outcome of one tool call. */
export interface ToolExecutionResult {
content: ContentBlock[]
isError: boolean
/**
* Set when the call failed with a {@link HarnessError}: machine-routable
* `{ name, code }` for retry/sandbox plugins and replay. The model-facing
* text in `content` is always present; this is extra structure for code.
*/
error?: ToolErrorInfo
/**
* Model-facing context for the next request, separate from this tool result. The loop
* accepts it into the active-batch FIFO, then appends after recorded results even if interrupted.
*/
additionalContexts?: HookContext[]
/**
* The tool-private presentation payload from a successful `execute` (the object
* return form). Threaded onto the `tool/result` session event and back into
* {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the
* tool attached none or the call failed.
*/
meta?: unknown
/** Thrown when a tool body or post-policy value violates its declared output. */
export class ToolOutputError extends HarnessError {
/** Schema/value violations in validation order. */
readonly violations: string[]
constructor(toolName: string, violations: string[]) {
super(`tool "${toolName}" returned invalid output: ${violations.join('; ')}`, 'INVALID_TOOL_OUTPUT')
this.name = 'ToolOutputError'
this.violations = violations
}
}
/** Successful canonical tool execution, including its Native/model projection. */
export interface ToolExecutionSuccess {
readonly isError: false
/** Execution-local canonical value; deliberately omitted from durable events. */
readonly value: JsonValue
readonly content: ContentBlock[]
readonly error?: never
readonly meta?: JsonValue
readonly additionalContexts?: HookContext[]
}
/** Failed canonical tool execution; failures never carry a successful value. */
export interface ToolExecutionFailure {
readonly isError: true
readonly error: ToolFailure
readonly value?: never
readonly content: ContentBlock[]
readonly meta?: JsonValue
readonly additionalContexts?: HookContext[]
}
/** The discriminated, execution-local outcome of one tool call. */
export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure
/**
* Pre-dispatch decision. `allow` runs the call; `deny` materializes an error;
* `ask` runs only after an approval service returns `allowed-once` and otherwise
@@ -352,11 +385,12 @@ export type PreToolDecision =
| { kind: 'ask'; reason?: string }
/**
* Post-dispatch decision: accept or replace content, attach context for the next
* request, or block by turning corrective feedback into an error result.
* Post-dispatch decision: accept, replace one projection, attach context for the
* next request, or block by turning corrective feedback into an error result.
*/
export type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] }
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] }
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] }
/**
@@ -381,6 +415,23 @@ function errorMessage(error: unknown): string {
}
}
/** Derive one failure message from policy feedback without changing its rendered blocks. */
function failureMessageFromContent(content: ContentBlock[]): string {
const text = content
.map(block => block.type === 'text' ? block.text : `[${block.type} content]`)
.join('\n')
return text.length > 0 ? text : 'tool result blocked by post-execute policy'
}
/** Snapshot and freeze one durable tool-result projection or reject lossy data. */
function materializePresentation<T>(candidate: T): T {
const detached = snapshotJsonValue(candidate)
if (detached === undefined) {
throw new TypeError('tool result must be losslessly JSON-serializable')
}
return deepFreeze(detached)
}
/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
function errorInfo(error: unknown): ToolErrorInfo | undefined {
try {
@@ -553,6 +604,13 @@ export class ToolRegistry extends Service {
register(definition: ToolDefinition): () => void {
const scope = scopeOf(this.ctx)
const name = definition.name
const output = (definition as Partial<ToolDefinition>).output
if (output === undefined || typeof output !== 'object'
|| typeof output.render !== 'function'
|| (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function')) {
throw new TypeError(`tool "${name}" must declare output { schema, render, presentationMeta? }`)
}
assertSupportedJsonSchema(output.schema)
const timeoutMs = definition.timeoutMs
if (timeoutMs !== undefined
&& (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
@@ -880,10 +938,11 @@ export class ToolRegistry extends Service {
return await next({
kind: 'post-result',
exec,
result: {
result: this.materializeFinalResult({
content: [{ type: 'text', text: `Error: ${denialReason}` }],
isError: true,
},
error: { message: denialReason },
}),
})
}
return await next({ kind: 'dispatch', exec })
@@ -909,27 +968,26 @@ export class ToolRegistry extends Service {
const tool = this.get(exec.name, exec.agent)
if (!tool) throw new ToolNotFoundError(exec.name)
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
return { content, isError: false, ...meta !== undefined ? { meta } : {} }
return this.createSuccessResult(exec, tool, returned)
} catch (error: unknown) {
return toolErrorResult(error)
return this.materializeFinalResult(toolErrorResult(error))
}
},
)
const normalized = this.normalizeDispatchResult(exec, result)
const deferredContexts = this.deferredContexts.get(exec)
/* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution')
const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
? result
: {
...result,
? normalized
: this.markCanonical({
...normalized,
additionalContexts: [
...deferredContexts,
...result.additionalContexts ?? [],
...normalized.additionalContexts ?? [],
],
}
return { kind: 'post-result', result: resultWithDeferredContexts }
})
return { kind: 'post-result', result: this.materializeFinalResult(resultWithDeferredContexts) }
} catch (error: unknown) {
return { kind: 'final-result', result: toolErrorResult(error) }
}
@@ -1046,32 +1104,103 @@ export class ToolRegistry extends Service {
)
const decisionContexts = decision.additionalContexts ?? []
if (decision.kind === 'block') {
return {
const message = failureMessageFromContent(decision.feedback)
return this.markCanonical({
content: decision.feedback,
isError: true,
error: { message },
...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {},
}
})
}
if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) {
throw new TypeError('tools/post-execute accept decision cannot replace both value and content')
}
// Accept: replace content if supplied, preserve the dispatched outcome, and
// append decision contexts after contexts deferred by the tool body.
const additionalContexts = [
...result.additionalContexts ?? [],
...decisionContexts,
]
return {
...result,
...decision.content ? { content: decision.content } : {},
...additionalContexts.length > 0 ? { additionalContexts } : {},
if (Object.hasOwn(decision, 'value')) {
if (result.isError) {
throw new TypeError('tools/post-execute cannot replace the value of a failed result')
}
const tool = this.get(exec.name, exec.agent)
if (tool === undefined) throw new ToolNotFoundError(exec.name)
const replaced = this.createSuccessResult(exec, tool, decision.value)
return this.markCanonical({
...replaced,
...additionalContexts.length > 0 ? { additionalContexts } : {},
})
}
return this.markCanonical({
...result,
...decision.content !== undefined ? { content: decision.content } : {},
...additionalContexts.length > 0 ? { additionalContexts } : {},
})
}
/** Results created by the registry already own a validated, frozen canonical value. */
private readonly canonicalResults = new WeakSet<object>()
/** Mark a registry-normalized result without freezing presentation fields prematurely. */
private markCanonical<T extends ToolExecutionResult>(result: T): T {
this.canonicalResults.add(result)
return result
}
/** Snapshot, validate, render, and optionally project one successful body value. */
private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess {
const detached = snapshotJsonValue(candidate)
if (detached === undefined) {
throw new ToolOutputError(tool.name, ['value is not lossless JSON'])
}
const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value')
if (violations.length > 0) throw new ToolOutputError(tool.name, violations)
const value = deepFreeze(detached as JsonValue)
const content = tool.output.render(exec.arguments, value)
const meta = exec.parent === undefined && tool.output.presentationMeta !== undefined
? tool.output.presentationMeta(exec.arguments, value)
: undefined
return this.markCanonical(this.materializeFinalResult({
isError: false,
value,
content,
...meta !== undefined ? { meta } : {},
}) as ToolExecutionSuccess)
}
/** Normalize an around-dispatch wrapper's authored result through the owning output contract. */
private normalizeDispatchResult(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult {
if (this.canonicalResults.has(result)) return result
if (result.isError) {
return this.markCanonical({
isError: true,
error: result.error,
content: result.content,
...result.meta !== undefined ? { meta: result.meta } : {},
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
})
}
const tool = this.get(exec.name, exec.agent)
if (tool === undefined) throw new ToolNotFoundError(exec.name)
const normalized = this.createSuccessResult(exec, tool, result.value)
return this.markCanonical({
...normalized,
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
})
}
/** Materialize the authoritative commit outcome once, immediately before `tools/result`. */
private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult {
const detached = snapshotJsonValue(result)
if (detached === undefined) {
throw new TypeError('tool result must be losslessly JSON-serializable')
const presentation = {
content: result.content,
...result.meta !== undefined ? { meta: result.meta } : {},
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
}
return deepFreeze(detached)
if (result.isError) {
return materializePresentation({ isError: true as const, error: result.error, ...presentation })
}
const detached = materializePresentation({ isError: false as const, ...presentation })
return deepFreeze({ ...detached, value: result.value })
}
}
@@ -1082,10 +1211,11 @@ function createExecutionToken(): ToolExecutionToken {
function toolErrorResult(error: unknown): ToolExecutionResult {
const info = errorInfo(error)
const message = errorMessage(error)
return {
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
content: [{ type: 'text', text: `Error: ${message}` }],
isError: true,
...info ? { error: info } : {},
error: { message, ...info ? { info } : {} },
}
}

View File

@@ -1,8 +1,9 @@
/** Unified JSON-value schema DSL, inference, compilation, and typed tool helper. @module dsh-tools/schema */
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { ToolDefinition, ToolExecuteReturn, ToolRunContext, ToolResult } from './index.ts'
import type { ToolDefinition, ToolRunContext, ToolResult } from './index.ts'
import { assertSupportedJsonSchema, isPlainJsonRecord, JsonSchemaError, validateJsonSchemaValue } from './json-schema.ts'
import type { JsonSchemaNode, JsonSchemaScalar, ObjectJsonSchema } from './json-schema.ts'
import type { ToolCallView, ToolResultView } from './presentation.ts'
@@ -114,21 +115,25 @@ type RequiredKeys<S extends ParameterSchemaSpec> = {
[K in keyof S]: S[K] extends { required: true } ? K : never
}[keyof S]
/** Advance the bounded inference walk through one nested schema node. */
type NextDepth<D extends readonly unknown[]> = readonly [...D, unknown]
/** Infer the declared value of one parameter property without key optionality. */
type InferProperty<P extends ParameterPropertySpec> = P extends ValueSchemaSpec ? InferValue<P> : never
type InferProperty<P extends ParameterPropertySpec, D extends readonly unknown[]> =
P extends ValueSchemaSpec ? InferValue<P, D> : never
/** Infer an implicit property map into required and optional object keys. */
type InferProperties<S extends ParameterSchemaSpec> = Simplify<
& { [K in RequiredKeys<S>]: InferProperty<S[K]> }
& { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferProperty<S[K]> }
type InferProperties<S extends ParameterSchemaSpec, D extends readonly unknown[]> = Simplify<
& { [K in RequiredKeys<S>]: InferProperty<S[K], D> }
& { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferProperty<S[K], D> }
>
/** Infer an explicit object node, including its declared openness. */
type InferObject<S extends ObjectValueSchemaSpec> =
type InferObject<S extends ObjectValueSchemaSpec, D extends readonly unknown[]> =
S extends { properties: infer P extends ParameterSchemaSpec }
? S['additionalProperties'] extends true
? InferProperties<P> & Record<string, JsonValue>
: InferProperties<P>
? InferProperties<P, D> & Record<string, JsonValue>
: InferProperties<P, D>
: S['additionalProperties'] extends true
? Record<string, JsonValue>
: Record<string, never>
@@ -143,20 +148,21 @@ type InferScalar<S, Fallback> =
* Infer the TypeScript value accepted by an author-facing value schema.
* Output schemas may therefore infer object, array, scalar, or null roots.
*/
export type InferValue<S extends ValueSchemaSpec> =
S extends StringValueSchemaSpec ? InferScalar<S, string> :
S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar<S, number> :
S extends BooleanValueSchemaSpec ? InferScalar<S, boolean> :
S extends NullValueSchemaSpec ? null :
S extends ArrayValueSchemaSpec
? S extends { items: infer I extends ValueSchemaSpec } ? InferValue<I>[] : JsonValue[]
: S extends ObjectValueSchemaSpec ? InferObject<S> :
S extends JsonValueSchemaSpec ? JsonValue :
S extends OneOfValueSchemaSpec ? InferValue<S['oneOf'][number]> :
never
export type InferValue<S extends ValueSchemaSpec, D extends readonly unknown[] = readonly []> =
D['length'] extends 12 ? JsonValue :
S extends StringValueSchemaSpec ? InferScalar<S, string> :
S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar<S, number> :
S extends BooleanValueSchemaSpec ? InferScalar<S, boolean> :
S extends NullValueSchemaSpec ? null :
S extends ArrayValueSchemaSpec
? S extends { items: infer I extends ValueSchemaSpec } ? InferValue<I, NextDepth<D>>[] : JsonValue[]
: S extends ObjectValueSchemaSpec ? InferObject<S, NextDepth<D>> :
S extends JsonValueSchemaSpec ? JsonValue :
S extends OneOfValueSchemaSpec ? InferValue<S['oneOf'][number], NextDepth<D>> :
never
/** Infer the TypeScript argument object for an implicit parameter schema. */
export type InferArgs<S extends ParameterSchemaSpec> = InferProperties<S>
export type InferArgs<S extends ParameterSchemaSpec> = InferProperties<S, readonly []>
const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
@@ -329,13 +335,22 @@ export function validateArgs(spec: ParameterSchemaSpec, args: unknown): string[]
}
/** Options for {@link defineTool}. */
export interface DefineToolOptions<S extends ParameterSchemaSpec> {
export interface DefineToolOptions<S extends ParameterSchemaSpec, O extends ValueSchemaSpec> {
/** Tool name (must be unique). */
readonly name: string
/** Human-readable description sent to the model. */
readonly description: string
/** Per-property parameter schema compiled to an implicit open object root. */
readonly parameters: S
/** Canonical output schema plus pure Native and presentation projections. */
readonly output: {
/** Schema enforced against every successful body or policy-replaced value. */
readonly schema: O
/** Pure Native/model rendering of one validated canonical value. */
render(args: InferArgs<S>, value: InferValue<NoInfer<O>>): ContentBlock[]
/** Pure replayable presentation metadata for direct surface calls. */
presentationMeta?(args: InferArgs<S>, value: InferValue<NoInfer<O>>): JsonValue
}
/** Optional positive cooperative timeout budget in milliseconds. */
readonly timeoutMs?: number
/**
@@ -348,9 +363,9 @@ export interface DefineToolOptions<S extends ParameterSchemaSpec> {
* Execute the tool after argument validation.
* @param args - typed validated arguments.
* @param exec - execution identity, caller, cancellation, and nesting data.
* @returns Model-facing content and optional presentation metadata.
* @returns The canonical value declared by `output.schema`.
*/
execute(args: InferArgs<S>, exec: ToolRunContext): Promise<ToolExecuteReturn>
execute(args: InferArgs<S>, exec: ToolRunContext): Promise<InferValue<NoInfer<O>>>
/**
* Pure pending-state presenter.
* @param args - typed validated arguments.
@@ -373,11 +388,17 @@ export interface DefineToolOptions<S extends ParameterSchemaSpec> {
* @param options - typed definition and optional presenters.
* @returns A registry-ready definition.
*/
export function defineTool<S extends ParameterSchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
export function defineTool<const S extends ParameterSchemaSpec, const O extends ValueSchemaSpec>(
options: DefineToolOptions<S, O>,
): ToolDefinition {
// Object-literal methods do not use `this`; retaining references is safe.
// eslint-disable-next-line @typescript-eslint/unbound-method
const userExecute = options.execute
// eslint-disable-next-line @typescript-eslint/unbound-method
const userRender = options.output.render
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentationMeta = options.output.presentationMeta
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentCall = options.presentCall
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentResult = options.presentResult
@@ -387,16 +408,28 @@ export function defineTool<S extends ParameterSchemaSpec>(options: DefineToolOpt
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
}
const parameters = parameterSchemaSpecToJsonSchema(options.parameters)
const outputSchema = valueSchemaSpecToJsonSchema(options.output.schema)
const validate = (args: unknown): string[] => validateJsonSchemaValue(parameters, args, '')
const tool: ToolDefinition = {
name: options.name,
description: options.description,
parameters: parameters as unknown as Record<string, unknown>,
output: {
schema: outputSchema,
render(args: unknown, value: JsonValue): ContentBlock[] {
return userRender(args as InferArgs<S>, value as unknown as InferValue<NoInfer<O>>)
},
...userPresentationMeta !== undefined ? {
presentationMeta(args: unknown, value: JsonValue): JsonValue {
return userPresentationMeta(args as InferArgs<S>, value as unknown as InferValue<NoInfer<O>>)
},
} : {},
},
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
async execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn> {
async execute(args: unknown, exec: ToolRunContext): Promise<JsonValue> {
const violations = validate(args)
if (violations.length > 0) throw new ToolArgsError(violations)
return userExecute(args as InferArgs<S>, exec)
return userExecute(args as InferArgs<S>, exec) as Promise<JsonValue>
},
}
if (userPresentCall) {

View File

@@ -0,0 +1,42 @@
/** Canonical tool-definition fixtures for repository tests. @module dsh-tools/testing */
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import { defineTool } from './schema.ts'
import type { DefineToolOptions, ParameterSchemaSpec } from './schema.ts'
import type { ToolDefinition, ToolRunContext } from './index.ts'
const CONTENT_VALUE_SCHEMA = { type: 'array', items: { type: 'json' } } as const
/** Options for a fixture whose canonical value is its rendered content array. */
export type ContentToolFixtureOptions<S extends ParameterSchemaSpec> = Omit<
DefineToolOptions<S, typeof CONTENT_VALUE_SCHEMA>,
'output' | 'execute'
> & {
/** Produce the fixture's content blocks as its canonical test value. */
execute(args: import('./schema.ts').InferArgs<S>, exec: ToolRunContext): Promise<ContentBlock[]>
}
/**
* Define a test fixture that deliberately uses its content blocks as the
* canonical JSON value. Product tools must declare domain-owned DTOs instead.
* @param options - ordinary fixture fields plus a content-producing body.
* @returns a registry-ready tool with an explicit JSON-array output contract.
* @internal
*/
export function defineContentToolFixture<const S extends ParameterSchemaSpec>(
options: ContentToolFixtureOptions<S>,
): ToolDefinition {
// eslint-disable-next-line @typescript-eslint/unbound-method
const execute = options.execute
return defineTool({
...options,
output: {
schema: CONTENT_VALUE_SCHEMA,
render: (_args, value) => value as unknown as ContentBlock[],
},
async execute(args, exec) {
return await execute(args, exec) as unknown as JsonValue[]
},
})
}

View File

@@ -6,7 +6,7 @@ import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
@@ -68,7 +68,7 @@ async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: S
/** Register a trivial echo tool; returns the calls it received. */
function registerEcho(ctx: Context, name = 'echo'): unknown[] {
const calls: unknown[] = []
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name,
description: `Echo tool ${name}.`,
parameters: { value: { type: 'string', required: true } },
@@ -217,7 +217,7 @@ describe('mode-aware wire contribution', () => {
it.each(['code', 'both'] as const)('reserves run_code against scoped shadows and explicit restrictions in mode %s', async (mode) => {
const { ctx, systemPrompt } = await setup({ mode })
const { scope, agent } = await mintAgentScope(ctx)
const impostor = defineTool({
const impostor = defineContentToolFixture({
name: RUN_CODE_NAME,
description: 'Scoped impostor.',
parameters: {},
@@ -229,7 +229,7 @@ describe('mode-aware wire contribution', () => {
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/)
scope.ctx.systemPrompt.section({ name: 'scoped-note', order: 149, text: 'safe note' })
scope.ctx.tools.register(defineTool({
scope.ctx.tools.register(defineContentToolFixture({
name: 'scoped_safe',
description: 'Safe scoped tool.',
parameters: {},
@@ -332,6 +332,8 @@ describe('the run_code dispatch bridge', () => {
}
const result = await runCode(ctx, 'const …: string = …', { agent })
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected run_code success')
expect(result.value).toEqual({ logs: ['saw echo:one'], result: 'echo:two' })
expect(result.content).toEqual([{ type: 'text', text: 'saw echo:one\necho:two' }])
expect(calls).toEqual([{ value: 'one' }, { value: 'two' }])
const dispatches = events.filter(event => event.type === 'tool/code-dispatch')
@@ -374,7 +376,7 @@ describe('the run_code dispatch bridge', () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const intervals: [string, string][] = []
let active = 0
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'probe',
description: 'Records execution overlap.',
parameters: { id: { type: 'string', required: true } },
@@ -405,7 +407,7 @@ describe('the run_code dispatch bridge', () => {
it('rejects the program-side call when the tool errors, with the tool error text', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'fail',
description: 'Always fails.',
parameters: {},
@@ -549,7 +551,7 @@ describe('the run_code dispatch bridge', () => {
})
const result = await runCode(ctx, 'program')
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' })
expect(result.error).toMatchObject({ info: { name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' } })
const text = (result.content[0] as { text: string }).text
expect(text).toContain('code run failed (timeout)')
expect(text).toContain('compute budget exhausted')
@@ -566,7 +568,7 @@ describe('the run_code dispatch bridge', () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const seen: string[] = []
let sawAbort = false
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'slow',
description: 'Slow tool observing its signal.',
parameters: { id: { type: 'string', required: true } },
@@ -602,7 +604,7 @@ describe('the run_code dispatch bridge', () => {
let sawAbort = false
let started!: () => void
const inFlight = new Promise<void>((resolve) => { started = resolve })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'slow',
description: 'Slow tool observing its signal.',
parameters: { id: { type: 'string', required: true } },
@@ -689,7 +691,7 @@ describe('the run_code dispatch bridge', () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const { agent, events } = fakeAgent()
const long = 'x'.repeat(300)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'mixed',
description: 'Returns mixed content.',
parameters: {},
@@ -714,7 +716,7 @@ describe('the run_code dispatch bridge', () => {
it('normalizes the session workspace root before bounding durable result summaries', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'workspace_path',
description: 'Return a path beneath the session workspace.',
parameters: {},
@@ -792,7 +794,7 @@ describe('the run_code dispatch bridge', () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const { agent, events } = fakeAgent()
let mutationSucceeded: boolean | undefined
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'mutator',
description: 'Attempts to mutate its args object.',
parameters: { list: { type: 'array', required: true } },
@@ -814,7 +816,7 @@ describe('the run_code dispatch bridge', () => {
it('exposes a tool named __proto__ as an ordinary own binding', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: '__proto__',
description: 'A prototype-colliding tool name.',
parameters: {},

View File

@@ -5,7 +5,7 @@ import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, {
defineTool,
defineContentToolFixture,
type ToolDefinition,
type ToolExecutionInput,
type ToolExecutionMode,
@@ -25,7 +25,7 @@ function exec(name: string, args: unknown): ToolExecutionInput {
describe('ToolRegistry.executionMode', () => {
it('returns parallel only for an explicit true classifier', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'safe',
description: 'parallel-safe',
parameters: {},
@@ -37,7 +37,7 @@ describe('ToolRegistry.executionMode', () => {
it('defaults to exclusive for a tool with no isConcurrencySafe declaration', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'plain',
description: 'no declaration',
parameters: {},
@@ -53,7 +53,7 @@ describe('ToolRegistry.executionMode', () => {
it('returns exclusive when the classifier returns false for these args', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'rw',
description: 'read or write',
parameters: { mode: { type: 'string', required: true } },
@@ -64,9 +64,9 @@ describe('ToolRegistry.executionMode', () => {
expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' })
})
it('classifies invalid defineTool arguments as exclusive without throwing', async () => {
it('classifies invalid defineContentToolFixture arguments as exclusive without throwing', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'needs-mode',
description: 'requires mode',
parameters: { mode: { type: 'string', required: true } },
@@ -82,8 +82,9 @@ describe('ToolRegistry.executionMode', () => {
name: 'thrower',
description: 'classifier throws',
parameters: { type: 'object', properties: {} },
output: { schema: { type: 'null' }, render: () => [] },
isConcurrencySafe() { throw new Error('boom') },
async execute() { return [] },
async execute() { return null },
}
ctx.tools.register(raw)
expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' })
@@ -95,8 +96,9 @@ describe('ToolRegistry.executionMode', () => {
name: 'truthy',
description: 'classifier returns a truthy string',
parameters: { type: 'object', properties: {} },
output: { schema: { type: 'null' }, render: () => [] },
isConcurrencySafe() { return 'yes' },
async execute() { return [] },
async execute() { return null },
} as unknown as ToolDefinition
ctx.tools.register(raw)
expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' })
@@ -109,8 +111,9 @@ describe('ToolRegistry.executionMode', () => {
name: 'raw-safe',
description: 'raw',
parameters: { type: 'object', properties: {} },
output: { schema: { type: 'null' }, render: () => [] },
isConcurrencySafe(args) { seen = args; return true },
async execute() { return [] },
async execute() { return null },
})
expect(ctx.tools.executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' })
expect(seen).toEqual({ anything: 1 })
@@ -118,7 +121,7 @@ describe('ToolRegistry.executionMode', () => {
it('isConcurrencySafe never reaches the model-facing schemas() projection', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'safe',
description: 'parallel-safe',
parameters: { x: { type: 'string', required: true } },

View File

@@ -9,7 +9,6 @@ import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
/** Mount the registry (with its systemPrompt dependency) on a fresh context. */
@@ -37,7 +36,11 @@ function tool(name: string, reply = `ran:${name}`): ToolDefinition {
name,
description: `tool ${name}`,
parameters: { type: 'object', properties: {} },
execute: (): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text: reply }]),
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value as string }],
},
execute: (): Promise<string> => Promise.resolve(reply),
}
}
@@ -221,7 +224,7 @@ describe('scoped execution dispatch', () => {
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
return Promise.resolve('ran:t')
},
})
const guard = (execution: Readonly<ToolExecution>): string => {
@@ -253,7 +256,7 @@ describe('scoped execution dispatch', () => {
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([])
return Promise.resolve('ran:t')
},
})
ctx.tools.guard(() => undefined)
@@ -276,14 +279,14 @@ describe('scoped execution dispatch', () => {
execute: (args) => {
safeCalls += 1
safeArguments = args
return Promise.resolve([{ type: 'text', text: 'safe' }])
return Promise.resolve('safe')
},
})
ctx.tools.register({
...tool('danger'),
execute: () => {
dangerCalls += 1
return Promise.resolve([{ type: 'text', text: 'danger' }])
return Promise.resolve('danger')
},
})
scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined)
@@ -335,7 +338,7 @@ describe('scoped execution dispatch', () => {
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([])
return Promise.resolve('ran:t')
},
})
ctx.on('tools/pre-execute', (_exec, next) => {
@@ -396,7 +399,7 @@ describe('scoped execution dispatch', () => {
...tool('t'),
execute: (_args, exec) => {
observed.push(exec.parent)
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
return Promise.resolve('ran:t')
},
})
ctx.on('tools/pre-execute', (exec, next) => {
@@ -511,7 +514,7 @@ describe('scoped execution dispatch', () => {
...tool('t'),
execute: () => {
bodyCalls += 1
return Promise.resolve([])
return Promise.resolve('ran:t')
},
})
ctx.on('tools/pre-execute', (_exec, next) => {
@@ -552,6 +555,7 @@ describe('scoped execution dispatch', () => {
expect(result).toEqual({
content: [{ type: 'text', text: 'ran:t' }],
isError: false,
value: 'ran:t',
})
})
@@ -570,6 +574,7 @@ describe('scoped execution dispatch', () => {
return {
content: [{ type: 'text', text: 'outer failure' }],
isError: true,
error: { message: 'outer failure' },
}
}, { prepend: true })
scope.ctx.on('tools/result', (_exec, result) => {

View File

@@ -5,9 +5,9 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { Agent } from '@deepseek-ai/dsh-agent'
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
import ToolRegistry, {
defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
defineContentToolFixture, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult,
type ToolDefinition, type ToolExecution, type ToolExecutionResult, type ToolExecutionToken,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -21,8 +21,12 @@ const echoTool = defineTool({
name: 'echo',
description: 'echo arguments back',
parameters: { text: { type: 'string' } },
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return [{ type: 'text' as const, text: args.text ?? '' }]
return args.text ?? ''
},
})
@@ -50,7 +54,7 @@ describe('ToolRegistry', () => {
// the system-prompt assembly → the model request, so those callbacks (and
// `execute`) must be stripped: a function in the JSON tool schema would
// corrupt the request. schemas() is an explicit allowlist, so it can't leak.
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'present',
description: 'has presenters',
parameters: { x: { type: 'string', required: true } },
@@ -67,7 +71,7 @@ describe('ToolRegistry', () => {
it('schemas() excludes timeoutMs — the budget must never reach the model', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'budgeted', description: 'has a budget', parameters: {}, timeoutMs: 5_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
}))
@@ -79,17 +83,24 @@ describe('ToolRegistry', () => {
it('executes a tool and returns its content', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let observed: ToolExecutionResult | undefined
ctx.on('tools/result', (_exec, result) => { observed = result })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false, value: 'hi' })
expect(observed).toEqual(result)
})
it('threads a tool-attached meta (object return form) onto the result', async () => {
it('projects presentation metadata from the canonical value', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'meta-tool',
output: {
...echoTool.output,
presentationMeta: () => ({ diffs: [{ path: 'a', oldText: null, newText: 'x' }] }),
},
async execute() {
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] } }
return 'ok'
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} })
@@ -97,20 +108,21 @@ describe('ToolRegistry', () => {
content: [{ type: 'text', text: 'ok' }],
isError: false,
meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] },
value: 'ok',
})
})
it('omits meta when the object return form supplies none', async () => {
it('omits meta when no presentation projector is declared', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'no-meta-tool',
async execute() {
return { content: [{ type: 'text', text: 'ok' }] }
return 'ok'
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false, value: 'ok' })
expect('meta' in result).toBe(false)
})
@@ -121,8 +133,12 @@ describe('ToolRegistry', () => {
ctx.tools.register({
...echoTool,
name: 'bad-meta',
output: {
...echoTool.output,
presentationMeta: () => (() => undefined) as unknown as JsonValue,
},
async execute() {
return { content: [], meta: () => undefined }
return 'ok'
},
})
@@ -134,6 +150,284 @@ describe('ToolRegistry', () => {
expect(observedError).toBe(true)
})
it('requires every raw registration to declare its canonical output', async () => {
const ctx = await setup()
const missingOutput = {
name: 'legacy-content-tool',
description: 'missing output',
parameters: {},
execute: async () => [{ type: 'text', text: 'legacy' }],
} as unknown as ToolDefinition
expect(() => ctx.tools.register(missingOutput))
.toThrow('must declare output { schema, render, presentationMeta? }')
})
it('rejects lossy and schema-mismatched body values before post-execute', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'lossy-output',
description: 'lossy',
parameters: {},
output: { schema: { type: 'json' }, render: () => [] },
execute: async () => (() => undefined) as unknown as JsonValue,
}))
ctx.tools.register(defineTool({
name: 'wrong-output',
description: 'wrong schema',
parameters: {},
output: { schema: { type: 'string' }, render: () => [] },
execute: async () => 42 as unknown as string,
}))
const lossy = await ctx.tools.execute({ callId: CallId('lossy'), name: 'lossy-output', arguments: {} })
const mismatch = await ctx.tools.execute({ callId: CallId('mismatch'), name: 'wrong-output', arguments: {} })
expect(lossy.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } })
expect(lossy.content[0]?.type === 'text' ? lossy.content[0].text : '').toContain('not lossless JSON')
expect(mismatch.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } })
expect(mismatch.content[0]?.type === 'text' ? mismatch.content[0].text : '').toContain('"value" must be a string')
})
it.each(['render', 'presentationMeta'] as const)('contains a throwing output.%s projector as one failed call', async (projector) => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: `throwing-${projector}`,
description: projector,
parameters: {},
output: {
schema: { type: 'string' },
render: () => {
if (projector === 'render') throw new Error('renderer exploded')
return [{ type: 'text', text: 'ok' }]
},
presentationMeta: () => {
if (projector === 'presentationMeta') throw new Error('metadata exploded')
return null
},
},
execute: async () => 'ok',
}))
const result = await ctx.tools.execute({ callId: CallId(projector), name: `throwing-${projector}`, arguments: {} })
expect(result).toMatchObject({
isError: true,
error: { message: projector === 'render' ? 'renderer exploded' : 'metadata exploded' },
})
expect('value' in result).toBe(false)
})
it('keeps value/meta through content replacement and recomputes both projections after value replacement', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'projected',
description: 'projected',
parameters: {},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: { text: { type: 'string', required: true } },
},
render: (_args, value) => [{ type: 'text', text: `render:${value.text}` }],
presentationMeta: (_args, value) => ({ projected: value.text }),
},
execute: async () => ({ text: 'body' }),
}))
let replacement: 'content' | 'value' = 'content'
ctx.on('tools/post-execute', async () => {
if (replacement === 'content') {
return { kind: 'accept', content: [{ type: 'text', text: 'policy content' }] }
}
return {
kind: 'accept',
value: { text: 'policy value' },
additionalContexts: [{ content: [{ type: 'text', text: 'value context' }], source: { kind: 'plugin', plugin: 'test' } }],
}
})
const content = await ctx.tools.execute({ callId: CallId('content'), name: 'projected', arguments: {} })
replacement = 'value'
const value = await ctx.tools.execute({ callId: CallId('value'), name: 'projected', arguments: {} })
expect(content).toEqual({
isError: false,
value: { text: 'body' },
content: [{ type: 'text', text: 'policy content' }],
meta: { projected: 'body' },
})
expect(value).toEqual({
isError: false,
value: { text: 'policy value' },
content: [{ type: 'text', text: 'render:policy value' }],
meta: { projected: 'policy value' },
additionalContexts: [{ content: [{ type: 'text', text: 'value context' }], source: { kind: 'plugin', plugin: 'test' } }],
})
})
it('fails a post-execute decision that replaces both projections or supplies an invalid value', async () => {
const both = await setup()
both.tools.register(echoTool)
both.on('tools/post-execute', async () => ({
kind: 'accept',
value: 'replacement',
content: [{ type: 'text', text: 'also replacement' }],
} as unknown as PostToolDecision))
const bothResult = await both.tools.execute({ callId: CallId('both'), name: 'echo', arguments: {} })
expect(bothResult).toMatchObject({
isError: true,
error: { message: 'tools/post-execute accept decision cannot replace both value and content' },
})
const invalid = await setup()
invalid.tools.register(echoTool)
invalid.on('tools/post-execute', async () => ({ kind: 'accept', value: 1 }))
const invalidResult = await invalid.tools.execute({ callId: CallId('invalid'), name: 'echo', arguments: {} })
expect(invalidResult.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } })
expect('value' in invalidResult).toBe(false)
})
it('turns a post-execute block into a valueless failure', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => ({
kind: 'block',
feedback: [{ type: 'text', text: 'blocked by policy' }],
}))
const result = await ctx.tools.execute({ callId: CallId('block'), name: 'echo', arguments: { text: 'secret' } })
expect(result).toEqual({
isError: true,
error: { message: 'blocked by policy' },
content: [{ type: 'text', text: 'blocked by policy' }],
})
expect('value' in result).toBe(false)
})
it('replaces a canonical value without manufacturing additional context', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => ({ kind: 'accept', value: 'replacement' }))
const result = await ctx.tools.execute({ callId: CallId('replace-value'), name: 'echo', arguments: {} })
expect(result).toEqual({
isError: false,
value: 'replacement',
content: [{ type: 'text', text: 'replacement' }],
})
})
it.each([
[[], 'tool result blocked by post-execute policy'],
[[{ type: 'reasoning', text: 'private rationale' }], '[reasoning content]'],
] as const)('derives a stable failure message from non-text or empty block feedback', async (feedback, message) => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => ({ kind: 'block', feedback: [...feedback] }))
const result = await ctx.tools.execute({ callId: CallId('block-message'), name: 'echo', arguments: {} })
expect(result.error?.message).toBe(message)
})
it('contains a non-JSON post-execute failure projection as a safe final error', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => ({
kind: 'block',
feedback: [{ type: 'text', text: 'blocked', invalid: () => undefined } as never],
}))
const result = await ctx.tools.execute({ callId: CallId('invalid-block'), name: 'echo', arguments: {} })
expect(result).toMatchObject({
isError: true,
error: { message: 'tool result must be losslessly JSON-serializable' },
})
})
it('rejects value replacement on a failed dispatch', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'throw-before-replace',
async execute() { throw new Error('body failed') },
})
ctx.on('tools/post-execute', async () => ({ kind: 'accept', value: 'replacement' }))
const result = await ctx.tools.execute({
callId: CallId('failed-replace'), name: 'throw-before-replace', arguments: {},
})
expect(result.error?.message).toBe('tools/post-execute cannot replace the value of a failed result')
})
it('fails value replacement when the owning tool disappears before post-policy resolves', async () => {
const ctx = await setup()
const dispose = ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => {
dispose()
return { kind: 'accept', value: 'replacement' }
})
const result = await ctx.tools.execute({ callId: CallId('post-disposed'), name: 'echo', arguments: {} })
expect(result.error).toEqual({
message: 'unknown tool "echo"',
info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' },
})
})
it('normalizes wrapper-authored failure metadata and contexts', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => ({
isError: true,
error: { message: 'wrapped failure' },
content: [{ type: 'text', text: 'wrapper content' }],
meta: { wrapped: true },
additionalContexts: [{ content: [{ type: 'text', text: 'wrapper context' }], source: { kind: 'plugin', plugin: 'test' } }],
}))
const result = await ctx.tools.execute({ callId: CallId('wrapper-failure'), name: 'echo', arguments: {} })
expect(result).toEqual({
isError: true,
error: { message: 'wrapped failure' },
content: [{ type: 'text', text: 'wrapper content' }],
meta: { wrapped: true },
additionalContexts: [{ content: [{ type: 'text', text: 'wrapper context' }], source: { kind: 'plugin', plugin: 'test' } }],
})
})
it('fails wrapper-authored success normalization when the owning tool disappears', async () => {
const ctx = await setup()
const dispose = ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => {
dispose()
return { isError: false, value: 'replacement', content: [] }
})
const result = await ctx.tools.execute({ callId: CallId('wrapper-disposed'), name: 'echo', arguments: {} })
expect(result.error).toEqual({
message: 'unknown tool "echo"',
info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' },
})
})
it('suppresses presentation metadata only for nested composite dispatches', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'meta-suppression',
output: { ...echoTool.output, presentationMeta: () => ({ card: true }) },
})
const direct = await ctx.tools.execute({ callId: CallId('direct'), name: 'meta-suppression', arguments: {} })
const nested = await ctx.tools.execute({
callId: CallId('nested'),
name: 'meta-suppression',
arguments: {},
parent: Symbol('outer') as ToolExecutionToken,
})
expect(direct.meta).toEqual({ card: true })
expect(nested.meta).toBeUndefined()
expect(nested.isError ? undefined : nested.value).toBe('')
})
it('returns isError results for unknown tools and throwing tools', async () => {
const ctx = await setup()
ctx.tools.register({
@@ -148,7 +442,10 @@ describe('ToolRegistry', () => {
expect(unknown.isError).toBe(true)
expect(unknown.content[0]).toMatchObject({ text: 'Error: unknown tool "nope"' })
// An unknown tool is a routable failure class, same as a tool-thrown one.
expect(unknown.error).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' })
expect(unknown.error).toEqual({
message: 'unknown tool "nope"',
info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' },
})
const thrown = await ctx.tools.execute({ callId: CallId('c2'), name: 'boom', arguments: {} })
expect(thrown.isError).toBe(true)
@@ -189,15 +486,22 @@ describe('ToolRegistry', () => {
it('lets a tools/pre-execute listener deny a call (permission pattern)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let postSawFrozen = false
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.name === 'echo') return { kind: 'deny', reason: 'denied by policy' }
return next()
})
ctx.on('tools/post-execute', async (_exec, result, next) => {
postSawFrozen = Object.isFrozen(result)
expect(Reflect.set(result, 'content', [])).toBe(false)
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
expect(postSawFrozen).toBe(true)
})
it('an ask decision degrades to deny when no approval seam is mounted', async () => {
@@ -378,7 +682,7 @@ describe('ToolRegistry', () => {
it('preserves tool-deferred, execute-wrapper, and post-execute contexts in order', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'composite',
description: 'composite',
parameters: {},
@@ -422,7 +726,7 @@ describe('ToolRegistry', () => {
it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'failing-composite',
description: 'failing composite',
parameters: {},
@@ -473,7 +777,7 @@ describe('ToolRegistry', () => {
it('runs tools/execute after an allowed pre-execute, around dispatch, and before post-execute', async () => {
const ctx = await setup()
const order: string[] = []
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'traced',
description: 'echo',
parameters: { text: { type: 'string' } },
@@ -493,7 +797,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false, value: [{ type: 'text', text: 'hi' }] })
// The around seam wraps dispatch; pre gates before it, post runs over its result.
expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
})
@@ -533,11 +837,35 @@ describe('ToolRegistry', () => {
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
expect(seen).toEqual({ isError: true, error: { name: 'HarnessError', code: 'BOOM' } })
expect(seen).toEqual({
isError: true,
error: { message: 'kaboom', info: { name: 'HarnessError', code: 'BOOM' } },
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
})
it('freezes core dispatch outcomes before around and post listeners can observe them', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const mutationAttempts: boolean[] = []
ctx.on('tools/execute', async (_exec, next) => {
const result = await next()
mutationAttempts.push(Reflect.set(result, 'value', 'around mutation'))
return result
})
ctx.on('tools/post-execute', async (_exec, result, next) => {
mutationAttempts.push(Reflect.set(result, 'value', 'post mutation'))
return next()
})
const result = await ctx.tools.execute({
callId: CallId('frozen-canonical'), name: 'echo', arguments: { text: 'original' },
})
expect(mutationAttempts).toEqual([false, false])
expect(result.isError ? undefined : result.value).toBe('original')
})
it('a thrown tool normalized inside tools/execute still reaches post-execute', async () => {
const ctx = await setup()
ctx.tools.register({
@@ -567,7 +895,7 @@ describe('ToolRegistry', () => {
name: 'signal-probe',
async execute(_args, exec) {
seenSignal = exec.signal
return [{ type: 'text' as const, text: 'ok' }]
return 'ok'
},
})
@@ -591,11 +919,11 @@ describe('ToolRegistry', () => {
ctx.tools.register({
...echoTool,
name: 'never-runs',
async execute() { dispatched = true; return [] },
async execute() { dispatched = true; return 'unreachable' },
})
ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
({ content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
({ content: [{ type: 'text', text: 'ignored authored content' }], isError: false, value: 'short-circuited' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} })
expect(dispatched).toBe(false) // returning without next() skips core dispatch
@@ -608,6 +936,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/execute', async () => ({
content: [{ type: 'text', text: 'short-circuited with context' }],
isError: false,
value: 'short-circuited with context',
additionalContexts: [{
content: [{ type: 'text', text: 'from around dispatch' }],
source: { kind: 'plugin', plugin: 'test' },
@@ -631,6 +960,7 @@ describe('ToolRegistry', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: wrapper broke' }],
error: { message: 'wrapper broke' },
isError: true,
})
})
@@ -646,6 +976,7 @@ describe('ToolRegistry', () => {
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: permission hook broke' }],
error: { message: 'permission hook broke' },
isError: true,
})
})
@@ -661,6 +992,7 @@ describe('ToolRegistry', () => {
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: post hook broke' }],
error: { message: 'post hook broke' },
isError: true,
})
})
@@ -676,7 +1008,7 @@ describe('ToolRegistry', () => {
expect(result).toMatchObject({
isError: true,
error: { name: 'HarnessError', code: 'DENIED' },
error: { message: 'denied', info: { name: 'HarnessError', code: 'DENIED' } },
})
})
@@ -839,10 +1171,14 @@ describe('defineTool / schema DSL', () => {
text: { type: 'string', required: true },
uppercase: { type: 'boolean' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
// args is typed: { text: string; uppercase?: boolean }
const result = args.uppercase ? args.text.toUpperCase() : args.text
return [{ type: 'text', text: result }]
return result
},
})
@@ -866,6 +1202,7 @@ describe('defineTool / schema DSL', () => {
arguments: { text: 'hello', uppercase: true },
})
expect(result.isError).toBe(false)
expect(result.isError ? undefined : result.value).toBe('HELLO')
expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }])
})
@@ -876,12 +1213,13 @@ describe('defineTool / schema DSL', () => {
name: 'type-check',
description: '',
parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } },
output: { schema: { type: 'string' }, render: () => [] },
async execute(args) {
// Verify types at runtime via typeof
expect(typeof args.a).toBe('string')
// args.b should be undefined when not provided
void args
return [{ type: 'text', text: args.a }]
return args.a
},
})
void tool
@@ -896,8 +1234,12 @@ describe('defineTool / schema DSL', () => {
req: { type: 'string', required: true },
opt: { type: 'number', description: 'Optional number' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return [{ type: 'text', text: `${args.req}:${args.opt ?? 'none'}` }]
return `${args.req}:${args.opt ?? 'none'}`
},
}))
@@ -933,9 +1275,13 @@ describe('defineTool / schema DSL', () => {
properties: { path: { type: 'string' } },
required: ['path'],
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value as string }],
},
async execute(args: unknown) {
const p = args as { path: string }
return [{ type: 'text', text: p.path }]
return p.path
},
})
@@ -1284,7 +1630,7 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
describe('defineTool validation (the runtime-validation Agent Note, part 1)', () => {
it('returns an isError result with the violations when the model sends bad args', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'reader',
description: 'reads a path',
parameters: { path: { type: 'string', required: true } },
@@ -1302,7 +1648,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
it('runs execute normally when args are valid', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'reader',
description: 'reads a path',
parameters: { path: { type: 'string', required: true } },
@@ -1311,7 +1657,11 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
},
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false })
expect(result).toEqual({
content: [{ type: 'text', text: 'read /x' }],
isError: false,
value: [{ type: 'text', text: 'read /x' }],
})
})
it('ToolArgsError carries a stable code and the violation list', () => {
@@ -1325,7 +1675,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
it('a schema-invalid call surfaces the structured error on the result', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'reader',
description: 'reads a path',
parameters: { path: { type: 'string', required: true } },
@@ -1335,7 +1685,10 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' })
expect(result.error).toEqual({
message: 'invalid arguments: missing required property "path"',
info: { name: 'ToolArgsError', code: 'INVALID_ARGS' },
})
})
it('a tool throwing a HarnessError surfaces its name and code', async () => {
@@ -1350,11 +1703,11 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'coded', arguments: {} })
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'HarnessError', code: 'ENOSPC' })
expect(result.error).toEqual({ message: 'disk full', info: { name: 'HarnessError', code: 'ENOSPC' } })
expect(result.content[0]).toMatchObject({ text: 'Error: disk full' })
})
it('a non-HarnessError throw has no structured error (only the text)', async () => {
it('a non-HarnessError throw retains only its message', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
@@ -1365,7 +1718,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'plain', arguments: {} })
expect(result.isError).toBe(true)
expect(result.error).toBeUndefined()
expect(result.error).toEqual({ message: 'just a message' })
expect(result.content[0]).toMatchObject({ text: 'Error: just a message' })
})
@@ -1376,8 +1729,12 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
name: 'raw',
description: 'raw tool',
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value as string }],
},
async execute(args: unknown) {
return [{ type: 'text', text: typeof args }]
return typeof args
},
})
// Missing the "required" path — but raw tools validate their own input, so
@@ -1387,7 +1744,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
})
it('attaches a positive-finite timeoutMs to the definition', () => {
const tool = defineTool({
const tool = defineContentToolFixture({
name: 'x', description: 'd', parameters: {}, timeoutMs: 30_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})
@@ -1395,7 +1752,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
})
it('omits timeoutMs when not declared', () => {
const tool = defineTool({
const tool = defineContentToolFixture({
name: 'x', description: 'd', parameters: {},
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})
@@ -1403,7 +1760,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
})
it('throws when timeoutMs is zero or negative', () => {
const make = (ms: number) => defineTool({
const make = (ms: number) => defineContentToolFixture({
name: 'x', description: 'd', parameters: {}, timeoutMs: ms,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})
@@ -1412,7 +1769,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
})
it('throws when timeoutMs is non-finite', () => {
expect(() => defineTool({
expect(() => defineContentToolFixture({
name: 'x', description: 'd', parameters: {}, timeoutMs: Infinity,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})).toThrow('positive finite number')
@@ -1421,7 +1778,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
describe('defineTool presentation (presentCall / presentResult)', () => {
it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => {
const tool = defineTool({
const tool = defineContentToolFixture({
name: 'demo',
description: 'demo',
parameters: { path: { type: 'string', required: true }, n: { type: 'number' } },
@@ -1441,7 +1798,7 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
})
it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => {
const tool = defineTool({
const tool = defineContentToolFixture({
name: 'plain',
description: 'plain',
parameters: { x: { type: 'string', required: true } },
@@ -1452,7 +1809,7 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
})
it('presentCall/presentResult validate softly: malformed args return undefined, never throw (display runs on replay)', () => {
const tool = defineTool({
const tool = defineContentToolFixture({
name: 'demo',
description: 'demo',
parameters: { path: { type: 'string', required: true } },