Extend agent capabilities with custom Rust functions.
Function tools let you give agents abilities beyond conversation - calling APIs, performing calculations, accessing databases, or any custom logic. The LLM decides when to use a tool based on the user's request.
Key highlights:
- 🚀
#[tool]macro - zero-boilerplate tool registration (recommended)- 🔧
FunctionTool::new()- wrap any async function manually- 📝 JSON parameters - flexible input/output
- 🎯 Type-safe schemas - automatic JSON Schema from types via schemars
- 🔗 Context access - session state, artifacts, memory
sequenceDiagram
participant LLM as Model Provider
participant Agent as LlmAgent
participant Tool as Tool::execute()
participant Fn as Your function
LLM->>Agent: Response with tool_call(name, args_json)
Agent->>Tool: Resolve tool by name, execute(ctx, args)
alt #[tool]: args match parameters_schema
Tool->>Fn: Deserialize args and call
Fn-->>Tool: Result (JSON Value)
Tool-->>Agent: Result
Agent->>LLM: Send tool_result back
else args break the schema
Tool-->>Agent: tool.invalid_arguments error (the function does not run)
Agent->>LLM: Error message as tool_result, so the model can correct the call
end
A #[tool] tool, and a FunctionTool or StatefulTool given a schema with
with_parameters_schema, checks its arguments itself; a hand-written Tool can check them
with ToolArgumentValidator.
The fastest way to create tools. The macro reads your doc comment as the description and derives the JSON schema from your args type:
use adk_tool::tool;
use adk_core::AdkError;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Deserialize, JsonSchema)]
struct WeatherArgs {
/// The city to look up
city: String,
/// Temperature unit (celsius or fahrenheit)
unit: Option<String>,
}
/// Get the current weather for a city.
#[tool]
async fn get_weather(args: WeatherArgs) -> Result<Value, AdkError> {
Ok(json!({ "temp": 22, "city": args.city }))
}
// Generated: pub struct GetWeather; — implements adk_core::Tool
// Use it: agent_builder.tool(Arc::new(GetWeather))If your tool needs session context, add Arc<dyn ToolContext> as the first parameter:
use adk_core::ToolContext;
use std::sync::Arc;
/// Search the user's saved documents.
#[tool]
async fn search_docs(
ctx: Arc<dyn ToolContext>,
args: SearchArgs,
) -> Result<Value, AdkError> {
let user_id = ctx.user_id();
// ... use context for scoped access
}Mark tools as read-only, idempotent, concurrency-safe, or long-running directly in the macro:
/// Look up cached data — no side effects, safe for parallel dispatch.
#[tool(read_only, concurrency_safe)]
async fn cache_lookup(args: LookupArgs) -> Result<Value, AdkError> {
Ok(json!({"result": "cached"}))
}
/// Start a long-running background report.
#[tool(long_running)]
async fn generate_report(args: ReportArgs) -> Result<Value, AdkError> {
Ok(json!({"task_id": "abc123", "status": "processing"}))
}Available attributes (all optional, combine freely):
| Attribute | Effect |
|---|---|
read_only |
is_read_only() → true and effect() → ToolEffect::ReadOnly — one of two signals required for concurrent Auto dispatch |
idempotent |
effect() → ToolEffect::Idempotent — a retry budget may retry the tool after a retryable error; ignored alongside read_only |
concurrency_safe |
is_concurrency_safe() → true — one of two signals required for concurrent Auto dispatch |
long_running |
is_long_running() → true — prevents LLM from re-calling a pending tool |
lenient_args |
Turns off argument validation: arguments are deserialized with serde alone, unknown keys are dropped, and schema bounds are advisory |
Plain #[tool] without attributes keeps the defaults (all false, effect NonIdempotent). See Tool Effects for how the effect governs retries.
A #[tool] tool checks each call's arguments against its schema before your function runs.
The schema is the one schemars derives from the args type — the one parameters_schema()
returns — with additionalProperties: false set on every object that declares all of its
fields. The closure applies to the check only: Gemini's function declarations reject
additionalProperties, so the declared schema leaves it out.
| The model sends | Result |
|---|---|
| A key the schema does not name, at any depth | Rejected, except in an object whose fields come partly from a flattened enum, which stays open |
A value outside a #[schemars(range(...))], length(...) or pattern(...) bound |
Rejected |
| A missing required field, or a value of the wrong type | Rejected |
null for an Option field |
Accepted |
An integral float such as 3.0 for an integer field |
Accepted, and read as 3 (also with lenient_args) |
A rejected call fails with an ErrorCategory::InvalidInput error with code
TOOL_INVALID_ARGUMENTS_CODE (tool.invalid_arguments) that lists each violation by JSON
pointer. LlmAgent always shows that message to the model, so it can fix the arguments and
call again; the function does not run.
use adk_core::AdkError;
use adk_tool::tool;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
#[derive(Deserialize, JsonSchema)]
struct BookArgs {
/// Room code such as `A1`.
#[schemars(length(min = 2, max = 4), pattern(r"^[A-Z][0-9]{1,3}$"))]
room: String,
/// Slots to book, from 1 to 10.
#[schemars(range(min = 1, max = 10))]
count: u32,
}
/// Book meeting-room slots.
#[tool]
async fn book_slots(args: BookArgs) -> Result<Value, AdkError> {
Ok(json!({ "booked": args.count, "room": args.room }))
}
// {"room": "A1", "count": 50, "dry_run": true} reaches the model as:
// tool.invalid_input: invalid arguments for 'book_slots': /count: 50 is greater than the
// maximum of 10; arguments: Additional properties are not allowed ('dry_run' was unexpected).
// Correct the arguments to match the tool's parameters schema and call it again.A schema narrower than what the type deserializes from rejects calls serde would accept, such
as a #[serde(alias = "...")] key or a field with a custom deserialize_with. Keep one
struct's keys open with #[schemars(extend("additionalProperties" = true))], or opt the whole
tool out with #[tool(lenient_args)].
FunctionTool and StatefulTool apply the same check from the type given to
with_parameters_schema::<T>(); .with_lenient_args(true) turns it off. Hand-written tools
use the check through adk_tool::ToolArgumentValidator, with
adk_tool::close_object_schemas to reject undeclared keys and
adk_tool::coerce_integral_floats to read 3.0 as 3:
use adk_core::AdkError;
use adk_tool::{ToolArgumentValidator, close_object_schemas};
use serde_json::json;
fn weather_validator() -> Result<ToolArgumentValidator, AdkError> {
let mut schema = json!({
"type": "object",
"properties": { "city": { "type": "string", "maxLength": 40 } },
"required": ["city"]
});
close_object_schemas(&mut schema);
ToolArgumentValidator::new(&schema)
}
// In `Tool::execute`, before acting on `args`:
// weather_validator()?.validate(self.name(), &args)?;For dynamic tools or when you prefer explicit registration:
Create a tool with FunctionTool::new() and always add a schema so the LLM knows what parameters to pass:
use adk_rust::prelude::*;
use adk_rust::Launcher;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
#[derive(JsonSchema, Serialize, Deserialize)]
struct WeatherParams {
/// The city or location to get weather for
location: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
let api_key = std::env::var("GOOGLE_API_KEY")?;
let model = GeminiModel::new(&api_key, "gemini-3.7-flash")?;
// Weather tool with proper schema
let weather_tool = FunctionTool::new(
"get_weather",
"Get current weather for a location",
|_ctx, args| async move {
let location = args.get("location")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
Ok(json!({
"location": location,
"temperature": "22°C",
"conditions": "sunny"
}))
},
)
.with_parameters_schema::<WeatherParams>(); // Required for LLM to call correctly!
let agent = LlmAgentBuilder::new("weather_agent")
.instruction("You help users check the weather. Always use the get_weather tool.")
.model(Arc::new(model))
.tool(Arc::new(weather_tool))
.build()?;
Launcher::new(Arc::new(agent)).run().await?;
Ok(())
}
⚠️ Important: Always use.with_parameters_schema<T>()- without it, the LLM won't know what parameters to pass and may not call the tool.
How it works:
- User asks: "What's the weather in Tokyo?"
- LLM decides to call
get_weatherwith{"location": "Tokyo"} - Tool returns
{"location": "Tokyo", "temperature": "22°C", "conditions": "sunny"} - LLM formats response: "The weather in Tokyo is sunny at 22°C."
Extract parameters from the JSON args:
let order_tool = FunctionTool::new(
"process_order",
"Process an order. Parameters: product_id (required), quantity (required), priority (optional)",
|_ctx, args| async move {
// Required parameters - return error if missing
let product_id = args.get("product_id")
.and_then(|v| v.as_str())
.ok_or_else(|| adk_core::AdkError::tool("product_id is required"))?;
let quantity = args.get("quantity")
.and_then(|v| v.as_i64())
.ok_or_else(|| adk_core::AdkError::tool("quantity is required"))?;
// Optional parameter with default
let priority = args.get("priority")
.and_then(|v| v.as_str())
.unwrap_or("normal");
Ok(json!({
"order_id": "ORD-12345",
"product_id": product_id,
"quantity": quantity,
"priority": priority,
"status": "confirmed"
}))
},
);For complex tools, use typed structs with JSON Schema:
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(JsonSchema, Serialize, Deserialize)]
struct CalculatorParams {
/// The arithmetic operation to perform
operation: Operation,
/// First operand
a: f64,
/// Second operand
b: f64,
}
#[derive(JsonSchema, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum Operation {
Add,
Subtract,
Multiply,
Divide,
}
let calculator = FunctionTool::new(
"calculator",
"Perform arithmetic operations",
|_ctx, args| async move {
let params: CalculatorParams = serde_json::from_value(args)?;
let result = match params.operation {
Operation::Add => params.a + params.b,
Operation::Subtract => params.a - params.b,
Operation::Multiply => params.a * params.b,
Operation::Divide if params.b != 0.0 => params.a / params.b,
Operation::Divide => return Err(adk_core::AdkError::tool("Cannot divide by zero")),
};
Ok(json!({ "result": result }))
},
)
.with_parameters_schema::<CalculatorParams>();The schema is auto-generated from Rust types using schemars.
Add multiple tools to one agent:
let agent = LlmAgentBuilder::new("assistant")
.instruction("Help with calculations, conversions, and weather.")
.model(Arc::new(model))
.tool(Arc::new(calc_tool))
.tool(Arc::new(convert_tool))
.tool(Arc::new(weather_tool))
.build()?;The LLM automatically chooses the right tool based on the user's request.
Return errors with the Tool component for tool-specific failures:
use adk_core::{AdkError, ErrorComponent, ErrorCategory};
let divide_tool = FunctionTool::new(
"divide",
"Divide two numbers",
|_ctx, args| async move {
let a = args.get("a").and_then(|v| v.as_f64())
.ok_or_else(|| AdkError::new(
ErrorComponent::Tool,
ErrorCategory::InvalidInput,
"tool.divide.missing_param",
"Parameter 'a' is required",
))?;
let b = args.get("b").and_then(|v| v.as_f64())
.ok_or_else(|| AdkError::new(
ErrorComponent::Tool,
ErrorCategory::InvalidInput,
"tool.divide.missing_param",
"Parameter 'b' is required",
))?;
if b == 0.0 {
return Err(AdkError::new(
ErrorComponent::Tool,
ErrorCategory::InvalidInput,
"tool.divide.division_by_zero",
"Cannot divide by zero",
));
}
Ok(json!({ "result": a / b }))
},
);For quick migration, the backward-compatible shorthand also works:
Err(AdkError::tool("Parameter 'a' is required"))Error messages are passed to the LLM, which can retry or ask for different input.
Access session info via ToolContext:
#[derive(JsonSchema, Serialize, Deserialize)]
struct GreetParams {
#[serde(default)]
message: Option<String>,
}
let greet_tool = FunctionTool::new(
"greet",
"Greet the user with session info",
|ctx, _args| async move {
let user_id = ctx.user_id();
let session_id = ctx.session_id();
let agent_name = ctx.agent_name();
Ok(json!({
"greeting": format!("Hello, user {}!", user_id),
"session": session_id,
"served_by": agent_name
}))
},
)
.with_parameters_schema::<GreetParams>();Available context:
ctx.user_id()- Current user IDctx.session_id()- Current session IDctx.agent_name()- Name of the agentctx.artifacts()- Access to artifact storagectx.search_memory(query)- Search memory service
For operations that take significant time (data processing, external APIs), use the non-blocking pattern:
- Start tool returns immediately with a task_id
- Background work runs asynchronously
- Status tool lets users check progress
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(JsonSchema, Serialize, Deserialize)]
struct ReportParams {
topic: String,
}
#[derive(JsonSchema, Serialize, Deserialize)]
struct StatusParams {
task_id: String,
}
// Shared task store
let tasks: Arc<RwLock<HashMap<String, TaskState>>> = Arc::new(RwLock::new(HashMap::new()));
let tasks1 = tasks.clone();
let tasks2 = tasks.clone();
// Tool 1: Start (returns immediately)
let start_tool = FunctionTool::new(
"generate_report",
"Start generating a report. Returns task_id immediately.",
move |_ctx, args| {
let tasks = tasks1.clone();
async move {
let topic = args.get("topic").and_then(|v| v.as_str()).unwrap_or("general").to_string();
let task_id = format!("task_{}", rand::random::<u32>());
// Store initial state
tasks.write().await.insert(task_id.clone(), TaskState {
status: "processing".to_string(),
progress: 0,
result: None,
});
// Spawn background work (non-blocking!)
let tasks_bg = tasks.clone();
let tid = task_id.clone();
tokio::spawn(async move {
// Simulate work...
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
if let Some(t) = tasks_bg.write().await.get_mut(&tid) {
t.status = "completed".to_string();
t.result = Some("Report complete".to_string());
}
});
// Return immediately with task_id
Ok(json!({"task_id": task_id, "status": "processing"}))
}
},
)
.with_parameters_schema::<ReportParams>()
.with_long_running(true); // Mark as long-running
// Tool 2: Check status
let status_tool = FunctionTool::new(
"check_report_status",
"Check report generation status",
move |_ctx, args| {
let tasks = tasks2.clone();
async move {
let task_id = args.get("task_id").and_then(|v| v.as_str()).unwrap_or("");
if let Some(t) = tasks.read().await.get(task_id) {
Ok(json!({"status": t.status, "result": t.result}))
} else {
Ok(json!({"error": "Task not found"}))
}
}
},
)
.with_parameters_schema::<StatusParams>();Key points:
.with_long_running(true)tells the agent this tool returns a pending status- The tool spawns work with
tokio::spawn()and returns immediately - Provide a status check tool so users can poll progress
This adds a note to prevent the LLM from calling the tool repeatedly.
Long-running tools can push intermediate output to the UI while still
executing, so the user sees a shell command's stdout, a build's logs, or a
download's bytes live instead of waiting for the final result. Call
ToolContext::emit_progress as output arrives:
use adk_core::{Result, Tool, ToolContext};
use std::sync::Arc;
#[async_trait::async_trait]
impl Tool for BuildTool {
// ... name(), description(), parameters_schema() ...
async fn execute(&self, ctx: Arc<dyn ToolContext>, args: serde_json::Value) -> Result<serde_json::Value> {
// Emit chunks as they arrive — each becomes a partial Event on the
// agent's EventStream, the SAME stream the model's reply travels on.
ctx.emit_progress("stdout", "Compiling project...\n").await;
ctx.emit_progress("stdout", "Build finished in 4.2s\n").await;
ctx.emit_progress("stderr", "warning: unused variable `x`\n").await;
// The final return value is still the complete result the model consumes.
Ok(serde_json::json!({ "status": "ok", "warnings": 1 }))
}
}The signature:
async fn emit_progress(&self, stream: &str, chunk: &str)stream— a label for the chunk:"stdout","stderr", or any custom channel.chunk— the text to emit (emit per line for terminal-style output).
How it reaches the UI. The framework forwards each chunk as a partial
Event on the agent's
EventStream. A consumer detects it with event.tool_progress_stream() and
renders it live. There is no second channel and no log scraping — progress,
model text, and the final tool result all arrive on one ordered stream.
Backward compatible. The default emit_progress is a no-op, so existing
tools and runners that don't stream are unaffected. Only tools that opt in emit
progress, and only consumers that check tool_progress_stream() observe it.
Progress is bounded and lossy. A tool can produce output faster than a client consumes it — a compiler log, a shell command, a runaway loop — so the framework caps what it will hold and forward rather than growing without limit:
| Limit | Value | On exceeding it |
|---|---|---|
| Queue depth per tool batch | 256 events | The tool waits up to 100 ms for space, then the chunk is dropped |
| Bytes per chunk | 8 KiB | The chunk is truncated on a character boundary |
| Bytes per tool call | 1 MiB | Remaining progress is not forwarded |
When output is dropped for any of these reasons, exactly one progress event
carrying the text [adk: tool progress truncated] is emitted for that call, so a
gap is always visible rather than silent. A slow consumer therefore slows the tool
briefly but can never stall it indefinitely or exhaust memory.
These limits apply only to progress. A tool's final result is not affected, so truncate large results inside the tool if that matters to you.
See the
streaming_bashexample for a complete web UI that renders livebashoutput and one-shot tool results (read_file,grep,glob) from a single event feed. The streamingbashtool itself lives inadk-devtools.
cargo adk new tool_agent --template tools
cd tool_agent
cargo run- Clear descriptions - Help the LLM understand when to use the tool
- Validate inputs - Return helpful error messages for missing parameters
- Return structured JSON - Use clear field names
- Keep tools focused - Each tool should do one thing well
- Use schemas - For complex tools, define parameter schemas
- Mark safe read-only tools - Set both
.with_read_only(true)and.with_concurrency_safe(true)soAutodispatch can include them in its concurrent subset
Mark tools as read-only or concurrency-safe to enable smarter dispatch:
// A lookup tool that performs no side effects
let lookup = FunctionTool::new("lookup", "Look up data", |_ctx, args| async move {
Ok(json!({"result": "cached data"}))
})
.with_read_only(true)
.with_concurrency_safe(true); // Auto mode requires both signals
// A mutation tool (defaults: read_only=false, concurrency_safe=false)
let update = FunctionTool::new("update", "Update record", |_ctx, args| async move {
Ok(json!({"updated": true}))
});When ToolExecutionStrategy::Auto is active, the dispatch loop first runs calls concurrently when their selected tools return true from both is_read_only() and is_concurrency_safe(). It then executes all remaining calls sequentially. ToolExecutionStrategy::Parallel is an explicit override that bypasses these signals, so its caller owns concurrency safety.
.with_effect(ToolEffect::Idempotent) declares a mutation tool safe to repeat, so a retry budget may retry it. Without it, a read-only tool is ReadOnly and every other tool is NonIdempotent and never retried. See Tool Effects.
When you need to call a tool outside the agent loop (testing, MCP server mode, sub-agent delegation), use SimpleToolContext instead of implementing the full ToolContext trait hierarchy:
use adk_tool::SimpleToolContext;
use adk_core::ToolContext;
use std::sync::Arc;
// Construct with just a caller name — all other fields get sensible defaults
let ctx = SimpleToolContext::new("my-test-harness");
// Optionally override the function call ID
let ctx = SimpleToolContext::new("my-mcp-server")
.with_function_call_id("custom-call-id");
// Bind a session ID so session-aware tools (and MCP servers that key state by
// session) see a stable identifier instead of the empty default.
let ctx = SimpleToolContext::new("my-mcp-server")
.with_session_id("session-42");
// Use it to execute any tool
let tool_ctx: Arc<dyn ToolContext> = Arc::new(ctx);
let result = my_tool.execute(tool_ctx, json!({"key": "value"})).await?;Defaults: user_id() → "anonymous", session_id() / branch() → "", artifacts() → None, search_memory() → empty vec. Both invocation_id and function_call_id are auto-generated UUIDs. Set a real session with with_session_id(...) when a tool routes or persists state per session.
For tools that need to maintain state between calls (counters, caches, connection pools), use StatefulTool<S>:
use adk_tool::StatefulTool;
use adk_core::ToolContext;
use std::sync::Arc;
use tokio::sync::RwLock;
struct AppCache {
entries: RwLock<HashMap<String, String>>,
}
let cache = Arc::new(AppCache {
entries: RwLock::new(HashMap::new()),
});
let cache_tool = StatefulTool::new(
"cache_lookup",
"Look up a value in the application cache",
cache.clone(),
|state, _ctx, args| async move {
let key = args["key"].as_str().unwrap_or("");
let entries = state.entries.read().await;
let value = entries.get(key).cloned().unwrap_or_default();
Ok(json!({"key": key, "value": value}))
},
)
.with_read_only(true)
.with_concurrency_safe(true);StatefulTool clones the Arc<S> on each invocation (cheap reference count bump), so all executions share the same underlying state. It supports the same builder methods as FunctionTool: with_long_running, with_parameters_schema (which also validates arguments), with_lenient_args, with_response_schema, with_scopes, with_read_only, and with_concurrency_safe.
- Built-in Tools - Pre-built tools (GoogleSearch, ExitLoop)
- MCP Tools - Model Context Protocol integration
- LlmAgent - Adding tools to agents
Gemini 3 models support receiving images, audio, PDFs, and file references in function responses — not just JSON. Tools can return multimodal data by including inline_data and/or file_data arrays in their JSON return value:
/// Tool that returns a chart image alongside JSON metadata.
async fn generate_chart(
_ctx: Arc<dyn ToolContext>,
args: serde_json::Value,
) -> Result<serde_json::Value> {
let png_bytes: Vec<u8> = render_chart(&args);
// Include inline_data in the return value — the framework extracts it automatically
Ok(json!({
"response": {
"title": "Q4 Sales",
"chart_type": "bar"
},
"inline_data": [{
"mime_type": "image/png",
"data": png_bytes
}]
}))
}The framework automatically:
- Detects
inline_data/file_dataviaFunctionResponseData::from_tool_result() - Base64-encodes inline binary data
- Nests the parts inside the
functionResponsewire object (matching the Gemini 3 API format)
For large files stored externally, use file_data with a URI instead of embedding bytes:
Ok(json!({
"response": { "document_id": "report-2024", "pages": 12 },
"file_data": [{
"mime_type": "application/pdf",
"file_uri": "gs://my-bucket/reports/report-2024.pdf"
}]
}))For framework-level code (custom agents, conversion layers), construct FunctionResponseData directly:
use adk_core::{FunctionResponseData, InlineDataPart, FileDataPart};
// JSON + inline image
let frd = FunctionResponseData::with_inline_data(
"chart_tool",
json!({"title": "Q4 Chart"}),
vec![InlineDataPart { mime_type: "image/png".into(), data: png_bytes }],
);
// JSON + file reference
let frd = FunctionResponseData::with_file_data(
"doc_tool",
json!({"status": "ok"}),
vec![FileDataPart { mime_type: "application/pdf".into(), file_uri: "gs://bucket/file.pdf".into() }],
);
// JSON + both
let frd = FunctionResponseData::with_multimodal("tool", json, inline_parts, file_parts);Note: Multimodal function responses require Gemini 3 series models such as
gemini-3.7-flash. Earlier models return a 400 error.
See examples/multimodal_function_response/ for a complete working example.
Previous: ← mistral.rs | Next: Built-in Tools →