Skip to main content

Crate llm_tool

Crate llm_tool 

Source
Expand description

§llm-tool

Framework-agnostic Rust tool definitions for LLM agents.

Define strongly-typed tools with the #[llm_tool] attribute macro and register them in a ToolRegistry. The registry produces ToolDefinition structs (name + description + JSON Schema) that any LLM framework can consume — there is zero coupling to a specific SDK or agent runtime.

§Quick start

Add llm-tool to your Cargo.toml:

[dependencies]
llm-tool = "0.1"

§Defining a tool with #[llm_tool]

The easiest way to create a tool is the #[llm_tool] attribute macro. It generates a params struct and a RustTool implementation from a plain function:

use llm_tool::{llm_tool, ToolError, ToolRegistry};

/// Adds two numbers together.
#[llm_tool]
fn add(
    /// First number.
    a: i64,
    /// Second number.
    b: i64,
) -> Result<String, ToolError> {
    Ok(format!("{}", a + b))
}

// The macro generates an `Add` struct (PascalCase of the fn name).
let registry = ToolRegistry::new().with_tool(Add);
let defs = registry.definitions();
assert_eq!(defs.len(), 1);
assert_eq!(defs[0].name, "add");

Rules for #[llm_tool] functions:

  • Must have a doc comment (becomes the tool description).
  • Every parameter must have a doc comment (becomes the JSON Schema description for that field).
  • Return type can be Result<T, E> or a bare T (infallible tools):
    • T is String (auto-wrapped into ToolOutput), ToolOutput (passed through), any T: Serialize (auto-serialized to JSON), or any T: Into<ToolOutput>.
    • E is any E: Into<ToolError> — built-in conversions exist for ToolError, String, std::io::Error, serde_json::Error, and Box<dyn Error + Send + Sync>.
  • Can be async fn — the generated RustTool::call is always async.
  • &str parameters are accepted — the generated struct stores String and auto-borrows.
  • Option<T> parameters automatically get #[serde(default)], so they are omitted from the JSON Schema required array.
  • A &ToolContext parameter is recognized as the execution context and forwarded from the registry — it is not included in the params struct.

§Returning ToolOutput with metadata

Tools can return a ToolOutput directly, attaching structured metadata for hooks, policies, and logging pipelines. Metadata is never sent to the model — only the content string is:

use llm_tool::{llm_tool, ToolOutput, ToolError};

/// Runs a shell command and returns its stdout.
#[llm_tool]
fn run_command(
    /// The command to execute.
    command: String,
) -> Result<ToolOutput, ToolError> {
    // In real code, you'd actually run the command.
    let stdout = format!("output of `{command}`");
    let exit_code = 0;
    Ok(ToolOutput::new(stdout)
        .with_meta("exit_code", serde_json::json!(exit_code))
        .with_meta("command", serde_json::json!(command)))
}

Similarly, ToolError supports metadata for error diagnostics:

use llm_tool::{llm_tool, ToolError};

/// Fetches a URL.
#[llm_tool]
fn fetch_url(
    /// The URL to fetch.
    url: String,
) -> Result<String, ToolError> {
    // Simulate an HTTP failure with structured error metadata.
    Err(ToolError::new("HTTP request failed")
        .with_meta("status_code", serde_json::json!(503))
        .with_meta("url", serde_json::json!(url)))
}

§The ? operator — zero-boilerplate error handling

ToolError implements From<std::io::Error>, From<serde_json::Error>, and From<Box<dyn Error + Send + Sync>>, so the ? operator works without manual .map_err():

use llm_tool::{llm_tool, ToolError, ToolContext, ToolRegistry};

/// Reads a file from disk.
#[llm_tool]
async fn read_file(
    /// Path to the file.
    path: String,
) -> Result<String, ToolError> {
    // `?` auto-converts std::io::Error into ToolError with error_kind metadata.
    let content = std::fs::read_to_string(&path)?;
    Ok(content)
}

§Infallible tools — no Result needed

Tools that can never fail can return a bare type instead of Result:

use llm_tool::{llm_tool, ToolRegistry};

/// Returns a friendly greeting.
#[llm_tool]
fn greet(
    /// Name to greet.
    name: String,
) -> String {
    format!("Hello, {name}!")
}

let registry = ToolRegistry::new().with_tool(Greet);
let ctx = llm_tool::ToolContext::new(None);
let result = registry
    .dispatch("greet", serde_json::json!({"name": "World"}), &ctx)
    .await
    .unwrap();
assert_eq!(result.content(), "Hello, World!");

§Returning structured data with ToolOutput::json()

Use ToolOutput::json() to serialize any T: Serialize to JSON:

use llm_tool::{llm_tool, ToolOutput, ToolError};
use serde::Serialize;

#[derive(Serialize)]
struct Weather {
    temp: f64,
    unit: String,
    city: String,
}

/// Gets the current weather for a city.
#[llm_tool]
fn get_weather(
    /// City name.
    city: String,
) -> Result<ToolOutput, ToolError> {
    let data = Weather {
        temp: 72.0,
        unit: "F".into(),
        city,
    };
    ToolOutput::json(&data)
}

§Auto-serialized return types

Any T: Serialize returned from a tool is automatically serialized to JSON by the macro’s compile-time dispatch — no ToolOutput::json() call needed:

use llm_tool::{llm_tool, ToolError};
use serde::Serialize;

#[derive(Serialize)]
struct FileInfo {
    path: String,
    size: u64,
    is_dir: bool,
}

/// Returns metadata about a file.
#[llm_tool]
fn inspect_file(
    /// Path to inspect.
    path: String,
) -> Result<FileInfo, ToolError> {
    // The macro auto-serializes `FileInfo` to JSON via serde.
    Ok(FileInfo {
        path,
        size: 42,
        is_dir: false,
    })
}

§Json<T> wrapper for infallible serialization

Use Json<T> when your tool is infallible but returns a serializable struct. It implements Into<ToolOutput>, panicking only if Serialize is broken (use ToolOutput::json() for fallible serialization):

use llm_tool::{llm_tool, Json};
use serde::Serialize;

#[derive(Serialize)]
struct Stats {
    count: usize,
    label: String,
}

/// Computes statistics.
#[llm_tool]
fn compute_stats(
    /// Number of items.
    count: usize,
) -> Json<Stats> {
    Json(Stats {
        count,
        label: format!("{count} items processed"),
    })
}

§Custom From<T> for ToolOutput

Implement From<YourType> for ToolOutput for domain types that should convert directly into tool output, then call .into() in the tool body:

use llm_tool::{llm_tool, ToolOutput, ToolError};

struct Markdown(String);

impl From<Markdown> for ToolOutput {
    fn from(md: Markdown) -> Self {
        ToolOutput::new(md.0)
            .with_meta("format", serde_json::json!("markdown"))
    }
}

/// Renders documentation as Markdown.
#[llm_tool]
fn render_docs(
    /// Topic to document.
    topic: String,
) -> Result<ToolOutput, ToolError> {
    Ok(Markdown(format!("# {topic}\n\nDocumentation for {topic}.")).into())
}

§Async tools

Async functions work out of the box. The body can .await freely:

use llm_tool::{llm_tool, ToolError, ToolContext, ToolRegistry};

/// Reads a file from disk.
#[llm_tool]
async fn read_file_async(
    /// Path to the file.
    path: String,
) -> Result<String, ToolError> {
    std::fs::read_to_string(&path)
        .map_err(|e| ToolError::new(format!("IO error: {e}")))
}

§Optional parameters

Option<T> fields are not required in the JSON the model sends:

use llm_tool::{llm_tool, ToolError};

/// Greets someone.
#[llm_tool]
fn greet_optional(
    /// Name to greet.
    name: String,
    /// Custom greeting (defaults to "Hello" if omitted).
    greeting: Option<String>,
) -> Result<String, ToolError> {
    let g = greeting.as_deref().unwrap_or("Hello");
    Ok(format!("{g}, {name}!"))
}

§Accessing ToolContext

If your tool needs the conversation ID or shared state, accept a &ToolContext parameter. It is automatically wired by the registry and excluded from the generated params struct:

use llm_tool::{llm_tool, ToolContext, ToolError};

/// Returns the current conversation ID.
#[llm_tool]
fn whoami(
    /// Unused placeholder.
    _label: String,
    ctx: &ToolContext,
) -> Result<String, ToolError> {
    Ok(ctx.conversation_id().unwrap_or("unknown").to_string())
}

§Manual RustTool implementation

For full control, implement RustTool directly:

use llm_tool::{JsonSchema, RustTool, ToolContext, ToolError, ToolOutput};
use serde::Deserialize;

#[derive(Deserialize, JsonSchema)]
struct FlashParams {
    /// Target device identifier.
    device_id: String,
    /// Path to the firmware image.
    image_path: String,
}

struct FlashDevice;

impl RustTool for FlashDevice {
    type Params = FlashParams;
    const NAME: &'static str = "flash_device";
    const DESCRIPTION: &'static str = "Flashes firmware to a connected device.";

    async fn call(
        &self,
        params: Self::Params,
        _ctx: &ToolContext,
    ) -> Result<ToolOutput, ToolError> {
        Ok(format!(
            "Flashed {} to {}",
            params.image_path, params.device_id
        ).into())
    }
}

§ToolRegistry

The registry stores tools and provides two operations:

  1. definitions() — returns Vec<ToolDefinition> with the name, description, and JSON Schema for each tool. Feed these to your framework.
  2. dispatch(name, args, ctx) — deserializes JSON args, calls the tool, and returns a ToolOutput or a ToolError.
use llm_tool::{llm_tool, ToolContext, ToolError, ToolRegistry};

/// Echoes its input.
#[llm_tool]
fn echo(
    /// The message.
    message: String,
) -> Result<String, ToolError> {
    Ok(message)
}

let registry = ToolRegistry::new().with_tool(Echo);

// 1. Get definitions to send to the model.
let defs = registry.definitions();
assert_eq!(defs[0].name, "echo");

// 2. Dispatch a tool call from the model.
let ctx = ToolContext::new(None);
let result = registry
    .dispatch("echo", serde_json::json!({"message": "hi"}), &ctx)
    .await
    .unwrap();
assert_eq!(result.content(), "hi");

§Plugging into any agent framework

llm-tool is deliberately framework-agnostic. To integrate with a new framework:

  1. Register your tools in a ToolRegistry.
  2. Extract definitions via registry.definitions() — each ToolDefinition has .name, .description, and .parameter_schema (a serde_json::Value containing a JSON Schema object).
  3. Convert ToolDefinitions into whatever format your framework expects (e.g. OpenAI function-calling JSON, Anthropic tool-use blocks, Gemini FunctionDeclarations, etc.). The parameter_schema is standard JSON Schema (draft 7, with nullable arrays already sanitized to scalar "type" strings for Go genai compatibility).
  4. On tool call, extract the tool name and JSON arguments from your framework’s response, then call registry.dispatch(name, args, &ctx).
  5. Return the result (or error message) to the model as the tool response.

Minimal integration sketch:

use llm_tool::{ToolContext, ToolDefinition, ToolRegistry};

fn send_definitions_to_model(defs: &[ToolDefinition]) {
    // Convert each def.parameter_schema to your framework's format.
    for def in defs {
        println!(
            "Tool: {} — {} — schema: {}",
            def.name, def.description, def.parameter_schema
        );
    }
}

async fn handle_tool_call(
    registry: &ToolRegistry,
    name: &str,
    args: serde_json::Value,
) -> String {
    let ctx = ToolContext::new(Some("conv-123".into()));
    match registry.dispatch(name, args, &ctx).await {
        Ok(output) => output.into_content(),
        Err(e) => format!("Tool error: {e}"),
    }
}

§Key types

TypeDescription
RustToolTrait for implementing a tool with typed parameters.
ToolRegistryRegistry for storing and dispatching tools by name.
ToolDefinitionSerializable metadata (name, description, JSON Schema).
ToolContextExecution context with conversation state and shared key-value store.
ToolOutputStructured return value (content + metadata) from tool execution.
ToolErrorError type with From impls for io::Error, serde_json::Error.
Json<T>Wrapper for infallible serialization of T: Serialize into output.
EmptyParamsConvenience struct for tools that take no parameters.
#[llm_tool]Proc-macro attribute for defining tools from plain functions.

§License

Dual-licensed under Apache-2.0 OR MIT.

Structs§

EmptyParams
Convenience type for tools that take no parameters.
Json
Wrapper for returning serializable values as JSON tool output.
ToolContext
Context passed to Rust tools during dispatch, mirroring the Python SDK’s ToolContext.
ToolDefinition
Describes a custom tool that can be registered with an agent.
ToolError
An error returned from a tool execution. The error message is sent back to the model as the tool’s error response. Structured metadata can be attached for hooks and logging — it is not sent to the model.
ToolOutput
The return value of a Rust tool execution.
ToolRegistry

Traits§

JsonSchema
A type which can be described as a JSON Schema document.
RustTool
A custom tool implemented entirely in Rust with strongly-typed parameters.

Functions§

definition_of
Build a ToolDefinition from any RustTool implementor.

Attribute Macros§

llm_tool
Re-export the #[llm_tool] proc macro for defining tools from plain functions. Transforms a function into a RustTool implementation.

Derive Macros§

JsonSchema