DEV Community

Cover image for DeepSeek Harness Series (03): The Tool System — Giving Your Agent Hands
WonderLab
WonderLab

Posted on

DeepSeek Harness Series (03): The Tool System — Giving Your Agent Hands

Why Tools Are the Core of Any Agent

An LLM by itself only outputs text. Agents that book flights, write code, and query databases do so through tools — the LLM decides which tool to call and with what arguments, and the tool actually executes.

Without a tool system, an Agent is just a chat interface with extra steps.

The dsh tool system is not a simple function registry. It handles every engineering problem along the entire chain from "the model decides which function to call" to "the result safely returns to the model":

  • The model needs a JSON Schema to understand tool parameter formats — dsh generates one from your TypeScript types automatically
  • Tool execution can be dangerous — dsh provides a three-layer pipeline for interception
  • Some tool operations are irreversible — dsh has a built-in approval mechanism that pauses for user confirmation before dangerous actions
  • Different Agents need different tool subsets — dsh supports fine-grained scope isolation

This article walks through each of these mechanisms.


What Is ctx.tools

ctx.tools is a Cordis Service of type ToolRuntime. It's the entry point for the tool system, responsible for:

  • Registration: manages the global tool table, with support for scope-level tool shadowing
  • Schema projection: projects internal definitions into ToolSchema[] the model can understand
  • Execution: drives the three-phase pipeline, returning a typed ToolExecutionResult

Using it requires declaring the dependency in your plugin:

export const inject = ['tools']

export function apply(ctx: Context): void {
  // Register a tool
  ctx.tools.register(myTool)

  // Get the schemas visible to the current scope (for the model)
  const schemas = ctx.tools.schemas()
}
Enter fullscreen mode Exit fullscreen mode

When a plugin is unloaded, all tools registered via ctx.tools.register are automatically deregistered — this is the Cordis Effect mechanism in action (covered in Part 02).


defineTool: Type-Safe Tool Definitions

You can implement the ToolDefinition interface directly, but dsh provides the defineTool helper that does compile-time type inference and runtime validation of parameters and return values.

A minimal example:

import { defineTool } from '@deepseek-ai/dsh-tools'

const greetTool = defineTool({
  // Tool name (the model uses this to call it)
  name: 'greet_user',
  // Tool description (the model uses this to decide when to use it)
  description: 'Send a greeting to a user by name.',

  // Parameter schema: uses dsh's DSL, not raw JSON Schema
  parameters: {
    name: {
      type: 'string',
      required: true,             // required: true = mandatory
      description: 'The user name to greet.',
    },
    formal: {
      type: 'boolean',
      description: 'Use formal greeting if true.',
      // No required: true = optional
    },
  },

  // Output definition: declare the return value schema and how to render it for the model
  output: {
    schema: { type: 'string' },
    render: (_args, value) => [{ type: 'text', text: value as string }],
  },

  // Execution function: args type is inferred automatically from parameters
  async execute(args, exec) {
    // TypeScript knows args.name: string, args.formal?: boolean
    const prefix = args.formal ? 'Good day' : 'Hello'
    return `${prefix}, ${args.name}!`
  },
})
Enter fullscreen mode Exit fullscreen mode

defineTool does three things:

  1. Infers the TypeScript type of args from parameters — no manual type annotations needed
  2. Compiles the parameter schema DSL into JSON Schema — sent to the model automatically
  3. Runtime validation: mismatched parameters throw ToolArgsError; invalid return values throw ToolOutputError

The Schema DSL Type System

dsh's schema DSL (ValueSchemaSpec) supports these node types:

// String
{ type: 'string', enum?: string[], const?: string }

// Numbers
{ type: 'number' }
{ type: 'integer' }

// Boolean
{ type: 'boolean' }

// Array
{ type: 'array', items?: ValueSchemaSpec }

// Object (additionalProperties is mandatory)
{ type: 'object', properties?: ParameterSchemaSpec, additionalProperties: boolean }

// Unconstrained JSON (any JSON value)
{ type: 'json' }

// Exactly one branch must match
{ oneOf: [spec1, spec2, ...] }
Enter fullscreen mode Exit fullscreen mode

Why not use raw JSON Schema directly?
Raw JSON Schema is too broad — many keywords dsh doesn't enforce. With the dsh DSL, you know at compile time that every constraint you declare is one dsh will actually enforce. No "wrote it but it doesn't do anything" dead zones.

Type inference goes 16 container levels deep, then falls back to JsonValue — this prevents TypeScript's type instantiation stack from overflowing.


The Three-Phase Execution Pipeline

This is the most important part of the tool system. When the Agent Loop receives a tool call request from the model, it walks this pipeline:

Model requests "call shell_run(cmd='ls -la')"
                │
                ▼
    ┌─────────────────────┐
    │  tools/pre-execute  │  waterfall: allow / deny / ask
    └─────────────────────┘
                │ allow (approved)
                ▼
    ┌─────────────────────┐
    │       Guards        │  monotonic deny-only checks
    └─────────────────────┘
                │ passes
                ▼
    ┌─────────────────────┐
    │   tools/execute     │  waterfall: around-dispatch (timeout / retry / metrics)
    └─────────────────────┘
                │
                ▼ tool execute() runs
                │
    ┌─────────────────────┐
    │  tools/post-execute │  waterfall: accept / replace / block
    └─────────────────────┘
                │
                ▼
    ┌─────────────────────┐
    │   finalizeContent   │  tool-owned last-mile content transform (optional)
    └─────────────────────┘
                │
                ▼
    ┌─────────────────────┐
    │    tools/result     │  emit: read-only observation for logging / metrics
    └─────────────────────┘
                │
                ▼
      Returns ToolExecutionResult to the Agent Loop
Enter fullscreen mode Exit fullscreen mode

Three core phases:

Phase 1: tools/pre-execute (Before Execution)

This is where you decide whether a tool call is allowed to run. Listeners return one of three decisions:

type PreToolDecision =
  | { kind: 'allow' }                // Allow execution
  | { kind: 'deny'; reason: string } // Deny, with a reason passed to the model
  | { kind: 'ask'; reason?: string } // Pause and request user approval
Enter fullscreen mode Exit fullscreen mode

ask is the core of dsh's permission system. When a listener returns ask, dsh calls the ctx.get('approval') service and waits for user confirmation before continuing. If no approval service is present, ask automatically becomes deny.

A common use case — adding permission interception to the shell command tool:

ctx.on('tools/pre-execute', async (exec, next) => {
  // Only intercept shell_run
  if (exec.name !== 'shell_run') return next()

  const cmd = (exec.arguments as { command: string }).command

  // Destructive commands require user confirmation
  if (cmd.includes('rm') || cmd.includes('sudo')) {
    return { kind: 'ask', reason: `Will run: ${cmd}` }
  }

  return next()
})
Enter fullscreen mode Exit fullscreen mode

Note: tools/pre-execute is a waterfall — just like covered in Part 02, listeners must either call next() or return a decision. Not calling next() silently cuts off all downstream listeners.

Phase 2: tools/execute (Around Dispatch)

This is where the tool body actually runs. Listeners wrap around the tool execution, good for:

// Adding timeout control
ctx.on('tools/execute', async (exec, next) => {
  const timer = new Promise<ToolExecutionResult>((_, reject) =>
    setTimeout(() => reject(new Error('Tool timeout')), 30_000)
  )
  return Promise.race([next(), timer])
})

// Adding execution time metrics
ctx.on('tools/execute', async (exec, next) => {
  const start = performance.now()
  const result = await next()
  console.log(`${exec.name} took ${performance.now() - start}ms`)
  return result
})
Enter fullscreen mode Exit fullscreen mode

Listeners may only modify exec.signal (the cancellation signal), not the tool arguments — arguments are already written to the session log at this point; changing them would create a mismatch between what's logged and what actually ran.

Phase 3: tools/post-execute (After Execution)

After the tool runs, this phase decides what result the model gets:

type PostToolDecision =
  | { kind: 'accept' }                              // Accept the original result
  | { kind: 'accept'; content: ContentBlock[] }     // Replace displayed content (keeps canonical value)
  | { kind: 'accept'; value: JsonValue }            // Replace canonical value (re-renders content)
  | { kind: 'block'; feedback: ContentBlock[] }     // Convert to an error result with corrective feedback
Enter fullscreen mode Exit fullscreen mode

block is useful for output validation — if a tool returns unexpected content, intercept it and let the model retry:

ctx.on('tools/post-execute', async (exec, result, next) => {
  if (exec.name === 'read_file' && !result.isError) {
    const content = result.value as string
    if (content.length > 50_000) {
      // Truncate and tell the model
      return {
        kind: 'accept',
        content: [{ type: 'text', text: `[Truncated] ${content.slice(0, 50_000)}...` }],
      }
    }
  }
  return next()
})
Enter fullscreen mode Exit fullscreen mode

Guards: Monotonic Denial

Guards registered with ctx.tools.guard() run after tools/pre-execute and before the tool body. They can only deny — even if the waterfall allowed a call, a guard can still veto it at the last moment:

// Guards return a string (denial reason) or undefined (no-op)
ctx.tools.guard((exec) => {
  // Block prompt injection patterns in any tool call
  const args = JSON.stringify(exec.arguments)
  if (args.includes('ignore previous instructions')) {
    return 'Potential prompt injection detected'
  }
  // Return undefined = no-op
})
Enter fullscreen mode Exit fullscreen mode

The monotonicity guarantee: once a guard denies a call, no subsequent guard can un-deny it. This prevents a malicious plugin from registering a guard that undoes security policy set by another plugin.


Scope Isolation (ToolRestriction)

Different Agent tasks may need different tool subsets. A code-review agent should only need read-only file tools and shouldn't be able to execute shell commands.

ctx.tools.restrict() lets you filter tools at the agent scope level:

// Allow only file-reading tools
const disposer = ctx.tools.restrict({
  allow: ['read_file', 'list_files', 'search_files'],
})

// Or: block dangerous tools
const disposer = ctx.tools.restrict({
  deny: ['shell_run', 'write_file', 'delete_file'],
})

// Clean up when done
disposer()
Enter fullscreen mode Exit fullscreen mode

When multiple restrict calls are active, they intersect: a tool is only visible to the model if all active rules allow it.

Scope-local tool registrations are not affected by restrictions — a subagent's own private tools are always visible to itself.


Parallel Tool Calls

Models sometimes request multiple tool calls in a single response. dsh supports parallel execution, but tools must explicitly opt in as concurrency-safe:

const safeReadTool = defineTool({
  name: 'read_file',
  // ...
  // Read-only; safe to run alongside other calls
  isConcurrencySafe: (args) => true,
  execute: async (args, exec) => {
    return fs.readFile(args.path, 'utf-8')
  },
})
Enter fullscreen mode Exit fullscreen mode

Tools without isConcurrencySafe or that return anything other than true default to exclusive mode — one at a time, each waiting for the previous to complete.

This is a defensive design: slightly slower serial execution beats state corruption. Opting into parallelism is a contract the tool author makes, not something the framework infers for you.


Walkthrough: A Weather Query Tool from Scratch

Let's put everything together and write a complete custom tool:

// packages/my-tools/src/weather.ts
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'tool-weather'
export const inject = ['tools']

export function apply(ctx: Context): void {
  ctx.tools.register(defineTool({
    name: 'get_weather',
    description: 'Get current weather for a city. Returns temperature and conditions.',

    parameters: {
      city: {
        type: 'string',
        required: true,
        description: 'City name, e.g. "Beijing" or "Shanghai".',
      },
      unit: {
        type: 'string',
        enum: ['celsius', 'fahrenheit'],
        description: 'Temperature unit. Defaults to celsius.',
      },
    },

    output: {
      schema: {
        type: 'object',
        additionalProperties: false,
        properties: {
          city: { type: 'string' },
          temperature: { type: 'number' },
          unit: { type: 'string' },
          condition: { type: 'string' },
        },
      },
      // render converts the structured value to the text the model sees
      render: (_args, value) => {
        const v = value as { city: string; temperature: number; unit: string; condition: string }
        return [{
          type: 'text',
          text: `Weather in ${v.city}: ${v.temperature}°${v.unit === 'celsius' ? 'C' : 'F'}, ${v.condition}`,
        }]
      },
    },

    // Read-only operation; safe for parallel execution
    isConcurrencySafe: () => true,

    async execute(args, exec) {
      const unit = args.unit ?? 'celsius'

      // In production: call a real weather API here
      // exec.signal lets us respond to cancellation
      const response = await fetch(
        `https://weather-api.example.com/current?city=${encodeURIComponent(args.city)}&unit=${unit}`,
        { signal: exec.signal },
      )

      if (!response.ok) {
        throw new Error(`Weather API returned ${response.status}`)
      }

      const data = await response.json()
      return {
        city: args.city,
        temperature: data.temperature,
        unit,
        condition: data.condition,
      }
    },

    // Custom UI presentation: shown while the call is in progress
    presentCall: (args) => ({
      card: 'generic',
      title: `Checking weather in ${(args as { city: string }).city}`,
      kind: 'fetch',
    }),

    // Custom UI presentation: shown after the call completes
    presentResult: (args, result) => ({
      card: 'generic',
      title: result.isError
        ? `Weather check failed`
        : `Weather in ${(args as { city: string }).city}`,
    }),
  }))
}
Enter fullscreen mode Exit fullscreen mode

After adding it to your dsh config, that's everything:

  1. ctx.tools.schemas() automatically includes get_weather's JSON Schema, and the model can see it
  2. All model calls go through the full three-phase pipeline automatically
  3. Return values are validated, rendered, and written to the session log

Design Principles Summary

Looking back at the dsh tool system's overall design philosophy:

Design Decision Reason
Arguments are frozen after pre-execute Logs, audit trails, and UI must stay in sync with execution
Guards can only deny, never allow Prevents later-registered plugins from undoing security policy
output.render is a pure function Session replay needs to reproduce the presentation faithfully
isConcurrencySafe defaults to exclusive Defensive design: correctness over throughput
Tools are a Cordis Service Registration/deregistration lifecycle follows the plugin; HMR doesn't leak tools
Tool schemas never expose execute/present callbacks The model only sees name/description/parameters

One principle runs through the entire dsh tool system: at every stage of a tool call, there is exactly one clearly-scoped extension point, with explicit responsibility boundaries — you can't cross them.

That's why you can safely hang a permission interceptor in production without worrying that it'll conflict with internal logic you can't see.


What's Next in the Series

The tool system answers "what can an Agent do." The next article on the Agent Loop covers "how does one conversation turn actually run" — the complete flow from model output through tool calls to loop termination, and all the places you can insert control logic.


Check out PrimeSkills — a curated marketplace of AI agents and skills validated in real-world, enterprise-grade workflows. Not demos — things that actually work in production.

Find more on my Homepage

Top comments (0)