2025-04-19 19:45:42 +01:00
/ * *
* @license
* Copyright 2025 Google LLC
* SPDX - License - Identifier : Apache - 2.0
* /
import fs from 'fs' ;
import path from 'path' ;
import * as Diff from 'diff' ;
2025-04-21 10:53:11 -04:00
import {
BaseTool ,
ToolCallConfirmationDetails ,
ToolConfirmationOutcome ,
ToolEditConfirmationDetails ,
ToolResult ,
ToolResultDisplay ,
} from './tools.js' ;
2025-04-19 19:45:42 +01:00
import { SchemaValidator } from '../utils/schemaValidator.js' ;
import { makeRelative , shortenPath } from '../utils/paths.js' ;
import { isNodeError } from '../utils/errors.js' ;
2025-05-12 23:23:24 -07:00
import { GeminiClient } from '../core/client.js' ;
2025-06-02 22:05:45 +02:00
import { Config , ApprovalMode } from '../config/config.js' ;
2025-05-25 14:16:08 -07:00
import { ensureCorrectEdit } from '../utils/editCorrector.js' ;
2025-05-25 22:38:44 -07:00
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js' ;
2025-06-06 22:54:37 -07:00
import { ReadFileTool } from './read-file.js' ;
2025-04-19 19:45:42 +01:00
/ * *
* Parameters for the Edit tool
* /
export interface EditToolParams {
/ * *
* The absolute path to the file to modify
* /
file_path : string ;
/ * *
2025-06-06 22:54:37 -07:00
* Array of edits to apply
2025-04-19 19:45:42 +01:00
* /
2025-06-06 22:54:37 -07:00
edits : Array < {
old_string : string ;
new_string : string ;
} > ;
2025-06-01 17:49:48 -04:00
/ * *
* Number of replacements expected . Defaults to 1 if not specified .
* Use when you want to replace multiple occurrences .
* /
expected_replacements? : number ;
2025-04-19 19:45:42 +01:00
}
2025-06-06 22:54:37 -07:00
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 ;
2025-04-19 19:45:42 +01:00
}
/ * *
2025-05-02 14:39:39 -07:00
* Implementation of the Edit tool logic
2025-04-19 19:45:42 +01:00
* /
2025-06-06 22:54:37 -07:00
export class EditTool extends BaseTool < EditToolParams , EditResult > {
2025-05-02 14:39:39 -07:00
static readonly Name = 'replace' ;
2025-05-16 23:33:12 -07:00
private readonly config : Config ;
2025-05-12 23:23:24 -07:00
private readonly rootDirectory : string ;
private readonly client : GeminiClient ;
2025-04-19 19:45:42 +01:00
/ * *
* Creates a new instance of the EditLogic
* @param rootDirectory Root directory to ground this tool in .
* /
2025-05-12 23:23:24 -07:00
constructor ( config : Config ) {
2025-04-19 19:45:42 +01:00
super (
2025-04-21 10:53:11 -04:00
EditTool . Name ,
2025-06-06 22:54:37 -07:00
'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.
2025-05-07 18:29:59 -07:00
2025-06-01 17:49:48 -04:00
Expectation for required parameters :
2025-05-12 23:23:24 -07:00
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.
2025-06-07 15:06:18 -04:00
* * 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.
2025-06-01 17:49:48 -04:00
* * 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. ` ,
2025-04-19 19:45:42 +01:00
{
properties : {
file_path : {
description :
2025-05-07 18:29:59 -07:00
"The absolute path to the file to modify. Must start with '/'." ,
2025-04-19 19:45:42 +01:00
type : 'string' ,
} ,
2025-06-06 22:54:37 -07:00
edits : {
2025-04-19 19:45:42 +01:00
description :
2025-06-06 22:54:37 -07:00
'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' ] ,
} ,
2025-04-19 19:45:42 +01:00
} ,
2025-06-01 17:49:48 -04:00
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 ,
} ,
2025-04-19 19:45:42 +01:00
} ,
2025-06-06 22:54:37 -07:00
required : [ 'file_path' , 'edits' ] ,
2025-04-19 19:45:42 +01:00
type : 'object' ,
} ,
) ;
2025-05-16 23:33:12 -07:00
this . config = config ;
this . rootDirectory = path . resolve ( this . config . getTargetDir ( ) ) ;
2025-06-02 22:30:52 -07:00
this . client = config . getGeminiClient ( ) ;
2025-04-19 19:45:42 +01:00
}
/ * *
* Checks if a path is within the root directory .
* @param pathToCheck The absolute path to check .
* @returns True if the path is within the root directory , false otherwise .
* /
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 )
) ;
}
/ * *
* Validates the parameters for the Edit tool
* @param params Parameters to validate
* @returns Error message string or null if valid
* /
2025-06-01 19:18:43 +01:00
validateToolParams ( params : EditToolParams ) : string | null {
2025-04-19 19:45:42 +01:00
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 } ` ;
}
2025-06-06 22:54:37 -07:00
// 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.' ;
}
2025-04-19 19:45:42 +01:00
return null ;
}
2025-05-25 14:16:08 -07:00
private _applyReplacement (
currentContent : string | null ,
oldString : string ,
newString : string ,
isNewFile : boolean ,
) : string {
if ( isNewFile ) {
return newString ;
}
if ( currentContent === null ) {
// Should not happen if not a new file, but defensively return empty or newString if oldString is also empty
return oldString === '' ? newString : '' ;
}
// If oldString is empty and it's not a new file, do not modify the content.
if ( oldString === '' && ! isNewFile ) {
return currentContent ;
}
return currentContent . replaceAll ( oldString , newString ) ;
}
2025-04-19 19:45:42 +01:00
/ * *
2025-06-06 22:54:37 -07:00
* Applies multiple edits to file content in sequence
* @param params Edit parameters
* @param abortSignal Abort signal for cancellation
* @returns Result with detailed edit metrics
2025-04-19 19:45:42 +01:00
* /
2025-06-06 22:54:37 -07:00
private async applyMultipleEdits (
2025-05-27 23:40:25 -07:00
params : EditToolParams ,
abortSignal : AbortSignal ,
2025-06-06 22:54:37 -07:00
) : 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
2025-04-19 19:45:42 +01:00
let currentContent : string | null = null ;
let fileExists = false ;
let isNewFile = false ;
try {
currentContent = fs . readFileSync ( params . file_path , 'utf8' ) ;
fileExists = true ;
} catch ( err : unknown ) {
if ( ! isNodeError ( err ) || err . code !== 'ENOENT' ) {
throw err ;
}
}
2025-06-06 22:54:37 -07:00
// If file doesn't exist and first edit has empty old_string, it's file creation
if ( ! fileExists && params . edits [ 0 ] . old_string === '' ) {
2025-04-19 19:45:42 +01:00
isNewFile = true ;
2025-06-06 22:54:37 -07:00
currentContent = '' ;
2025-04-19 19:45:42 +01:00
} else if ( ! fileExists ) {
2025-06-06 22:54:37 -07:00
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 } ` ) ;
2025-04-19 19:45:42 +01:00
}
2025-06-06 22:54:37 -07:00
const expectedReplacements = params . expected_replacements ? ? 1 ;
2025-05-25 14:16:08 -07:00
2025-06-06 22:54:37 -07:00
const result = {
newContent : currentContent || '' ,
editsApplied : 0 ,
editsAttempted : params.edits.length ,
editsFailed : 0 ,
failedEdits : [ ] as FailedEdit [ ] ,
2025-04-19 19:45:42 +01:00
isNewFile ,
2025-06-06 22:54:37 -07:00
originalContent : currentContent ,
2025-04-19 19:45:42 +01:00
} ;
2025-06-06 22:54:37 -07:00
// 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 ;
2025-04-19 19:45:42 +01:00
}
2025-04-21 10:53:11 -04:00
/ * *
* Handles the confirmation prompt for the Edit tool in the CLI .
* It needs to calculate the diff to show the user .
* /
async shouldConfirmExecute (
params : EditToolParams ,
2025-05-27 23:40:25 -07:00
abortSignal : AbortSignal ,
2025-04-21 10:53:11 -04:00
) : Promise < ToolCallConfirmationDetails | false > {
2025-06-02 22:05:45 +02:00
if ( this . config . getApprovalMode ( ) === ApprovalMode . AUTO_EDIT ) {
2025-04-21 10:53:11 -04:00
return false ;
}
const validationError = this . validateToolParams ( params ) ;
if ( validationError ) {
console . error (
` [EditTool Wrapper] Attempted confirmation with invalid parameters: ${ validationError } ` ,
) ;
return false ;
}
2025-05-25 14:16:08 -07:00
2025-04-21 10:53:11 -04:00
try {
2025-06-06 22:54:37 -07:00
// 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 ) {
2025-04-21 10:53:11 -04:00
return false ;
}
2025-05-12 23:23:24 -07:00
2025-06-06 22:54:37 -07:00
// 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 ;
}
2025-04-21 10:53:11 -04:00
}
2025-05-25 14:16:08 -07:00
2025-06-06 22:54:37 -07:00
// Generate diff for confirmation
const fileName = path . basename ( params . file_path ) ;
const fileDiff = Diff . createPatch (
fileName ,
currentContent || '' ,
editResult . newContent ,
'Current' ,
'Proposed' ,
DEFAULT_DIFF_OPTIONS ,
) ;
2025-05-25 14:16:08 -07:00
2025-06-06 22:54:37 -07:00
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 ;
}
2025-04-21 10:53:11 -04:00
}
2025-04-19 19:45:42 +01:00
getDescription ( params : EditToolParams ) : string {
2025-06-06 22:54:37 -07:00
if ( ! params . file_path ) {
2025-06-03 08:59:17 -07:00
return ` Model did not provide valid parameters for edit tool ` ;
}
2025-04-19 19:45:42 +01:00
const relativePath = makeRelative ( params . file_path , this . rootDirectory ) ;
2025-06-03 08:59:17 -07:00
2025-06-06 22:54:37 -07:00
if ( ! params . edits || params . edits . length === 0 ) {
return ` Edit ${ shortenPath ( relativePath ) } ` ;
}
2025-06-03 08:59:17 -07:00
2025-06-06 22:54:37 -07:00
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) ` ;
2025-06-05 06:48:03 -07:00
}
2025-04-19 19:45:42 +01:00
}
/ * *
* Executes the edit operation with the given parameters .
* @param params Parameters for the edit operation
* @returns Result of the edit operation
* /
2025-05-09 23:29:02 -07:00
async execute (
params : EditToolParams ,
2025-06-06 22:54:37 -07:00
abortSignal : AbortSignal ,
) : Promise < EditResult > {
2025-06-01 19:18:43 +01:00
const validationError = this . validateToolParams ( params ) ;
2025-04-19 19:45:42 +01:00
if ( validationError ) {
return {
llmContent : ` Error: Invalid parameters provided. Reason: ${ validationError } ` ,
returnDisplay : ` Error: ${ validationError } ` ,
2025-06-06 22:54:37 -07:00
editsApplied : 0 ,
editsAttempted : 0 ,
editsFailed : 1 ,
2025-04-19 19:45:42 +01:00
} ;
}
try {
2025-06-06 22:54:37 -07:00
const editResult = await this . applyMultipleEdits ( params , abortSignal ) ;
2025-04-19 19:45:42 +01:00
2025-06-06 22:54:37 -07:00
// Apply the changes to the file
2025-04-19 19:45:42 +01:00
this . ensureParentDirectoriesExist ( params . file_path ) ;
2025-06-06 22:54:37 -07:00
fs . writeFileSync ( params . file_path , editResult . newContent , 'utf8' ) ;
2025-04-19 19:45:42 +01:00
2025-06-06 22:54:37 -07:00
// Generate appropriate response messages
2025-04-19 19:45:42 +01:00
let displayResult : ToolResultDisplay ;
2025-06-06 22:54:37 -07:00
let llmContent : string ;
if ( editResult . isNewFile ) {
2025-04-19 19:45:42 +01:00
displayResult = ` Created ${ shortenPath ( makeRelative ( params . file_path , this . rootDirectory ) ) } ` ;
2025-06-06 22:54:37 -07:00
llmContent = ` Created new file: ${ params . file_path } ` ;
} else if ( editResult . editsApplied > 0 ) {
// Generate diff for display using original content before writing
2025-04-19 19:45:42 +01:00
const fileName = path . basename ( params . file_path ) ;
2025-06-06 22:54:37 -07:00
// Use the original content from before the edit was applied
const originalContent = editResult . originalContent || '' ;
2025-04-19 19:45:42 +01:00
const fileDiff = Diff . createPatch (
fileName ,
2025-06-06 22:54:37 -07:00
originalContent ,
editResult . newContent ,
2025-04-19 19:45:42 +01:00
'Current' ,
'Proposed' ,
2025-05-25 22:38:44 -07:00
DEFAULT_DIFF_OPTIONS ,
2025-04-19 19:45:42 +01:00
) ;
2025-05-15 23:51:53 -07:00
displayResult = { fileDiff , fileName } ;
2025-06-06 22:54:37 -07:00
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 } ` ;
2025-04-19 19:45:42 +01:00
}
2025-06-06 22:54:37 -07:00
// 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 } ` ;
}
2025-04-19 19:45:42 +01:00
return {
2025-06-06 22:54:37 -07:00
llmContent ,
2025-04-19 19:45:42 +01:00
returnDisplay : displayResult ,
2025-06-06 22:54:37 -07:00
editsApplied : editResult.editsApplied ,
editsAttempted : editResult.editsAttempted ,
editsFailed : editResult.editsFailed ,
failedEdits : editResult.failedEdits ,
2025-04-19 19:45:42 +01:00
} ;
} catch ( error ) {
const errorMsg = error instanceof Error ? error.message : String ( error ) ;
2025-06-06 22:54:37 -07:00
const editsAttempted = params . edits . length ;
2025-04-19 19:45:42 +01:00
return {
2025-06-06 22:54:37 -07:00
llmContent : ` Error executing edits: ${ errorMsg } ` ,
returnDisplay : ` Error: ${ errorMsg } ` ,
editsApplied : 0 ,
editsAttempted ,
editsFailed : editsAttempted ,
2025-04-19 19:45:42 +01:00
} ;
}
}
/ * *
* Creates parent directories if they don ' t exist
* /
private ensureParentDirectoriesExist ( filePath : string ) : void {
const dirName = path . dirname ( filePath ) ;
if ( ! fs . existsSync ( dirName ) ) {
fs . mkdirSync ( dirName , { recursive : true } ) ;
}
}
}