Merge remote-tracking branch 'origin/master' into worktree-hooks-a-taxonomy

# Conflicts:
#	docs/rfc/README.md
This commit is contained in:
Tianyi Cui
2026-07-04 02:15:52 +08:00
207 changed files with 16514 additions and 534 deletions

View File

@@ -711,6 +711,9 @@ async function runStep(
content: result.content,
isError: result.isError,
...result.error ? { error: result.error } : {},
// The tool's private presentation payload (e.g. a result-time diff),
// persisted so a UI bridge reproduces the card on replay.
...result.meta !== undefined ? { meta: result.meta } : {},
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
// signal CAN flip during the await above (abort() inside a tool);
// the analyzer can't see through the await boundary.

View File

@@ -115,6 +115,32 @@ describe('agent loop', () => {
expect(types).toContain('tool/result')
})
it('threads a tool-attached meta (execute object return) onto the tool/result event', 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' } },
async execute() {
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } }
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
const toolResult = agent.session.events.find(e => e.type === 'tool/result')
expect(toolResult?.type === 'tool/result' && toolResult.data.meta)
.toEqual({ diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] })
})
it('passes assembled system prompt and tool schemas into the request', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)

View File

@@ -16,6 +16,7 @@ import { SurfaceManager, isSurfaceEligibleType } from './surface.ts'
export * from './types.ts'
export { isJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
export { interruptedTurnClosers } from './repair.ts'
export type { SurfaceNode } from './surface.ts'
export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'

View File

@@ -13,6 +13,16 @@
* @module @deepseek-ai/dsh-session/json
*/
/**
* A value that round-trips losslessly through JSON: `null`, a boolean, a finite
* number, a string, an array of such values, or a plain object whose values are
* such values. The static type companion to {@link isJsonValue} (which validates
* the same shape at runtime). Use it to type a payload that must survive
* session-log persistence and replay byte-identically — e.g. a tool's private
* presentation `meta`.
*/
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
/**
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers,
* booleans, strings, plain arrays, and plain objects of such values. Rejects

View File

@@ -210,7 +210,16 @@ export interface SessionEventMap {
*/
'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage }
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: 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).
*/
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown }
/** Steering content injected between steps of a running turn. */
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
/**

View File

@@ -8,7 +8,7 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber.
- `ctx.tools.get(name: string): ToolDefinition | undefined`
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions).
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog/tools.md](../../../docs/tool-catalog/tools.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/execute` waterfall.
### Injected services
@@ -24,10 +24,10 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[]>`, plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below).
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below).
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay).
- `ToolCallPresentation` / `ToolResultPresentation` — provider-neutral shapes a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards both `error` and `meta` onto the `tool/result` session event (for retry/sandbox plugins, replay, and result-card rendering).
- `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").
### Extension points
@@ -70,12 +70,18 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an
### Tool-owned UI presentation
A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods:
A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods that return a **`card`-tagged render intent** (a discriminated union — a tool declares its card kind once and a UI bridge switches on `card`):
- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a shell command as a string, NOT the whole args object), an optional `content` (UI content shown alongside the title/card — e.g. a bash `description` as a text block above the terminal card), and an optional `terminal` (a neutral `{ cwd? }` asking a capable UI to render this call as a TERMINAL, e.g. for `bash`).
- `presentResult(args, result): ToolResultPresentation | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError }` result: an optional replacement `title`, reformatted `content` (e.g. wrap command output in a fenced ` ```console ` block — a UI-only affordance that must NOT appear in the model-facing `execute` result), and an optional `terminal` (the `{ output?, exitCode?, signal? }` for a terminal-rendered call). The `ToolTerminal` shape is provider-neutral; a UI bridge (the ACP bridge) maps it to a terminal card (with an exit-status pill) and a UI that can't ignores it and uses `content`.
- `presentCall(args): ToolCallView | undefined` — the PENDING state, one of:
- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a background task id, NOT the whole args object), optional `content` (extra UI content blocks), and optional `locations` (`{ path, line? }[]` — files this call reads/modifies, so a capable UI can follow along; the ACP bridge forwards them as `tool_call.locations`).
- `{ card: 'terminal', title, description?, cwd? }` — a shell command: a capable UI renders a terminal card (the `title` is the command, `description` renders above it, `cwd` heads it); an incapable UI falls back to a generic execute card.
- `{ card: 'diff', title, diffs, locations? }` — a file create/modify: a capable UI renders an inline diff card from `diffs` (`{ path, oldText, newText }[]`; `oldText: null` for a new file). Used by `write`/`edit`.
- `presentResult(args, result): ToolResultView | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError, meta? }` result, one of:
- `{ card: 'generic', title?, content? }` — an optional replacement `title` and reformatted `content`.
- `{ card: 'terminal', title?, output?, exitCode?, signal? }` — a terminal run's captured `output` and exit status. A capable UI shows an exit-status pill; an incapable UI gets a fenced ` ```console ` fallback the BRIDGE derives from `output` (the tool does not encode the fences).
- `{ card: 'diff', title?, diffs }` — a completed file mutation as an inline diff. `diffs` is `FileDiff[]` — typically the applied hunks with surrounding context computed from the before/after content, or a whole-file diff (`oldText: null`) when there is no before-image (a file create). Used by `write`/`edit`; a `tool_call_update.content` replaces the call's content, so a mutation tool returns this even when it duplicates the call-time snippet (else the result text would clobber the pending diff).
Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. With `defineTool`, `args` is the typed `InferArgs<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The shapes are provider-neutral — the ACP bridge (`dsh-acp`) maps them to ACP `tool_call`/`tool_call_update` wire fields, and `dsh-tool-bash` is the reference implementation.
Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. `result.meta` is the tool's own optional presentation payload (opaque `unknown`, JSON-serializable), attached by `execute` (see below) and persisted on the `tool/result` event, so a `presentResult` reading it stays replay-deterministic (the same `meta` is read back from the log). With `defineTool`, `args` is the typed `InferArgs<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The views are provider-neutral — the ACP bridge (`dsh-acp`) maps each `card` to ACP `tool_call`/`tool_call_update` wire fields (a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention), and relativizes a file card's title against the session cwd. See the render-intent-union RFC (`docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md`) and the applied-hunk-diffs RFC (`docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md`); `dsh-tool-bash` (terminal) and `dsh-tool-fs` (diff/generic) are the reference implementations.
```ts
import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -90,13 +96,13 @@ const bash = defineTool({
async execute(args) {
return [{ type: 'text', text: `ran: ${args.command}` }]
},
// The command is the readable title; the description rides as a content block.
presentCall: args => ({ title: args.command, kind: 'execute', rawInput: args.command, content: [{ type: 'text', text: args.description }] }),
// Wrap the output as a console block for the UI (not in the model-facing result).
// A terminal card: the command is the title, the description renders above it.
presentCall: args => ({ card: 'terminal', title: args.command, description: args.description }),
// A terminal result: the raw output + exit; the bridge derives the fenced fallback.
presentResult: (_args, result) => {
const block = result.content.length === 1 ? result.content[0] : undefined
if (block === undefined || block.type !== 'text') return undefined
return { content: [{ type: 'text', text: '```console\n' + block.text + '\n```' }] }
return { card: 'terminal', output: block.text }
},
})
```

View File

@@ -12,6 +12,7 @@ import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type { ToolCallView, ToolResultView } from './presentation.ts'
export {
defineTool,
@@ -26,6 +27,23 @@ export {
type JsonSchemaObject,
} from './schema.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`
// stays the single public surface for consumers (producers + the ACP bridge).
export type {
ToolCallKind,
FileLocation,
FileDiff,
ToolCallView,
GenericCallView,
TerminalCallView,
DiffCallView,
ToolResultView,
GenericResultView,
TerminalResultView,
DiffResultView,
} from './presentation.ts'
declare module 'cordis' {
interface Context {
tools: ToolRegistry
@@ -55,147 +73,37 @@ declare module 'cordis' {
// executes sequentially).
/**
* Category of a tool call, used by a UI to pick an icon / treatment. A neutral
* vocabulary owned here (NOT an ACP type) so tools describe themselves without
* depending on any client protocol; a UI bridge maps it to its own enum. The
* member set mirrors the common ACP `ToolKind` values; `other` is the default.
* What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the
* common case (model-facing content only); the object form additionally attaches
* a tool-private `meta` presentation payload that the registry threads onto the
* `tool/result` session event and hands back to the tool's `presentResult`.
* `meta` is opaque to the core (`unknown` — the tool owns and narrows its shape),
* and MUST be JSON-serializable: it persists on the durable log (the session
* enforces this at `append`), so replay reproduces the card.
*/
export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'
// FIXME(tool-presentation): the ToolCallPresentation / ToolResultPresentation /
// ToolTerminal shapes need a rethink. They grew incrementally (title/kind/
// rawInput, then a `content` block, then a `terminal` sub-shape carrying cwd/
// output/exit) and the split of responsibility is now muddy: the call vs result
// terminal fields overlap, the bridge has to reconcile a `content` block AND a
// `terminal` block AND `rawInput` per call, and the "pending vs completed"
// boundary doesn't cleanly map to how editors actually render (terminal card,
// diff, generic card). Before more tools/UIs depend on this, redesign the type
// so a tool declares its render INTENT once (e.g. a tagged union over card
// kinds) rather than a bag of optional fields the bridge stitches together.
// Pin the design in an RFC and migrate dsh-tool-bash + the ACP bridge together.
/**
* How a tool wants ONE of its calls shown in a UI (an editor's tool-call card,
* a CLI log line) BEFORE the result is known — the *pending* state. Provider-
* neutral: a tool returns this from {@link ToolDefinition.presentCall} and a UI
* plugin (e.g. the ACP bridge) maps it to its own wire shape. The tool owns its
* own presentation — the UI must not special-case tool names.
*/
export interface ToolCallPresentation {
/**
* Human-readable, always-visible label describing what THIS call does (e.g.
* the model-written one-line summary of a bash command). Keep it short — a UI
* shows it as a card header / log line. Required: a presentation must have a
* title (a UI falls back to the tool name only when `presentCall` is absent).
*/
title: string
/** Category for icon/treatment; defaults to `other` when omitted. */
kind?: ToolCallKind
/**
* The salient input to surface in a detail/expanded view — e.g. the bash
* COMMAND itself (as a string), so the title can stay a readable summary
* while the exact command is still visible. Omit to show nothing; a string is
* rendered as-is, an object as pretty JSON. NOT the full raw args object
* unless that is genuinely what a reader wants.
*/
rawInput?: unknown
/**
* UI-facing content to show on the PENDING call alongside the title/card —
* harness {@link ContentBlock}s, in render order. A terminal tool uses this to
* surface its human-readable `description` as a text block ABOVE the terminal
* card (the card itself is requested via {@link terminal} and labelled by the
* command in `title`), since the card has no description slot. Omit to show no
* extra content. A UI maps these to its own content blocks and renders a
* {@link terminal} block (if any) as a terminal card.
*/
content?: ContentBlock[]
/**
* Ask a capable UI to render this call as a TERMINAL (a command running in a
* working directory), not a generic tool card — set by a tool whose call IS a
* shell command (e.g. `bash`). Provider-neutral; a UI bridge maps it to its
* own terminal affordance and a UI that can't falls back to the normal card.
* Pair with {@link ToolResultPresentation.terminal} for the output/exit.
*/
terminal?: ToolTerminal
}
/**
* A request to render a tool call as a terminal. The pending presentation
* supplies the working directory; the result presentation (see
* {@link ToolResultPresentation.terminal}) supplies the captured output and exit
* status. Provider-neutral — no client-protocol types. A UI that supports
* terminals shows a cwd-headed terminal card with the command, its output, and
* an exit-status pill; a UI that does not ignores this and renders the ordinary
* card/content.
*/
export interface ToolTerminal {
/**
* Working directory the command ran in, shown as the terminal header. An
* ABSOLUTE path is used as-is; a RELATIVE path is resolved by the UI bridge
* against the session workspace (the pure tool presenter can't see the
* session cwd). Omit entirely to let the bridge use the session workspace.
*/
cwd?: string
/** Captured command output (stdout+stderr as the tool chooses to combine them). Result-state only. */
output?: string
/**
* Process exit code, when the run ended by exiting (not a signal). Result-state
* only; lets a capable UI show an exit-status pill on the terminal card. Omit
* when the command was killed by a signal or the exit code is unknown.
*/
exitCode?: number
/**
* Signal name that killed the process (e.g. `SIGTERM`), when it died by signal
* rather than exiting. Result-state only; mutually exclusive with `exitCode`.
*/
signal?: string
}
/**
* How a tool wants the COMPLETED call shown — the *result* state, after
* `execute` returns. Lets the tool reformat its result for a UI distinctly from
* the model-facing text it returned from `execute` (e.g. wrap command output in
* a fenced ```console block for monospace rendering, which the model-facing
* result must NOT carry). All fields optional: a UI keeps the pending-state
* title and renders the raw result content for anything left unset.
*/
export interface ToolResultPresentation {
/** Replacement title for the completed call (e.g. append an exit status). Omit to keep the pending-state title. */
title?: string
/**
* UI-facing result content (harness {@link ContentBlock}s), reformatted from
* the model-facing result. Omit to let the UI render the raw result content.
* Stays in harness vocabulary; the UI maps these to its own content blocks.
*/
content?: ContentBlock[]
/**
* Terminal output/exit for a call the pending presentation marked as a
* terminal (see {@link ToolCallPresentation.terminal}). A capable UI renders
* `output` in the terminal card and shows the exit status; an incapable UI
* uses `content` (the tool should supply a text fallback there too).
*/
terminal?: ToolTerminal
}
export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
/** A registered tool: its schema plus the execution function. */
export interface ToolDefinition extends ToolSchema {
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]>
execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>
/**
* Optional: how to present the PENDING state of one call in a UI, derived
* from the call's `args` (parsed arguments, `unknown` — the tool validates/
* narrows its own input). Returning `undefined` (or omitting the method) tells
* a UI to fall back to a generic presentation (title = tool name, raw args as
* input). Pure and side-effect-free: a UI may call it during live streaming
* AND a session-log replay, so it must depend only on `args`.
* Optional: how to present the PENDING state of one call in a UI, derived from
* the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
* its own input). Returns a {@link ToolCallView} (a `card`-tagged render intent),
* or `undefined` (or omit the method) to fall back to a generic presentation
* (title = tool name, raw args as input). Pure and side-effect-free: a UI may
* call it during live streaming AND a session-log replay, so it must depend
* only on `args`.
*/
presentCall?(args: unknown): ToolCallPresentation | undefined
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). Returning `undefined`
* (or omitting the method) tells a UI to keep the pending title and render the
* raw result content. Pure and side-effect-free for the same replay reason.
* `result` (`execute`'s content + whether it errored). 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.
*/
presentResult?(args: unknown, result: ToolResult): ToolResultPresentation | undefined
presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined
}
/** The completed outcome handed to {@link ToolDefinition.presentResult}. */
@@ -204,6 +112,13 @@ export interface ToolResult {
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.
*/
meta?: unknown
}
/** One pending tool call, as it flows through the execution waterfall. */
@@ -247,6 +162,13 @@ export interface ToolExecutionResult {
* text in `content` is always present; this is extra structure for code.
*/
error?: ToolErrorInfo
/**
* 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
}
/**
@@ -351,8 +273,13 @@ export class ToolRegistry extends Service {
// Unknown tool routes through the same catch as a tool-thrown error, so
// both failure classes get structured `{ name, code }` from one path.
if (!tool) throw new ToolNotFoundError(exec.name)
const content = await tool.execute(exec.arguments, exec)
return { callId: exec.callId, content, isError: false }
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
// meta) or a { content, meta } object (a tool attaching a private
// presentation payload). An array IS the content; the object carries it.
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
return toolErrorResult(exec.callId, error)
}

View File

@@ -0,0 +1,206 @@
/**
* Tool render-intent vocabulary: the provider-neutral types a tool declares via
* `ToolDefinition.presentCall`/`ToolDefinition.presentResult` to say
* how ONE of its calls renders in a UI (an editor's tool-call card, a CLI log
* line). A UI bridge switches on the `card` tag to map each intent to its own
* wire shape, so a UI never special-cases tool names.
*
* This is the UI-facing surface of `dsh-tools`, kept separate from the registry
* and execution core in `index.ts`: this module owns ONLY presentation
* vocabulary and references none of the execution types, so the dependency runs
* one way (`index.ts` imports these views for the `ToolDefinition` method
* signatures). The opaque `meta` presentation channel is execution plumbing and
* lives with the registry in `index.ts`, not here.
*
* See the render-intent-union RFC
* (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
*
* @module @deepseek-ai/dsh-tools/src/presentation
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
/**
* Category of a tool call, used by a UI to pick an icon / treatment. A neutral
* vocabulary owned here (NOT an ACP type) so tools describe themselves without
* depending on any client protocol; a UI bridge maps it to its own enum. The
* member set mirrors the common ACP `ToolKind` values; `other` is the default.
*/
export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'
/**
* A file location a tool reads or modifies, so a capable UI can "follow along" —
* highlight or jump to the file (and line) as the tool runs. Provider-neutral;
* a UI bridge maps it to its own affordance (the ACP bridge forwards it as
* `tool_call.locations`). `path` is what the tool operated on (the model-facing
* path); `line` is an optional 1-based line to focus (e.g. a read's offset).
*/
export interface FileLocation {
path: string
line?: number
}
/**
* A single-file change a tool is about to make, for a UI that renders inline
* diffs (an editor's diff card). Provider-neutral; the ACP bridge forwards it as
* a `{ type: 'diff' }` tool-call content block. `oldText` is `null` for a
* new-file create (nothing to diff against); an overwrite also uses `null`,
* because a call-time presenter has no access to the file's prior content.
*/
export interface FileDiff {
path: string
/** Prior content, or `null` for a new file / an overwrite (no prior content available at call time). */
oldText: string | null
/** Content after the change. */
newText: string
}
/**
* How a tool wants ONE of its calls shown in a UI (an editor's tool-call card, a
* CLI log line) BEFORE the result is known — the *pending* state. A `card`-tagged
* discriminated union: a tool declares its render INTENT once and a UI bridge
* switches on `card` to map it to the bridge's own wire shape. Provider-neutral —
* the tool owns its presentation, so a UI never special-cases tool names.
*
* Returned by `ToolDefinition.presentCall`. See the render-intent-union
* RFC (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
*/
export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView
/**
* The default card: a titled tool-call row with an optional category icon, a
* salient raw input, extra content blocks, and follow-along file locations. Any
* tool whose call is not a terminal or a diff uses this.
*/
export interface GenericCallView {
card: 'generic'
/**
* Human-readable, always-visible label describing what THIS call does. Keep it
* short — a UI shows it as a card header / log line.
*/
title: string
/** Category for icon/treatment; defaults to `other` when omitted. */
kind?: ToolCallKind
/**
* The salient input to surface in a detail/expanded view (e.g. a background
* task id). Omit to show nothing; a string renders as-is, an object as pretty
* JSON. NOT the full raw args object unless that is genuinely what a reader wants.
*/
rawInput?: unknown
/**
* UI-facing content blocks to show on the pending call alongside the title.
* Omit to show none. A UI maps these to its own content blocks.
*/
content?: ContentBlock[]
/** Files this call reads/modifies, for editor follow-along. Omit for a call that touches no file. */
locations?: FileLocation[]
}
/**
* A call that IS a shell command running in a working directory: a capable UI
* renders it as a terminal card (cwd-headed, with the command as the title and
* live/afterward output from the {@link TerminalResultView}); an incapable UI
* falls back to a generic card whose body is the fenced command output. Set by a
* tool whose call is a foreground command (e.g. `bash`).
*/
export interface TerminalCallView {
card: 'terminal'
/** The command, shown as the terminal card's title / header line. */
title: string
/**
* A human-readable one-line summary of what the command does, rendered ABOVE
* the terminal card (the card itself has no description slot). Omit for none.
*/
description?: string
/**
* Working directory the command runs in, shown as the terminal header. An
* ABSOLUTE path is used as-is; a RELATIVE path is resolved by the UI bridge
* against the session workspace (the pure presenter can't see the session cwd).
* Omit entirely to let the bridge use the session workspace.
*/
cwd?: string
}
/**
* A call that creates or modifies files, rendered as an inline diff card by a
* capable UI. Set by a tool whose call writes/edits a file (e.g. `write`,
* `edit`). The diffs are derived from the call ARGUMENTS (a create's `oldText` is
* `null`); the tool emits a separate {@link DiffResultView} after `execute` — the
* applied change (an edit/overwrite hunk with context, or a whole-file diff for a
* create).
*/
export interface DiffCallView {
card: 'diff'
/** Card header (e.g. `Write foo.txt`). */
title: string
/** One entry per file the call changes. */
diffs: FileDiff[]
/** Files this call modifies, for editor follow-along (usually the diffs' paths). */
locations?: FileLocation[]
}
/**
* How a tool wants the COMPLETED call shown — the *result* state, after `execute`
* returns. A `card`-tagged union mirroring {@link ToolCallView}: a UI switches on
* `card`. Lets the tool reformat its result for a UI distinctly from the
* model-facing text it returned from `execute`. Returned by
* `ToolDefinition.presentResult`; omitting the method keeps the pending
* title and renders the raw result content.
*/
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView
/**
* The default completed card: an optional replacement title and reformatted
* content. Omit a field to keep the pending title / render the raw result content.
*/
export interface GenericResultView {
card: 'generic'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/**
* UI-facing result content (harness {@link ContentBlock}s), reformatted from
* the model-facing result. Omit to let the UI render the raw result content.
*/
content?: ContentBlock[]
}
/**
* The completed state of a {@link TerminalCallView}: the captured output and exit
* status. A capable UI renders `output` in the terminal card and shows an
* exit-status pill; an incapable UI gets a fenced ```console fallback the BRIDGE
* derives from `output` (the tool does not double-encode it).
*/
export interface TerminalResultView {
card: 'terminal'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/** Captured command output (stdout+stderr as the tool chooses to combine them). */
output?: string
/**
* Process exit code, when the run ended by exiting (not a signal). Lets a
* capable UI show an exit-status pill. Omit when killed by a signal or unknown.
*/
exitCode?: number
/** Signal name that killed the process (e.g. `SIGTERM`). Mutually exclusive with `exitCode`. */
signal?: string
}
/**
* A completed file mutation rendered as an inline diff card, the *result-time*
* analogue of {@link DiffCallView}. Set by a tool whose `execute` applied a file
* change (e.g. `write`, `edit`): `diffs` are the change to show — typically the
* APPLIED hunks computed from the before/after content (one entry per hunk, each
* with surrounding context lines), so the editor shows the real change in place;
* a tool with no before-image (e.g. a file create) may instead give a whole-file
* diff (`oldText: null`). A `tool_call_update`'s content REPLACES the call's
* content in an editor, so a mutation tool returns this even when it duplicates
* the call-time snippet — otherwise the model-facing result text would replace
* (clobber) the pending diff card.
*/
export interface DiffResultView {
card: 'diff'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */
diffs: FileDiff[]
}

View File

@@ -19,9 +19,9 @@
* @module dsh-tools/schema
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ToolCallPresentation, ToolDefinition, ToolExecution, ToolResult, ToolResultPresentation } from './index.ts'
import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts'
import type { ToolCallView, ToolResultView } from './presentation.ts'
// ---------------------------------------------------------------------------
// SchemaSpec — the author-facing per-property type
@@ -291,25 +291,27 @@ export interface DefineToolOptions<S extends SchemaSpec> {
parameters: S
/**
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
* casts needed.
* casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
* content only) or a `{ content, meta }` object to also attach a tool-private
* presentation payload (see {@link ToolExecuteReturn}).
*/
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ContentBlock[]>
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ToolExecuteReturn>
/**
* Optional: how to present the PENDING state of one call in a UI (an editor
* tool-call card, a CLI log line). `args` is the typed, schema-validated
* argument shape — zero casts. Pure and side-effect-free: a UI may call it
* during live streaming AND a session-log replay, so depend only on `args`.
* The tool owns its presentation so a UI never special-cases tool names. See
* {@link ToolCallPresentation}.
* {@link ToolCallView}.
*/
presentCall?(args: InferArgs<S>): ToolCallPresentation | undefined
presentCall?(args: InferArgs<S>): ToolCallView | undefined
/**
* Optional: how to present the COMPLETED state, given the typed `args` and the
* `result`. Use it to reformat result content for a UI distinctly from the
* model-facing text (e.g. a fenced ```console block). Pure and side-effect-
* free for the same replay reason. See {@link ToolResultPresentation}.
* free for the same replay reason. See {@link ToolResultView}.
*/
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultPresentation | undefined
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined
/** Whether the tool requires structured output (default false). */
strict?: boolean
}
@@ -354,7 +356,7 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
description: options.description,
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
...options.strict !== undefined ? { strict: options.strict } : {},
async execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
// Validate the model-generated args before the typed body runs. On
// mismatch we throw ToolArgsError; the registry turns it into an
// isError result so the model can self-correct. After this guard, the
@@ -369,13 +371,13 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
// fall back to `undefined` (a generic UI presentation) on any mismatch, rather
// than the hard `ToolArgsError` the execute path raises.
if (userPresentCall) {
tool.presentCall = (args: unknown): ToolCallPresentation | undefined => {
tool.presentCall = (args: unknown): ToolCallView | undefined => {
if (validateArgs(options.parameters, args).length > 0) return undefined
return userPresentCall(args as InferArgs<S>)
}
}
if (userPresentResult) {
tool.presentResult = (args: unknown, result: ToolResult): ToolResultPresentation | undefined => {
tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => {
if (validateArgs(options.parameters, args).length > 0) return undefined
return userPresentResult(args as InferArgs<S>, result)
}

View File

@@ -0,0 +1,117 @@
/**
* Guarantee tests for the tool-schema catalog generator
* (`scripts/gen-tool-catalog.ts`).
*
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
* the freshness half is exercised by `pnpm run verify-tool-catalog` in CI. What
* a freshness diff CANNOT prove is (a) that BOOTING the tool plugins yields the
* shipped schema — the whole reason this generator boots instead of parsing
* source (a runtime-spread enum resolves to its literal members) — and (b) that
* the completeness guard REJECTS a tool package missing from the boot manifest,
* the property that replaces the AST pass's "nothing silently omitted". These
* tests drive the exported `collectToolCatalog` / `assertManifestComplete` /
* `render` directly, mirroring the negative-path style of the cordis-catalog
* generator tests.
*/
import { describe, expect, it } from 'vitest'
import {
assertManifestComplete,
collectToolCatalog,
render,
type ToolCatalog,
} from '../../../../scripts/gen-tool-catalog.ts'
/** JSON Schema shape enough to reach the values AST extraction can't. */
interface JsonSchema {
type: string
properties?: Record<string, JsonSchema>
items?: JsonSchema
enum?: string[]
required?: string[]
}
describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {
expect((schema.parameters as unknown as JsonSchema).type).toBe('object')
}
}
})
it('resolves a runtime-spread enum to its literal members (the payoff over AST)', async () => {
const catalog = await collectToolCatalog()
const todo = catalog
.flatMap(entry => entry.schemas)
.find(s => s.name === 'todo_write')
// `todo-todo` writes `enum: [...STATUSES]` — a source AST would see the
// spread, not the values. Booting yields the shipped enum literals.
const status = (((todo?.parameters as unknown as JsonSchema).properties?.todos)?.items)?.properties?.status
expect(status?.enum).toEqual(['pending', 'in_progress', 'completed'])
})
it('attributes each package with a source pointer that names its index', async () => {
const catalog = await collectToolCatalog()
const bash = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-bash')
expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts')
})
it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => {
// `tool-subagent`'s registered name is the load-time `toolName` config, so
// the shipped agents surface this one package as both `subagent` and
// `subagent_fork`. Booting yields only the default name; the note is how a
// reader learns the fork alias the model also sees. Without it the catalog
// would silently under-report the shipped tool surface.
const catalog = await collectToolCatalog()
const subagent = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent')
expect(subagent?.schemas.map(s => s.name)).toEqual(['subagent'])
expect(subagent?.note).toMatch(/subagent_fork/)
})
})
describe('gen-tool-catalog assertManifestComplete', () => {
it('passes when the manifest lists every on-disk tool package (the default)', () => {
expect(() => { assertManifestComplete() }).not.toThrow()
})
it('throws, naming the omitted package, when a tool package is missing from the manifest', () => {
// An empty manifest scanned against the real tree: every `tool-*` package
// is unlisted, so the guard must fire and name them.
expect(() => { assertManifestComplete([]) }).toThrow(/not in the boot manifest/)
expect(() => { assertManifestComplete([]) }).toThrow(/tool-bash/)
})
})
describe('gen-tool-catalog render', () => {
it('emits a package heading, a tool heading, and a json schema fence', () => {
const catalog: ToolCatalog = [
{
pkg: '@deepseek-ai/dsh-tool-demo',
source: 'packages/demo/tool-demo/src/index.ts',
schemas: [{ name: 'demo', description: 'A demo tool.', parameters: { type: 'object', properties: {} } }],
},
]
const md = render(catalog)
expect(md).toContain('## `@deepseek-ai/dsh-tool-demo`')
expect(md).toContain('### `demo`')
expect(md).toContain('A demo tool.')
expect(md).toContain('```json')
expect(md).toContain('Source: [`packages/demo/tool-demo/src/index.ts`]')
})
it('renders the strict flag when a schema sets it', () => {
const catalog: ToolCatalog = [
{
pkg: '@deepseek-ai/dsh-tool-demo',
source: 'packages/demo/tool-demo/src/index.ts',
schemas: [{ name: 'demo', description: '', parameters: { type: 'object', properties: {} }, strict: true }],
},
]
expect(render(catalog)).toContain('Strict: `true`')
})
})

View File

@@ -52,8 +52,8 @@ describe('ToolRegistry', () => {
description: 'has presenters',
parameters: { x: { type: 'string', required: true } },
async execute() { return [] },
presentCall: args => ({ title: args.x }),
presentResult: (args, result) => ({ title: args.x, content: result.content }),
presentCall: args => ({ card: 'generic', title: args.x }),
presentResult: (args, result) => ({ card: 'generic', title: args.x, content: result.content }),
}))
const schema = ctx.tools.schemas()[0] as unknown as Record<string, unknown>
expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters'])
@@ -81,6 +81,38 @@ describe('ToolRegistry', () => {
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
})
it('threads a tool-attached meta (object return form) onto the result', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'meta-tool',
async execute() {
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] } }
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} })
expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] },
})
})
it('omits meta when the object return form supplies none', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'no-meta-tool',
async execute() {
return { content: [{ type: 'text', text: 'ok' }] }
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
expect('meta' in result).toBe(false)
})
it('returns isError results for unknown tools and throwing tools', async () => {
const ctx = await setup()
ctx.tools.register({
@@ -906,15 +938,15 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
presentCall(args) {
// args is typed { path: string; n?: number } — zero casts.
expectTypeOf(args).toEqualTypeOf<{ path: string; n?: number }>()
return { title: `Open ${args.path}`, kind: 'read', rawInput: args.path }
return { card: 'generic', title: `Open ${args.path}`, kind: 'read', rawInput: args.path }
},
presentResult(args, result) {
return { title: `Opened ${args.path}`, content: result.content }
return { card: 'generic', title: `Opened ${args.path}`, content: result.content }
},
})
expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ title: 'Open /a', kind: 'read', rawInput: '/a' })
expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ card: 'generic', title: 'Open /a', kind: 'read', rawInput: '/a' })
expect(tool.presentResult!({ path: '/a' }, { content: [{ type: 'text', text: 'x' }], isError: false }))
.toEqual({ title: 'Opened /a', content: [{ type: 'text', text: 'x' }] })
.toEqual({ card: 'generic', title: 'Opened /a', content: [{ type: 'text', text: 'x' }] })
})
it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => {
@@ -934,8 +966,8 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
description: 'demo',
parameters: { path: { type: 'string', required: true } },
async execute() { return [] },
presentCall: args => ({ title: args.path }),
presentResult: (args, result) => ({ title: args.path, content: result.content }),
presentCall: args => ({ card: 'generic', title: args.path }),
presentResult: (args, result) => ({ card: 'generic', title: args.path, content: result.content }),
})
// Unlike execute (which throws ToolArgsError on a mismatch), the display
// methods soft-validate and fall back to undefined so a UI never crashes