組み込みツールの実装
This commit is contained in:
@@ -24,17 +24,19 @@ impl Scope {
|
||||
|
||||
/// Check whether `path` falls within this scope.
|
||||
///
|
||||
/// The path is canonicalized before comparison.
|
||||
/// The path is canonicalized before comparison. If the path does not
|
||||
/// exist yet (typical for new-file writes), the closest existing
|
||||
/// ancestor is canonicalized and checked, so deep new directory
|
||||
/// hierarchies inside the scope are also accepted.
|
||||
pub fn contains(&self, path: &Path) -> bool {
|
||||
match path.canonicalize() {
|
||||
Ok(canonical) => canonical.starts_with(&self.root),
|
||||
Err(_) => {
|
||||
// Path doesn't exist yet — check the parent directory instead.
|
||||
// This handles write_file to a new file inside the scope.
|
||||
match path.parent().and_then(|p| p.canonicalize().ok()) {
|
||||
Some(parent) => parent.starts_with(&self.root),
|
||||
None => false,
|
||||
}
|
||||
let mut cur = path;
|
||||
loop {
|
||||
if let Ok(canonical) = cur.canonicalize() {
|
||||
return canonical.starts_with(&self.root);
|
||||
}
|
||||
match cur.parent() {
|
||||
Some(parent) if parent != cur => cur = parent,
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,4 +100,16 @@ mod tests {
|
||||
let traversal = dir.path().join("../../../etc/passwd");
|
||||
assert!(!scope.contains(&traversal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_deeply_nested_new_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = Scope::new(dir.path()).unwrap();
|
||||
|
||||
// Neither the file nor any of its ancestors (a, a/b, a/b/c) exist yet
|
||||
// under the scope; contains should still accept because the closest
|
||||
// existing ancestor (the scope root) is inside the scope.
|
||||
let deep = dir.path().join("a/b/c/new.txt");
|
||||
assert!(scope.contains(&deep));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ thiserror = "2.0"
|
||||
tokio = { version = "1.49", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync"] }
|
||||
toml = "1.1.2"
|
||||
tracing = "0.1.44"
|
||||
tools = { version = "0.1.0", path = "../tools" }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1.89"
|
||||
|
||||
@@ -83,6 +83,10 @@ impl PodController {
|
||||
// Keep the server alive by moving it into the controller task
|
||||
// (it will be dropped when the task ends)
|
||||
|
||||
// Grab the scope before the mutable borrow of the worker so we can
|
||||
// build a `ScopedFs` for the builtin tools. `Scope` is cheap to clone.
|
||||
let scope_for_tools = pod.scope().cloned();
|
||||
|
||||
// Register event bridge callbacks on the worker
|
||||
{
|
||||
let worker = pod.worker_mut();
|
||||
@@ -155,6 +159,19 @@ impl PodController {
|
||||
message: event.message.clone(),
|
||||
});
|
||||
});
|
||||
|
||||
// Register the builtin file-manipulation tools (Read / Write /
|
||||
// Edit / Glob / Grep) when the manifest declares a scope.
|
||||
//
|
||||
// `ScopedFs` carries the pod-lifetime write boundary (derived
|
||||
// from the manifest scope). `ReadTracker` is session-scoped —
|
||||
// a fresh instance per controller spawn ensures state from a
|
||||
// previous process lifetime cannot be reused after a resume.
|
||||
if let Some(scope) = scope_for_tools {
|
||||
let fs = tools::ScopedFs::new(scope);
|
||||
let tracker = tools::ReadTracker::new();
|
||||
worker.register_tools(tools::builtin_tools(fs, tracker));
|
||||
}
|
||||
}
|
||||
|
||||
// Clone cancel sender before moving pod
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "tools"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1.89"
|
||||
globset = "0.4.18"
|
||||
grep-matcher = "0.1.8"
|
||||
grep-regex = "0.1.14"
|
||||
grep-searcher = "0.1.16"
|
||||
ignore = "0.4.25"
|
||||
llm-worker = { version = "0.2.1", path = "../llm-worker" }
|
||||
manifest = { version = "0.1.0", path = "../manifest" }
|
||||
schemars = "1.2.1"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
sha2 = "0.11.0"
|
||||
tempfile = "3.27.0"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.51.1", features = ["rt"] }
|
||||
tracing = "0.1.44"
|
||||
|
||||
[dev-dependencies]
|
||||
filetime = "0.2.27"
|
||||
tokio = { version = "1.51.1", features = ["macros", "rt-multi-thread"] }
|
||||
@@ -0,0 +1,292 @@
|
||||
//! `Edit` tool — partial string replacement with uniqueness check.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::ToolsError;
|
||||
use crate::read_tracker::ReadTracker;
|
||||
use crate::scoped_fs::ScopedFs;
|
||||
|
||||
const DESCRIPTION: &str = "Replace a substring in an existing file. By default \
|
||||
`old_string` must be unique in the file; set `replace_all: true` to replace \
|
||||
every occurrence. The file must have been read first (via the Read tool) in \
|
||||
this session. Paths must be absolute.";
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct EditParams {
|
||||
/// Absolute path to the file.
|
||||
pub file_path: PathBuf,
|
||||
/// String to replace. Must be unique in the file unless `replace_all` is true.
|
||||
pub old_string: String,
|
||||
/// Replacement string. Must differ from `old_string`.
|
||||
pub new_string: String,
|
||||
/// Replace all occurrences. Defaults to false.
|
||||
#[serde(default)]
|
||||
pub replace_all: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct EditTool {
|
||||
fs: ScopedFs,
|
||||
tracker: ReadTracker,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for EditTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: EditParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Edit input: {e}")))?;
|
||||
|
||||
tracing::debug!(
|
||||
path = %params.file_path.display(),
|
||||
replace_all = params.replace_all,
|
||||
"Edit"
|
||||
);
|
||||
|
||||
if params.old_string.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"old_string must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if params.old_string == params.new_string {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"old_string and new_string are identical".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Load current content and verify it matches the recorded hash.
|
||||
let current_bytes = self.fs.read_bytes(¶ms.file_path)?;
|
||||
self.tracker.verify(¶ms.file_path, ¤t_bytes)?;
|
||||
|
||||
let current_text = std::str::from_utf8(¤t_bytes).map_err(|_| {
|
||||
ToolsError::InvalidArgument(format!(
|
||||
"file is not valid UTF-8: {}",
|
||||
params.file_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let count = current_text.matches(¶ms.old_string).count();
|
||||
if count == 0 {
|
||||
return Err(ToolsError::StringNotFound {
|
||||
path: params.file_path.clone(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
if !params.replace_all && count > 1 {
|
||||
return Err(ToolsError::NotUnique {
|
||||
path: params.file_path.clone(),
|
||||
count,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let new_text = if params.replace_all {
|
||||
current_text.replace(¶ms.old_string, ¶ms.new_string)
|
||||
} else {
|
||||
current_text.replacen(¶ms.old_string, ¶ms.new_string, 1)
|
||||
};
|
||||
let occurrences = if params.replace_all { count } else { 1 };
|
||||
|
||||
self.fs.write(¶ms.file_path, new_text.as_bytes())?;
|
||||
self.tracker
|
||||
.record(¶ms.file_path, new_text.as_bytes());
|
||||
|
||||
let summary = format!(
|
||||
"Edited {} ({} replacement{})",
|
||||
params.file_path.display(),
|
||||
occurrences,
|
||||
if occurrences == 1 { "" } else { "s" }
|
||||
);
|
||||
let preview = make_preview(&new_text, ¶ms.new_string);
|
||||
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: Some(preview),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a small line-numbered snippet centered on the first occurrence of
|
||||
/// `needle` in `text`. Shows ±3 surrounding lines.
|
||||
fn make_preview(text: &str, needle: &str) -> String {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
if lines.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let first_needle_line = needle.lines().next().unwrap_or(needle);
|
||||
let hit = lines
|
||||
.iter()
|
||||
.position(|l| l.contains(first_needle_line))
|
||||
.unwrap_or(0);
|
||||
|
||||
let start = hit.saturating_sub(3);
|
||||
let end = (hit + 4).min(lines.len());
|
||||
|
||||
use std::fmt::Write as _;
|
||||
let mut out = String::new();
|
||||
for (i, line) in lines[start..end].iter().enumerate() {
|
||||
let lineno = start + i + 1;
|
||||
let _ = writeln!(&mut out, "{:>6}\t{}", lineno, line);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Factory for the `Edit` tool.
|
||||
pub fn edit_tool(fs: ScopedFs, tracker: ReadTracker) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(EditParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("Edit")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(EditTool {
|
||||
fs: fs.clone(),
|
||||
tracker: tracker.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::read::read_tool;
|
||||
use manifest::Scope;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, ScopedFs, ReadTracker) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(Scope::new(dir.path()).unwrap());
|
||||
(dir, fs, ReadTracker::new())
|
||||
}
|
||||
|
||||
async fn read_first(fs: &ScopedFs, tracker: &ReadTracker, file: &std::path::Path) {
|
||||
let def = read_tool(fs.clone(), tracker.clone());
|
||||
let (_, reader) = def();
|
||||
let inp = serde_json::json!({ "file_path": file.to_str().unwrap() });
|
||||
reader.execute(&inp.to_string()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_unique_replacement() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "line1\nfoo bar\nline3\n").unwrap();
|
||||
read_first(&fs, &tracker, &file).await;
|
||||
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (meta, tool) = def();
|
||||
assert_eq!(meta.name, "Edit");
|
||||
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "foo bar",
|
||||
"new_string": "foo baz",
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
assert!(out.summary.contains("1 replacement"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&file).unwrap(),
|
||||
"line1\nfoo baz\nline3\n"
|
||||
);
|
||||
assert!(out.content.unwrap().contains("foo baz"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_replace_all() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "x x x\n").unwrap();
|
||||
read_first(&fs, &tracker, &file).await;
|
||||
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "x",
|
||||
"new_string": "y",
|
||||
"replace_all": true,
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
assert!(out.summary.contains("3 replacements"));
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "y y y\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_not_unique() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "a a\n").unwrap();
|
||||
read_first(&fs, &tracker, &file).await;
|
||||
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "a",
|
||||
"new_string": "b",
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_string_not_found() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "hello\n").unwrap();
|
||||
read_first(&fs, &tracker, &file).await;
|
||||
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "world",
|
||||
"new_string": "x",
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_requires_prior_read() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "foo\n").unwrap();
|
||||
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "foo",
|
||||
"new_string": "bar",
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_detects_external_modification() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "foo\n").unwrap();
|
||||
read_first(&fs, &tracker, &file).await;
|
||||
|
||||
// External tampering between read and edit
|
||||
std::fs::write(&file, "something else").unwrap();
|
||||
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "foo",
|
||||
"new_string": "bar",
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("modified externally"), "{msg}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Error type shared across the `tools` crate.
|
||||
//!
|
||||
//! `ToolsError` is the crate-level error returned by `ScopedFs` and each
|
||||
//! builtin tool's internal logic. Tool `execute()` impls convert it to
|
||||
//! [`llm_worker::tool::ToolError`] via the `From` impl defined here.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use llm_worker::tool::ToolError;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ToolsError {
|
||||
#[error("path must be absolute: {}", .0.display())]
|
||||
RelativePath(PathBuf),
|
||||
|
||||
#[error("path is outside allowed scope: {}", .0.display())]
|
||||
OutOfScope(PathBuf),
|
||||
|
||||
#[error("path is a directory: {}", .0.display())]
|
||||
IsDirectory(PathBuf),
|
||||
|
||||
#[error("file not found: {}", .0.display())]
|
||||
NotFound(PathBuf),
|
||||
|
||||
#[error("file has not been read in this session; read it first: {}", .0.display())]
|
||||
NotRead(PathBuf),
|
||||
|
||||
#[error("file was modified externally after last read: {}", .0.display())]
|
||||
ExternallyModified(PathBuf),
|
||||
|
||||
#[error("string not found in file: {}", .path.display())]
|
||||
StringNotFound { path: PathBuf },
|
||||
|
||||
#[error(
|
||||
"string is not unique in file ({count} occurrences); pass replace_all=true or disambiguate: {}",
|
||||
.path.display()
|
||||
)]
|
||||
NotUnique { path: PathBuf, count: usize },
|
||||
|
||||
#[error("invalid argument: {0}")]
|
||||
InvalidArgument(String),
|
||||
|
||||
#[error("invalid regex: {0}")]
|
||||
InvalidRegex(String),
|
||||
|
||||
#[error("invalid glob pattern: {0}")]
|
||||
InvalidGlob(String),
|
||||
|
||||
#[error("I/O error at {}: {source}", .path.display())]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
impl ToolsError {
|
||||
/// Helper to wrap an [`std::io::Error`] with the path it occurred on.
|
||||
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
|
||||
Self::Io {
|
||||
path: path.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ToolsError> for ToolError {
|
||||
fn from(err: ToolsError) -> Self {
|
||||
use ToolsError::*;
|
||||
match err {
|
||||
RelativePath(_)
|
||||
| OutOfScope(_)
|
||||
| IsDirectory(_)
|
||||
| NotRead(_)
|
||||
| ExternallyModified(_)
|
||||
| StringNotFound { .. }
|
||||
| NotUnique { .. }
|
||||
| InvalidArgument(_)
|
||||
| InvalidRegex(_)
|
||||
| InvalidGlob(_) => ToolError::InvalidArgument(err.to_string()),
|
||||
NotFound(_) => ToolError::ExecutionFailed(err.to_string()),
|
||||
Io { .. } => ToolError::ExecutionFailed(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! `Glob` tool — recursive file search by glob pattern, sorted by mtime.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::ToolsError;
|
||||
use crate::scoped_fs::ScopedFs;
|
||||
|
||||
const DESCRIPTION: &str = "Recursively find files matching a glob pattern \
|
||||
(e.g. \"**/*.rs\"). Results are sorted by modification time, newest first, \
|
||||
and capped at 1000 entries. Hidden files are included. The `path` parameter \
|
||||
defaults to the scope root when omitted. Paths must be absolute.";
|
||||
|
||||
const RESULT_LIMIT: usize = 1000;
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct GlobParams {
|
||||
/// Glob pattern, e.g. `"**/*.rs"`. Matched against paths relative to
|
||||
/// `path` (or the scope root if omitted).
|
||||
pub pattern: String,
|
||||
/// Absolute directory to search under. Defaults to the scope root.
|
||||
#[serde(default)]
|
||||
pub path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub(crate) struct GlobTool {
|
||||
fs: ScopedFs,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GlobTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: GlobParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Glob input: {e}")))?;
|
||||
|
||||
tracing::debug!(
|
||||
pattern = %params.pattern,
|
||||
path = ?params.path,
|
||||
"Glob"
|
||||
);
|
||||
|
||||
let base = params
|
||||
.path
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.fs.scope().root().to_path_buf());
|
||||
let pattern = params.pattern.clone();
|
||||
|
||||
// ignore::Walk is synchronous; run it on a blocking thread so we
|
||||
// don't stall the runtime for large trees.
|
||||
let results = tokio::task::spawn_blocking(move || run_glob(&base, &pattern))
|
||||
.await
|
||||
.map_err(|e| ToolError::Internal(format!("spawn_blocking failed: {e}")))??;
|
||||
|
||||
let total = results.len();
|
||||
let (shown, truncated) = if total > RESULT_LIMIT {
|
||||
(&results[..RESULT_LIMIT], true)
|
||||
} else {
|
||||
(&results[..], false)
|
||||
};
|
||||
|
||||
if shown.is_empty() {
|
||||
return Ok(ToolOutput {
|
||||
summary: format!("No files found matching {}", params.pattern),
|
||||
content: None,
|
||||
});
|
||||
}
|
||||
|
||||
let mut body = String::new();
|
||||
for p in shown {
|
||||
body.push_str(&p.display().to_string());
|
||||
body.push('\n');
|
||||
}
|
||||
|
||||
let summary = if truncated {
|
||||
format!(
|
||||
"Found {total}+ files matching {} (truncated to {RESULT_LIMIT})",
|
||||
params.pattern
|
||||
)
|
||||
} else {
|
||||
format!("Found {total} file(s) matching {}", params.pattern)
|
||||
};
|
||||
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: Some(body),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn run_glob(base: &Path, pattern: &str) -> Result<Vec<PathBuf>, ToolsError> {
|
||||
if !base.is_absolute() {
|
||||
return Err(ToolsError::RelativePath(base.to_path_buf()));
|
||||
}
|
||||
if !base.exists() {
|
||||
return Err(ToolsError::NotFound(base.to_path_buf()));
|
||||
}
|
||||
|
||||
let glob = globset::Glob::new(pattern)
|
||||
.map_err(|e| ToolsError::InvalidGlob(e.to_string()))?
|
||||
.compile_matcher();
|
||||
|
||||
// Glob is an explicit-pattern tool, so gitignore/hidden are *not* honored.
|
||||
let walker = ignore::WalkBuilder::new(base)
|
||||
.hidden(false)
|
||||
.git_ignore(false)
|
||||
.git_global(false)
|
||||
.git_exclude(false)
|
||||
.ignore(false)
|
||||
.parents(false)
|
||||
.follow_links(false)
|
||||
.build();
|
||||
|
||||
let mut hits: Vec<(PathBuf, SystemTime)> = Vec::new();
|
||||
for entry in walker.flatten() {
|
||||
let ft = match entry.file_type() {
|
||||
Some(ft) => ft,
|
||||
None => continue,
|
||||
};
|
||||
if !ft.is_file() {
|
||||
continue;
|
||||
}
|
||||
let rel = match entry.path().strip_prefix(base) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !glob.is_match(rel) {
|
||||
continue;
|
||||
}
|
||||
let mtime = entry
|
||||
.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
hits.push((entry.path().to_path_buf(), mtime));
|
||||
}
|
||||
|
||||
hits.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
Ok(hits.into_iter().map(|(p, _)| p).collect())
|
||||
}
|
||||
|
||||
/// Factory for the `Glob` tool.
|
||||
pub fn glob_tool(fs: ScopedFs) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(GlobParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("Glob")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(GlobTool { fs: fs.clone() });
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, ScopedFs) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(Scope::new(dir.path()).unwrap());
|
||||
(dir, fs)
|
||||
}
|
||||
|
||||
fn touch(path: &Path, content: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_finds_matching_files() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.rs"), "");
|
||||
touch(&dir.path().join("sub/b.rs"), "");
|
||||
touch(&dir.path().join("sub/c.txt"), "");
|
||||
|
||||
let def = glob_tool(fs);
|
||||
let (meta, tool) = def();
|
||||
assert_eq!(meta.name, "Glob");
|
||||
|
||||
let inp = serde_json::json!({ "pattern": "**/*.rs" });
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
assert!(out.summary.contains("2 file(s)"));
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("a.rs"));
|
||||
assert!(body.contains("b.rs"));
|
||||
assert!(!body.contains("c.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_sorts_by_mtime_desc() {
|
||||
let (dir, fs) = setup();
|
||||
let older = dir.path().join("old.rs");
|
||||
let newer = dir.path().join("new.rs");
|
||||
touch(&older, "");
|
||||
touch(&newer, "");
|
||||
|
||||
filetime::set_file_mtime(&older, filetime::FileTime::from_unix_time(1_000, 0)).unwrap();
|
||||
filetime::set_file_mtime(&newer, filetime::FileTime::from_unix_time(2_000, 0)).unwrap();
|
||||
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "*.rs" });
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
let new_pos = body.find("new.rs").unwrap();
|
||||
let old_pos = body.find("old.rs").unwrap();
|
||||
assert!(new_pos < old_pos, "newer file should come first:\n{body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_empty_results() {
|
||||
let (_dir, fs) = setup();
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "**/*.nonexistent" });
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
assert!(out.summary.contains("No files"));
|
||||
assert!(out.content.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_invalid_pattern() {
|
||||
let (_dir, fs) = setup();
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "[unterminated" });
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_honors_hidden_files() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join(".hidden.rs"), "");
|
||||
touch(&dir.path().join("visible.rs"), "");
|
||||
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "*.rs" });
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains(".hidden.rs"));
|
||||
assert!(body.contains("visible.rs"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
//! `Grep` tool — recursive regex search powered by ripgrep's component crates.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use grep_regex::RegexMatcherBuilder;
|
||||
use grep_searcher::sinks::UTF8 as UTF8Sink;
|
||||
use grep_searcher::{
|
||||
BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch,
|
||||
};
|
||||
use ignore::WalkBuilder;
|
||||
use ignore::overrides::OverrideBuilder;
|
||||
use ignore::types::TypesBuilder;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::ToolsError;
|
||||
use crate::scoped_fs::ScopedFs;
|
||||
|
||||
const DESCRIPTION: &str = "Recursive regex search across files, powered by \
|
||||
ripgrep. Supports file filtering (`glob`, `type`), context lines, multiline \
|
||||
matching, and three output modes: `files_with_matches` (default), `content`, \
|
||||
and `count`. Honors .gitignore. Binary files are skipped. Paths must be \
|
||||
absolute.";
|
||||
|
||||
const DEFAULT_HEAD_LIMIT: usize = 250;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema, Default, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum GrepOutputMode {
|
||||
#[default]
|
||||
FilesWithMatches,
|
||||
Content,
|
||||
Count,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct GrepParams {
|
||||
/// Regex pattern to search for.
|
||||
pub pattern: String,
|
||||
/// Absolute path to search under. Defaults to the scope root.
|
||||
#[serde(default)]
|
||||
pub path: Option<PathBuf>,
|
||||
/// Glob filter applied to candidate files, e.g. `"*.rs"`.
|
||||
#[serde(default)]
|
||||
pub glob: Option<String>,
|
||||
/// File type filter, e.g. `"rust"` or `"py"`. See ripgrep's default types.
|
||||
#[serde(default, rename = "type")]
|
||||
pub file_type: Option<String>,
|
||||
/// Output mode: `files_with_matches` (default), `content`, or `count`.
|
||||
#[serde(default)]
|
||||
pub output_mode: Option<GrepOutputMode>,
|
||||
/// Show line numbers in content mode. Defaults to true.
|
||||
#[serde(default, rename = "-n")]
|
||||
pub line_numbers: Option<bool>,
|
||||
/// Case-insensitive matching.
|
||||
#[serde(default, rename = "-i")]
|
||||
pub case_insensitive: bool,
|
||||
/// Trailing context lines after each match.
|
||||
#[serde(default, rename = "-A")]
|
||||
pub after: Option<usize>,
|
||||
/// Leading context lines before each match.
|
||||
#[serde(default, rename = "-B")]
|
||||
pub before: Option<usize>,
|
||||
/// Context lines before AND after each match (overrides -A/-B when set).
|
||||
#[serde(default, rename = "-C")]
|
||||
pub context: Option<usize>,
|
||||
/// Allow patterns to match across newlines.
|
||||
#[serde(default)]
|
||||
pub multiline: bool,
|
||||
/// Maximum number of output entries. Defaults to 250.
|
||||
#[serde(default)]
|
||||
pub head_limit: Option<usize>,
|
||||
/// Skip the first N output entries (pagination).
|
||||
#[serde(default)]
|
||||
pub offset: Option<usize>,
|
||||
}
|
||||
|
||||
pub(crate) struct GrepTool {
|
||||
fs: ScopedFs,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GrepTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: GrepParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Grep input: {e}")))?;
|
||||
|
||||
tracing::debug!(
|
||||
pattern = %params.pattern,
|
||||
mode = ?params.output_mode,
|
||||
"Grep"
|
||||
);
|
||||
|
||||
let default_base = self.fs.scope().root().to_path_buf();
|
||||
let report =
|
||||
tokio::task::spawn_blocking(move || run_grep(default_base, params))
|
||||
.await
|
||||
.map_err(|e| ToolError::Internal(format!("spawn_blocking failed: {e}")))??;
|
||||
|
||||
Ok(report.render())
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory for the `Grep` tool.
|
||||
pub fn grep_tool(fs: ScopedFs) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(GrepParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("Grep")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(GrepTool { fs: fs.clone() });
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Implementation
|
||||
// =============================================================================
|
||||
|
||||
struct ContentLine {
|
||||
path: PathBuf,
|
||||
line_number: Option<u64>,
|
||||
text: String,
|
||||
is_match: bool,
|
||||
}
|
||||
|
||||
struct GrepReport {
|
||||
mode: GrepOutputMode,
|
||||
show_line_numbers: bool,
|
||||
files: Vec<PathBuf>,
|
||||
counts: Vec<(PathBuf, usize)>,
|
||||
lines: Vec<ContentLine>,
|
||||
truncated: bool,
|
||||
head_limit: usize,
|
||||
}
|
||||
|
||||
impl GrepReport {
|
||||
fn render(self) -> ToolOutput {
|
||||
match self.mode {
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
if self.files.is_empty() {
|
||||
return ToolOutput {
|
||||
summary: "No files matched".into(),
|
||||
content: None,
|
||||
};
|
||||
}
|
||||
let mut body = String::new();
|
||||
for p in &self.files {
|
||||
body.push_str(&p.display().to_string());
|
||||
body.push('\n');
|
||||
}
|
||||
let mut summary = format!("Found matches in {} file(s)", self.files.len());
|
||||
if self.truncated {
|
||||
summary.push_str(&format!(" (truncated at {})", self.head_limit));
|
||||
}
|
||||
ToolOutput {
|
||||
summary,
|
||||
content: Some(body),
|
||||
}
|
||||
}
|
||||
GrepOutputMode::Count => {
|
||||
if self.counts.is_empty() {
|
||||
return ToolOutput {
|
||||
summary: "No files matched".into(),
|
||||
content: None,
|
||||
};
|
||||
}
|
||||
let total_lines: usize = self.counts.iter().map(|(_, n)| *n).sum();
|
||||
let mut body = String::new();
|
||||
for (p, n) in &self.counts {
|
||||
body.push_str(&format!("{}:{}\n", p.display(), n));
|
||||
}
|
||||
let mut summary = format!(
|
||||
"Found matches in {} file(s), {} total line(s)",
|
||||
self.counts.len(),
|
||||
total_lines
|
||||
);
|
||||
if self.truncated {
|
||||
summary.push_str(&format!(" (truncated at {})", self.head_limit));
|
||||
}
|
||||
ToolOutput {
|
||||
summary,
|
||||
content: Some(body),
|
||||
}
|
||||
}
|
||||
GrepOutputMode::Content => {
|
||||
if self.lines.is_empty() {
|
||||
return ToolOutput {
|
||||
summary: "No matches".into(),
|
||||
content: None,
|
||||
};
|
||||
}
|
||||
let match_count = self.lines.iter().filter(|l| l.is_match).count();
|
||||
let file_set: std::collections::BTreeSet<&Path> =
|
||||
self.lines.iter().map(|l| l.path.as_path()).collect();
|
||||
let mut body = String::new();
|
||||
for line in &self.lines {
|
||||
let sep = if line.is_match { ':' } else { '-' };
|
||||
if self.show_line_numbers {
|
||||
if let Some(n) = line.line_number {
|
||||
body.push_str(&format!(
|
||||
"{}{}{}{}{}\n",
|
||||
line.path.display(),
|
||||
sep,
|
||||
n,
|
||||
sep,
|
||||
line.text
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
body.push_str(&format!(
|
||||
"{}{}{}\n",
|
||||
line.path.display(),
|
||||
sep,
|
||||
line.text
|
||||
));
|
||||
}
|
||||
let mut summary = format!(
|
||||
"{} matching line(s) in {} file(s)",
|
||||
match_count,
|
||||
file_set.len()
|
||||
);
|
||||
if self.truncated {
|
||||
summary.push_str(&format!(" (truncated at {})", self.head_limit));
|
||||
}
|
||||
ToolOutput {
|
||||
summary,
|
||||
content: Some(body),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_grep(default_base: PathBuf, p: GrepParams) -> Result<GrepReport, ToolsError> {
|
||||
let matcher = RegexMatcherBuilder::new()
|
||||
.case_insensitive(p.case_insensitive)
|
||||
.multi_line(p.multiline)
|
||||
.dot_matches_new_line(p.multiline)
|
||||
.build(&p.pattern)
|
||||
.map_err(|e| ToolsError::InvalidRegex(e.to_string()))?;
|
||||
|
||||
let (before, after) = match (p.before, p.after, p.context) {
|
||||
(_, _, Some(c)) => (c, c),
|
||||
(b, a, None) => (b.unwrap_or(0), a.unwrap_or(0)),
|
||||
};
|
||||
|
||||
let mut sb = SearcherBuilder::new();
|
||||
sb.binary_detection(BinaryDetection::quit(b'\x00'))
|
||||
.line_number(p.line_numbers.unwrap_or(true))
|
||||
.multi_line(p.multiline)
|
||||
.before_context(before)
|
||||
.after_context(after);
|
||||
let mut searcher = sb.build();
|
||||
|
||||
let base = p.path.unwrap_or(default_base);
|
||||
if !base.is_absolute() {
|
||||
return Err(ToolsError::RelativePath(base));
|
||||
}
|
||||
if !base.exists() {
|
||||
return Err(ToolsError::NotFound(base));
|
||||
}
|
||||
|
||||
let mut wb = WalkBuilder::new(&base);
|
||||
wb.hidden(true)
|
||||
.git_ignore(true)
|
||||
.git_global(true)
|
||||
.git_exclude(true)
|
||||
.ignore(true)
|
||||
.parents(true)
|
||||
.follow_links(false);
|
||||
|
||||
if let Some(t) = p.file_type.as_deref() {
|
||||
let mut tb = TypesBuilder::new();
|
||||
tb.add_defaults();
|
||||
tb.select(t);
|
||||
let types = tb
|
||||
.build()
|
||||
.map_err(|e| ToolsError::InvalidArgument(format!("invalid type {t}: {e}")))?;
|
||||
wb.types(types);
|
||||
}
|
||||
if let Some(g) = p.glob.as_deref() {
|
||||
let mut ob = OverrideBuilder::new(&base);
|
||||
ob.add(g).map_err(|e| ToolsError::InvalidGlob(e.to_string()))?;
|
||||
let ov = ob
|
||||
.build()
|
||||
.map_err(|e| ToolsError::InvalidGlob(e.to_string()))?;
|
||||
wb.overrides(ov);
|
||||
}
|
||||
|
||||
let mode = p.output_mode.unwrap_or_default();
|
||||
let head_limit = p.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
||||
let offset = p.offset.unwrap_or(0);
|
||||
let show_line_numbers = p.line_numbers.unwrap_or(true);
|
||||
|
||||
let mut report = GrepReport {
|
||||
mode,
|
||||
show_line_numbers,
|
||||
files: Vec::new(),
|
||||
counts: Vec::new(),
|
||||
lines: Vec::new(),
|
||||
truncated: false,
|
||||
head_limit,
|
||||
};
|
||||
|
||||
// Per-mode walker state.
|
||||
let mut matching_files_seen: usize = 0;
|
||||
let mut matches_seen: usize = 0;
|
||||
|
||||
'walker: for entry in wb.build().flatten() {
|
||||
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
|
||||
match mode {
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
let hit = scan_any_match(&mut searcher, &matcher, path)?;
|
||||
if !hit {
|
||||
continue;
|
||||
}
|
||||
if matching_files_seen >= offset {
|
||||
report.files.push(path.to_path_buf());
|
||||
if report.files.len() >= head_limit {
|
||||
report.truncated = true;
|
||||
break 'walker;
|
||||
}
|
||||
}
|
||||
matching_files_seen += 1;
|
||||
}
|
||||
GrepOutputMode::Count => {
|
||||
let count = scan_count(&mut searcher, &matcher, path)?;
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
if matching_files_seen >= offset {
|
||||
report.counts.push((path.to_path_buf(), count));
|
||||
if report.counts.len() >= head_limit {
|
||||
report.truncated = true;
|
||||
break 'walker;
|
||||
}
|
||||
}
|
||||
matching_files_seen += 1;
|
||||
}
|
||||
GrepOutputMode::Content => {
|
||||
let before_count = matches_seen;
|
||||
let mut sink = ContentSink {
|
||||
path: path.to_path_buf(),
|
||||
lines: &mut report.lines,
|
||||
matches_seen: &mut matches_seen,
|
||||
offset,
|
||||
head_limit,
|
||||
};
|
||||
searcher
|
||||
.search_path(&matcher, path, &mut sink)
|
||||
.map_err(|e| ToolsError::io(path, e))?;
|
||||
// If we hit head_limit during this file, stop walking.
|
||||
if matches_seen >= offset.saturating_add(head_limit) && matches_seen > before_count
|
||||
{
|
||||
report.truncated = true;
|
||||
break 'walker;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn scan_any_match(
|
||||
searcher: &mut Searcher,
|
||||
matcher: &grep_regex::RegexMatcher,
|
||||
path: &Path,
|
||||
) -> Result<bool, ToolsError> {
|
||||
let mut hit = false;
|
||||
let sink = UTF8Sink(|_, _| {
|
||||
hit = true;
|
||||
Ok(false) // stop searching this file immediately
|
||||
});
|
||||
searcher
|
||||
.search_path(matcher, path, sink)
|
||||
.map_err(|e| ToolsError::io(path, e))?;
|
||||
Ok(hit)
|
||||
}
|
||||
|
||||
fn scan_count(
|
||||
searcher: &mut Searcher,
|
||||
matcher: &grep_regex::RegexMatcher,
|
||||
path: &Path,
|
||||
) -> Result<usize, ToolsError> {
|
||||
let mut count = 0usize;
|
||||
let sink = UTF8Sink(|_, _| {
|
||||
count += 1;
|
||||
Ok(true)
|
||||
});
|
||||
searcher
|
||||
.search_path(matcher, path, sink)
|
||||
.map_err(|e| ToolsError::io(path, e))?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
struct ContentSink<'a> {
|
||||
path: PathBuf,
|
||||
lines: &'a mut Vec<ContentLine>,
|
||||
matches_seen: &'a mut usize,
|
||||
offset: usize,
|
||||
head_limit: usize,
|
||||
}
|
||||
|
||||
impl Sink for ContentSink<'_> {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn matched(
|
||||
&mut self,
|
||||
_searcher: &Searcher,
|
||||
mat: &SinkMatch<'_>,
|
||||
) -> Result<bool, Self::Error> {
|
||||
let idx = *self.matches_seen;
|
||||
*self.matches_seen += 1;
|
||||
|
||||
// Skip matches before offset.
|
||||
if idx < self.offset {
|
||||
return Ok(true);
|
||||
}
|
||||
// Stop searching this file once we've filled the head_limit.
|
||||
if idx >= self.offset.saturating_add(self.head_limit) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(mat.bytes())
|
||||
.trim_end_matches('\n')
|
||||
.trim_end_matches('\r')
|
||||
.to_string();
|
||||
self.lines.push(ContentLine {
|
||||
path: self.path.clone(),
|
||||
line_number: mat.line_number(),
|
||||
text,
|
||||
is_match: true,
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn context(
|
||||
&mut self,
|
||||
_searcher: &Searcher,
|
||||
ctx: &SinkContext<'_>,
|
||||
) -> Result<bool, Self::Error> {
|
||||
let seen = *self.matches_seen;
|
||||
if seen < self.offset {
|
||||
return Ok(true);
|
||||
}
|
||||
if seen >= self.offset.saturating_add(self.head_limit) {
|
||||
return Ok(false);
|
||||
}
|
||||
let text = String::from_utf8_lossy(ctx.bytes())
|
||||
.trim_end_matches('\n')
|
||||
.trim_end_matches('\r')
|
||||
.to_string();
|
||||
self.lines.push(ContentLine {
|
||||
path: self.path.clone(),
|
||||
line_number: ctx.line_number(),
|
||||
text,
|
||||
is_match: false,
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, ScopedFs) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(Scope::new(dir.path()).unwrap());
|
||||
(dir, fs)
|
||||
}
|
||||
|
||||
fn touch(path: &Path, content: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_files_with_matches_default() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "alpha\nbravo\n");
|
||||
touch(&dir.path().join("b.txt"), "charlie\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (meta, tool) = def();
|
||||
assert_eq!(meta.name, "Grep");
|
||||
|
||||
let inp = serde_json::json!({ "pattern": "bravo" });
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
assert!(out.summary.contains("1 file"));
|
||||
assert!(out.content.unwrap().contains("a.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_content_mode_with_line_numbers() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "one\ntwo\nthree\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "two",
|
||||
"output_mode": "content",
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains(":2:two"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_count_mode() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "x\nx\nx\n");
|
||||
touch(&dir.path().join("b.txt"), "x\ny\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "x",
|
||||
"output_mode": "count",
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("a.txt:3"));
|
||||
assert!(body.contains("b.txt:1"));
|
||||
assert!(out.summary.contains("4 total"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_case_insensitive() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "HELLO\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "hello",
|
||||
"-i": true,
|
||||
"output_mode": "content",
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
assert!(out.content.unwrap().contains("HELLO"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_context_lines() {
|
||||
let (dir, fs) = setup();
|
||||
touch(
|
||||
&dir.path().join("a.txt"),
|
||||
"line1\nline2\nMATCH\nline4\nline5\n",
|
||||
);
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "MATCH",
|
||||
"output_mode": "content",
|
||||
"-C": 1,
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
// should contain: line2 (before context), MATCH, line4 (after context)
|
||||
assert!(body.contains("line2"));
|
||||
assert!(body.contains("MATCH"));
|
||||
assert!(body.contains("line4"));
|
||||
assert!(!body.contains("line1"));
|
||||
assert!(!body.contains("line5"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_multiline() {
|
||||
let (dir, fs) = setup();
|
||||
touch(
|
||||
&dir.path().join("a.txt"),
|
||||
"start\nfoo\nbar\nend\n",
|
||||
);
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
// Match across newlines: "foo" followed by "bar" on the next line
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "foo[\\s\\S]*?bar",
|
||||
"multiline": true,
|
||||
"output_mode": "content",
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("foo"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_glob_filter() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.rs"), "target\n");
|
||||
touch(&dir.path().join("b.txt"), "target\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "target",
|
||||
"glob": "*.rs",
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("a.rs"));
|
||||
assert!(!body.contains("b.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_type_filter() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.rs"), "target\n");
|
||||
touch(&dir.path().join("b.py"), "target\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "target",
|
||||
"type": "rust",
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("a.rs"));
|
||||
assert!(!body.contains("b.py"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_head_limit_truncates() {
|
||||
let (dir, fs) = setup();
|
||||
for i in 0..5 {
|
||||
touch(&dir.path().join(format!("f{i}.txt")), "x\n");
|
||||
}
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "x",
|
||||
"head_limit": 2,
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert_eq!(body.lines().count(), 2);
|
||||
assert!(out.summary.contains("truncated at 2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_offset_paginates() {
|
||||
let (dir, fs) = setup();
|
||||
// Create 5 files, all matching, deterministically named
|
||||
for i in 0..5 {
|
||||
touch(&dir.path().join(format!("f{i}.txt")), "x\n");
|
||||
}
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "x",
|
||||
"offset": 3,
|
||||
"head_limit": 10,
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
// We skipped 3, so only 2 should remain.
|
||||
assert_eq!(body.lines().count(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_binary_files_are_skipped() {
|
||||
let (dir, fs) = setup();
|
||||
let mut bin = Vec::from(b"\x00\x01\x02needle\n".as_slice());
|
||||
bin.extend(b"more\n");
|
||||
fs::write(dir.path().join("a.bin"), bin).unwrap();
|
||||
touch(&dir.path().join("b.txt"), "needle\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "needle" });
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("b.txt"));
|
||||
assert!(!body.contains("a.bin"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_invalid_regex() {
|
||||
let (_dir, fs) = setup();
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "(" });
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_unknown_type() {
|
||||
let (_dir, fs) = setup();
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "x",
|
||||
"type": "nonexistent",
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_no_matches() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "nothing here\n");
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "zzz" });
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
assert_eq!(out.summary, "No files matched");
|
||||
assert!(out.content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_schema_contains_dash_keys() {
|
||||
// Sanity check: schemars must preserve the `-n`, `-A`, etc. keys
|
||||
// from serde(rename). If this fails we need to rename the fields.
|
||||
let schema = schemars::schema_for!(GrepParams);
|
||||
let json = serde_json::to_value(&schema).unwrap();
|
||||
let json_str = json.to_string();
|
||||
assert!(json_str.contains("\"-n\""), "schema missing -n: {json_str}");
|
||||
assert!(json_str.contains("\"-A\""), "schema missing -A: {json_str}");
|
||||
assert!(json_str.contains("\"-B\""), "schema missing -B: {json_str}");
|
||||
assert!(json_str.contains("\"-C\""), "schema missing -C: {json_str}");
|
||||
assert!(json_str.contains("\"-i\""), "schema missing -i: {json_str}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Built-in tools for the Insomnia LLM agent.
|
||||
//!
|
||||
//! Implements Read / Write / Edit / Glob / Grep on top of the `llm-worker`
|
||||
//! `Tool` infrastructure. Filesystem access is mediated by two orthogonal
|
||||
//! concerns:
|
||||
//!
|
||||
//! - [`ScopedFs`] — pod-lifetime, expresses the write-block boundary for
|
||||
//! the current scope. Derived from the manifest and shareable across
|
||||
//! sessions.
|
||||
//! - [`ReadTracker`] — session-lifetime, enforces the "read before edit"
|
||||
//! policy via content hashes. Recreated fresh per session.
|
||||
//!
|
||||
//! The Pod layer owns both instances and passes them to
|
||||
//! [`builtin_tools`] when registering tools on a `Worker`.
|
||||
|
||||
pub mod error;
|
||||
pub mod read_tracker;
|
||||
pub mod scoped_fs;
|
||||
|
||||
mod edit;
|
||||
mod glob;
|
||||
mod grep;
|
||||
mod read;
|
||||
mod write;
|
||||
|
||||
pub use edit::edit_tool;
|
||||
pub use error::ToolsError;
|
||||
pub use glob::glob_tool;
|
||||
pub use grep::grep_tool;
|
||||
pub use read::read_tool;
|
||||
pub use read_tracker::ReadTracker;
|
||||
pub use scoped_fs::ScopedFs;
|
||||
pub use write::write_tool;
|
||||
|
||||
/// Register all builtin tools, wiring them to a shared `ScopedFs`
|
||||
/// (pod-lifetime) and `ReadTracker` (session-lifetime).
|
||||
///
|
||||
/// All returned factories share the same tracker instance so that
|
||||
/// `Read` / `Write` / `Edit` see a consistent history across tool
|
||||
/// invocations within a single session.
|
||||
pub fn builtin_tools(
|
||||
fs: ScopedFs,
|
||||
tracker: ReadTracker,
|
||||
) -> Vec<llm_worker::tool::ToolDefinition> {
|
||||
vec![
|
||||
read_tool(fs.clone(), tracker.clone()),
|
||||
write_tool(fs.clone(), tracker.clone()),
|
||||
edit_tool(fs.clone(), tracker.clone()),
|
||||
glob_tool(fs.clone()),
|
||||
grep_tool(fs),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! `Read` tool — read a text file with offset/limit, return line-numbered output.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::read_tracker::ReadTracker;
|
||||
use crate::scoped_fs::ScopedFs;
|
||||
|
||||
const DESCRIPTION: &str = "Read a text file from the local filesystem. \
|
||||
Supports offset/limit for large files. Returns line-numbered output (1-based). \
|
||||
Directories cannot be read. The file must be read before Write or Edit can \
|
||||
modify it. Paths must be absolute.";
|
||||
|
||||
const DEFAULT_LIMIT: usize = 2000;
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct ReadParams {
|
||||
/// Absolute path to the file.
|
||||
pub file_path: PathBuf,
|
||||
/// 0-based line offset from the start. Defaults to 0.
|
||||
#[serde(default)]
|
||||
pub offset: Option<usize>,
|
||||
/// Maximum number of lines to return. Defaults to 2000.
|
||||
#[serde(default)]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub(crate) struct ReadTool {
|
||||
fs: ScopedFs,
|
||||
tracker: ReadTracker,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ReadTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: ReadParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Read input: {e}")))?;
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
||||
|
||||
tracing::debug!(
|
||||
path = %params.file_path.display(),
|
||||
offset,
|
||||
limit,
|
||||
"Read"
|
||||
);
|
||||
|
||||
let bytes = self.fs.read_bytes(¶ms.file_path)?;
|
||||
// Record the raw bytes under the read-history so subsequent Edit /
|
||||
// Write can detect external modification.
|
||||
self.tracker.record(¶ms.file_path, &bytes);
|
||||
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let rendered = render_numbered(&text, offset, limit);
|
||||
|
||||
let summary = if rendered.truncated {
|
||||
format!(
|
||||
"Read {} line(s) [{}..{}] of {} from {}",
|
||||
rendered.line_count,
|
||||
offset + 1,
|
||||
offset + rendered.line_count,
|
||||
rendered.total_lines,
|
||||
params.file_path.display()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Read {} line(s) from {}",
|
||||
rendered.line_count,
|
||||
params.file_path.display()
|
||||
)
|
||||
};
|
||||
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: Some(rendered.body),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Rendered {
|
||||
body: String,
|
||||
line_count: usize,
|
||||
total_lines: usize,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
/// Format a slice of lines from `text` with `cat -n` style 1-based line
|
||||
/// numbers. Pure function — no I/O, no history touching.
|
||||
fn render_numbered(text: &str, offset: usize, limit: usize) -> Rendered {
|
||||
let all_lines: Vec<&str> = text.lines().collect();
|
||||
let total_lines = all_lines.len();
|
||||
let start = offset.min(total_lines);
|
||||
let end = start.saturating_add(limit).min(total_lines);
|
||||
let slice = &all_lines[start..end];
|
||||
let line_count = slice.len();
|
||||
|
||||
use std::fmt::Write as _;
|
||||
let mut body = String::with_capacity(text.len().saturating_add(line_count * 8));
|
||||
for (i, line) in slice.iter().enumerate() {
|
||||
let lineno = start + i + 1;
|
||||
let _ = writeln!(&mut body, "{:>6}\t{}", lineno, line);
|
||||
}
|
||||
|
||||
Rendered {
|
||||
body,
|
||||
line_count,
|
||||
total_lines,
|
||||
truncated: start > 0 || end < total_lines,
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory for the `Read` tool.
|
||||
pub fn read_tool(fs: ScopedFs, tracker: ReadTracker) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(ReadParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("Read")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(ReadTool {
|
||||
fs: fs.clone(),
|
||||
tracker: tracker.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, ScopedFs, ReadTracker) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(Scope::new(dir.path()).unwrap());
|
||||
(dir, fs, ReadTracker::new())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_tool_basic_records_history() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "alpha\nbeta\ngamma\n").unwrap();
|
||||
|
||||
let def = read_tool(fs, tracker.clone());
|
||||
let (meta, tool) = def();
|
||||
assert_eq!(meta.name, "Read");
|
||||
|
||||
let input = serde_json::json!({ "file_path": file.to_str().unwrap() });
|
||||
let out = tool.execute(&input.to_string()).await.unwrap();
|
||||
assert!(out.summary.contains("Read 3 line(s)"));
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains(" 1\talpha"));
|
||||
assert!(body.contains(" 3\tgamma"));
|
||||
|
||||
// History recorded
|
||||
assert!(tracker.has(&file));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_tool_offset_limit() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "1\n2\n3\n4\n5\n").unwrap();
|
||||
|
||||
let def = read_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let input = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"offset": 1,
|
||||
"limit": 2,
|
||||
});
|
||||
let out = tool.execute(&input.to_string()).await.unwrap();
|
||||
assert!(out.summary.contains("[2..3] of 5"));
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains(" 2\t2"));
|
||||
assert!(body.contains(" 3\t3"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_tool_missing_file() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
let def = read_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let input = serde_json::json!({
|
||||
"file_path": dir.path().join("nope.txt").to_str().unwrap()
|
||||
});
|
||||
let err = tool.execute(&input.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::ExecutionFailed(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_tool_bad_json() {
|
||||
let (_dir, fs, tracker) = setup();
|
||||
let def = read_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let err = tool.execute("not json").await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Read-before-edit policy tracker for the builtin file-manipulation tools.
|
||||
//!
|
||||
//! A `ReadTracker` records a SHA-256 hash of each file's contents at the
|
||||
//! moment it was observed via the `Read` tool, and lets `Write` / `Edit`
|
||||
//! later verify that the file has not been externally modified since then.
|
||||
//!
|
||||
//! # Lifetime
|
||||
//!
|
||||
//! A `ReadTracker` is **session-scoped**: the Pod layer creates a fresh
|
||||
//! instance at the start of each agent session and discards it when the
|
||||
//! session ends. The `ScopedFs` write boundary, by contrast, is
|
||||
//! pod-lifetime (derived from the manifest). The two are orthogonal and
|
||||
//! the Pod wires them together when registering builtin tools.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # use manifest::Scope;
|
||||
//! # use tools::{ScopedFs, ReadTracker, builtin_tools};
|
||||
//! let scope = Scope::new("/workspace").unwrap();
|
||||
//! let fs = ScopedFs::new(scope); // pod lifetime
|
||||
//! let tracker = ReadTracker::new(); // session lifetime
|
||||
//! let defs = builtin_tools(fs, tracker);
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::error::ToolsError;
|
||||
|
||||
/// Fixed-size content hash recorded per file.
|
||||
type ContentHash = [u8; 32];
|
||||
|
||||
fn hash_bytes(bytes: &[u8]) -> ContentHash {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Canonical-path keyed record of which files have been observed and at
|
||||
/// what content hash.
|
||||
///
|
||||
/// Cheap to clone: internally an `Arc<Mutex<HashMap<...>>>`, so sharing a
|
||||
/// `ReadTracker` across every builtin tool in a session is effectively
|
||||
/// free and keeps their views consistent.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ReadTracker {
|
||||
inner: Arc<Mutex<HashMap<PathBuf, ContentHash>>>,
|
||||
}
|
||||
|
||||
impl ReadTracker {
|
||||
/// Create an empty tracker. Typically called once per session.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Record that `path` has been observed with the given content bytes.
|
||||
///
|
||||
/// Called by the `Read` tool after a successful read, and by the
|
||||
/// `Write` / `Edit` tools after a successful modification (so that
|
||||
/// subsequent edits see a clean history).
|
||||
pub fn record(&self, path: &Path, bytes: &[u8]) {
|
||||
let key = canonicalize_or_owned(path);
|
||||
let hash = hash_bytes(bytes);
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(key, hash);
|
||||
}
|
||||
|
||||
/// Verify that `path` was previously recorded and its current bytes
|
||||
/// match the recorded hash.
|
||||
///
|
||||
/// - If the path has no history entry, returns [`ToolsError::NotRead`].
|
||||
/// - If the current content hashes differ from the recorded value,
|
||||
/// returns [`ToolsError::ExternallyModified`].
|
||||
pub fn verify(&self, path: &Path, current_bytes: &[u8]) -> Result<(), ToolsError> {
|
||||
let key = canonicalize_or_owned(path);
|
||||
let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let recorded = guard
|
||||
.get(&key)
|
||||
.ok_or_else(|| ToolsError::NotRead(path.to_path_buf()))?;
|
||||
let current = hash_bytes(current_bytes);
|
||||
if *recorded != current {
|
||||
return Err(ToolsError::ExternallyModified(path.to_path_buf()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true if `path` has a history entry. Test-only.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn has(&self, path: &Path) -> bool {
|
||||
let key = canonicalize_or_owned(path);
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.contains_key(&key)
|
||||
}
|
||||
|
||||
/// Number of distinct files in the history. Test-only.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_or_owned(path: &Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn record_then_verify_clean_ok() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let file = dir.path().join("a.txt");
|
||||
fs::write(&file, b"hello").unwrap();
|
||||
|
||||
let tracker = ReadTracker::new();
|
||||
tracker.record(&file, b"hello");
|
||||
assert!(tracker.has(&file));
|
||||
assert_eq!(tracker.len(), 1);
|
||||
tracker.verify(&file, b"hello").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_without_record_returns_not_read() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let file = dir.path().join("a.txt");
|
||||
fs::write(&file, b"x").unwrap();
|
||||
|
||||
let tracker = ReadTracker::new();
|
||||
let err = tracker.verify(&file, b"x").unwrap_err();
|
||||
assert!(matches!(err, ToolsError::NotRead(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_mismatch_returns_externally_modified() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let file = dir.path().join("a.txt");
|
||||
fs::write(&file, b"original").unwrap();
|
||||
|
||||
let tracker = ReadTracker::new();
|
||||
tracker.record(&file, b"original");
|
||||
let err = tracker.verify(&file, b"tampered").unwrap_err();
|
||||
assert!(matches!(err, ToolsError::ExternallyModified(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_overwrites_previous_hash() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let file = dir.path().join("a.txt");
|
||||
fs::write(&file, b"v1").unwrap();
|
||||
|
||||
let tracker = ReadTracker::new();
|
||||
tracker.record(&file, b"v1");
|
||||
tracker.record(&file, b"v2");
|
||||
tracker.verify(&file, b"v2").unwrap();
|
||||
assert!(tracker.verify(&file, b"v1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_keys_collapse_symlink_variants() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::symlink;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let real = dir.path().join("real.txt");
|
||||
fs::write(&real, b"data").unwrap();
|
||||
let link = dir.path().join("link.txt");
|
||||
symlink(&real, &link).unwrap();
|
||||
|
||||
let tracker = ReadTracker::new();
|
||||
tracker.record(&real, b"data");
|
||||
// Looking up via the symlink should hit the same entry.
|
||||
tracker.verify(&link, b"data").unwrap();
|
||||
// Exactly one entry.
|
||||
assert_eq!(tracker.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_shares_state() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let file = dir.path().join("a.txt");
|
||||
fs::write(&file, b"x").unwrap();
|
||||
|
||||
let t1 = ReadTracker::new();
|
||||
let t2 = t1.clone();
|
||||
t1.record(&file, b"x");
|
||||
t2.verify(&file, b"x").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_bytes_hash_stable() {
|
||||
let tracker = ReadTracker::new();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let file = dir.path().join("empty.txt");
|
||||
fs::write(&file, b"").unwrap();
|
||||
|
||||
tracker.record(&file, b"");
|
||||
tracker.verify(&file, b"").unwrap();
|
||||
assert!(tracker.verify(&file, b"x").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Scope-aware filesystem primitive.
|
||||
//!
|
||||
//! `ScopedFs` represents **only** the write-block boundary: it knows a
|
||||
//! [`manifest::Scope`] and refuses writes outside of it. It carries no
|
||||
//! per-session state and is cheap to clone (pod-lifetime, reusable across
|
||||
//! sessions). The read-before-edit policy lives separately in
|
||||
//! [`crate::ReadTracker`].
|
||||
//!
|
||||
//! Reads are unrestricted by design (see `tickets/builtin-tools.md`).
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use manifest::Scope;
|
||||
|
||||
use crate::error::ToolsError;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScopedFsInner {
|
||||
scope: Scope,
|
||||
}
|
||||
|
||||
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScopedFs {
|
||||
inner: Arc<ScopedFsInner>,
|
||||
}
|
||||
|
||||
/// Outcome of a [`ScopedFs::write`] call.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WriteOutcome {
|
||||
pub bytes_written: usize,
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
impl ScopedFs {
|
||||
/// Create a new [`ScopedFs`] wrapping the given [`Scope`].
|
||||
pub fn new(scope: Scope) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(ScopedFsInner { scope }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The underlying [`Scope`].
|
||||
pub fn scope(&self) -> &Scope {
|
||||
&self.inner.scope
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Read — unrestricted
|
||||
// =========================================================================
|
||||
|
||||
/// Read the full contents of `path` as raw bytes.
|
||||
///
|
||||
/// Follows symlinks. Rejects directories, relative paths, and missing
|
||||
/// files. No scope check.
|
||||
pub fn read_bytes(&self, path: &Path) -> Result<Vec<u8>, ToolsError> {
|
||||
if !path.is_absolute() {
|
||||
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
||||
}
|
||||
let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => ToolsError::NotFound(path.to_path_buf()),
|
||||
_ => ToolsError::io(path, e),
|
||||
})?;
|
||||
if meta.is_dir() {
|
||||
return Err(ToolsError::IsDirectory(path.to_path_buf()));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| ToolsError::io(path, e))
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Write — scope-checked, atomic
|
||||
// =========================================================================
|
||||
|
||||
/// Atomically write `content` to `path`, creating or overwriting it.
|
||||
///
|
||||
/// - `path` must be absolute and inside the scope (delegates to
|
||||
/// [`Scope::contains`]).
|
||||
/// - Missing parent directories inside the scope are created.
|
||||
/// - The actual write uses a sibling tempfile + `persist`, so the
|
||||
/// target file transitions atomically between states.
|
||||
///
|
||||
/// This method does **not** consult any read history. Callers that
|
||||
/// want the "must read before overwrite" policy should verify with a
|
||||
/// [`ReadTracker`](crate::ReadTracker) beforehand.
|
||||
pub fn write(&self, path: &Path, content: &[u8]) -> Result<WriteOutcome, ToolsError> {
|
||||
if !path.is_absolute() {
|
||||
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
||||
}
|
||||
if !self.inner.scope.contains(path) {
|
||||
return Err(ToolsError::OutOfScope(path.to_path_buf()));
|
||||
}
|
||||
|
||||
// Reject existing directory targets.
|
||||
match std::fs::metadata(path) {
|
||||
Ok(meta) if meta.is_dir() => {
|
||||
return Err(ToolsError::IsDirectory(path.to_path_buf()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let existed = path.exists();
|
||||
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
ToolsError::InvalidArgument(format!(
|
||||
"path has no parent directory: {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
if !parent.as_os_str().is_empty() && !parent.exists() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| ToolsError::io(parent, e))?;
|
||||
}
|
||||
|
||||
let tmp_parent: &Path = if parent.as_os_str().is_empty() {
|
||||
Path::new(".")
|
||||
} else {
|
||||
parent
|
||||
};
|
||||
let mut tmp = tempfile::NamedTempFile::new_in(tmp_parent)
|
||||
.map_err(|e| ToolsError::io(tmp_parent, e))?;
|
||||
tmp.write_all(content).map_err(|e| ToolsError::io(path, e))?;
|
||||
tmp.as_file()
|
||||
.sync_all()
|
||||
.map_err(|e| ToolsError::io(path, e))?;
|
||||
tmp.persist(path)
|
||||
.map_err(|e| ToolsError::io(path, e.error))?;
|
||||
|
||||
Ok(WriteOutcome {
|
||||
bytes_written: content.len(),
|
||||
created: !existed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_fs(dir: &TempDir) -> ScopedFs {
|
||||
ScopedFs::new(Scope::new(dir.path()).unwrap())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// read_bytes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn read_bytes_returns_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let file = dir.path().join("a.txt");
|
||||
fs::write(&file, b"abc").unwrap();
|
||||
assert_eq!(fs.read_bytes(&file).unwrap(), b"abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_rejects_relative() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.read_bytes(Path::new("rel.txt")).unwrap_err();
|
||||
assert!(matches!(err, ToolsError::RelativePath(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_rejects_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.read_bytes(dir.path()).unwrap_err();
|
||||
assert!(matches!(err, ToolsError::IsDirectory(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_rejects_missing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.read_bytes(&dir.path().join("nope.txt")).unwrap_err();
|
||||
assert!(matches!(err, ToolsError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_allows_paths_outside_scope() {
|
||||
// Reads are unrestricted — scope only gates writes.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let outside_file = outside.path().join("x.txt");
|
||||
fs::write(&outside_file, b"hi").unwrap();
|
||||
|
||||
let scoped = make_fs(&dir);
|
||||
assert_eq!(scoped.read_bytes(&outside_file).unwrap(), b"hi");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// write
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn write_creates_new_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let file = dir.path().join("new.txt");
|
||||
let out = fs.write(&file, b"hello").unwrap();
|
||||
assert!(out.created);
|
||||
assert_eq!(out.bytes_written, 5);
|
||||
assert_eq!(fs::read(&file).unwrap(), b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_overwrites_existing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let file = dir.path().join("a.txt");
|
||||
fs::write(&file, b"old").unwrap();
|
||||
let out = fs.write(&file, b"new").unwrap();
|
||||
assert!(!out.created);
|
||||
assert_eq!(fs::read(&file).unwrap(), b"new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_out_of_scope() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.write(&outside.path().join("x"), b"x").unwrap_err();
|
||||
assert!(matches!(err, ToolsError::OutOfScope(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_relative_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.write(Path::new("rel.txt"), b"x").unwrap_err();
|
||||
assert!(matches!(err, ToolsError::RelativePath(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_creates_missing_parents_inside_scope() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let nested = dir.path().join("a/b/c/deep.txt");
|
||||
fs.write(&nested, b"x").unwrap();
|
||||
assert_eq!(fs::read(&nested).unwrap(), b"x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_directory_target() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.write(dir.path(), b"x").unwrap_err();
|
||||
assert!(matches!(err, ToolsError::IsDirectory(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! `Write` tool — create or overwrite a file.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::read_tracker::ReadTracker;
|
||||
use crate::scoped_fs::ScopedFs;
|
||||
|
||||
const DESCRIPTION: &str = "Create a new file or overwrite an existing one with \
|
||||
the given content. Missing parent directories within scope are created \
|
||||
automatically. Existing files must have been read first (via the Read tool) \
|
||||
in this session. Paths must be absolute.";
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct WriteParams {
|
||||
/// Absolute path to the file.
|
||||
pub file_path: PathBuf,
|
||||
/// Full content to write. Overwrites any existing content.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub(crate) struct WriteTool {
|
||||
fs: ScopedFs,
|
||||
tracker: ReadTracker,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WriteTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: WriteParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Write input: {e}")))?;
|
||||
|
||||
tracing::debug!(
|
||||
path = %params.file_path.display(),
|
||||
bytes = params.content.len(),
|
||||
"Write"
|
||||
);
|
||||
|
||||
// Policy check: if the target already exists, it must have been
|
||||
// observed by the Read tool (via the tracker) and its current
|
||||
// contents must match the recorded hash.
|
||||
if params.file_path.exists() {
|
||||
let current = self.fs.read_bytes(¶ms.file_path)?;
|
||||
self.tracker.verify(¶ms.file_path, ¤t)?;
|
||||
}
|
||||
|
||||
let outcome = self.fs.write(¶ms.file_path, params.content.as_bytes())?;
|
||||
|
||||
// Refresh the history entry to reflect the newly-written content,
|
||||
// so a subsequent Edit / Write can proceed without a re-read.
|
||||
self.tracker
|
||||
.record(¶ms.file_path, params.content.as_bytes());
|
||||
|
||||
let summary = format!(
|
||||
"{} {} ({} bytes)",
|
||||
if outcome.created { "Created" } else { "Overwrote" },
|
||||
params.file_path.display(),
|
||||
outcome.bytes_written
|
||||
);
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory for the `Write` tool.
|
||||
pub fn write_tool(fs: ScopedFs, tracker: ReadTracker) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(WriteParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("Write")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(WriteTool {
|
||||
fs: fs.clone(),
|
||||
tracker: tracker.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::read::read_tool;
|
||||
use manifest::Scope;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, ScopedFs, ReadTracker) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(Scope::new(dir.path()).unwrap());
|
||||
(dir, fs, ReadTracker::new())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_creates_new_file_without_read() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
let def = write_tool(fs, tracker);
|
||||
let (meta, tool) = def();
|
||||
assert_eq!(meta.name, "Write");
|
||||
|
||||
let file = dir.path().join("new.txt");
|
||||
let input = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"content": "hello\n",
|
||||
});
|
||||
let out = tool.execute(&input.to_string()).await.unwrap();
|
||||
assert!(out.summary.contains("Created"));
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "hello\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_existing_requires_prior_read() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "old").unwrap();
|
||||
|
||||
let def = write_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let input = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"content": "new",
|
||||
});
|
||||
let err = tool.execute(&input.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_existing_after_read_succeeds() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "old\n").unwrap();
|
||||
|
||||
let read_def = read_tool(fs.clone(), tracker.clone());
|
||||
let (_, reader) = read_def();
|
||||
let read_in = serde_json::json!({ "file_path": file.to_str().unwrap() });
|
||||
reader.execute(&read_in.to_string()).await.unwrap();
|
||||
|
||||
let write_def = write_tool(fs, tracker);
|
||||
let (_, writer) = write_def();
|
||||
let write_in = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"content": "new\n",
|
||||
});
|
||||
let out = writer.execute(&write_in.to_string()).await.unwrap();
|
||||
assert!(out.summary.contains("Overwrote"));
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "new\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_detects_external_modification_via_hash() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "v1").unwrap();
|
||||
|
||||
// Read records hash of "v1".
|
||||
let read_def = read_tool(fs.clone(), tracker.clone());
|
||||
let (_, reader) = read_def();
|
||||
reader
|
||||
.execute(&serde_json::json!({ "file_path": file.to_str().unwrap() }).to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// External process overwrites with a different content.
|
||||
std::fs::write(&file, "tampered").unwrap();
|
||||
|
||||
let write_def = write_tool(fs, tracker);
|
||||
let (_, writer) = write_def();
|
||||
let err = writer
|
||||
.execute(
|
||||
&serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"content": "new",
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("modified externally"), "{msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_rejects_out_of_scope() {
|
||||
let (_dir, fs, tracker) = setup();
|
||||
let outside = TempDir::new().unwrap();
|
||||
|
||||
let def = write_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let input = serde_json::json!({
|
||||
"file_path": outside.path().join("x.txt").to_str().unwrap(),
|
||||
"content": "x",
|
||||
});
|
||||
let err = tool.execute(&input.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Edge-case regression tests that should stay green.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use llm_worker::tool::{Tool, ToolDefinition};
|
||||
use manifest::Scope;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tools::{ReadTracker, ScopedFs, builtin_tools};
|
||||
|
||||
struct Registry {
|
||||
entries: Vec<(llm_worker::tool::ToolMeta, Arc<dyn Tool>)>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
fn new(defs: Vec<ToolDefinition>) -> Self {
|
||||
Self {
|
||||
entries: defs.into_iter().map(|f| f()).collect(),
|
||||
}
|
||||
}
|
||||
fn get(&self, name: &str) -> Arc<dyn Tool> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find(|(m, _)| m.name == name)
|
||||
.map(|(_, t)| Arc::clone(t))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn setup() -> (TempDir, Registry) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(Scope::new(dir.path()).unwrap());
|
||||
let tracker = ReadTracker::new();
|
||||
(dir, Registry::new(builtin_tools(fs, tracker)))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unicode_path_and_content() {
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("日本語ファイル.txt");
|
||||
let content = "こんにちは 🦀 世界\nabc\n";
|
||||
|
||||
let write = reg.get("Write");
|
||||
write
|
||||
.execute(
|
||||
&json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"content": content,
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let read = reg.get("Read");
|
||||
let out = read
|
||||
.execute(
|
||||
&json!({ "file_path": file.to_str().unwrap() })
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("🦀"));
|
||||
assert!(body.contains("こんにちは"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn symlink_to_outside_scope_is_rejected_for_write() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let (dir, reg) = setup();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let outside_target = outside.path().join("secret.txt");
|
||||
std::fs::write(&outside_target, "secret").unwrap();
|
||||
|
||||
// Create a symlink inside the scope pointing to the outside file.
|
||||
let link = dir.path().join("linked.txt");
|
||||
symlink(&outside_target, &link).unwrap();
|
||||
|
||||
// Read tool must work against the symlink (read is unrestricted).
|
||||
let read = reg.get("Read");
|
||||
read.execute(
|
||||
&json!({ "file_path": link.to_str().unwrap() }).to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Write through the symlink must be rejected because canonicalization
|
||||
// resolves it to outside the scope.
|
||||
let write = reg.get("Write");
|
||||
let err = write
|
||||
.execute(
|
||||
&json!({
|
||||
"file_path": link.to_str().unwrap(),
|
||||
"content": "overwritten",
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("outside allowed scope"),
|
||||
"symlink escape not rejected: {msg}"
|
||||
);
|
||||
// Outside file must not have been touched.
|
||||
assert_eq!(std::fs::read_to_string(&outside_target).unwrap(), "secret");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_file_read_and_edit() {
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("empty.txt");
|
||||
std::fs::write(&file, "").unwrap();
|
||||
|
||||
let read = reg.get("Read");
|
||||
let out = read
|
||||
.execute(&json!({ "file_path": file.to_str().unwrap() }).to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("0 line"));
|
||||
|
||||
// Edit on empty file must produce StringNotFound
|
||||
let edit = reg.get("Edit");
|
||||
let err = edit
|
||||
.execute(
|
||||
&json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "foo",
|
||||
"new_string": "bar",
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn very_long_single_line() {
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("long.txt");
|
||||
let big: String = "x".repeat(1024 * 1024); // 1 MiB, no newlines
|
||||
std::fs::write(&file, &big).unwrap();
|
||||
|
||||
let read = reg.get("Read");
|
||||
let out = read
|
||||
.execute(&json!({ "file_path": file.to_str().unwrap() }).to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
// Should return exactly 1 line
|
||||
assert!(out.summary.contains("1 line"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relative_path_is_rejected() {
|
||||
let (_dir, reg) = setup();
|
||||
let read = reg.get("Read");
|
||||
let err = read
|
||||
.execute(&json!({ "file_path": "relative.txt" }).to_string())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("absolute"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn directory_target_is_rejected_for_read() {
|
||||
let (dir, reg) = setup();
|
||||
let read = reg.get("Read");
|
||||
let err = read
|
||||
.execute(&json!({ "file_path": dir.path().to_str().unwrap() }).to_string())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("directory"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deeply_nested_new_file_is_created() {
|
||||
let (dir, reg) = setup();
|
||||
let deep = dir.path().join("a/b/c/d/e/deep.txt");
|
||||
let write = reg.get("Write");
|
||||
write
|
||||
.execute(
|
||||
&json!({
|
||||
"file_path": deep.to_str().unwrap(),
|
||||
"content": "deep\n",
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&deep).unwrap(), "deep\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_preserves_unicode() {
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("u.txt");
|
||||
std::fs::write(&file, "🦀 rust 🦀\n").unwrap();
|
||||
|
||||
let read = reg.get("Read");
|
||||
read.execute(&json!({ "file_path": file.to_str().unwrap() }).to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let edit = reg.get("Edit");
|
||||
edit.execute(
|
||||
&json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "rust",
|
||||
"new_string": "ラスト",
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "🦀 ラスト 🦀\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_handles_unicode_pattern() {
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("u.txt");
|
||||
std::fs::write(&file, "English\n日本語\nрусский\n").unwrap();
|
||||
|
||||
let grep = reg.get("Grep");
|
||||
let out = grep
|
||||
.execute(
|
||||
&json!({
|
||||
"pattern": "日本語",
|
||||
"output_mode": "content",
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("日本語"));
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Cross-tool integration tests exercising `builtin_tools()` end-to-end.
|
||||
//!
|
||||
//! `ToolServerHandle::register_tool` / `flush_pending` are `pub(crate)` in
|
||||
//! llm-worker, so from here we exercise the factories directly — the same
|
||||
//! code path that `flush_pending()` runs at production time.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolMeta};
|
||||
use manifest::Scope;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tools::{ReadTracker, ScopedFs, builtin_tools};
|
||||
|
||||
struct Registry {
|
||||
entries: Vec<(ToolMeta, Arc<dyn Tool>)>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
fn new(defs: Vec<ToolDefinition>) -> Self {
|
||||
let entries = defs.into_iter().map(|f| f()).collect();
|
||||
Self { entries }
|
||||
}
|
||||
|
||||
fn get(&self, name: &str) -> Arc<dyn Tool> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find(|(m, _)| m.name == name)
|
||||
.map(|(_, t)| Arc::clone(t))
|
||||
.unwrap_or_else(|| panic!("tool not found: {name}"))
|
||||
}
|
||||
|
||||
fn names(&self) -> Vec<&str> {
|
||||
self.entries.iter().map(|(m, _)| m.name.as_str()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn setup() -> (TempDir, Registry) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(Scope::new(dir.path()).unwrap());
|
||||
let tracker = ReadTracker::new();
|
||||
let reg = Registry::new(builtin_tools(fs, tracker));
|
||||
(dir, reg)
|
||||
}
|
||||
|
||||
async fn call(tool: &Arc<dyn Tool>, input: serde_json::Value) -> llm_worker::tool::ToolOutput {
|
||||
tool.execute(&input.to_string())
|
||||
.await
|
||||
.expect("tool execution failed")
|
||||
}
|
||||
|
||||
async fn call_err(
|
||||
tool: &Arc<dyn Tool>,
|
||||
input: serde_json::Value,
|
||||
) -> llm_worker::tool::ToolError {
|
||||
tool.execute(&input.to_string())
|
||||
.await
|
||||
.expect_err("expected error")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_tools_registers_all_five() {
|
||||
let (_dir, reg) = setup();
|
||||
let mut names = reg.names();
|
||||
names.sort();
|
||||
assert_eq!(names, vec!["Edit", "Glob", "Grep", "Read", "Write"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_has_description_and_schema() {
|
||||
let (_dir, reg) = setup();
|
||||
for (meta, _) in ®.entries {
|
||||
assert!(!meta.description.is_empty(), "{} missing description", meta.name);
|
||||
// Input schema must be a JSON object
|
||||
assert!(
|
||||
meta.input_schema.is_object(),
|
||||
"{} input_schema is not an object",
|
||||
meta.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_then_edit_then_read_roundtrip() {
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "hello world\n").unwrap();
|
||||
let p = file.to_str().unwrap();
|
||||
|
||||
let read = reg.get("Read");
|
||||
let edit = reg.get("Edit");
|
||||
|
||||
// Read
|
||||
let r = call(&read, json!({ "file_path": p })).await;
|
||||
assert!(r.content.unwrap().contains("hello world"));
|
||||
|
||||
// Edit (unique replacement)
|
||||
let e = call(
|
||||
&edit,
|
||||
json!({
|
||||
"file_path": p,
|
||||
"old_string": "world",
|
||||
"new_string": "universe",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert!(e.summary.contains("1 replacement"));
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "hello universe\n");
|
||||
|
||||
// Re-read reflects the change
|
||||
let r2 = call(&read, json!({ "file_path": p })).await;
|
||||
assert!(r2.content.unwrap().contains("hello universe"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_then_grep_finds_content() {
|
||||
let (dir, reg) = setup();
|
||||
let write = reg.get("Write");
|
||||
let grep = reg.get("Grep");
|
||||
|
||||
let file = dir.path().join("notes.txt");
|
||||
call(
|
||||
&write,
|
||||
json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"content": "alpha\nNEEDLE\nomega\n",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let g = call(
|
||||
&grep,
|
||||
json!({
|
||||
"pattern": "NEEDLE",
|
||||
"output_mode": "content",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let body = g.content.unwrap();
|
||||
assert!(body.contains("notes.txt"));
|
||||
assert!(body.contains("NEEDLE"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_finds_written_files() {
|
||||
let (dir, reg) = setup();
|
||||
let write = reg.get("Write");
|
||||
let glob = reg.get("Glob");
|
||||
|
||||
for name in ["one.md", "two.md", "three.txt"] {
|
||||
call(
|
||||
&write,
|
||||
json!({
|
||||
"file_path": dir.path().join(name).to_str().unwrap(),
|
||||
"content": "x",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let g = call(&glob, json!({ "pattern": "*.md" })).await;
|
||||
let body = g.content.unwrap();
|
||||
assert!(body.contains("one.md"));
|
||||
assert!(body.contains("two.md"));
|
||||
assert!(!body.contains("three.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn out_of_scope_write_is_rejected() {
|
||||
let (_dir, reg) = setup();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let write = reg.get("Write");
|
||||
|
||||
let err = call_err(
|
||||
&write,
|
||||
json!({
|
||||
"file_path": outside.path().join("x.txt").to_str().unwrap(),
|
||||
"content": "x",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// ToolsError::OutOfScope → ToolError::InvalidArgument
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("outside allowed scope"), "unexpected: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_to_existing_without_read_fails() {
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("exists.txt");
|
||||
std::fs::write(&file, "preexisting").unwrap();
|
||||
|
||||
let write = reg.get("Write");
|
||||
let err = call_err(
|
||||
&write,
|
||||
json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"content": "new",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("has not been read"), "unexpected: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_scoped_fs_across_tools() {
|
||||
// The key invariant: all builtin tools share the same ScopedFs instance,
|
||||
// so read-history set by Read is visible to Edit and Write.
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("shared.txt");
|
||||
std::fs::write(&file, "one\n").unwrap();
|
||||
|
||||
let read = reg.get("Read");
|
||||
let write = reg.get("Write");
|
||||
|
||||
// Read via Read tool
|
||||
call(&read, json!({ "file_path": file.to_str().unwrap() })).await;
|
||||
// Write via Write tool — must succeed because the shared ScopedFs has the read
|
||||
call(
|
||||
&write,
|
||||
json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"content": "two\n",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "two\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_requires_read_across_tools() {
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "foo\n").unwrap();
|
||||
|
||||
let edit = reg.get("Edit");
|
||||
// No prior Read — Edit should fail
|
||||
let err = call_err(
|
||||
&edit,
|
||||
json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"old_string": "foo",
|
||||
"new_string": "bar",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("has not been read"), "unexpected: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deterministic_tool_order_is_registration_order() {
|
||||
let (_dir, reg) = setup();
|
||||
// Registration order from builtin_tools(): Read, Write, Edit, Glob, Grep
|
||||
let names: Vec<&str> = reg.entries.iter().map(|(m, _)| m.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["Read", "Write", "Edit", "Glob", "Grep"]);
|
||||
}
|
||||
|
||||
// Regression: tool name capitalization matches Claude Code reference
|
||||
#[test]
|
||||
fn tool_names_match_reference_spec() {
|
||||
let (_dir, reg) = setup();
|
||||
for expected in ["Read", "Write", "Edit", "Glob", "Grep"] {
|
||||
assert!(
|
||||
reg.entries.iter().any(|(m, _)| m.name == expected),
|
||||
"missing tool {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: unused Path import guard
|
||||
const _: fn() -> &'static Path = || Path::new("/");
|
||||
Reference in New Issue
Block a user