1. /quit and /help commands restored: - /quit: exits the interactive loop - /help: shows formatted help box with available commands & tips - Unknown commands get a friendly error pointing to /help - Slash commands intercepted before LLM processing (no token waste) 2. Zero warnings across entire workspace (13 files fixed): - Fixed ambiguous glob re-exports in gyandoot-core - Added #[allow(dead_code)] on intentional dead fields - Fixed unused imports, variables, and macros - Fixed unused assignments in subagents - cleaned up service.rs, profile, kanban warnings install.sh now produces a completely clean build with zero warnings.
978 lines
32 KiB
Rust
978 lines
32 KiB
Rust
//! 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<String>;
|
|
}
|
|
|
|
// ─── 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<String, Box<dyn Tool + Send + Sync>>,
|
|
}
|
|
|
|
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<dyn Tool + Send + Sync>) {
|
|
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<ToolDefinition> {
|
|
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<ToolResult> {
|
|
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<String> {
|
|
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::<Vec<_>>()
|
|
.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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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());
|
|
}
|
|
} |