Files
deepseek-harness/packages/core/tools
Tianyi Cui 7c5133488a refactor(core): every registry register-method returns the exact effect disposer
The exact-disposer fix (5fbac8be B1) repaired agents.register but left the
same wrapper (return () => void dispose()) at seven sibling sites:
tools.register, tools.restrict, systemPrompt.section/tools/variable,
agents.setFactory, and subagents.registerProvider. A wrapper makes correct
composite usage unrepresentable — the exact disposer cannot be recovered, so
a generator effect yielding it leaves the inner effect disposing as a
CONCURRENT SIBLING on owner unload, silently reproducing B1's ordering
corruption. The exact disposer serves both usages (composite-nestable AND
fire-and-forget callable); all seven now return it, typed
() => Promise<void> | void, with the convention pinned by a discriminating
test: an async-link composite probe that passes with the exact disposer and
observes the sibling unregistration firing mid-drain with a wrapper.

Re-auditing also surfaced that B1 itself SHIPPED a full-lint failure: it
changed register()'s return type without updating cross-file consumers
(agent.spec.ts dispose() statements, tool-bash's disposer list), which the
staged-scoped pre-commit lint never saw — pnpm run lint was red at HEAD.
Those three sites and this change's own fallout are fixed together: tests
now await disposers (stronger — they observe the full unwind), sync
paths void them, and the two annotation sites carry the honest union type.
agents.register's README line had drifted the same way (B1 updated the
JSDoc, not the README) — all seven README signatures now match; services
catalog regenerated.
2026-07-09 13:05:44 +08:00
..

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

Service: ToolRegistry (ctx key: tools)

Public API

  • ctx.tools.register(definition: ToolDefinition): () => Promise<void> | void Register a tool. The layer is the CALLING context's scope (dsh-scope): a plain plugin context registers globally; an agent's agent.ctx registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw. Disposed with the calling fiber (= the agent, for scoped registrations).
  • ctx.tools.restrict(filter: ToolRestriction): () => Promise<void> | void Scoped-only (throws on a plain context): mask the GLOBAL tool surface for the calling agent — allow keeps only the listed tools, deny removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. Snapshot-at-registration, loud unknown-name validation, restrict({}) rejects (the materialized-empty-config trap).
  • 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.visible(scope?: ScopeKey): ToolDefinition[] THE visibility function — restricted global layer the scope's own layer — feeding prompt assembly, get, and execute, so what the model sees and what dispatches can never disagree.
  • ctx.tools.knownNames(scope?: ScopeKey): string[] The PRE-restriction name universe configuration (toolOrder, restrict) validates against: a typo fails loud while a restricted-away tool stays a normal absence.
  • 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, 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. Scope-filtered by exec.agent: an agent.ctx listener gates only its own agent
tools/post-execute waterfall Inspect/replace the result AFTER a tool runs, attach context; returns PostToolDecision
tools/change emit A tool or restriction was registered or unregistered (possibly for one scope); deliberately unfiltered

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 }
  },
})

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.