dsh-cc/dsh-cc--packages-core-tools ↗★ 1
@dsh-cc/tools
为DeepSeek Harness提供工具注册与执行流水线,含拦截与呈现模式。 适合构建或扩展工具插件的开发者,需理解注册、执行与呈现机制。
安装
此插件尚未提供可验证的 bundle,或兼容性检查未通过。请先阅读仓库说明。 阅读完整 README ↗
说明文档
阅读完整 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): () => voidRegister a trusted typed same-process definition with a mandatory canonicaloutputdeclaration. The layer is the calling context's scope: a plain plugin context registers globally; an agent'sagent.ctxregisters for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reservedrun_codetransport name. Missing or unsupported output declarations and a non-positive or non-finitetimeoutMsfail at registration. The optional synchronousfinalizeContentcallback 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): () => voidselects this agent's model-facing presentation, shadowing themodeconfig 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 owntools:sdksection. 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): () => voidReserve a capability NAME in the calling layer without registering a visible definition. The name joins the known/restrictable universe — a scope may laterrestrict()it andtoolOrdermay list it — but it never reaches the model-facing schema until a realregister()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): booleanWhether a global capability name passes every scoped restriction on the viewing scope's chain, independent of registration. A name masked by anallowlist it is absent from, or present in adenylist, 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 | undefinedResolution 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 theexecutefunctions). 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): () => voidRegister a monotonic synchronous execution guard aftertools/pre-execute: returning a reason denies the call, whileundefinedleaves it unchanged. A plain-context guard applies globally; anagent.ctxguard 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 onlysignal; the registry re-fuses the original caller signal immediately before the body.ctx.tools.executionMode(exec)returnsparallelonly when the visible definition'sisConcurrencySafe(exec.arguments)classifier returns exactlytrue; 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+ mandatoryoutput { schema, render, presentationMeta? }+execute(args, exec), optional final-content and presentation callbacks, cooperativetimeoutMs, and optional per-callisConcurrencySafe(args)classification. A body returns only the canonical JSON value declared by the output schema and cooperatively stops throughexec.signal.finalizeContent(exec, result)runs exactly once for every normalized result, including failures that bypass post-policy, and can replace onlycontent; it must be synchronous and total.ToolExecutionInput— the caller-supplied call description:{ callId, name, arguments, signal, agent?, parent? };signalis required and readonly, callers may pass an enclosing execution's opaque token asparent, and callers never choose the new execution's own token.ToolExecutionToken— a fresh brandedSymbolassigned 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.ToolDispatchExecutionis thetools/execute-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call'sparentis aToolExecutionToken, not an execution object.ToolRunContext— the execution passed to a tool body, extendingToolExecutionwithdeferContext(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 immutableToolExecution. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation.ToolFailure.infocarries an internal{ name, code }for aHarnessError;additionalContextspreserves every deferred or post-execute identifiedUserMessagefor the loop's post-result FIFO.PreToolDecision—{kind:'allow'}|{kind:'deny', reason}|{kind:'ask', reason?}. Input rewrite is deliberately not offered;askis serviced byctx.approvalwhen mounted and otherwise degrades to deny.PostToolDecision— accept may replacecontentorvalue, never both, and may attachadditionalContexts; 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-neutralcard-tagged render intents a tool returns frompresentCall/presentResultto 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-executeis the reorderable allow/deny/ask gate;ctx.tools.guard()adds monotonic owner policy after it.tools/executewraps 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-executemay replace presentation content, replace the canonical value, block with feedback, or attach ordered contexts. A definition's optionalfinalizeContentthen owns its last content-only invariant across normal results and outer pipeline failures;tools/resultobserves 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 ofread).translateToolNames(names, policy, onDiagnostic?)— for allow/deny lists bound forrestrict().strict(agent frontmatter, load time) passes unknown names through sorestrict()fails loudly with its own error;lenient(skill activation, user/model-driven data) drops unknown names with a diagnostic and yieldsundefinedwhen 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 harnessexec.name, so bothBash(...)andbash(...)rules work; aliases are match-only and never written back into a restriction.ccCanonicalToolName(name)— the CC name for CC-facing payloads (e.g. hooktool_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