Add batch editing capabilities to Edit Tool (#648)

Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
This commit is contained in:
Keith Ballinger
2025-06-06 22:54:37 -07:00
committed by GitHub
parent 76ec9122c0
commit 0c86874677
23 changed files with 3298 additions and 1542 deletions

View File

@@ -18,11 +18,11 @@ import {
import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { isNodeError } from '../utils/errors.js';
import { ReadFileTool } from './read-file.js';
import { GeminiClient } from '../core/client.js';
import { Config, ApprovalMode } from '../config/config.js';
import { ensureCorrectEdit } from '../utils/editCorrector.js';
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
import { ReadFileTool } from './read-file.js';
/**
* Parameters for the Edit tool
@@ -34,14 +34,12 @@ export interface EditToolParams {
file_path: string;
/**
* The text to replace
* Array of edits to apply
*/
old_string: string;
/**
* The text to replace it with
*/
new_string: string;
edits: Array<{
old_string: string;
new_string: string;
}>;
/**
* Number of replacements expected. Defaults to 1 if not specified.
@@ -50,18 +48,29 @@ export interface EditToolParams {
expected_replacements?: number;
}
interface CalculatedEdit {
currentContent: string | null;
newContent: string;
occurrences: number;
error?: { display: string; raw: string };
isNewFile: boolean;
interface EditResult extends ToolResult {
editsApplied: number;
editsAttempted: number;
editsFailed: number;
failedEdits?: Array<{
index: number;
oldString: string;
newString: string;
error: string;
}>;
}
interface FailedEdit {
index: number;
oldString: string;
newString: string;
error: string;
}
/**
* Implementation of the Edit tool logic
*/
export class EditTool extends BaseTool<EditToolParams, ToolResult> {
export class EditTool extends BaseTool<EditToolParams, EditResult> {
static readonly Name = 'replace';
private readonly config: Config;
private readonly rootDirectory: string;
@@ -74,8 +83,8 @@ export class EditTool extends BaseTool<EditToolParams, ToolResult> {
constructor(config: Config) {
super(
EditTool.Name,
'Edit',
`Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${ReadFileTool} tool to examine the file's current content before attempting a text replacement.
'EditFile',
`Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool also supports batch editing with multiple edits in a single operation. Requires providing significant context around the change to ensure precise targeting. Always use the ${ReadFileTool.Name} tool to examine the file's current content before attempting a text replacement.
Expectation for required parameters:
1. \`file_path\` MUST be an absolute path; otherwise an error will be thrown.
@@ -91,15 +100,26 @@ Expectation for required parameters:
"The absolute path to the file to modify. Must start with '/'.",
type: 'string',
},
old_string: {
edits: {
description:
'The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. For multiple replacements, specify expected_replacements parameter. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.',
type: 'string',
},
new_string: {
description:
'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.',
type: 'string',
'Array of edit operations to apply. Each edit should have old_string and new_string properties.',
type: 'array',
items: {
type: 'object',
properties: {
old_string: {
description:
'The exact literal text to replace, preferably unescaped. CRITICAL: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely.',
type: 'string',
},
new_string: {
description:
'The exact literal text to replace old_string with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.',
type: 'string',
},
},
required: ['old_string', 'new_string'],
},
},
expected_replacements: {
type: 'number',
@@ -108,7 +128,7 @@ Expectation for required parameters:
minimum: 1,
},
},
required: ['file_path', 'old_string', 'new_string'],
required: ['file_path', 'edits'],
type: 'object',
},
);
@@ -158,6 +178,11 @@ Expectation for required parameters:
return `File path must be within the root directory (${this.rootDirectory}): ${params.file_path}`;
}
// Validate that edits array is provided and not empty
if (!params.edits || params.edits.length === 0) {
return 'Must provide "edits" array with at least one edit.';
}
return null;
}
@@ -182,95 +207,124 @@ Expectation for required parameters:
}
/**
* Calculates the potential outcome of an edit operation.
* @param params Parameters for the edit operation
* @returns An object describing the potential edit outcome
* @throws File system errors if reading the file fails unexpectedly (e.g., permissions)
* Applies multiple edits to file content in sequence
* @param params Edit parameters
* @param abortSignal Abort signal for cancellation
* @returns Result with detailed edit metrics
*/
private async calculateEdit(
private async applyMultipleEdits(
params: EditToolParams,
abortSignal: AbortSignal,
): Promise<CalculatedEdit> {
const expectedReplacements = params.expected_replacements ?? 1;
): Promise<{
newContent: string;
editsApplied: number;
editsAttempted: number;
editsFailed: number;
failedEdits: FailedEdit[];
isNewFile: boolean;
originalContent: string | null;
}> {
// Read current file content or determine if this is a new file
let currentContent: string | null = null;
let fileExists = false;
let isNewFile = false;
let finalNewString = params.new_string;
let finalOldString = params.old_string;
let occurrences = 0;
let error: { display: string; raw: string } | undefined = undefined;
try {
currentContent = fs.readFileSync(params.file_path, 'utf8');
fileExists = true;
} catch (err: unknown) {
if (!isNodeError(err) || err.code !== 'ENOENT') {
// Rethrow unexpected FS errors (permissions, etc.)
throw err;
}
fileExists = false;
}
if (params.old_string === '' && !fileExists) {
// Creating a new file
// If file doesn't exist and first edit has empty old_string, it's file creation
if (!fileExists && params.edits[0].old_string === '') {
isNewFile = true;
currentContent = '';
} else if (!fileExists) {
// Trying to edit a non-existent file (and old_string is not empty)
error = {
display: `File not found. Cannot apply edit. Use an empty old_string to create a new file.`,
raw: `File not found: ${params.file_path}`,
};
} else if (currentContent !== null) {
// Editing an existing file
const correctedEdit = await ensureCorrectEdit(
currentContent,
params,
this.client,
abortSignal,
);
finalOldString = correctedEdit.params.old_string;
finalNewString = correctedEdit.params.new_string;
occurrences = correctedEdit.occurrences;
if (params.old_string === '') {
// Error: Trying to create a file that already exists
error = {
display: `Failed to edit. Attempted to create a file that already exists.`,
raw: `File already exists, cannot create: ${params.file_path}`,
};
} else if (occurrences === 0) {
error = {
display: `Failed to edit, could not find the string to replace.`,
raw: `Failed to edit, 0 occurrences found for old_string in ${params.file_path}. No edits made. The exact text in old_string was not found. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use ${ReadFileTool.Name} tool to verify.`,
};
} else if (occurrences !== expectedReplacements) {
error = {
display: `Failed to edit, expected ${expectedReplacements} occurrence(s) but found ${occurrences}.`,
raw: `Failed to edit, Expected ${expectedReplacements} occurrences but found ${occurrences} for old_string in file: ${params.file_path}`,
};
}
} else {
// Should not happen if fileExists and no exception was thrown, but defensively:
error = {
display: `Failed to read content of file.`,
raw: `Failed to read content of existing file: ${params.file_path}`,
};
throw new Error(`File does not exist: ${params.file_path}`);
} else if (fileExists && params.edits[0].old_string === '') {
// Protect against accidentally creating a file that already exists
throw new Error(`File already exists: ${params.file_path}`);
}
const newContent = this._applyReplacement(
currentContent,
finalOldString,
finalNewString,
isNewFile,
);
const expectedReplacements = params.expected_replacements ?? 1;
return {
currentContent,
newContent,
occurrences,
error,
const result = {
newContent: currentContent || '',
editsApplied: 0,
editsAttempted: params.edits.length,
editsFailed: 0,
failedEdits: [] as FailedEdit[],
isNewFile,
originalContent: currentContent,
};
// Apply each edit
for (let i = 0; i < params.edits.length; i++) {
const edit = params.edits[i];
// Handle new file creation with empty old_string
if (isNewFile && edit.old_string === '') {
result.newContent = edit.new_string;
result.editsApplied++;
continue;
}
// Use edit corrector for better matching
try {
const correctedEdit = await ensureCorrectEdit(
result.newContent,
{
...params,
old_string: edit.old_string,
new_string: edit.new_string,
},
this.client,
abortSignal,
);
// Handle both single and multiple replacements based on expected_replacements
if (expectedReplacements === 1 && correctedEdit.occurrences === 1) {
result.newContent = result.newContent.replace(
correctedEdit.params.old_string,
correctedEdit.params.new_string,
);
result.editsApplied++;
} else if (
expectedReplacements > 1 &&
correctedEdit.occurrences === expectedReplacements
) {
result.newContent = result.newContent.replaceAll(
correctedEdit.params.old_string,
correctedEdit.params.new_string,
);
result.editsApplied++;
} else {
result.editsFailed++;
result.failedEdits.push({
index: i,
oldString: edit.old_string,
newString: edit.new_string,
error:
correctedEdit.occurrences === 0
? 'String not found'
: `Expected ${expectedReplacements} occurrences but found ${correctedEdit.occurrences}`,
});
}
} catch (error) {
result.editsFailed++;
result.failedEdits.push({
index: i,
oldString: edit.old_string,
newString: edit.new_string,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
/**
@@ -291,98 +345,89 @@ Expectation for required parameters:
);
return false;
}
let currentContent: string | null = null;
let fileExists = false;
let finalNewString = params.new_string;
let finalOldString = params.old_string;
let occurrences = 0;
try {
currentContent = fs.readFileSync(params.file_path, 'utf8');
fileExists = true;
} catch (err: unknown) {
if (isNodeError(err) && err.code === 'ENOENT') {
fileExists = false;
} else {
console.error(`Error reading file for confirmation diff: ${err}`);
// Calculate what the edits would produce
const editResult = await this.applyMultipleEdits(params, abortSignal);
// Don't show confirmation if no edits would be applied
if (editResult.editsApplied === 0 && !editResult.isNewFile) {
return false;
}
}
if (params.old_string === '' && !fileExists) {
// Creating new file, newContent is just params.new_string
} else if (!fileExists) {
return false; // Cannot edit non-existent file if old_string is not empty
} else if (currentContent !== null) {
const correctedEdit = await ensureCorrectEdit(
currentContent,
params,
this.client,
abortSignal,
);
finalOldString = correctedEdit.params.old_string;
finalNewString = correctedEdit.params.new_string;
occurrences = correctedEdit.occurrences;
const expectedReplacements = params.expected_replacements ?? 1;
if (occurrences === 0 || occurrences !== expectedReplacements) {
return false;
}
} else {
return false; // Should not happen
}
const isNewFileScenario = params.old_string === '' && !fileExists;
const newContent = this._applyReplacement(
currentContent,
finalOldString,
finalNewString,
isNewFileScenario,
);
const fileName = path.basename(params.file_path);
const fileDiff = Diff.createPatch(
fileName,
currentContent ?? '',
newContent,
'Current',
'Proposed',
DEFAULT_DIFF_OPTIONS,
);
const confirmationDetails: ToolEditConfirmationDetails = {
type: 'edit',
title: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`,
fileName,
fileDiff,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
// Read current content for diff comparison
let currentContent: string | null = null;
try {
currentContent = fs.readFileSync(params.file_path, 'utf8');
} catch (err: unknown) {
if (isNodeError(err) && err.code === 'ENOENT') {
currentContent = '';
} else {
console.error(`Error reading file for confirmation diff: ${err}`);
return false;
}
},
};
return confirmationDetails;
}
// Generate diff for confirmation
const fileName = path.basename(params.file_path);
const fileDiff = Diff.createPatch(
fileName,
currentContent || '',
editResult.newContent,
'Current',
'Proposed',
DEFAULT_DIFF_OPTIONS,
);
const editsCount = params.edits.length;
const title =
editsCount > 1
? `Confirm ${editsCount} Edits: ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`
: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`;
const confirmationDetails: ToolEditConfirmationDetails = {
type: 'edit',
title,
fileName,
fileDiff,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
}
},
};
return confirmationDetails;
} catch (error) {
console.error(`Error generating confirmation diff: ${error}`);
return false;
}
}
getDescription(params: EditToolParams): string {
if (!params.file_path || !params.old_string || !params.new_string) {
if (!params.file_path) {
return `Model did not provide valid parameters for edit tool`;
}
const relativePath = makeRelative(params.file_path, this.rootDirectory);
if (params.old_string === '') {
return `Create ${shortenPath(relativePath)}`;
if (!params.edits || params.edits.length === 0) {
return `Edit ${shortenPath(relativePath)}`;
}
const oldStringSnippet =
params.old_string.split('\n')[0].substring(0, 30) +
(params.old_string.length > 30 ? '...' : '');
const newStringSnippet =
params.new_string.split('\n')[0].substring(0, 30) +
(params.new_string.length > 30 ? '...' : '');
if (params.old_string === params.new_string) {
return `No file changes to ${shortenPath(relativePath)}`;
if (params.edits.length === 1) {
const edit = params.edits[0];
if (edit.old_string === '') {
return `Create ${shortenPath(relativePath)}`;
}
const oldSnippet =
edit.old_string.split('\n')[0].substring(0, 30) +
(edit.old_string.length > 30 ? '...' : '');
const newSnippet =
edit.new_string.split('\n')[0].substring(0, 30) +
(edit.new_string.length > 30 ? '...' : '');
return `${shortenPath(relativePath)}: ${oldSnippet} => ${newSnippet}`;
} else {
return `Edit ${shortenPath(relativePath)} (${params.edits.length} edits)`;
}
return `${shortenPath(relativePath)}: ${oldStringSnippet} => ${newStringSnippet}`;
}
/**
@@ -392,69 +437,79 @@ Expectation for required parameters:
*/
async execute(
params: EditToolParams,
_signal: AbortSignal,
): Promise<ToolResult> {
abortSignal: AbortSignal,
): Promise<EditResult> {
const validationError = this.validateToolParams(params);
if (validationError) {
return {
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
returnDisplay: `Error: ${validationError}`,
};
}
let editData: CalculatedEdit;
try {
editData = await this.calculateEdit(params, _signal);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return {
llmContent: `Error preparing edit: ${errorMsg}`,
returnDisplay: `Error preparing edit: ${errorMsg}`,
};
}
if (editData.error) {
return {
llmContent: editData.error.raw,
returnDisplay: `Error: ${editData.error.display}`,
editsApplied: 0,
editsAttempted: 0,
editsFailed: 1,
};
}
try {
const editResult = await this.applyMultipleEdits(params, abortSignal);
// Apply the changes to the file
this.ensureParentDirectoriesExist(params.file_path);
fs.writeFileSync(params.file_path, editData.newContent, 'utf8');
fs.writeFileSync(params.file_path, editResult.newContent, 'utf8');
// Generate appropriate response messages
let displayResult: ToolResultDisplay;
if (editData.isNewFile) {
let llmContent: string;
if (editResult.isNewFile) {
displayResult = `Created ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`;
} else {
// Generate diff for display, even though core logic doesn't technically need it
// The CLI wrapper will use this part of the ToolResult
llmContent = `Created new file: ${params.file_path}`;
} else if (editResult.editsApplied > 0) {
// Generate diff for display using original content before writing
const fileName = path.basename(params.file_path);
// Use the original content from before the edit was applied
const originalContent = editResult.originalContent || '';
const fileDiff = Diff.createPatch(
fileName,
editData.currentContent ?? '', // Should not be null here if not isNewFile
editData.newContent,
originalContent,
editResult.newContent,
'Current',
'Proposed',
DEFAULT_DIFF_OPTIONS,
);
displayResult = { fileDiff, fileName };
llmContent = `Successfully applied ${editResult.editsApplied}/${editResult.editsAttempted} edits to ${params.file_path}`;
} else {
displayResult = `No edits applied to ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`;
llmContent = `Failed to apply any edits to ${params.file_path}`;
}
const llmSuccessMessage = editData.isNewFile
? `Created new file: ${params.file_path} with provided content.`
: `Successfully modified file: ${params.file_path} (${editData.occurrences} replacements).`;
// Add details about failed edits
if (editResult.editsFailed > 0) {
const failureDetails = editResult.failedEdits
.map((f) => `Edit ${f.index + 1}: ${f.error}`)
.join('; ');
llmContent += `. Failed edits: ${failureDetails}`;
}
return {
llmContent: llmSuccessMessage,
llmContent,
returnDisplay: displayResult,
editsApplied: editResult.editsApplied,
editsAttempted: editResult.editsAttempted,
editsFailed: editResult.editsFailed,
failedEdits: editResult.failedEdits,
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
const editsAttempted = params.edits.length;
return {
llmContent: `Error executing edit: ${errorMsg}`,
returnDisplay: `Error writing file: ${errorMsg}`,
llmContent: `Error executing edits: ${errorMsg}`,
returnDisplay: `Error: ${errorMsg}`,
editsApplied: 0,
editsAttempted,
editsFailed: editsAttempted,
};
}
}