//! Tool trait, execution context, and the registry of all built-in tools. //! //! This module defines the core `Tool` trait that every agent-invocable tool //! must implement, the shared `ToolCtx` execution context passed to every tool //! invocation, and utility functions for path resolution, command execution, //! argument extraction, and edit-log persistence. use crate::utils::CastOr; use anyhow::Result; use serde_json::Value; use sha2::Digest; use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; pub mod bash_tools; pub mod fs; pub mod git; pub mod lsp; pub mod memory; pub mod plan; pub mod search; pub mod sequential_think; pub mod shell; pub mod shell_filter; pub mod spawn; pub mod utility; pub mod workflow; pub use git::git_cred; pub use git::git_operator; pub use git::git_worktree; /// Common interface every agent-invocable tool implements. pub trait Tool: Send + Sync { fn name(&self) -> &'static str; fn description(&self) -> &'static str; fn parameters(&self) -> Value; fn run(&self, ctx: &ToolCtx, args: &Value) -> Result; } /// A project-defined rule that flags a matching file path or content pattern /// for review. #[derive(Debug, Clone)] pub struct GraduatedCheck { pub name: String, pub pattern: String, pub rule: String, } /// Shared execution context passed to every `Tool::run` call: workspace roots, /// session paths, cached directory state, and workflow-level findings sharing. #[derive(Clone)] pub struct ToolCtx { pub workspaces: Vec, pub session_dir: PathBuf, pub memory_dir: PathBuf, pub worktrees_dir: PathBuf, pub dir_cache: Arc>, pub mention_index: crate::MentionIndex, pub origin: crate::Origin, pub graduated_checks: Vec, pub lsp_manager: Arc>, pub turn_events: Option>>>, pub workflow_findings: Option>>>, pub abort_flag: Option>, } impl ToolCtx { pub fn builder() -> ToolCtxBuilder { ToolCtxBuilder::default() } } /// Builder for `ToolCtx`; fields default to empty paths and a fresh `DirCache`. #[derive(Clone)] pub struct ToolCtxBuilder { pub workspaces: Vec, pub session_dir: PathBuf, pub memory_dir: PathBuf, pub worktrees_dir: PathBuf, pub dir_cache: Arc>, pub mention_index: crate::MentionIndex, pub origin: crate::Origin, pub graduated_checks: Vec, pub lsp_manager: Arc>, pub turn_events: Option>>>, pub workflow_findings: Option>>>, pub abort_flag: Option>, } impl Default for ToolCtxBuilder { fn default() -> Self { ToolCtxBuilder { workspaces: Vec::new(), session_dir: PathBuf::new(), memory_dir: PathBuf::new(), worktrees_dir: PathBuf::new(), dir_cache: Arc::new(tokio::sync::RwLock::new(crate::DirCache::new())), mention_index: crate::MentionIndex::new(), origin: crate::Origin::Main, graduated_checks: Vec::new(), lsp_manager: Arc::new(Mutex::new(crate::lsp::manager::LspManager::new())), turn_events: None, workflow_findings: None, abort_flag: None, } } } impl ToolCtxBuilder { pub fn session_dir(mut self, v: PathBuf) -> Self { self.session_dir = v; self } pub fn workspaces(mut self, v: Vec) -> Self { self.workspaces = v; self } pub fn origin(mut self, v: crate::Origin) -> Self { self.origin = v; self } pub fn turn_events( mut self, v: Arc>>, ) -> Self { self.turn_events = Some(v); self } pub fn workflow_findings(mut self, v: Option>>>) -> Self { self.workflow_findings = v; self } pub fn build(self) -> ToolCtx { ToolCtx { workspaces: self.workspaces, session_dir: self.session_dir, memory_dir: self.memory_dir, worktrees_dir: self.worktrees_dir, dir_cache: self.dir_cache, mention_index: self.mention_index, origin: self.origin, graduated_checks: self.graduated_checks, lsp_manager: self.lsp_manager, turn_events: self.turn_events, workflow_findings: self.workflow_findings, abort_flag: self.abort_flag, } } } /// Check which graduated checks apply to a given file path/content pair. pub fn check_graduated_checks(path: &str, content: &str, checks: &[GraduatedCheck]) -> Vec { let mut matches = Vec::new(); for check in checks { if path.contains(&check.pattern) || content.contains(&check.rule) { matches.push(check.name.clone()); } } matches } /// Construct one instance of every built-in tool. pub fn all_tools() -> Vec> { vec![ Box::new(fs::read::Read), Box::new(fs::write::Write), Box::new(fs::edit::Edit), Box::new(fs::delete::Delete), Box::new(search::Grep), Box::new(search::Glob), Box::new(bash_tools::BashOutput), Box::new(bash_tools::BashKill), Box::new(shell::Bash), Box::new(git_operator::GitOperator), Box::new(git_worktree::GitWorktree), Box::new(git_cred::GitCred), Box::new(sequential_think::SeqThink), Box::new(plan::PlanEnter), Box::new(plan::PlanReady), Box::new(workflow::WorkflowRun), Box::new(workflow::NoteFinding), Box::new(workflow::ReadFindings), Box::new(workflow::HiveMind), Box::new(spawn::SpawnAgents), Box::new(spawn::SpawnPipeline), Box::new(memory::remember::Remember), Box::new(memory::forget::Forget), Box::new(memory::recall::Recall), Box::new(utility::cd::Cd), Box::new(utility::dir_list::DirList), Box::new(utility::dir_cache_update::DirCacheUpdate), Box::new(utility::pong::Pong), Box::new(utility::todowrite::Todowrite), Box::new(utility::todofinish::Todofinish), Box::new(lsp::LspConnect), Box::new(lsp::LspDiagnostics), Box::new(lsp::LspHover), Box::new(lsp::LspCompletion), Box::new(lsp::LspDefinition), Box::new(lsp::LspReferences), Box::new(lsp::LspDisconnect), ] } /// Whether a tool by name can mutate the filesystem or run arbitrary shell commands. pub fn tool_is_risky(name: &str) -> bool { matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator") } /// Extract a required string argument from a JSON args map. pub fn arg_str(args: &Value, name: &str) -> Result { args.get(name) .and_then(|v| v.as_str()) .map(std::string::ToString::to_string) .ok_or_else(|| anyhow::anyhow!("missing required argument: {name}")) } /// Execute a `std::process::Command` and return its combined stdout/stderr. pub fn execute_cmd(cmd: &mut std::process::Command) -> Result { let output = cmd .output() .map_err(|e| anyhow::anyhow!("command execution failed: {e}"))?; let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) .trim() .to_string() }; if output.status.success() { Ok(combined) } else { let code = output.status.code().unwrap_or(-1); anyhow::bail!("command failed with exit code {code}:\n{combined}") } } /// Resolve a tool-supplied relative path to an absolute path within a workspace /// root, rejecting escapes. pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result { let (ws_idx, path) = if rel.starts_with('[') { let close = rel .find(']') .ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?; let idx: usize = rel[1..close] .parse() .map_err(|_| anyhow::anyhow!("invalid workspace index"))?; (idx, &rel[close + 1..]) } else { (0, rel) }; let base = workspaces .get(ws_idx) .ok_or_else(|| anyhow::anyhow!("workspace index {ws_idx} out of range"))?; let abs = if path.is_empty() { base.clone() } else { base.join(path) }; let canon = if let Ok(c) = abs.canonicalize() { c } else { let base_canon = workspaces .iter() .find_map(|w| w.canonicalize().ok()) .unwrap_or_else(|| base.clone()); let mut resolved = base_canon.clone(); if let Ok(rel_components) = abs.strip_prefix(&base_canon) { for comp in rel_components.components() { match comp { std::path::Component::ParentDir => { resolved.pop(); } std::path::Component::CurDir => {} c => resolved.push(c), } } } resolved }; if workspaces.iter().any(|w| canon.starts_with(w)) { Ok(canon) } else { anyhow::bail!("path '{rel}' is outside all workspace roots") } } /// After a successful write/edit tool run, compute content hash and byte /// delta, then persist an `EditLogEntry` to the session's edit log. pub fn log_write_edit_tool( args: &serde_json::Value, tool_name: &str, origin_tag: &str, session_dir: &std::path::Path, session_id: &str, ) { let reason = args .get("reason") .and_then(|v| v.as_str()) .unwrap_or("unnamed"); let path = args .get("path") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let content = args.get("content").or_else(|| args.get("new")); let content_str = content.and_then(|v| v.as_str()).unwrap_or(""); let content_sha256 = hex::encode(sha2::Sha256::digest(content_str.as_bytes())); let bytes_delta = if tool_name == "write" { content_str.len().cast_or(0i64) } else { let old = args.get("old").and_then(|v| v.as_str()).unwrap_or(""); let new = args.get("new").and_then(|v| v.as_str()).unwrap_or(""); let new_len: i64 = new.len().cast_or(0i64); let old_len: i64 = old.len().cast_or(0i64); (new_len - old_len).abs() }; let entry = zesdex_domain::cms::EditLogEntry { ts: chrono::Utc::now().timestamp_millis(), tool: tool_name.to_string(), path: path.to_string(), reason: reason.to_string(), content_sha256, bytes_delta, origin: origin_tag.to_string(), session_id: session_id.to_string(), }; use zesdex_domain::cms::repository::EditLogRepository; let repo = crate::persistence::cms::edit_log_repo::JsonlEditLogRepository::new(); if let Ok(mut el) = repo.open(session_dir) { let _ = repo.append(session_dir, &mut el, entry); } } /// Convert a list of tools into provider-facing `ToolDef` request schema. pub fn tool_defs(tools: &[Box]) -> Vec { tools .iter() .map(|t| zesdex_domain::core::ToolDef { type_: "function".to_string(), function: zesdex_domain::core::ToolFunctionDef { name: t.name().to_string(), description: t.description().to_string(), parameters: t.parameters(), }, }) .collect() }