From 01e01dae0e1ade8a0839339ce3f267546fca6a9f Mon Sep 17 00:00:00 2001 From: Yogthos Date: Tue, 19 May 2026 11:26:26 -0400 Subject: [PATCH 1/3] Add apply_patch tool for multi-file edits in one call (Phase 6) Supports create, update (exact text match), delete, and rename operations. Executes in order, stops on first failure, returns summary per operation. Permission checked per file path. Tests cover all four operation types. --- src/agent/builder.rs | 4 + src/agent/tools/apply_patch.rs | 292 +++++++++++++++++++++++++++++++++ src/agent/tools/mod.rs | 2 + src/ui/mod.rs | 1 + 4 files changed, 299 insertions(+) create mode 100644 src/agent/tools/apply_patch.rs diff --git a/src/agent/builder.rs b/src/agent/builder.rs index b38f363c..63f75e54 100644 --- a/src/agent/builder.rs +++ b/src/agent/builder.rs @@ -174,6 +174,10 @@ pub async fn build_agent_inner( ask_tx.clone(), )), Box::new(tools::MemoryTool::new(permission.clone(), ask_tx.clone())), + Box::new(tools::ApplyPatchTool::new( + permission.clone(), + ask_tx.clone(), + )), ]; let question_tool = question_tx diff --git a/src/agent/tools/apply_patch.rs b/src/agent/tools/apply_patch.rs new file mode 100644 index 00000000..d9a0da5e --- /dev/null +++ b/src/agent/tools/apply_patch.rs @@ -0,0 +1,292 @@ +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::Deserialize; +use std::path::Path; + +use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm_path}; + +#[derive(Deserialize, Debug, Clone)] +#[serde(tag = "action")] +pub enum PatchOp { + #[serde(rename = "create")] + Create { path: String, content: String }, + #[serde(rename = "update")] + Update { + path: String, + old_text: String, + new_text: String, + }, + #[serde(rename = "delete")] + Delete { path: String }, + #[serde(rename = "rename")] + Rename { path: String, new_path: String }, +} + +pub struct ApplyPatchTool { + pub permission: Option, + pub ask_tx: Option, +} + +impl ApplyPatchTool { + pub fn new(permission: Option, ask_tx: Option) -> Self { + Self { + permission, + ask_tx, + } + } +} + +#[derive(Deserialize)] +pub struct ApplyPatchArgs { + pub operations: Vec, +} + +fn apply_create(path: &str, content: &str) -> Result { + let p = Path::new(path); + if p.exists() { + return Err(format!("file already exists: {}", path)); + } + if let Some(parent) = p.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create parent dir: {}", e))?; + } + std::fs::write(p, content).map_err(|e| format!("write failed: {}", e))?; + Ok(format!("created {}", path)) +} + +fn apply_update(path: &str, old_text: &str, new_text: &str) -> Result { + let original = std::fs::read_to_string(path) + .map_err(|e| format!("read failed: {}", e))?; + + if !original.contains(old_text) { + return Err(format!( + "text not found in {}", + path + )); + } + + let matches: Vec<_> = original.match_indices(old_text).collect(); + if matches.len() > 1 { + return Err(format!( + "text matches {} locations in {} — provide more context to make unique", + matches.len(), + path + )); + } + + let updated = original.replacen(old_text, new_text, 1); + std::fs::write(path, &updated).map_err(|e| format!("write failed: {}", e))?; + Ok(format!("updated {}", path)) +} + +fn apply_delete(path: &str) -> Result { + std::fs::remove_file(path).map_err(|e| format!("delete failed: {}", e))?; + Ok(format!("deleted {}", path)) +} + +fn apply_rename(path: &str, new_path: &str) -> Result { + std::fs::rename(path, new_path) + .map_err(|e| format!("rename failed: {}", e))?; + Ok(format!("renamed {} -> {}", path, new_path)) +} + +impl Tool for ApplyPatchTool { + const NAME: &'static str = "apply_patch"; + + type Error = ToolError; + type Args = ApplyPatchArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: "apply_patch".to_string(), + description: "Apply multiple file operations atomically in a single call. Supports create, update (by exact text match), delete, and rename. Operations execute in order and stop on first failure." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "operations": { + "type": "array", + "description": "Ordered list of file operations to execute", + "items": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create", "update", "delete", "rename"], + "description": "The type of operation" + }, + "path": { + "type": "string", + "description": "Target file path" + }, + "content": { + "type": "string", + "description": "File content (required for create)" + }, + "old_text": { + "type": "string", + "description": "Exact text to find and replace (required for update)" + }, + "new_text": { + "type": "string", + "description": "Replacement text (required for update)" + }, + "new_path": { + "type": "string", + "description": "New file path (required for rename)" + } + }, + "required": ["action", "path"] + } + } + }, + "required": ["operations"] + }), + } + } + + async fn call(&self, args: ApplyPatchArgs) -> Result { + if args.operations.is_empty() { + return Err(ToolError::Msg("no operations provided".to_string())); + } + + let mut results = Vec::new(); + + for op in &args.operations { + // Permission check for the path + match op { + PatchOp::Create { path, .. } + | PatchOp::Update { path, .. } + | PatchOp::Delete { path } + | PatchOp::Rename { path, .. } => { + check_perm_path(&self.permission, &self.ask_tx, "apply_patch", path) + .await?; + } + } + + let result = match op { + PatchOp::Create { path, content } => apply_create(path, content), + PatchOp::Update { + path, + old_text, + new_text, + } => apply_update(path, old_text, new_text), + PatchOp::Delete { path } => apply_delete(path), + PatchOp::Rename { path, new_path } => apply_rename(path, new_path), + }; + + match result { + Ok(msg) => results.push(msg), + Err(e) => { + results.push(format!("FAILED: {}", e)); + break; + } + } + } + + Ok(results.join("\n")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestFile { + path: String, + } + + impl TestFile { + fn new(name: &str) -> Self { + let path = format!("/tmp/dirge-test-{}", name); + // Clean up any leftover + let _ = std::fs::remove_file(&path); + Self { path } + } + } + + impl Drop for TestFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } + } + + #[test] + fn test_create_and_read() { + let tf = TestFile::new("create-test.txt"); + let result = apply_create(&tf.path, "hello world"); + assert!(result.is_ok()); + let content = std::fs::read_to_string(&tf.path).unwrap(); + assert_eq!(content, "hello world"); + } + + #[test] + fn test_create_existing_file_fails() { + let tf = TestFile::new("create-exists.txt"); + std::fs::write(&tf.path, "existing").unwrap(); + let result = apply_create(&tf.path, "new"); + assert!(result.is_err()); + } + + #[test] + fn test_update_text() { + let tf = TestFile::new("update-test.txt"); + std::fs::write(&tf.path, "before after").unwrap(); + let result = apply_update(&tf.path, "before", "replaced"); + assert!(result.is_ok()); + let content = std::fs::read_to_string(&tf.path).unwrap(); + assert_eq!(content, "replaced after"); + } + + #[test] + fn test_update_text_not_found() { + let tf = TestFile::new("update-notfound.txt"); + std::fs::write(&tf.path, "some content").unwrap(); + let result = apply_update(&tf.path, "nonexistent", "replacement"); + assert!(result.is_err()); + } + + #[test] + fn test_delete_file() { + let tf = TestFile::new("delete-test.txt"); + std::fs::write(&tf.path, "to delete").unwrap(); + assert!(Path::new(&tf.path).exists()); + let result = apply_delete(&tf.path); + assert!(result.is_ok()); + assert!(!Path::new(&tf.path).exists()); + } + + #[test] + fn test_rename_file() { + let src = TestFile::new("rename-src.txt"); + let dst = "/tmp/dirge-test-rename-dst.txt"; + let _ = std::fs::remove_file(dst); + std::fs::write(&src.path, "rename me").unwrap(); + + let result = apply_rename(&src.path, dst); + assert!(result.is_ok()); + assert!(!Path::new(&src.path).exists()); + assert!(Path::new(dst).exists()); + let _ = std::fs::remove_file(dst); + } + + #[tokio::test] + async fn test_rejects_empty_operations() { + let tool = ApplyPatchTool::new(None, None); + let result = tool + .call(ApplyPatchArgs { + operations: vec![], + }) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("no operations")); + } + + #[tokio::test] + async fn test_definition_has_correct_name() { + let tool = ApplyPatchTool::new(None, None); + let def = tool.definition(String::new()).await; + assert_eq!(def.name, "apply_patch"); + } +} diff --git a/src/agent/tools/mod.rs b/src/agent/tools/mod.rs index 18bd5e91..b7ae36ad 100644 --- a/src/agent/tools/mod.rs +++ b/src/agent/tools/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod apply_patch; pub(crate) mod background; mod bash; pub(crate) mod cache; @@ -19,6 +20,7 @@ mod webfetch; mod websearch; mod write; +pub use apply_patch::ApplyPatchTool; pub use bash::BashTool; pub use cache::ToolCache; pub use edit::EditTool; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 20446286..347e205e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -77,6 +77,7 @@ fn format_tool_call_summary(name: &str, args: &serde_json::Value) -> String { "bash" => &["command"], "question" => &["questions"], "task" | "task_status" => &["prompt", "task_id"], + "apply_patch" => &["operations"], _ => &[], }; From 23f0333772e564d75ddcf21da51f109819f28dfd Mon Sep 17 00:00:00 2001 From: Yogthos Date: Tue, 19 May 2026 11:59:32 -0400 Subject: [PATCH 2/3] Fix apply-patch: description, rename permission, create size limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix misleading 'atomically' in description — operations execute in order, prior ops remain applied on failure - Add permission check for rename new_path (previously only checked source) - Add 1MB limit on create content to prevent runaway file writes --- src/agent/tools/apply_patch.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/agent/tools/apply_patch.rs b/src/agent/tools/apply_patch.rs index d9a0da5e..d51071fd 100644 --- a/src/agent/tools/apply_patch.rs +++ b/src/agent/tools/apply_patch.rs @@ -5,6 +5,9 @@ use std::path::Path; use crate::agent::tools::{AskSender, PermCheck, ToolError, check_perm_path}; +/// Max content size for a single create operation (1MB). +const MAX_CREATE_SIZE: usize = 1_048_576; + #[derive(Deserialize, Debug, Clone)] #[serde(tag = "action")] pub enum PatchOp { @@ -100,7 +103,7 @@ impl Tool for ApplyPatchTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { name: "apply_patch".to_string(), - description: "Apply multiple file operations atomically in a single call. Supports create, update (by exact text match), delete, and rename. Operations execute in order and stop on first failure." + description: "Apply multiple file operations in a single call. Supports create, update (by exact text match), delete, and rename. Operations execute in order and stop on first failure — prior operations that succeeded remain applied." .to_string(), parameters: serde_json::json!({ "type": "object", @@ -154,7 +157,7 @@ impl Tool for ApplyPatchTool { let mut results = Vec::new(); for op in &args.operations { - // Permission check for the path + // Permission check for the target path match op { PatchOp::Create { path, .. } | PatchOp::Update { path, .. } @@ -164,6 +167,18 @@ impl Tool for ApplyPatchTool { .await?; } } + // Rename also requires permission on the new path + if let PatchOp::Rename { new_path, .. } = op { + check_perm_path(&self.permission, &self.ask_tx, "apply_patch", new_path) + .await?; + } + // Validate create content size + if let PatchOp::Create { content, .. } = op { + if content.len() > MAX_CREATE_SIZE { + results.push(format!("FAILED: create content exceeds {} bytes ({} bytes provided)", MAX_CREATE_SIZE, content.len())); + break; + } + } let result = match op { PatchOp::Create { path, content } => apply_create(path, content), From 2fde31b1aca4507efcdf87a60bd5c1349d4383b7 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Tue, 19 May 2026 12:48:30 -0400 Subject: [PATCH 3/3] Run cargo fmt --- src/agent/tools/apply_patch.rs | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/src/agent/tools/apply_patch.rs b/src/agent/tools/apply_patch.rs index d51071fd..da977930 100644 --- a/src/agent/tools/apply_patch.rs +++ b/src/agent/tools/apply_patch.rs @@ -32,10 +32,7 @@ pub struct ApplyPatchTool { impl ApplyPatchTool { pub fn new(permission: Option, ask_tx: Option) -> Self { - Self { - permission, - ask_tx, - } + Self { permission, ask_tx } } } @@ -58,14 +55,10 @@ fn apply_create(path: &str, content: &str) -> Result { } fn apply_update(path: &str, old_text: &str, new_text: &str) -> Result { - let original = std::fs::read_to_string(path) - .map_err(|e| format!("read failed: {}", e))?; + let original = std::fs::read_to_string(path).map_err(|e| format!("read failed: {}", e))?; if !original.contains(old_text) { - return Err(format!( - "text not found in {}", - path - )); + return Err(format!("text not found in {}", path)); } let matches: Vec<_> = original.match_indices(old_text).collect(); @@ -88,8 +81,7 @@ fn apply_delete(path: &str) -> Result { } fn apply_rename(path: &str, new_path: &str) -> Result { - std::fs::rename(path, new_path) - .map_err(|e| format!("rename failed: {}", e))?; + std::fs::rename(path, new_path).map_err(|e| format!("rename failed: {}", e))?; Ok(format!("renamed {} -> {}", path, new_path)) } @@ -163,19 +155,21 @@ impl Tool for ApplyPatchTool { | PatchOp::Update { path, .. } | PatchOp::Delete { path } | PatchOp::Rename { path, .. } => { - check_perm_path(&self.permission, &self.ask_tx, "apply_patch", path) - .await?; + check_perm_path(&self.permission, &self.ask_tx, "apply_patch", path).await?; } } // Rename also requires permission on the new path if let PatchOp::Rename { new_path, .. } = op { - check_perm_path(&self.permission, &self.ask_tx, "apply_patch", new_path) - .await?; + check_perm_path(&self.permission, &self.ask_tx, "apply_patch", new_path).await?; } // Validate create content size if let PatchOp::Create { content, .. } = op { if content.len() > MAX_CREATE_SIZE { - results.push(format!("FAILED: create content exceeds {} bytes ({} bytes provided)", MAX_CREATE_SIZE, content.len())); + results.push(format!( + "FAILED: create content exceeds {} bytes ({} bytes provided)", + MAX_CREATE_SIZE, + content.len() + )); break; } } @@ -289,11 +283,7 @@ mod tests { #[tokio::test] async fn test_rejects_empty_operations() { let tool = ApplyPatchTool::new(None, None); - let result = tool - .call(ApplyPatchArgs { - operations: vec![], - }) - .await; + let result = tool.call(ApplyPatchArgs { operations: vec![] }).await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("no operations")); }