Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/forge_app/src/fmt/fmt_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
7 changes: 7 additions & 0 deletions crates/forge_app/src/fmt/fmt_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}",
Expand Down
31 changes: 29 additions & 2 deletions crates/forge_app/src/operation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -54,6 +54,10 @@ pub enum ToolOperation {
input: FSPatch,
output: PatchOutput,
},
FsMultiPatch {
input: FSMultiPatch,
output: PatchOutput,
},
FsUndo {
input: FSUndo,
output: FsUndoOutput,
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions crates/forge_app/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,13 @@ pub trait FsPatchService: Send + Sync {
content: String,
replace_all: bool,
) -> anyhow::Result<PatchOutput>;

/// Applies multiple patches to a single file in sequence
async fn multi_patch(
&self,
path: String,
edits: Vec<forge_domain::PatchEdit>,
) -> anyhow::Result<PatchOutput>;
}

#[async_trait::async_trait]
Expand Down Expand Up @@ -798,6 +805,14 @@ impl<I: Services> FsPatchService for I {
.patch(path, search, content, replace_all)
.await
}

async fn multi_patch(
&self,
path: String,
edits: Vec<forge_domain::PatchEdit>,
) -> anyhow::Result<PatchOutput> {
self.fs_patch_service().multi_patch(path, edits).await
}
}

#[async_trait::async_trait]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 17 additions & 3 deletions crates/forge_app/src/tool_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down Expand Up @@ -327,9 +335,15 @@ impl<
let env = self.services.get_environment();
let config = &self.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
Expand Down
3 changes: 3 additions & 0 deletions crates/forge_domain/src/compact/summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,9 @@ fn extract_tool_info(call: &ToolCallFull, current_todos: &[Todo]) -> Option<Summ
ToolCatalog::Read(input) => 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) => {
Expand Down
37 changes: 37 additions & 0 deletions crates/forge_domain/src/tools/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pub enum ToolCatalog {
SemSearch(SemanticSearch),
Remove(FSRemove),
Patch(FSPatch),
MultiPatch(FSMultiPatch),
Undo(FSUndo),
Shell(Shell),
Fetch(NetFetch),
Expand Down Expand Up @@ -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<PatchEdit>,
}

#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema, ToolDescription, PartialEq)]
#[tool_description_file = "crates/forge_domain/src/tools/descriptions/fs_undo.md"]
pub struct FSUndo {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -811,6 +838,7 @@ impl ToolCatalog {

let mut schema = match self {
ToolCatalog::Patch(_) => r#gen.into_root_schema_for::<FSPatch>(),
ToolCatalog::MultiPatch(_) => r#gen.into_root_schema_for::<FSMultiPatch>(),
ToolCatalog::Shell(_) => r#gen.into_root_schema_for::<Shell>(),
ToolCatalog::Followup(_) => r#gen.into_root_schema_for::<Followup>(),
ToolCatalog::Fetch(_) => r#gen.into_root_schema_for::<NetFetch>(),
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading