Skip to content

Tools & policy

A tool is anything the agent can call to act on the world. DeepAgents represents tools as AgentTool conformers - a simple Swift protocol you can implement to add any capability, from file I/O to network requests to native macOS APIs.


AgentTool protocol

public protocol AgentTool: Sendable {
    var name: String { get }
    var description: String { get }
    var parameters: [ToolParameter] { get }

    func execute(
        _ arguments: [String: AgentJSON],
        _ context: ToolContext
    ) async throws -> ToolOutput

    func toolSchema() -> ToolSchema

    var isParallelSafe: Bool { get }
}
Member Purpose
name Stable identifier; used for dispatch, disabling, and approval rules. Normalized to [a-z0-9_] - see below
description Natural-language description sent to the model in its tool schema
parameters Typed parameter list; drives JSON schema generation and model guidance
execute(_:_:) Called when the model invokes this tool; receives parsed arguments and context
toolSchema() Returns the JSON schema representation sent to the backend; derived from parameters by default
isParallelSafe Whether calls to this tool may run concurrently with the round's other parallel-safe calls; defaults to false

Names are normalized

A tool's name is put through ToolName.normalized in both directions: it is what the model is shown, and every name the model sends back goes through the same rule before it is matched, so a call finds its tool however the model spelled it. ASCII letters, digits and _ survive (lowercased); everything else becomes _. Pick a name in that alphabet and what you write is what dispatch sees. See One spelling per tool.

Tip

Descriptions matter. The model decides whether to call a tool - and how to fill its arguments - based almost entirely on description and the description fields of each parameter. Write descriptions as you would a function docstring: state the effect, not the implementation.


ToolParameter

public enum ToolParameterType: Sendable {
    case string
    case bool
    case int
    case double
    case array(elementType: ToolParameterType)
    case object(properties: [ToolParameter])
    case data
}

public struct ToolParameter: Sendable {
    public let name: String
    public let type: ToolParameterType
    public let description: String
    public let isRequired: Bool
    public let extraProperties: [String: any Sendable]

    public static func required(
        _ name: String,
        type: ToolParameterType,
        description: String
    ) -> ToolParameter

    public static func optional(
        _ name: String,
        type: ToolParameterType,
        description: String
    ) -> ToolParameter
}

Parameter types

Type Swift equivalent Notes
string String Most common; use for paths, queries, free text
bool Bool Flags and toggles
int Int Integer counts, offsets, line numbers
double Double Floating-point values
array(elementType:) [T] Homogeneous list; specify the element type
object(properties:) Structured dict Nested parameter object; properties are themselves [ToolParameter]
data Data Binary payload; encoded as base64 in the JSON schema

Required vs optional

// Model must provide this argument
ToolParameter.required("path", type: .string, description: "Absolute path to the file")

// Model may omit this argument
ToolParameter.optional("encoding", type: .string, description: "Text encoding, defaults to UTF-8")

ToolContext and ToolOutput

ToolContext

ToolContext is passed to every execute call. It carries the agent's live state, allowing a tool to read current messages, access the filesystem backend, and emit progress events during a long-running operation.

ToolOutput

// Conceptual shape - result text + optional state update
ToolOutput(text: "File written successfully.", stateUpdate: nil)

ToolOutput carries:

  • A result string that is returned to the model as the tool's output message.
  • An optional state update that the agent loop applies to AgentState after the tool returns.

Return a clear, concise result string. The model reads this to decide its next action, so vague output ("done") is less useful than specific output ("Wrote 142 bytes to /tmp/output.txt").


Parallel-safe tools

A round's tool calls run one after another by default, and each call sees the state and results of the calls before it. A tool can opt out of that ordering:

struct ReadFileTool: AgentTool {
    var name: String { "read_file" }
    // …
    var isParallelSafe: Bool { true }
}

The loop then runs each run of consecutive parallel-safe calls as one concurrent batch (at most four at a time), so a round of three read_file calls costs one read instead of three. Results still come back in call order; the events show the batch running and each call finishing as it lands, which is why they carry a callID to pair on. See The agent loop for the batching rules.

Declaring true asserts two things about the tool:

  • It is safe to run concurrently with itself and with other tools. Anything holding a lock, a subprocess working tree, or a device the OS serialises is a poor candidate.
  • It does not need an earlier call's result or state update. Every call in a batch is handed the same state snapshot, taken when the batch started - siblings are invisible to each other.

Anything that writes - write_file, edit_file, shell, write_clipboard, task - should leave the default in place. So should a tool whose ordering relative to a writer matters, and a tool whose semantics you don't control (MCPTool defaults to false for exactly this reason).

Two further rules the runtime applies for you:

  • A gated tool still fans out. Only the approval request is serialised - one card at a time, the next raised when the previous decision returns - so a gated tool costs nothing extra when the host answers the gate itself. (Read-only tools default to ask, so the opposite rule would make this feature inert.)
  • Middleware wrapToolCall chains do run concurrently around a batch. A middleware whose wrapper is order-sensitive should not wrap parallel-safe tools.

Among the built-in toolsets, the read-only tools declare it: ls, read_file, grep, glob, tree, head, tail, diff, fetch, every git_* tool, current_datetime, calculator, mdfind, and read_clipboard.


Generic toolsets

The following toolsets ship in DeepAgents. Each toolset is owned by a middleware that contributes its tools via the tools property. See Middleware for the full capability catalog table and adapter requirements.

ID Middleware Tools
web WebToolsMiddleware fetch, curl
search SearchToolsMiddleware grep, glob, tree (see walk limits)
text TextToolsMiddleware head, tail, diff
git GitToolsMiddleware git_status, git_diff, git_log, git_show, git_blame
shell ShellToolsMiddleware shell
macos MacToolsMiddleware mdfind, open, open_app, download, say, notify
filesystem FilesystemMiddleware ls, read_file, write_file, edit_file, mkdir
clipboard ClipboardMiddleware read_clipboard, write_clipboard
screenshot ScreenshotMiddleware take_screenshot, take_window_screenshots
apple_notes AppleNotesMiddleware list_notes, read_note, create_note, update_note
container ContainerShellMiddleware container_shell

shell and ShellGuard

The shell tool is gated by ShellGuard, which enforces command allowlisting. Raw shell access is powerful and should be paired with an approval handler in production. See Human in the loop.


AgentToolPolicy

AgentToolPolicy is a Codable, Sendable struct that captures tool governance preferences in a serialisable form - suitable for persisting user settings between sessions.

public struct AgentToolPolicy: Codable, Sendable {
    public var disabledMiddleware: Set<String>          // catalog IDs to exclude entirely
    public var disabledTools: Set<String>               // individual tool names to exclude
    public var approvals: [String: ToolApprovalMode]   // per-tool or per-middleware approval rules
    public var sandbox: SandboxMode                    // .off / .failover / .containerOnly
    public var sandboxImage: String?                   // Docker image for container sandbox

    // Lazy tools (see `ToolSearchMiddleware`). Inert while `toolSearch` is false.
    public var toolSearch: Bool                        // the feature switch
    public var auxiliaryMiddleware: Set<String>        // catalog IDs whose tools are auxiliary
    public var auxiliaryTools: Set<String>             // individual auxiliary tool names
    public var coreMCPServers: Set<String>             // MCP servers promoted to core (default: auxiliary)
    public var toolSearchModel: String?                // retriever repo id; nil = lexical
    public var toolSearchLimit: Int                    // matches per `search_tools` call

    public func expand(
        catalog: [MiddlewareDescriptor] = MiddlewareCatalog.all,
        extraDefaults: [String: ToolApprovalMode] = [:],
        extraAuxiliary: Set<String> = []
    ) -> Expansion
}

Call expand(...) to resolve a policy against the live MiddlewareCatalog and get back the concrete sets of tools to disable, the per-tool approval modes to pass to HumanInTheLoopMiddleware, and the auxiliaryToolNames to pass to createDeepAgent.

Tiers vs disabling

A disabled tool is gone: never rendered, never dispatchable. An auxiliary tool is only unrendered - it stays fully callable, and the agent finds it through search_tools. The two are disjoint (expand() subtracts disabled names from the auxiliary set), and a tier never changes what a tool is allowed to do: an auxiliary write is still gated by its approval mode. See Middleware → ToolSearchMiddleware.

ToolApprovalMode

public enum ToolApprovalMode: String, Codable {
    case approve   // always allow without asking
    case ask       // show approval UI before executing
    case deny      // always block
}

SandboxMode

public enum SandboxMode {
    case off            // no sandboxing; shell runs on the host
    case failover       // try container first; fall back to host if unavailable
    case containerOnly  // all shell commands routed through container_shell
}

Disabling happens at factory time

When you pass disabledToolNames: to createAgent / createDeepAgent, or expand a policy with disabled tools, the excluded tools are removed from the merged tool list before the agent is constructed. The model never sees these tools in its schema. This is architecturally different from the approval gate:

Mechanism When it fires Model sees the tool?
disabledTools / disabledToolNames Factory construction No - never rendered
ToolApprovalMode.deny Dispatch time (wrapToolCall) Yes - but call is blocked
ToolApprovalMode.ask Dispatch time (wrapToolCall) Yes - pending user confirmation

Prefer disabling at factory time for tools that should never be available in a given context (e.g. no filesystem writes in a read-only agent). Use ask / deny approval modes for tools that should be available but audited or constrained at runtime.


Search walk limits

grep, glob and tree share one file walk, with two limits worth knowing about.

Build output is skipped. build, DerivedData, node_modules, Pods, Carthage and __pycache__ are never descended into. They are generated, routinely far larger than the source they came from, and never what a search is looking for - in the Mispher repo, Ripple/build is 23,288 files, 96% of everything under the root. tree names such a folder rather than descending it, so the layout stays honest.

The list is deliberately short and excludes names that plausibly hold real sources (target, dist, site, vendor). A wrongly skipped directory is a search that confidently misses something real - the same defect as the one below, from the other direction.

A truncated walk says so. The walk stops after FileWalk.maxFiles (20,000). When it does, the tools report that they searched the first N files and stopped, rather than "No matches":

Searched the first 20000 files under "." and found no match for /pattern/, then stopped -
the folder holds more than that, so the pattern may still be present in what was not
searched. Narrow it with `path` or `include`.

This matters more than the cap itself. Reporting a truncated search as an absence gives the model a false fact it cannot detect, and it will act on it - in one measured case, spending 18 rounds trying to reconcile "no matches" with a question that presumed the symbol existed. A partial result that admits it is partial costs a follow-up call; a wrong one costs the whole turn.