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.ToolRunContext— the execution passed to a tool body, extendingToolExecutionwithdeferContext(context). Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately.ToolExecutionResult— losslessly JSON-serializable outcome:{ content, isError, error?, additionalContexts?, meta? }. Call identity stays on the immutableToolExecutionsupplied alongside the result instead of being duplicated on the outcome. 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.additionalContextspreserves each deferred or post-executeHookContextwith its own source, envelope, and durable JSON metadata; the loop buffers the array and appends each entry as acontext/messageafter alltool/results in the step.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?, additionalContexts?}(keep the call successful, optionally replacing the model-facing content) |{kind:'block', feedback, additionalContexts?}(turn it into anisErrorwhose content is the corrective feedback). 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 core dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal.tools/post-executemay replace content, block with feedback, or attach ordered contexts;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 sub-call additionalContexts are deferred through the parent result 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.
- The SDK section (
tools:sdk, order 150): a lazy prompt section regenerating, at each assembly, adeclare const tools: {...}TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (jsonSchemaToTs, exported) is total: constructs outside thedefineToolsubset degrade tounknown, never throw. - The dispatch bridge (
run_code's execute): every binding call is JSON-normalized before dispatch (a value that does not survive —BigInt, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (evenPromise.allexecutes underlying calls one at a time in submission order), given the outer execution's opaque token asparent, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as atool/code-dispatchsession event with deterministic id<parent>:code:<n>;deriveMessages()does not surface that event. Token correlation lets commit-style observers defer an inner success until the finalrun_coderesult without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-calladditionalContextsentry is deferred through the outerToolRunContextin dispatch order; the loop appends those contexts only after the parentrun_coderesult, preserving adjacency and retaining each source/envelope/meta even when the program later fails. - Settlement discipline: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every
tool/code-dispatchlands inside the open turn. A failed run throwsCodeRunFailedError(code: 'CODE_RUN_FAILED', message = the failure kind + captured logs), which the pipeline converts to a structuredisErrorthe model self-corrects from.
Model Experience
Normal tool schemas
What the model sees: In normal mode the model sees each visible definition's exact name, description, and JSON schema; the shipped definitions are recorded in the generated tool package map and schema sections. Agent-scoped restrictions, shadows, and extension registrations change that agent's end-tool set.
Token effect: Fixed per-request cost proportional to the visible definitions. Restrictions that hide tools remove their entire schema cost for that agent.
Code Mode schema and system prompt
What the model sees: Code Mode exposes the generated run_code schema, the SDK instructions below, and the generated exact declare const tools block. both exposes normal schemas and this Code Mode surface.
Token effect: Fixed per-request cost proportional to the visible definitions. Code Mode trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction.
Code Mode SDK instructions
## Writing code for run_code
Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:
- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.
- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.
- Calls execute sequentially, even under `Promise.all`.
- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
The available tools:
Tool-call history and results
What the model sees: The loop retains model-emitted arguments and the registry's final content. Any thrown or denied call becomes exactly Error: <message>. Code Mode returns only the outer program's printed lines and rendered return value, (run_code completed with no output) when both are empty, or Error: code run failed (<kind>): <message> followed conditionally by Captured output: and the captured lines. Inner dispatch events stay log-only; post-execute listeners may append source-attributed context after the result.
Token effect: Arguments, results, and additional context are data-dependent and resent until compaction. Restrictions that hide tools also remove their schemas before the model can call them.
Known Limitations and Deferred Work
- Native tool calls execute sequentially —
ToolDefinitioncarries no concurrency-safety metadata; adding it (and parallel execution in the loop) waits on the deferred tool-shapes review (TODO(review)). tools/pre-executedeliberately cannot rewriteexec.arguments— logged and rendered args would desync from what ran; the rewrite design is a proposed RFC.defineTool's schema DSL is a deliberate subset — string/number/boolean/object/array with string-onlyenum;validateArgstolerates extra keys and never appliesdefault(XXX(unused-default)flags removing that field); raw-registered JSON-Schema tools validate their own input.timeoutMson a definition is declarative only — the registry never enforces deadlines; enforcement requires the@deepseek-ai/dsh-timeout-policywrapper.- Code Mode is TypeScript-only and the presentation mode is service-wide —
mode: code/bothrejects prompt assembly unlessctx.codeRuntime.language === 'typescript'; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only. - Code Mode bindings return text only — non-text content blocks in a sub-call result collapse to
[<type> content]placeholders. run_codestate is fresh per run — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see the Code Mode RFC.