Skip to main content

Tool System

Loom’s tool system provides AI agents with capabilities to interact with the development environment through structured JSON schemas. Tools are implemented in loom-cli-tools and registered at runtime.

Architecture

// Tool trait definition
#[async_trait]
pub trait Tool: Send + Sync {
    fn name(&self) -> &str;
    fn description(&self) -> &str;
    fn input_schema(&self) -> serde_json::Value;
    async fn invoke(
        &self,
        args: serde_json::Value,
        ctx: &ToolContext,
    ) -> Result<serde_json::Value, ToolError>;
}
Key components:
  • ToolRegistry: Central registry for all available tools
  • ToolContext: Execution context (workspace root, environment)
  • ToolDefinition: JSON schema for LLM consumption
  • ToolExecutionOutcome: Result type (success or error)

Tool Registration

Tools are registered during CLI initialization:
fn create_tool_registry() -> ToolRegistry {
    let mut registry = ToolRegistry::new();
    registry.register(Box::new(ReadFileTool::new()));
    registry.register(Box::new(ListFilesTool::new()));
    registry.register(Box::new(EditFileTool::new()));
    registry.register(Box::new(BashTool::new()));
    registry.register(Box::new(OracleTool::default()));
    registry.register(Box::new(WebSearchToolGoogle::default()));
    registry.register(Box::new(WebSearchToolSerper::default()));
    registry
}

Built-in Tools

read_file

Read file contents from the workspace.
path
string
required
Path to file (absolute or relative to workspace)
max_bytes
integer
Maximum bytes to read (default: 1MB)
Returns:
{
  "path": "/workspace/src/main.rs",
  "contents": "fn main() {...}",
  "truncated": false
}
Features:
  • Path validation (prevents directory traversal)
  • Workspace boundary enforcement
  • Automatic truncation for large files
  • UTF-8 lossy conversion
let canonical_path = Self::validate_path(&args.path, &ctx.workspace_root)?;
let metadata = tokio::fs::metadata(&canonical_path).await?;
let file_size = metadata.len();
let truncated = file_size > max_bytes;

let contents = if truncated {
    let bytes = tokio::fs::read(&canonical_path).await?;
    String::from_utf8_lossy(&bytes[..max_bytes as usize]).to_string()
} else {
    tokio::fs::read_to_string(&canonical_path).await?
};

edit_file

Apply snippet-based edits to files.
path
string
required
Path to file (created if doesn’t exist)
edits
array
required
List of edit operationsEach edit:
  • old_str: Text to find (empty string for new files)
  • new_str: Replacement text
  • replace_all: Replace all occurrences (default: false)
Returns:
{
  "path": "/workspace/src/lib.rs",
  "edits_applied": 3,
  "original_bytes": 1024,
  "new_bytes": 1156
}
Edit semantics:
  • Edits applied sequentially
  • If old_str is empty: append new_str to file (create if needed)
  • If old_str found once: replace with new_str
  • If old_str found multiple times: error (unless replace_all: true)
  • If old_str not found: error
let mut content = if file_path.exists() {
    tokio::fs::read_to_string(&file_path).await?
} else {
    String::new()
};

let original_bytes = content.len();
let mut edits_applied = 0;

for edit in &args.edits {
    if edit.old_str.is_empty() {
        // New file: append new_str
        content.push_str(&edit.new_str);
        edits_applied += 1;
    } else if edit.replace_all.unwrap_or(false) {
        // Replace all occurrences
        content = content.replace(&edit.old_str, &edit.new_str);
        edits_applied += 1;
    } else {
        // Replace single occurrence
        let count = content.matches(&edit.old_str).count();
        match count {
            0 => return Err(ToolError::InvalidArguments(
                format!("old_str not found: {}", edit.old_str)
            )),
            1 => {
                content = content.replacen(&edit.old_str, &edit.new_str, 1);
                edits_applied += 1;
            }
            _ => return Err(ToolError::InvalidArguments(
                format!("old_str found {} times, use replace_all", count)
            )),
        }
    }
}

tokio::fs::write(&file_path, content.as_bytes()).await?;

bash

Execute shell commands in the workspace.
command
string
required
Shell command to execute
cwd
string
Working directory (relative to workspace, default: workspace root)
timeout_secs
integer
Timeout in seconds (default: 60, max: 300)
Returns:
{
  "exit_code": 0,
  "stdout": "test output\n",
  "stderr": "",
  "timed_out": false,
  "truncated": false
}
Features:
  • Runs commands via sh -c
  • Respects workspace boundaries (cwd validation)
  • Automatic timeout (prevents hanging)
  • Output truncation (256KB per stream)
  • Captures both stdout and stderr
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(&args.command).current_dir(&working_dir);

let result = timeout(Duration::from_secs(timeout_secs), cmd.output()).await;

let (exit_code, stdout, stderr, timed_out, truncated) = match result {
    Ok(Ok(output)) => {
        let (stdout, stdout_truncated) = Self::truncate_output(&output.stdout, MAX_OUTPUT_BYTES);
        let (stderr, stderr_truncated) = Self::truncate_output(&output.stderr, MAX_OUTPUT_BYTES);
        (output.status.code(), stdout, stderr, false, stdout_truncated || stderr_truncated)
    }
    Ok(Err(e)) => return Err(ToolError::Io(e.to_string())),
    Err(_) => (None, String::new(), String::new(), true, false),
};
The bash tool can execute arbitrary commands. Always validate inputs and enforce workspace boundaries.

list_files

List files in a directory (glob-like functionality).
path
string
Directory path (default: workspace root)
recursive
boolean
List files recursively (default: false)
max_depth
integer
Maximum recursion depth (default: 10)
Returns:
{
  "files": [
    {
      "path": "src/main.rs",
      "size": 1024,
      "is_dir": false
    },
    {
      "path": "Cargo.toml",
      "size": 512,
      "is_dir": false
    }
  ]
}
Perform web searches via Loom server proxy.
query
string
required
Search query in natural language
max_results
integer
Maximum results (default: 5, max: 10)
Implementation: Proxies to server endpoint: POST /proxy/cse Providers:
  • WebSearchToolGoogle: Google Custom Search Engine
  • WebSearchToolSerper: Serper.dev API
Web search requires API keys configured on the server. The tool automatically retries on transient failures using exponential backoff.

oracle

Reserved for future use (AI model introspection).

Tool Context

Every tool receives a ToolContext with:
pub struct ToolContext {
    pub workspace_root: PathBuf,
    // Future: environment variables, user info, etc.
}
Usage:
let ctx = ToolContext::new(&workspace_path);
let result = tool.invoke(args, &ctx).await?;

Error Handling

Tools return Result<serde_json::Value, ToolError>:
pub enum ToolError {
    FileNotFound(PathBuf),
    PathOutsideWorkspace(PathBuf),
    InvalidArguments(String),
    Io(String),
    Serialization(String),
    NotFound(String),
    // ... other variants
}
Error propagation:
1

Tool returns error

Err(ToolError::FileNotFound(path))
2

Executor wraps error

ToolExecutionOutcome::Error {
    call_id: tool_call.id.clone(),
    error: e,
}
3

Error sent to LLM

{
  "role": "tool",
  "tool_call_id": "call_123",
  "content": "Error: File not found: /workspace/missing.txt"
}
4

LLM retries or adapts

The agent can correct the path or try a different approach.

Security

Path Validation

All file tools validate paths:
fn validate_path(path: &PathBuf, workspace_root: &Path) -> Result<PathBuf, ToolError> {
    let absolute_path = if path.is_absolute() {
        path.clone()
    } else {
        workspace_root.join(path)
    };

    let canonical = absolute_path.canonicalize()
        .map_err(|_| ToolError::FileNotFound(absolute_path.clone()))?;

    let workspace_canonical = workspace_root.canonicalize()
        .map_err(|_| ToolError::FileNotFound(workspace_root.to_path_buf()))?;

    if !canonical.starts_with(&workspace_canonical) {
        return Err(ToolError::PathOutsideWorkspace(canonical));
    }

    Ok(canonical)
}
Prevents:
  • Directory traversal (../../../etc/passwd)
  • Absolute paths outside workspace (/etc/shadow)
  • Symlink escape (via canonicalize())

Bash Sandboxing

  • Commands run in workspace subdirectory
  • No network access (unless container allows)
  • Timeout prevents infinite loops
  • Output truncation prevents memory exhaustion

Resource Limits

ToolLimitRationale
read_file1 MBPrevent memory overflow
bash stdout/stderr256 KB eachPrevent log spam
bash timeout300s maxPrevent hanging processes
list_files depth10 levelsPrevent infinite recursion

Testing

Loom tools use property-based testing with proptest:
proptest! {
    #[test]
    fn roundtrip_file_content(content in "[a-zA-Z0-9 \n]{0,1000}") {
        let rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async {
            let workspace = setup_workspace();
            let file_path = workspace.path().join("test.txt");
            std::fs::write(&file_path, &content).unwrap();

            let tool = ReadFileTool::new();
            let ctx = ToolContext::new(workspace.path().to_path_buf());

            let result = tool
                .invoke(serde_json::json!({"path": "test.txt"}), &ctx)
                .await
                .unwrap();

            prop_assert_eq!(result["contents"].as_str().unwrap(), content);
            Ok(())
        }).unwrap();
    }
}
Test coverage:
  • Path validation (traversal, absolute, symlinks)
  • Content preservation (UTF-8, truncation)
  • Error conditions (not found, permissions)
  • Edge cases (empty files, large files)

Adding Custom Tools

1

Implement the Tool trait

pub struct MyTool;

#[async_trait]
impl Tool for MyTool {
    fn name(&self) -> &str { "my_tool" }
    fn description(&self) -> &str { "Does something useful" }
    fn input_schema(&self) -> serde_json::Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "arg1": {"type": "string"}
            },
            "required": ["arg1"]
        })
    }
    async fn invoke(&self, args: serde_json::Value, ctx: &ToolContext)
        -> Result<serde_json::Value, ToolError>
    {
        // Implementation
        Ok(serde_json::json!({"result": "success"}))
    }
}
2

Register in the registry

registry.register(Box::new(MyTool));
3

Write tests

#[tokio::test]
async fn my_tool_works() {
    let tool = MyTool;
    let ctx = ToolContext::new(PathBuf::from("/workspace"));
    let result = tool.invoke(
        serde_json::json!({"arg1": "test"}),
        &ctx
    ).await.unwrap();
    assert_eq!(result["result"], "success");
}

Best Practices

Validate all inputs

Use schema validation and runtime checks. Never trust LLM-generated arguments.

Enforce resource limits

Set timeouts, max sizes, and depth limits to prevent abuse.

Return structured errors

Provide clear error messages the LLM can understand and act on.

Test with proptest

Use property-based testing to find edge cases.

Log tool execution

Use tracing for debugging: tracing::debug!("executing tool")

Keep tools atomic

One tool = one operation. Compose complex workflows in the agent layer.

Build docs developers (and LLMs) love