Skip to content

Tool presentation is the whole path from "the model asked to call a tool" to "the result is back in model history": schema presentation, dispatch, policy, guarded execution, and event recording. It spans the scoped tool registry (packages/core/tools/src/index.ts, exposed as ctx.tools), the loop's scheduler (packages/core/agent-loop/src/tool-calls.ts), and the agent-plane presentation selector (packages/core/agent-tool-presentation/src/index.ts).

PackageOwns
@deepseek-ai/dsh-toolsctx.tools — the scoped tool registry, defineTool, guards, presentAs, restrict, execution pipeline
@deepseek-ai/dsh-agent-loopThe scheduler that plans/dispatches one step's tool calls in model order
@deepseek-ai/dsh-agent-tool-presentationThe "which form of the tools the model sees" selector (native/code/both)
@deepseek-ai/dsh-llmToolSchema, ToolCallBlock, createToolResultMessage

The model-facing schema

Every registered tool is a ToolDefinition whose model-facing surface is the ToolSchema declared in packages/llm/llm/src/types.ts:

ts
export interface ToolSchema {
  name: string
  description: string
  /** JSON Schema object for the arguments. */
  parameters: Record<string, unknown>
}

Operationally ToolDefinition adds an execute(args, exec) body plus optional final-content and UI callbacks — but the registry's schemas(scope) method projects only name, description, and parameters to the model (whitelisting), detaching parameter objects via structuredClone so no runtime state leaks to the prompt. Tools are authored through the defineTool DSL (packages/core/tools/src/schema.ts), which compiles a typed ValueSchemaSpec/ParameterSchemaSpec into JSON Schema plus generated types.

Presenting scoped vs global tools

Tool schemas reach the prompt through systemPrompt.tools(provider). The tools registry contributes only the scope-visible schemas — global tools filtered by scope restrictions plus scope-local registrations — after shadowing (a scoped tool hides its same-named global twin for that scope). How those schemas render is chosen by presentation mode:

ModeWhat the model sees
nativeEvery visible ToolSchema
codeOnly the reserved run_code transport plus a generated SDK (Code Mode)
bothBoth the run_code transport and native schemas

ctx.tools.presentAs(mode) declares the mode for the mounting scope (an agent preset's standing mount), so one row per composition, not one per session. The agent-tool-presentation plugin reads a mode config and either calls presentAs('native') directly or waits for a codeRuntime service before declaring a code mode.

The execution pipeline

The registry's pipeline runs the tools/pre-execute waterfall (hooks, permission, sandbox), then registered monotonic guards, then the tools/execute and tools/post-execute waterfalls, with definition-owned finalizeContent and the tools/result notification last. Here is the flow in ASCII:

assistant message → tool-call block
        │  plan one ToolExecutionInput per block

[log  tool/call]  [UI  presentCall(args)]


tools/pre-execute waterfall   (hooks, permission, sandbox) → allow / deny / ask
        ▼  allow
registered monotonic guards    (deny or abstain; identity protected)
        ▼  allow
tools/execute waterfall       (timeout, retry, metrics wrap the body)

registered tool execute() body
        │  tool-owned events: todo/write, fs/observed, hook/invoked, …

tools/post-execute waterfall  (accept / block / replace / add context)

outer normalization           (pipeline/result snapshot throws → isError)

ToolDefinition.finalizeContent (last content-only invariant)

tools/result  (synchronous, frozen authoritative outcome)

[log  tool/result → cites its tool/call seq]  [UI  presentResult(args, result)]

tool batch settled → additionalContexts FIFO → injected next-step user/message

ctx.approval resolves asked calls before the monotonic guards; owner policy that must not be reordered stays a registered guard. Around-dispatch concerns (timeouts) wrap tools/execute. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before finalizeContent enforces its synchronous content-only invariant, so tools/result always observes an immutable, lossless-JSON outcome.

The loop's scheduler and event mapping

executeToolCalls (in packages/core/agent-loop/src/tool-calls.ts) schedules one assistant step's tool calls by live concurrency mode: exclusive calls form barriers, parallel calls use a bounded rolling pool sized by ctx.agentLoop.config.maxParallelToolCalls. Events are committed in model order even though dispatch overlaps. The durable mapping to the session log:

ts
function appendToolCall(session, turn, step, block): number {
  const event = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments })
  return event.seq
}
ts
function appendToolResult(session, turn, step, block, result, callSeq): void {
  const message = createToolResultMessage({ callId: block.id, content: result.content, isError: result.isError })
  session.append('tool/result', {
    turn, step, message,
    ...result.error?.info ? { error: result.error.info } : {},
    ...result.meta !== undefined ? { meta: result.meta } : {},
  }, { surfaceOp: 'append', sourceEventSeqs: [callSeq] })
}

The callId from the model's tool-call block pairs the tool/call and its tool/result; the result cites the call's seq in sourceEventSeqs, preserving adjacency for replay. A result may carry concludesTurn (end the turn at its step) and additionalContexts (spliced into the next-step inbox as user/message by the scheduler's acceptContext). On cancellation, skipped model calls receive a synthetic error result (TOOL_ABORTED_BEFORE_DISPATCH) so replay stays valid.

The scheduler surface and result contract

Executing the pipeline in two overlapping phases (ordered policy, then concurrent dispatch) is the tool registry's internal scheduler, reached by the loop through the symbol TOOL_RUNTIME_SCHEDULER. Its three operations sequence the stages:

ts
interface ToolRuntimeScheduler {
  /** Materialize input, run the ordered pre-execute/guard gate, and decide what stage follows. */
  prepare(exec: ToolExecutionInput): Promise<ScheduledToolPreparation>
  /** Run only the around-dispatch/body stage. */
  dispatch(exec: ToolRunContext): Promise<ScheduledToolDispatch>
  /** Run post-execute and definition-owned content finalization, then materialize and notify. */
  finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult>
  /** Run definition-owned content finalization, then materialize and notify without post-execute. */
  finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult
}

prepare returns a stage decision: dispatch still needs the body, post-result still goes through tools/post-execute, and final-result already matches failure semantics and bypasses it. The commit in tool-calls.ts calls finalize when the result is a post-result and finish for a final-result — that is exactly the two options on the commitReady path. The resulting ToolExecutionResult is a union of success (ToolExecutionSuccess: content + isError:false) and failure (ToolExecutionFailure) and may carry additionalContexts, meta, and concludesTurn.

Concurrency is decided per call via ctx.tools.executionMode(exec). Exclusive calls form a barrier: the scheduler re-reads later modes before starting, so a registry change can promote a mid-batch call to exclusive after the current pool drains. Parallel calls use a bounded rolling pool (maxParallelToolCalls) whose dispatch overlaps while policy, results, and result context stay model-ordered. Both modes honor signal abort: started calls drain and commit, unstarted calls receive synthetic results.

Restriction filtering

tools.restrict(filter) filters the global tool set for one scope by intersection. A filtered-away global tool is absent from the prompt AND refuses execution — indistinguishably from a tool that never existed. Scope-local registrations are merged after the filter. restrict({}) is rejected as a no-op, and the reserved run_code transport name cannot be restricted directly (restrict end-capability tools instead).

Real events

The pipeline routes through these scope-filtered events:

EventDispatchRole
tools/pre-executewaterfallhooks, permission, sandbox allow/deny/ask
tools/executewaterfallaround-dispatch timeout / retry / metrics
tools/post-executewaterfallaccept / block / replace / add context
tools/resultemitsynchronous frozen authoritative outcome
session tool/call / tool/resultboard-session logdurable call/result pairs

Further reading

  • The agent loop — the step() loop that calls executeToolCalls.
  • Session management — the tool/call / tool/result events and their surface semantics.
  • Scope system — scoped tools, shadowing, and restriction-by-intersection.
  • System prompt assembly — how visible tool schemas reach the prompt and toolOrder.
  • Repo source: packages/core/tools/src/index.ts, packages/core/agent-loop/src/tool-calls.ts, packages/core/agent-tool-presentation/src/index.ts.
  • Official scaffold: docs/tool-execution-pipeline.md, docs/tool-catalog.md.