It's pronounced tools-r-us!!
Tools-rs is a framework for building, registering, and executing tools with automatic JSON schema generation for Large Language Model (LLM) integration.
- 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
tokiointegration - 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
inventorycrate for zero-runtime-cost discovery
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 {
name: "add".into(),
arguments: json!({ "pair": [3, 4] }),
})
.await?;
println!("add → {sum}"); // Outputs: "add → 7"
let hi = tools
.call(FunctionCall {
name: "greet".into(),
arguments: json!({ "name": "Alice" }),
})
.await?;
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(())
}Add the following to your Cargo.toml:
[dependencies]
tools-rs = "0.1.1"
tokio = { version = "1.45", features = ["macros", "rt-multi-thread"] }
serde_json = "1.0"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 (
ToolSchematrait) - Error handling (
ToolError,DeserializationError) - Core data structures (
FunctionCall,ToolRegistration, etc.)
- Tool collection and execution (
- 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.
Tools-rs requires Rust 1.70 or later and supports:
- Automatically generate JSON schemas for LLM consumption
- Execute tools safely with full type checking
- Handle errors gracefully with detailed context
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"
}
}
]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::new();
// Register a simple tool manually
tools.register(
"multiply",
"Multiplies two numbers",
|args: serde_json::Value| async move {
let a = args["a"].as_i64().unwrap_or(0);
let b = args["b"].as_i64().unwrap_or(0);
Ok(json!(a * b))
}
)?;
// Call the manually registered tool
let result = tools.call(tools_rs::FunctionCall {
name: "multiply".to_string(),
arguments: json!({"a": 6, "b": 7}),
}).await?;
println!("6 * 7 = {}", result);
Ok(())
}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::new();
tools.register(
"calculate",
"Performs arithmetic operations on a list of numbers",
|input: Calculator| async move {
match input.operation.as_str() {
"sum" => Ok(input.operands.iter().sum::<f64>()),
"product" => Ok(input.operands.iter().product::<f64>()),
"mean" => Ok(input.operands.iter().sum::<f64>() / input.operands.len() as f64),
_ => Err(format!("Unknown operation: {}", input.operation)),
}
}
)?;
Ok(())
}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_demoEach 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
collect_tools()- Discover all tools registered via#[tool]macrofunction_declarations()- Generate JSON schema declarations for LLMscall_tool(name, args)- Execute a tool by name with JSON argumentscall_tool_with(name, typed_args)- Execute a tool with typed argumentscall_tool_by_name(collection, name, args)- Execute tool on specific collectionlist_tool_names(collection)- List all available tool names
ToolCollection- Container for registered tools with execution capabilitiesFunctionCall- Represents a tool invocation with name and argumentsToolError- Comprehensive error type for tool operationsToolSchema- Trait for automatic JSON schema generationToolRegistration- Internal representation of registered toolsFunctionDecl- LLM-compatible function declaration structure
#[tool]- Attribute macro for automatic tool registration#[derive(ToolSchema)]- Derive macro for automatic schema generation
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 {
name: "nonexistent".to_string(),
arguments: json!({}),
}).await {
Ok(result) => println!("Result: {}", 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),
}
}- 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 registration happens at compile-time via the
inventorycrate - Runtime tool collection (
collect_tools()) is a zero-cost operation - Tools are stored in efficient hash maps for O(1) lookup by name
- Tool calls have minimal overhead beyond JSON serialization/deserialization
- Async execution allows for concurrent tool invocation
- Error handling uses
Resulttypes to avoid exceptions and maintain performance
- 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
// 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?;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
inventoryis properly collecting tools withcollect_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
ToolSchemaimplementations
Deserialization failures
- Verify JSON arguments match the expected parameter structure
- Check that argument names match function parameter names exactly
- Use
serdeattributes like#[serde(rename = "...")]for custom field names
Async execution issues
- All tool functions must be
async fnwhen using#[tool] - Ensure you're using
tokioruntime for async execution - Tool execution is inherently async - use
.awaitwhen calling tools
// 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)?);We welcome contributions!
# Clone the repository
git clone https://github.com/EggerMarc/tools-rs
cd tools-rs
# Run tests
cargo test
# Run examples
cargo run --example basicThis project is licensed under the MIT License - see the LICENSE file for details.