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.

  1. Permission check: Aggregate required permissions from all tools
  2. Permission request: If any tools need permission, prompt the user
  3. Parallel execution: Execute tools concurrently (if permissions granted)
  4. Result collection: Gather results from all tools
  5. 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

FieldDescription
display_nameHuman-readable name shown in UI (e.g., “File Reader”)
display_titleFunction that generates a title from input parameters
display_contentFunction 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,
}
FieldDescription
contentThe formatted content to display
content_typeFormat hint for rendering (Json, Markdown, etc.)
is_truncatedWhether content was truncated for display
full_lengthOriginal 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.