Files
deepseek-harness/packages/core/tools
Yichen Jiang ee111fd978 Merge remote-tracking branch 'origin/master' into codex/project-instruction-files
# Conflicts:
#	AGENTS.md
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md
#	docs/rfc/implemented/feature/2026-06-15-code-mode.md
#	docs/rfc/implemented/feature/2026-06-30-hook-bridges.md
#	docs/rfc/implemented/feature/2026-06-30-interception-seams.md
#	docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md
#	docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md
#	examples/AGENTS.md
#	examples/acp-agent/cordis.yml
#	examples/acp-agent/tests/acp.snapshot.ts
#	examples/echo-agent/cordis.yml
#	examples/sandbox-acp-agent/cordis.yml
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-core/README.md
#	packages/core/agent-core/src/index.ts
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent-loop/tests/interception.spec.ts
#	packages/core/agent/src/types.ts
#	packages/core/tools/README.md
#	packages/core/tools/src/code-mode.ts
#	packages/core/tools/src/index.ts
#	packages/fs/fs-local/src/index.ts
#	packages/fs/fs/README.md
#	packages/fs/fs/src/index.ts
#	packages/guard/repeat-tool-guard/README.md
#	packages/guard/repeat-tool-guard/src/index.ts
#	packages/hooks/hooks-claude/src/index.ts
#	packages/hooks/hooks-codex/src/index.ts
#	packages/ui/acp-agent/src/index.ts
2026-07-14 19:50:25 +08:00
..

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): () => void Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's agent.ctx registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved run_code transport 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 | undefined Resolution 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 the execute functions). 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): () => void Register a monotonic synchronous execution guard after tools/pre-execute: returning a reason denies the call, while undefined leaves it unchanged. A plain-context guard applies globally; an agent.ctx guard 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 only signal.

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

  • ToolDefinitionToolSchema + execute(args, exec), optional presentation callbacks, and cooperative timeoutMs.
  • ToolExecutionInput — the caller-supplied call description: { callId, name, arguments, agent?, parent?, signal? }; callers may pass an enclosing execution's opaque token as parent but never choose the new execution's own token.
  • ToolExecutionToken — a fresh branded Symbol assigned 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 operational signal, which an around wrapper may add, replace, remove, and restore. A nested call's parent is a ToolExecutionToken, not an execution object.
  • ToolRunContext — the execution passed to a tool body, extending ToolExecution with deferContext(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: { callId, content, isError, error?, additionalContexts?, meta? }. The registry materializes and freezes the complete post-policy value before final observation. On failure with a HarnessError, error: { name, code } carries the structured failure class alongside the model-facing text. additionalContexts preserves each deferred or post-execute HookContext with its own source, envelope, and durable JSON metadata; the loop buffers the array and appends each entry as a context/message after all tool/results in the step.
  • PreToolDecision{kind:'allow'} | {kind:'deny', reason} | {kind:'ask', reason?}. Input rewrite is deliberately not offered; ask is serviced by ctx.approval when 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 an isError whose 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-neutral card-tagged render intents a tool returns from presentCall / presentResult to 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-execute is the reorderable allow/deny/ask gate; ctx.tools.guard() adds monotonic owner policy after it.
  • tools/execute wraps normalized core dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal.
  • tools/post-execute may replace content, block with feedback, or attach ordered contexts; tools/result observes 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, a declare 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 the defineTool subset degrade to unknown, 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 (even Promise.all executes underlying calls one at a time in submission order), given the outer execution's opaque token as parent, 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 a tool/code-dispatch session 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 final run_code result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call additionalContexts entry is deferred through the outer ToolRunContext in dispatch order; the loop appends those contexts only after the parent run_code result, 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-dispatch lands inside the open turn. A failed run throws CodeRunFailedError (code: 'CODE_RUN_FAILED', message = the failure kind + captured logs), which the pipeline converts to a structured isError the 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 sequentiallyToolDefinition carries no concurrency-safety metadata; adding it (and parallel execution in the loop) waits on the deferred tool-shapes review (TODO(review)).
  • tools/pre-execute deliberately cannot rewrite exec.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-only enum; validateArgs tolerates extra keys and never applies default (XXX(unused-default) flags removing that field); raw-registered JSON-Schema tools validate their own input.
  • timeoutMs on a definition is declarative only — the registry never enforces deadlines; enforcement requires the @deepseek-ai/dsh-timeout-policy wrapper.
  • Code Mode is TypeScript-only and the presentation mode is service-widemode: code/both rejects prompt assembly unless ctx.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_code state 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.