Files
deepseek-harness/packages/core/tools

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 extensible allow/deny gate) → monotonic registered guards → tools/execute (an around-dispatch wrapper for timeout/retry/metrics plugins) → tools/post-execute (inspect/replace the result, attach context) → the observe-only tools/result notification. 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 visible tools as function definitions. code contributes the reserved run_code transport and generated tools:sdk section; both contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a TypeScript ctx.codeRuntime, and incompatible tool-order configuration rejects prompt assembly.

Public API

  • ctx.tools.register(definition: ToolDefinition): () => void Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's agent.ctx registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved run_code transport name. timeoutMs, when present, must be positive and finite. Disposed with the calling fiber.
  • ctx.tools.restrict(filter) applies an agent-scoped allow/deny mask to global tools; multiple masks intersect and scope-local tools merge afterwards. Unknown, local, or reserved names and empty filters reject. This is visibility composition, not an authority boundary; see the scope security non-goal.
  • 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.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.guard(guard: ToolGuard): () => void Register a monotonic synchronous execution guard after tools/pre-execute: returning a reason denies the call, while undefined leaves it unchanged. A plain-context guard applies globally; an agent.ctx guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
  • ctx.tools.execute(exec) snapshots arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, and snapshots the authoritative outcome before final observation.

Injected services

SystemPrompt — the registry automatically feeds its tool schemas into the system-prompt assembly via ctx.systemPrompt.tools(). The approval seam is consumed opportunistically instead (ctx.get('approval'), no static inject): a deployment without it keeps the ask→deny degrade, and the registry stays active either way.

Live events

The live registry pipeline has three transformable waterfalls followed by the observe-only tools/result boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated Cordis event catalog, while the complete ordering is visualized in the generated tool execution pipeline. tools/result is live; the similarly named tool/result is the durable session event the agent loop appends afterwards.

Key types

  • ToolDefinitionToolSchema + execute(args, exec), optional presentation callbacks, and cooperative timeoutMs.
  • ToolExecutionInput — the caller-supplied call description: { callId, name, arguments, agent?, parent?, signal? }; callers may pass an enclosing execution's opaque token as parent but never choose the new execution's own token.
  • ToolExecutionToken — a fresh branded Symbol assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
  • ToolExecution — the pipeline-owned call: immutable { token, callId, name, arguments, agent?, parent? } identity plus optional operational signal, which an around wrapper may add, replace, remove, and restore. A nested call's parent is a ToolExecutionToken, not an execution object.
  • ToolExecutionResult — losslessly JSON-serializable outcome: { callId, content, isError, error?, additionalContext?, meta? }. The registry materializes and freezes the complete post-policy value before final observation. On failure with a HarnessError, error: { name, code } carries the structured failure class alongside the model-facing text.
  • PreToolDecision{kind:'allow'} | {kind:'deny', reason} | {kind:'ask', reason?}. Input rewrite is deliberately not offered; ask is serviced by ctx.approval when mounted and otherwise degrades to deny.
  • 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.
  • ToolGuard(execution) => string | undefined; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.
  • 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 reorderable allow/deny/ask gate; ctx.tools.guard() adds monotonic owner policy after it.
  • tools/execute wraps normalized core dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal.
  • tools/post-execute may replace content, block with feedback, or attach context; tools/result observes the immutable final outcome.
  • Exact signatures and ordering live in the generated event catalog and pipeline.
  • 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 and uses the same typed spec for execute/presentation validation. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly.

A defineTool definition validates model arguments before execution and turns violations into ToolArgsError (INVALID_ARGS) for the normal error-result path. Extra keys are allowed and defaults are not applied. Raw-registered tools own their validation.

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

Optional timeoutMs must be positive and finite; it is policy metadata, not model-visible schema.

Structured-output schema subset

StructuredOutputSchema is the object-rooted raw JSON Schema subset used by subagents and workflows for machine-readable results. It supports scalar types, objects, arrays, scalar enum/const, and annotations. Unsupported or inconsistent keywords fail through OutputSchemaError; validateStructuredValue() returns path-qualified violations.

Tool-owned UI presentation

Tools optionally own pure presentCall() and presentResult() render intents, so UIs do not special-case tool names. The card discriminator is generic, terminal, or diff; returning undefined selects generic fallback. Result-time presentation may read JSON-serializable result.meta, which is persisted for replay. The render-intent RFC owns the shapes and rationale.

Code Mode

Under code or both, the registry exposes the reserved run_code transport and a deterministic TypeScript SDK for the current scope. Each program binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Run settlement aborts and drains outstanding bindings; failures surface as CodeRunFailedError. See the Code Mode RFC and code-runtime seam. Try pnpm run demo:code-mode.

What is NOT here (TODO)

  • Concurrency metadata — tool definitions do not declare whether executions are safe to overlap.
  • Parallel execution — the loop and Code Mode bridge execute tool calls sequentially until that metadata exists.