Files
deepseek-harness/docs/core-data-structures/tools.md
Chinesezjc 62275b6cd6 Merge remote-tracking branch 'origin/master' into feat/search-presenter
# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/tools.i18n.yaml
#	docs/core-data-structures/tools.md
#	docs/core-data-structures/tools.zh.md
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/tools/README.i18n.yaml
#	packages/core/tools/README.md
#	packages/core/tools/README.zh.md
#	packages/core/tools/src/index.ts
#	packages/core/tools/src/presentation.ts
#	packages/ui/tui/src/components/transcript.ts
2026-07-31 14:40:29 +08:00

28 KiB

Tools

English | 中文

The tool pipeline of dsh-tools. core.md introduces ToolDefinition as the one pipeline-authoring type promoted to the spine and ToolSchema as the model-facing wire shape. This page owns the full ToolDefinition, the typed schema DSL that builds it, the guarded execution shapes, and the UI-presentation vocabulary.

Source: packages/core/tools/src/index.ts · packages/core/tools/src/schema.ts · packages/core/tools/src/presentation.ts

ToolDefinition — a registered tool

A ToolSchema (the model-facing fields) plus a mandatory canonical output declaration, the execute function, host-only scheduler metadata, an optional final-content callback, and optional UI presenters. The registry holds these; the loop dispatches calls through them. The registry's schemas() builds the model-facing ToolSchema[] by an explicit allowlist — output/execute/finalizeContent/timeoutMs/isConcurrencySafe/presentCall/presentResult must never leak into a model request.

/** Tool-owned canonical output contract used after the body returns a JSON value. */
interface ToolOutputDefinition {
  /** Raw supported JSON Schema enforced against every successful canonical value. */
  readonly schema: JsonSchemaNode
  /** Pure projection from validated arguments and value to Native/model content. */
  render(args: unknown, value: JsonValue): ContentBlock[]
  /** Pure replayable presentation projection, computed only for surface calls. */
  presentationMeta?(args: unknown, value: JsonValue): JsonValue
}
/** A registered tool: its schema plus the execution function. */
interface ToolDefinition extends ToolSchema {
  /** Mandatory canonical output declaration. */
  readonly output: ToolOutputDefinition
  /**
   * Run one accepted call and return only its canonical lossless-JSON value.
   * Async work must observe or forward `exec.signal` and settle only after its
   * owned work reaches quiescence. The registry preserves caller cancellation
   * through around-dispatch signal replacement and does not abandon this
   * promise, but it cannot hard-kill same-process code.
   * @param args - losslessly snapshotted, frozen model arguments.
   * @param exec - execution identity, cancellation signal, and context deferral.
   * @returns the canonical value declared by `output.schema`.
   */
  execute(args: unknown, exec: ToolRunContext): Promise<unknown>
  /**
   * Synchronous last-mile transform for model-facing content. The registry
   * snapshots this callback when execution starts and invokes it exactly once
   * for every normalized outcome, including pipeline failures that bypass
   * `tools/post-execute`, immediately before lossless materialization.
   * Returning `undefined` preserves the content; every other result field
   * remains registry-owned. The callback must be total and must not throw.
   * @param exec - immutable execution identity and arguments.
   * @param result - complete normalized outcome before materialization.
   * @returns replacement content, or `undefined` to preserve it.
   */
  finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined
  /**
   * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
   * Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
   * is NEVER sent to the model — `schemas()` whitelists only name/description/
   * parameters. Declaring it asserts this tool forwards `exec.signal` to a
   * cooperative implementation that can reach quiescence when the signal aborts.
   */
  timeoutMs?: number
  /**
   * Pure synchronous classifier for overlap with sibling tool calls. Only
   * `true` opts in; omission, exceptions, non-`true` returns, and invalid
   * `defineTool` arguments are exclusive. This metadata is never model-visible.
   *
   * Opted-in executions must not mutate parent-owned state. Shared state must
   * tolerate concurrent dispatch; recorder races are permitted only when they
   * commute or fail closed. See the
   * [parallel-tool-call Agent Note](../../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)
   * for the full contract.
   * @param args - parsed arguments; `defineTool` validates before calling.
   * @returns Whether this call may join a parallel group.
   */
  isConcurrencySafe?(args: unknown): boolean
  /**
   * Optional: how to present the PENDING state of one call in a UI, derived from
   * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
   * its own input). Returns a {@link ToolCallView} (a `card`-tagged render intent),
   * or `undefined` (or omit the method) to fall back to a generic presentation
   * (title = tool name, raw args as input). Pure and side-effect-free: a UI may
   * call it during live streaming AND a session-log replay, so it must depend
   * only on `args`.
   */
  presentCall?(args: unknown): ToolCallView | undefined
  /**
   * Optional: how to present the COMPLETED state, given the same `args` and the
   * durable result projection (`content`, failure state, and optional `meta`). Returns a
   * {@link ToolResultView}, or `undefined` (or omit the method) to keep the
   * pending title and render the raw result content. Pure and side-effect-free
   * for the same replay reason.
   */
  presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined
}

execute receives args: unknown — a raw ToolDefinition validates its own input. First-party tools don't write that by hand; they use defineTool, which validates and narrows the arguments, infers the body return from output.schema, and types both output projectors. finalizeContent deliberately receives the immutable execution instead of typed arguments because invalid-input and outer pipeline failures reach it too; it may enforce a tool-owned content bound while preserving isError, canonical value, structured error identity, deferred contexts, and presentation metadata.

The unified JSON-value schema DSL

Plugin authors use one vocabulary for typed parameters and typed output values. ValueSchemaSpec supports string, number, integer, boolean, null, array, object, author-only json, and exact-one oneOf; scalar enum and const values must match their node type. An explicit object node always declares additionalProperties: true | false. Parameter definitions remain an implicit open object property map, with required: true attached to each required property.

Source: packages/core/tools/src/schema.ts

/** One author-facing schema for any lossless JSON value root. */
type ValueSchemaSpec =
  | StringValueSchemaSpec
  | NumberValueSchemaSpec
  | IntegerValueSchemaSpec
  | BooleanValueSchemaSpec
  | NullValueSchemaSpec
  | ArrayValueSchemaSpec
  | ObjectValueSchemaSpec
  | JsonValueSchemaSpec
  | OneOfValueSchemaSpec
/** One implicit parameter-root property, optionally required. */
type ParameterPropertySpec = ValueSchemaSpec & { required?: true }
/**
 * Tool parameter schema. The map itself is an implicit open object root;
 * requiredness remains a per-property `required: true` annotation.
 */
type ParameterSchemaSpec = {
  [key: string]: ParameterPropertySpec
  [key: symbol]: never
}

{ type: 'json' } infers JsonValue and compiles to an annotation-only unconstrained raw schema. Output roots can be objects, arrays, scalars, or null. InferValue<S> honors literal constraints and object openness through 16 container levels, then falls back to JsonValue instead of exhausting TypeScript's type-instantiation stack. InferArgs<P> turns per-property requiredness into required and optional string keys:

/**
 * Infer the TypeScript value accepted by an author-facing value schema. Exact
 * inference is bounded to 16 container levels, then falls back to `JsonValue`.
 */
type InferValue<S> = InferValueAt<S, []>
/** Infer the TypeScript argument object for an implicit parameter schema. */
type InferArgs<S> = InferProperties<S, []>

defineTool({ name, description, parameters, output, execute, … }) ties parameter inference to parameterSchemaSpecToJsonSchema() and validateArgs(), and ties execute/render/presentationMeta to InferValue<OutputSchema>. Schema records contain only own enumerable string keys, and schema arrays are dense intrinsic arrays, so inference, compilation, and validation observe the same declaration. Inference stays exact through 16 container levels and then widens to JsonValue; runtime validation keeps walking the complete schema. valueSchemaSpecToJsonSchema() compiles output declarations through the same enforced raw subset. A parameter mismatch throws ToolArgsError (INVALID_ARGS); an invalid body or post-policy value throws ToolOutputError (INVALID_TOOL_OUTPUT). Both use the normal tool-error path. Raw JSON Schema remains open by default; unsupported keywords reject instead of being accepted without enforcement.

Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input, requires output, validates its raw schema, and checks semantic requirements such as a positive finite timeoutMs; schemas() materializes the explicit model-facing projection at the model boundary so execution and presentation share one resolved definition without leaking callbacks onto the wire.

ToolRestriction — one scope's live global filter

ToolRestriction applies only to the live deployment-global tool layer. The registry compiles readonly names into private sets, intersects multiple restrictions, then overlays scope-local tools. A deny-only filter admits later unlisted globals, while an allow-list excludes them.

/**
 * Per-scope filter over global tools. Restrictions intersect and do not affect
 * scoped registrations or the reserved Code Mode transport.
 */
interface ToolRestriction {
  /** Global tool names that stay visible; everything else is removed. */
  readonly allow?: readonly string[]
  /** Global tool names removed from visibility. */
  readonly deny?: readonly string[]
}

Execution: extensible waterfalls plus monotonic policy

ctx.tools.execute() accepts a caller-owned ToolExecutionInput with a required readonly signal, materializes its parsed JSON arguments once into a pipeline-owned ToolExecution, and runs that call through tools/pre-execute (the reorderable allow/deny/ask waterfall) → registered monotonic guards → tools/execute (around-dispatch wrappers) → tools/post-execute (inspect/replace the result) → optional definition-owned finalizeContenttools/result (the immutable authoritative outcome). Only the tools/execute view may replace the required signal. The outcome is a ToolExecutionResult.

/** Opaque call identity that permits correlation without exposing mutable execution state. */
type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true }
/**
 * Caller-supplied description of one tool call. {@link ToolRegistry.execute}
 * adds the registry-owned token to form a pipeline {@link ToolExecution};
 * callers do not choose that token.
 */
interface ToolExecutionInput {
  readonly callId: CallId
  readonly name: string
  /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */
  readonly arguments: unknown
  /** The agent on whose behalf the call runs (set by the agent loop). */
  readonly agent?: Agent
  /**
   * Opaque token of the enclosing transport execution, when one exists. Code
   * Mode sets this on SDK sub-dispatches so commit-style observers can wait for
  * the outer `run_code` outcome without receiving its live mutable execution.
  */
  readonly parent?: ToolExecutionToken
  /** Required caller-owned cancellation for this invocation. */
  readonly signal: AbortSignal
}

A tool body receives the runtime extension. deferContext() is the composite-tool channel: it records nested-dispatch context without injecting inside the still-open outer call.

/**
 * Runtime context handed to a tool implementation after the registry has
 * accepted a {@link ToolExecution}. A composite tool uses
 * {@link deferContext} to ferry context produced by nested dispatches back to
 * the outer result; the loop appends it only after the outer `tool/result`.
 */
interface ToolRunContext extends ToolExecution {
  /**
   * Defer one nested-dispatch context until this tool's final result reaches
   * the agent loop. Contexts retain their individual source and metadata and
   * are emitted in call order.
   */
  deferContext(context: UserMessage): void
  /**
   * Mark a successful final result as terminal for the current agent turn.
   * The marker rides this execution's own result (`concludesTurn` exists only
   * on {@link ToolExecutionSuccess}); a composite that dispatches nested
   * calls forwards it from the nested result, exactly like
   * `additionalContexts`, so only an authoritative nested success can
   * conclude the enclosing run.
   */
  concludeTurn(): void
}

The agent loop asks the registry for each pending call's execution mode and uses it to form exclusive barriers and rolling-pool parallel runs:

/**
 * Scheduling mode for one pending call. `parallel` may overlap with siblings;
 * `exclusive` runs alone and forms an ordering barrier.
 */
type ToolExecutionMode =
  | { kind: 'parallel' }
  | { kind: 'exclusive' }

Code Mode's bridge additionally exposes each settled sub-dispatch to the tools/code-dispatch-log waterfall, which may reshape the durable event's copy of the content (the program's value and the model contract are untouched):

/**
 * One settled `run_code` sub-dispatch about to be logged, as seen by the
 * `tools/code-dispatch-log` waterfall: the parent execution (session owner,
 * outer call identity), the sub-call identity, and the outcome whose durable
 * copy a listener may reshape. `content` is the RENDERED result projection
 * (what a native `tool/result` would carry) — the program itself received
 * the structured `value` (or just the error message on failure); only the
 * `tool/code-dispatch` event's copy changes.
 */
interface CodeDispatchLog {
  /** The outer `run_code` execution. */
  readonly exec: ToolExecution
  /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */
  readonly agent?: Agent
  /** Deterministic sub-call id (`<parent>:code:<n>`). */
  readonly subCallId: CallId
  /** The dispatched sub-tool name. */
  readonly name: string
  /** Whether the sub-call settled as an error. */
  readonly isError: boolean
  /** The sub-call's complete model-facing content (the settle event's default payload). */
  readonly content: ContentBlock[]
}
/**
 * One pending tool call inside the registry pipeline. Parsed arguments cross
 * one lossless-JSON materialization boundary before policy and are deep-frozen;
 * call identity, the caller signal, and the registry-assigned {@link token} are
 * readonly. The registry freezes the complete object before `tools/result`
 * observers run.
 */
interface ToolExecution extends ToolExecutionInput {
  /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
  readonly token: ToolExecutionToken
}
/**
 * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper
 * may replace the signal for its delegated lifetime, but it cannot remove it.
 * The registry fuses every replacement with the captured caller signal.
 */
interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> {
  /** Cancellation signal visible to the next wrapper or tool body. */
  signal: AbortSignal
}

ToolExecutionToken is an opaque runtime Symbol used only for identity comparison. Before policy, execute() materializes and freezes arguments, rejects non-JSON input, and assigns the token. Identity fields, the required caller signal, and the optional parent token remain readonly. A ToolDispatchExecution wrapper may replace but not remove the signal; the registry re-fuses the caller signal before invoking the body. Final observers receive the frozen execution identity.

A ToolGuard is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: undefined preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it.

/**
 * A monotonic execution guard evaluated after every `tools/pre-execute`
 * listener and before the tool body. Returning a reason denies the call;
 * returning `undefined` leaves it unchanged. Because guards have no allow
 * result, listener ordering cannot turn a denial back into permission.
 * @param execution - the identity-protected call after extensible pre-execute policy completed.
 * @returns a final denial reason, or `undefined` to leave the call allowed.
 */
type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
/** Canonical failure detail; internal routing information remains optional. */
interface ToolFailure {
  /** Human-readable failure message without the Native `Error: ` envelope. */
  message: string
  /** Internal error class/code used by policy and durable diagnostics. */
  info?: ToolErrorInfo
}
/** Successful canonical tool execution, including its Native/model projection. */
interface ToolExecutionSuccess {
  readonly isError: false
  /** Execution-local canonical value; deliberately omitted from durable events. */
  readonly value: JsonValue
  readonly content: ContentBlock[]
  readonly error?: never
  readonly meta?: JsonValue
  readonly additionalContexts?: UserMessage[]
  /** The agent loop stops after committing this successful result batch. */
  readonly concludesTurn?: true
}
/** Failed canonical tool execution; failures never carry a successful value. */
interface ToolExecutionFailure {
  readonly isError: true
  readonly error: ToolFailure
  readonly value?: never
  readonly content: ContentBlock[]
  readonly meta?: JsonValue
  readonly additionalContexts?: UserMessage[]
  readonly concludesTurn?: never
}
/** The discriminated, execution-local outcome of one tool call. */
type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure

The result carries only the outcome. Call identity remains on the immutable ToolExecution that accompanies it through every hook and on the durable tool/call / tool/result session events, so wrappers cannot create a second, disagreeing identity. The canonical value is execution-local: the loop persists only content, error, and meta, while tool/code-dispatch stores the sub-call's rendered content and isError verbatim. Replay reproduces presentation but cannot reconstruct canonical intermediate values.

On success the registry snapshots and validates the body value, freezes it, and invokes the pure renderer plus the optional direct-surface metadata projector. It separately materializes the durable presentation fields immediately before tools/result; an invalid value, renderer/projector failure, or non-JSON presentation becomes a JSON-safe isError. The final live observer therefore sees the exact execution-local value beside fields safe for the later durable append.

Before final content, the registry materializes the candidate result; a failure in content, structured error, additional context, or presentation metadata becomes a JSON-safe isError result that still reaches finalizeContent. The registry invokes that callback exactly once, then materializes and freezes the accepted result immediately before tools/result, so the observed live outcome is safe for the later durable tool/result append.

Each interception waterfall returns a typed Decision (the idiom shared with the agent/* seams). tools/pre-execute listeners receive (exec, next) and return a PreToolDecision; tools/execute wrappers return a ToolExecutionResult; tools/post-execute listeners receive (exec, result, next) and return a PostToolDecision:

/**
 * Pre-dispatch decision. `allow` runs the call; `deny` materializes an error;
 * `ask` runs only after an approval service returns `allowed-once` and otherwise
 * denies. Input rewriting is excluded because arguments are already logged and
 * presented.
 */
type PreToolDecision =
  | { kind: 'allow' }
  | { kind: 'deny'; reason: string }
  | { kind: 'ask'; reason?: string }
/**
 * Post-dispatch decision: accept, replace one projection, attach context for the
 * next request, or block by turning corrective feedback into an error result.
 */
type PostToolDecision =
  | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] }
  | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] }
  | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] }

Call next() for the default or return a decision to short-circuit. Pre-policy may deny or ask; only allowed-once proceeds, while a non-grant, missing approval channel or service, or agent-less request becomes a denial. Guards may still impose a final denial. Arguments cannot be rewritten because history, audit, UI, and execution must agree.

Post-policy may replace either content or value, never both. Content replacement preserves the canonical value and existing metadata; value replacement is revalidated and recomputes content/metadata; a block removes the value and becomes an isError containing corrective feedback. Content replacement is presentation policy, not confidentiality policy: a listener that must hide the programmatic value blocks or replaces it. tools/result receives the frozen execution and result after normalization; observers cannot transform them, and observer failures are contained. Unknown and throwing tools both become structured errors (ToolNotFoundError maps to UNKNOWN_TOOL), so the call fails without ending the turn.

The enforced raw JSON Schema subset

Raw schemas from subagents, workflows, MCP, and dynamic registrations use the wire-level counterpart of the author DSL. assertSupportedJsonSchema() accepts any JSON root, validateJsonSchemaValue() enforces it, and JsonSchemaError reports every unsupported or malformed schema path. The empty annotation-only node means unconstrained lossless JSON. oneOf requires at least two branches and a value must match exactly one. Consumers that still require an object root call assertObjectJsonSchema() and carry ObjectJsonSchema; this is how subagent/workflow caller-defined structured output remains object-rooted without restricting the shared vocabulary.

/** Scalar JSON values supported by `enum` and `const`. */
type JsonSchemaScalar = string | number | boolean | null
/** Single-type keywords accepted by the enforced subset. */
type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
/**
 * One raw JSON Schema node in the enforced subset. The optional fields express
 * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid
 * combinations before a caller treats the node as trusted.
 */
interface JsonSchemaNode {
  /** Omit with no constraints for any JSON value, or use `oneOf`. */
  type?: JsonSchemaType
  /** Exactly one branch must validate; at least two branches are required. */
  oneOf?: JsonSchemaNode[]
  /** Nested property schemas (`type: 'object'` only). */
  properties?: Record<string, JsonSchemaNode>
  /** Required property names; each must appear in `properties`. */
  required?: string[]
  /** `false` rejects undeclared keys; absent/`true` follows JSON Schema's open default. */
  additionalProperties?: boolean
  /** Item schema (`type: 'array'` only); absent accepts any JSON item. */
  items?: JsonSchemaNode
  /** Allowed values for a scalar node. */
  enum?: JsonSchemaScalar[]
  /** The single allowed value for a scalar node. */
  const?: JsonSchemaScalar
  /** Annotation, ignored for validation. */
  description?: string
  /** Annotation, ignored for validation. */
  title?: string
  /** Annotation, ignored for validation but required to be lossless JSON. */
  default?: JsonValue
  /** Annotation, ignored for validation but required to be lossless JSON. */
  examples?: JsonValue
}
/** A consumer-constrained object-rooted schema. */
type ObjectJsonSchema = JsonSchemaNode & { type: 'object' }

Tool-presentation UI vocabulary

How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. presentCall/presentResult return a card-tagged render intent — a discriminated union a UI bridge switches on:

  • ToolCallView (pending): { card: 'generic', title, kind?, rawInput?, content?, locations? } (the default card; locations is { path, line? }[] files the call reads/modifies, for editor follow-along), { card: 'terminal', title, description?, cwd? } (a shell command → a terminal card), or { card: 'diff', title, diffs, locations? } (a file create/modify → an inline diff card; diffs is { path, oldText, newText }[], oldText: null for a new file).
  • ToolResultView (completed): { card: 'generic', title?, content? }, { card: 'terminal', title?, output?, exitCode?, signal? } (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ```console fallback), { card: 'diff', title?, diffs } (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image), { card: 'search', shape, title?, truncated, total, … } (a completed discovery search → grouped-by-file matches for shape: 'matches' (grep) or a flat path list for shape: 'paths' (glob); truncated/total report whether the inline result was capped so a UI never presents a partial result as complete; the view carries no result text — a UI without a search card falls back to the raw result content), { card: 'read', title?, path, offset, lines, totalLines, lang?, content? } (a completed file read → a line-numbered, optionally syntax-highlighted code view; offset is the 1-based first line the window requested, kept even when lines is empty; lang is a language hint from the extension, and content is the envelope-stripped text a UI without read support falls back to), or { card: 'web', kind: 'search' | 'fetch', title?, … } (a completed web retrieval; kind: 'search' carries the structured sources/answer?/truncated, kind: 'fetch' carries url/statusCode/truncated, and a UI without the web capability falls back to the raw result content — the body is not duplicated into the view). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet; a search and a web retrieval have no card call-time analogue (their pending state stays a generic card, since the structured result exists only after execute).

ToolCallKind ('read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other') picks an icon on a generic card. FileLocation ({ path, line? }), FileDiff ({ path, oldText, newText }), and ReadFileLine ({ number, text }, one 1-based numbered line of a read window) are the shared file-card vocabulary. The design is pinned in the render-intent-union Agent Note; the TUI and host/client runtime project this neutral vocabulary into their own views.

The full presentation field docs live in packages/core/tools/src/presentation.ts. The bash schema and executor are on bash.md; generic background controls are on tasks.md.