Files
zesdex/apps/infrastructure/src/tools/mod.rs
T

354 lines
12 KiB
Rust

//! 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<String>;
}
/// 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<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
pub mention_index: crate::MentionIndex,
pub origin: crate::Origin,
pub graduated_checks: Vec<GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
pub turn_events:
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
pub abort_flag: Option<Arc<AtomicBool>>,
}
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<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
pub mention_index: crate::MentionIndex,
pub origin: crate::Origin,
pub graduated_checks: Vec<GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
pub turn_events:
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
pub abort_flag: Option<Arc<AtomicBool>>,
}
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<PathBuf>) -> 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<Mutex<std::collections::VecDeque<crate::TurnEvent>>>,
) -> Self {
self.turn_events = Some(v);
self
}
pub fn workflow_findings(mut self, v: Option<Arc<Mutex<Vec<String>>>>) -> 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<String> {
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<Box<dyn Tool>> {
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<String> {
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<String> {
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<PathBuf> {
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<dyn Tool>]) -> Vec<zesdex_domain::core::ToolDef> {
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()
}