Tool Execution
Tools execute asynchronously using Rust’s async/await patterns. The framework handles individual and batch tool execution, managing parallelization and result collection.
Async Execution Pattern
All tool execution in agent-air is asynchronous, allowing tools to perform I/O operations without blocking the runtime. The async pattern uses Rust’s Pin<Box<dyn Future>> type to enable dynamic dispatch while maintaining async execution.
The execute() method returns a pinned, boxed future:
fn execute(
&self,
context: ToolContext,
input: HashMap<String, serde_json::Value>,
) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
Box::pin(async move {
// Async operations here
Ok("Result".to_string())
})
}
Why Box::pin?
Understanding why we use Box::pin helps when implementing tools. This pattern is necessary because async functions return anonymous types that can’t be named directly.
The Executable trait is object-safe, allowing tools to be stored in collections and dispatched dynamically. Since async functions return opaque types, we use Pin<Box<dyn Future>> to erase the concrete type while maintaining async execution.
Capturing State
Because the async block is moved into the returned future, you must clone any data it needs access to before the async block begins. This is a common pattern when working with async closures.
Clone any state needed inside the async block:
fn execute(
&self,
context: ToolContext,
input: HashMap<String, serde_json::Value>,
) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
let client = self.client.clone(); // Clone before moving into async
let config = self.config.clone();
Box::pin(async move {
// Use client and config
let result = client.fetch(&config).await?;
Ok(result)
})
}
Async Operations
Inside the async block, you can use any async operation: HTTP requests, file I/O, database queries, or other async APIs. Use .await to wait for each operation and handle errors with the ? operator.
Perform any async operations inside the block:
Box::pin(async move {
// HTTP requests
let response = reqwest::get(url).await
.map_err(|e| e.to_string())?;
// File I/O
let content = tokio::fs::read_to_string(path).await
.map_err(|e| e.to_string())?;
// Database queries
let rows = sqlx::query("SELECT * FROM users")
.fetch_all(&pool)
.await
.map_err(|e| e.to_string())?;
Ok("Done".to_string())
})
Tool Executor
The framework’s tool executor orchestrates tool invocation, handling permission checks, parallel execution, and result collection. You don’t interact with it directly, but understanding its behavior helps when designing tools.
The ToolExecutor manages tool execution, handling single invocations and batches.
Single Execution
let result = executor.execute(tool_name, context, input).await;
Batch Execution
When the LLM requests multiple tools in one turn, the executor processes them:
let results = executor.execute_batch(tool_requests).await;
Execution Flow
When tools execute, the framework follows a consistent flow that ensures permissions are checked before any operation runs. This protects users from unauthorized actions.
- Permission check: Aggregate required permissions from all tools
- Permission request: If any tools need permission, prompt the user
- Parallel execution: Execute tools concurrently (if permissions granted)
- Result collection: Gather results from all tools
- Error handling: Capture and report any failures
Batch Tool Execution
LLMs often call multiple tools in a single response to accomplish complex tasks efficiently. The framework executes these as a batch, running independent tools in parallel while properly handling permissions and errors.
When the LLM calls multiple tools in a single response, they execute as a batch.
Parallel Processing
When multiple tools don’t depend on each other, they run concurrently. This significantly improves performance when reading multiple files or making several API calls.
Tools without interdependencies execute in parallel:
// These execute concurrently
let results = tokio::join!(
executor.execute("read_file", ctx1, input1),
executor.execute("read_file", ctx2, input2),
executor.execute("grep", ctx3, input3),
);
Permission Aggregation
Instead of prompting for each tool individually, the framework collects all required permissions and presents them to the user in a single prompt. This improves the user experience for complex operations.
Before batch execution, permissions are aggregated:
// Tool 1 needs write permission for /path/a
// Tool 2 needs write permission for /path/b
// Tool 3 needs read permission for /path/c
// User sees single prompt with all permissions
The user can grant or deny each permission individually, or grant all for the session.
Batch Results
After all tools complete, results are collected and returned to the LLM. Each result includes the tool’s identifier so results can be matched to their requests.
Results are returned in order, matching the original request:
pub struct BatchResult {
pub tool_use_id: String,
pub tool_name: String,
pub result: Result<String, String>,
}
Failed tools don’t prevent other tools from executing.
Display Configuration
Display configuration determines how your tool’s invocations appear to users in the TUI. Good display configuration makes it easy for users to understand what the tool is doing and review its results.
The DisplayConfig struct controls how tool invocations appear in the UI.
pub struct DisplayConfig {
pub display_name: String,
pub display_title: Box<dyn Fn(&HashMap<String, Value>) -> String + Send + Sync>,
pub display_content: Box<dyn Fn(&HashMap<String, Value>, &str) -> DisplayResult + Send + Sync>,
}
Fields
| Field | Description |
|---|---|
display_name | Human-readable name shown in UI (e.g., “File Reader”) |
display_title | Function that generates a title from input parameters |
display_content | Function that formats the result for display |
Display Title
The display title function generates a contextual header based on the tool’s input parameters. A good title helps users quickly understand what operation was performed.
The title appears as the header for the tool invocation:
display_title: Box::new(|input| {
input.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("Unknown file")
.to_string()
})
For a file read of /src/main.rs, the UI shows:
File Reader: /src/main.rs
Display Content
The display content function transforms the raw tool output into a user-friendly format. It can add context, truncate long output, and specify the content type for proper syntax highlighting.
The content function formats the result:
display_content: Box::new(|input, result| {
DisplayResult {
content: result.to_string(),
content_type: ResultContentType::PlainText,
is_truncated: result.len() > 10000,
full_length: result.len(),
}
})
Display Result
The DisplayResult struct packages formatted content with metadata about how it should be rendered. It includes information about truncation so the UI can indicate when content has been shortened.
The DisplayResult struct holds formatted output:
pub struct DisplayResult {
pub content: String,
pub content_type: ResultContentType,
pub is_truncated: bool,
pub full_length: usize,
}
| Field | Description |
|---|---|
content | The formatted content to display |
content_type | Format hint for rendering (Json, Markdown, etc.) |
is_truncated | Whether content was truncated for display |
full_length | Original content length before truncation |
Truncation
Large tool outputs can overwhelm the UI and consume excessive context. Truncate content for display while preserving the full length so users know data was omitted.
For large outputs, truncate content but preserve the full length:
const MAX_DISPLAY: usize = 10000;
display_content: Box::new(|_input, result| {
let truncated = result.len() > MAX_DISPLAY;
let content = if truncated {
format!("{}...\n\n[Truncated, {} total bytes]",
&result[..MAX_DISPLAY],
result.len())
} else {
result.to_string()
};
DisplayResult {
content,
content_type: ResultContentType::PlainText,
is_truncated: truncated,
full_length: result.len(),
}
})
Complete Example
This example shows a database query tool with comprehensive display configuration. It demonstrates truncating the title, adding context to results, and providing a compact summary for context compaction.
A tool with full display configuration:
impl Executable for DatabaseQueryTool {
fn name(&self) -> &str {
"query_database"
}
fn description(&self) -> &str {
"Executes a read-only SQL query and returns results as JSON."
}
fn input_schema(&self) -> &str {
r#"{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "SQL SELECT query to execute"
},
"limit": {
"type": "integer",
"description": "Maximum rows to return (default: 100)"
}
},
"required": ["query"]
}"#
}
fn execute(
&self,
_context: ToolContext,
input: HashMap<String, serde_json::Value>,
) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
let pool = self.pool.clone();
Box::pin(async move {
let query = input.get("query")
.and_then(|v| v.as_str())
.ok_or("Missing query parameter")?;
let limit = input.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(100);
// Execute query
let rows = sqlx::query(query)
.fetch_all(&pool)
.await
.map_err(|e| format!("Query failed: {}", e))?;
// Convert to JSON
let result = serde_json::to_string_pretty(&rows)
.map_err(|e| e.to_string())?;
Ok(result)
})
}
fn display_config(&self) -> DisplayConfig {
DisplayConfig {
display_name: "Database Query".to_string(),
display_title: Box::new(|input| {
let query = input.get("query")
.and_then(|v| v.as_str())
.unwrap_or("");
// Show first 50 chars of query
if query.len() > 50 {
format!("{}...", &query[..50])
} else {
query.to_string()
}
}),
display_content: Box::new(|input, result| {
let limit = input.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(100);
DisplayResult {
content: format!("Query returned results (limit: {})\n\n{}",
limit, result),
content_type: ResultContentType::Json,
is_truncated: false,
full_length: result.len(),
}
}),
}
}
fn compact_summary(
&self,
input: &HashMap<String, serde_json::Value>,
result: &str,
) -> String {
let query = input.get("query")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let row_count = result.matches('{').count(); // Rough estimate
format!("[DB Query: {} rows from '{}...']", row_count, &query[..20.min(query.len())])
}
}
Error Handling in Batch Execution
The framework isolates failures so one tool’s error doesn’t prevent others from completing. This is important for batch operations where some tools might fail due to permissions or missing resources.
When one tool fails in a batch, others still execute:
// Batch of 3 tools
// Tool 1: Success -> "file content"
// Tool 2: Error -> "Permission denied"
// Tool 3: Success -> "search results"
// All results returned to LLM
The LLM receives all results and can decide how to handle failures.
Timeouts
Long-running operations should have timeouts to prevent blocking the agent indefinitely. Use tokio’s timeout utilities to wrap operations that might hang.
Tools should respect reasonable timeouts:
Box::pin(async move {
let result = tokio::time::timeout(
Duration::from_secs(30),
perform_operation()
).await;
match result {
Ok(Ok(data)) => Ok(data),
Ok(Err(e)) => Err(e.to_string()),
Err(_) => Err("Operation timed out".to_string()),
}
})
The bash tool has configurable timeouts. Other tools should implement appropriate timeout handling for their operations.
