Adding Tools

How to add new built-in tools to swarm.

Overview

Tools are the primary way agents interact with the outside world. Each tool implements the Tool trait and is registered in the ToolRegistry. This guide walks through adding a new native tool from scratch.

The Tool Trait

Every tool implements this trait from swarm/src/tools/mod.rs:

#![allow(unused)]
fn main() {
pub trait Tool: Send + Sync {
    /// Unique tool name (e.g., "bash", "read", "my_tool")
    fn name(&self) -> &str;

    /// Human-readable description for the model
    fn description(&self) -> &str;

    /// JSON Schema describing the tool's input parameters
    fn input_schema(&self) -> Value;

    /// Whether the tool runs natively or in a sandbox (default: Native)
    fn execution_mode(&self) -> ExecutionMode {
        ExecutionMode::Native
    }

    /// Execute the tool with the given input and context
    fn execute<'a>(
        &'a self,
        input: Value,
        ctx: &'a ToolContext,
    ) -> Pin<Box<dyn Future<Output = Result<ToolResult>> + Send + 'a>>;
}
}

The five methods:

MethodRequiredPurpose
name()YesUnique identifier used in tool calls
description()YesShown to the model to explain what the tool does
input_schema()YesJSON Schema the model uses to construct input
execution_mode()No (default: Native)Native or Sandboxed
execute()YesPerforms the actual work

Step 1: Create the Tool File

Create a new file in swarm/src/tools/:

touch swarm/src/tools/my_tool.rs

Step 2: Implement the Tool Trait

#![allow(unused)]
fn main() {
use anyhow::Result;
use serde_json::Value;
use std::future::Future;
use std::pin::Pin;

use super::{Tool, ToolResult};
use super::context::ToolContext;

pub struct MyTool;

impl MyTool {
    pub fn new() -> Self {
        Self
    }
}

impl Tool for MyTool {
    fn name(&self) -> &str {
        "my_tool"
    }

    fn description(&self) -> &str {
        "A brief description of what this tool does. \
         The model reads this to decide when to use the tool."
    }

    fn input_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The search query"
                },
                "limit": {
                    "type": "integer",
                    "description": "Maximum number of results",
                    "default": 10
                }
            },
            "required": ["query"]
        })
    }

    fn execute<'a>(
        &'a self,
        input: Value,
        ctx: &'a ToolContext,
    ) -> Pin<Box<dyn Future<Output = Result<ToolResult>> + Send + 'a>> {
        Box::pin(async move {
            // Parse input
            let query = input["query"]
                .as_str()
                .ok_or_else(|| anyhow::anyhow!("missing required field: query"))?;

            let limit = input["limit"].as_u64().unwrap_or(10);

            // Use context for working directory, agent info, etc.
            let _working_dir = &ctx.working_dir;

            // Check permissions if needed
            let decision = ctx.check_permission("my_tool", &input);
            // Handle decision...

            // Do the actual work
            let result = format!("Found results for '{}' (limit: {})", query, limit);

            Ok(ToolResult::text(result))
        })
    }
}
}

Key Points

  • ToolResult::text(s) — Creates a successful result with text content
  • ToolResult::error(s) — Creates an error result (displayed to the model as an error)
  • ctx.working_dir — The agent's working directory (its worktree)
  • ctx.agent_name — The name of the calling agent
  • ctx.session_id — The current session ID
  • ctx.is_cancelled() — Check if the agent's session has been cancelled
  • ctx.check_permission(tool, input) — Check permission for this operation

Step 3: Register the Module

Add the module to swarm/src/tools/mod.rs:

#![allow(unused)]
fn main() {
mod my_tool;
}

Then register the tool in the default_registry() function in swarm/src/tools/registry.rs:

#![allow(unused)]
fn main() {
pub fn default_registry() -> ToolRegistry {
    let mut registry = ToolRegistry::new();
    registry.register(Box::new(bash::BashTool::new()));
    registry.register(Box::new(read::ReadTool::new()));
    // ... existing tools ...
    registry.register(Box::new(my_tool::MyTool::new()));
    registry
}
}

Step 4: Write Tests

Add tests in the same file or in a separate test module:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use crate::tools::context::ToolContext;
    use serde_json::json;
    use std::path::PathBuf;

    #[tokio::test]
    async fn test_my_tool_basic() {
        let tool = MyTool::new();
        let ctx = ToolContext::new(
            PathBuf::from("/tmp/test"),
            "test-agent".to_string(),
            "test-session".to_string(),
        );

        let input = json!({
            "query": "hello"
        });

        let result = tool.execute(input, &ctx).await.unwrap();
        assert!(!result.is_error);
    }

    #[tokio::test]
    async fn test_my_tool_missing_query() {
        let tool = MyTool::new();
        let ctx = ToolContext::new(
            PathBuf::from("/tmp/test"),
            "test-agent".to_string(),
            "test-session".to_string(),
        );

        let input = json!({});
        let result = tool.execute(input, &ctx).await;
        assert!(result.is_err());
    }

    #[test]
    fn test_schema_is_valid() {
        let tool = MyTool::new();
        let schema = tool.input_schema();
        assert_eq!(schema["type"], "object");
        assert!(schema["properties"]["query"].is_object());
    }
}
}

Step 5: Run Tests

cargo test --lib tools::my_tool

ToolResult Details

The ToolResult struct supports text and image content:

#![allow(unused)]
fn main() {
pub struct ToolResult {
    pub content: Vec<ToolResultContent>,
    pub is_error: bool,
}

pub enum ToolResultContent {
    Text(String),
    Image { media_type: String, data: String },
}
}

Helper constructors:

MethodCreates
ToolResult::text("output")Successful text result
ToolResult::error("message")Error result displayed to model

For richer results, construct ToolResult directly:

#![allow(unused)]
fn main() {
Ok(ToolResult {
    content: vec![
        ToolResultContent::Text("Found 3 files:".to_string()),
        ToolResultContent::Text("- src/main.rs\n- src/lib.rs\n- Cargo.toml".to_string()),
    ],
    is_error: false,
})
}

ToolContext Details

The ToolContext provides execution context:

#![allow(unused)]
fn main() {
pub struct ToolContext {
    pub working_dir: PathBuf,           // Agent's worktree directory
    pub agent_name: String,             // Name of the calling agent
    pub session_id: String,             // Current session ID
    pub env_vars: HashMap<String, String>, // Agent environment variables
    pub cancellation_token: CancellationToken, // Cancellation signal
    pub permissions: Option<Arc<PermissionEvaluator>>, // Permission checker
}
}

Use ctx.is_cancelled() to check for cancellation in long-running tools. This is important for tools that perform loops or wait for external resources.

Walkthrough: Existing Tool

Here's how the built-in GlobTool is structured (simplified):

#![allow(unused)]
fn main() {
pub struct GlobTool;

impl Tool for GlobTool {
    fn name(&self) -> &str { "glob" }

    fn description(&self) -> &str {
        "Fast file pattern matching tool that works with any codebase size"
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "pattern": {
                    "type": "string",
                    "description": "Glob pattern (e.g., **/*.rs)"
                },
                "path": {
                    "type": "string",
                    "description": "Directory to search in"
                }
            },
            "required": ["pattern"]
        })
    }

    fn execute<'a>(
        &'a self,
        input: Value,
        ctx: &'a ToolContext,
    ) -> Pin<Box<dyn Future<Output = Result<ToolResult>> + Send + 'a>> {
        Box::pin(async move {
            let pattern = input["pattern"].as_str()
                .ok_or_else(|| anyhow::anyhow!("missing pattern"))?;

            let base_dir = input["path"].as_str()
                .map(PathBuf::from)
                .unwrap_or_else(|| ctx.working_dir.clone());

            let full_pattern = base_dir.join(pattern);
            let matches = glob::glob(full_pattern.to_str().unwrap())?;

            let files: Vec<String> = matches
                .filter_map(|entry| entry.ok())
                .map(|p| p.display().to_string())
                .collect();

            Ok(ToolResult::text(files.join("\n")))
        })
    }
}
}

Pattern to follow:

  1. Parse input from JSON, validating required fields
  2. Use ctx.working_dir as default base path
  3. Perform the operation
  4. Return ToolResult::text() for success or ToolResult::error() for failures

Checklist

Before submitting your tool:

  • Implements all required Tool trait methods
  • Input schema has "type": "object" with documented properties
  • Required fields are listed in "required" array
  • Description is clear enough for the model to know when to use it
  • Tool handles missing/invalid input gracefully
  • Module is declared in tools/mod.rs
  • Tool is registered in default_registry() in tools/registry.rs
  • Tests cover basic usage and error cases
  • cargo test passes
  • cargo clippy has no warnings