diff --git a/docs/tools/file-system.md b/docs/tools/file-system.md index c2c29c6963..09c792f84d 100644 --- a/docs/tools/file-system.md +++ b/docs/tools/file-system.md @@ -105,10 +105,11 @@ lines containing matches, along with their file paths and line numbers. ## 6. `replace` (Edit) -`replace` replaces text within a file. By default, replaces a single occurrence, -but can replace multiple occurrences when `expected_replacements` is specified. -This tool is designed for precise, targeted changes and requires significant -context around the `old_string` to ensure it modifies the correct location. +`replace` replaces text within a file. By default, the tool expects to find and +replace exactly ONE occurrence of `old_string`. If you want to replace multiple +occurrences of the exact same string, set `allow_multiple` to `true`. This tool +is designed for precise, targeted changes and requires significant context +around the `old_string` to ensure it modifies the correct location. - **Tool name:** `replace` - **Arguments:** @@ -116,6 +117,8 @@ context around the `old_string` to ensure it modifies the correct location. - `instruction` (string, required): Semantic description of the change. - `old_string` (string, required): Exact literal text to find. - `new_string` (string, required): Exact literal text to replace with. + - `allow_multiple` (boolean, optional): If `true`, replaces all occurrences. + If `false` (default), only succeeds if exactly one occurrence is found. - **Confirmation:** Requires manual user approval. ## Next steps diff --git a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap index 2a94784f74..effab9144d 100644 --- a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap +++ b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap @@ -503,7 +503,7 @@ Use this tool when the user's query implies needing the content of several files exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snapshot for tool: replace 1`] = ` { - "description": "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 read_file tool to examine the file's current content before attempting a text replacement. + "description": "Replaces text within a file. By default, the tool expects to find and replace exactly ONE occurrence of \`old_string\`. If you want to replace multiple occurrences of the exact same string, set \`allow_multiple\` to true. This tool requires providing significant context around the change to ensure precise targeting. Always use the read_file tool to examine the file's current content before attempting a text replacement. The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response. @@ -512,16 +512,15 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps 2. \`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 and that \`old_string\` and \`new_string\` are different. 3. \`instruction\` is the detailed instruction of what needs to be changed. It is important to Make it specific and detailed so developers or large language models can understand what needs to be changed and perform the changes on their own if necessary. 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. + **Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the instance(s) 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 and \`allow_multiple\` is not true, the tool will fail. 5. Prefer to break down complex and long changes into multiple smaller atomic calls to this tool. Always check the content of the file after changes or not finding a string to match. - **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.", + **Multiple replacements:** Set \`allow_multiple\` to true if you want to replace ALL occurrences that match \`old_string\` exactly.", "name": "replace", "parametersJsonSchema": { "properties": { - "expected_replacements": { - "description": "Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.", - "minimum": 1, - "type": "number", + "allow_multiple": { + "description": "If true, the tool will replace all occurrences of \`old_string\`. If false (default), it will only succeed if exactly one occurrence is found.", + "type": "boolean", }, "file_path": { "description": "The path to the file to modify.", @@ -1291,15 +1290,14 @@ Use this tool when the user's query implies needing the content of several files exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: replace 1`] = ` { - "description": "Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences ONLY when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. + "description": "Replaces text within a file. By default, the tool expects to find and replace exactly ONE occurrence of \`old_string\`. If you want to replace multiple occurrences of the exact same string, set \`allow_multiple\` to true. This tool requires providing significant context around the change to ensure precise targeting. The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.", "name": "replace", "parametersJsonSchema": { "properties": { - "expected_replacements": { - "description": "Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.", - "minimum": 1, - "type": "number", + "allow_multiple": { + "description": "If true, the tool will replace all occurrences of \`old_string\`. If false (default), it will only succeed if exactly one occurrence is found.", + "type": "boolean", }, "file_path": { "description": "The path to the file to modify.", diff --git a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts index 854aa4ddeb..569f379cd0 100644 --- a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts +++ b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts @@ -289,7 +289,7 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = { replace: { name: EDIT_TOOL_NAME, - description: `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 ${READ_FILE_TOOL_NAME} tool to examine the file's current content before attempting a text replacement. + description: `Replaces text within a file. By default, the tool expects to find and replace exactly ONE occurrence of \`old_string\`. If you want to replace multiple occurrences of the exact same string, set \`allow_multiple\` to true. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${READ_FILE_TOOL_NAME} tool to examine the file's current content before attempting a text replacement. The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response. @@ -298,9 +298,9 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = { 2. \`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 and that \`old_string\` and \`new_string\` are different. 3. \`instruction\` is the detailed instruction of what needs to be changed. It is important to Make it specific and detailed so developers or large language models can understand what needs to be changed and perform the changes on their own if necessary. 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. + **Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the instance(s) 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 and \`allow_multiple\` is not true, the tool will fail. 5. Prefer to break down complex and long changes into multiple smaller atomic calls to this tool. Always check the content of the file after changes or not finding a string to match. - **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.`, + **Multiple replacements:** Set \`allow_multiple\` to true if you want to replace ALL occurrences that match \`old_string\` exactly.`, parametersJsonSchema: { type: 'object', properties: { @@ -336,11 +336,10 @@ A good instruction should concisely answer: "The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic. Do not use omission placeholders like '(rest of methods ...)', '...', or 'unchanged code'; provide exact literal code.", type: 'string', }, - expected_replacements: { - type: 'number', + allow_multiple: { + type: 'boolean', description: - 'Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.', - minimum: 1, + 'If true, the tool will replace all occurrences of `old_string`. If false (default), it will only succeed if exactly one occurrence is found.', }, }, required: ['file_path', 'instruction', 'old_string', 'new_string'], diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index 09de59b51e..0cfe8ffbc2 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -290,7 +290,7 @@ export const GEMINI_3_SET: CoreToolSet = { replace: { name: EDIT_TOOL_NAME, - description: `Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences ONLY when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. + description: `Replaces text within a file. By default, the tool expects to find and replace exactly ONE occurrence of \`old_string\`. If you want to replace multiple occurrences of the exact same string, set \`allow_multiple\` to true. This tool requires providing significant context around the change to ensure precise targeting. The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.`, parametersJsonSchema: { type: 'object', @@ -313,11 +313,10 @@ The user has the ability to modify the \`new_string\` content. If modified, this "The exact literal text to replace `old_string` with, unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic. Do not use omission placeholders like '(rest of methods ...)', '...', or 'unchanged code'; provide exact literal code.", type: 'string', }, - expected_replacements: { - type: 'number', + allow_multiple: { + type: 'boolean', description: - 'Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.', - minimum: 1, + 'If true, the tool will replace all occurrences of `old_string`. If false (default), it will only succeed if exactly one occurrence is found.', }, }, required: ['file_path', 'instruction', 'old_string', 'new_string'], diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index 9c67515f38..0cae5a070c 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -934,7 +934,7 @@ function doIt() { ); }); - describe('expected_replacements', () => { + describe('allow_multiple', () => { const testFile = 'replacements_test.txt'; let filePath: string; @@ -944,34 +944,70 @@ function doIt() { it.each([ { - name: 'succeed when occurrences match expected_replacements', + name: 'succeed when allow_multiple is true and there are multiple occurrences', content: 'foo foo foo', - expected: 3, + allow_multiple: true, shouldSucceed: true, finalContent: 'bar bar bar', }, { - name: 'fail when occurrences do not match expected_replacements', - content: 'foo foo foo', - expected: 2, - shouldSucceed: false, + name: 'succeed when allow_multiple is true and there is exactly 1 occurrence', + content: 'foo', + allow_multiple: true, + shouldSucceed: true, + finalContent: 'bar', }, { - name: 'default to 1 expected replacement if not specified', - content: 'foo foo', - expected: undefined, + name: 'fail when allow_multiple is false and there are multiple occurrences', + content: 'foo foo foo', + allow_multiple: false, shouldSucceed: false, + expectedError: ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH, + }, + { + name: 'default to 1 expected replacement if allow_multiple not specified', + content: 'foo foo', + allow_multiple: undefined, + shouldSucceed: false, + expectedError: ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH, + }, + { + name: 'succeed when allow_multiple is false and there is exactly 1 occurrence', + content: 'foo', + allow_multiple: false, + shouldSucceed: true, + finalContent: 'bar', + }, + { + name: 'fail when allow_multiple is true but there are 0 occurrences', + content: 'baz', + allow_multiple: true, + shouldSucceed: false, + expectedError: ToolErrorType.EDIT_NO_OCCURRENCE_FOUND, + }, + { + name: 'fail when allow_multiple is false but there are 0 occurrences', + content: 'baz', + allow_multiple: false, + shouldSucceed: false, + expectedError: ToolErrorType.EDIT_NO_OCCURRENCE_FOUND, }, ])( 'should $name', - async ({ content, expected, shouldSucceed, finalContent }) => { + async ({ + content, + allow_multiple, + shouldSucceed, + finalContent, + expectedError, + }) => { fs.writeFileSync(filePath, content, 'utf8'); const params: EditToolParams = { file_path: filePath, instruction: 'Replace all foo with bar', old_string: 'foo', new_string: 'bar', - ...(expected !== undefined && { expected_replacements: expected }), + ...(allow_multiple !== undefined && { allow_multiple }), }; const invocation = tool.build(params); const result = await invocation.execute(new AbortController().signal); @@ -981,9 +1017,7 @@ function doIt() { if (finalContent) expect(fs.readFileSync(filePath, 'utf8')).toBe(finalContent); } else { - expect(result.error?.type).toBe( - ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH, - ); + expect(result.error?.type).toBe(expectedError); } }, ); diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index edd6959949..a7169e99f2 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -138,9 +138,8 @@ async function calculateExactReplacement( const normalizedReplace = new_string.replace(/\r\n/g, '\n'); const exactOccurrences = normalizedCode.split(normalizedSearch).length - 1; - const expectedReplacements = params.expected_replacements ?? 1; - if (exactOccurrences > expectedReplacements) { + if (!params.allow_multiple && exactOccurrences > 1) { return { newContent: currentContent, occurrences: exactOccurrences, @@ -256,28 +255,33 @@ async function calculateRegexReplacement( // The final pattern captures leading whitespace (indentation) and then matches the token pattern. // 'm' flag enables multi-line mode, so '^' matches the start of any line. const finalPattern = `^([ \t]*)${pattern}`; - const flexibleRegex = new RegExp(finalPattern, 'm'); - const match = flexibleRegex.exec(currentContent); + // Always use a global regex to count all potential occurrences for accurate validation. + const globalRegex = new RegExp(finalPattern, 'gm'); + const matches = currentContent.match(globalRegex); - if (!match) { + if (!matches) { return null; } - const indentation = match[1] || ''; + const occurrences = matches.length; const newLines = normalizedReplace.split('\n'); - const newBlockWithIndent = applyIndentation(newLines, indentation).join('\n'); - // Use replace with the regex to substitute the matched content. - // Since the regex doesn't have the 'g' flag, it will only replace the first occurrence. + // Use the appropriate regex for replacement based on allow_multiple. + const replaceRegex = new RegExp( + finalPattern, + params.allow_multiple ? 'gm' : 'm', + ); + const modifiedCode = currentContent.replace( - flexibleRegex, - newBlockWithIndent, + replaceRegex, + (_match, indentation) => + applyIndentation(newLines, indentation || '').join('\n'), ); return { newContent: restoreTrailingNewline(currentContent, modifiedCode), - occurrences: 1, // This method is designed to find and replace only the first occurrence. + occurrences, finalOldString: normalizedSearch, finalNewString: normalizedReplace, }; @@ -341,7 +345,6 @@ export async function calculateReplacement( export function getErrorReplaceResult( params: EditToolParams, occurrences: number, - expectedReplacements: number, finalOldString: string, finalNewString: string, ) { @@ -353,13 +356,10 @@ export function getErrorReplaceResult( raw: `Failed to edit, 0 occurrences found for old_string in ${params.file_path}. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use ${READ_FILE_TOOL_NAME} tool to verify.`, type: ToolErrorType.EDIT_NO_OCCURRENCE_FOUND, }; - } else if (occurrences !== expectedReplacements) { - const occurrenceTerm = - expectedReplacements === 1 ? 'occurrence' : 'occurrences'; - + } else if (!params.allow_multiple && occurrences !== 1) { error = { - display: `Failed to edit, expected ${expectedReplacements} ${occurrenceTerm} but found ${occurrences}.`, - raw: `Failed to edit, Expected ${expectedReplacements} ${occurrenceTerm} but found ${occurrences} for old_string in file: ${params.file_path}`, + display: `Failed to edit, expected 1 occurrence but found ${occurrences}.`, + raw: `Failed to edit, Expected 1 occurrence but found ${occurrences} for old_string in file: ${params.file_path}. If you intended to replace multiple occurrences, set 'allow_multiple' to true.`, type: ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH, }; } else if (finalOldString === finalNewString) { @@ -392,10 +392,10 @@ export interface EditToolParams { new_string: string; /** - * Number of replacements expected. Defaults to 1 if not specified. - * Use when you want to replace multiple occurrences. + * If true, the tool will replace all occurrences of `old_string` with `new_string`. + * If false (default), the tool will only succeed if exactly one occurrence is found. */ - expected_replacements?: number; + allow_multiple?: boolean; /** * The instruction for what needs to be done. @@ -517,7 +517,6 @@ class EditToolInvocation const secondError = getErrorReplaceResult( params, secondAttemptResult.occurrences, - params.expected_replacements ?? 1, secondAttemptResult.finalOldString, secondAttemptResult.finalNewString, ); @@ -562,7 +561,6 @@ class EditToolInvocation params: EditToolParams, abortSignal: AbortSignal, ): Promise { - const expectedReplacements = params.expected_replacements ?? 1; let currentContent: string | null = null; let fileExists = false; let originalLineEnding: '\r\n' | '\n' = '\n'; // Default for new files @@ -649,7 +647,6 @@ class EditToolInvocation const initialError = getErrorReplaceResult( params, replacementResult.occurrences, - expectedReplacements, replacementResult.finalOldString, replacementResult.finalNewString, ); diff --git a/packages/core/src/utils/editCorrector.ts b/packages/core/src/utils/editCorrector.ts index e15be8cfc4..660bff0b17 100644 --- a/packages/core/src/utils/editCorrector.ts +++ b/packages/core/src/utils/editCorrector.ts @@ -185,12 +185,16 @@ export async function ensureCorrectEdit( unescapeStringForGeminiBug(originalParams.new_string) !== originalParams.new_string; - const expectedReplacements = originalParams.expected_replacements ?? 1; + const allowMultiple = originalParams.allow_multiple ?? false; let finalOldString = originalParams.old_string; let occurrences = countOccurrences(currentContent, finalOldString); - if (occurrences === expectedReplacements) { + const isOccurrencesMatch = allowMultiple + ? occurrences > 0 + : occurrences === 1; + + if (isOccurrencesMatch) { if (newStringPotentiallyEscaped && !disableLLMCorrection) { finalNewString = await correctNewStringEscaping( baseLlmClient, @@ -199,30 +203,8 @@ export async function ensureCorrectEdit( abortSignal, ); } - } else if (occurrences > expectedReplacements) { - const expectedReplacements = originalParams.expected_replacements ?? 1; - - // If user expects multiple replacements, return as-is - if (occurrences === expectedReplacements) { - const result: CorrectedEditResult = { - params: { ...originalParams }, - occurrences, - }; - editCorrectionCache.set(cacheKey, result); - return result; - } - - // If user expects 1 but found multiple, try to correct (existing behavior) - if (expectedReplacements === 1) { - const result: CorrectedEditResult = { - params: { ...originalParams }, - occurrences, - }; - editCorrectionCache.set(cacheKey, result); - return result; - } - - // If occurrences don't match expected, return as-is (will fail validation later) + } else if (occurrences > 1 && !allowMultiple) { + // If user doesn't allow multiple but found multiple, return as-is (will fail validation later) const result: CorrectedEditResult = { params: { ...originalParams }, occurrences, @@ -236,7 +218,11 @@ export async function ensureCorrectEdit( ); occurrences = countOccurrences(currentContent, unescapedOldStringAttempt); - if (occurrences === expectedReplacements) { + const isUnescapedOccurrencesMatch = allowMultiple + ? occurrences > 0 + : occurrences === 1; + + if (isUnescapedOccurrencesMatch) { finalOldString = unescapedOldStringAttempt; if (newStringPotentiallyEscaped && !disableLLMCorrection) { finalNewString = await correctNewString( @@ -296,7 +282,11 @@ export async function ensureCorrectEdit( llmCorrectedOldString, ); - if (llmOldOccurrences === expectedReplacements) { + const isLlmOccurrencesMatch = allowMultiple + ? llmOldOccurrences > 0 + : llmOldOccurrences === 1; + + if (isLlmOccurrencesMatch) { finalOldString = llmCorrectedOldString; occurrences = llmOldOccurrences; @@ -322,7 +312,7 @@ export async function ensureCorrectEdit( return result; } } else { - // Unescaping old_string resulted in > 1 occurrence + // Unescaping old_string resulted in > 1 occurrence but not allowMultiple const result: CorrectedEditResult = { params: { ...originalParams }, occurrences, // This will be > 1 @@ -336,7 +326,7 @@ export async function ensureCorrectEdit( finalOldString, finalNewString, currentContent, - expectedReplacements, + allowMultiple, ); finalOldString = targetString; finalNewString = pair; @@ -705,7 +695,7 @@ function trimPairIfPossible( target: string, trimIfTargetTrims: string, currentContent: string, - expectedReplacements: number, + allowMultiple: boolean, ) { const trimmedTargetString = trimPreservingTrailingNewline(target); if (target.length !== trimmedTargetString.length) { @@ -714,7 +704,11 @@ function trimPairIfPossible( trimmedTargetString, ); - if (trimmedTargetOccurrences === expectedReplacements) { + const isMatch = allowMultiple + ? trimmedTargetOccurrences > 0 + : trimmedTargetOccurrences === 1; + + if (isMatch) { const trimmedReactiveString = trimPreservingTrailingNewline(trimIfTargetTrims); return {