From 7844743decd665e7cd1a9c15afd91b5a44f80e32 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Sat, 4 Apr 2026 18:26:32 +0530 Subject: [PATCH] feat(tools): add multi_patch tool for batch file edits --- crates/forge_app/src/fmt/fmt_input.rs | 8 ++ crates/forge_app/src/fmt/fmt_output.rs | 7 ++ crates/forge_app/src/operation.rs | 31 ++++++- crates/forge_app/src/services.rs | 15 ++++ ...istry__all_rendered_tool_descriptions.snap | 46 +++++++++++ crates/forge_app/src/tool_executor.rs | 20 ++++- crates/forge_domain/src/compact/summary.rs | 3 + crates/forge_domain/src/tools/catalog.rs | 37 +++++++++ ..._definition__usage__tests__tool_usage.snap | 1 + .../src/tools/descriptions/fs_multi_patch.md | 41 ++++++++++ ..._catalog__tests__tool_definition_json.snap | 41 ++++++++++ crates/forge_repo/src/agents/forge.md | 1 + ...s__openai_responses_all_catalog_tools.snap | 50 ++++++++++++ .../src/tool_services/fs_patch.rs | 80 +++++++++++++++++++ 14 files changed, 376 insertions(+), 5 deletions(-) create mode 100644 crates/forge_domain/src/tools/descriptions/fs_multi_patch.md diff --git a/crates/forge_app/src/fmt/fmt_input.rs b/crates/forge_app/src/fmt/fmt_input.rs index 931bcdcbfd..6fa3936555 100644 --- a/crates/forge_app/src/fmt/fmt_input.rs +++ b/crates/forge_app/src/fmt/fmt_input.rs @@ -96,6 +96,14 @@ impl FormatContent for ToolCatalog { .into(), ) } + ToolCatalog::MultiPatch(input) => { + let display_path = display_path_for(&input.file_path); + Some( + TitleFormat::debug("Multi-patch") + .sub_title(format!("{} ({} edits)", display_path, input.edits.len())) + .into(), + ) + } ToolCatalog::Undo(input) => { let display_path = display_path_for(&input.path); Some(TitleFormat::debug("Undo").sub_title(display_path).into()) diff --git a/crates/forge_app/src/fmt/fmt_output.rs b/crates/forge_app/src/fmt/fmt_output.rs index bcf8361e8e..63866c3169 100644 --- a/crates/forge_app/src/fmt/fmt_output.rs +++ b/crates/forge_app/src/fmt/fmt_output.rs @@ -24,6 +24,13 @@ impl FormatContent for ToolOperation { .diff() .to_string(), )), + ToolOperation::FsMultiPatch { input: _, output } => { + Some(ChatResponseContent::ToolOutput( + DiffFormat::format(&output.before, &output.after) + .diff() + .to_string(), + )) + } ToolOperation::PlanCreate { input: _, output } => Some({ let title = TitleFormat::debug(format!( "Create {}", diff --git a/crates/forge_app/src/operation.rs b/crates/forge_app/src/operation.rs index b53e646a8c..78c8cc42bb 100644 --- a/crates/forge_app/src/operation.rs +++ b/crates/forge_app/src/operation.rs @@ -7,8 +7,8 @@ use derive_setters::Setters; use forge_config::ForgeConfig; use forge_display::DiffFormat; use forge_domain::{ - CodebaseSearchResults, Environment, FSPatch, FSRead, FSRemove, FSSearch, FSUndo, FSWrite, - FileOperation, LineNumbers, Metrics, NetFetch, PlanCreate, ToolKind, + CodebaseSearchResults, Environment, FSMultiPatch, FSPatch, FSRead, FSRemove, FSSearch, FSUndo, + FSWrite, FileOperation, LineNumbers, Metrics, NetFetch, PlanCreate, ToolKind, }; use forge_template::Element; @@ -54,6 +54,10 @@ pub enum ToolOperation { input: FSPatch, output: PatchOutput, }, + FsMultiPatch { + input: FSMultiPatch, + output: PatchOutput, + }, FsUndo { input: FSUndo, output: FsUndoOutput, @@ -477,6 +481,29 @@ impl ToolOperation { forge_domain::ToolOutput::text(elm) } + ToolOperation::FsMultiPatch { input, output } => { + let diff_result = DiffFormat::format(&output.before, &output.after); + let diff = console::strip_ansi_codes(diff_result.diff()).to_string(); + + let mut elm = Element::new("file_diff") + .attr("path", &input.file_path) + .attr("total_lines", output.after.lines().count()) + .cdata(diff); + + if !output.errors.is_empty() { + elm = elm.append(create_validation_warning(&input.file_path, &output.errors)); + } + + *metrics = metrics.clone().insert( + input.file_path.clone(), + FileOperation::new(tool_kind) + .lines_added(diff_result.lines_added()) + .lines_removed(diff_result.lines_removed()) + .content_hash(Some(output.content_hash.clone())), + ); + + forge_domain::ToolOutput::text(elm) + } ToolOperation::FsUndo { input, output } => { // Diff between snapshot state (after_undo) and modified state // (before_undo) diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index 9cf7a12c89..3ef25b1982 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -383,6 +383,13 @@ pub trait FsPatchService: Send + Sync { content: String, replace_all: bool, ) -> anyhow::Result; + + /// Applies multiple patches to a single file in sequence + async fn multi_patch( + &self, + path: String, + edits: Vec, + ) -> anyhow::Result; } #[async_trait::async_trait] @@ -790,6 +797,14 @@ impl FsPatchService for I { .patch(path, search, content, replace_all) .await } + + async fn multi_patch( + &self, + path: String, + edits: Vec, + ) -> anyhow::Result { + self.fs_patch_service().multi_patch(path, edits).await + } } #[async_trait::async_trait] diff --git a/crates/forge_app/src/snapshots/forge_app__tool_registry__all_rendered_tool_descriptions.snap b/crates/forge_app/src/snapshots/forge_app__tool_registry__all_rendered_tool_descriptions.snap index 76fd5f059e..52a227fb60 100644 --- a/crates/forge_app/src/snapshots/forge_app__tool_registry__all_rendered_tool_descriptions.snap +++ b/crates/forge_app/src/snapshots/forge_app__tool_registry__all_rendered_tool_descriptions.snap @@ -97,6 +97,52 @@ Usage: --- +### multi_patch + +This is a tool for making multiple edits to a single file in one operation. It is built on top of the patch tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the patch tool when you need to make multiple edits to the same file. + +Before using this tool: + +1. Use the Read tool to understand the file's contents and context +2. Verify the directory path is correct + +To make multiple file edits, provide the following: +1. file_path: The absolute path to the file to modify (must be absolute, not relative) +2. edits: An array of edit operations to perform, where each edit contains: + - oldString: The text to replace (must match the file contents exactly, including all whitespace and indentation) + - newString: The edited text to replace the oldString + - replaceAll: Replace all occurrences of oldString. This parameter is optional and defaults to false. + +IMPORTANT: +- All edits are applied in sequence, in the order they are provided +- Each edit operates on the result of the previous edit +- All edits must be valid for the operation to succeed - if any edit fails, none will be applied +- This tool is ideal when you need to make several changes to different parts of the same file + +CRITICAL REQUIREMENTS: +1. All edits follow the same requirements as the single Edit tool +2. The edits are atomic - either all succeed or none are applied +3. Plan your edits carefully to avoid conflicts between sequential operations + +WARNING: +- The tool will fail if edits.oldString doesn't match the file contents exactly (including whitespace) +- The tool will fail if edits.oldString and edits.newString are the same +- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find + +When making edits: +- Ensure all edits result in idiomatic, correct code +- Do not leave the code in a broken state +- Always use absolute file paths (starting with /) +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- Use replaceAll for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. + +If you want to create a new file, use: +- A new file path, including dir name if needed +- First edit: empty oldString and the new file's contents as newString +- Subsequent edits: normal edit operations on the created content + +--- + ### undo Reverts the most recent file operation (create/modify/delete) on a specific file. Use this tool when you need to recover from incorrect file changes or if a revert is requested by the user. diff --git a/crates/forge_app/src/tool_executor.rs b/crates/forge_app/src/tool_executor.rs index 35539721a7..1fd64d4f88 100644 --- a/crates/forge_app/src/tool_executor.rs +++ b/crates/forge_app/src/tool_executor.rs @@ -243,6 +243,14 @@ impl< .await?; (input, output).into() } + ToolCatalog::MultiPatch(input) => { + let normalized_path = self.normalize_path(input.file_path.clone()); + let output = self + .services + .multi_patch(normalized_path, input.edits.clone()) + .await?; + (input, output).into() + } ToolCatalog::Undo(input) => { let normalized_path = self.normalize_path(input.path.clone()); let output = self.services.undo(normalized_path).await?; @@ -327,9 +335,15 @@ impl< let env = self.services.get_environment(); let config = self.services.get_config(); - // Enforce read-before-edit for patch - if let ToolCatalog::Patch(input) = &tool_input { - self.require_prior_read(context, &input.file_path, "edit it")?; + // Enforce read-before-edit for patch operations + let file_path = match &tool_input { + ToolCatalog::Patch(input) => Some(&input.file_path), + ToolCatalog::MultiPatch(input) => Some(&input.file_path), + _ => None, + }; + + if let Some(path) = file_path { + self.require_prior_read(context, path, "edit it")?; } // Enforce read-before-edit for overwrite writes diff --git a/crates/forge_domain/src/compact/summary.rs b/crates/forge_domain/src/compact/summary.rs index 371087419c..2a82a3bf48 100644 --- a/crates/forge_domain/src/compact/summary.rs +++ b/crates/forge_domain/src/compact/summary.rs @@ -341,6 +341,9 @@ fn extract_tool_info(call: &ToolCallFull, current_todos: &[Todo]) -> Option Some(SummaryTool::FileRead { path: input.file_path }), ToolCatalog::Write(input) => Some(SummaryTool::FileUpdate { path: input.file_path }), ToolCatalog::Patch(input) => Some(SummaryTool::FileUpdate { path: input.file_path }), + ToolCatalog::MultiPatch(input) => { + Some(SummaryTool::FileUpdate { path: input.file_path }) + } ToolCatalog::Remove(input) => Some(SummaryTool::FileRemove { path: input.path }), ToolCatalog::Shell(input) => Some(SummaryTool::Shell { command: input.command }), ToolCatalog::FsSearch(input) => { diff --git a/crates/forge_domain/src/tools/catalog.rs b/crates/forge_domain/src/tools/catalog.rs index d17aefa00b..f1c9380bd1 100644 --- a/crates/forge_domain/src/tools/catalog.rs +++ b/crates/forge_domain/src/tools/catalog.rs @@ -47,6 +47,7 @@ pub enum ToolCatalog { SemSearch(SemanticSearch), Remove(FSRemove), Patch(FSPatch), + MultiPatch(FSMultiPatch), Undo(FSUndo), Shell(Shell), Fetch(NetFetch), @@ -522,6 +523,31 @@ pub struct FSPatch { pub replace_all: bool, } +/// A single edit operation in a multi-patch +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct PatchEdit { + /// The text to replace + pub old_string: String, + + /// The text to replace it with (must be different from old_string) + pub new_string: String, + + /// Replace all occurrences of old_string (default false) + #[serde(default)] + #[schemars(default)] + pub replace_all: bool, +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema, ToolDescription, PartialEq)] +#[tool_description_file = "crates/forge_domain/src/tools/descriptions/fs_multi_patch.md"] +pub struct FSMultiPatch { + /// The absolute path to the file to modify + pub file_path: String, + + /// Array of edit operations to perform sequentially on the file + pub edits: Vec, +} + #[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema, ToolDescription, PartialEq)] #[tool_description_file = "crates/forge_domain/src/tools/descriptions/fs_undo.md"] pub struct FSUndo { @@ -754,6 +780,7 @@ impl ToolDescription for ToolCatalog { fn description(&self) -> String { match self { ToolCatalog::Patch(v) => v.description(), + ToolCatalog::MultiPatch(v) => v.description(), ToolCatalog::Shell(v) => v.description(), ToolCatalog::Followup(v) => v.description(), ToolCatalog::Fetch(v) => v.description(), @@ -811,6 +838,7 @@ impl ToolCatalog { let mut schema = match self { ToolCatalog::Patch(_) => r#gen.into_root_schema_for::(), + ToolCatalog::MultiPatch(_) => r#gen.into_root_schema_for::(), ToolCatalog::Shell(_) => r#gen.into_root_schema_for::(), ToolCatalog::Followup(_) => r#gen.into_root_schema_for::(), ToolCatalog::Fetch(_) => r#gen.into_root_schema_for::(), @@ -923,6 +951,15 @@ impl ToolCatalog { cwd, message: format!("Modify file: {}", display_path_for(&input.file_path)), }), + ToolCatalog::MultiPatch(input) => Some(crate::policies::PermissionOperation::Write { + path: std::path::PathBuf::from(&input.file_path), + cwd, + message: format!( + "Modify file with {} edits: {}", + input.edits.len(), + display_path_for(&input.file_path) + ), + }), ToolCatalog::Shell(input) => Some(crate::policies::PermissionOperation::Execute { command: input.command.clone(), cwd, diff --git a/crates/forge_domain/src/tools/definition/snapshots/forge_domain__tools__definition__usage__tests__tool_usage.snap b/crates/forge_domain/src/tools/definition/snapshots/forge_domain__tools__definition__usage__tests__tool_usage.snap index f958703c84..337e74516c 100644 --- a/crates/forge_domain/src/tools/definition/snapshots/forge_domain__tools__definition__usage__tests__tool_usage.snap +++ b/crates/forge_domain/src/tools/definition/snapshots/forge_domain__tools__definition__usage__tests__tool_usage.snap @@ -8,6 +8,7 @@ expression: prompt {"name":"sem_search","description":"AI-powered semantic code search. YOUR DEFAULT TOOL for code discovery and exploration when searching within {{env.cwd}}. Use this when you need to find code locations, understand implementations, discover patterns, or explore unfamiliar code - it works with natural language about behavior and concepts, not just keyword matching.\n\n**WHEN TO USE sem_search:**\n- Finding implementation of specific features or algorithms\n- Understanding how a system works across multiple files\n- Discovering architectural patterns and design approaches\n- Locating test examples or fixtures\n- Finding where specific technologies/libraries are used\n- Exploring unfamiliar codebases to learn structure\n- Finding documentation files (README, guides, API docs)\n\n**WHEN NOT TO USE (use {{tool_names.fs_search}} instead):**\n- Searching for exact strings, TODOs, or specific function names\n- Finding all occurrences of a variable or identifier\n- Searching in specific file paths or with regex patterns\n- When you know the exact text to search for\n\nIMPORTANT: Only searches within {{env.cwd}} and subdirectories. For paths outside this scope, use {{tool_names.fs_search}} with path parameter.\n\n**TIPS FOR SUCCESS:**\n- Use 2-3 varied queries to capture different aspects (e.g., \"OAuth token refresh\", \"JWT expiry handling\", \"authentication middleware\")\n- Balance specificity (focused results) with generality (don't miss relevant code)\n- Avoid overly broad queries like \"authentication\" or \"tools\" - be specific about what aspect you need\n- Keep queries targeted - too many broad queries can cause timeouts\n- **Match your intent**: If seeking documentation, use doc-focused keywords (\"setup guide\", \"configuration README\"); if seeking code, use implementation terms (\"token refresh logic\", \"error handling implementation\")\n\nReturns the topK most relevant file:line locations with code context. Each query is ranked independently, then reranked by relevance to your stated intent.","arguments":{"queries":{"description":"List of search queries to execute in parallel. Using multiple queries\n(2-3) with varied phrasings significantly improves results - each query\ncaptures different aspects of what you're looking for. Each query pairs\na search term with a use_case for reranking. Example: for\nauthentication, try \"user login verification\", \"token generation\",\n\"OAuth flow\".","type":"array","is_required":true}}} {"name":"remove","description":"Request to remove a file at the specified path. Use when you need to delete an existing file. The path must be absolute. This operation can be undone using the `{{tool_names.undo}}` tool.","arguments":{"path":{"description":"The path of the file to remove (absolute path required)","type":"string","is_required":true}}} {"name":"patch","description":"Performs exact string replacements in files.\nUsage:\n- You must use your `{{tool_names.read}}` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. \n- When editing text from `{{tool_names.read}}` tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: 'line_number:'. Everything after that line_number: is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.\n- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`. \n- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.","arguments":{"file_path":{"description":"The absolute path to the file to modify","type":"string","is_required":true},"new_string":{"description":"The text to replace it with (must be different from old_string)","type":"string","is_required":true},"old_string":{"description":"The text to replace","type":"string","is_required":true},"replace_all":{"description":"Replace all occurrences of old_string (default false)","type":"boolean","is_required":false}}} +{"name":"multi_patch","description":"This is a tool for making multiple edits to a single file in one operation. It is built on top of the {{tool_names.patch}} tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the {{tool_names.patch}} tool when you need to make multiple edits to the same file.\n\nBefore using this tool:\n\n1. Use the Read tool to understand the file's contents and context\n2. Verify the directory path is correct\n\nTo make multiple file edits, provide the following:\n1. file_path: The absolute path to the file to modify (must be absolute, not relative)\n2. edits: An array of edit operations to perform, where each edit contains:\n - oldString: The text to replace (must match the file contents exactly, including all whitespace and indentation)\n - newString: The edited text to replace the oldString\n - replaceAll: Replace all occurrences of oldString. This parameter is optional and defaults to false.\n\nIMPORTANT:\n- All edits are applied in sequence, in the order they are provided\n- Each edit operates on the result of the previous edit\n- All edits must be valid for the operation to succeed - if any edit fails, none will be applied\n- This tool is ideal when you need to make several changes to different parts of the same file\n\nCRITICAL REQUIREMENTS:\n1. All edits follow the same requirements as the single Edit tool\n2. The edits are atomic - either all succeed or none are applied\n3. Plan your edits carefully to avoid conflicts between sequential operations\n\nWARNING:\n- The tool will fail if edits.oldString doesn't match the file contents exactly (including whitespace)\n- The tool will fail if edits.oldString and edits.newString are the same\n- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find\n\nWhen making edits:\n- Ensure all edits result in idiomatic, correct code\n- Do not leave the code in a broken state\n- Always use absolute file paths (starting with /)\n- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n- Use replaceAll for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.\n\nIf you want to create a new file, use:\n- A new file path, including dir name if needed\n- First edit: empty oldString and the new file's contents as newString\n- Subsequent edits: normal edit operations on the created content","arguments":{"edits":{"description":"Array of edit operations to perform sequentially on the file","type":"array","is_required":true},"file_path":{"description":"The absolute path to the file to modify","type":"string","is_required":true}}} {"name":"undo","description":"Reverts the most recent file operation (create/modify/delete) on a specific file. Use this tool when you need to recover from incorrect file changes or if a revert is requested by the user.","arguments":{"path":{"description":"The absolute path of the file to revert to its previous state.","type":"string","is_required":true}}} {"name":"shell","description":"Executes shell commands. The `cwd` parameter sets the working directory for command execution. If not specified, defaults to `{{env.cwd}}`.\n\nCRITICAL: Do NOT use `cd` commands in the command string. This is FORBIDDEN. Always use the `cwd` parameter to set the working directory instead. Any use of `cd` in the command is redundant, incorrect, and violates the tool contract.\n\nIMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.\n\nBefore executing the command, please follow these steps:\n\n1. Directory Verification:\n - If the command will create new directories or files, first use `shell` with `ls` to verify the parent directory exists and is the correct location\n - For example, before running \"mkdir foo/bar\", first use `ls foo` to check that \"foo\" exists and is the intended parent directory\n\n2. Command Execution:\n - Always quote file paths that contain spaces with double quotes (e.g., python \"path with spaces/script.py\")\n - Examples of proper quoting:\n - mkdir \"/Users/name/My Documents\" (correct)\n - mkdir /Users/name/My Documents (incorrect - will fail)\n - python \"/path/with spaces/script.py\" (correct)\n - python /path/with spaces/script.py (incorrect - will fail)\n - After ensuring proper quoting, execute the command.\n - Capture the output of the command.\n\nUsage notes:\n - The command argument is required.\n - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.\n - If the output exceeds {{config.stdoutMaxPrefixLength}} prefix lines or {{config.stdoutMaxSuffixLength}} suffix lines, or if a line exceeds {{config.stdoutMaxLineLength}} characters, it will be truncated and the full output will be written to a temporary file. You can use read with start_line/end_line to read specific sections or fs_search to search the full content. Because of this, you should NOT use `head`, `tail`, or other truncation commands to limit output - just run the command directly.\n - Do not use {{tool_names.shell}} with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:\n - File search: Use `{{tool_names.fs_search}}` (NOT find or ls)\n - Content search: Use `{{tool_names.fs_search}}` with regex (NOT grep or rg)\n - Read files: Use `{{tool_names.read}}` (NOT cat/head/tail)\n - Edit files: Use `{{tool_names.patch}}`(NOT sed/awk)\n - Write files: Use `{{tool_names.write}}` (NOT echo >/cat < && `. Use the `cwd` parameter to change directories instead.\n\nGood examples:\n - With explicit cwd: cwd=\"/foo/bar\" with command: pytest tests\n\nBad example:\n cd /foo/bar && pytest tests\n\nReturns complete output including stdout, stderr, and exit code for diagnostic purposes.","arguments":{"command":{"description":"The shell command to execute.","type":"string","is_required":true},"cwd":{"description":"The working directory where the command should be executed.\nIf not specified, defaults to the current working directory from the\nenvironment.","type":"string","is_required":false},"description":{"description":"Clear, concise description of what this command does. Recommended to be\n5-10 words for simple commands. For complex commands with pipes or\nmultiple operations, provide more context. Examples: \"Lists files in\ncurrent directory\", \"Installs package dependencies\", \"Compiles Rust\nproject with release optimizations\".","type":"string","is_required":false},"env":{"description":"Environment variable names to pass to command execution (e.g., [\"PATH\",\n\"HOME\", \"USER\"]). The system automatically reads the specified\nvalues and applies them during command execution.","type":"array","is_required":false},"keep_ansi":{"description":"Whether to preserve ANSI escape codes in the output.\nIf true, ANSI escape codes will be preserved in the output.\nIf false (default), ANSI escape codes will be stripped from the output.","type":"boolean","is_required":false}}} {"name":"fetch","description":"Retrieves content from URLs as markdown or raw text. Enables access to current online information including websites, APIs and documentation. Use for obtaining up-to-date information beyond training data, verifying facts, or retrieving specific online content. Handles HTTP/HTTPS and converts HTML to readable markdown by default. Cannot access private/restricted resources requiring authentication. Respects robots.txt and may be blocked by anti-scraping measures. For large pages, returns the first 40,000 characters and stores the complete content in a temporary file for subsequent access.\n\nIMPORTANT: This tool only handles text-based content (HTML, JSON, XML, plain text, etc.). It will reject binary file downloads (.tar.gz, .zip, .bin, .deb, images, audio, video, etc.) with an error. To download binary files, use the `shell` tool with `curl -fLo ` instead.","arguments":{"raw":{"description":"Get raw content without any markdown conversion (default: false)","type":"boolean","is_required":false},"url":{"description":"URL to fetch","type":"string","is_required":true}}} diff --git a/crates/forge_domain/src/tools/descriptions/fs_multi_patch.md b/crates/forge_domain/src/tools/descriptions/fs_multi_patch.md new file mode 100644 index 0000000000..bc0084b4a2 --- /dev/null +++ b/crates/forge_domain/src/tools/descriptions/fs_multi_patch.md @@ -0,0 +1,41 @@ +This is a tool for making multiple edits to a single file in one operation. It is built on top of the {{tool_names.patch}} tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the {{tool_names.patch}} tool when you need to make multiple edits to the same file. + +Before using this tool: + +1. Use the Read tool to understand the file's contents and context +2. Verify the directory path is correct + +To make multiple file edits, provide the following: +1. file_path: The absolute path to the file to modify (must be absolute, not relative) +2. edits: An array of edit operations to perform, where each edit contains: + - oldString: The text to replace (must match the file contents exactly, including all whitespace and indentation) + - newString: The edited text to replace the oldString + - replaceAll: Replace all occurrences of oldString. This parameter is optional and defaults to false. + +IMPORTANT: +- All edits are applied in sequence, in the order they are provided +- Each edit operates on the result of the previous edit +- All edits must be valid for the operation to succeed - if any edit fails, none will be applied +- This tool is ideal when you need to make several changes to different parts of the same file + +CRITICAL REQUIREMENTS: +1. All edits follow the same requirements as the single Edit tool +2. The edits are atomic - either all succeed or none are applied +3. Plan your edits carefully to avoid conflicts between sequential operations + +WARNING: +- The tool will fail if edits.oldString doesn't match the file contents exactly (including whitespace) +- The tool will fail if edits.oldString and edits.newString are the same +- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find + +When making edits: +- Ensure all edits result in idiomatic, correct code +- Do not leave the code in a broken state +- Always use absolute file paths (starting with /) +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- Use replaceAll for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. + +If you want to create a new file, use: +- A new file path, including dir name if needed +- First edit: empty oldString and the new file's contents as newString +- Subsequent edits: normal edit operations on the created content diff --git a/crates/forge_domain/src/tools/snapshots/forge_domain__tools__catalog__tests__tool_definition_json.snap b/crates/forge_domain/src/tools/snapshots/forge_domain__tools__catalog__tests__tool_definition_json.snap index ce2da28a9c..ffa297172c 100644 --- a/crates/forge_domain/src/tools/snapshots/forge_domain__tools__catalog__tests__tool_definition_json.snap +++ b/crates/forge_domain/src/tools/snapshots/forge_domain__tools__catalog__tests__tool_definition_json.snap @@ -215,6 +215,47 @@ expression: tools "new_string" ] } +{ + "title": "FSMultiPatch", + "type": "object", + "properties": { + "edits": { + "description": "Array of edit operations to perform sequentially on the file", + "type": "array", + "items": { + "description": "A single edit operation in a multi-patch", + "type": "object", + "properties": { + "new_string": { + "description": "The text to replace it with (must be different from old_string)", + "type": "string" + }, + "old_string": { + "description": "The text to replace", + "type": "string" + }, + "replace_all": { + "description": "Replace all occurrences of old_string (default false)", + "type": "boolean", + "default": false + } + }, + "required": [ + "old_string", + "new_string" + ] + } + }, + "file_path": { + "description": "The absolute path to the file to modify", + "type": "string" + } + }, + "required": [ + "file_path", + "edits" + ] +} { "title": "FSUndo", "type": "object", diff --git a/crates/forge_repo/src/agents/forge.md b/crates/forge_repo/src/agents/forge.md index 9d18f9918e..6d49a39b3e 100644 --- a/crates/forge_repo/src/agents/forge.md +++ b/crates/forge_repo/src/agents/forge.md @@ -13,6 +13,7 @@ tools: - undo - remove - patch + - multi_patch - shell - fetch - skill diff --git a/crates/forge_repo/src/provider/openai_responses/snapshots/forge_repo__provider__openai_responses__request__tests__openai_responses_all_catalog_tools.snap b/crates/forge_repo/src/provider/openai_responses/snapshots/forge_repo__provider__openai_responses__request__tests__openai_responses_all_catalog_tools.snap index 008e13d036..0edcf7c84b 100644 --- a/crates/forge_repo/src/provider/openai_responses/snapshots/forge_repo__provider__openai_responses__request__tests__openai_responses_all_catalog_tools.snap +++ b/crates/forge_repo/src/provider/openai_responses/snapshots/forge_repo__provider__openai_responses__request__tests__openai_responses_all_catalog_tools.snap @@ -359,6 +359,56 @@ expression: actual.tools "strict": true, "description": "Performs exact string replacements in files.\nUsage:\n- You must use your `{{tool_names.read}}` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. \n- When editing text from `{{tool_names.read}}` tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: 'line_number:'. Everything after that line_number: is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.\n- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`. \n- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance." }, + { + "type": "function", + "name": "multi_patch", + "parameters": { + "additionalProperties": false, + "properties": { + "edits": { + "description": "Array of edit operations to perform sequentially on the file", + "items": { + "additionalProperties": false, + "description": "A single edit operation in a multi-patch", + "properties": { + "new_string": { + "description": "The text to replace it with (must be different from old_string)", + "type": "string" + }, + "old_string": { + "description": "The text to replace", + "type": "string" + }, + "replace_all": { + "default": false, + "description": "Replace all occurrences of old_string (default false)", + "type": "boolean" + } + }, + "required": [ + "new_string", + "old_string", + "replace_all" + ], + "type": "object" + }, + "type": "array" + }, + "file_path": { + "description": "The absolute path to the file to modify", + "type": "string" + } + }, + "required": [ + "edits", + "file_path" + ], + "title": "FSMultiPatch", + "type": "object" + }, + "strict": true, + "description": "This is a tool for making multiple edits to a single file in one operation. It is built on top of the {{tool_names.patch}} tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the {{tool_names.patch}} tool when you need to make multiple edits to the same file.\n\nBefore using this tool:\n\n1. Use the Read tool to understand the file's contents and context\n2. Verify the directory path is correct\n\nTo make multiple file edits, provide the following:\n1. file_path: The absolute path to the file to modify (must be absolute, not relative)\n2. edits: An array of edit operations to perform, where each edit contains:\n - oldString: The text to replace (must match the file contents exactly, including all whitespace and indentation)\n - newString: The edited text to replace the oldString\n - replaceAll: Replace all occurrences of oldString. This parameter is optional and defaults to false.\n\nIMPORTANT:\n- All edits are applied in sequence, in the order they are provided\n- Each edit operates on the result of the previous edit\n- All edits must be valid for the operation to succeed - if any edit fails, none will be applied\n- This tool is ideal when you need to make several changes to different parts of the same file\n\nCRITICAL REQUIREMENTS:\n1. All edits follow the same requirements as the single Edit tool\n2. The edits are atomic - either all succeed or none are applied\n3. Plan your edits carefully to avoid conflicts between sequential operations\n\nWARNING:\n- The tool will fail if edits.oldString doesn't match the file contents exactly (including whitespace)\n- The tool will fail if edits.oldString and edits.newString are the same\n- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find\n\nWhen making edits:\n- Ensure all edits result in idiomatic, correct code\n- Do not leave the code in a broken state\n- Always use absolute file paths (starting with /)\n- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n- Use replaceAll for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.\n\nIf you want to create a new file, use:\n- A new file path, including dir name if needed\n- First edit: empty oldString and the new file's contents as newString\n- Subsequent edits: normal edit operations on the created content" + }, { "type": "function", "name": "undo", diff --git a/crates/forge_services/src/tool_services/fs_patch.rs b/crates/forge_services/src/tool_services/fs_patch.rs index cdb1883de3..7136b6b779 100644 --- a/crates/forge_services/src/tool_services/fs_patch.rs +++ b/crates/forge_services/src/tool_services/fs_patch.rs @@ -411,6 +411,86 @@ impl, + ) -> anyhow::Result { + let path = Path::new(&input_path); + assert_absolute_path(path)?; + + // Read the original content once + let mut current_content = fs::read_to_string(path) + .await + .map_err(Error::FileOperation)?; + // Save the old content before modification for diff generation + let old_content = current_content.clone(); + + // Apply each edit sequentially + for edit in &edits { + // Convert replace_all boolean to PatchOperation + let operation = if edit.replace_all { + PatchOperation::ReplaceAll + } else { + PatchOperation::Replace + }; + + // Compute range from search if provided + let range = match compute_range(¤t_content, Some(&edit.old_string), &operation) { + Ok(r) => r, + Err(Error::NoMatch(search_text)) + if matches!( + operation, + PatchOperation::Replace | PatchOperation::ReplaceAll | PatchOperation::Swap + ) => + { + // Try fuzzy search as fallback + match self + .infra + .fuzzy_search(&search_text, ¤t_content, false) + .await + { + Ok(matches) if !matches.is_empty() => { + // Use the first fuzzy match + Some(Range::from_search_match(¤t_content, &matches[0])) + } + _ => return Err(Error::NoMatch(search_text).into()), + } + } + Err(e) => return Err(e.into()), + }; + + // Apply the replacement + current_content = + apply_replacement(current_content, range, &operation, &edit.new_string)?; + } + + // SNAPSHOT COORDINATION: Always capture snapshot before modifying + self.infra.insert_snapshot(path).await?; + + // Write final content to file after all patches are applied + self.infra + .write(path, Bytes::from(current_content.clone())) + .await?; + + // Compute hash of the final file content + let content_hash = compute_hash(¤t_content); + + // Validate file syntax using remote validation API (graceful failure) + let errors = self + .infra + .validate_file(path, ¤t_content) + .await + .unwrap_or_default(); + + Ok(PatchOutput { + errors, + before: old_content, + after: current_content, + content_hash, + }) + } } #[cfg(test)]