Files
zesdex/src/tool/mod.rs
T

256 lines
9.7 KiB
Rust
Raw Normal View History

//! Tool trait, execution context, and the registry of all built-in tools.
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use serde_json::Value;
use anyhow::Result;
pub mod bash_tools;
pub mod fs;
pub mod git_cred;
pub mod git_operator;
pub mod git_worktree;
pub mod lsp;
pub mod memory;
pub mod plan;
pub mod search;
pub mod seqthink;
pub mod shell;
pub mod shell_filter;
pub mod utility;
pub mod workflow;
/// Common interface every agent-invocable tool implements: name, JSON schema, and execution.
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, and cached directory state.
#[derive(Clone)]
pub struct ToolCtx {
pub workspaces: Vec<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub _download_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
pub origin: crate::app::state::types::Origin,
pub graduated_checks: Vec<GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::app::lsp::LspManager>>,
}
/// Find which graduated checks apply to a given file path/content pair.
///
/// Flow: for each check, match its `pattern` against `path` or its `rule` against
/// `content` (substring match) → collect matching check names.
///
/// Return: names of all matching checks; empty if none match.
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
}
impl ToolCtx {
/// Start building a `ToolCtx` with `ToolCtxBuilder`'s defaults.
pub fn builder() -> ToolCtxBuilder {
ToolCtxBuilder::default()
}
}
/// Builder for `ToolCtx`; fields default to empty paths and a fresh `DirCache`.
pub struct ToolCtxBuilder {
pub workspaces: Vec<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub download_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
pub origin: crate::app::state::types::Origin,
pub graduated_checks: Vec<GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::app::lsp::LspManager>>,
}
impl Default for ToolCtxBuilder {
fn default() -> Self {
ToolCtxBuilder {
workspaces: Vec::new(),
session_dir: PathBuf::new(),
memory_dir: PathBuf::new(),
download_dir: PathBuf::new(),
worktrees_dir: PathBuf::new(),
dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new(super::app::state::misc::DirCache::new())),
origin: crate::app::state::types::Origin::Main,
graduated_checks: Vec::new(),
lsp_manager: Arc::new(Mutex::new(crate::app::lsp::LspManager::new())),
}
}
}
impl ToolCtxBuilder {
/// Set the session directory.
pub fn session_dir(mut self, v: PathBuf) -> Self { self.session_dir = v; self }
/// Set the origin (main process vs. daemon-attached).
pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { self.origin = v; self }
/// Set the lsp_manager.
#[allow(dead_code)]
pub fn lsp_manager(mut self, v: Arc<Mutex<crate::app::lsp::LspManager>>) -> Self { self.lsp_manager = v; self }
/// Consume the builder and produce the final `ToolCtx`.
pub fn build(self) -> ToolCtx {
ToolCtx {
workspaces: self.workspaces,
session_dir: self.session_dir,
memory_dir: self.memory_dir,
_download_dir: self.download_dir,
worktrees_dir: self.worktrees_dir,
dir_cache: self.dir_cache,
origin: self.origin,
graduated_checks: self.graduated_checks,
lsp_manager: self.lsp_manager,
}
}
}
/// Construct one instance of every built-in tool, in the fixed order exposed to the LLM.
///
/// Return: boxed trait objects for all 28 tools (fs, search, bash, git, memory, plan,
/// workflow, utility).
pub fn all_tools() -> Vec<Box<dyn Tool>> {
vec![
Box::new(super::tool::fs::read::Read),
Box::new(super::tool::fs::write::Write),
Box::new(super::tool::fs::edit::Edit),
Box::new(super::tool::fs::delete::Delete),
Box::new(super::tool::search::Grep),
Box::new(super::tool::search::Glob),
Box::new(super::tool::bash_tools::BashOutput),
Box::new(super::tool::bash_tools::BashKill),
Box::new(super::tool::shell::Bash),
Box::new(super::tool::git_operator::GitOperator),
Box::new(super::tool::git_worktree::GitWorktree),
Box::new(super::tool::git_cred::GitCred),
Box::new(super::tool::seqthink::SeqThink),
Box::new(super::tool::plan::PlanEnter),
Box::new(super::tool::plan::PlanReady),
Box::new(super::tool::workflow::WorkflowRun),
Box::new(super::tool::workflow::NoteFinding),
Box::new(super::tool::memory::remember::Remember),
Box::new(super::tool::memory::forget::Forget),
Box::new(super::tool::memory::recall::Recall),
Box::new(super::tool::utility::cd::Cd),
Box::new(super::tool::utility::dir_list::DirList),
Box::new(super::tool::utility::dir_cache_update::DirCacheUpdate),
Box::new(super::tool::utility::pong::Pong),
Box::new(super::tool::utility::todowrite::Todowrite),
Box::new(super::tool::lsp::LspConnect),
Box::new(super::tool::lsp::LspDiagnostics),
Box::new(super::tool::lsp::LspHover),
Box::new(super::tool::lsp::LspCompletion),
Box::new(super::tool::lsp::LspDefinition),
Box::new(super::tool::lsp::LspReferences),
Box::new(super::tool::lsp::LspDisconnect),
]
}
/// Whether a tool by name can mutate the filesystem or run arbitrary shell commands.
///
/// Why: used by the harness to decide which tool calls need user confirmation/guard checks.
pub fn tool_is_risky(name: &str) -> bool {
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
}
/// Convert a list of tools into the provider-facing `ToolDef` request schema.
///
/// Return: one `ToolDef` per tool, in the same order as `tools`.
pub fn tool_defs(tools: &[Box<dyn Tool>]) -> Vec<crate::dto::provider::request::ToolDef> {
tools
.iter()
.map(|t| crate::dto::provider::request::ToolDef {
type_: "function".to_string(),
function: crate::dto::provider::request::ToolFunctionDef {
name: t.name().to_string(),
description: t.description().to_string(),
parameters: t.parameters(),
},
})
.collect()
}
/// Resolve a tool-supplied relative path to an absolute path within a workspace root,
/// rejecting escapes.
///
/// Flow: parse optional `[N]` workspace-index prefix (defaults to workspace 0) → join
/// remainder onto that workspace root → canonicalize → verify the canonical path
/// still starts with one of `workspaces`.
///
/// Why: canonicalizing and re-checking containment (rather than trusting the join)
/// prevents `../` traversal from escaping the sandboxed workspace roots.
///
/// Return: the canonical absolute path, or an error if the workspace index is invalid
/// or the resolved path falls outside all workspace roots.
pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
let _parts: Vec<&str> = rel.splitn(2, '/').collect();
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 {} out of range", ws_idx))?;
let abs = if path.is_empty() {
base.clone()
} else {
base.join(path)
};
// Resolve the path with canonicalisation. For non-existent files
// (e.g. the write tool creating a new file), canonicalise the base
// workspace root first and then resolve parent-dir (`../`) traversal
// component-by-component so that `Path::starts_with` cannot be
// bypassed by unnormalised intermediate segments.
let canon = match abs.canonicalize() {
Ok(c) => c,
Err(_) => {
let base_canon = workspaces
.iter()
.filter_map(|w| w.canonicalize().ok())
.next()
.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 '{}' is outside all workspace roots", rel)
}
}