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): () => 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; 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 | 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)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
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 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.