mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 01:07:46 +00:00
Migrate EditTool, GrepTool, and GlobTool to DeclarativeTool (#5744)
This commit is contained in:
@@ -8,11 +8,12 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as Diff from 'diff';
|
||||
import {
|
||||
BaseTool,
|
||||
BaseDeclarativeTool,
|
||||
Icon,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
ToolEditConfirmationDetails,
|
||||
ToolInvocation,
|
||||
ToolLocation,
|
||||
ToolResult,
|
||||
ToolResultDisplay,
|
||||
@@ -29,6 +30,26 @@ import { ReadFileTool } from './read-file.js';
|
||||
import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js';
|
||||
import { IDEConnectionStatus } from '../ide/ide-client.js';
|
||||
|
||||
export function 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the Edit tool
|
||||
*/
|
||||
@@ -68,112 +89,14 @@ interface CalculatedEdit {
|
||||
isNewFile: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of the Edit tool logic
|
||||
*/
|
||||
export class EditTool
|
||||
extends BaseTool<EditToolParams, ToolResult>
|
||||
implements ModifiableDeclarativeTool<EditToolParams>
|
||||
{
|
||||
static readonly Name = 'replace';
|
||||
class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
public params: EditToolParams,
|
||||
) {}
|
||||
|
||||
constructor(private readonly 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.
|
||||
|
||||
The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.
|
||||
|
||||
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.`,
|
||||
Icon.Pencil,
|
||||
{
|
||||
properties: {
|
||||
file_path: {
|
||||
description:
|
||||
"The absolute path to the file to modify. Must start with '/'.",
|
||||
type: 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: 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: Type.STRING,
|
||||
},
|
||||
expected_replacements: {
|
||||
type: 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: Type.OBJECT,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the parameters for the Edit tool
|
||||
* @param params Parameters to validate
|
||||
* @returns Error message string or null if valid
|
||||
*/
|
||||
validateToolParams(params: EditToolParams): string | null {
|
||||
const errors = SchemaValidator.validate(this.schema.parameters, params);
|
||||
if (errors) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(params.file_path)) {
|
||||
return `File path must be absolute: ${params.file_path}`;
|
||||
}
|
||||
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(params.file_path)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
return `File path must be within one of the workspace directories: ${directories.join(', ')}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines any file locations affected by the tool execution
|
||||
* @param params Parameters for the tool execution
|
||||
* @returns A list of such paths
|
||||
*/
|
||||
toolLocations(params: EditToolParams): ToolLocation[] {
|
||||
return [{ path: params.file_path }];
|
||||
}
|
||||
|
||||
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);
|
||||
toolLocations(): ToolLocation[] {
|
||||
return [{ path: this.params.file_path }];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,7 +194,7 @@ Expectation for required parameters:
|
||||
};
|
||||
}
|
||||
|
||||
const newContent = this._applyReplacement(
|
||||
const newContent = applyReplacement(
|
||||
currentContent,
|
||||
finalOldString,
|
||||
finalNewString,
|
||||
@@ -292,23 +215,15 @@ Expectation for required parameters:
|
||||
* It needs to calculate the diff to show the user.
|
||||
*/
|
||||
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);
|
||||
editData = await this.calculateEdit(this.params, abortSignal);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.log(`Error preparing edit: ${errorMsg}`);
|
||||
@@ -320,7 +235,7 @@ Expectation for required parameters:
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileName = path.basename(params.file_path);
|
||||
const fileName = path.basename(this.params.file_path);
|
||||
const fileDiff = Diff.createPatch(
|
||||
fileName,
|
||||
editData.currentContent ?? '',
|
||||
@@ -334,14 +249,14 @@ Expectation for required parameters:
|
||||
this.config.getIdeModeFeature() &&
|
||||
this.config.getIdeMode() &&
|
||||
ideClient?.getConnectionStatus().status === IDEConnectionStatus.Connected
|
||||
? ideClient.openDiff(params.file_path, editData.newContent)
|
||||
? ideClient.openDiff(this.params.file_path, editData.newContent)
|
||||
: undefined;
|
||||
|
||||
const confirmationDetails: ToolEditConfirmationDetails = {
|
||||
type: 'edit',
|
||||
title: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.config.getTargetDir()))}`,
|
||||
title: `Confirm Edit: ${shortenPath(makeRelative(this.params.file_path, this.config.getTargetDir()))}`,
|
||||
fileName,
|
||||
filePath: params.file_path,
|
||||
filePath: this.params.file_path,
|
||||
fileDiff,
|
||||
originalContent: editData.currentContent,
|
||||
newContent: editData.newContent,
|
||||
@@ -355,8 +270,8 @@ Expectation for required parameters:
|
||||
if (result.status === 'accepted' && result.content) {
|
||||
// TODO(chrstn): See https://github.com/google-gemini/gemini-cli/pull/5618#discussion_r2255413084
|
||||
// for info on a possible race condition where the file is modified on disk while being edited.
|
||||
params.old_string = editData.currentContent ?? '';
|
||||
params.new_string = result.content;
|
||||
this.params.old_string = editData.currentContent ?? '';
|
||||
this.params.new_string = result.content;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -365,26 +280,23 @@ Expectation for required parameters:
|
||||
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`;
|
||||
}
|
||||
getDescription(): string {
|
||||
const relativePath = makeRelative(
|
||||
params.file_path,
|
||||
this.params.file_path,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
if (params.old_string === '') {
|
||||
if (this.params.old_string === '') {
|
||||
return `Create ${shortenPath(relativePath)}`;
|
||||
}
|
||||
|
||||
const oldStringSnippet =
|
||||
params.old_string.split('\n')[0].substring(0, 30) +
|
||||
(params.old_string.length > 30 ? '...' : '');
|
||||
this.params.old_string.split('\n')[0].substring(0, 30) +
|
||||
(this.params.old_string.length > 30 ? '...' : '');
|
||||
const newStringSnippet =
|
||||
params.new_string.split('\n')[0].substring(0, 30) +
|
||||
(params.new_string.length > 30 ? '...' : '');
|
||||
this.params.new_string.split('\n')[0].substring(0, 30) +
|
||||
(this.params.new_string.length > 30 ? '...' : '');
|
||||
|
||||
if (params.old_string === params.new_string) {
|
||||
if (this.params.old_string === this.params.new_string) {
|
||||
return `No file changes to ${shortenPath(relativePath)}`;
|
||||
}
|
||||
return `${shortenPath(relativePath)}: ${oldStringSnippet} => ${newStringSnippet}`;
|
||||
@@ -395,25 +307,10 @@ Expectation for required parameters:
|
||||
* @param params Parameters for the edit operation
|
||||
* @returns Result of the edit operation
|
||||
*/
|
||||
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}`,
|
||||
error: {
|
||||
message: validationError,
|
||||
type: ToolErrorType.INVALID_TOOL_PARAMS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async execute(signal: AbortSignal): Promise<ToolResult> {
|
||||
let editData: CalculatedEdit;
|
||||
try {
|
||||
editData = await this.calculateEdit(params, signal);
|
||||
editData = await this.calculateEdit(this.params, signal);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
@@ -438,16 +335,16 @@ Expectation for required parameters:
|
||||
}
|
||||
|
||||
try {
|
||||
this.ensureParentDirectoriesExist(params.file_path);
|
||||
fs.writeFileSync(params.file_path, editData.newContent, 'utf8');
|
||||
this.ensureParentDirectoriesExist(this.params.file_path);
|
||||
fs.writeFileSync(this.params.file_path, editData.newContent, 'utf8');
|
||||
|
||||
let displayResult: ToolResultDisplay;
|
||||
if (editData.isNewFile) {
|
||||
displayResult = `Created ${shortenPath(makeRelative(params.file_path, this.config.getTargetDir()))}`;
|
||||
displayResult = `Created ${shortenPath(makeRelative(this.params.file_path, this.config.getTargetDir()))}`;
|
||||
} else {
|
||||
// Generate diff for display, even though core logic doesn't technically need it
|
||||
// The CLI wrapper will use this part of the ToolResult
|
||||
const fileName = path.basename(params.file_path);
|
||||
const fileName = path.basename(this.params.file_path);
|
||||
const fileDiff = Diff.createPatch(
|
||||
fileName,
|
||||
editData.currentContent ?? '', // Should not be null here if not isNewFile
|
||||
@@ -466,12 +363,12 @@ Expectation for required parameters:
|
||||
|
||||
const llmSuccessMessageParts = [
|
||||
editData.isNewFile
|
||||
? `Created new file: ${params.file_path} with provided content.`
|
||||
: `Successfully modified file: ${params.file_path} (${editData.occurrences} replacements).`,
|
||||
? `Created new file: ${this.params.file_path} with provided content.`
|
||||
: `Successfully modified file: ${this.params.file_path} (${editData.occurrences} replacements).`,
|
||||
];
|
||||
if (params.modified_by_user) {
|
||||
if (this.params.modified_by_user) {
|
||||
llmSuccessMessageParts.push(
|
||||
`User modified the \`new_string\` content to be: ${params.new_string}.`,
|
||||
`User modified the \`new_string\` content to be: ${this.params.new_string}.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -501,6 +398,91 @@ Expectation for required parameters:
|
||||
fs.mkdirSync(dirName, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of the Edit tool logic
|
||||
*/
|
||||
export class EditTool
|
||||
extends BaseDeclarativeTool<EditToolParams, ToolResult>
|
||||
implements ModifiableDeclarativeTool<EditToolParams>
|
||||
{
|
||||
static readonly Name = 'replace';
|
||||
constructor(private readonly 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.
|
||||
|
||||
The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.
|
||||
|
||||
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.`,
|
||||
Icon.Pencil,
|
||||
{
|
||||
properties: {
|
||||
file_path: {
|
||||
description:
|
||||
"The absolute path to the file to modify. Must start with '/'.",
|
||||
type: 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: 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: Type.STRING,
|
||||
},
|
||||
expected_replacements: {
|
||||
type: 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: Type.OBJECT,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the parameters for the Edit tool
|
||||
* @param params Parameters to validate
|
||||
* @returns Error message string or null if valid
|
||||
*/
|
||||
validateToolParams(params: EditToolParams): string | null {
|
||||
const errors = SchemaValidator.validate(this.schema.parameters, params);
|
||||
if (errors) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(params.file_path)) {
|
||||
return `File path must be absolute: ${params.file_path}`;
|
||||
}
|
||||
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(params.file_path)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
return `File path must be within one of the workspace directories: ${directories.join(', ')}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: EditToolParams,
|
||||
): ToolInvocation<EditToolParams, ToolResult> {
|
||||
return new EditToolInvocation(this.config, params);
|
||||
}
|
||||
|
||||
getModifyContext(_: AbortSignal): ModifyContext<EditToolParams> {
|
||||
return {
|
||||
@@ -516,7 +498,7 @@ Expectation for required parameters:
|
||||
getProposedContent: async (params: EditToolParams): Promise<string> => {
|
||||
try {
|
||||
const currentContent = fs.readFileSync(params.file_path, 'utf8');
|
||||
return this._applyReplacement(
|
||||
return applyReplacement(
|
||||
currentContent,
|
||||
params.old_string,
|
||||
params.new_string,
|
||||
|
||||
Reference in New Issue
Block a user