M1: read, write, bash, glob, and grep tools
Implements the first five harness-tools: read, write, bash (with timeout/output truncation), glob, and grep, plus shared path-resolution helpers. Wires them into the core tool registry and processor.
This commit is contained in:
Generated
+162
@@ -47,6 +47,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
@@ -78,6 +79,31 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
@@ -105,6 +131,24 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs_io"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -263,6 +307,43 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grep-matcher"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36d7b71093325ab22d780b40d7df3066ae4aebb518ba719d38c697a8228a8023"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grep-regex"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"grep-matcher",
|
||||
"log",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grep-searcher"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac63295322dc48ebb20a25348147905d816318888e64f531bfc2a2bc0577dc34"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"encoding_rs",
|
||||
"encoding_rs_io",
|
||||
"grep-matcher",
|
||||
"log",
|
||||
"memchr",
|
||||
"memmap2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "harness-app"
|
||||
version = "0.1.0"
|
||||
@@ -320,7 +401,23 @@ dependencies = [
|
||||
name = "harness-tools"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"globset",
|
||||
"grep-matcher",
|
||||
"grep-regex",
|
||||
"grep-searcher",
|
||||
"harness-core",
|
||||
"ignore",
|
||||
"libc",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shell-words",
|
||||
"similar",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -348,6 +445,22 @@ dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ignore"
|
||||
version = "0.4.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"globset",
|
||||
"log",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"same-file",
|
||||
"walkdir",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -427,6 +540,15 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
@@ -617,6 +739,15 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -701,6 +832,12 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-words"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -717,6 +854,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -904,6 +1047,16 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -974,6 +1127,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
|
||||
@@ -57,6 +57,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
dirs = "5"
|
||||
shell-words = "1"
|
||||
libc = "0.2"
|
||||
tempfile = "3"
|
||||
insta = "1"
|
||||
wiremock = "0.6"
|
||||
|
||||
@@ -424,6 +424,11 @@ impl<'a> Run<'a> {
|
||||
}
|
||||
};
|
||||
|
||||
// docs/05-tools.md: cap tool output at 30k chars regardless of which tool produced it.
|
||||
let output = crate::tool::truncate::truncate(&output, &self.ctx.data_dir, &call_id)
|
||||
.map(|t| t.text)
|
||||
.unwrap_or(output);
|
||||
|
||||
let final_state = if is_error {
|
||||
ToolState::Error {
|
||||
input,
|
||||
|
||||
@@ -122,6 +122,7 @@ pub struct ToolCtx {
|
||||
pub metadata: MetadataSink,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ToolOutput {
|
||||
pub title: String,
|
||||
pub output: String,
|
||||
|
||||
@@ -6,6 +6,27 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
harness-core = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
ignore = { workspace = true }
|
||||
globset = { workspace = true }
|
||||
grep-searcher = { workspace = true }
|
||||
grep-regex = { workspace = true }
|
||||
grep-matcher = { workspace = true }
|
||||
similar = { workspace = true }
|
||||
shell-words = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use harness_core::tool::{Tool, ToolCtx, ToolError, ToolOutput};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 2 * 60 * 1000;
|
||||
const MAX_TIMEOUT_MS: u64 = 10 * 60 * 1000;
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct BashParams {
|
||||
command: String,
|
||||
timeout_ms: Option<u64>,
|
||||
cwd: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
description: String,
|
||||
}
|
||||
|
||||
pub struct BashTool;
|
||||
|
||||
/// The "always allow" pattern is coarser than the exact command: opencode/docs/05-tools.md
|
||||
/// grants `<first word> *` (e.g. `git *`), not the literal command string.
|
||||
fn always_pattern(command: &str) -> String {
|
||||
shell_words::split(command)
|
||||
.ok()
|
||||
.and_then(|words| words.into_iter().next())
|
||||
.map(|first| format!("{first} *"))
|
||||
.unwrap_or_else(|| "*".to_string())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[allow(unsafe_code)]
|
||||
fn kill_group(pid: u32) {
|
||||
// Negative pid signals the whole process group (spawned with `process_group(0)`).
|
||||
// Safety: `kill` is a plain libc syscall; passing a negative pid targets the group,
|
||||
// which is exactly the process tree we spawned and want to tear down.
|
||||
unsafe {
|
||||
libc::kill(-(pid as i32), libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn kill_group(_pid: u32) {}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for BashTool {
|
||||
fn name(&self) -> &str {
|
||||
"bash"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Runs a shell command and returns its combined stdout/stderr."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
serde_json::to_value(schemars::schema_for!(BashParams)).unwrap()
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
input: serde_json::Value,
|
||||
ctx: ToolCtx,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: BashParams =
|
||||
serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?;
|
||||
let timeout_ms = params
|
||||
.timeout_ms
|
||||
.unwrap_or(DEFAULT_TIMEOUT_MS)
|
||||
.min(MAX_TIMEOUT_MS);
|
||||
|
||||
ctx.ask
|
||||
.ask(
|
||||
"bash",
|
||||
params.command.clone(),
|
||||
always_pattern(¶ms.command),
|
||||
serde_json::json!({"command": params.command}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cwd = params
|
||||
.cwd
|
||||
.as_ref()
|
||||
.map(|c| crate::paths::resolve(&ctx.cwd, c))
|
||||
.unwrap_or_else(|| ctx.cwd.clone());
|
||||
|
||||
let mut cmd = tokio::process::Command::new("sh");
|
||||
cmd.arg("-c")
|
||||
.arg(¶ms.command)
|
||||
.current_dir(&cwd)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
cmd.process_group(0);
|
||||
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| ToolError::Other(format!("spawn failed: {e}")))?;
|
||||
let pid = child.id();
|
||||
|
||||
tokio::select! {
|
||||
result = child.wait_with_output() => {
|
||||
let output = result.map_err(|e| ToolError::Other(format!("wait failed: {e}")))?;
|
||||
let mut combined = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
combined.push_str(&String::from_utf8_lossy(&output.stderr));
|
||||
let title = format!("{} (exit {})", params.command, output.status.code().unwrap_or(-1));
|
||||
Ok(ToolOutput::new(title, combined))
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(timeout_ms)) => {
|
||||
if let Some(pid) = pid { kill_group(pid); }
|
||||
Err(ToolError::Other(format!("command timed out after {timeout_ms}ms")))
|
||||
}
|
||||
_ = ctx.cancel.cancelled() => {
|
||||
if let Some(pid) = pid { kill_group(pid); }
|
||||
Err(ToolError::Cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use harness_core::event::EventBus;
|
||||
use harness_core::permission::{spawn_auto_approve, PermissionService};
|
||||
use harness_core::tool::{MetadataSink, PermissionHandle};
|
||||
use harness_core::types::SessionId;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn ctx(cwd: std::path::PathBuf) -> ToolCtx {
|
||||
let bus = EventBus::new();
|
||||
let service = Arc::new(PermissionService::new(bus.clone()));
|
||||
spawn_auto_approve(bus, service.clone());
|
||||
let (metadata, _rx) = MetadataSink::channel();
|
||||
ToolCtx {
|
||||
session_id: SessionId::new(),
|
||||
message_id: harness_core::types::MessageId::new(),
|
||||
call_id: "call_1".into(),
|
||||
data_dir: cwd.join("tool-output"),
|
||||
cwd,
|
||||
cancel: CancellationToken::new(),
|
||||
ask: PermissionHandle::new(
|
||||
service,
|
||||
SessionId::new(),
|
||||
Vec::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
CancellationToken::new(),
|
||||
),
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn always_pattern_uses_first_word() {
|
||||
assert_eq!(always_pattern("git push origin main"), "git *");
|
||||
assert_eq!(always_pattern("ls -la"), "ls *");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runs_command_and_captures_stdout() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = BashTool
|
||||
.execute(
|
||||
serde_json::json!({"command": "echo hello", "description": "say hi"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(output.output.contains("hello"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn captures_stderr_too() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = BashTool
|
||||
.execute(
|
||||
serde_json::json!({"command": "echo err 1>&2", "description": "stderr"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(output.output.contains("err"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn times_out_long_running_commands() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let err = BashTool
|
||||
.execute(
|
||||
serde_json::json!({"command": "sleep 5", "timeout_ms": 50, "description": "slow"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::Other(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn respects_cwd_override() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = BashTool
|
||||
.execute(
|
||||
serde_json::json!({"command": "pwd", "description": "where"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(output
|
||||
.output
|
||||
.trim()
|
||||
.ends_with(dir.path().file_name().unwrap().to_str().unwrap()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use globset::GlobBuilder;
|
||||
use harness_core::tool::{Tool, ToolCtx, ToolError, ToolOutput};
|
||||
use ignore::WalkBuilder;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
const MAX_RESULTS: usize = 100;
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct GlobParams {
|
||||
pattern: String,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
/// Gitignore-aware file finder — read-only, no permission ask.
|
||||
pub struct GlobTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GlobTool {
|
||||
fn name(&self) -> &str {
|
||||
"glob"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Finds files by glob pattern (gitignore-aware), most recently modified first."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
serde_json::to_value(schemars::schema_for!(GlobParams)).unwrap()
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
input: serde_json::Value,
|
||||
ctx: ToolCtx,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: GlobParams =
|
||||
serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?;
|
||||
let root = params
|
||||
.path
|
||||
.as_ref()
|
||||
.map(|p| paths::resolve(&ctx.cwd, p))
|
||||
.unwrap_or_else(|| ctx.cwd.clone());
|
||||
let pattern = params.pattern.clone();
|
||||
|
||||
let matcher = GlobBuilder::new(&pattern)
|
||||
.literal_separator(true)
|
||||
.build()
|
||||
.map_err(|e| ToolError::Invalid(e.to_string()))?
|
||||
.compile_matcher();
|
||||
|
||||
let mut matches: Vec<(PathBuf, SystemTime)> = Vec::new();
|
||||
for entry in WalkBuilder::new(&root)
|
||||
.hidden(false)
|
||||
.require_git(false)
|
||||
.build()
|
||||
{
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let rel = entry.path().strip_prefix(&root).unwrap_or(entry.path());
|
||||
if !matcher.is_match(rel) {
|
||||
continue;
|
||||
}
|
||||
let mtime = entry
|
||||
.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
matches.push((entry.path().to_path_buf(), mtime));
|
||||
}
|
||||
|
||||
matches.sort_by_key(|(_, mtime)| std::cmp::Reverse(*mtime));
|
||||
let truncated = matches.len() > MAX_RESULTS;
|
||||
matches.truncate(MAX_RESULTS);
|
||||
|
||||
let mut output = matches
|
||||
.iter()
|
||||
.map(|(p, _)| p.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if truncated {
|
||||
output.push_str(
|
||||
"\n\n(Results are truncated: showing first 100 results. Consider using a more specific path or pattern.)",
|
||||
);
|
||||
}
|
||||
if output.is_empty() {
|
||||
output = "(no matches)".to_string();
|
||||
}
|
||||
Ok(ToolOutput::new(pattern, output))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use harness_core::event::EventBus;
|
||||
use harness_core::permission::PermissionService;
|
||||
use harness_core::tool::{MetadataSink, PermissionHandle};
|
||||
use harness_core::types::SessionId;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn ctx(cwd: std::path::PathBuf) -> ToolCtx {
|
||||
let bus = EventBus::new();
|
||||
let service = Arc::new(PermissionService::new(bus));
|
||||
let (metadata, _rx) = MetadataSink::channel();
|
||||
ToolCtx {
|
||||
session_id: SessionId::new(),
|
||||
message_id: harness_core::types::MessageId::new(),
|
||||
call_id: "call_1".into(),
|
||||
data_dir: cwd.join("tool-output"),
|
||||
cwd,
|
||||
cancel: CancellationToken::new(),
|
||||
ask: PermissionHandle::new(
|
||||
service,
|
||||
SessionId::new(),
|
||||
Vec::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
CancellationToken::new(),
|
||||
),
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finds_files_matching_pattern() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("a.rs"), "").unwrap();
|
||||
std::fs::write(dir.path().join("b.txt"), "").unwrap();
|
||||
let output = GlobTool
|
||||
.execute(
|
||||
serde_json::json!({"pattern": "*.rs"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(output.output.ends_with("a.rs"));
|
||||
assert!(!output.output.contains("b.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn respects_gitignore() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join(".gitignore"), "ignored.rs\n").unwrap();
|
||||
std::fs::write(dir.path().join("ignored.rs"), "").unwrap();
|
||||
std::fs::write(dir.path().join("kept.rs"), "").unwrap();
|
||||
let output = GlobTool
|
||||
.execute(
|
||||
serde_json::json!({"pattern": "*.rs"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(output.output.contains("kept.rs"));
|
||||
assert!(!output.output.contains("ignored.rs"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_matches_reports_clearly() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = GlobTool
|
||||
.execute(
|
||||
serde_json::json!({"pattern": "*.nonexistent"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.output, "(no matches)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use globset::GlobBuilder;
|
||||
use grep_regex::RegexMatcher;
|
||||
use grep_searcher::sinks::UTF8;
|
||||
use grep_searcher::SearcherBuilder;
|
||||
use harness_core::tool::{Tool, ToolCtx, ToolError, ToolOutput};
|
||||
use ignore::WalkBuilder;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
const MAX_RESULTS: usize = 100;
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct GrepParams {
|
||||
pattern: String,
|
||||
path: Option<String>,
|
||||
include: Option<String>,
|
||||
}
|
||||
|
||||
struct Hit {
|
||||
file: PathBuf,
|
||||
line_number: u64,
|
||||
line: String,
|
||||
mtime: SystemTime,
|
||||
}
|
||||
|
||||
/// Content search via ripgrep's engine as a library — read-only, no permission ask.
|
||||
pub struct GrepTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GrepTool {
|
||||
fn name(&self) -> &str {
|
||||
"grep"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Searches file contents by regex (gitignore-aware), most recently modified first."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
serde_json::to_value(schemars::schema_for!(GrepParams)).unwrap()
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
input: serde_json::Value,
|
||||
ctx: ToolCtx,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: GrepParams =
|
||||
serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?;
|
||||
let root = params
|
||||
.path
|
||||
.as_ref()
|
||||
.map(|p| paths::resolve(&ctx.cwd, p))
|
||||
.unwrap_or_else(|| ctx.cwd.clone());
|
||||
|
||||
let matcher =
|
||||
RegexMatcher::new(¶ms.pattern).map_err(|e| ToolError::Invalid(e.to_string()))?;
|
||||
let include_matcher = params
|
||||
.include
|
||||
.as_ref()
|
||||
.map(|inc| GlobBuilder::new(inc).literal_separator(true).build())
|
||||
.transpose()
|
||||
.map_err(|e| ToolError::Invalid(e.to_string()))?
|
||||
.map(|g| g.compile_matcher());
|
||||
|
||||
let mut hits: Vec<Hit> = Vec::new();
|
||||
for entry in WalkBuilder::new(&root)
|
||||
.hidden(false)
|
||||
.require_git(false)
|
||||
.build()
|
||||
{
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let rel = entry.path().strip_prefix(&root).unwrap_or(entry.path());
|
||||
if let Some(glob) = &include_matcher {
|
||||
if !glob.is_match(rel) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let mtime = entry
|
||||
.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
|
||||
let path = entry.path().to_path_buf();
|
||||
let mut searcher = SearcherBuilder::new().line_number(true).build();
|
||||
let _ = searcher.search_path(
|
||||
&matcher,
|
||||
&path,
|
||||
UTF8(|line_number, line| {
|
||||
hits.push(Hit {
|
||||
file: path.clone(),
|
||||
line_number,
|
||||
line: line.trim_end().to_string(),
|
||||
mtime,
|
||||
});
|
||||
Ok(true)
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
hits.sort_by_key(|h| std::cmp::Reverse(h.mtime));
|
||||
let truncated = hits.len() > MAX_RESULTS;
|
||||
hits.truncate(MAX_RESULTS);
|
||||
|
||||
let mut output = hits
|
||||
.iter()
|
||||
.map(|h| format!("{}:{}:{}", h.file.display(), h.line_number, h.line))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if truncated {
|
||||
output.push_str("\n\n(Results are truncated: showing first 100 matches. Consider a more specific pattern.)");
|
||||
}
|
||||
if output.is_empty() {
|
||||
output = "(no matches)".to_string();
|
||||
}
|
||||
Ok(ToolOutput::new(params.pattern, output))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use harness_core::event::EventBus;
|
||||
use harness_core::permission::PermissionService;
|
||||
use harness_core::tool::{MetadataSink, PermissionHandle};
|
||||
use harness_core::types::SessionId;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn ctx(cwd: std::path::PathBuf) -> ToolCtx {
|
||||
let bus = EventBus::new();
|
||||
let service = Arc::new(PermissionService::new(bus));
|
||||
let (metadata, _rx) = MetadataSink::channel();
|
||||
ToolCtx {
|
||||
session_id: SessionId::new(),
|
||||
message_id: harness_core::types::MessageId::new(),
|
||||
call_id: "call_1".into(),
|
||||
data_dir: cwd.join("tool-output"),
|
||||
cwd,
|
||||
cancel: CancellationToken::new(),
|
||||
ask: PermissionHandle::new(
|
||||
service,
|
||||
SessionId::new(),
|
||||
Vec::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
CancellationToken::new(),
|
||||
),
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finds_matching_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("a.rs"), "fn main() {}\nfn helper() {}\n").unwrap();
|
||||
let output = GrepTool
|
||||
.execute(
|
||||
serde_json::json!({"pattern": "fn main"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(output.output.contains("a.rs:1:fn main() {}"));
|
||||
assert!(!output.output.contains("helper"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filters_by_include_glob() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("a.rs"), "needle\n").unwrap();
|
||||
std::fs::write(dir.path().join("b.txt"), "needle\n").unwrap();
|
||||
let output = GrepTool
|
||||
.execute(
|
||||
serde_json::json!({"pattern": "needle", "include": "*.rs"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(output.output.contains("a.rs"));
|
||||
assert!(!output.output.contains("b.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_matches_reports_clearly() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("a.rs"), "content\n").unwrap();
|
||||
let output = GrepTool
|
||||
.execute(
|
||||
serde_json::json!({"pattern": "nonexistent"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.output, "(no matches)");
|
||||
}
|
||||
}
|
||||
@@ -1 +1,25 @@
|
||||
// Built-in tools (bash, read, edit, write, glob, grep, todo, webfetch, task) land here in M1/M4.
|
||||
mod bash;
|
||||
mod glob;
|
||||
mod grep;
|
||||
mod paths;
|
||||
mod read;
|
||||
mod write;
|
||||
|
||||
pub use bash::BashTool;
|
||||
pub use glob::GlobTool;
|
||||
pub use grep::GrepTool;
|
||||
pub use read::ReadTool;
|
||||
pub use write::WriteTool;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use harness_core::tool::ToolRegistry;
|
||||
|
||||
/// Registers the M1 built-ins (`edit` is added separately once its replacer chain lands).
|
||||
pub fn register_builtins(registry: &mut ToolRegistry) {
|
||||
registry.register(Arc::new(ReadTool));
|
||||
registry.register(Arc::new(WriteTool));
|
||||
registry.register(Arc::new(BashTool));
|
||||
registry.register(Arc::new(GlobTool));
|
||||
registry.register(Arc::new(GrepTool));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Resolves a (possibly relative) tool-supplied path against `cwd`.
|
||||
pub fn resolve(cwd: &Path, file_path: &str) -> PathBuf {
|
||||
let path = Path::new(file_path);
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
cwd.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Slash-separated path relative to `cwd`, used as the permission pattern — falls back to
|
||||
/// the absolute path (still slash-separated) when `path` isn't under `cwd`.
|
||||
pub fn relative_pattern(cwd: &Path, path: &Path) -> String {
|
||||
let rel = path.strip_prefix(cwd).unwrap_or(path);
|
||||
rel.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_joins_relative_paths_to_cwd() {
|
||||
let cwd = Path::new("/home/user/project");
|
||||
assert_eq!(
|
||||
resolve(cwd, "src/main.rs"),
|
||||
PathBuf::from("/home/user/project/src/main.rs")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_leaves_absolute_paths_untouched() {
|
||||
let cwd = Path::new("/home/user/project");
|
||||
assert_eq!(resolve(cwd, "/etc/hosts"), PathBuf::from("/etc/hosts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_pattern_strips_cwd_prefix() {
|
||||
let cwd = Path::new("/home/user/project");
|
||||
let path = Path::new("/home/user/project/src/main.rs");
|
||||
assert_eq!(relative_pattern(cwd, path), "src/main.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_pattern_falls_back_to_absolute_outside_cwd() {
|
||||
let cwd = Path::new("/home/user/project");
|
||||
let path = Path::new("/etc/hosts");
|
||||
assert_eq!(relative_pattern(cwd, path), "/etc/hosts");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use async_trait::async_trait;
|
||||
use harness_core::tool::{Tool, ToolCtx, ToolError, ToolOutput};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
const DEFAULT_LIMIT: usize = 2000;
|
||||
const LINE_CHAR_CLAMP: usize = 2000;
|
||||
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "svg"];
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ReadParams {
|
||||
file_path: String,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub struct ReadTool;
|
||||
|
||||
fn is_binary(bytes: &[u8]) -> bool {
|
||||
bytes.iter().take(8000).any(|&b| b == 0)
|
||||
}
|
||||
|
||||
fn clamp_line(line: &str) -> String {
|
||||
if line.chars().count() <= LINE_CHAR_CLAMP {
|
||||
line.to_string()
|
||||
} else {
|
||||
let clamped: String = line.chars().take(LINE_CHAR_CLAMP).collect();
|
||||
format!("{clamped}... [line truncated]")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ReadTool {
|
||||
fn name(&self) -> &str {
|
||||
"read"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Reads a file from the local filesystem, returning `cat -n`-style numbered lines."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
serde_json::to_value(schemars::schema_for!(ReadParams)).unwrap()
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
input: serde_json::Value,
|
||||
ctx: ToolCtx,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: ReadParams =
|
||||
serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?;
|
||||
let path = paths::resolve(&ctx.cwd, ¶ms.file_path);
|
||||
let pattern = paths::relative_pattern(&ctx.cwd, &path);
|
||||
ctx.ask
|
||||
.ask(
|
||||
"read",
|
||||
pattern.clone(),
|
||||
pattern,
|
||||
serde_json::json!({"file_path": params.file_path}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
if IMAGE_EXTENSIONS.contains(&ext.to_lowercase().as_str()) {
|
||||
return Ok(ToolOutput::new(
|
||||
params.file_path.clone(),
|
||||
format!(
|
||||
"{}: image file — binary content not shown",
|
||||
params.file_path
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
.map_err(|e| ToolError::Other(format!("{}: {e}", path.display())))?;
|
||||
if is_binary(&bytes) {
|
||||
return Ok(ToolOutput::new(
|
||||
params.file_path.clone(),
|
||||
format!("{}: binary file — content not shown", params.file_path),
|
||||
));
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let limit = params.limit.unwrap_or(DEFAULT_LIMIT);
|
||||
let numbered: Vec<String> = text
|
||||
.lines()
|
||||
.enumerate()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.map(|(i, line)| format!("{:>6}\t{}", i + 1, clamp_line(line)))
|
||||
.collect();
|
||||
|
||||
if numbered.is_empty() {
|
||||
return Ok(ToolOutput::new(
|
||||
params.file_path.clone(),
|
||||
"(empty file or offset past end)".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(ToolOutput::new(
|
||||
params.file_path.clone(),
|
||||
numbered.join("\n"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use harness_core::event::EventBus;
|
||||
use harness_core::permission::{spawn_auto_approve, PermissionService};
|
||||
use harness_core::tool::{MetadataSink, PermissionHandle};
|
||||
use harness_core::types::SessionId;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn ctx(cwd: std::path::PathBuf) -> ToolCtx {
|
||||
let bus = EventBus::new();
|
||||
let service = Arc::new(PermissionService::new(bus.clone()));
|
||||
spawn_auto_approve(bus, service.clone());
|
||||
let (metadata, _rx) = MetadataSink::channel();
|
||||
ToolCtx {
|
||||
session_id: SessionId::new(),
|
||||
message_id: harness_core::types::MessageId::new(),
|
||||
call_id: "call_1".into(),
|
||||
data_dir: cwd.join("tool-output"),
|
||||
cwd,
|
||||
cancel: CancellationToken::new(),
|
||||
ask: PermissionHandle::new(
|
||||
service,
|
||||
SessionId::new(),
|
||||
Vec::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
CancellationToken::new(),
|
||||
),
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_and_numbers_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "line one\nline two\nline three").unwrap();
|
||||
let output = ReadTool
|
||||
.execute(
|
||||
serde_json::json!({"file_path": "a.txt"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
output.output,
|
||||
" 1\tline one\n 2\tline two\n 3\tline three"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn respects_offset_and_limit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "a\nb\nc\nd\ne").unwrap();
|
||||
let output = ReadTool
|
||||
.execute(
|
||||
serde_json::json!({"file_path": "a.txt", "offset": 1, "limit": 2}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.output, " 2\tb\n 3\tc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detects_binary_content() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("bin.dat"), [0u8, 1, 2, 3]).unwrap();
|
||||
let output = ReadTool
|
||||
.execute(
|
||||
serde_json::json!({"file_path": "bin.dat"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(output.output.contains("binary file"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_file_is_a_tool_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let err = ReadTool
|
||||
.execute(
|
||||
serde_json::json!({"file_path": "missing.txt"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::Other(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use async_trait::async_trait;
|
||||
use harness_core::tool::{Tool, ToolCtx, ToolError, ToolOutput};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use similar::TextDiff;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct WriteParams {
|
||||
file_path: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
pub struct WriteTool;
|
||||
|
||||
fn unified_diff(before: &str, after: &str, file_path: &str) -> String {
|
||||
TextDiff::from_lines(before, after)
|
||||
.unified_diff()
|
||||
.header(&format!("a/{file_path}"), &format!("b/{file_path}"))
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WriteTool {
|
||||
fn name(&self) -> &str {
|
||||
"write"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Writes content to a file, creating it (and parent directories) if needed."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
serde_json::to_value(schemars::schema_for!(WriteParams)).unwrap()
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
input: serde_json::Value,
|
||||
ctx: ToolCtx,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: WriteParams =
|
||||
serde_json::from_value(input).map_err(|e| ToolError::Invalid(e.to_string()))?;
|
||||
let path = paths::resolve(&ctx.cwd, ¶ms.file_path);
|
||||
let pattern = paths::relative_pattern(&ctx.cwd, &path);
|
||||
|
||||
let before = tokio::fs::read_to_string(&path).await.unwrap_or_default();
|
||||
let diff = unified_diff(&before, ¶ms.content, ¶ms.file_path);
|
||||
|
||||
// Same permission key as opencode's edit tool — writing is an edit.
|
||||
ctx.ask
|
||||
.ask(
|
||||
"edit",
|
||||
pattern,
|
||||
"*",
|
||||
serde_json::json!({"file_path": params.file_path, "diff": diff}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| ToolError::Other(format!("{}: {e}", parent.display())))?;
|
||||
}
|
||||
tokio::fs::write(&path, ¶ms.content)
|
||||
.await
|
||||
.map_err(|e| ToolError::Other(format!("{}: {e}", path.display())))?;
|
||||
|
||||
let added = diff
|
||||
.lines()
|
||||
.filter(|l| l.starts_with('+') && !l.starts_with("+++"))
|
||||
.count();
|
||||
let removed = diff
|
||||
.lines()
|
||||
.filter(|l| l.starts_with('-') && !l.starts_with("---"))
|
||||
.count();
|
||||
let mut output = ToolOutput::new(
|
||||
params.file_path.clone(),
|
||||
format!("wrote {} bytes", params.content.len()),
|
||||
);
|
||||
output.metadata = serde_json::json!({"diff": diff, "added": added, "removed": removed});
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use harness_core::event::EventBus;
|
||||
use harness_core::permission::{spawn_auto_approve, PermissionService};
|
||||
use harness_core::tool::{MetadataSink, PermissionHandle};
|
||||
use harness_core::types::SessionId;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn ctx(cwd: std::path::PathBuf) -> ToolCtx {
|
||||
let bus = EventBus::new();
|
||||
let service = Arc::new(PermissionService::new(bus.clone()));
|
||||
spawn_auto_approve(bus, service.clone());
|
||||
let (metadata, _rx) = MetadataSink::channel();
|
||||
ToolCtx {
|
||||
session_id: SessionId::new(),
|
||||
message_id: harness_core::types::MessageId::new(),
|
||||
call_id: "call_1".into(),
|
||||
data_dir: cwd.join("tool-output"),
|
||||
cwd,
|
||||
cancel: CancellationToken::new(),
|
||||
ask: PermissionHandle::new(
|
||||
service,
|
||||
SessionId::new(),
|
||||
Vec::new(),
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
CancellationToken::new(),
|
||||
),
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_file_and_parent_dirs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
WriteTool
|
||||
.execute(
|
||||
serde_json::json!({"file_path": "nested/dir/a.txt", "content": "hello"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let content = std::fs::read_to_string(dir.path().join("nested/dir/a.txt")).unwrap();
|
||||
assert_eq!(content, "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overwrites_existing_file_and_reports_diff() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "old\n").unwrap();
|
||||
let output = WriteTool
|
||||
.execute(
|
||||
serde_json::json!({"file_path": "a.txt", "content": "new\n"}),
|
||||
ctx(dir.path().to_path_buf()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let content = std::fs::read_to_string(dir.path().join("a.txt")).unwrap();
|
||||
assert_eq!(content, "new\n");
|
||||
assert_eq!(output.metadata["added"], 1);
|
||||
assert_eq!(output.metadata["removed"], 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user