The exact-disposer fix (5fbac8be B1) repaired agents.register but left the same wrapper (return () => void dispose()) at seven sibling sites: tools.register, tools.restrict, systemPrompt.section/tools/variable, agents.setFactory, and subagents.registerProvider. A wrapper makes correct composite usage unrepresentable — the exact disposer cannot be recovered, so a generator effect yielding it leaves the inner effect disposing as a CONCURRENT SIBLING on owner unload, silently reproducing B1's ordering corruption. The exact disposer serves both usages (composite-nestable AND fire-and-forget callable); all seven now return it, typed () => Promise<void> | void, with the convention pinned by a discriminating test: an async-link composite probe that passes with the exact disposer and observes the sibling unregistration firing mid-drain with a wrapper. Re-auditing also surfaced that B1 itself SHIPPED a full-lint failure: it changed register()'s return type without updating cross-file consumers (agent.spec.ts dispose() statements, tool-bash's disposer list), which the staged-scoped pre-commit lint never saw — pnpm run lint was red at HEAD. Those three sites and this change's own fallout are fixed together: tests now await disposers (stronger — they observe the full unwind), sync paths void them, and the two annotation sites carry the honest union type. agents.register's README line had drifted the same way (B1 updated the JSDoc, not the README) — all seven README signatures now match; services catalog regenerated.
14 KiB
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 allow/deny gate) → core dispatch → tools/post-execute (inspect/replace the result, attach context).
Service: ToolRegistry (ctx key: tools)
Public API
ctx.tools.register(definition: ToolDefinition): () => Promise<void> | voidRegister a tool. The layer is the CALLING context's scope (dsh-scope): a plain plugin context registers globally; an agent'sagent.ctxregisters for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw. Disposed with the calling fiber (= the agent, for scoped registrations).ctx.tools.restrict(filter: ToolRestriction): () => Promise<void> | voidScoped-only (throws on a plain context): mask the GLOBAL tool surface for the calling agent —allowkeeps only the listed tools,denyremoves them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. Snapshot-at-registration, loud unknown-name validation,restrict({})rejects (the materialized-empty-config trap).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.visible(scope?: ScopeKey): ToolDefinition[]THE visibility function — restricted global layer ∪ the scope's own layer — feeding prompt assembly,get, andexecute, so what the model sees and what dispatches can never disagree.ctx.tools.knownNames(scope?: ScopeKey): string[]The PRE-restriction name universe configuration (toolOrder,restrict) validates against: a typo fails loud while a restricted-away tool stays a normal absence.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.execute(exec: ToolExecution): Promise<ToolExecutionResult>Execute one tool call through thetools/pre-execute→ dispatch →tools/post-executepipeline.
Injected services
SystemPrompt — the registry automatically feeds its tool schemas into the system-prompt assembly via ctx.systemPrompt.tools().
Events
| Event | Mode | Purpose |
|---|---|---|
tools/pre-execute |
waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns PreToolDecision. Scope-filtered by exec.agent: an agent.ctx listener gates only its own agent |
tools/post-execute |
waterfall | Inspect/replace the result AFTER a tool runs, attach context; returns PostToolDecision |
tools/change |
emit | A tool or restriction was registered or unregistered (possibly for one scope); deliberately unfiltered |
Key types
ToolDefinition—ToolSchema+execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>(the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializablemetapresentation payload persisted on thetool/resultevent and handed back topresentResult), plus optionalpresentCall(args)/presentResult(args, result)for tool-owned UI presentation (see below).ToolExecution— one pending tool call:{ callId, name, arguments, agent?, signal? }.ToolExecutionResult— outcome:{ callId, content, isError, error?, additionalContext?, meta? }. On failure with aHarnessError,error: { name, code }carries the structured failure class alongside the model-facing text (the loop forwards it onto thetool/resultsession event for retry/sandbox plugins and replay).additionalContext(aHookContext) ferries anytools/post-executecontext up to the loop, which buffers it and appends it as acontext/messageafter alltool/results in the step.metais the tool's opaque presentation payload from a successfulexecute(the object return form); the loop forwards it onto thetool/resultsession event for result-card rendering.PreToolDecision—{kind:'allow'}|{kind:'deny', reason}|{kind:'ask', reason?}. Input rewrite (changingarguments) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC);askdegrades todenyuntil the permission system lands.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.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 allow/deny gate (sandbox, permission, hooks): listeners receive(exec, next)and callnext()to delegate to the default (allow) or return aPreToolDecisionto short-circuit; adeny/askskips dispatch and yields anisErrorresult.tools/post-executeis the inspect/transform seam:(exec, result, next)→ aPostToolDecisionthat can replace content, block with feedback, or attachadditionalContext. Core dispatch sits between them as plain code; the tool body keeps its own try/catch so a thrown tool still reachespost-executeas anisError. Both follow the typed-Decision idiom shared with theagent/*interception seams (seedsh-agent).- 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. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly.
A defineTool tool also validates the model-generated arguments against its SchemaSpec before execute runs (validateArgs). The model's JSON is untrusted — InferArgs<S> is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a ToolArgsError (code: 'INVALID_ARGS'); the registry turns it into an isError result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, default is not applied, and an object/array prop without properties/items only type-checks. Raw-registered tools (MCP) are not validated by the harness — they validate their own input.
See defineTool, validateArgs, ToolArgsError, SchemaSpec, InferArgs, and schemaSpecToJsonSchema in the public API for details.
Structured-output schema subset
A separate vocabulary for callers that DEMAND a machine-readable value from an agent — the subagent seam's SubagentStartRequest.outputSchema (and, by extension, a workflow's agent({ schema })). Unlike SchemaSpec (the author-facing DSL for tool parameters), a StructuredOutputSchema is an object-rooted raw JSON Schema subset as data: it travels verbatim to the model as a forced tool's parameters, and the produced value is validated against it.
The subset is deliberately narrow and REJECTS LOUD outside it — accepting a keyword the validator doesn't enforce would validate less than the schema promises (accepted-then-ignored). Supported: single-string type (object/array/string/number/integer/boolean/null; type arrays rejected), properties/required/additionalProperties (boolean; every required key must be declared), items, scalar-only enum/const; annotations (description/title/default/examples) are ignored but must still be JSON data. assertSupportedOutputSchema(schema) throws OutputSchemaError (code: 'UNSUPPORTED_SCHEMA', listing every violation) for anything else; validateStructuredValue(schema, value) returns path-qualified violations (empty = valid, total — never throws).
Tool-owned UI presentation
A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A ToolDefinition may declare two optional, pure, display-only methods that return a card-tagged render intent (a discriminated union — a tool declares its card kind once and a UI bridge switches on card):
presentCall(args): ToolCallView | undefined— the PENDING state, one of:{ card: 'generic', title, kind?, rawInput?, content?, locations? }— the default card: a human-readabletitle, an optionalkind(read/edit/execute/… for icon/treatment, defaultother), an optionalrawInput(the salient input to show in a detail view — e.g. a background task id, NOT the whole args object), optionalcontent(extra UI content blocks), and optionallocations({ path, line? }[]— files this call reads/modifies, so a capable UI can follow along; the ACP bridge forwards them astool_call.locations).{ card: 'terminal', title, description?, cwd? }— a shell command: a capable UI renders a terminal card (thetitleis the command,descriptionrenders above it,cwdheads it); an incapable UI falls back to a generic execute card.{ card: 'diff', title, diffs, locations? }— a file create/modify: a capable UI renders an inline diff card fromdiffs({ path, oldText, newText }[];oldText: nullfor a new file). Used bywrite/edit.
presentResult(args, result): ToolResultView | undefined— the COMPLETED state, given the sameargsand the{ content, isError, meta? }result, one of:{ card: 'generic', title?, content? }— an optional replacementtitleand reformattedcontent.{ card: 'terminal', title?, output?, exitCode?, signal? }— a terminal run's capturedoutputand exit status. A capable UI shows an exit-status pill; an incapable UI gets a fenced```consolefallback the BRIDGE derives fromoutput(the tool does not encode the fences).{ card: 'diff', title?, diffs }— a completed file mutation as an inline diff.diffsisFileDiff[]— typically the applied hunks with surrounding context computed from the before/after content, or a whole-file diff (oldText: null) when there is no before-image (a file create). Used bywrite/edit; atool_call_update.contentreplaces the call's content, so a mutation tool returns this even when it duplicates the call-time snippet (else the result text would clobber the pending diff).
Returning undefined (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be pure and side-effect-free: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. result.meta is the tool's own optional presentation payload (opaque unknown, JSON-serializable), attached by execute (see below) and persisted on the tool/result event, so a presentResult reading it stays replay-deterministic (the same meta is read back from the log). With defineTool, args is the typed InferArgs<S> shape; the helper soft-validates before calling (a malformed/older logged arg shape yields undefined rather than throwing, since display must never crash a replay). The views are provider-neutral — the ACP bridge (dsh-acp) maps each card to ACP tool_call/tool_call_update wire fields (a diff card to a { type: 'diff' } content block, a terminal card to the _meta terminal convention), and relativizes a file card's title against the session cwd. See the render-intent-union RFC (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md) and the applied-hunk-diffs RFC (docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md); dsh-tool-bash (terminal) and dsh-tool-fs (diff/generic) are the reference implementations.
import { defineTool } from '@deepseek-ai/dsh-tools'
const bash = defineTool({
name: 'bash',
description: 'Run a shell command.',
parameters: {
command: { type: 'string', required: true, description: 'The command to run.' },
description: { type: 'string', required: true, description: 'One-line summary shown in the UI.' },
},
async execute(args) {
return [{ type: 'text', text: `ran: ${args.command}` }]
},
// A terminal card: the command is the title, the description renders above it.
presentCall: args => ({ card: 'terminal', title: args.command, description: args.description }),
// A terminal result: the raw output + exit; the bridge derives the fenced fallback.
presentResult: (_args, result) => {
const block = result.content.length === 1 ? result.content[0] : undefined
if (block === undefined || block.type !== 'text') return undefined
return { card: 'terminal', output: block.text }
},
})
What is NOT here (TODO)
- Tool shapes review — when real tools land (e.g. a concurrency-safety hint for parallel execution); phase 1 executes tool calls sequentially.
- Parallel execution — the loop currently iterates tool calls sequentially.