pub mod agent;
pub mod bash;
pub mod deagle;
pub mod edit;
#[cfg(test)]
mod edit_tests;
pub mod file;
pub mod git;
pub mod native;
pub mod search;
pub mod ares_bridge;
use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
pub use thulp_core::ToolDefinition;
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn mutating(&self) -> bool {
false }
fn parameters_schema(&self) -> Value;
async fn execute(&self, args: Value) -> crate::Result<Value>;
fn thulp_definition(&self) -> thulp_core::ToolDefinition {
let params = thulp_core::ToolDefinition::parse_mcp_input_schema(&self.parameters_schema())
.unwrap_or_default();
thulp_core::ToolDefinition::builder(self.name())
.description(self.description())
.parameters(params)
.build()
}
fn validate_args(&self, args: &Value) -> std::result::Result<(), String> {
self.thulp_definition()
.validate_args(args)
.map_err(|e| e.to_string())
}
fn to_definition(&self) -> ToolDefinition {
self.thulp_definition()
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ToolTier {
Core,
Standard,
Extended,
}
pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn Tool>>,
tiers: HashMap<String, ToolTier>,
activated: std::sync::Mutex<std::collections::HashSet<String>>,
tool_text_cache: HashMap<String, String>,
}
impl ToolRegistry {
pub fn new() -> Self {
Self {
tools: HashMap::new(),
tiers: HashMap::new(),
activated: std::sync::Mutex::new(std::collections::HashSet::new()),
tool_text_cache: HashMap::new(),
}
}
pub fn with_defaults(workspace_root: std::path::PathBuf) -> Self {
let mut registry = Self::new();
use ToolTier::*;
registry.register_with_tier(Arc::new(bash::BashTool::new(workspace_root.clone())), Core);
registry.register_with_tier(Arc::new(file::ReadFileTool::new(workspace_root.clone())), Core);
registry.register_with_tier(Arc::new(file::WriteFileTool::new(workspace_root.clone())), Core);
registry.register_with_tier(Arc::new(edit::EditFileTool::new(workspace_root.clone())), Core);
registry.register_with_tier(Arc::new(native::AstGrepTool::new(workspace_root.clone())), Core);
registry.register_with_tier(Arc::new(native::GlobSearchTool::new(workspace_root.clone())), Core);
registry.register_with_tier(Arc::new(native::GrepSearchTool::new(workspace_root.clone())), Core);
registry.register_with_tier(Arc::new(file::ListDirectoryTool::new(workspace_root.clone())), Standard);
registry.register_with_tier(Arc::new(edit::EditFileLinesTool::new(workspace_root.clone())), Standard);
registry.register_with_tier(Arc::new(edit::InsertAfterTool::new(workspace_root.clone())), Standard);
registry.register_with_tier(Arc::new(edit::AppendFileTool::new(workspace_root.clone())), Standard);
registry.register_with_tier(Arc::new(git::GitStatusTool::new(workspace_root.clone())), Standard);
registry.register_with_tier(Arc::new(git::GitDiffTool::new(workspace_root.clone())), Standard);
registry.register_with_tier(Arc::new(git::GitAddTool::new(workspace_root.clone())), Standard);
registry.register_with_tier(Arc::new(git::GitCommitTool::new(workspace_root.clone())), Standard);
registry.register_with_tier(Arc::new(git::GitLogTool::new(workspace_root.clone())), Standard);
registry.register_with_tier(Arc::new(git::GitBlameTool::new(workspace_root.clone())), Standard);
registry.register_with_tier(Arc::new(git::GitBranchTool::new(workspace_root.clone())), Standard);
registry.register_with_tier(Arc::new(git::GitCheckoutTool::new(workspace_root.clone())), Standard);
registry.register_with_tier(Arc::new(git::GitStashTool::new(workspace_root.clone())), Standard);
registry.register_with_tier(Arc::new(agent::SpawnAgentsTool::new(workspace_root.clone())), Standard);
registry.register_with_tier(Arc::new(agent::SpawnAgentTool::new(workspace_root.clone())), Standard);
registry.register_with_tier(Arc::new(native::RipgrepTool::new(workspace_root.clone())), Extended);
registry.register_with_tier(Arc::new(native::FdTool::new(workspace_root.clone())), Extended);
registry.register_with_tier(Arc::new(native::SdTool::new(workspace_root.clone())), Extended);
registry.register_with_tier(Arc::new(native::ErdTool::new(workspace_root.clone())), Extended);
registry.register_with_tier(Arc::new(native::MiseTool::new(workspace_root.clone())), Extended);
registry.register_with_tier(Arc::new(native::ZoxideTool::new(workspace_root.clone())), Extended);
registry.register_with_tier(Arc::new(native::LspTool::new(workspace_root.clone())), Extended);
registry.register_with_tier(Arc::new(deagle::DeagleSearchTool::new(workspace_root.clone())), Extended);
registry.register_with_tier(Arc::new(deagle::DeagleKeywordTool::new(workspace_root.clone())), Extended);
registry.register_with_tier(Arc::new(deagle::DeagleSgTool::new(workspace_root.clone())), Extended);
registry.register_with_tier(Arc::new(deagle::DeagleStatsTool::new(workspace_root.clone())), Extended);
registry.register_with_tier(Arc::new(deagle::DeagleMapTool::new(workspace_root)), Extended);
registry
}
pub fn register(&mut self, tool: Arc<dyn Tool>) {
self.register_with_tier(tool, ToolTier::Standard);
}
pub fn register_with_tier(&mut self, tool: Arc<dyn Tool>, tier: ToolTier) {
let name = tool.name().to_string();
let cached_text = format!("{} {}", name, tool.description()).to_lowercase();
self.tool_text_cache.insert(name.clone(), cached_text);
self.tiers.insert(name.clone(), tier);
self.tools.insert(name, tool);
}
pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
self.tools.get(name)
}
pub fn has_tool(&self, name: &str) -> bool {
self.tools.contains_key(name)
}
pub async fn execute(&self, name: &str, args: Value) -> crate::Result<Value> {
match self.tools.get(name) {
Some(tool) => tool.execute(args).await,
None => Err(crate::PawanError::NotFound(format!(
"Tool not found: {}",
name
))),
}
}
pub fn get_definitions(&self) -> Vec<ToolDefinition> {
let activated = self.activated.lock().unwrap_or_else(|e| e.into_inner());
self.tools.iter()
.filter(|(name, _)| {
match self.tiers.get(name.as_str()).copied().unwrap_or(ToolTier::Standard) {
ToolTier::Core | ToolTier::Standard => true,
ToolTier::Extended => activated.contains(name.as_str()),
}
})
.map(|(_, tool)| tool.to_definition())
.collect()
}
pub fn select_for_query(&self, query: &str, max_tools: usize) -> Vec<ToolDefinition> {
let query_lower = query.to_lowercase();
let query_words: Vec<&str> = query_lower.split_whitespace().collect();
let mut scored: Vec<(i32, String)> = Vec::new();
for name in self.tools.keys() {
let tier = self.tiers.get(name.as_str()).copied().unwrap_or(ToolTier::Standard);
if tier == ToolTier::Core { continue; }
let tool_text = self.tool_text_cache.get(name.as_str())
.map(|s| s.as_str())
.unwrap_or("");
let mut score: i32 = 0;
for word in &query_words {
if word.len() < 3 { continue; } if tool_text.contains(word) { score += 2; }
}
let search_words = ["search", "find", "web", "query", "look", "google", "bing", "wikipedia"];
let git_words = ["git", "commit", "branch", "diff", "status", "log", "stash", "checkout", "blame"];
let file_words = ["file", "read", "write", "edit", "append", "insert", "directory", "list"];
let code_words = ["refactor", "rename", "replace", "ast", "lsp", "symbol", "function", "struct"];
let tool_words = ["install", "mise", "tool", "runtime", "build", "test", "cargo"];
for word in &query_words {
if search_words.contains(word) && tool_text.contains("search") { score += 3; }
if git_words.contains(word) && tool_text.contains("git") { score += 3; }
if file_words.contains(word) && (tool_text.contains("file") || tool_text.contains("edit")) { score += 3; }
if code_words.contains(word) && (tool_text.contains("ast") || tool_text.contains("lsp")) { score += 3; }
if tool_words.contains(word) && tool_text.contains("mise") { score += 3; }
}
if name.starts_with("mcp_") {
score += 1;
if name.contains("search") || name.contains("web") {
let web_words = ["web", "search", "internet", "online", "find", "look up", "google"];
if web_words.iter().any(|w| query_lower.contains(w)) {
score += 10; }
}
}
let activated = self.activated.lock().unwrap_or_else(|e| e.into_inner());
if tier == ToolTier::Extended && activated.contains(name.as_str()) { score += 2; }
if score > 0 || tier == ToolTier::Standard {
scored.push((score, name.clone()));
}
}
scored.sort_by(|a, b| b.0.cmp(&a.0));
let mut result: Vec<ToolDefinition> = self.tools.iter()
.filter(|(name, _)| {
self.tiers.get(name.as_str()).copied().unwrap_or(ToolTier::Standard) == ToolTier::Core
})
.map(|(_, tool)| tool.to_definition())
.collect();
let remaining_slots = max_tools.saturating_sub(result.len());
for (_, name) in scored.into_iter().take(remaining_slots) {
if let Some(tool) = self.tools.get(&name) {
result.push(tool.to_definition());
}
}
result
}
pub fn get_all_definitions(&self) -> Vec<ToolDefinition> {
self.tools.values().map(|t| t.to_definition()).collect()
}
pub fn activate(&self, name: &str) {
if self.tools.contains_key(name) {
self.activated.lock().unwrap_or_else(|e| e.into_inner()).insert(name.to_string());
}
}
pub fn tool_names(&self) -> Vec<&str> {
self.tools.keys().map(|s| s.as_str()).collect()
}
pub fn query_tools(&self, query: &str) -> Vec<thulp_core::ToolDefinition> {
let criteria = match thulp_query::parse_query(query) {
Ok(c) => c,
Err(e) => {
tracing::warn!(query = %query, error = %e, "failed to parse tool query");
return Vec::new();
}
};
self.tools
.values()
.map(|tool| tool.thulp_definition())
.filter(|def| criteria.matches(def))
.collect()
}
}
impl Default for ToolRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_registry_new_is_empty() {
let registry = ToolRegistry::new();
assert!(registry.tool_names().is_empty());
assert!(!registry.has_tool("bash"));
assert!(registry.get("nonexistent").is_none());
}
#[test]
fn test_registry_with_defaults_contains_core_tools() {
let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
for name in &["bash", "read_file", "write_file", "edit_file", "grep_search", "glob_search"] {
assert!(
registry.has_tool(name),
"default registry missing core tool: {}",
name
);
}
assert!(registry.has_tool("git_status"));
assert!(registry.has_tool("git_commit"));
assert!(registry.has_tool("rg"));
assert!(registry.has_tool("fd"));
}
#[test]
fn test_registry_get_definitions_hides_extended_until_activated() {
let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
let initial: Vec<String> = registry
.get_definitions()
.iter()
.map(|d| d.name.clone())
.collect();
assert!(!initial.contains(&"rg".to_string()), "rg should be hidden until activated");
assert!(!initial.contains(&"fd".to_string()), "fd should be hidden until activated");
assert!(initial.contains(&"bash".to_string()));
assert!(initial.contains(&"read_file".to_string()));
registry.activate("rg");
let after: Vec<String> = registry
.get_definitions()
.iter()
.map(|d| d.name.clone())
.collect();
assert!(after.contains(&"rg".to_string()), "rg should be visible after activate");
assert!(after.len() > initial.len(), "activation should grow visible set");
}
#[test]
fn test_registry_get_all_definitions_returns_everything() {
let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
let all = registry.get_all_definitions();
let visible = registry.get_definitions();
assert!(
all.len() > visible.len(),
"get_all_definitions ({}) should include hidden extended tools beyond get_definitions ({})",
all.len(),
visible.len()
);
let all_names: Vec<String> = all.iter().map(|d| d.name.clone()).collect();
assert!(all_names.contains(&"rg".to_string()));
}
#[test]
fn test_registry_query_tools_filters_by_dsl() {
let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
let bash_match = registry.query_tools("name:bash");
assert!(
!bash_match.is_empty(),
"query_tools('name:bash') should match the bash tool"
);
let names: Vec<String> = bash_match.iter().map(|d| d.name.clone()).collect();
assert!(names.contains(&"bash".to_string()));
let no_match = registry.query_tools("name:definitely_not_a_tool_xyz");
assert!(
no_match.is_empty(),
"query_tools for nonexistent name should return empty, got {:?}",
no_match.iter().map(|d| &d.name).collect::<Vec<_>>()
);
}
struct MockTool {
name: String,
description: String,
return_value: Value,
}
impl MockTool {
fn new(name: &str, description: &str, return_value: Value) -> Self {
Self {
name: name.to_string(),
description: description.to_string(),
return_value,
}
}
}
#[async_trait]
impl Tool for MockTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn parameters_schema(&self) -> Value {
serde_json::json!({ "type": "object", "properties": {} })
}
async fn execute(&self, _args: Value) -> crate::Result<Value> {
Ok(self.return_value.clone())
}
}
#[test]
fn test_register_defaults_to_standard_tier() {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(MockTool::new(
"mock_std",
"a test mock",
Value::Null,
)));
let visible: Vec<String> = registry
.get_definitions()
.iter()
.map(|d| d.name.clone())
.collect();
assert!(
visible.contains(&"mock_std".to_string()),
"register() should default to Standard tier (visible without activation), got {:?}",
visible
);
}
#[test]
fn test_register_with_tier_overwrites_same_name() {
let mut registry = ToolRegistry::new();
registry.register_with_tier(
Arc::new(MockTool::new("dup", "first registration", Value::Null)),
ToolTier::Standard,
);
registry.register_with_tier(
Arc::new(MockTool::new("dup", "second registration", Value::Null)),
ToolTier::Core,
);
let names = registry.tool_names();
assert_eq!(
names.iter().filter(|n| **n == "dup").count(),
1,
"register_with_tier of an existing name must replace, not duplicate"
);
let def = registry.get("dup").expect("dup should exist after overwrite");
assert_eq!(def.description(), "second registration");
let visible: Vec<String> = registry
.get_definitions()
.iter()
.map(|d| d.name.clone())
.collect();
assert!(visible.contains(&"dup".to_string()));
}
#[tokio::test]
async fn test_execute_dispatches_to_registered_tool() {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(MockTool::new(
"echo",
"returns a fixed value",
serde_json::json!({ "answer": 42 }),
)));
let out = registry
.execute("echo", Value::Null)
.await
.expect("execute on a registered tool should succeed");
assert_eq!(out, serde_json::json!({ "answer": 42 }));
}
#[tokio::test]
async fn test_execute_unknown_tool_returns_not_found() {
let registry = ToolRegistry::new();
let err = registry
.execute("nonexistent_tool", Value::Null)
.await
.expect_err("execute on missing tool should fail");
match err {
crate::PawanError::NotFound(msg) => {
assert!(msg.contains("nonexistent_tool"), "error should name the missing tool, got: {}", msg);
}
other => panic!("expected PawanError::NotFound, got: {:?}", other),
}
}
#[test]
fn test_select_for_query_always_includes_core_tools() {
let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
let selected = registry.select_for_query("xyzzy plover", 5);
let names: Vec<String> = selected.iter().map(|d| d.name.clone()).collect();
for core in &["bash", "read_file", "write_file", "edit_file", "grep_search", "glob_search", "ast_grep"] {
assert!(
names.contains(&core.to_string()),
"select_for_query must include core tool {} regardless of query, got {:?}",
core,
names
);
}
}
#[test]
fn test_select_for_query_caps_at_max_tools_when_possible() {
let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
let selected = registry.select_for_query("git commit my changes", 10);
assert!(
selected.len() <= 10,
"select_for_query(max=10) returned {} tools, must not exceed cap",
selected.len()
);
let names: Vec<String> = selected.iter().map(|d| d.name.clone()).collect();
assert!(
names.iter().any(|n| n.starts_with("git_")),
"git query should pull in at least one git_ tool, got {:?}",
names
);
}
#[test]
fn test_activate_no_op_for_unknown_tool_does_not_panic() {
let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
registry.activate("not_a_real_tool_at_all");
let visible: Vec<String> = registry
.get_definitions()
.iter()
.map(|d| d.name.clone())
.collect();
assert!(
!visible.contains(&"not_a_real_tool_at_all".to_string()),
"activate of unknown tool must not make it visible"
);
}
#[test]
fn test_tool_names_lists_every_registered_tool() {
let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
let names = registry.tool_names();
assert!(
names.len() >= 30,
"default registry should expose >=30 tools via tool_names(), got {}",
names.len()
);
for name in &names {
assert!(registry.has_tool(name));
assert!(registry.get(name).is_some());
}
}
#[test]
fn test_default_impl_returns_empty_registry() {
let registry = ToolRegistry::default();
assert!(registry.tool_names().is_empty());
assert!(registry.get_definitions().is_empty());
}
}