|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import * as fs from 'fs'; |
|
|
import * as path from 'path'; |
|
|
import * as Diff from 'diff'; |
|
|
import { |
|
|
BaseTool, |
|
|
ToolCallConfirmationDetails, |
|
|
ToolConfirmationOutcome, |
|
|
ToolEditConfirmationDetails, |
|
|
ToolResult, |
|
|
ToolResultDisplay, |
|
|
} from './tools.js'; |
|
|
import { SchemaValidator } from '../utils/schemaValidator.js'; |
|
|
import { makeRelative, shortenPath } from '../utils/paths.js'; |
|
|
import { isNodeError } from '../utils/errors.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'; |
|
|
import { ModifiableTool, ModifyContext } from './modifiable-tool.js'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export interface EditToolParams { |
|
|
|
|
|
|
|
|
|
|
|
file_path: string; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
old_string: string; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
new_string: string; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
expected_replacements?: number; |
|
|
} |
|
|
|
|
|
interface CalculatedEdit { |
|
|
currentContent: string | null; |
|
|
newContent: string; |
|
|
occurrences: number; |
|
|
error?: { display: string; raw: string }; |
|
|
isNewFile: boolean; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export class EditTool |
|
|
extends BaseTool<EditToolParams, ToolResult> |
|
|
implements ModifiableTool<EditToolParams> |
|
|
{ |
|
|
static readonly Name = 'replace'; |
|
|
private readonly config: Config; |
|
|
private readonly rootDirectory: string; |
|
|
private readonly client: GeminiClient; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.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. |
|
|
2. \`old_string\` MUST be the exact literal text to replace (including all whitespace, indentation, newlines, and surrounding code etc.). |
|
|
3. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic. |
|
|
4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement. |
|
|
**Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: 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. If this string matches multiple locations, or does not match exactly, the tool will fail. |
|
|
**Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.`, |
|
|
{ |
|
|
properties: { |
|
|
file_path: { |
|
|
description: |
|
|
"The absolute path to the file to modify. Must start with '/'.", |
|
|
type: 'string', |
|
|
}, |
|
|
old_string: { |
|
|
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', |
|
|
}, |
|
|
expected_replacements: { |
|
|
type: 'number', |
|
|
description: |
|
|
'Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.', |
|
|
minimum: 1, |
|
|
}, |
|
|
}, |
|
|
required: ['file_path', 'old_string', 'new_string'], |
|
|
type: 'object', |
|
|
}, |
|
|
); |
|
|
this.config = config; |
|
|
this.rootDirectory = path.resolve(this.config.getTargetDir()); |
|
|
this.client = config.getGeminiClient(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private isWithinRoot(pathToCheck: string): boolean { |
|
|
const normalizedPath = path.normalize(pathToCheck); |
|
|
const normalizedRoot = this.rootDirectory; |
|
|
const rootWithSep = normalizedRoot.endsWith(path.sep) |
|
|
? normalizedRoot |
|
|
: normalizedRoot + path.sep; |
|
|
return ( |
|
|
normalizedPath === normalizedRoot || |
|
|
normalizedPath.startsWith(rootWithSep) |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
validateToolParams(params: EditToolParams): string | null { |
|
|
if ( |
|
|
this.schema.parameters && |
|
|
!SchemaValidator.validate( |
|
|
this.schema.parameters as Record<string, unknown>, |
|
|
params, |
|
|
) |
|
|
) { |
|
|
return 'Parameters failed schema validation.'; |
|
|
} |
|
|
|
|
|
if (!path.isAbsolute(params.file_path)) { |
|
|
return `File path must be absolute: ${params.file_path}`; |
|
|
} |
|
|
|
|
|
if (!this.isWithinRoot(params.file_path)) { |
|
|
return `File path must be within the root directory (${this.rootDirectory}): ${params.file_path}`; |
|
|
} |
|
|
|
|
|
return null; |
|
|
} |
|
|
|
|
|
private _applyReplacement( |
|
|
currentContent: string | null, |
|
|
oldString: string, |
|
|
newString: string, |
|
|
isNewFile: boolean, |
|
|
): string { |
|
|
if (isNewFile) { |
|
|
return newString; |
|
|
} |
|
|
if (currentContent === null) { |
|
|
|
|
|
return oldString === '' ? newString : ''; |
|
|
} |
|
|
|
|
|
if (oldString === '' && !isNewFile) { |
|
|
return currentContent; |
|
|
} |
|
|
return currentContent.replaceAll(oldString, newString); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async calculateEdit( |
|
|
params: EditToolParams, |
|
|
abortSignal: AbortSignal, |
|
|
): Promise<CalculatedEdit> { |
|
|
const expectedReplacements = params.expected_replacements ?? 1; |
|
|
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'); |
|
|
|
|
|
currentContent = currentContent.replace(/\r\n/g, '\n'); |
|
|
fileExists = true; |
|
|
} catch (err: unknown) { |
|
|
if (!isNodeError(err) || err.code !== 'ENOENT') { |
|
|
|
|
|
throw err; |
|
|
} |
|
|
fileExists = false; |
|
|
} |
|
|
|
|
|
if (params.old_string === '' && !fileExists) { |
|
|
|
|
|
isNewFile = true; |
|
|
} else if (!fileExists) { |
|
|
|
|
|
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) { |
|
|
|
|
|
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 = { |
|
|
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 { |
|
|
|
|
|
error = { |
|
|
display: `Failed to read content of file.`, |
|
|
raw: `Failed to read content of existing file: ${params.file_path}`, |
|
|
}; |
|
|
} |
|
|
|
|
|
const newContent = this._applyReplacement( |
|
|
currentContent, |
|
|
finalOldString, |
|
|
finalNewString, |
|
|
isNewFile, |
|
|
); |
|
|
|
|
|
return { |
|
|
currentContent, |
|
|
newContent, |
|
|
occurrences, |
|
|
error, |
|
|
isNewFile, |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async shouldConfirmExecute( |
|
|
params: EditToolParams, |
|
|
abortSignal: AbortSignal, |
|
|
): Promise<ToolCallConfirmationDetails | false> { |
|
|
if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) { |
|
|
return false; |
|
|
} |
|
|
const validationError = this.validateToolParams(params); |
|
|
if (validationError) { |
|
|
console.error( |
|
|
`[EditTool Wrapper] Attempted confirmation with invalid parameters: ${validationError}`, |
|
|
); |
|
|
return false; |
|
|
} |
|
|
|
|
|
let editData: CalculatedEdit; |
|
|
try { |
|
|
editData = await this.calculateEdit(params, abortSignal); |
|
|
} catch (error) { |
|
|
const errorMsg = error instanceof Error ? error.message : String(error); |
|
|
console.log(`Error preparing edit: ${errorMsg}`); |
|
|
return false; |
|
|
} |
|
|
|
|
|
if (editData.error) { |
|
|
console.log(`Error: ${editData.error.display}`); |
|
|
return false; |
|
|
} |
|
|
|
|
|
const fileName = path.basename(params.file_path); |
|
|
const fileDiff = Diff.createPatch( |
|
|
fileName, |
|
|
editData.currentContent ?? '', |
|
|
editData.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); |
|
|
} |
|
|
}, |
|
|
}; |
|
|
return confirmationDetails; |
|
|
} |
|
|
|
|
|
getDescription(params: EditToolParams): string { |
|
|
if (!params.file_path || !params.old_string || !params.new_string) { |
|
|
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)}`; |
|
|
} |
|
|
|
|
|
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)}`; |
|
|
} |
|
|
return `${shortenPath(relativePath)}: ${oldStringSnippet} => ${newStringSnippet}`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async execute( |
|
|
params: EditToolParams, |
|
|
signal: AbortSignal, |
|
|
): Promise<ToolResult> { |
|
|
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}`, |
|
|
}; |
|
|
} |
|
|
|
|
|
try { |
|
|
this.ensureParentDirectoriesExist(params.file_path); |
|
|
fs.writeFileSync(params.file_path, editData.newContent, 'utf8'); |
|
|
|
|
|
let displayResult: ToolResultDisplay; |
|
|
if (editData.isNewFile) { |
|
|
displayResult = `Created ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`; |
|
|
} else { |
|
|
|
|
|
|
|
|
const fileName = path.basename(params.file_path); |
|
|
const fileDiff = Diff.createPatch( |
|
|
fileName, |
|
|
editData.currentContent ?? '', |
|
|
editData.newContent, |
|
|
'Current', |
|
|
'Proposed', |
|
|
DEFAULT_DIFF_OPTIONS, |
|
|
); |
|
|
displayResult = { fileDiff, fileName }; |
|
|
} |
|
|
|
|
|
const llmSuccessMessage = editData.isNewFile |
|
|
? `Created new file: ${params.file_path} with provided content.` |
|
|
: `Successfully modified file: ${params.file_path} (${editData.occurrences} replacements).`; |
|
|
|
|
|
return { |
|
|
llmContent: llmSuccessMessage, |
|
|
returnDisplay: displayResult, |
|
|
}; |
|
|
} catch (error) { |
|
|
const errorMsg = error instanceof Error ? error.message : String(error); |
|
|
return { |
|
|
llmContent: `Error executing edit: ${errorMsg}`, |
|
|
returnDisplay: `Error writing file: ${errorMsg}`, |
|
|
}; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private ensureParentDirectoriesExist(filePath: string): void { |
|
|
const dirName = path.dirname(filePath); |
|
|
if (!fs.existsSync(dirName)) { |
|
|
fs.mkdirSync(dirName, { recursive: true }); |
|
|
} |
|
|
} |
|
|
|
|
|
getModifyContext(_: AbortSignal): ModifyContext<EditToolParams> { |
|
|
return { |
|
|
getFilePath: (params: EditToolParams) => params.file_path, |
|
|
getCurrentContent: async (params: EditToolParams): Promise<string> => { |
|
|
try { |
|
|
return fs.readFileSync(params.file_path, 'utf8'); |
|
|
} catch (err) { |
|
|
if (!isNodeError(err) || err.code !== 'ENOENT') throw err; |
|
|
return ''; |
|
|
} |
|
|
}, |
|
|
getProposedContent: async (params: EditToolParams): Promise<string> => { |
|
|
try { |
|
|
const currentContent = fs.readFileSync(params.file_path, 'utf8'); |
|
|
return this._applyReplacement( |
|
|
currentContent, |
|
|
params.old_string, |
|
|
params.new_string, |
|
|
params.old_string === '' && currentContent === '', |
|
|
); |
|
|
} catch (err) { |
|
|
if (!isNodeError(err) || err.code !== 'ENOENT') throw err; |
|
|
return ''; |
|
|
} |
|
|
}, |
|
|
createUpdatedParams: ( |
|
|
oldContent: string, |
|
|
modifiedProposedContent: string, |
|
|
originalParams: EditToolParams, |
|
|
): EditToolParams => ({ |
|
|
...originalParams, |
|
|
old_string: oldContent, |
|
|
new_string: modifiedProposedContent, |
|
|
}), |
|
|
}; |
|
|
} |
|
|
} |
|
|
|