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:
@@ -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