//! Production-grade tool execution subsystem. //! //! Defines the `Tool` trait and `ToolExecutor` which discovers, registers, //! and executes tools on behalf of the LLM. Includes built-in tools for //! filesystem operations and shell command execution, all with appropriate //! security checks and resource limits. use std::collections::HashMap; use std::time::Duration; use gyandoot_core::{ToolCall, ToolDefinition, ToolResult}; use serde_json; use tracing::{debug, error, warn}; // ─── Tool trait ───────────────────────────────────────────────────────── /// Trait that all tools (built-in or skill-based) must implement. #[async_trait::async_trait] pub trait Tool: Send + Sync { /// Short, unique name for the tool (e.g. `"read"`, `"bash"`). fn name(&self) -> &str; /// Human-readable description for the LLM. fn description(&self) -> &str; /// JSON Schema for the tool's parameters. fn parameters(&self) -> serde_json::Value; /// Execute the tool with the given arguments. Returns the output text. async fn execute(&self, args: serde_json::Value) -> anyhow::Result; } // ─── BuiltinTool enum ─────────────────────────────────────────────────── /// Enumeration of all built-in tools available to the agent. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BuiltinTool { Read, Write, Edit, Bash, Grep, Find, Ls, } impl BuiltinTool { /// All built-in tool variants. pub fn all() -> &'static [BuiltinTool] { &[ BuiltinTool::Read, BuiltinTool::Write, BuiltinTool::Edit, BuiltinTool::Bash, BuiltinTool::Grep, BuiltinTool::Find, BuiltinTool::Ls, ] } #[allow(dead_code)] fn name(&self) -> &'static str { match self { BuiltinTool::Read => "read", BuiltinTool::Write => "write", BuiltinTool::Edit => "edit", BuiltinTool::Bash => "bash", BuiltinTool::Grep => "grep", BuiltinTool::Find => "find", BuiltinTool::Ls => "ls", } } } // ─── ToolExecutor ─────────────────────────────────────────────────────── /// Manages tool registration, definition generation, and execution. pub struct ToolExecutor { tools: HashMap>, } impl ToolExecutor { /// Create an empty executor (no tools registered). pub fn new() -> Self { Self { tools: HashMap::new(), } } /// Create an executor pre-populated with all built-in tools. pub fn with_defaults() -> Self { let mut executor = Self::new(); executor.register_builtins(); executor } /// Register a single tool. pub fn register(&mut self, tool: Box) { let name = tool.name().to_string(); debug!("Registering tool: {name}"); self.tools.insert(name, tool); } /// Register all built-in tools. pub fn register_builtins(&mut self) { self.register(Box::new(ReadTool)); self.register(Box::new(WriteTool)); self.register(Box::new(EditTool)); self.register(Box::new(BashTool::default())); self.register(Box::new(GrepTool)); self.register(Box::new(FindTool)); self.register(Box::new(LsTool)); debug!("Registered {} built-in tools", BuiltinTool::all().len()); } /// Return `ToolDefinition` values for every registered tool (for LLM /// function-calling schemas). pub fn definitions(&self) -> Vec { self.tools .values() .map(|t| ToolDefinition { name: t.name().to_string(), description: t.description().to_string(), parameters: t.parameters(), }) .collect() } /// Execute a single tool call, returning a structured result. pub async fn execute(&self, call: &ToolCall) -> anyhow::Result { let span = tracing::span!(tracing::Level::INFO, "tool_exec", tool = %call.name); let _guard = span.enter(); match self.tools.get(&call.name) { Some(tool) => { debug!( "Executing tool '{}' with args: {}", call.name, call.arguments.to_string() ); match tool.execute(call.arguments.clone()).await { Ok(output) => { debug!("Tool '{}' succeeded ({} bytes)", call.name, output.len()); Ok(ToolResult { tool_call_id: call.id.clone(), success: true, output, error: None, }) } Err(e) => { error!("Tool '{}' failed: {e}", call.name); Ok(ToolResult { tool_call_id: call.id.clone(), success: false, output: String::new(), error: Some(e.to_string()), }) } } } None => { warn!("Tool '{}' not found", call.name); Ok(ToolResult { tool_call_id: call.id.clone(), success: false, output: String::new(), error: Some(format!("Tool '{}' is not available", call.name)), }) } } } /// Check whether a specific tool name is registered. pub fn has_tool(&self, name: &str) -> bool { self.tools.contains_key(name) } /// Number of registered tools. pub fn len(&self) -> usize { self.tools.len() } /// True if no tools are registered. pub fn is_empty(&self) -> bool { self.tools.is_empty() } } impl Default for ToolExecutor { fn default() -> Self { Self::new() } } // ═══════════════════════════════════════════════════════════════════════ // Built-in tools // ═══════════════════════════════════════════════════════════════════════ // ─── ReadTool ─────────────────────────────────────────────────────────── /// Read a file, optionally with line numbers. pub struct ReadTool; #[async_trait::async_trait] impl Tool for ReadTool { fn name(&self) -> &str { "read" } fn description(&self) -> &str { "Read the contents of a file. Optionally show line numbers and limit output." } fn parameters(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "file_path": { "type": "string", "description": "Path to the file to read" }, "offset": { "type": "integer", "description": "Line number to start from (1-indexed)", "default": 1 }, "limit": { "type": "integer", "description": "Maximum number of lines to read", "default": 500 }, "line_numbers": { "type": "boolean", "description": "Show line numbers next to each line", "default": true } }, "required": ["file_path"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let file_path = args["file_path"] .as_str() .ok_or_else(|| anyhow::anyhow!("'file_path' is required (string)"))?; let offset = args["offset"].as_u64().unwrap_or(1) as usize; let limit = args["limit"].as_u64().unwrap_or(500) as usize; let line_numbers = args["line_numbers"].as_bool().unwrap_or(true); let content = tokio::fs::read_to_string(file_path) .await .map_err(|e| anyhow::anyhow!("Failed to read '{file_path}': {e}"))?; let lines: Vec<&str> = content.lines().collect(); let start = offset.saturating_sub(1).min(lines.len()); let end = (start + limit).min(lines.len()); let slice = &lines[start..end]; if slice.is_empty() { return Ok(format!("(empty — file has {} lines)", lines.len())); } let out: String = if line_numbers { slice .iter() .enumerate() .map(|(i, line)| format!("{:>6}| {}", start + i + 1, line)) .collect::>() .join("\n") } else { slice.join("\n") }; let total = lines.len(); if end < total { Ok(format!( "{out}\n\n--- {}/{} lines shown (use offset={}, limit={limit} for more) ---", end - start, total, end + 1, )) } else { Ok(format!("{out}\n\n--- {}/{} lines ---", end - start, total)) } } } // ─── WriteTool ────────────────────────────────────────────────────────── /// Write or create a file. pub struct WriteTool; #[async_trait::async_trait] impl Tool for WriteTool { fn name(&self) -> &str { "write" } fn description(&self) -> &str { "Write content to a file, creating parent directories if needed. Overwrites existing content." } fn parameters(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "file_path": { "type": "string", "description": "Path to the file to write" }, "content": { "type": "string", "description": "Content to write to the file" } }, "required": ["file_path", "content"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let file_path = args["file_path"] .as_str() .ok_or_else(|| anyhow::anyhow!("'file_path' is required (string)"))?; let content = args["content"] .as_str() .ok_or_else(|| anyhow::anyhow!("'content' is required (string)"))?; // Create parent directories if let Some(parent) = std::path::Path::new(file_path).parent() { tokio::fs::create_dir_all(parent) .await .map_err(|e| anyhow::anyhow!("Failed to create parent directory: {e}"))?; } tokio::fs::write(file_path, content) .await .map_err(|e| anyhow::anyhow!("Failed to write '{file_path}': {e}"))?; Ok(format!( "Wrote {} bytes to {file_path}", content.len() )) } } // ─── EditTool ─────────────────────────────────────────────────────────── /// Apply a targeted edit to an existing file (find-and-replace). pub struct EditTool; #[async_trait::async_trait] impl Tool for EditTool { fn name(&self) -> &str { "edit" } fn description(&self) -> &str { "Apply a targeted find-and-replace edit to a file using fuzzy matching. \ Use this instead of rewriting entire files." } fn parameters(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "file_path": { "type": "string", "description": "Path to the file to edit" }, "old_string": { "type": "string", "description": "Exact text to find and replace" }, "new_string": { "type": "string", "description": "Replacement text" } }, "required": ["file_path", "old_string", "new_string"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let file_path = args["file_path"] .as_str() .ok_or_else(|| anyhow::anyhow!("'file_path' is required (string)"))?; let old_string = args["old_string"] .as_str() .ok_or_else(|| anyhow::anyhow!("'old_string' is required (string)"))?; let new_string = args["new_string"] .as_str() .ok_or_else(|| anyhow::anyhow!("'new_string' is required (string)"))?; let content = tokio::fs::read_to_string(file_path) .await .map_err(|e| anyhow::anyhow!("Failed to read '{file_path}': {e}"))?; if !content.contains(old_string) { return Err(anyhow::anyhow!( "Could not find the specified text in '{file_path}'" )); } let new_content = content.replace(old_string, new_string); let replacements = content.matches(old_string).count(); tokio::fs::write(file_path, &new_content) .await .map_err(|e| anyhow::anyhow!("Failed to write '{file_path}': {e}"))?; Ok(format!( "Applied {replacements} replacement(s) to {file_path} ({} → {} bytes)", content.len(), new_content.len() )) } } // ─── BashTool ─────────────────────────────────────────────────────────── /// Configuration for the Bash tool. #[derive(Debug, Clone)] pub struct BashToolConfig { /// Maximum execution timeout in seconds. pub timeout_secs: u64, /// Maximum output characters returned. pub max_output_chars: usize, } impl Default for BashToolConfig { fn default() -> Self { Self { timeout_secs: 120, max_output_chars: 100_000, } } } /// Execute a shell command with timeout and output cap. pub struct BashTool { config: BashToolConfig, } impl BashTool { pub fn new(config: BashToolConfig) -> Self { Self { config } } } impl Default for BashTool { fn default() -> Self { Self::new(BashToolConfig::default()) } } #[async_trait::async_trait] impl Tool for BashTool { fn name(&self) -> &str { "bash" } fn description(&self) -> &str { "Execute a shell command. Supports pipes, redirects, and any POSIX command. \ Timeout and output are capped for safety." } fn parameters(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "command": { "type": "string", "description": "The shell command to execute" }, "workdir": { "type": "string", "description": "Working directory (default: current dir)", "default": "." }, "description": { "type": "string", "description": "Brief description of what this command does (for audit log)" } }, "required": ["command"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let command = args["command"] .as_str() .ok_or_else(|| anyhow::anyhow!("'command' is required (string)"))?; let workdir = args["workdir"].as_str().unwrap_or("."); // Basic security: refuse destructive commands without explicit allow let trimmed = command.trim(); if trimmed.starts_with("rm -rf /") || trimmed.starts_with("rm -rf /*") || trimmed.starts_with("mkfs") || trimmed.starts_with("dd if=") { return Err(anyhow::anyhow!( "Security: destructive command '{}' is blocked by default", trimmed.split_whitespace().next().unwrap_or("?") )); } debug!( "Bash tool: running '{}' in '{}' (timeout={}s)", command, workdir, self.config.timeout_secs ); let output = tokio::time::timeout( Duration::from_secs(self.config.timeout_secs), tokio::process::Command::new("sh") .arg("-c") .arg(command) .current_dir(workdir) .output(), ) .await .map_err(|_| { anyhow::anyhow!( "Command timed out after {}s", self.config.timeout_secs ) })? .map_err(|e| anyhow::anyhow!("Failed to execute command: {e}"))?; let mut result = String::new(); if !output.stdout.is_empty() { let stdout = String::from_utf8_lossy(&output.stdout); result.push_str(&stdout); } if !output.stderr.is_empty() { let stderr = String::from_utf8_lossy(&output.stderr); if !result.is_empty() { result.push('\n'); } result.push_str(&format!("STDERR:\n{stderr}")); } let exit_code = output.status.code().unwrap_or(-1); if exit_code != 0 { result.push_str(&format!("\n(exit code: {exit_code})")); } // Cap output if result.len() > self.config.max_output_chars { let truncated = result[..self.config.max_output_chars].to_string(); result = format!( "{truncated}\n\n--- Output truncated ({} chars, cap {}) ---", result.len(), self.config.max_output_chars ); } Ok(result) } } // ─── GrepTool ─────────────────────────────────────────────────────────── /// Search file contents using regex. pub struct GrepTool; #[async_trait::async_trait] impl Tool for GrepTool { fn name(&self) -> &str { "grep" } fn description(&self) -> &str { "Search file contents using regex patterns. Returns matching lines with file paths and line numbers." } fn parameters(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "pattern": { "type": "string", "description": "Regex pattern to search for" }, "path": { "type": "string", "description": "Directory or file to search in", "default": "." }, "file_glob": { "type": "string", "description": "Filter file types (e.g., '*.rs', '*.py')", "default": "" }, "max_results": { "type": "integer", "description": "Maximum number of results", "default": 50 } }, "required": ["pattern"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let pattern = args["pattern"] .as_str() .ok_or_else(|| anyhow::anyhow!("'pattern' is required (string)"))?; let path = args["path"].as_str().unwrap_or("."); let file_glob = args["file_glob"].as_str().unwrap_or(""); let max_results = args["max_results"].as_u64().unwrap_or(50) as usize; let mut cmd = tokio::process::Command::new("rg"); cmd.arg("--line-number") .arg("--with-filename") .arg("--no-heading") .arg("--max-count") .arg(max_results.to_string()) .arg(pattern) .arg(path); if !file_glob.is_empty() { cmd.arg("--glob").arg(file_glob); } let output = cmd .output() .await .map_err(|e| anyhow::anyhow!("Failed to run ripgrep: {e}"))?; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } else if output.status.code() == Some(1) { Ok("(no matches found)".to_string()) } else { let stderr = String::from_utf8_lossy(&output.stderr); Err(anyhow::anyhow!("rg failed: {stderr}")) } } } // ─── FindTool ─────────────────────────────────────────────────────────── /// Find files by glob/name pattern. pub struct FindTool; #[async_trait::async_trait] impl Tool for FindTool { fn name(&self) -> &str { "find" } fn description(&self) -> &str { "Find files by name pattern (glob). Returns file paths sorted by modification time (newest first)." } fn parameters(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "pattern": { "type": "string", "description": "Glob pattern to search for (e.g., '*.rs', '*config*')" }, "path": { "type": "string", "description": "Directory to search in", "default": "." }, "max_results": { "type": "integer", "description": "Maximum number of results", "default": 50 } }, "required": ["pattern"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let pattern = args["pattern"] .as_str() .ok_or_else(|| anyhow::anyhow!("'pattern' is required (string)"))?; let path = args["path"].as_str().unwrap_or("."); let max_results = args["max_results"].as_u64().unwrap_or(50) as usize; let output = tokio::process::Command::new("fd") .arg("--glob") .arg(pattern) .arg(path) .output() .await .map_err(|e| anyhow::anyhow!("Failed to run fd (try 'find' directly): {e}"))?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let results: Vec<&str> = stdout .lines() .take(max_results) .collect(); if results.is_empty() { Ok("(no files found)".to_string()) } else { Ok(format!( "Found {} file(s):\n{}", results.len(), results.join("\n") )) } } else { // Fallback to POSIX find let output2 = tokio::process::Command::new("find") .arg(path) .arg("-name") .arg(pattern) .output() .await .map_err(|e| anyhow::anyhow!("find also failed: {e}"))?; let stdout2 = String::from_utf8_lossy(&output2.stdout).to_string(); let results: Vec<&str> = stdout2 .lines() .take(max_results) .collect(); if results.is_empty() { Ok("(no files found)".to_string()) } else { Ok(format!( "Found {} file(s) (via find):\n{}", results.len(), results.join("\n") )) } } } } // ─── LsTool ───────────────────────────────────────────────────────────── /// List directory contents. pub struct LsTool; #[async_trait::async_trait] impl Tool for LsTool { fn name(&self) -> &str { "ls" } fn description(&self) -> &str { "List files and directories at a given path with size and modification time." } fn parameters(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "path": { "type": "string", "description": "Directory path to list", "default": "." }, "long": { "type": "boolean", "description": "Show detailed listing (permissions, size, date)", "default": false }, "all": { "type": "boolean", "description": "Show hidden files", "default": false } } }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { let dir_path = args["path"].as_str().unwrap_or("."); let long = args["long"].as_bool().unwrap_or(false); let all = args["all"].as_bool().unwrap_or(false); let mut cmd = tokio::process::Command::new("ls"); if long { cmd.arg("-lh"); } if all { cmd.arg("-A"); } cmd.arg(dir_path); let output = cmd .output() .await .map_err(|e| anyhow::anyhow!("Failed to list directory: {e}"))?; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } else { let stderr = String::from_utf8_lossy(&output.stderr); Err(anyhow::anyhow!("ls failed: {stderr}")) } } } // ─── Tests ────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; struct EchoTool; #[async_trait::async_trait] impl Tool for EchoTool { fn name(&self) -> &str { "echo" } fn description(&self) -> &str { "Echo back the input" } fn parameters(&self) -> serde_json::Value { serde_json::json!({ "type": "object", "properties": { "message": {"type": "string"} }, "required": ["message"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { Ok(args["message"].as_str().unwrap_or("").to_string()) } } #[tokio::test] async fn test_tool_registration_and_execution() { let mut executor = ToolExecutor::new(); executor.register(Box::new(EchoTool)); let defs = executor.definitions(); assert_eq!(defs.len(), 1); assert_eq!(defs[0].name, "echo"); let call = ToolCall { id: "1".to_string(), name: "echo".to_string(), arguments: serde_json::json!({"message": "hello"}), }; let result = executor.execute(&call).await.unwrap(); assert!(result.success); assert_eq!(result.output, "hello"); } #[tokio::test] async fn test_unknown_tool_returns_error() { let executor = ToolExecutor::new(); let call = ToolCall { id: "1".to_string(), name: "nonexistent".to_string(), arguments: serde_json::json!({}), }; let result = executor.execute(&call).await.unwrap(); assert!(!result.success); assert!(result.error.unwrap().contains("not available")); } #[test] fn test_builtin_tool_names() { for t in BuiltinTool::all() { assert!(!t.name().is_empty()); } } #[test] fn test_with_defaults_registers_seven_tools() { let executor = ToolExecutor::with_defaults(); assert_eq!(executor.len(), 7); assert!(executor.has_tool("read")); assert!(executor.has_tool("write")); assert!(executor.has_tool("edit")); assert!(executor.has_tool("bash")); assert!(executor.has_tool("grep")); assert!(executor.has_tool("find")); assert!(executor.has_tool("ls")); } #[tokio::test] async fn test_read_tool_basic() { // Create a temp file let tmp = std::env::temp_dir().join("gyandoot_test_read.txt"); tokio::fs::write(&tmp, "line1\nline2\nline3\n") .await .unwrap(); let tool = ReadTool; let result = tool .execute(serde_json::json!({ "file_path": tmp.to_str().unwrap(), "line_numbers": false })) .await .unwrap(); assert!(result.contains("line1")); assert!(result.contains("line2")); tokio::fs::remove_file(&tmp).await.ok(); } #[tokio::test] async fn test_write_tool_creates_file() { let tmp = std::env::temp_dir().join("gyandoot_test_write.txt"); let tool = WriteTool; let result = tool .execute(serde_json::json!({ "file_path": tmp.to_str().unwrap(), "content": "hello world" })) .await .unwrap(); assert!(result.contains("11 bytes")); assert!(tokio::fs::read_to_string(&tmp).await.unwrap() == "hello world"); tokio::fs::remove_file(&tmp).await.ok(); } #[tokio::test] async fn test_bash_tool_basic() { let tool = BashTool::default(); let result = tool .execute(serde_json::json!({"command": "echo hello"})) .await .unwrap(); assert!(result.contains("hello")); } #[tokio::test] async fn test_bash_tool_security_block() { let tool = BashTool::default(); let result = tool .execute(serde_json::json!({"command": "rm -rf /"})) .await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("blocked")); } #[tokio::test] async fn test_edit_tool() { let tmp = std::env::temp_dir().join("gyandoot_test_edit.txt"); tokio::fs::write(&tmp, "old content").await.unwrap(); let tool = EditTool; let result = tool .execute(serde_json::json!({ "file_path": tmp.to_str().unwrap(), "old_string": "old", "new_string": "new" })) .await .unwrap(); assert!(result.contains("1 replacement(s)")); assert_eq!( tokio::fs::read_to_string(&tmp).await.unwrap(), "new content" ); tokio::fs::remove_file(&tmp).await.ok(); } #[tokio::test] async fn test_ls_tool() { let tool = LsTool; let result = tool .execute(serde_json::json!({"path": "."})) .await .unwrap(); assert!(!result.is_empty()); } }