diff --git a/Cargo.lock b/Cargo.lock index 5e316fe..d53e58b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,7 +368,11 @@ dependencies = [ "async-trait", "beyond-ai-agent-core", "clap", + "globset", + "ignore", + "regex", "serde_json", + "tempfile", "tokio", "tracing", "tracing-subscriber", @@ -1323,6 +1327,19 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "h2" version = "0.4.14" @@ -1645,6 +1662,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "1.9.3" diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 146b2cb..9324aa0 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -22,3 +22,13 @@ serde_json.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true + +# Coding tools: gitignore-aware walking (ripgrep's `ignore`), regex search (`grep`), glob matching +# (`find`) — the same crates ripgrep/fd are built on. +globset = "0.4" +ignore = "0.4" +regex = "1" + +[dev-dependencies] +# Scratch files/dirs for the file-tool tests. +tempfile = "3" diff --git a/crates/agent/src/main.rs b/crates/agent/src/main.rs index dc7aa33..b11591e 100644 --- a/crates/agent/src/main.rs +++ b/crates/agent/src/main.rs @@ -1,16 +1,29 @@ //! Beyond agent harness — CLI. //! -//! Scaffold. The command surface is the one the harness targets: a one-shot `run` and a headless -//! `serve` (with an attachable control API, for remote control over SSH). Neither is wired to the -//! agent loop yet — that arrives with the loop (M4), the coding tools (M6), and the control API -//! (M7). `tools` is a working, no-network demo that the core links and the async `Tool` seam runs. +//! `run` drives a one-shot coding task to completion through the gateway. `serve` (M7) will expose +//! the headless control API. `tools` lists the advertised tool set. Model traffic always flows +//! through the gateway (`AI_GATEWAY_URL`) authenticated with a `bai_v1` key (`AI_AGENT_KEY`). +// Unit tests assert preconditions with `.unwrap()`; allow that under `test` (matches the gateway and +// agent-core crate roots). Production paths stay panic-free per the workspace lints. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] + +use std::io::Write as _; use std::sync::Arc; -use agent_core::error::ToolError; -use agent_core::{Tool, ToolRegistry}; +use agent_core::{Agent, GatewayClient, Session, StreamEvent}; use clap::{Parser, Subcommand}; -use serde_json::{Value, json}; + +mod tools; + +/// Default model when neither `--model` nor `AI_AGENT_MODEL` is set. +const DEFAULT_MODEL: &str = "claude-opus-4-8"; +/// Default gateway base URL. +const DEFAULT_GATEWAY: &str = "http://ai.internal"; + +const SYSTEM_PROMPT: &str = "You are the Beyond coding agent. You operate inside a real working \ +directory with tools: read, write, edit, bash, ls, grep, find. Use them to accomplish the user's \ +task directly — inspect before you change, make minimal edits, and verify your work. Be concise."; #[derive(Parser)] #[command(name = "beyond-ai-agent", version, about = "Beyond agent harness")] @@ -21,14 +34,26 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Run a one-shot agent task to completion. (Agent loop lands in a later milestone.) + /// Run a one-shot agent task to completion, streaming output to stdout. Run { /// The task prompt for the agent. task: String, + /// Model id (default `claude-opus-4-8`, or `AI_AGENT_MODEL`). + #[arg(long, env = "AI_AGENT_MODEL")] + model: Option, + /// Gateway base URL (default `http://ai.internal`, or `AI_GATEWAY_URL`). + #[arg(long, env = "AI_GATEWAY_URL")] + gateway_url: Option, + /// Virtual key (`bai_v1…`) or BYO provider key. Required; or set `AI_AGENT_KEY`. + #[arg(long, env = "AI_AGENT_KEY")] + key: Option, + /// Max loop iterations before bailing. + #[arg(long, default_value_t = 24)] + max_steps: u32, }, - /// Run the headless agent server exposing an attachable control API. (Later milestone.) + /// Run the headless agent server exposing an attachable control API. (M7.) Serve, - /// List the tools the agent advertises to the model, and run one (no-network scaffold demo). + /// List the tools the agent advertises to the model. Tools, } @@ -39,62 +64,66 @@ async fn main() -> Result<(), Box> { .try_init(); match Cli::parse().command { - Command::Tools => demo_tools().await?, - Command::Run { task } => { - println!("scaffold: `run` is not wired yet (agent loop = M4, CLI = M6)."); - println!("would drive the agent loop to completion for task: {task:?}"); + Command::Run { + task, + model, + gateway_url, + key, + max_steps, + } => { + run_task(task, model, gateway_url, key, max_steps).await?; } Command::Serve => { println!("scaffold: `serve` is not wired yet (headless control API = M7)."); } + Command::Tools => { + let reg = tools::default_registry(); + println!("{} tools:\n", reg.len()); + println!("{}", serde_json::to_string_pretty(®.definitions())?); + } } Ok(()) } -/// Build the agent's tool registry. As milestones land, the core Read/Write/Edit/Bash and the Beyond -/// fork/sync/logs tools register here; today it carries a single placeholder. -fn registry() -> ToolRegistry { - let mut reg = ToolRegistry::new(); - reg.register(Arc::new(EchoTool)); - reg -} +async fn run_task( + task: String, + model: Option, + gateway_url: Option, + key: Option, + max_steps: u32, +) -> Result<(), Box> { + let gateway = gateway_url.unwrap_or_else(|| DEFAULT_GATEWAY.to_string()); + let model = model.unwrap_or_else(|| DEFAULT_MODEL.to_string()); + let key = + key.ok_or("no gateway key: pass --key or set AI_AGENT_KEY (a bai_v1… virtual key)")?; -async fn demo_tools() -> Result<(), Box> { - let reg = registry(); - println!("{} tool(s) registered:\n", reg.len()); - println!("{}", serde_json::to_string_pretty(®.definitions())?); - // Prove the async tool seam runs end to end — no network, no model. - if let Some(echo) = reg.get("echo") { - let out = echo.run(json!({ "text": "harness online" })).await?; - println!("\necho.run -> {out:?}"); - } - Ok(()) -} + let client = GatewayClient::new(gateway, key)?; + let agent = Agent::new(Arc::new(client), model) + .with_tools(tools::default_registry()) + .with_system(SYSTEM_PROMPT) + .with_max_steps(max_steps); -/// Placeholder built-in so the scaffold demonstrates the registry + async tool path. Replaced by the -/// real coding tools (Read/Write/Edit/Bash) in M6. -struct EchoTool; + let mut session = Session::new(); + session.user(task); -#[async_trait::async_trait] -impl Tool for EchoTool { - fn name(&self) -> &str { - "echo" - } - fn description(&self) -> &str { - "Echo the `text` argument back (scaffold placeholder)." - } - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { "text": { "type": "string" } }, - "required": ["text"], + // Render assistant text live; surface tool activity on its own line. + agent + .run(&mut session, |ev| match ev { + StreamEvent::TextDelta { text } => { + print!("{text}"); + let _ = std::io::stdout().flush(); + } + StreamEvent::ToolUseStart { name, .. } => { + println!("\n[tool: {name}]"); + } + _ => {} }) - } - async fn run(&self, input: Value) -> Result { - input - .get("text") - .and_then(Value::as_str) - .map(str::to_string) - .ok_or_else(|| ToolError::InvalidInput("missing `text`".into())) - } + .await?; + + println!(); + eprintln!( + "[done in {} step(s); {} in / {} out tokens]", + session.steps, session.input_tokens, session.output_tokens + ); + Ok(()) } diff --git a/crates/agent/src/tools/bash.rs b/crates/agent/src/tools/bash.rs new file mode 100644 index 0000000..fafbc6e --- /dev/null +++ b/crates/agent/src/tools/bash.rs @@ -0,0 +1,206 @@ +//! `bash` — run a shell command via `sh -c` and return its combined output. + +use std::sync::Arc; +use std::time::Duration; + +use agent_core::ToolError; +use agent_core::tool::Tool; +use async_trait::async_trait; +use serde_json::{Value, json}; + +use super::exec::{CommandRunner, RealRunner}; + +/// Default command timeout (ms) when the model doesn't specify one. +const DEFAULT_TIMEOUT_MS: u64 = 120_000; +/// Cap on returned output bytes (head + tail kept) to protect the model's context. +const MAX_OUTPUT: usize = 30_000; + +pub struct Bash { + runner: Arc, +} + +impl Bash { + /// A `bash` tool that runs commands for real. + pub fn real() -> Self { + Self { + runner: Arc::new(RealRunner), + } + } + + /// A `bash` tool over a custom runner (tests inject one to capture the invocation). + #[cfg(test)] + pub fn with_runner(runner: Arc) -> Self { + Self { runner } + } +} + +#[async_trait] +impl Tool for Bash { + fn name(&self) -> &str { + "bash" + } + fn description(&self) -> &str { + "Run a shell command via `sh -c` and return its combined stdout/stderr. Supports an optional \ + `cwd` and `timeout_ms`." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "command": { "type": "string", "description": "Shell command to run." }, + "cwd": { "type": "string", "description": "Working directory." }, + "timeout_ms": { "type": "integer", "description": "Timeout in milliseconds." } + }, + "required": ["command"] + }) + } + + async fn run(&self, input: Value) -> Result { + let command = input + .get("command") + .and_then(Value::as_str) + .ok_or_else(|| ToolError::InvalidInput("missing `command`".into()))?; + let cwd = input.get("cwd").and_then(Value::as_str); + let timeout_ms = input + .get("timeout_ms") + .and_then(Value::as_u64) + .unwrap_or(DEFAULT_TIMEOUT_MS); + + let args = vec!["-c".to_string(), command.to_string()]; + let result = self + .runner + .run("sh", &args, cwd, Duration::from_millis(timeout_ms)) + .await + .map_err(|e| ToolError::Execution(format!("spawn failed: {e}")))?; + + if result.timed_out { + return Err(ToolError::Execution(format!( + "command timed out after {timeout_ms}ms" + ))); + } + + let mut out = String::new(); + out.push_str(result.stdout.trim_end()); + if !result.stderr.trim().is_empty() { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(result.stderr.trim_end()); + } + match result.code { + Some(0) => {} + Some(code) => out.push_str(&format!("\n[exit code {code}]")), + None => out.push_str("\n[killed]"), + } + Ok(truncate(out)) + } +} + +/// Keep the head and tail of oversized output; the middle is what's least useful to the model. +fn truncate(s: String) -> String { + if s.len() <= MAX_OUTPUT { + return s; + } + let half = MAX_OUTPUT / 2; + let head: String = s.chars().take(half).collect(); + let tail: String = s + .chars() + .rev() + .take(half) + .collect::() + .chars() + .rev() + .collect(); + format!("{head}\n… (output truncated) …\n{tail}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::exec::ExecResult; + + /// Records the last invocation and returns a canned result. + struct RecordingRunner { + last: std::sync::Mutex)>>, + result: ExecResult, + } + + #[async_trait] + impl CommandRunner for RecordingRunner { + async fn run( + &self, + program: &str, + args: &[String], + _cwd: Option<&str>, + _t: Duration, + ) -> std::io::Result { + *self.last.lock().unwrap() = Some((program.to_string(), args.to_vec())); + Ok(self.result.clone()) + } + } + + #[tokio::test] + async fn invokes_sh_dash_c() { + let runner = Arc::new(RecordingRunner { + last: std::sync::Mutex::new(None), + result: ExecResult { + code: Some(0), + stdout: "hi\n".into(), + stderr: String::new(), + timed_out: false, + }, + }); + let bash = Bash::with_runner(runner.clone()); + let out = bash.run(json!({ "command": "echo hi" })).await.unwrap(); + assert_eq!(out, "hi"); + let (prog, args) = runner.last.lock().unwrap().clone().unwrap(); + assert_eq!(prog, "sh"); + assert_eq!(args, vec!["-c".to_string(), "echo hi".to_string()]); + } + + #[tokio::test] + async fn appends_nonzero_exit_code() { + let runner = Arc::new(RecordingRunner { + last: std::sync::Mutex::new(None), + result: ExecResult { + code: Some(2), + stdout: String::new(), + stderr: "boom".into(), + timed_out: false, + }, + }); + let out = Bash::with_runner(runner) + .run(json!({ "command": "false" })) + .await + .unwrap(); + assert!(out.contains("boom")); + assert!(out.contains("[exit code 2]")); + } + + #[tokio::test] + async fn real_runner_executes() { + let out = Bash::real() + .run(json!({ "command": "printf done" })) + .await + .unwrap(); + assert_eq!(out, "done"); + } + + #[tokio::test] + async fn timeout_is_reported() { + let runner = Arc::new(RecordingRunner { + last: std::sync::Mutex::new(None), + result: ExecResult { + code: None, + stdout: String::new(), + stderr: String::new(), + timed_out: true, + }, + }); + let err = Bash::with_runner(runner) + .run(json!({ "command": "sleep 10", "timeout_ms": 5 })) + .await + .unwrap_err(); + assert!(matches!(err, ToolError::Execution(_))); + } +} diff --git a/crates/agent/src/tools/edit.rs b/crates/agent/src/tools/edit.rs new file mode 100644 index 0000000..1678b32 --- /dev/null +++ b/crates/agent/src/tools/edit.rs @@ -0,0 +1,187 @@ +//! `edit` — exact string replacement in a file (unique match unless `replace_all`). + +use agent_core::ToolError; +use agent_core::tool::Tool; +use async_trait::async_trait; +use serde_json::{Value, json}; + +pub struct Edit; + +#[async_trait] +impl Tool for Edit { + fn name(&self) -> &str { + "edit" + } + fn description(&self) -> &str { + "Apply exact-match replacements to a file. Pass an `edits` array of {old_string, new_string} \ + (each `old_string` must match exactly once), or a single old_string/new_string. With \ + `replace_all`, a single edit replaces every occurrence." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "File to edit." }, + "edits": { + "type": "array", + "description": "Ordered replacements; each old_string must match exactly once.", + "items": { + "type": "object", + "properties": { + "old_string": { "type": "string" }, + "new_string": { "type": "string" } + }, + "required": ["old_string", "new_string"] + } + }, + "old_string": { "type": "string", "description": "Single-edit form: exact text to replace." }, + "new_string": { "type": "string", "description": "Single-edit form: replacement text." }, + "replace_all": { "type": "boolean", "description": "Single-edit form: replace every occurrence." } + }, + "required": ["path"] + }) + } + + async fn run(&self, input: Value) -> Result { + let path = input + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| ToolError::InvalidInput("missing `path`".into()))?; + let edits = parse_edits(&input)?; + let replace_all = input + .get("replace_all") + .and_then(Value::as_bool) + .unwrap_or(false); + + let mut content = std::fs::read_to_string(path) + .map_err(|e| ToolError::Execution(format!("read {path}: {e}")))?; + let mut applied = 0usize; + for (old, new) in &edits { + let count = content.matches(old.as_str()).count(); + if count == 0 { + return Err(ToolError::InvalidInput(format!( + "`old_string` not found in {path}: {old:?}" + ))); + } + if count > 1 && !(replace_all && edits.len() == 1) { + return Err(ToolError::InvalidInput(format!( + "`old_string` is not unique in {path} ({count} matches): {old:?}; add surrounding context" + ))); + } + // Replace once per edit (or all, only for the single-edit replace_all form). + content = if replace_all && edits.len() == 1 { + applied += count; + content.replace(old.as_str(), new) + } else { + applied += 1; + content.replacen(old.as_str(), new, 1) + }; + } + std::fs::write(path, content) + .map_err(|e| ToolError::Execution(format!("write {path}: {e}")))?; + Ok(format!( + "edited {path} ({applied} replacement{})", + if applied == 1 { "" } else { "s" } + )) + } +} + +/// Accept either the `edits` array form (pi-style) or the single old_string/new_string form. +fn parse_edits(input: &Value) -> Result, ToolError> { + if let Some(arr) = input.get("edits").and_then(Value::as_array) { + if arr.is_empty() { + return Err(ToolError::InvalidInput("`edits` is empty".into())); + } + return arr + .iter() + .map(|e| { + let old = e.get("old_string").and_then(Value::as_str); + let new = e.get("new_string").and_then(Value::as_str); + match (old, new) { + (Some(o), Some(n)) => Ok((o.to_string(), n.to_string())), + _ => Err(ToolError::InvalidInput( + "each edit needs old_string and new_string".into(), + )), + } + }) + .collect(); + } + let old = input.get("old_string").and_then(Value::as_str); + let new = input.get("new_string").and_then(Value::as_str); + match (old, new) { + (Some(o), Some(n)) => Ok(vec![(o.to_string(), n.to_string())]), + _ => Err(ToolError::InvalidInput( + "provide `edits`, or `old_string` and `new_string`".into(), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_tmp(contents: &str) -> tempfile::NamedTempFile { + use std::io::Write as _; + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(contents.as_bytes()).unwrap(); + f + } + + #[tokio::test] + async fn replaces_unique_match() { + let f = write_tmp("the quick brown fox"); + let p = f.path().to_str().unwrap(); + Edit.run(json!({ "path": p, "old_string": "quick", "new_string": "slow" })) + .await + .unwrap(); + assert_eq!(std::fs::read_to_string(p).unwrap(), "the slow brown fox"); + } + + #[tokio::test] + async fn rejects_ambiguous_match() { + let f = write_tmp("a a a"); + let err = Edit + .run( + json!({ "path": f.path().to_str().unwrap(), "old_string": "a", "new_string": "b" }), + ) + .await + .unwrap_err(); + assert!(matches!(err, ToolError::InvalidInput(_))); + } + + #[tokio::test] + async fn replace_all_replaces_every_occurrence() { + let f = write_tmp("a a a"); + let p = f.path().to_str().unwrap(); + Edit.run(json!({ "path": p, "old_string": "a", "new_string": "b", "replace_all": true })) + .await + .unwrap(); + assert_eq!(std::fs::read_to_string(p).unwrap(), "b b b"); + } + + #[tokio::test] + async fn applies_edits_array_in_order() { + let f = write_tmp("foo and bar"); + let p = f.path().to_str().unwrap(); + Edit.run(json!({ + "path": p, + "edits": [ + { "old_string": "foo", "new_string": "baz" }, + { "old_string": "bar", "new_string": "qux" } + ] + })) + .await + .unwrap(); + assert_eq!(std::fs::read_to_string(p).unwrap(), "baz and qux"); + } + + #[tokio::test] + async fn missing_string_is_invalid_input() { + let f = write_tmp("hello"); + let err = Edit + .run(json!({ "path": f.path().to_str().unwrap(), "old_string": "zzz", "new_string": "x" })) + .await + .unwrap_err(); + assert!(matches!(err, ToolError::InvalidInput(_))); + } +} diff --git a/crates/agent/src/tools/exec.rs b/crates/agent/src/tools/exec.rs new file mode 100644 index 0000000..198f828 --- /dev/null +++ b/crates/agent/src/tools/exec.rs @@ -0,0 +1,76 @@ +//! A command-execution seam. +//! +//! `bash` runs real commands through [`RealRunner`]; the Beyond tools (fork/sync/logs, M8) run the +//! `beyond` CLI through the same trait, so their tests inject a [`CommandRunner`] that records the +//! argv instead of shelling out. This is the boundary that keeps shell-driven tools testable. + +use std::process::Stdio; +use std::time::Duration; + +use async_trait::async_trait; + +/// The result of running a command. +#[derive(Debug, Clone)] +pub struct ExecResult { + /// Process exit code, or `None` if killed (e.g. timed out). + pub code: Option, + pub stdout: String, + pub stderr: String, + /// True if the command was killed for exceeding its timeout. + pub timed_out: bool, +} + +/// Runs an external command. Implemented by [`RealRunner`] (production) and by test doubles that +/// capture the invocation. +#[async_trait] +pub trait CommandRunner: Send + Sync { + async fn run( + &self, + program: &str, + args: &[String], + cwd: Option<&str>, + timeout: Duration, + ) -> std::io::Result; +} + +/// Spawns the command for real, capturing stdout/stderr and enforcing a wall-clock timeout +/// (`kill_on_drop` reaps the child if it overruns). +pub struct RealRunner; + +#[async_trait] +impl CommandRunner for RealRunner { + async fn run( + &self, + program: &str, + args: &[String], + cwd: Option<&str>, + timeout: Duration, + ) -> std::io::Result { + let mut cmd = tokio::process::Command::new(program); + cmd.args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + let child = cmd.spawn()?; + match tokio::time::timeout(timeout, child.wait_with_output()).await { + Ok(result) => { + let out = result?; + Ok(ExecResult { + code: out.status.code(), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + timed_out: false, + }) + } + Err(_) => Ok(ExecResult { + code: None, + stdout: String::new(), + stderr: "command exceeded its timeout".into(), + timed_out: true, + }), + } + } +} diff --git a/crates/agent/src/tools/find.rs b/crates/agent/src/tools/find.rs new file mode 100644 index 0000000..a283607 --- /dev/null +++ b/crates/agent/src/tools/find.rs @@ -0,0 +1,124 @@ +//! `find` — locate files by glob, gitignore-aware (ripgrep's `ignore` + `globset`). + +use agent_core::ToolError; +use agent_core::tool::Tool; +use async_trait::async_trait; +use globset::Glob; +use ignore::WalkBuilder; +use serde_json::{Value, json}; + +/// Default cap on reported paths. +const DEFAULT_LIMIT: usize = 1000; + +pub struct Find; + +#[async_trait] +impl Tool for Find { + fn name(&self) -> &str { + "find" + } + fn description(&self) -> &str { + "Find files by glob pattern (e.g. \"*.rs\", \"src/**/*.test.ts\"), honoring .gitignore. A \ + pattern without \"/\" matches the file name; with \"/\" it matches the full path." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pattern": { "type": "string", "description": "Glob pattern to match." }, + "path": { "type": "string", "description": "Directory to search (default \".\")." }, + "limit": { "type": "integer", "description": "Max results (default 1000)." } + }, + "required": ["pattern"] + }) + } + + async fn run(&self, input: Value) -> Result { + let pattern = input + .get("pattern") + .and_then(Value::as_str) + .ok_or_else(|| ToolError::InvalidInput("missing `pattern`".into()))?; + let root = input.get("path").and_then(Value::as_str).unwrap_or("."); + let limit = input + .get("limit") + .and_then(Value::as_u64) + .map(|n| n as usize) + .unwrap_or(DEFAULT_LIMIT); + + // A pattern with no path separator matches the basename; otherwise match the whole path + // (prepend `**/` so `src/**/*.rs`-style anchored patterns still match nested roots). + let basename_only = !pattern.contains('/'); + let glob_src = if basename_only || pattern.starts_with("**/") || pattern.starts_with('/') { + pattern.to_string() + } else { + format!("**/{pattern}") + }; + let matcher = Glob::new(&glob_src) + .map_err(|e| ToolError::InvalidInput(format!("bad glob: {e}")))? + .compile_matcher(); + + let mut out = String::new(); + let mut hits = 0usize; + for entry in WalkBuilder::new(root).hidden(false).build() { + if hits >= limit { + out.push_str(&format!( + "… (result limit {limit} reached; raise `limit` for more)\n" + )); + break; + } + let Ok(entry) = entry else { continue }; + if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) { + continue; + } + let path = entry.path(); + let candidate = if basename_only { + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + } else { + path.to_string_lossy().into_owned() + }; + if matcher.is_match(candidate.as_str()) { + out.push_str(&format!("{}\n", path.display())); + hits += 1; + } + } + if out.is_empty() { + return Ok(format!("no files matching {pattern:?}")); + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn finds_by_basename_glob() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("src")).unwrap(); + std::fs::write(dir.path().join("src/main.rs"), "").unwrap(); + std::fs::write(dir.path().join("src/lib.rs"), "").unwrap(); + std::fs::write(dir.path().join("README.md"), "").unwrap(); + + let out = Find + .run(json!({ "pattern": "*.rs", "path": dir.path().to_str().unwrap() })) + .await + .unwrap(); + assert!(out.contains("main.rs")); + assert!(out.contains("lib.rs")); + assert!(!out.contains("README.md")); + } + + #[tokio::test] + async fn no_match_is_reported() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), "").unwrap(); + let out = Find + .run(json!({ "pattern": "*.zzz", "path": dir.path().to_str().unwrap() })) + .await + .unwrap(); + assert!(out.contains("no files matching")); + } +} diff --git a/crates/agent/src/tools/grep.rs b/crates/agent/src/tools/grep.rs new file mode 100644 index 0000000..a8c36b2 --- /dev/null +++ b/crates/agent/src/tools/grep.rs @@ -0,0 +1,159 @@ +//! `grep` — regex search across files, gitignore-aware (ripgrep's `ignore` + `regex` crates). + +use agent_core::ToolError; +use agent_core::tool::Tool; +use async_trait::async_trait; +use globset::Glob; +use ignore::WalkBuilder; +use regex::RegexBuilder; +use serde_json::{Value, json}; + +/// Default cap on reported matches. +const DEFAULT_LIMIT: usize = 100; +/// Long match lines are clipped to keep output readable. +const MAX_LINE: usize = 500; + +pub struct Grep; + +#[async_trait] +impl Tool for Grep { + fn name(&self) -> &str { + "grep" + } + fn description(&self) -> &str { + "Search file contents by regular expression, honoring .gitignore. Optionally restrict to a \ + `path`, a `glob` (e.g. \"*.rs\"), case-insensitive with `ignore_case`." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pattern": { "type": "string", "description": "Regular expression to search for." }, + "path": { "type": "string", "description": "Directory or file to search (default \".\")." }, + "glob": { "type": "string", "description": "Only search files matching this glob." }, + "ignore_case": { "type": "boolean", "description": "Case-insensitive (default false)." }, + "limit": { "type": "integer", "description": "Max matches to report (default 100)." } + }, + "required": ["pattern"] + }) + } + + async fn run(&self, input: Value) -> Result { + let pattern = input + .get("pattern") + .and_then(Value::as_str) + .ok_or_else(|| ToolError::InvalidInput("missing `pattern`".into()))?; + let root = input.get("path").and_then(Value::as_str).unwrap_or("."); + let ignore_case = input + .get("ignore_case") + .and_then(Value::as_bool) + .unwrap_or(false); + let limit = input + .get("limit") + .and_then(Value::as_u64) + .map(|n| n as usize) + .unwrap_or(DEFAULT_LIMIT); + + let re = RegexBuilder::new(pattern) + .case_insensitive(ignore_case) + .build() + .map_err(|e| ToolError::InvalidInput(format!("bad regex: {e}")))?; + + let glob = match input.get("glob").and_then(Value::as_str) { + Some(g) => Some( + Glob::new(g) + .map_err(|e| ToolError::InvalidInput(format!("bad glob: {e}")))? + .compile_matcher(), + ), + None => None, + }; + + let mut out = String::new(); + let mut hits = 0usize; + // `hidden(false)` includes dotfiles (like ripgrep --hidden); .gitignore is respected by default. + for entry in WalkBuilder::new(root).hidden(false).build() { + if hits >= limit { + out.push_str(&format!( + "… (match limit {limit} reached; narrow the pattern or raise `limit`)\n" + )); + break; + } + let Ok(entry) = entry else { continue }; + if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) { + continue; + } + let path = entry.path(); + if let Some(g) = &glob { + if !g.is_match(path) { + continue; + } + } + // Skip non-UTF8 / binary files silently. + let Ok(content) = std::fs::read_to_string(path) else { + continue; + }; + for (i, line) in content.lines().enumerate() { + if hits >= limit { + break; + } + if re.is_match(line) { + let shown = if line.len() > MAX_LINE { + format!("{}… [truncated]", &line[..MAX_LINE]) + } else { + line.to_string() + }; + out.push_str(&format!("{}:{}: {}\n", path.display(), i + 1, shown)); + hits += 1; + } + } + } + if out.is_empty() { + return Ok(format!("no matches for {pattern:?}")); + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn finds_matches_with_path_and_line() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), "hello\nworld\nhello again\n").unwrap(); + std::fs::write(dir.path().join("b.log"), "nothing here\n").unwrap(); + + let out = Grep + .run(json!({ "pattern": "hello", "path": dir.path().to_str().unwrap() })) + .await + .unwrap(); + assert!(out.contains("a.txt:1: hello")); + assert!(out.contains("a.txt:3: hello again")); + assert!(!out.contains("b.log")); + } + + #[tokio::test] + async fn glob_filters_files() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("keep.rs"), "fn target() {}\n").unwrap(); + std::fs::write(dir.path().join("skip.txt"), "fn target() {}\n").unwrap(); + let out = Grep + .run(json!({ "pattern": "target", "path": dir.path().to_str().unwrap(), "glob": "*.rs" })) + .await + .unwrap(); + assert!(out.contains("keep.rs")); + assert!(!out.contains("skip.txt")); + } + + #[tokio::test] + async fn no_matches_is_reported() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), "abc\n").unwrap(); + let out = Grep + .run(json!({ "pattern": "zzz", "path": dir.path().to_str().unwrap() })) + .await + .unwrap(); + assert!(out.contains("no matches")); + } +} diff --git a/crates/agent/src/tools/ls.rs b/crates/agent/src/tools/ls.rs new file mode 100644 index 0000000..8b4e1d3 --- /dev/null +++ b/crates/agent/src/tools/ls.rs @@ -0,0 +1,80 @@ +//! `ls` — list a directory's entries (directories suffixed with `/`). + +use agent_core::ToolError; +use agent_core::tool::Tool; +use async_trait::async_trait; +use serde_json::{Value, json}; + +pub struct Ls; + +#[async_trait] +impl Tool for Ls { + fn name(&self) -> &str { + "ls" + } + fn description(&self) -> &str { + "List the entries of a directory. Directories are suffixed with `/`. Hidden entries are \ + shown only when `all` is true." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Directory to list (default \".\")." }, + "all": { "type": "boolean", "description": "Include dot-files (default false)." } + } + }) + } + + async fn run(&self, input: Value) -> Result { + let path = input.get("path").and_then(Value::as_str).unwrap_or("."); + let all = input.get("all").and_then(Value::as_bool).unwrap_or(false); + + let mut entries: Vec = Vec::new(); + let dir = + std::fs::read_dir(path).map_err(|e| ToolError::Execution(format!("ls {path}: {e}")))?; + for entry in dir { + let entry = entry.map_err(|e| ToolError::Execution(e.to_string()))?; + let name = entry.file_name().to_string_lossy().into_owned(); + if !all && name.starts_with('.') { + continue; + } + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + entries.push(if is_dir { format!("{name}/") } else { name }); + } + // Directories first, then alphabetical — stable, predictable output for the model. + entries.sort_by(|a, b| { + let (ad, bd) = (a.ends_with('/'), b.ends_with('/')); + bd.cmp(&ad).then_with(|| a.cmp(b)) + }); + if entries.is_empty() { + return Ok("(empty directory)".into()); + } + Ok(entries.join("\n")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn lists_dirs_first_and_hides_dotfiles() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("subdir")).unwrap(); + std::fs::write(dir.path().join("file.txt"), "x").unwrap(); + std::fs::write(dir.path().join(".hidden"), "x").unwrap(); + + let out = Ls + .run(json!({ "path": dir.path().to_str().unwrap() })) + .await + .unwrap(); + assert_eq!(out, "subdir/\nfile.txt"); + + let all = Ls + .run(json!({ "path": dir.path().to_str().unwrap(), "all": true })) + .await + .unwrap(); + assert!(all.contains(".hidden")); + } +} diff --git a/crates/agent/src/tools/mod.rs b/crates/agent/src/tools/mod.rs new file mode 100644 index 0000000..2a1f17c --- /dev/null +++ b/crates/agent/src/tools/mod.rs @@ -0,0 +1,44 @@ +//! The agent's coding tools — pi's tool set, ported. +//! +//! Each tool implements [`agent_core::Tool`]; [`default_registry`] assembles the set the agent +//! advertises to the model. The Beyond platform tools (fork/sync/logs) register here too once added. + +use std::sync::Arc; + +use agent_core::ToolRegistry; + +pub mod bash; +pub mod edit; +pub mod exec; +pub mod find; +pub mod grep; +pub mod ls; +pub mod read; +pub mod write; + +/// The default coding tool set: read, write, edit, bash, ls, grep, find. +pub fn default_registry() -> ToolRegistry { + let mut reg = ToolRegistry::new(); + reg.register(Arc::new(read::Read)); + reg.register(Arc::new(write::Write)); + reg.register(Arc::new(edit::Edit)); + reg.register(Arc::new(ls::Ls)); + reg.register(Arc::new(grep::Grep)); + reg.register(Arc::new(find::Find)); + reg.register(Arc::new(bash::Bash::real())); + reg +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_registry_has_the_pi_tool_set() { + let reg = default_registry(); + for name in ["read", "write", "edit", "bash", "ls", "grep", "find"] { + assert!(reg.get(name).is_some(), "missing tool: {name}"); + } + assert_eq!(reg.len(), 7); + } +} diff --git a/crates/agent/src/tools/read.rs b/crates/agent/src/tools/read.rs new file mode 100644 index 0000000..569300d --- /dev/null +++ b/crates/agent/src/tools/read.rs @@ -0,0 +1,118 @@ +//! `read` — read a file, optionally a line range, with line numbers. + +use agent_core::ToolError; +use agent_core::tool::Tool; +use async_trait::async_trait; +use serde_json::{Value, json}; + +/// Default cap on returned lines when no `limit` is given (keeps large files from flooding context). +const DEFAULT_LIMIT: usize = 2000; + +pub struct Read; + +#[async_trait] +impl Tool for Read { + fn name(&self) -> &str { + "read" + } + fn description(&self) -> &str { + "Read a text file. Optionally start at a 1-based line `offset` and return up to `limit` \ + lines. Output is line-numbered." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "File path to read." }, + "offset": { "type": "integer", "description": "1-based first line to read." }, + "limit": { "type": "integer", "description": "Max lines to return." } + }, + "required": ["path"] + }) + } + + async fn run(&self, input: Value) -> Result { + let path = input + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| ToolError::InvalidInput("missing `path`".into()))?; + let content = std::fs::read_to_string(path) + .map_err(|e| ToolError::Execution(format!("read {path}: {e}")))?; + if content.is_empty() { + return Ok("(empty file)".into()); + } + + let offset = input + .get("offset") + .and_then(Value::as_u64) + .unwrap_or(1) + .max(1) as usize; + let limit = input + .get("limit") + .and_then(Value::as_u64) + .map(|n| n as usize) + .unwrap_or(DEFAULT_LIMIT); + + let mut out = String::new(); + let mut shown = 0usize; + for (i, line) in content.lines().enumerate() { + let lineno = i + 1; + if lineno < offset { + continue; + } + if shown >= limit { + out.push_str(&format!( + "… (truncated at {limit} lines; pass a larger `limit` to see more)\n" + )); + break; + } + out.push_str(&format!("{lineno:>6}\t{line}\n")); + shown += 1; + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn tmp_file(contents: &str) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(contents.as_bytes()).unwrap(); + f + } + + #[tokio::test] + async fn reads_with_line_numbers() { + let f = tmp_file("alpha\nbeta\ngamma\n"); + let out = Read + .run(json!({ "path": f.path().to_str().unwrap() })) + .await + .unwrap(); + assert!(out.contains(" 1\talpha")); + assert!(out.contains(" 3\tgamma")); + } + + #[tokio::test] + async fn honors_offset_and_limit() { + let f = tmp_file("a\nb\nc\nd\ne\n"); + let out = Read + .run(json!({ "path": f.path().to_str().unwrap(), "offset": 2, "limit": 2 })) + .await + .unwrap(); + assert!(out.contains(" 2\tb")); + assert!(out.contains(" 3\tc")); + assert!(!out.contains("\td\n")); + } + + #[tokio::test] + async fn missing_file_is_execution_error() { + let err = Read + .run(json!({ "path": "/no/such/file/xyz" })) + .await + .unwrap_err(); + assert!(matches!(err, ToolError::Execution(_))); + } +} diff --git a/crates/agent/src/tools/write.rs b/crates/agent/src/tools/write.rs new file mode 100644 index 0000000..8c5c182 --- /dev/null +++ b/crates/agent/src/tools/write.rs @@ -0,0 +1,72 @@ +//! `write` — create or overwrite a file (creating parent directories). + +use agent_core::ToolError; +use agent_core::tool::Tool; +use async_trait::async_trait; +use serde_json::{Value, json}; + +pub struct Write; + +#[async_trait] +impl Tool for Write { + fn name(&self) -> &str { + "write" + } + fn description(&self) -> &str { + "Create or overwrite a file with the given contents. Parent directories are created." + } + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "File path to write." }, + "content": { "type": "string", "description": "Full file contents." } + }, + "required": ["path", "content"] + }) + } + + async fn run(&self, input: Value) -> Result { + let path = input + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| ToolError::InvalidInput("missing `path`".into()))?; + let content = input + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| ToolError::InvalidInput("missing `content`".into()))?; + if let Some(parent) = std::path::Path::new(path).parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|e| { + ToolError::Execution(format!("mkdir {}: {e}", parent.display())) + })?; + } + } + std::fs::write(path, content) + .map_err(|e| ToolError::Execution(format!("write {path}: {e}")))?; + Ok(format!("wrote {} bytes to {path}", content.len())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn writes_and_creates_parents() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested/dir/file.txt"); + let out = Write + .run(json!({ "path": path.to_str().unwrap(), "content": "hello" })) + .await + .unwrap(); + assert!(out.contains("5 bytes")); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello"); + } + + #[tokio::test] + async fn missing_content_is_invalid_input() { + let err = Write.run(json!({ "path": "/tmp/x" })).await.unwrap_err(); + assert!(matches!(err, ToolError::InvalidInput(_))); + } +} diff --git a/crates/agent/tests/run_e2e.rs b/crates/agent/tests/run_e2e.rs new file mode 100644 index 0000000..9102c42 --- /dev/null +++ b/crates/agent/tests/run_e2e.rs @@ -0,0 +1,152 @@ +//! End-to-end: the real `beyond-ai-agent run` binary against a mock model server. +//! +//! Scripts a two-turn exchange — the model calls the `read` tool, the loop runs it and feeds the +//! result back, the model replies and ends — and asserts the binary (a) performed the tool call and +//! (b) fed the file's contents back to the model on the second request. No gateway, no provider, no +//! network beyond loopback. This exercises the entire stack: CLI → GatewayClient → loop → tools. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::process::Command; +use std::sync::{Arc, Mutex}; +use std::thread; + +use serde_json::json; + +/// Build an Anthropic SSE turn that calls one tool with the given JSON-argument string. +fn turn_tool_use(id: &str, name: &str, args_json: &str) -> String { + let events = [ + json!({ "type": "message_start", "message": { "usage": { "input_tokens": 10, "output_tokens": 1 } } }), + json!({ "type": "content_block_start", "index": 0, "content_block": { "type": "tool_use", "id": id, "name": name, "input": {} } }), + json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": args_json } }), + json!({ "type": "content_block_stop", "index": 0 }), + json!({ "type": "message_delta", "delta": { "stop_reason": "tool_use" }, "usage": { "output_tokens": 8 } }), + json!({ "type": "message_stop" }), + ]; + sse(&events) +} + +/// Build an Anthropic SSE turn that emits text and ends. +fn turn_text(text: &str) -> String { + let events = [ + json!({ "type": "message_start", "message": { "usage": { "input_tokens": 12, "output_tokens": 1 } } }), + json!({ "type": "content_block_start", "index": 0, "content_block": { "type": "text", "text": "" } }), + json!({ "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": text } }), + json!({ "type": "content_block_stop", "index": 0 }), + json!({ "type": "message_delta", "delta": { "stop_reason": "end_turn" }, "usage": { "output_tokens": 6 } }), + json!({ "type": "message_stop" }), + ]; + sse(&events) +} + +fn sse(events: &[serde_json::Value]) -> String { + events.iter().map(|e| format!("data: {e}\n\n")).collect() +} + +/// Read a full HTTP/1.1 request (headers + Content-Length body) from a stream. +fn read_http_request(stream: &mut TcpStream) -> String { + let mut buf = Vec::new(); + let mut tmp = [0u8; 2048]; + loop { + let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n"); + if let Some(pos) = header_end { + let headers = String::from_utf8_lossy(&buf[..pos]).to_ascii_lowercase(); + let len = headers + .lines() + .find_map(|l| { + l.strip_prefix("content-length:") + .map(|v| v.trim().parse::().unwrap_or(0)) + }) + .unwrap_or(0); + let mut body = buf[pos + 4..].to_vec(); + while body.len() < len { + let n = stream.read(&mut tmp).unwrap_or(0); + if n == 0 { + break; + } + body.extend_from_slice(&tmp[..n]); + } + return String::from_utf8_lossy(&body).into_owned(); + } + let n = stream.read(&mut tmp).unwrap_or(0); + if n == 0 { + return String::from_utf8_lossy(&buf).into_owned(); + } + buf.extend_from_slice(&tmp[..n]); + } +} + +/// Spawn a model server that answers `responses` in order, recording each request body. +fn spawn_model_server(responses: Vec) -> (String, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let bodies = Arc::new(Mutex::new(Vec::new())); + let recorder = bodies.clone(); + thread::spawn(move || { + for resp in responses { + if let Ok((mut stream, _)) = listener.accept() { + let body = read_http_request(&mut stream); + recorder.lock().unwrap().push(body); + let http = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n{resp}" + ); + let _ = stream.write_all(http.as_bytes()); + let _ = stream.flush(); + } + } + }); + (format!("http://{addr}"), bodies) +} + +#[test] +fn run_binary_performs_tool_round_trip() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("hello.txt"), "secret-marker-42\n").unwrap(); + let abs = dir.path().join("hello.txt").to_string_lossy().into_owned(); + + let turn1 = turn_tool_use("toolu_1", "read", &json!({ "path": abs }).to_string()); + let turn2 = turn_text("I read the file."); + let (base, bodies) = spawn_model_server(vec![turn1, turn2]); + + let bin = env!("CARGO_BIN_EXE_beyond-ai-agent"); + let output = Command::new(bin) + .args([ + "run", + "read hello.txt and report it", + "--gateway-url", + &base, + "--key", + "bai_v1.test", + "--model", + "claude-test", + "--max-steps", + "4", + ]) + .current_dir(dir.path()) + .output() + .expect("spawn binary"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "binary failed.\nstdout: {stdout}\nstderr: {stderr}" + ); + assert!( + stdout.contains("[tool: read]"), + "should show the tool call.\nstdout: {stdout}" + ); + assert!( + stdout.contains("I read the file."), + "should print the final turn.\nstdout: {stdout}" + ); + + let bodies = bodies.lock().unwrap(); + assert_eq!(bodies.len(), 2, "expected two model requests"); + assert!( + bodies[1].contains("secret-marker-42"), + "the 2nd request must feed the file contents back as a tool_result.\nbody: {}", + bodies[1] + ); +}