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

@@ -50,6 +50,7 @@ import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-commands'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
import type {} from '@deepseek-ai/dsh-permission'
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -1348,7 +1349,7 @@ export class ToolPresenter {
* @param meta - the result's machine-readable meta, forwarded when present.
* @returns a normalized tool-owned view or raw-content fallback.
*/
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: JsonValue): ToolResultView {
const call = this.pending.get(callId)
this.pending.delete(callId)
// No remembered call (unknown/late callId) → nothing to present from; raw content.

View File

@@ -10,6 +10,11 @@ import FsLocal from '@deepseek-ai/dsh-fs-local'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import { streamSessionEventUpdate, agentOptions, todosToPlan, ToolPresenter } from '../src/index.ts'
const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
schema: { type: 'null' },
render: () => [],
}
/** Collect the updates a single event produces (no presenter → generic fallback). */
function updatesFor(event: SessionEvent): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
@@ -232,6 +237,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'bash',
description: 'run a command',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: (args: unknown) => {
const a = args as { command: string; description: string }
@@ -289,7 +295,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
})
it('a tool with no presentCall/presentResult gets the generic fallback (title = name)', () => {
const plain: ToolDefinition = { name: 'plain', description: 'p', parameters: {}, execute: async () => [] }
const plain: ToolDefinition = { name: 'plain', description: 'p', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [] }
const presenter = new ToolPresenter(registryOf(plain))
const [update] = updatesWith(presenter, evt('tool/call', {
turn: 1, step: 1, callId: CallId('c1'), name: 'plain', arguments: '{"a":1}',
@@ -305,6 +311,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'mini',
description: 'm',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Doing a thing' }),
presentResult: () => ({ card: 'generic', title: 'Did the thing' }),
@@ -351,6 +358,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'boom',
description: 'b',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => { throw new Error('call boom') },
presentResult: () => { throw new Error('result boom') },
@@ -380,6 +388,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'boom',
description: 'b',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => { throw new Error('call boom') },
presentResult: () => { throw new Error('result boom') },
@@ -403,6 +412,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'rogue',
description: 'r',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
// A card value outside the union — forced with a cast (no valid input reaches this).
presentCall: () => ({ card: 'chart', title: 'nope' }) as unknown as ReturnType<NonNullable<ToolDefinition['presentCall']>>,
@@ -421,6 +431,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'rogue',
description: 'r',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'r' }),
presentResult: () => ({ card: 'chart' }) as unknown as ReturnType<NonNullable<ToolDefinition['presentResult']>>,
@@ -483,6 +494,7 @@ describe('terminal-card mapping (capability-gated)', () => {
name: 'bash',
description: 'run a command',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: (args: unknown) => {
const command = (args as { command: string }).command
@@ -644,6 +656,7 @@ describe('terminal-card mapping (capability-gated)', () => {
name: 'bash',
description: 'run a command',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: (args: unknown) => ({ card: 'terminal', title: (args as { command: string }).command }),
}
@@ -666,6 +679,7 @@ describe('diff-card mapping', () => {
name: 'writer',
description: 'writes a file',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => view as ReturnType<NonNullable<ToolDefinition['presentCall']>>,
})
@@ -799,6 +813,7 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
name: 'writer',
description: 'writes a file',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => ({ card: 'diff', title: 'Write x', diffs: [{ path: 'x', oldText: null, newText: 'y' }] }),
presentResult: () => ({ card: 'diff', diffs: [] }),

View File

@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import {
errorResponse,
@@ -63,7 +63,7 @@ describe('acp bridge — turn outcomes', () => {
storageDir,
script: [toolCallResponse('c1', 'bash', { command: 'echo hi' }), textResponse('done')],
})
harness.ctx.tools.register(defineTool({
harness.ctx.tools.register(defineContentToolFixture({
name: 'bash',
description: 'run a command',
parameters: { command: { type: 'string' } },
@@ -204,7 +204,7 @@ describe('acp bridge — turn outcomes', () => {
storageDir,
script: [toolCallResponse('c1', 'kaboom', { x: 1 }), textResponse('done')],
})
harness.ctx.tools.register(defineTool({
harness.ctx.tools.register(defineContentToolFixture({
name: 'kaboom',
description: 'explodes when presented',
parameters: { x: { type: 'number' } },
@@ -227,7 +227,7 @@ describe('acp bridge — turn outcomes', () => {
storageDir,
script: [toolCallResponse('c1', 'bash', { command: 'boom' }), textResponse('ok')],
})
harness.ctx.tools.register(defineTool({
harness.ctx.tools.register(defineContentToolFixture({
name: 'bash',
description: 'run a command',
parameters: { command: { type: 'string' } },

View File

@@ -13,7 +13,7 @@ Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the mo
- `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label.
- `multi_select` — whether that question may return more than one selected option.
The tool calls `ctx.userInteraction.ask()` and returns JSON text shaped as `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices.
The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`.
## Role
@@ -52,4 +52,4 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **A pending question blocks the tool call until the human answers** — the tool declares no `timeout-policy` budget; cancellation rides the turn's `exec.signal` only.
- **Answers return as JSON text** — the seam's structured `AskUserQuestionAnswer` is serialized into the tool result rather than carried as typed content blocks.
- **Native answers render as JSON text** — the canonical value remains structured, but the model-facing result uses compact JSON rather than a richer content-block vocabulary.

View File

@@ -55,6 +55,28 @@ export function apply(ctx: Context): void {
},
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
answers: {
type: 'array',
required: true,
items: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
selected: { type: 'array', required: true, items: { type: 'string' } },
custom: { type: 'string' },
},
},
},
},
},
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
},
async execute(args, exec) {
const result = await ctx.userInteraction.ask({
questions: args.questions.map(question => ({
@@ -67,7 +89,13 @@ export function apply(ctx: Context): void {
...exec.agent !== undefined ? { agent: exec.agent } : {},
...exec.signal !== undefined ? { signal: exec.signal } : {},
})
return [{ type: 'text', text: JSON.stringify(result) }]
return {
answers: result.answers.map(answer => ({
id: answer.id,
selected: [...answer.selected],
...answer.custom !== undefined ? { custom: answer.custom } : {},
})),
}
},
}))
}

View File

@@ -159,6 +159,14 @@ describe('ask_user_question tool', () => {
},
})
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected ask_user_question success')
expect(result.value).toEqual({
answers: [
{ id: 'targets', selected: ['tests', 'docs'] },
{ id: 'notes', selected: [], custom: 'ship today' },
],
})
expect(result.content).toEqual([{
type: 'text',
text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
@@ -219,7 +227,7 @@ describe('ask_user_question tool', () => {
expect(result).toMatchObject({
isError: true,
error: { name: 'UserInteractionError', code: 'NO_PROVIDER' },
error: { info: { name: 'UserInteractionError', code: 'NO_PROVIDER' } },
})
})
@@ -234,7 +242,7 @@ describe('ask_user_question tool', () => {
expect(result).toMatchObject({
isError: true,
error: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' },
error: { info: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' } },
})
})

View File

@@ -39,7 +39,7 @@ import type {} from '@deepseek-ai/dsh-commands'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session'
import { SessionId, type JsonValue, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session'
import type {
FileDiff,
TerminalCallView,
@@ -454,7 +454,7 @@ function diffLines(diff: FileDiff, palette: Palette): string[] {
}
class ToolCardComponent implements Component {
private result: { content: ContentBlock[]; isError: boolean; meta?: unknown } | undefined
private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined
private expanded = false
private callView: ToolCallView
private resultView: ToolResultView | undefined

View File

@@ -5,7 +5,7 @@ import { afterAll, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Session } from '@deepseek-ai/dsh-session'
import type { JsonValue, Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
@@ -131,7 +131,7 @@ function appendToolResult(
session: Session,
id: string,
content: ContentBlock[],
options: { isError?: boolean; meta?: unknown } = {},
options: { isError?: boolean; meta?: JsonValue } = {},
): void {
session.append('tool/result', {
turn: 1,
@@ -152,6 +152,7 @@ function visualTool(
name,
description: `${name} snapshot fixture`,
parameters: {},
output: { schema: { type: 'null' }, render: () => [] },
execute: () => Promise.resolve([]),
presentCall: call,
...result === undefined ? {} : { presentResult: result },

View File

@@ -23,6 +23,11 @@ import {
type TuiHarnessOptions,
} from './harness.ts'
const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
schema: { type: 'null' },
render: () => [],
}
class FakeTerminal implements Terminal {
columns = 88
rows = 32
@@ -645,17 +650,17 @@ describe('pi-tui chat lifecycle and transcript', () => {
describe('tool cards and surface replay', () => {
const tools: Record<string, ToolDefinition> = {
bash: {
name: 'bash', description: '', parameters: {}, execute: async () => [],
name: 'bash', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'terminal', title: 'printf hello', description: 'Run command', cwd: '/tmp' }),
presentResult: () => ({ card: 'terminal', output: 'hello\nworld\nthird', exitCode: 0 }),
},
signal: {
name: 'signal', description: '', parameters: {}, execute: async () => [],
name: 'signal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'terminal', title: 'sleep 10' }),
presentResult: () => ({ card: 'terminal', signal: 'SIGTERM' }),
},
edit: {
name: 'edit', description: '', parameters: {}, execute: async () => [],
name: 'edit', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({
card: 'diff',
title: 'Edit files',
@@ -667,35 +672,35 @@ describe('tool cards and surface replay', () => {
presentResult: () => ({ card: 'diff', diffs: [{ path: 'a.txt', oldText: null, newText: 'created' }] }),
},
generic: {
name: 'generic', description: '', parameters: {}, execute: async () => [],
name: 'generic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }),
presentResult: () => ({ card: 'generic', title: 'Inspected', content: [{ type: 'text', text: 'result text' }] }),
},
throwing: {
name: 'throwing', description: '', parameters: {}, execute: async () => [],
name: 'throwing', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => { throw new Error('call presenter boom') },
presentResult: () => { throw new Error('result presenter boom') },
},
rawTerminal: {
name: 'rawTerminal', description: '', parameters: {}, execute: async () => [],
name: 'rawTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'terminal', title: 'raw command' }),
},
undefinedViews: {
name: 'undefinedViews', description: '', parameters: {}, execute: async () => [],
name: 'undefinedViews', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => undefined,
presentResult: () => undefined,
},
empty: {
name: 'empty', description: '', parameters: {}, execute: async () => [],
name: 'empty', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Empty card' }),
},
terminalResult: {
name: 'terminalResult', description: '', parameters: {}, execute: async () => [],
name: 'terminalResult', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }),
presentResult: () => ({ card: 'terminal', output: 'converted terminal' }),
},
symbolic: {
name: 'symbolic', description: '', parameters: {}, execute: async () => [],
name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }),
},
}