#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum ToolEffect {
ReadOnly,
RemoteAction,
LocalMutation,
Destructive,
}
pub fn classify_tool(name: &str) -> ToolEffect {
match name {
"Read" | "List" | "Grep" | "Glob" | "MemoryRead" | "ListAgents" | "ListSkills"
| "ActivateSkill" | "RecallContext" | "AstAnalysis" => ToolEffect::ReadOnly,
"WebFetch" => ToolEffect::ReadOnly, "InvokeAgent" => ToolEffect::ReadOnly,
"Write" | "Edit" | "MemoryWrite" => ToolEffect::LocalMutation,
"Bash" => ToolEffect::LocalMutation,
"Delete" => ToolEffect::Destructive,
"EmailRead" | "EmailSearch" => ToolEffect::ReadOnly,
"EmailSend" => ToolEffect::RemoteAction,
_ => ToolEffect::LocalMutation,
}
}
pub fn is_mutating_tool(name: &str) -> bool {
!matches!(classify_tool(name), ToolEffect::ReadOnly)
}
pub mod agent;
pub mod file_tools;
pub mod glob_tool;
pub mod grep;
pub mod memory;
pub mod recall;
pub mod shell;
pub mod skill_tools;
pub mod web_fetch;
use anyhow::Result;
use path_clean::PathClean;
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
use crate::output_caps::OutputCaps;
macro_rules! require_email_config {
($self:ident) => {
match koda_email::config::EmailConfig::from_env() {
Ok(c) => c,
Err(e) => {
return ToolResult {
output: format!(
"Email not configured: {e:#}\n\n{}",
koda_email::config::EmailConfig::setup_instructions()
),
success: false,
};
}
}
};
}
use crate::providers::ToolDefinition;
pub type FileReadCache = Arc<std::sync::Mutex<HashMap<String, (u64, SystemTime)>>>;
#[derive(Debug, Clone)]
pub struct ToolResult {
pub output: String,
pub success: bool,
}
pub struct ToolRegistry {
project_root: PathBuf,
definitions: HashMap<String, ToolDefinition>,
read_cache: FileReadCache,
pub undo: std::sync::Mutex<crate::undo::UndoStack>,
pub skill_registry: crate::skills::SkillRegistry,
db: std::sync::RwLock<Option<std::sync::Arc<crate::db::Database>>>,
session_id: std::sync::RwLock<Option<String>>,
pub caps: OutputCaps,
}
impl ToolRegistry {
pub fn new(project_root: PathBuf, max_context_tokens: usize) -> Self {
let mut definitions = HashMap::new();
for def in file_tools::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in grep::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in shell::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in agent::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in glob_tool::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in web_fetch::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in memory::definitions() {
definitions.insert(def.name.clone(), def);
}
for def in skill_tools::definitions() {
definitions.insert(def.name.clone(), def);
}
let recall_def = recall::definition();
definitions.insert(recall_def.name.clone(), recall_def);
for td in koda_ast::tool_definitions() {
definitions.insert(
td.name.to_string(),
ToolDefinition {
name: td.name.to_string(),
description: td.description.to_string(),
parameters: serde_json::from_str(td.parameters_json).unwrap_or_default(),
},
);
}
for td in koda_email::tool_definitions() {
definitions.insert(
td.name.to_string(),
ToolDefinition {
name: td.name.to_string(),
description: td.description.to_string(),
parameters: serde_json::from_str(td.parameters_json).unwrap_or_default(),
},
);
}
let skill_registry = crate::skills::SkillRegistry::discover(&project_root);
Self {
project_root,
definitions,
read_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
undo: std::sync::Mutex::new(crate::undo::UndoStack::new()),
skill_registry,
db: std::sync::RwLock::new(None),
session_id: std::sync::RwLock::new(None),
caps: OutputCaps::for_context(max_context_tokens),
}
}
pub fn with_shared_cache(mut self, cache: FileReadCache) -> Self {
self.read_cache = cache;
self
}
pub fn file_read_cache(&self) -> FileReadCache {
Arc::clone(&self.read_cache)
}
pub fn set_session(&self, db: std::sync::Arc<crate::db::Database>, session_id: String) {
if let Ok(mut guard) = self.db.write() {
*guard = Some(db);
}
if let Ok(mut guard) = self.session_id.write() {
*guard = Some(session_id);
}
}
pub fn all_builtin_tool_names(&self) -> Vec<String> {
let mut names: Vec<String> = self.definitions.keys().cloned().collect();
names.sort();
names
}
pub fn has_tool(&self, name: &str) -> bool {
self.definitions.contains_key(name)
}
pub fn list_skills(&self) -> Vec<(String, String, String)> {
self.skill_registry
.list()
.into_iter()
.map(|m| {
let source = match m.source {
crate::skills::SkillSource::BuiltIn => "built-in",
crate::skills::SkillSource::User => "user",
crate::skills::SkillSource::Project => "project",
};
(m.name.clone(), m.description.clone(), source.to_string())
})
.collect()
}
pub fn search_skills(&self, query: &str) -> Vec<(String, String, String)> {
self.skill_registry
.search(query)
.into_iter()
.map(|m| {
let source = match m.source {
crate::skills::SkillSource::BuiltIn => "built-in",
crate::skills::SkillSource::User => "user",
crate::skills::SkillSource::Project => "project",
};
(m.name.clone(), m.description.clone(), source.to_string())
})
.collect()
}
pub fn get_definitions(&self, allowed: &[String]) -> Vec<ToolDefinition> {
if !allowed.is_empty() {
allowed
.iter()
.filter_map(|name| self.definitions.get(name).cloned())
.collect()
} else {
self.definitions.values().cloned().collect()
}
}
pub async fn execute(&self, name: &str, arguments: &str) -> ToolResult {
let args: Value = match serde_json::from_str(arguments) {
Ok(v) => v,
Err(e) => {
return ToolResult {
output: format!("Invalid JSON arguments: {e}"),
success: false,
};
}
};
tracing::info!(
"Executing tool: {name} with args: [{} chars]",
arguments.len()
);
if let Some(file_path) = crate::undo::is_mutating_tool(name)
.then(|| crate::undo::extract_file_path(name, &args))
.flatten()
{
let resolved = self.project_root.join(&file_path);
if let Ok(mut undo) = self.undo.lock() {
undo.snapshot(&resolved);
}
}
let result = match name {
"Read" => file_tools::read_file(&self.project_root, &args, &self.read_cache).await,
"Write" => file_tools::write_file(&self.project_root, &args).await,
"Edit" => file_tools::edit_file(&self.project_root, &args).await,
"Delete" => file_tools::delete_file(&self.project_root, &args).await,
"List" => {
file_tools::list_files(&self.project_root, &args, self.caps.list_entries).await
}
"Grep" => grep::grep(&self.project_root, &args, self.caps.grep_matches).await,
"Glob" => {
glob_tool::glob_search(&self.project_root, &args, self.caps.glob_results).await
}
"Bash" => {
shell::run_shell_command(&self.project_root, &args, self.caps.shell_output_lines)
.await
}
"WebFetch" => web_fetch::web_fetch(&args, self.caps.web_body_chars).await,
"MemoryRead" => memory::memory_read(&self.project_root).await,
"MemoryWrite" => memory::memory_write(&self.project_root, &args).await,
"ListAgents" => {
let detail = args["detail"].as_bool().unwrap_or(false);
if detail {
Ok(agent::list_agents_detail(&self.project_root))
} else {
let agents = agent::list_agents(&self.project_root);
if agents.is_empty() {
Ok("No sub-agents configured.".to_string())
} else {
let lines: Vec<String> = agents
.iter()
.map(|(name, desc, source)| {
if source == "built-in" {
format!(" {name} — {desc}")
} else {
format!(" {name} — {desc} [{source}]")
}
})
.collect();
Ok(lines.join("\n"))
}
}
}
"ListSkills" => Ok(skill_tools::list_skills(&self.skill_registry, &args)),
"ActivateSkill" => Ok(skill_tools::activate_skill(&self.skill_registry, &args)),
"RecallContext" => {
let db_opt = self.db.read().ok().and_then(|g| g.clone());
let sid_opt = self.session_id.read().ok().and_then(|g| g.clone());
if let (Some(db), Some(sid)) = (db_opt, sid_opt) {
Ok(recall::recall_context(&db, &sid, &args).await)
} else {
Ok("RecallContext requires an active session.".to_string())
}
}
"AstAnalysis" => {
let action = args["action"].as_str().unwrap_or("");
let file_path = args["file_path"].as_str().unwrap_or("");
let symbol = args["symbol"].as_str();
koda_ast::execute(&self.project_root, action, file_path, symbol)
.map_err(|e| anyhow::anyhow!(e))
}
"EmailRead" => {
let config = require_email_config!(self);
let count = args["count"].as_u64().unwrap_or(5).clamp(1, 20) as u32;
match koda_email::imap_client::read_emails(&config, count).await {
Ok(emails) if emails.is_empty() => Ok("No emails found in INBOX.".to_string()),
Ok(emails) => Ok(format_email_list(&emails)),
Err(e) => Err(anyhow::anyhow!("Error reading emails: {e:#}")),
}
}
"EmailSend" => {
let config = require_email_config!(self);
let to = args["to"].as_str().unwrap_or("");
let subject = args["subject"].as_str().unwrap_or("");
let body = args["body"].as_str().unwrap_or("");
koda_email::smtp_client::send_email(&config, to, subject, body)
.await
.map_err(|e| anyhow::anyhow!("Error sending email: {e:#}"))
}
"EmailSearch" => {
let config = require_email_config!(self);
let query = args["query"].as_str().unwrap_or("");
let max = args["max_results"].as_u64().unwrap_or(10).clamp(1, 50) as u32;
match koda_email::imap_client::search_emails(&config, query, max).await {
Ok(emails) if emails.is_empty() => {
Ok(format!("No emails found matching: {query}"))
}
Ok(emails) => Ok(format!(
"Found {} result(s) for \"{query}\":\n\n{}",
emails.len(),
format_email_list(&emails)
)),
Err(e) => Err(anyhow::anyhow!("Error searching emails: {e:#}")),
}
}
"InvokeAgent" => {
return ToolResult {
output: "InvokeAgent is handled by the inference loop.".to_string(),
success: false,
};
}
other => Err(anyhow::anyhow!("Unknown tool: {other}")),
};
match result {
Ok(output) => ToolResult {
output,
success: true,
},
Err(e) => ToolResult {
output: format!("Error: {e}"),
success: false,
},
}
}
}
pub fn safe_resolve_path(project_root: &Path, requested: &str) -> Result<PathBuf> {
let requested_path = Path::new(requested);
let resolved = if requested_path.is_absolute() {
requested_path.to_path_buf().clean()
} else {
project_root.join(requested_path).clean()
};
if !resolved.starts_with(project_root) {
anyhow::bail!(
"Path escapes project root. Requested: {requested:?}, Resolved: {resolved:?}"
);
}
Ok(resolved)
}
fn format_email_list(emails: &[koda_email::imap_client::EmailSummary]) -> String {
emails
.iter()
.enumerate()
.map(|(i, e)| {
format!(
"{}. [{}] {}\n From: {}\n Date: {}\n {}\n",
i + 1,
e.uid,
e.subject,
e.from,
e.date,
if e.snippet.is_empty() {
"(no preview)"
} else {
&e.snippet
}
)
})
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn root() -> PathBuf {
PathBuf::from("/home/user/project")
}
#[test]
fn test_relative_path_resolves_inside_root() {
let result = safe_resolve_path(&root(), "src/main.rs").unwrap();
assert_eq!(result, PathBuf::from("/home/user/project/src/main.rs"));
}
#[test]
fn test_dot_path_resolves_to_root() {
let result = safe_resolve_path(&root(), ".").unwrap();
assert_eq!(result, PathBuf::from("/home/user/project"));
}
#[test]
fn test_new_file_in_new_dir_resolves() {
let result = safe_resolve_path(&root(), "src/brand_new/feature.rs").unwrap();
assert_eq!(
result,
PathBuf::from("/home/user/project/src/brand_new/feature.rs")
);
}
#[test]
fn test_dotdot_traversal_blocked() {
let result = safe_resolve_path(&root(), "../../etc/passwd");
assert!(result.is_err());
}
#[test]
fn test_dotdot_sneaky_traversal_blocked() {
let result = safe_resolve_path(&root(), "src/../../etc/passwd");
assert!(result.is_err());
}
#[test]
fn test_absolute_path_inside_root_allowed() {
let result = safe_resolve_path(&root(), "/home/user/project/src/lib.rs").unwrap();
assert_eq!(result, PathBuf::from("/home/user/project/src/lib.rs"));
}
#[test]
fn test_absolute_path_outside_root_blocked() {
let result = safe_resolve_path(&root(), "/etc/shadow");
assert!(result.is_err());
}
#[test]
fn test_empty_path_resolves_to_root() {
let result = safe_resolve_path(&root(), "").unwrap();
assert_eq!(result, PathBuf::from("/home/user/project"));
}
}
pub fn describe_action(tool_name: &str, args: &serde_json::Value) -> String {
match tool_name {
"Bash" => {
let cmd = args
.get("command")
.or(args.get("cmd"))
.and_then(|v| v.as_str())
.unwrap_or("?");
cmd.to_string()
}
"Delete" => {
let path = args
.get("file_path")
.or(args.get("path"))
.and_then(|v| v.as_str())
.unwrap_or("?");
let recursive = args
.get("recursive")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if recursive {
format!("Delete directory (recursive): {path}")
} else {
format!("Delete: {path}")
}
}
"Write" => {
let path = args
.get("path")
.or(args.get("file_path"))
.and_then(|v| v.as_str())
.unwrap_or("?");
let overwrite = args
.get("overwrite")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if overwrite {
format!("Overwrite file: {path}")
} else {
format!("Create file: {path}")
}
}
"Edit" => {
let path = if let Some(payload) = args.get("payload") {
payload
.get("file_path")
.or(payload.get("path"))
.and_then(|v| v.as_str())
.unwrap_or("?")
} else {
args.get("file_path")
.or(args.get("path"))
.and_then(|v| v.as_str())
.unwrap_or("?")
};
format!("Edit file: {path}")
}
"WebFetch" => {
let url = args.get("url").and_then(|v| v.as_str()).unwrap_or("?");
format!("Fetch URL: {url}")
}
"AstAnalysis" => {
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("?");
let file = args
.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("?");
format!("AST {action}: {file}")
}
"EmailSend" => {
let to = args.get("to").and_then(|v| v.as_str()).unwrap_or("?");
let subject = args.get("subject").and_then(|v| v.as_str()).unwrap_or("?");
format!("Send email to {to}: {subject}")
}
_ => format!("Execute: {tool_name}"),
}
}
#[cfg(test)]
mod describe_action_tests {
use super::*;
use serde_json::json;
#[test]
fn test_describe_bash() {
let desc = describe_action("Bash", &json!({"command": "cargo build"}));
assert!(desc.contains("cargo build"));
}
#[test]
fn test_describe_delete() {
let desc = describe_action("Delete", &json!({"file_path": "old.rs"}));
assert!(desc.contains("old.rs"));
}
#[test]
fn test_describe_edit() {
let desc = describe_action("Edit", &json!({"payload": {"file_path": "src/main.rs"}}));
assert!(desc.contains("src/main.rs"));
}
#[test]
fn test_describe_write() {
let desc = describe_action("Write", &json!({"path": "new.rs"}));
assert!(desc.contains("Create file"));
assert!(desc.contains("new.rs"));
}
#[test]
fn test_describe_write_overwrite() {
let desc = describe_action("Write", &json!({"path": "x.rs", "overwrite": true}));
assert!(desc.contains("Overwrite"));
}
}