Skip to main content

pawan/tools/
mod.rs

1//! Tools for Pawan agent
2//!
3//! This module provides all the tools that Pawan can use to interact with
4//! the filesystem, execute commands, and perform coding operations.
5//!
6//! Native tools (rg, fd, sd, erd, mise) are thin wrappers over CLI binaries
7//! that provide structured JSON output and auto-install hints.
8
9pub mod agent;
10pub mod bash;
11pub mod edit;
12#[cfg(test)]
13mod edit_tests;
14pub mod file;
15pub mod git;
16pub mod native;
17pub mod search;
18
19#[cfg(feature = "ares")]
20pub mod ares_bridge;
21
22use async_trait::async_trait;
23use serde_json::Value;
24use std::collections::HashMap;
25use std::sync::Arc;
26
27/// Tool definition for LLM
28#[derive(Debug, Clone)]
29pub struct ToolDefinition {
30    /// Tool name
31    pub name: String,
32    /// Tool description
33    pub description: String,
34    /// JSON Schema for parameters
35    pub parameters: Value,
36}
37
38/// Trait for implementing tools
39#[async_trait]
40pub trait Tool: Send + Sync {
41    /// Returns the unique name of this tool
42    fn name(&self) -> &str;
43
44    /// Returns a description of what this tool does
45    fn description(&self) -> &str;
46
47    /// Returns the JSON schema for this tool's parameters
48    fn parameters_schema(&self) -> Value;
49
50    /// Executes the tool with the given arguments
51    async fn execute(&self, args: Value) -> crate::Result<Value>;
52
53    /// Returns a thulp-core ToolDefinition with typed parameters for validation.
54    /// Override in tools that use Parameter::builder() for rich validation.
55    /// Default: parses JSON schema back into thulp Parameters (best-effort).
56    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
57        let params = thulp_core::ToolDefinition::parse_mcp_input_schema(&self.parameters_schema())
58            .unwrap_or_default();
59        thulp_core::ToolDefinition::builder(self.name())
60            .description(self.description())
61            .parameters(params)
62            .build()
63    }
64
65    /// Validate arguments using thulp-core typed parameters.
66    /// Returns Ok(()) or an error describing which params are wrong/missing.
67    fn validate_args(&self, args: &Value) -> std::result::Result<(), String> {
68        self.thulp_definition()
69            .validate_args(args)
70            .map_err(|e| e.to_string())
71    }
72
73    /// Convert to ToolDefinition
74    fn to_definition(&self) -> ToolDefinition {
75        ToolDefinition {
76            name: self.name().to_string(),
77            description: self.description().to_string(),
78            parameters: self.parameters_schema(),
79        }
80    }
81}
82
83/// Tool tier — controls which tools are sent to the LLM in the prompt.
84/// All tools remain executable regardless of tier; tier only affects
85/// which tool definitions appear in the LLM system prompt.
86#[derive(Debug, Clone, Copy, PartialEq)]
87pub enum ToolTier {
88    /// Always sent to LLM — core file ops, bash, ast-grep
89    Core,
90    /// Sent to LLM by default — git, search, agent
91    Standard,
92    /// Only sent when explicitly requested or after first use — mise, tree, zoxide, sd, ripgrep, fd
93    Extended,
94}
95
96/// Registry for managing tools with tiered visibility.
97///
98/// All tools are always executable. Tier controls which definitions
99/// are sent to the LLM to save prompt tokens on simple tasks.
100pub struct ToolRegistry {
101    tools: HashMap<String, Arc<dyn Tool>>,
102    tiers: HashMap<String, ToolTier>,
103    /// Extended tools that have been activated (promoted to visible)
104    activated: std::sync::Mutex<std::collections::HashSet<String>>,
105    /// Precomputed lowercased "name description" for each tool (avoids per-query allocation)
106    tool_text_cache: HashMap<String, String>,
107}
108
109impl ToolRegistry {
110    /// Create a new empty registry
111    pub fn new() -> Self {
112        Self {
113            tools: HashMap::new(),
114            tiers: HashMap::new(),
115            activated: std::sync::Mutex::new(std::collections::HashSet::new()),
116            tool_text_cache: HashMap::new(),
117        }
118    }
119
120    /// Create a registry with all default tools, assigned to tiers.
121    ///
122    /// Core (always in LLM prompt): bash, read/write/edit, ast_grep, glob/grep
123    /// Standard (in prompt by default): git, agents
124    /// Extended (in prompt after first use): ripgrep, fd, sd, erd, mise, zoxide
125    pub fn with_defaults(workspace_root: std::path::PathBuf) -> Self {
126        let mut registry = Self::new();
127        use ToolTier::*;
128
129        // ── Core tier: always visible to LLM ──
130        registry.register_with_tier(Arc::new(bash::BashTool::new(workspace_root.clone())), Core);
131        registry.register_with_tier(Arc::new(file::ReadFileTool::new(workspace_root.clone())), Core);
132        registry.register_with_tier(Arc::new(file::WriteFileTool::new(workspace_root.clone())), Core);
133        registry.register_with_tier(Arc::new(edit::EditFileTool::new(workspace_root.clone())), Core);
134        registry.register_with_tier(Arc::new(native::AstGrepTool::new(workspace_root.clone())), Core);
135        registry.register_with_tier(Arc::new(native::GlobSearchTool::new(workspace_root.clone())), Core);
136        registry.register_with_tier(Arc::new(native::GrepSearchTool::new(workspace_root.clone())), Core);
137
138        // ── Standard tier: visible by default ──
139        registry.register_with_tier(Arc::new(file::ListDirectoryTool::new(workspace_root.clone())), Standard);
140        registry.register_with_tier(Arc::new(edit::EditFileLinesTool::new(workspace_root.clone())), Standard);
141        registry.register_with_tier(Arc::new(edit::InsertAfterTool::new(workspace_root.clone())), Standard);
142        registry.register_with_tier(Arc::new(edit::AppendFileTool::new(workspace_root.clone())), Standard);
143        registry.register_with_tier(Arc::new(git::GitStatusTool::new(workspace_root.clone())), Standard);
144        registry.register_with_tier(Arc::new(git::GitDiffTool::new(workspace_root.clone())), Standard);
145        registry.register_with_tier(Arc::new(git::GitAddTool::new(workspace_root.clone())), Standard);
146        registry.register_with_tier(Arc::new(git::GitCommitTool::new(workspace_root.clone())), Standard);
147        registry.register_with_tier(Arc::new(git::GitLogTool::new(workspace_root.clone())), Standard);
148        registry.register_with_tier(Arc::new(git::GitBlameTool::new(workspace_root.clone())), Standard);
149        registry.register_with_tier(Arc::new(git::GitBranchTool::new(workspace_root.clone())), Standard);
150        registry.register_with_tier(Arc::new(git::GitCheckoutTool::new(workspace_root.clone())), Standard);
151        registry.register_with_tier(Arc::new(git::GitStashTool::new(workspace_root.clone())), Standard);
152        registry.register_with_tier(Arc::new(agent::SpawnAgentsTool::new(workspace_root.clone())), Standard);
153        registry.register_with_tier(Arc::new(agent::SpawnAgentTool::new(workspace_root.clone())), Standard);
154
155        // ── Extended tier: hidden until first use ──
156        registry.register_with_tier(Arc::new(native::RipgrepTool::new(workspace_root.clone())), Extended);
157        registry.register_with_tier(Arc::new(native::FdTool::new(workspace_root.clone())), Extended);
158        registry.register_with_tier(Arc::new(native::SdTool::new(workspace_root.clone())), Extended);
159        registry.register_with_tier(Arc::new(native::ErdTool::new(workspace_root.clone())), Extended);
160        registry.register_with_tier(Arc::new(native::MiseTool::new(workspace_root.clone())), Extended);
161        registry.register_with_tier(Arc::new(native::ZoxideTool::new(workspace_root.clone())), Extended);
162        registry.register_with_tier(Arc::new(native::LspTool::new(workspace_root)), Extended);
163
164        registry
165    }
166
167    /// Register a tool at Standard tier (default)
168    pub fn register(&mut self, tool: Arc<dyn Tool>) {
169        self.register_with_tier(tool, ToolTier::Standard);
170    }
171
172    /// Register a tool at a specific tier
173    pub fn register_with_tier(&mut self, tool: Arc<dyn Tool>, tier: ToolTier) {
174        let name = tool.name().to_string();
175        let cached_text = format!("{} {}", name, tool.description()).to_lowercase();
176        self.tool_text_cache.insert(name.clone(), cached_text);
177        self.tiers.insert(name.clone(), tier);
178        self.tools.insert(name, tool);
179    }
180
181    /// Get a tool by name
182    pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
183        self.tools.get(name)
184    }
185
186    /// Check if a tool exists
187    pub fn has_tool(&self, name: &str) -> bool {
188        self.tools.contains_key(name)
189    }
190
191    /// Execute a tool by name
192    pub async fn execute(&self, name: &str, args: Value) -> crate::Result<Value> {
193        match self.tools.get(name) {
194            Some(tool) => tool.execute(args).await,
195            None => Err(crate::PawanError::NotFound(format!(
196                "Tool not found: {}",
197                name
198            ))),
199        }
200    }
201
202    /// Get tool definitions visible to the LLM (Core + Standard + activated Extended).
203    /// Extended tools become visible after first use or explicit activation.
204    pub fn get_definitions(&self) -> Vec<ToolDefinition> {
205        let activated = self.activated.lock().unwrap_or_else(|e| e.into_inner());
206        self.tools.iter()
207            .filter(|(name, _)| {
208                match self.tiers.get(name.as_str()).copied().unwrap_or(ToolTier::Standard) {
209                    ToolTier::Core | ToolTier::Standard => true,
210                    ToolTier::Extended => activated.contains(name.as_str()),
211                }
212            })
213            .map(|(_, tool)| tool.to_definition())
214            .collect()
215    }
216
217    /// Dynamic tool selection — pick the most relevant tools for a given query.
218    ///
219    /// Returns Core tools (always) + top-K scored Standard/Extended tools based
220    /// on keyword matching between the query and tool names/descriptions.
221    /// This reduces 22+ tools to ~8-10, making MCP and extended tools visible.
222    pub fn select_for_query(&self, query: &str, max_tools: usize) -> Vec<ToolDefinition> {
223        let query_lower = query.to_lowercase();
224        let query_words: Vec<&str> = query_lower.split_whitespace().collect();
225
226        let mut scored: Vec<(i32, String)> = Vec::new();
227
228        for name in self.tools.keys() {
229            let tier = self.tiers.get(name.as_str()).copied().unwrap_or(ToolTier::Standard);
230
231            // Core tools always included — skip scoring
232            if tier == ToolTier::Core { continue; }
233
234            // Score based on keyword overlap — use precomputed cache
235            let tool_text = self.tool_text_cache.get(name.as_str())
236                .map(|s| s.as_str())
237                .unwrap_or("");
238            let mut score: i32 = 0;
239
240            for word in &query_words {
241                if word.len() < 3 { continue; } // skip short words
242                if tool_text.contains(word) { score += 2; }
243            }
244
245            // Bonus for keyword categories
246            let search_words = ["search", "find", "web", "query", "look", "google", "bing", "wikipedia"];
247            let git_words = ["git", "commit", "branch", "diff", "status", "log", "stash", "checkout", "blame"];
248            let file_words = ["file", "read", "write", "edit", "append", "insert", "directory", "list"];
249            let code_words = ["refactor", "rename", "replace", "ast", "lsp", "symbol", "function", "struct"];
250            let tool_words = ["install", "mise", "tool", "runtime", "build", "test", "cargo"];
251
252            for word in &query_words {
253                if search_words.contains(word) && tool_text.contains("search") { score += 3; }
254                if git_words.contains(word) && tool_text.contains("git") { score += 3; }
255                if file_words.contains(word) && (tool_text.contains("file") || tool_text.contains("edit")) { score += 3; }
256                if code_words.contains(word) && (tool_text.contains("ast") || tool_text.contains("lsp")) { score += 3; }
257                if tool_words.contains(word) && tool_text.contains("mise") { score += 3; }
258            }
259
260            // MCP tools get a boost — especially web search when query mentions web/internet/online
261            if name.starts_with("mcp_") {
262                score += 1;
263                if name.contains("search") || name.contains("web") {
264                    let web_words = ["web", "search", "internet", "online", "find", "look up", "google"];
265                    if web_words.iter().any(|w| query_lower.contains(w)) {
266                        score += 10; // Strong boost — this is what the user wants
267                    }
268                }
269            }
270
271            // Activated extended tools get a boost (user has used them before)
272            let activated = self.activated.lock().unwrap_or_else(|e| e.into_inner());
273            if tier == ToolTier::Extended && activated.contains(name.as_str()) { score += 2; }
274
275            if score > 0 || tier == ToolTier::Standard {
276                scored.push((score, name.clone()));
277            }
278        }
279
280        // Sort by score descending
281        scored.sort_by(|a, b| b.0.cmp(&a.0));
282
283        // Collect: all Core tools + top-K scored tools
284        let mut result: Vec<ToolDefinition> = self.tools.iter()
285            .filter(|(name, _)| {
286                self.tiers.get(name.as_str()).copied().unwrap_or(ToolTier::Standard) == ToolTier::Core
287            })
288            .map(|(_, tool)| tool.to_definition())
289            .collect();
290
291        let remaining_slots = max_tools.saturating_sub(result.len());
292        for (_, name) in scored.into_iter().take(remaining_slots) {
293            if let Some(tool) = self.tools.get(&name) {
294                result.push(tool.to_definition());
295            }
296        }
297
298        result
299    }
300
301    /// Get ALL tool definitions regardless of tier (for tests and introspection)
302    pub fn get_all_definitions(&self) -> Vec<ToolDefinition> {
303        self.tools.values().map(|t| t.to_definition()).collect()
304    }
305
306    /// Activate an extended tool (makes it visible to the LLM)
307    pub fn activate(&self, name: &str) {
308        if self.tools.contains_key(name) {
309            self.activated.lock().unwrap_or_else(|e| e.into_inner()).insert(name.to_string());
310        }
311    }
312
313    /// Get tool names
314    pub fn tool_names(&self) -> Vec<&str> {
315        self.tools.keys().map(|s| s.as_str()).collect()
316    }
317}
318
319impl Default for ToolRegistry {
320    fn default() -> Self {
321        Self::new()
322    }
323}