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

@@ -19,7 +19,7 @@ For a tool that **declares a `timeoutMs`** the listener:
1. Reads the budget from the tool's own declaration in the registry (`ctx.tools.get(exec.name)?.timeoutMs`) and arms `deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT')` — one signal fusing the caller's abort with this plugin's timer (`@deepseek-ai/dsh-timeout`).
2. Swaps that derived signal onto `exec` for the downstream dispatch, then restores the caller's own signal afterward (cordis `next()` ignores passed arguments, so the wrapper mutates the shared `exec` in place; restoring keeps `tools/post-execute` seeing the caller's signal).
3. After dispatch, if `timeoutOf(d.signal, 'TOOL_TIMEOUT')` matches — this plugin's own timer fired — replaces the result with a structured `TOOL_TIMEOUT` tool result: `{ isError: true, error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, content: 'Error: tool call timed out after <ms>ms' }`.
3. After dispatch, if `timeoutOf(d.signal, 'TOOL_TIMEOUT')` matches — this plugin's own timer fired — replaces the result with a structured `TOOL_TIMEOUT` tool result: `{ isError: true, error: { message, info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' } }, content: 'Error: tool call timed out after <ms>ms' }`.
A tool that **declares no budget** delegates untouched (no deadline).

View File

@@ -33,10 +33,11 @@ export const inject = ['tools']
* @returns the `isError` {@link ToolExecutionResult} with a `TOOL_TIMEOUT` error.
*/
function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
const message = `tool call timed out after ${timeoutMs}ms`
return {
content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
content: [{ type: 'text', text: `Error: ${message}` }],
isError: true,
error: { name: 'ToolTimeoutError', code: TOOL_TIMEOUT },
error: { message, info: { name: 'ToolTimeoutError', code: TOOL_TIMEOUT } },
}
}

View File

@@ -11,7 +11,7 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
import { TOOL_TIMEOUT } from '@deepseek-ai/dsh-timeout-policy'
@@ -25,7 +25,7 @@ async function setup() {
}
/** A cooperative tool that settles ONLY when its exec.signal aborts (returns text). */
const cooperativeTool = defineTool({
const cooperativeTool = defineContentToolFixture({
name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100,
execute(_args, exec): Promise<{ type: 'text'; text: string }[]> {
const done = [{ type: 'text' as const, text: 'stopped cooperatively' }]
@@ -35,7 +35,7 @@ const cooperativeTool = defineTool({
})
/** A cooperative tool that THROWS its own upstream-abort error when aborted (web-provider shape). */
const abortThrowingTool = defineTool({
const abortThrowingTool = defineContentToolFixture({
name: 'aborter', description: 'throws WEB_ABORTED when aborted', parameters: {}, timeoutMs: 100,
execute(_args, exec): Promise<never> {
if (exec.signal?.aborted) return Promise.reject(new HarnessError('web fetch aborted', 'WEB_ABORTED'))
@@ -47,7 +47,7 @@ describe('timeout-policy delegation (unconfigured / fast)', () => {
it('delegates a tool with NO declared budget unchanged and does not touch exec.signal', async () => {
const ctx = await setup()
let seenSignal: AbortSignal | undefined
ctx.tools.register(defineTool({ name: 'probe', description: 'd', parameters: {},
ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'd', parameters: {},
async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
const upstream = new AbortController().signal
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
@@ -57,16 +57,20 @@ describe('timeout-policy delegation (unconfigured / fast)', () => {
it('a tool with a budget that returns fast keeps its own result (no timeout)', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
ctx.tools.register(defineContentToolFixture({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} })
expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
expect(result).toEqual({
content: [{ type: 'text', text: 'ok' }],
isError: false,
value: [{ type: 'text', text: 'ok' }],
})
})
it('a budgeted tool receives the DERIVED deadline signal (not the caller signal) during dispatch', async () => {
const ctx = await setup()
let seenSignal: AbortSignal | undefined
ctx.tools.register(defineTool({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
const upstream = new AbortController().signal
await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
@@ -78,7 +82,7 @@ describe('timeout-policy delegation (unconfigured / fast)', () => {
describe('timeout-policy signal restoration', () => {
it('restores the caller signal for post-execute after wrapping', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
ctx.tools.register(defineContentToolFixture({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
let postSignal: AbortSignal | undefined | 'unset' = 'unset'
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { postSignal = exec.signal; return next() })
@@ -89,7 +93,7 @@ describe('timeout-policy signal restoration', () => {
it('deletes exec.signal again when the caller passed none', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
ctx.tools.register(defineContentToolFixture({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
let hadSignal: boolean | undefined
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { hadSignal = 'signal' in exec && exec.signal !== undefined; return next() })
@@ -111,7 +115,10 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: tool call timed out after 100ms' }],
isError: true,
error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
error: {
message: 'tool call timed out after 100ms',
info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
},
})
})
@@ -122,7 +129,10 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
await vi.advanceTimersByTimeAsync(150)
const result = await pending
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' })
expect(result.error).toEqual({
message: 'tool call timed out after 100ms',
info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
})
expect(result.content[0]).toMatchObject({ text: 'Error: tool call timed out after 100ms' })
})
@@ -151,7 +161,7 @@ describe('timeout-policy disposal (HMR safety)', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
let seenSignal: AbortSignal | undefined
ctx.tools.register(defineTool({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
const fiber = await ctx.plugin(timeoutPolicy)
const upstream = new AbortController().signal
@@ -178,7 +188,7 @@ describe('dsh-timeout-policy real-load-path guard', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 5_000,
ctx.tools.register(defineContentToolFixture({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 5_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters<Context['plugin']>[0]