diff --git a/README.md b/README.md index eb9cba7..0c50e49 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,13 @@ MCP servers, timeouts, permissions, and agent behavior. LLM settings come from e "plan_required": true, "execute_enabled": false }, - "permissions": [ - { "operations": ["read"], "paths": ["./*"], "mode": "allow" } - ] + "writable_paths": ["./src/*", "/tmp/out/*"], + "permissions": [] } ``` +By default all paths are **read-only**. Only paths listed in `writable_paths` can be written to (by `write_file`, `edit_file`, `execute`). Patterns use glob syntax (`**`, `*`, `?`). The `permissions` array is kept for advanced allow/deny rules and is checked as a fallback when `writable_paths` does not match. + ### Timeout Settings Timeouts can be set in `agent-runner.json` and overridden by CLI flags: @@ -137,9 +138,10 @@ API keys can be provided via: "keep_tokens": 20000, "trim_tokens": 4000 }, + "writable_paths": ["./src/*"], "permissions": [ { - "operations": ["read"], + "operations": ["write"], "paths": ["./*"], "mode": "allow" } @@ -158,10 +160,11 @@ agent-runner --agent-dir --prompt [OPTIONS] |--------|---------|-------------| | `--agent-dir` | (required) | Path to agent folder | | `--prompt` | (required) | Task prompt or path to a text file | -| `--plan-only` | `false` | Generate plan and exit without executing | +| `--plan-only` | `false` | Generate plan (`plan.json`) and exit without executing | | `--max-iterations` | `50` | Maximum agent loop iterations | | `--output-dir` | `./agent-output` | Output directory for reports and traces | | `--working-dir` | `.` | Working directory for filesystem/execute tools | +| `--writable-paths` | (from config) | Comma-separated glob patterns of writable paths (e.g. `./src/*,/tmp/*`) | | `--tool-timeout` | `120` | Timeout in seconds for each tool call | | `--run-limit` | `3600` | Maximum total run time in seconds | | `--verbose` | `false` | Print iteration details to stderr | @@ -186,28 +189,37 @@ The agent has these tools available by default: | `read_file` | Read file contents with line-based pagination | | `write_file` | Write content to a file (creates parent dirs) | | `edit_file` | Find-and-replace strings in a file | -| `glob` | Find files matching a glob pattern | -| `grep` | Search file contents with regex | +| `glob` | Find files matching a glob pattern (respects `.gitignore`) | +| `grep` | Search file contents with regex (respects `.gitignore`) | | `execute` | Run a shell command (when enabled) | +| `read_plan` | Read the structured execution plan (`plan.json`) | +| `update_plan` | Update a plan step's status (`pending`/`in_progress`/`done`/`skipped`) | | `task_done` | Signal task completion | | `write_todos` | Update internal todo list | | `compact_conversation` | Trigger conversation compaction | ### Permissions -Control which tools can access which paths: +By default **all paths are read-only** — `ls`, `read_file`, `glob`, and `grep` work everywhere. Write operations (`write_file`, `edit_file`, `execute`) are only allowed on paths matching `writable_paths`: + +```json +{ + "writable_paths": ["./src/*", "./tests/*", "/tmp/out/*"] +} +``` + +Patterns use glob syntax (`**` for recursive, `*` for single-level, `?` for one char). For advanced allow/deny rules, the legacy `permissions` array is still supported as a fallback: ```json { + "writable_paths": ["./src/*"], "permissions": [ - { "operations": ["read"], "paths": ["./*"], "mode": "allow" }, - { "operations": ["write"], "paths": ["./src/*"], "mode": "allow" }, { "operations": ["write"], "paths": ["./secrets/*"], "mode": "deny" } ] } ``` -Operations: `"read"` covers `ls`, `read_file`, `glob`, `grep`. `"write"` covers `write_file`, `edit_file`, `execute`. Paths support `/*` for prefix matching. +`writable_paths` can also be set via the `--writable-paths` CLI flag (comma-separated); CLI values are added on top of config values. ## Output @@ -216,7 +228,7 @@ After execution, the output directory contains: | File | Description | |------|-------------| | `run.json` | Detailed run log with per-iteration and per-tool TAT, errors, and exceptions | -| `plan.md` | Generated execution plan | +| `plan.json` | Structured execution plan (steps with status, readable/writable by the agent) | | `report.json` | Status, token usage, iterations, duration, todos | | `transcript.json` | Full message history | | `trace.jsonl` | Structured event log (one JSON object per line) | diff --git a/agent-runner/Cargo.lock b/agent-runner/Cargo.lock index 3a3cb7d..b21f025 100644 --- a/agent-runner/Cargo.lock +++ b/agent-runner/Cargo.lock @@ -9,7 +9,9 @@ dependencies = [ "async-trait", "chrono", "clap", - "glob", + "dotenvy", + "globset", + "ignore", "lettre", "regex", "reqwest", @@ -132,6 +134,16 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -245,6 +257,31 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[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 = "displaydoc" version = "0.2.5" @@ -256,6 +293,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "email-encoding" version = "0.4.1" @@ -417,10 +460,17 @@ dependencies = [ ] [[package]] -name = "glob" -version = "0.3.3" +name = "globset" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] [[package]] name = "h2" @@ -728,6 +778,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1074,9 +1140,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1201,6 +1267,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[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 = "schannel" version = "0.1.29" @@ -1703,6 +1778,16 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[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 = "want" version = "0.3.1" @@ -1835,6 +1920,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-core" version = "0.62.2" diff --git a/agent-runner/Cargo.toml b/agent-runner/Cargo.toml index bf034c9..2246240 100644 --- a/agent-runner/Cargo.toml +++ b/agent-runner/Cargo.toml @@ -9,7 +9,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", features = ["json"] } -glob = "0.3" +globset = "0.4" +ignore = "0.4" regex = "1" lettre = "0.11" chrono = "0.4" diff --git a/agent-runner/src/agent/planner.rs b/agent-runner/src/agent/planner.rs index d7633de..afa9267 100644 --- a/agent-runner/src/agent/planner.rs +++ b/agent-runner/src/agent/planner.rs @@ -1,7 +1,25 @@ use crate::provider::{Message, Provider}; use crate::trace::TraceLogger; +use chrono::Utc; +use serde::{Deserialize, Serialize}; use std::sync::Arc; +/// A single step in an execution plan. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlanStep { + pub id: u32, + pub description: String, + pub status: String, +} + +/// A structured execution plan, persisted as `plan.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Plan { + pub task: String, + pub created_at: String, + pub steps: Vec, +} + pub struct Planner { provider: Arc, trace: Arc, @@ -47,10 +65,38 @@ impl Planner { Ok(plan) } - pub fn save_plan(plan: &str, output_dir: &std::path::Path) -> Result<(), String> { + /// Parse the free-form plan text returned by the LLM into a structured `Plan`. + /// Each non-empty line becomes a step with an incrementing id (starting at 1) + /// and a default status of "pending". + pub fn parse_plan(plan_text: &str, task: &str) -> Plan { + let steps: Vec = plan_text + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .enumerate() + .map(|(i, line)| PlanStep { + id: (i + 1) as u32, + description: line.trim_start_matches(|c| c == '-' || c == '*' || c == ' ' || c == '\t') + .trim() + .to_string(), + status: "pending".to_string(), + }) + .collect(); + + Plan { + task: task.to_string(), + created_at: Utc::now().to_rfc3339(), + steps, + } + } + + /// Persist the structured plan as `plan.json` in the output directory. + pub fn save_plan_json(plan: &Plan, output_dir: &std::path::Path) -> Result<(), String> { std::fs::create_dir_all(output_dir) .map_err(|e| format!("Failed to create output dir: {}", e))?; - std::fs::write(output_dir.join("plan.md"), plan) - .map_err(|e| format!("Failed to write plan: {}", e)) + let json = serde_json::to_string_pretty(plan) + .map_err(|e| format!("Failed to serialize plan: {}", e))?; + std::fs::write(output_dir.join("plan.json"), json) + .map_err(|e| format!("Failed to write plan.json: {}", e)) } } diff --git a/agent-runner/src/config.rs b/agent-runner/src/config.rs index 971771f..07fc558 100644 --- a/agent-runner/src/config.rs +++ b/agent-runner/src/config.rs @@ -10,6 +10,8 @@ pub struct Config { #[serde(default)] pub permissions: Vec, #[serde(default)] + pub writable_paths: Vec, + #[serde(default)] pub subagents: Vec, #[serde(default)] pub agent: AgentConfig, @@ -220,6 +222,7 @@ impl Default for Config { mcp_servers: HashMap::new(), summarization: SummarizationConfig::default(), permissions: Vec::new(), + writable_paths: Vec::new(), subagents: Vec::new(), agent: AgentConfig::default(), timeouts: TimeoutConfig::default(), diff --git a/agent-runner/src/main.rs b/agent-runner/src/main.rs index 461f7b9..0811a2b 100644 --- a/agent-runner/src/main.rs +++ b/agent-runner/src/main.rs @@ -14,6 +14,7 @@ use summarization::Summarizer; use tools::compact::CompactTool; use tools::done::TaskDoneTool; use tools::filesystem::create_filesystem_tools; +use tools::plan::{ReadPlanTool, UpdatePlanTool}; use tools::todos::TodosTool; pub mod agent; @@ -60,6 +61,9 @@ pub struct Cli { #[arg(long, default_value_t = false)] pub sandbox: bool, + #[arg(long, value_delimiter = ',')] + pub writable_paths: Option>, + #[arg(long, default_value_t = 120, value_name = "SECONDS")] pub tool_timeout: u64, @@ -113,7 +117,15 @@ async fn main() { } }); - let evaluator = PermissionEvaluator::new(agent_dir.config.permissions.clone()); + let mut writable_paths = agent_dir.config.writable_paths.clone(); + if let Some(cli_wp) = &cli.writable_paths { + writable_paths.extend(cli_wp.iter().cloned()); + } + let evaluator = PermissionEvaluator::new( + agent_dir.config.permissions.clone(), + writable_paths, + cli.working_dir.clone(), + ); let compact_tool = Arc::new(CompactTool::new()); let todos_tool = Arc::new(TodosTool::new()); @@ -131,6 +143,10 @@ async fn main() { tools.push(Box::new(TaskDoneTool)); tools.push(Box::new(tools::compact::CompactTool::new())); + let plan_path = cli.output_dir.join("plan.json"); + tools.push(Box::new(ReadPlanTool::new(plan_path.clone()))); + tools.push(Box::new(UpdatePlanTool::new(plan_path.clone()))); + let skill_tools = tools::skill_tool::from_skills(&agent_dir.skills); tools.extend(skill_tools); @@ -161,36 +177,40 @@ async fn main() { } } - let plan = if agent_dir.config.agent.plan_required { + let plan_text = if agent_dir.config.agent.plan_required { let planner = Planner::new(provider.clone(), trace.clone()); let all_tool_names: Vec = tools.iter().map(|t| t.name().to_string()).collect(); - let plan = planner + let raw_plan = planner .generate_plan(&system_prompt, &prompt_text, &all_tool_names) .await .unwrap_or_else(|e| { run_logger.add_error(0, "planning", &e); String::new() }); - Planner::save_plan(&plan, &cli.output_dir) + let plan = Planner::parse_plan(&raw_plan, &prompt_text); + Planner::save_plan_json(&plan, &cli.output_dir) .unwrap_or_else(|e| { run_logger.add_error(0, "planning", &format!("Failed to save plan: {}", e)); eprintln!("Warning: {}", e) }); - plan + raw_plan } else { String::new() }; if cli.plan_only { - println!("{}", plan); + match std::fs::read_to_string(cli.output_dir.join("plan.json")) { + Ok(content) => println!("{}", content), + Err(_) => println!("{}", plan_text), + } std::process::exit(0); } let mut messages = vec![provider::Message::system(system_prompt)]; - if !plan.is_empty() { + if !plan_text.is_empty() { messages.push(provider::Message::system(format!( - "[Execution Plan]\n{}", - plan + "[Execution Plan]\n{}\n\nUse the read_plan tool to review the plan and its step statuses, and update_plan to mark steps as in_progress, done, or skipped as you progress.", + plan_text ))); } messages.push(provider::Message::user(prompt_text.clone())); @@ -246,7 +266,7 @@ async fn main() { result.status.clone(), result.exit_code, prompt_text.clone(), - cli.output_dir.join("plan.md").to_string_lossy().into_owned(), + cli.output_dir.join("plan.json").to_string_lossy().into_owned(), todos_final, 0, Metrics { diff --git a/agent-runner/src/permissions.rs b/agent-runner/src/permissions.rs index 1be6fec..1869454 100644 --- a/agent-runner/src/permissions.rs +++ b/agent-runner/src/permissions.rs @@ -1,15 +1,92 @@ use crate::config::FilesystemPermission; +use globset::{Glob, GlobSet, GlobSetBuilder}; +use std::path::{Component, PathBuf}; + +/// Normalize a path string by removing `.` components so that joined +/// relative paths (e.g. `/work/./src/**`) become canonical (`/work/src/**`). +/// This is necessary for globset matching to work consistently. +fn normalize(path: &str) -> String { + let p = PathBuf::from(path); + let mut out = PathBuf::new(); + for comp in p.components() { + match comp { + Component::CurDir => {} + other => out.push(other.as_os_str()), + } + } + out.to_string_lossy().into_owned() +} pub struct PermissionEvaluator { rules: Vec, + writable_globs: GlobSet, + working_dir: PathBuf, } impl PermissionEvaluator { - pub fn new(rules: Vec) -> Self { - Self { rules } + pub fn new( + rules: Vec, + writable_paths: Vec, + working_dir: PathBuf, + ) -> Self { + let normalized_wd = normalize(&working_dir.to_string_lossy()); + let mut builder = GlobSetBuilder::new(); + for pattern in &writable_paths { + // Resolve relative patterns against the working directory so they + // match absolute paths consistently. Strip a leading "./" so the + // joined path doesn't contain a spurious "." component. + let clean = pattern.strip_prefix("./").unwrap_or(pattern); + let resolved_pattern = if PathBuf::from(pattern).is_absolute() { + normalize(pattern) + } else { + format!("{}/{}", normalized_wd, clean) + }; + if let Ok(glob) = Glob::new(&resolved_pattern) { + builder.add(glob); + } + } + let writable_globs = builder.build().unwrap_or_else(|_| GlobSet::empty()); + + Self { + rules, + writable_globs, + working_dir: PathBuf::from(normalized_wd), + } + } + + /// Resolve a (possibly relative) path against the working directory so + /// that all permission checks operate on canonical absolute paths. + fn resolve(&self, path: &str) -> String { + let p = PathBuf::from(path); + let resolved = if p.is_absolute() { + normalize(path) + } else { + normalize(&format!("{}/{}", self.working_dir.to_string_lossy(), path)) + }; + resolved } pub fn check(&self, operation: &str, path: &str) -> bool { + // Read operations are allowed everywhere by default. + if operation == "read" { + return true; + } + + // Write operations: check writable_paths first, then fall back to the + // explicit permissions array for backwards compatibility. + let resolved = self.resolve(path); + + // 1. writable_paths whitelist (globset match). + if self.writable_globs.is_match(&resolved) { + return true; + } + // Also try matching the raw (unresolved) path, since a user may + // configure writable_paths with relative patterns. + if path != resolved && self.writable_globs.is_match(path) { + return true; + } + + // 2. Explicit permissions rules (legacy / advanced). for rule in &self.rules { if !rule.operations.iter().any(|op| op == operation) { continue; @@ -18,9 +95,9 @@ impl PermissionEvaluator { let path_matches = rule.paths.iter().any(|pattern| { if pattern.ends_with("/*") { let prefix = &pattern[..pattern.len() - 2]; - path == prefix || path.starts_with(&format!("{}/", prefix)) + resolved == prefix || resolved.starts_with(&format!("{}/", prefix)) } else { - path == pattern + resolved == *pattern || path == *pattern } }); @@ -28,6 +105,63 @@ impl PermissionEvaluator { return rule.mode == "allow"; } } + + // 3. Default-deny for writes. false } } + +#[cfg(test)] +mod tests { + use super::*; + + fn evaluator(writable: Vec<&str>, working_dir: &str) -> PermissionEvaluator { + PermissionEvaluator::new( + Vec::new(), + writable.into_iter().map(String::from).collect(), + PathBuf::from(working_dir), + ) + } + + #[test] + fn read_always_allowed() { + let e = evaluator(vec![], "/work"); + assert!(e.check("read", "/etc/passwd")); + assert!(e.check("read", "anything")); + } + + #[test] + fn write_denied_by_default() { + let e = evaluator(vec![], "/work"); + assert!(!e.check("write", "/work/src/main.rs")); + } + + #[test] + fn write_allowed_in_writable_paths() { + let e = evaluator(vec!["/work/src/**"], "/work"); + assert!(e.check("write", "/work/src/main.rs")); + assert!(e.check("write", "/work/src/nested/deep.rs")); + assert!(!e.check("write", "/work/other.rs")); + } + + #[test] + fn write_allowed_with_relative_pattern() { + let e = evaluator(vec!["./src/**"], "/work"); + // Resolved absolute path should match. + assert!(e.check("write", "src/main.rs")); + assert!(e.check("write", "/work/src/main.rs")); + assert!(!e.check("write", "/work/out/main.rs")); + } + + #[test] + fn legacy_permissions_still_work() { + let rules = vec![FilesystemPermission { + operations: vec!["write".into()], + paths: vec!["/work/out/*".into()], + mode: "allow".into(), + }]; + let e = PermissionEvaluator::new(rules, vec![], PathBuf::from("/work")); + assert!(e.check("write", "/work/out/file.txt")); + assert!(!e.check("write", "/work/secret.txt")); + } +} diff --git a/agent-runner/src/tools/filesystem.rs b/agent-runner/src/tools/filesystem.rs index 9e743fc..3d15813 100644 --- a/agent-runner/src/tools/filesystem.rs +++ b/agent-runner/src/tools/filesystem.rs @@ -265,12 +265,12 @@ impl Tool for GlobTool { fn definition(&self) -> ToolDefinition { ToolDefinition { name: "glob".into(), - description: "Find files matching a glob pattern.".into(), + description: "Find files matching a glob pattern. Respects .gitignore and skips hidden files by default.".into(), parameters: serde_json::json!({ "type": "object", "properties": { - "pattern": { "type": "string", "description": "Glob pattern to match" }, - "path": { "type": "string", "description": "Base directory to search in", "default": "/" } + "pattern": { "type": "string", "description": "Glob pattern to match (e.g. **/*.rs, src/*.ts)" }, + "path": { "type": "string", "description": "Base directory to search in", "default": "." } }, "required": ["pattern"] }), @@ -279,34 +279,89 @@ impl Tool for GlobTool { async fn execute(&self, args: serde_json::Value) -> ToolOutput { let pattern = args["pattern"].as_str().unwrap_or(""); - let base_path = args["path"].as_str().unwrap_or("/"); + let base_path = args["path"].as_str().unwrap_or("."); let base = resolve_path(&self.working_dir, base_path); - let full_pattern = if pattern.starts_with('/') { - pattern.to_string() - } else { - base.join(pattern).to_string_lossy().into_owned() - }; + if pattern.is_empty() { + return ToolOutput { + content: "Pattern must not be empty".into(), + is_error: true, + }; + } - match glob::glob(&full_pattern) { - Ok(paths) => { - let results: Vec = paths - .filter_map(|p| p.ok()) - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - ToolOutput { - content: if results.is_empty() { - "No matches found".into() - } else { - results.join("\n") - }, - is_error: false, + // Compile the user pattern into a GlobSet matcher. + let glob = match globset::Glob::new(pattern) { + Ok(g) => g, + Err(e) => { + return ToolOutput { + content: format!("Invalid glob pattern: {}", e), + is_error: true, } } - Err(e) => ToolOutput { - content: format!("Invalid glob pattern: {}", e), + }; + let matcher = match globset::GlobSetBuilder::new().add(glob).build() { + Ok(gs) => gs, + Err(e) => { + return ToolOutput { + content: format!("Failed to compile glob set: {}", e), + is_error: true, + } + } + }; + + // If the base path is a single file, just test it directly. + if base.is_file() { + let rel = base.strip_prefix(&self.working_dir).unwrap_or(&base); + let matched = matcher.is_match(base.as_path()) + || matcher.is_match(rel); + let content = if matched { + base.to_string_lossy().into_owned() + } else { + "No matches found".into() + }; + return ToolOutput { content, is_error: false }; + } + + if !base.is_dir() { + return ToolOutput { + content: format!("Path not found: {}", base.display()), is_error: true, + }; + } + + // Walk the directory tree with .gitignore / hidden-file awareness. + let walker = ignore::WalkBuilder::new(&base) + .hidden(true) + .git_ignore(true) + .git_exclude(true) + .git_global(true) + .build(); + + let mut results: Vec = Vec::new(); + for entry in walker.flatten() { + if results.len() >= 1000 { + results.push("... (truncated at 1000 results)".into()); + break; + } + let path = entry.path(); + if !path.is_file() { + continue; + } + // Match against both the absolute path and the path relative to base. + let rel = path.strip_prefix(&base).unwrap_or(path); + if matcher.is_match(path) || matcher.is_match(rel) { + results.push(path.to_string_lossy().into_owned()); + } + } + + ToolOutput { + content: if results.is_empty() { + "No matches found".into() + } else { + results.sort(); + results.join("\n") }, + is_error: false, } } } @@ -324,13 +379,13 @@ impl Tool for GrepTool { fn definition(&self) -> ToolDefinition { ToolDefinition { name: "grep".into(), - description: "Search file contents using a regex pattern.".into(), + description: "Search file contents using a regex pattern. Respects .gitignore and skips hidden files by default.".into(), parameters: serde_json::json!({ "type": "object", "properties": { "pattern": { "type": "string", "description": "Regex pattern to search for" }, - "path": { "type": "string", "description": "Directory or file to search in" }, - "glob": { "type": "string", "description": "File glob pattern to filter files" } + "path": { "type": "string", "description": "Directory or file to search in", "default": "." }, + "glob": { "type": "string", "description": "File glob pattern to filter files (e.g. *.rs)" } }, "required": ["pattern"] }), @@ -353,12 +408,52 @@ impl Tool for GrepTool { }; let search_path = resolve_path(&self.working_dir, path_str); + + // Optional file-name glob filter. + let file_filter = if let Some(gp) = glob_pattern { + match globset::Glob::new(gp) { + Ok(g) => Some(g.compile_matcher()), + Err(_) => None, + } + } else { + None + }; + let mut results: Vec = Vec::new(); + let mut truncated = false; if search_path.is_file() { - search_file(&search_path, &re, &mut results); + search_file(&search_path, &re, &mut results, 1000); } else if search_path.is_dir() { - search_dir(&search_path, &re, glob_pattern, &mut results); + // Walk with .gitignore / hidden-file awareness. + let walker = ignore::WalkBuilder::new(&search_path) + .hidden(true) + .git_ignore(true) + .git_exclude(true) + .git_global(true) + .build(); + + for entry in walker.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + // Apply optional glob filter on the file name. + if let Some(ref filter) = file_filter { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if !filter.is_match(name) { + continue; + } + } else { + continue; + } + } + search_file(path, &re, &mut results, 1000); + if results.len() >= 1000 { + truncated = true; + break; + } + } } else { return ToolOutput { content: format!("Path not found: {}", search_path.display()), @@ -366,6 +461,10 @@ impl Tool for GrepTool { }; } + if truncated { + results.push("... (truncated at 1000 matches)".into()); + } + ToolOutput { content: if results.is_empty() { "No matches found".into() @@ -377,41 +476,20 @@ impl Tool for GrepTool { } } -fn search_file(path: &std::path::Path, re: ®ex::Regex, results: &mut Vec) { +fn search_file( + path: &std::path::Path, + re: ®ex::Regex, + results: &mut Vec, + limit: usize, +) { if let Ok(content) = std::fs::read_to_string(path) { for (i, line) in content.lines().enumerate() { if re.is_match(line) { results.push(format!("{}:{}: {}", path.display(), i + 1, line)); - } - } - } -} - -fn search_dir( - dir: &std::path::Path, - re: ®ex::Regex, - glob_pattern: Option<&str>, - results: &mut Vec, -) { - let entries = match std::fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return, - }; - - for entry in entries.filter_map(|e| e.ok()) { - let path = entry.path(); - if path.is_dir() { - search_dir(&path, re, glob_pattern, results); - } else if let Some(gp) = glob_pattern { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - if let Ok(pat) = glob::Pattern::new(gp) { - if pat.matches(name) { - search_file(&path, re, results); - } + if results.len() >= limit { + return; } } - } else { - search_file(&path, re, results); } } } diff --git a/agent-runner/src/tools/mod.rs b/agent-runner/src/tools/mod.rs index dc57c2a..a2c3859 100644 --- a/agent-runner/src/tools/mod.rs +++ b/agent-runner/src/tools/mod.rs @@ -5,6 +5,7 @@ pub mod compact; pub mod done; pub mod execute; pub mod filesystem; +pub mod plan; pub mod skill_tool; pub mod subagent; pub mod todos; diff --git a/agent-runner/src/tools/plan.rs b/agent-runner/src/tools/plan.rs new file mode 100644 index 0000000..63553e5 --- /dev/null +++ b/agent-runner/src/tools/plan.rs @@ -0,0 +1,186 @@ +use async_trait::async_trait; +use crate::agent::planner::Plan; +use crate::provider::ToolDefinition; +use crate::tools::{Tool, ToolOutput}; +use std::path::PathBuf; + +/// Tool that reads the structured `plan.json` so the agent can review the +/// current plan and the status of each step during the execution loop. +pub struct ReadPlanTool { + plan_path: PathBuf, +} + +impl ReadPlanTool { + pub fn new(plan_path: PathBuf) -> Self { + Self { plan_path } + } +} + +#[async_trait] +impl Tool for ReadPlanTool { + fn name(&self) -> &str { + "read_plan" + } + + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "read_plan".into(), + description: "Read the execution plan (plan.json) including all steps and their current status.".into(), + parameters: serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }), + } + } + + async fn execute(&self, _args: serde_json::Value) -> ToolOutput { + match std::fs::read_to_string(&self.plan_path) { + Ok(content) => ToolOutput { + content, + is_error: false, + }, + Err(_) => ToolOutput { + content: "No plan found. A plan is generated at startup when plan_required is true.".into(), + is_error: false, + }, + } + } +} + +/// Tool that updates a single step's status (and optionally description) in +/// `plan.json`. Lets the agent track progress as it works through the plan. +pub struct UpdatePlanTool { + plan_path: PathBuf, +} + +impl UpdatePlanTool { + pub fn new(plan_path: PathBuf) -> Self { + Self { plan_path } + } +} + +#[async_trait] +impl Tool for UpdatePlanTool { + fn name(&self) -> &str { + "update_plan" + } + + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "update_plan".into(), + description: "Update the status of a plan step in plan.json. Use this to mark steps as in_progress, done, or skipped as you work.".into(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "step_id": { "type": "integer", "description": "The id of the step to update" }, + "status": { "type": "string", "enum": ["pending", "in_progress", "done", "skipped"], "description": "New status for the step" }, + "description": { "type": "string", "description": "Optional: update the step description" } + }, + "required": ["step_id", "status"] + }), + } + } + + async fn execute(&self, args: serde_json::Value) -> ToolOutput { + let step_id = match args["step_id"].as_u64() { + Some(id) => id as u32, + None => { + return ToolOutput { + content: "Missing or invalid 'step_id'".into(), + is_error: true, + } + } + }; + let status = match args["status"].as_str() { + Some(s) => s.to_string(), + None => { + return ToolOutput { + content: "Missing or invalid 'status'".into(), + is_error: true, + } + } + }; + + // Validate status value. + let valid = ["pending", "in_progress", "done", "skipped"]; + if !valid.contains(&status.as_str()) { + return ToolOutput { + content: format!( + "Invalid status '{}'. Must be one of: {}", + status, + valid.join(", ") + ), + is_error: true, + }; + } + + let new_description = args["description"].as_str().map(|s| s.to_string()); + + // Read current plan. + let content = match std::fs::read_to_string(&self.plan_path) { + Ok(c) => c, + Err(_) => { + return ToolOutput { + content: "No plan found. Cannot update a non-existent plan.".into(), + is_error: true, + } + } + }; + + let mut plan: Plan = match serde_json::from_str(&content) { + Ok(p) => p, + Err(e) => { + return ToolOutput { + content: format!("Failed to parse plan.json: {}", e), + is_error: true, + } + } + }; + + // Find and update the matching step. + let mut found = None; + for step in &mut plan.steps { + if step.id == step_id { + step.status = status.clone(); + if let Some(desc) = &new_description { + step.description = desc.clone(); + } + found = Some(step.clone()); + break; + } + } + + let updated_step = match found { + Some(s) => s, + None => { + return ToolOutput { + content: format!("Step with id {} not found in plan", step_id), + is_error: true, + } + } + }; + + // Write back. + let json = match serde_json::to_string_pretty(&plan) { + Ok(j) => j, + Err(e) => { + return ToolOutput { + content: format!("Failed to serialize updated plan: {}", e), + is_error: true, + } + } + }; + if let Err(e) = std::fs::write(&self.plan_path, json) { + return ToolOutput { + content: format!("Failed to write plan.json: {}", e), + is_error: true, + }; + } + + ToolOutput { + content: serde_json::to_string_pretty(&updated_step).unwrap_or_default(), + is_error: false, + } + } +} diff --git a/web-site/index.html b/web-site/index.html index 27348c2..1d33d4d 100644 --- a/web-site/index.html +++ b/web-site/index.html @@ -357,7 +357,7 @@

Multi-Provider LLM

Planning Phase

-

Generates a step-by-step execution plan before acting. Preview with --plan-only.

+

Generates a structured plan.json before acting. Agent tracks step status with read_plan & update_plan.

@@ -372,12 +372,12 @@

Summarization

Filesystem Tools

-

ls, read, write, edit, glob, grep. Everything an agent needs to work with code.

+

ls, read, write, edit, glob, grep. Built on ripgrep's ignore engine — respects .gitignore.

Permission System

-

Allow/deny rules for read/write on specific paths. Sandboxed by default.

+

Read-only by default. Only writable_paths allow writes. Simple glob-based whitelist.

@@ -479,9 +479,8 @@

What goes where

"plan_required": true, "execute_enabled": false }, - "permissions": [ - { "operations": ["read"], "paths": ["./*"], "mode": "allow" } - ] + "writable_paths": ["./src/*", "/tmp/out/*"], + "permissions": [] }
@@ -584,6 +583,8 @@

What goes where

globfind files by pattern
grepsearch with regex
executerun shell commands
+
read_planread plan.json
+
update_planupdate step status
task_donesignal completion
write_todostrack task list
compactshrink context