dsh-cc/dsh-cc--packages-core-tools ↗★ 1

@dsh-cc/tools

Tool registry and execution pipeline for the DeepSeek Harness 适合构建或扩展工具插件的开发者,需理解注册、执行与呈现机制。

Package
@dsh-cc/tools
Compatibility
Unverified
Harness peer range
>=0.1.5-rc.1
Cordis peer range
>=0.1.5-rc.1
Version
0.7.1
License
Apache-2.0
Last updated
Sep 16, 2026

Install

This plugin has no verified bundle, or compatibility checks failed. Read the repository notes first. Read the full README ↗

dsh-tools

English | 中文

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 definition-owned finalizeContent boundary → 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, and one agent shadows that default for itself with presentAs.

Service: ToolRuntime (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, the generated tools:sdk section, and the tools:code-only rule stating that only run_code may be called directly — which the executor then enforces, resolving a model-direct call naming any other tool to UNKNOWN_TOOL before policy runs; both contributes both forms and states no such rule, because its native calls do execute. This is the default for agents that declare none of their own — an agent preset selects its own with dsh-agent-tool-presentation. The reserved transport cannot be registered, shadowed, restricted, or removed, and its name is reserved whatever the configured mode, because any agent may select a code mode. Non-native modes require a ctx.codeRuntime whose language has a registered SDK renderer — TypeScript ships via dsh-code-runtime-worker-thread; a Python renderer is built in and drives any runtime that reports language: 'python' (a first-party dsh-code-runtime-python backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a systemPrompt.toolOrder entry for a tool the mode does not contribute rejects prompt assembly. A system-prompt/assemble listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.

Public API

  • ctx.tools.register(definition: ToolDefinition): () => void Register a trusted typed same-process definition with a mandatory canonical output declaration. 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. Missing or unsupported output declarations and a non-positive or non-finite timeoutMs fail at registration. The optional synchronous finalizeContent callback is snapshotted when a call starts and may replace only final model-facing content after every pipeline outcome is normalized, including an error discovered while materializing another result field. Disposed with the calling fiber.
  • ctx.tools.presentAs(mode: ToolPresentationMode): () => void selects this agent's model-facing presentation, shadowing the mode config for that agent alone; it throws from a plain context (a process-wide presentation is the config field) and from a second declaration in the same scope. A code mode also registers that agent's own tools:sdk section. The catalog is unchanged — schemas(agent) still reports the agent's capabilities; only the assembly's tools collapse. Disposed with the calling fiber.
  • ctx.tools.restrict(filter) applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the scope security non-goal.
  • ctx.tools.reserve(name: string): () => void Reserve a capability NAME in the calling layer without registering a visible definition. The name joins the known/restrictable universe — a scope may later restrict() it and toolOrder may list it — but it never reaches the model-facing schema until a real register() supplies the definition. This lets a deferred-tool registry seed the names a composition may gate before their heavy definitions load (dsh-tool-search).
  • ctx.tools.isAdmitted(name: string, scope?: ScopeKey): boolean Whether a global capability name passes every scoped restriction on the viewing scope's chain, independent of registration. A name masked by an allow list it is absent from, or present in a deny list, is not admitted; restrictions intersect across the chain exactly as they do for registration visibility. Used to gate whether a deferred capability may load for one agent.
  • 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 Agent Note).
  • 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) losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body. Around wrappers may replace only signal; the registry re-fuses the original caller signal immediately before the body.
  • ctx.tools.executionMode(exec) returns parallel only when the visible definition's isConcurrencySafe(exec.arguments) classifier returns exactly true; unknown, hidden, undeclared, invalid, or throwing classifications are exclusive.

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.

Cancellation

Cancellation is cooperative and quiescent. Every typed invocation supplies a caller-owned AbortSignal; tool bodies receive it as required readonly exec.signal, while only tools/execute wrappers may temporarily replace the required signal. The registry preserves caller cancellation through replacement and never races away from a started same-process promise. Cancellation before body invocation is ABORTED_BEFORE_DISPATCH; cancellation after invocation can replace only a successful outcome with ABORTED. A denial, wrapper failure, tool failure, post-policy failure, or timeout-owned TOOL_TIMEOUT remains more specific. A pre-aborted entry materializes and freezes arguments, then skips every policy and dispatch phase and publishes one result. Every async tool must observe or forward the signal and settle only after owned work stops. The tool-cancellation Agent Note owns the full contract and hard-termination limit.

Live events

The live registry pipeline has three transformable waterfalls, then the definition-owned content finalizer, then the observe-only tools/result event; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure containment contracts live in the generated region of tools.md, 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

  • ToolDefinition — ToolSchema + mandatory output { schema, render, presentationMeta? } + execute(args, exec), optional final-content and presentation callbacks, cooperative timeoutMs, and optional per-call isConcurrencySafe(args) classification. A body returns only the canonical JSON value declared by the output schema and cooperatively stops through exec.signal. finalizeContent(exec, result) runs exactly once for every normalized result, including failures that bypass post-policy, and can replace only content; it must be synchronous and total.
  • ToolExecutionInput — the caller-supplied call description: { callId, name, arguments, signal, agent?, parent? }; signal is required and readonly, callers may pass an enclosing execution's opaque token as parent, and callers 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 readonly pipeline view: immutable { token, callId, name, arguments, signal, agent?, parent? }; the registry separately retains and re-fuses the original caller signal. ToolDispatchExecution is the tools/execute-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's parent is a ToolExecutionToken, not an execution object.
  • ToolRunContext — the execution passed to a tool body, extending ToolExecution with deferContext(context). It defers one context until the tool's final result reaches the loop — typically a nested-dispatch context ferried by a composite tool, or a fresh plugin-sourced instruction minted by a leaf tool (tool-goal's wrap-up) — even when the tool later throws or cancellation wins; it never injects immediately.
  • ToolExecutionResult — discriminated execution-local outcome. Success is { isError:false, value:JsonValue, content, meta?, additionalContexts? }; failure is { isError:true, error:{ message, info? }, content, meta?, additionalContexts? } and has no value. Call identity stays on the immutable ToolExecution. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. ToolFailure.info carries an internal { name, code } for a HarnessError; additionalContexts preserves every deferred or post-execute identified UserMessage for the loop's post-result FIFO.
  • 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 — accept may replace content or value, never both, and may attach additionalContexts; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
  • 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 canonical dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal; a wrapper-authored success is normalized through the resolved tool's output declaration. Each canonical result belongs to one immutable dispatch token, so a cached result from another call or tool is revalidated under the active declaration.
  • tools/post-execute may replace presentation content, replace the canonical value, block with feedback, or attach ordered contexts. A definition's optional finalizeContent then owns its last content-only invariant across normal results and outer pipeline failures; tools/result observes the immutable final outcome. Content replacement is not a confidentiality boundary: block or replace the value when programmatic consumers must not receive it.
  • Exact signatures and ordering live in the generated region of tools.md and pipeline.
  • MCP servers: one plugin per server, discover tools, call ctx.tools.register() with the server's schemas.

CC tool-name translation (cc-names)

The harness registers global tools under its own names (mostly lowercase: read, bash, web_fetch; a few capitalized: NotebookEdit, Sleep), while Claude Code config authors write CC names (Read, Bash, WebFetch). tools.restrict() validates names strictly and throws on unknown ones, so CC names must be translated at the ingestion boundary — never compared or restricted raw:

  • CC_TO_HARNESS_TOOLS — the one-to-many map (e.g. Read → read + read_image, since the harness splits image reading out of read).
  • translateToolNames(names, policy, onDiagnostic?) — for allow/deny lists bound for restrict(). strict (agent frontmatter, load time) passes unknown names through so restrict() fails loudly with its own error; lenient (skill activation, user/model-driven data) drops unknown names with a diagnostic and yields undefined when nothing survives — a dropped name must never kill the session. Parenthesized arg-specs (Bash(git status)) are stripped to the bare name, deliberately widening to a name-level gate.
  • ccToolAliases(name) / ccToolAliases-based matching — for comparisons (permission rules, hook matchers): match a CC-authored name against every alias of the harness exec.name, so both Bash(...) and bash(...) rules work; aliases are match-only and never written back into a restriction.
  • ccCanonicalToolName(name) — the CC name for CC-facing payloads (e.g. hook tool_name), or the input when no CC alias exists.

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 '@deepseek-ai/cordis'
import { defineTool } from '@dsh-cc/tools'

declare const ctx: Context

ctx.tools.register(defineTool({
  name: 'read_file',
  desc