Files
deepseek-harness/packages/core/tools/README.md
Tianyi Cui bc7da642d4 feat: fold the Code Mode demos into demo:code-mode with a UI argument
Code Mode is the point; the UI is just the surface it happens to wear.
demo:code and demo:acp-code collapse into one dispatcher
(scripts/demo-code-mode.mjs): `pnpm run demo:code-mode [repl|acp]` —
repl (default) boots the stdio REPL over examples/code-agent, acp
serves examples/acp-agent's code-mode overlay; each UI runs the exact
node invocation its standalone script ran, and an unknown argument
fails loud with usage. All nine references across READMEs, the RFC,
the overlay header, and the keyless-smoke comment renamed. Smoked all
three paths: usage exit 2, ACP initialize handshake, REPL boot + EOF.
2026-07-09 12:16:37 +08:00

17 KiB

dsh-tools

Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through tools/pre-execute (the allow/deny gate) → core dispatch → tools/post-execute (inspect/replace the result, attach context). The registry also owns HOW its tools are presented to the model — its mode config selects native function calling, Code Mode, or both.

Service: ToolRegistry (ctx key: tools)

Config

tools:
  mode: native   # native (default) | code | both

native contributes every registered tool as a wire function definition — the default, byte-for-byte the pre-config behavior. code contributes exactly ONE wire tool, run_code, plus the generated tools:sdk prompt section (see Code Mode). both contributes every native definition AND run_code + the SDK section. Non-native modes require a loaded ctx.codeRuntime with language: 'typescript'; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a systemPrompt.toolOrder naming tools the mode no longer contributes rejects the assembly the same way.

Public API

  • 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). The shipped tools' schemas are catalogued in docs/tool-catalog.md, generated by booting each tool plugin and harvesting this method (see the tool-schema-catalog RFC).
  • ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult> Execute one tool call through the tools/pre-execute → dispatch → tools/post-execute pipeline.

Injected services

SystemPrompt — the registry automatically feeds its tool schemas into the system-prompt assembly via ctx.systemPrompt.tools().

Events

Event Mode Purpose
tools/pre-execute waterfall Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns PreToolDecision
tools/post-execute waterfall Inspect/replace the result AFTER a tool runs, attach context; returns PostToolDecision
tools/change emit A tool was registered or unregistered

Key types

  • ToolDefinitionToolSchema + 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?, additionalContext?, meta? }. 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). additionalContext (a HookContext) ferries any tools/post-execute context up to the loop, which buffers it and appends it as a context/message after all tool/results in the step. meta is the tool's opaque presentation payload from a successful execute (the object return form); the loop forwards it onto the tool/result session event for result-card rendering.
  • PreToolDecision{kind:'allow'} | {kind:'deny', reason} | {kind:'ask', reason?}. Input rewrite (changing arguments) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); ask degrades to deny until the permission system lands.
  • PostToolDecision{kind:'accept', content?, additionalContext?} (keep the call successful, optionally replacing the model-facing content) | {kind:'block', feedback, additionalContext?} (turn it into an isError whose content is the corrective feedback). Output replacement is clean because tool/result is logged AFTER execute() returns.
  • 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

  • Tool plugins call ctx.tools.register() — schemas flow into the assembly automatically.
  • tools/pre-execute is the allow/deny gate (sandbox, permission, hooks): listeners receive (exec, next) and call next() to delegate to the default (allow) or return a PreToolDecision to short-circuit; a deny/ask skips dispatch and yields an isError result. tools/post-execute is the inspect/transform seam: (exec, result, next) → a PostToolDecision that can replace content, block with feedback, or attach additionalContext. Core dispatch sits between them as plain code; the tool body keeps its own try/catch so a thrown tool still reaches post-execute as an isError. Both follow the typed-Decision idiom shared with the agent/* interception seams (see dsh-agent).
  • MCP servers: one plugin per server, discover tools, call ctx.tools.register() with the server's schemas.

Typed tool parameter schemas

First-party plugin authors can use the defineTool() helper (exported from this package) for typed tool parameter schemas:

import { readFile } from 'node:fs/promises'
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

declare const ctx: Context

ctx.tools.register(defineTool({
  name: 'read_file',
  description: 'Read a file from disk.',
  parameters: {
    path: { type: 'string', required: true, description: 'Absolute file path' },
    offset: { type: 'number' },
    limit: { type: 'number' },
  },
  async execute(args, exec) {
    // args is typed: { path: string; offset?: number; limit?: number }
    const text = await readFile(args.path, 'utf8')
    return [{ type: 'text', text }]
  },
}))

The helper converts the author-facing SchemaSpec (with required: true as a per-property boolean) to standard JSON Schema for the wire format. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly.

A defineTool tool also validates the model-generated arguments against its SchemaSpec before execute runs (validateArgs). The model's JSON is untrusted — InferArgs<S> is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a ToolArgsError (code: 'INVALID_ARGS'); the registry turns it into an isError result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, default is not applied, and an object/array prop without properties/items only type-checks. Raw-registered tools (MCP) are not validated by the harness — they validate their own input.

See defineTool, validateArgs, ToolArgsError, SchemaSpec, InferArgs, and schemaSpecToJsonSchema in the public API for details.

Structured-output schema subset

A separate vocabulary for callers that DEMAND a machine-readable value from an agent — the subagent seam's SubagentStartRequest.outputSchema (and, by extension, a workflow's agent({ schema })). Unlike SchemaSpec (the author-facing DSL for tool parameters), a StructuredOutputSchema is an object-rooted raw JSON Schema subset as data: it travels verbatim to the model as a forced tool's parameters, and the produced value is validated against it.

The subset is deliberately narrow and REJECTS LOUD outside it — accepting a keyword the validator doesn't enforce would validate less than the schema promises (accepted-then-ignored). Supported: single-string type (object/array/string/number/integer/boolean/null; type arrays rejected), properties/required/additionalProperties (boolean; every required key must be declared), items, scalar-only enum/const; annotations (description/title/default/examples) are ignored but must still be JSON data. assertSupportedOutputSchema(schema) throws OutputSchemaError (code: 'UNSUPPORTED_SCHEMA', listing every violation) for anything else; validateStructuredValue(schema, value) returns path-qualified violations (empty = valid, total — never throws).

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 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): 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. 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.

import { defineTool } from '@deepseek-ai/dsh-tools'

const bash = defineTool({
  name: 'bash',
  description: 'Run a shell command.',
  parameters: {
    command: { type: 'string', required: true, description: 'The command to run.' },
    description: { type: 'string', required: true, description: 'One-line summary shown in the UI.' },
  },
  async execute(args) {
    return [{ type: 'text', text: `ran: ${args.command}` }]
  },
  // 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 { card: 'terminal', output: block.text }
  },
})

Code Mode

Under mode: code (or both) the registry turns the tool surface into a programming API, per the Code Mode RFC: the model writes a TypeScript program (the body of an async function) and passes it to the ONE wire tool run_code; the program runs in ctx.codeRuntime (the code-execution seam — the shipped backend is a worker thread) with one async binding per registered tool (await tools.bash({...})), and ONLY what it prints or returns re-enters the model's context.

  • The SDK section (tools:sdk, order 150): a lazy prompt section regenerating, at each assembly, a declare const tools: {...} TypeScript declaration of every registered tool except run_code (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (jsonSchemaToTs, exported) is TOTAL: constructs outside the defineTool subset degrade to unknown, never throw.
  • The dispatch bridge (run_code's execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — BigInt, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even Promise.all executes the underlying ctx.tools.execute() calls one at a time in submission order — the tool contract carries no concurrency-safety metadata yet), gated by tools/pre-execute/tools/post-execute like any native call (a deny reaches the program as a binding rejection), and logged as one tool/code-dispatch session event (log-only: deriveMessages() never surfaces it) with the deterministic sub-id <parent>:code:<n>. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's additionalContext is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode).
  • Settlement discipline: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every tool/code-dispatch lands inside the open turn. A failed run throws CodeRunFailedError (code: 'CODE_RUN_FAILED', message = the failure kind + captured logs), which the pipeline converts to a structured isError the model self-corrects from.

The wire collapse is the registry's own contribution (systemPrompt.tools() is mode-aware), so the logged request/header records it for free — under code, the assembled tool list is exactly [run_code], pinned by tests and the snapshot goldens. Try it: pnpm run demo:code-mode (examples/code-agent); pnpm run demo:code-mode acp serves the same mode over ACP instead of the REPL.

What is NOT here (TODO)

  • Tool shapes review — when real tools land (e.g. a concurrency-safety hint for parallel execution); phase 1 executes tool calls sequentially.
  • Parallel execution — the loop currently iterates tool calls sequentially.