354 lines
12 KiB
Rust
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()
|
|
}
|