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 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. 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.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 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.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 RFC).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.
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
ToolDefinition—ToolSchema+execute(args, exec), optional presentation callbacks, and cooperativetimeoutMs.ToolExecutionInput— the caller-supplied call description:{ callId, name, arguments, agent?, parent?, signal? }; callers may pass an enclosing execution's opaque token asparentbut 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 pipeline-owned call: immutable{ token, callId, name, arguments, agent?, parent? }identity plus optional operationalsignal, which an around wrapper may add, replace, remove, and restore. A nested call'sparentis aToolExecutionToken, 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 aHarnessError,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;askis serviced byctx.approvalwhen 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 anisErrorwhose content is the corrective feedback). Output replacement is clean becausetool/resultis logged AFTERexecute()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-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 core dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal.tools/post-executemay replace content, block with feedback, or attach context;tools/resultobserves 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 missing required values, wrong primitives, invalid enum members, and nested violations into ToolArgsError (INVALID_ARGS) for the normal error-result path. Extra keys are allowed, defaults are not applied, and object or array fields without properties or items receive only a type check. 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 accepts one scalar type, object properties/required/boolean additionalProperties, array items, and scalar enum/const. The annotations description, title, default, and examples are ignored but must remain JSON data. Type arrays, undeclared required keys, and unsupported keywords fail through OutputSchemaError rather than being ignored; validateStructuredValue() returns path-qualified violations without throwing.
Tool-owned UI presentation
Tools optionally own pure presentCall() and presentResult() render intents, so UIs do not special-case tool names:
- Call views are
{ card: 'generic', title, kind?, rawInput?, content?, locations? },{ card: 'terminal', title, description?, cwd? }, or{ card: 'diff', title, diffs, locations? }. - Result views are
{ card: 'generic', title?, content? },{ card: 'terminal', title?, output?, exitCode?, signal? }, or{ card: 'diff', title?, diffs }.
Returning undefined selects generic fallback. Presenters depend only on their arguments because UIs call them during live streaming and log replay. Result presentation may read JSON-serializable result.meta, which persists with the result; defineTool soft-validates older logged arguments and falls back instead of crashing replay. dsh-tool-bash and dsh-tool-fs are the reference implementations; the render-intent RFC owns the rationale.
Code Mode
Under code or both, the registry exposes the reserved run_code transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and mid-run additionalContext is omitted to preserve call/result adjacency. 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.