tools-rs 0.2.0

Core functionality for the tools-rs tool collection system
Documentation

Tools-rs - Tool Collection and Execution Framework

It's pronounced tools-r-us!!

Crates.io Documentation License: MIT

Tools-rs is a framework for building, registering, and executing tools with automatic JSON schema generation for Large Language Model (LLM) integration.

Features

  • Automatic Registration - Use #[tool] to automatically register functions with compile-time discovery
  • JSON Schema Generation - Automatic schema generation for LLM integration with full type information
  • Type Safety - Full type safety with JSON serialization at boundaries, compile-time parameter validation
  • Async Support - Built for async/await from the ground up with tokio integration
  • Error Handling - Comprehensive error types with context and proper error chaining
  • LLM Integration - Export function declarations for LLM function calling APIs (OpenAI, Anthropic, etc.)
  • Manual Registration - Programmatic tool registration for dynamic scenarios
  • Inventory System - Link-time tool collection using the inventory crate for zero-runtime-cost discovery
  • Typed Metadata - Attach #[tool(key = value)] attributes to tools and read them through a user-defined M type on ToolCollection<M> (see Tool Metadata)

Quick Start

use serde_json::json;
use tools_rs::{collect_tools, FunctionCall, tool};

#[tool]
/// Adds two numbers.
async fn add(pair: (i32, i32)) -> i32 {
    pair.0 + pair.1
}

#[tool]
/// Greets a person.
async fn greet(name: String) -> String {
    format!("Hello, {name}!")
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let tools = collect_tools();

    let sum = tools
        .call(FunctionCall::new(
            "add".into(),
            json!({ "pair": [3, 4] }),
        ))
        .await?.result;
    println!("add → {sum}");  // Outputs: "add → 7"

    let hi = tools
        .call(FunctionCall::new(
            "greet".into(),
            json!({ "name": "Alice" }),
        ))
        .await?.result;
    println!("greet → {hi}");  // Outputs: "greet → \"Hello, Alice!\""

    // Export function declarations for LLM APIs
    let declarations = tools.json()?;
    println!("Function declarations: {}", serde_json::to_string_pretty(&declarations)?);

    Ok(())
}

Installation

Add the following to your Cargo.toml:

[dependencies]
tools-rs = "0.2.0"
tokio = { version = "1.45", features = ["macros", "rt-multi-thread"] }
serde_json = "1.0"

Crate Structure

The tools-rs system is organized as a Rust workspace with three main crates:

  • tools-rs: Main entry point, re-exports the most commonly used items
  • tools_core: Core runtime implementation including:
    • Tool collection and execution (ToolCollection)
    • JSON schema generation (ToolSchema trait)
    • Error handling (ToolError, DeserializationError)
    • Core data structures (FunctionCall, ToolRegistration, etc.)
  • tools_macros: Procedural macros for tool registration:
    • #[tool] attribute macro for automatic registration
    • #[derive(ToolSchema)] for automatic schema generation
  • examples: Comprehensive examples demonstrating different use cases

For more details about the codebase organization, see CODE_ORGANIZATION.md.

Compatibility

Rust Version Support

Tools-rs requires Rust 1.85 or later and supports:

  • Automatically generate JSON schemas for LLM consumption
  • Execute tools safely with full type checking
  • Handle errors gracefully with detailed context

Function Declarations for LLMs

Tools-rs can automatically generate function declarations suitable for LLM APIs:

use tools_rs::{function_declarations, tool};

#[tool]
/// Return the current date in ISO-8601 format.
async fn today() -> String {
    chrono::Utc::now().date_naive().to_string()
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Generate function declarations for an LLM
    let declarations = function_declarations()?;

    // Use in API request
    let llm_request = serde_json::json!({
        "model": "gpt-4o",
        "messages": [/* ... */],
        "tools": declarations
    });

    Ok(())
}

The generated declarations follow proper JSON Schema format:

[
  {
    "description": "Return the current date in ISO-8601 format.",
    "name": "today",
    "parameters": {
      "properties": {},
      "required": [],
      "type": "object"
    }
  }
]

Manual Registration

While the #[tool] macro provides the most convenient way to register tools, you can also register tools manually for more dynamic scenarios:

use tools_rs::ToolCollection;
use serde_json::json;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut tools: ToolCollection = ToolCollection::new();

    // Register a simple tool manually. Pass `()` as the metadata when the
    // collection uses the default `NoMeta`; pass a real `M` value when the
    // collection is typed (e.g. `ToolCollection::<MyPolicy>::new()`).
    tools.register(
        "multiply",
        "Multiplies two numbers",
        |pair: (i64, i64)| async move { pair.0 * pair.1 },
        (),
    )?;

    // Call the manually registered tool
    let result = tools.call(tools_rs::FunctionCall {
        id: None, // Refers to the call ID
        name: "multiply".to_string(),
        arguments: json!({"a": 6, "b": 7}),
    }).await?;

    println!("6 * 7 = {}", result);
    Ok(())
}

Advanced Manual Registration

For complex scenarios with custom types:

use tools_rs::{ToolCollection, ToolSchema};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, ToolSchema)]
struct Calculator {
    operation: String,
    operands: Vec<f64>,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut tools: ToolCollection = ToolCollection::new();

    tools.register(
        "calculate",
        "Performs arithmetic operations on a list of numbers",
        |input: Calculator| async move {
            match input.operation.as_str() {
                "sum" => input.operands.iter().sum::<f64>(),
                "product" => input.operands.iter().product::<f64>(),
                "mean" => input.operands.iter().sum::<f64>() / input.operands.len() as f64,
                _ => f64::NAN,
            }
        },
        (),
    )?;

    Ok(())
}

Tool Metadata

#[tool(...)] accepts flat key = value attributes that get stored on each tool and read back through a user-defined metadata type. This lets a single tool declaration feed multiple collections, each typed to the schema that collection cares about — useful for HITL approval gates, cost tiering, and similar policy concerns that don't belong in the function body.

use serde::Deserialize;
use tools_rs::{tool, ToolCollection};

#[derive(Debug, Default, Deserialize, Clone, Copy)]
#[serde(default)]
struct Policy {
    requires_approval: bool,
    cost_tier: u8,
}

#[tool(requires_approval = true, cost_tier = 3)]
/// Deletes a file.
async fn delete_file(path: String) -> String { format!("deleted {path}") }

#[tool]
/// Reads a file (no metadata declared — fields default).
async fn read_file(path: String) -> String { format!("read {path}") }

fn main() -> Result<(), tools_rs::ToolError> {
    let tools = ToolCollection::<Policy>::collect_tools()?;
    let entry = tools.get("delete_file").unwrap();
    if entry.meta.requires_approval {
        // gate the call behind your approval flow
    }
    Ok(())
}

Default behavior

ToolCollection defaults to ToolCollection<NoMeta>. NoMeta is an empty struct that swallows any attributes a tool declared, so existing collect_tools() callers see no behavioral change. Opt into typed metadata by writing ToolCollection::<MyMeta>::collect_tools() instead.

Validation

ToolCollection::<M>::collect_tools() fails fast on the first tool whose attributes don't match M. For CI, two helpers accumulate every failure across the inventory before returning:

use tools_core::{validate_tool_attrs, validate_tool_attrs_for};
# #[derive(serde::Deserialize)] struct Policy;

#[test]
fn every_tool_conforms_to_policy() {
    validate_tool_attrs::<Policy>().unwrap();
}

#[test]
fn destructive_tools_have_approval_metadata() {
    // Subset gating: only check the named tools, error if any name doesn't
    // match a registered tool (typos in the test list are caught too).
    validate_tool_attrs_for::<Policy>(&["delete_file", "drop_table"]).unwrap();
}

Attribute syntax

  • #[tool(key = "value")] — string
  • #[tool(key = 42)] — integer (negative literals are accepted: -3)
  • #[tool(key = 1.5)] — float
  • #[tool(key = true)] — boolean
  • #[tool(flag)] — bare flag, equivalent to flag = true
  • Multiple attributes are comma-separated: #[tool(a = 1, b = "x", flag)]

Attributes are flat-only — nested structures (#[tool(policy = { ... })]) are not supported. Use richer types in runtime metadata, not at the attribute site. The keys name and description are reserved (the function name and doc comment supply them).

Programmatic registration with metadata

ToolCollection::register takes a metadata argument. For untyped collections, pass (); passing () to a typed collection is a compile error.

# use tools_rs::ToolCollection;
# #[derive(Default)] struct Policy { requires_approval: bool }
let mut tools: ToolCollection<Policy> = ToolCollection::new();
tools.register(
    "noop",
    "does nothing",
    |_: ()| async {},
    Policy { requires_approval: false },
)?;
# Ok::<(), tools_rs::ToolError>(())

Examples

Check out the examples directory for comprehensive sample code:

# Run the basic example - simple tool registration and calling
cargo run --example basic

# Run the function declarations example - LLM integration demo
cargo run --example function_declarations

# Run the schema example - complex type schemas and validation
cargo run --example schema

# Run the newtype demo - custom type wrapping examples
cargo run --example newtype_demo

Each example demonstrates different aspects of the framework:

  • basic: Simple tool registration with #[tool] and basic function calls
  • function_declarations: Complete LLM integration workflow with JSON schema generation
  • schema: Advanced schema generation for complex nested types and collections
  • newtype_demo: Working with custom wrapper types and serialization patterns

API Reference

Core Functions

  • collect_tools() - Discover all tools registered via #[tool] macro
  • function_declarations() - Generate JSON schema declarations for LLMs
  • call_tool(name, args) - Execute a tool by name with JSON arguments
  • call_tool_with(name, typed_args) - Execute a tool with typed arguments
  • call_tool_by_name(collection, name, args) - Execute tool on specific collection
  • list_tool_names(collection) - List all available tool names

Core Types

  • ToolCollection - Container for registered tools with execution capabilities
  • FunctionCall - Represents a tool invocation with id, name, and arguments
  • FunctionResponse - Represents the response of a tool invocation with matching id to call, name, and result
  • ToolError - Comprehensive error type for tool operations
  • ToolSchema - Trait for automatic JSON schema generation
  • ToolRegistration - Internal representation of registered tools
  • FunctionDecl - LLM-compatible function declaration structure

Macros

  • #[tool] - Attribute macro for automatic tool registration
  • #[derive(ToolSchema)] - Derive macro for automatic schema generation

Error Handling

Tools-rs provides comprehensive error handling with detailed context:

use tools_rs::{ToolError, collect_tools, FunctionCall};
use serde_json::json;

#[tokio::main]
async fn main() {
    let tools = collect_tools();

    match tools.call(FunctionCall::new(
        "nonexistent".to_string(),
        json!({}),
    )).await {
        Ok(response) => println!("Result: {}", response.result),
        Err(ToolError::FunctionNotFound { name }) => {
            println!("Tool '{}' not found", name);
        },
        Err(ToolError::Deserialize(err)) => {
            println!("Deserialization error: {}", err.source);
        },
        Err(e) => println!("Other error: {}", e),
    }
}

Performance Considerations

Schema Caching

  • JSON schemas are generated once and cached.
  • Schema generation has minimal runtime overhead after first access
  • Primitive types use pre-computed static schemas for optimal performance

Tool Discovery

  • Tool registration happens at compile-time via the inventory crate
  • Runtime tool collection (collect_tools()) is a zero-cost operation
  • Tools are stored in efficient hash maps for O(1) lookup by name

Execution Performance

  • Tool calls have minimal overhead beyond JSON serialization/deserialization
  • Async execution allows for concurrent tool invocation
  • Error handling uses Result types to avoid exceptions and maintain performance

Memory Usage

  • Tool metadata is stored statically with minimal heap allocation
  • JSON schemas are shared across all instances of the same type
  • Function declarations are generated on-demand and can be cached by the application

Optimization Tips

// Reuse ToolCollection instances to avoid repeated discovery
let tools = collect_tools(); // Call once, reuse multiple times

// Generate function declarations once for LLM integration
let declarations = function_declarations()?;
// Cache and reuse declarations across multiple LLM requests

// Use typed parameters to avoid repeated JSON parsing
use tools_rs::call_tool_with;
let result = call_tool_with("my_tool", &my_typed_args).await?.result;

Troubleshooting

Common Issues

Tool not found at runtime

  • Ensure the #[tool] macro is applied to your function
  • Verify the function is in a module that gets compiled (not behind unused feature flags)
  • Check that inventory is properly collecting tools with collect_tools()

Schema generation errors

  • Ensure all parameter and return types implement ToolSchema
  • For custom types, add #[derive(ToolSchema)] to struct definitions
  • Complex generic types may need manual ToolSchema implementations

Deserialization failures

  • Verify JSON arguments match the expected parameter structure
  • Check that argument names match function parameter names exactly
  • Use serde attributes like #[serde(rename = "...")] for custom field names

Async execution issues

  • All tool functions must be async fn when using #[tool]
  • Ensure you're using tokio runtime for async execution
  • Tool execution is inherently async - use .await when calling tools

Debugging Tips

// Enable debug logging to see tool registration and execution
use tools_rs::{collect_tools, list_tool_names};

let tools = collect_tools();
println!("Registered tools: {:?}", list_tool_names(&tools));

// Inspect generated schemas
let declarations = tools.json()?;
println!("Function declarations: {}", serde_json::to_string_pretty(&declarations)?);

Contributing

We welcome contributions!

Development Setup

# Clone the repository
git clone https://github.com/EggerMarc/tools-rs
cd tools-rs 

# Run tests
cargo test

# Run examples
cargo run --example basic

License

This project is licensed under the MIT License - see the LICENSE file for details.