diff --git a/package.json b/package.json index cfc6e62872..5027f0c329 100644 --- a/package.json +++ b/package.json @@ -740,6 +740,58 @@ ] } }, + { + "name": "copilot_multiReplaceString", + "toolReferenceName": "multiReplaceString", + "displayName": "%copilot.tools.multiReplaceString.name%", + "modelDescription": "This tool allows you to apply multiple replace_string_in_file operations in a single call, which is more efficient than calling replace_string_in_file multiple times. It takes an array of replacement operations and applies them sequentially. Each replacement operation has the same parameters as replace_string_in_file: filePath, oldString, newString, and explanation. This tool is ideal when you need to make multiple edits across different files or multiple edits in the same file. The tool will provide a summary of successful and failed operations.", + "when": "!config.github.copilot.chat.disableReplaceTool", + "inputSchema": { + "type": "object", + "properties": { + "explanation": { + "type": "string", + "description": "A brief explanation of what the multi-replace operation will accomplish." + }, + "replacements": { + "type": "array", + "description": "An array of replacement operations to apply sequentially.", + "items": { + "type": "object", + "properties": { + "explanation": { + "type": "string", + "description": "A brief explanation of this specific replacement operation." + }, + "filePath": { + "type": "string", + "description": "An absolute path to the file to edit." + }, + "oldString": { + "type": "string", + "description": "The exact literal text to replace, preferably unescaped. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string is not the exact literal text or does not match exactly, this replacement will fail." + }, + "newString": { + "type": "string", + "description": "The exact literal text to replace `oldString` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic." + } + }, + "required": [ + "explanation", + "filePath", + "oldString", + "newString" + ] + }, + "minItems": 1 + } + }, + "required": [ + "explanation", + "replacements" + ] + } + }, { "name": "copilot_editNotebook", "toolReferenceName": "editNotebook", diff --git a/package.nls.json b/package.nls.json index f69ef3e818..016ba9bee9 100644 --- a/package.nls.json +++ b/package.nls.json @@ -271,6 +271,7 @@ "copilot.tools.createFile.name": "Create File", "copilot.tools.insertEdit.name": "Edit File", "copilot.tools.replaceString.name": "Replace String in File", + "copilot.tools.multiReplaceString.name": "Multi-Replace String in Files", "copilot.tools.editNotebook.name": "Edit Notebook", "copilot.tools.runNotebookCell.name": "Run Notebook Cell", "copilot.tools.getNotebookCellOutput.name": "Get Notebook Cell Output", diff --git a/src/extension/intents/node/agentIntent.ts b/src/extension/intents/node/agentIntent.ts index 9e903e60ef..6fae53af59 100644 --- a/src/extension/intents/node/agentIntent.ts +++ b/src/extension/intents/node/agentIntent.ts @@ -61,7 +61,7 @@ const getTools = (instaService: IInstantiationService, request: vscode.ChatReque const allowTools: Record = {}; allowTools[ToolName.EditFile] = true; - allowTools[ToolName.ReplaceString] = modelSupportsReplaceString(model) || !!(model.family.includes('gemini') && configurationService.getExperimentBasedConfig(ConfigKey.Internal.GeminiReplaceString, experimentationService)); + allowTools[ToolName.ReplaceString] = modelSupportsReplaceString(model); allowTools[ToolName.ApplyPatch] = await modelSupportsApplyPatch(model) && !!toolsService.getTool(ToolName.ApplyPatch); if (modelCanUseReplaceStringExclusively(model)) { @@ -69,6 +69,12 @@ const getTools = (instaService: IInstantiationService, request: vscode.ChatReque allowTools[ToolName.EditFile] = false; } + if (allowTools[ToolName.ReplaceString]) { + if (configurationService.getExperimentBasedConfig(ConfigKey.Internal.MultiReplaceString, experimentationService)) { + allowTools[ToolName.MultiReplaceString] = true; + } + } + allowTools[ToolName.RunTests] = await testService.hasAnyTests(); allowTools[ToolName.CoreRunTask] = !!(configurationService.getConfig(ConfigKey.AgentCanRunTasks) && tasksService.getTasks().length); diff --git a/src/extension/intents/node/editCodeIntent2.ts b/src/extension/intents/node/editCodeIntent2.ts index 87df074e31..f5884fc114 100644 --- a/src/extension/intents/node/editCodeIntent2.ts +++ b/src/extension/intents/node/editCodeIntent2.ts @@ -6,6 +6,7 @@ import type * as vscode from 'vscode'; import { ChatLocation } from '../../../platform/chat/common/commonTypes'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { modelSupportsReplaceString } from '../../../platform/endpoint/common/chatModelCapabilities'; import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider'; import { IEnvService } from '../../../platform/env/common/envService'; import { ILogService } from '../../../platform/log/common/logService'; @@ -40,14 +41,18 @@ const getTools = (instaService: IInstantiationService, request: vscode.ChatReque const experimentalService = accessor.get(IExperimentationService); const model = await endpointProvider.getChatEndpoint(request); const lookForTools = new Set([ToolName.EditFile]); + const experimentationService = accessor.get(IExperimentationService); if (configurationService.getExperimentBasedConfig(ConfigKey.EditsCodeNewNotebookAgentEnabled, experimentalService) !== false && requestHasNotebookRefs(request, notebookService, { checkPromptAsWell: true })) { lookForTools.add(ToolName.CreateNewJupyterNotebook); } - if (model.family.startsWith('claude')) { + if (modelSupportsReplaceString(model)) { lookForTools.add(ToolName.ReplaceString); + if (configurationService.getExperimentBasedConfig(ConfigKey.Internal.MultiReplaceString, experimentationService)) { + lookForTools.add(ToolName.MultiReplaceString); + } } lookForTools.add(ToolName.EditNotebook); if (requestHasNotebookRefs(request, notebookService, { checkPromptAsWell: true })) { diff --git a/src/extension/intents/node/notebookEditorIntent.ts b/src/extension/intents/node/notebookEditorIntent.ts index 6bac92cf00..078d55a7ae 100644 --- a/src/extension/intents/node/notebookEditorIntent.ts +++ b/src/extension/intents/node/notebookEditorIntent.ts @@ -33,6 +33,7 @@ import { IToolsService } from '../../tools/common/toolsService'; import { EditCodeIntent, EditCodeIntentOptions } from './editCodeIntent'; import { EditCode2IntentInvocation } from './editCodeIntent2'; import { getRequestedToolCallIterationLimit } from './toolCallingLoop'; +import { modelSupportsReplaceString } from '../../../platform/endpoint/common/chatModelCapabilities'; const getTools = (instaService: IInstantiationService, request: vscode.ChatRequest): Promise => instaService.invokeFunction(async accessor => { @@ -43,13 +44,17 @@ const getTools = (instaService: IInstantiationService, request: vscode.ChatReque const experimentalService = accessor.get(IExperimentationService); const model = await endpointProvider.getChatEndpoint(request); const lookForTools = new Set([ToolName.EditFile]); + const experimentationService = accessor.get(IExperimentationService); if (configurationService.getExperimentBasedConfig(ConfigKey.EditsCodeNewNotebookAgentEnabled, experimentalService) !== false && requestHasNotebookRefs(request, notebookService, { checkPromptAsWell: true })) { lookForTools.add(ToolName.CreateNewJupyterNotebook); } - if (model.family.startsWith('claude')) { + if (modelSupportsReplaceString(model)) { lookForTools.add(ToolName.ReplaceString); + if (configurationService.getExperimentBasedConfig(ConfigKey.Internal.MultiReplaceString, experimentationService)) { + lookForTools.add(ToolName.MultiReplaceString); + } } lookForTools.add(ToolName.EditNotebook); diff --git a/src/extension/prompts/node/agent/agentInstructions.tsx b/src/extension/prompts/node/agent/agentInstructions.tsx index e07773be6e..2ff31090f1 100644 --- a/src/extension/prompts/node/agent/agentInstructions.tsx +++ b/src/extension/prompts/node/agent/agentInstructions.tsx @@ -129,13 +129,15 @@ export class DefaultAgentPrompt extends PromptElement { {tools[ToolName.ReplaceString] ? <> Before you edit an existing file, make sure you either already have it in the provided context, or read it with the {ToolName.ReadFile} tool, so that you can make proper changes.
- Use the {ToolName.ReplaceString} tool to edit files, paying attention to context to ensure your replacement is unique. You can use this tool multiple times per file.
- Use the {ToolName.EditFile} tool to insert code into a file ONLY if {ToolName.ReplaceString} has failed.
+ {tools[ToolName.MultiReplaceString] + ? <>Use the {ToolName.ReplaceString} tool for single string replacements, paying attention to context to ensure your replacement is unique. Prefer the {ToolName.MultiReplaceString} tool when you need to make multiple string replacements across one or more files in a single operation. This is significantly more efficient than calling {ToolName.ReplaceString} multiple times and should be your first choice for: fixing similar patterns across files, applying consistent formatting changes, bulk refactoring operations, or any scenario where you need to make the same type of change in multiple places.
+ : <>Use the {ToolName.ReplaceString} tool to edit files, paying attention to context to ensure your replacement is unique. You can use this tool multiple times per file.
} + Use the {ToolName.EditFile} tool to insert code into a file ONLY if {tools[ToolName.MultiReplaceString] ? `${ToolName.MultiReplaceString}/` : ''}{ToolName.ReplaceString} has failed.
When editing files, group your changes by file.
{isGpt5 && <>Make the smallest set of edits needed and avoid reformatting or moving unrelated code. Preserve existing style and conventions, and keep imports, exports, and public APIs stable unless the task requires changes. Prefer completing all edits for a file within a single message when practical.
} NEVER show the changes to the user, just call the tool, and the edits will be applied and shown to the user.
- NEVER print a codeblock that represents a change to a file, use {ToolName.ReplaceString} or {ToolName.EditFile} instead.
- For each file, give a short description of what needs to be changed, then use the {ToolName.ReplaceString} or {ToolName.EditFile} tools. You can use any tool multiple times in a response, and you can keep writing text after using a tool.
+ NEVER print a codeblock that represents a change to a file, use {ToolName.ReplaceString}{tools[ToolName.MultiReplaceString] ? `, ${ToolName.MultiReplaceString},` : ''} or {ToolName.EditFile} instead.
+ For each file, give a short description of what needs to be changed, then use the {ToolName.ReplaceString}{tools[ToolName.MultiReplaceString] ? `, ${ToolName.MultiReplaceString},` : ''} or {ToolName.EditFile} tools. You can use any tool multiple times in a response, and you can keep writing text after using a tool.
: <> Don't try to edit an existing file without reading it first, so you can make changes properly.
Use the {ToolName.EditFile} tool to edit files. When editing files, group your changes by file.
@@ -656,20 +658,22 @@ export class AlternateGPTPrompt extends PromptElement { {tools[ToolName.ReplaceString] ? <> Before you edit an existing file, make sure you either already have it in the provided context, or read it with the {ToolName.ReadFile} tool, so that you can make proper changes.
- Use the {ToolName.ReplaceString} tool to edit files, paying attention to context to ensure your replacement is unique. You can use this tool multiple times per file.
- Use the {ToolName.EditFile} tool to insert code into a file ONLY if {ToolName.ReplaceString} has failed.
+ {tools[ToolName.MultiReplaceString] + ? <>Use the {ToolName.ReplaceString} tool for single string replacements, paying attention to context to ensure your replacement is unique. Prefer the {ToolName.MultiReplaceString} tool when you need to make multiple string replacements across one or more files in a single operation. This is significantly more efficient than calling {ToolName.ReplaceString} multiple times and should be your first choice for: fixing similar patterns across files, applying consistent formatting changes, bulk refactoring operations, or any scenario where you need to make the same type of change in multiple places.
+ : <>Use the {ToolName.ReplaceString} tool to edit files, paying attention to context to ensure your replacement is unique. You can use this tool multiple times per file.
} + Use the {ToolName.EditFile} tool to insert code into a file ONLY if {tools[ToolName.MultiReplaceString] ? `${ToolName.MultiReplaceString}/` : ''}{ToolName.ReplaceString} has failed.
When editing files, group your changes by file.
{isGpt5 && <>Make the smallest set of edits needed and avoid reformatting or moving unrelated code. Preserve existing style and conventions, and keep imports, exports, and public APIs stable unless the task requires changes. Prefer completing all edits for a file within a single message when practical.
} NEVER show the changes to the user, just call the tool, and the edits will be applied and shown to the user.
- NEVER print a codeblock that represents a change to a file, use {ToolName.ReplaceString} or {ToolName.EditFile} instead.
- For each file, give a short description of what needs to be changed, then use the {ToolName.ReplaceString} or {ToolName.EditFile} tools. You can use any tool multiple times in a response, and you can keep writing text after using a tool.
: + NEVER print a codeblock that represents a change to a file, use {ToolName.ReplaceString}{tools[ToolName.MultiReplaceString] ? `, ${ToolName.MultiReplaceString},` : ''} or {ToolName.EditFile} instead.
+ For each file, give a short description of what needs to be changed, then use the {ToolName.ReplaceString}{tools[ToolName.MultiReplaceString] ? `, ${ToolName.MultiReplaceString},` : ''} or {ToolName.EditFile} tools. You can use any tool multiple times in a response, and you can keep writing text after using a tool.
: <> Don't try to edit an existing file without reading it first, so you can make changes properly.
- Use the {ToolName.ReplaceString} tool to edit files. When editing files, group your changes by file.
+ Use the {ToolName.EditFile} tool to edit files. When editing files, group your changes by file.
{isGpt5 && <>Make the smallest set of edits needed and avoid reformatting or moving unrelated code. Preserve existing style and conventions, and keep imports, exports, and public APIs stable unless the task requires changes. Prefer completing all edits for a file within a single message when practical.
} NEVER show the changes to the user, just call the tool, and the edits will be applied and shown to the user.
- NEVER print a codeblock that represents a change to a file, use {ToolName.ReplaceString} instead.
- For each file, give a short description of what needs to be changed, then use the {ToolName.ReplaceString} tool. You can use any tool multiple times in a response, and you can keep writing text after using a tool.
+ NEVER print a codeblock that represents a change to a file, use {ToolName.EditFile} instead.
+ For each file, give a short description of what needs to be changed, then use the {ToolName.EditFile} tool. You can use any tool multiple times in a response, and you can keep writing text after using a tool.
} The {ToolName.EditFile} tool is very smart and can understand how to apply your edits to the user's files, you just need to provide minimal hints.
@@ -858,6 +862,7 @@ export class SweBenchAgentPrompt extends PromptElement {!!tools[ToolName.ReplaceString] && {ToolName.ReplaceString} tool is a tool for editing files. For moving or renaming files, you should generally use the {ToolName.CoreRunInTerminal} with the 'mv' command instead. For larger edits, split it into small edits and call the edit tool multiple times to finish the whole edit carefully.
+ {tools[ToolName.MultiReplaceString] && <>Use the {ToolName.MultiReplaceString} tool when you need to make multiple string replacements across one or more files in a single operation.
} Before using {ToolName.ReplaceString} tool, you must use {ToolName.ReadFile} tool to understand the file's contents and context you want to edit
To make a file edit, provide the following:
1. filePath: The absolute path to the file to modify (must be absolute, not relative)
diff --git a/src/extension/prompts/node/agent/agentPrompt.tsx b/src/extension/prompts/node/agent/agentPrompt.tsx index 7ebce4adbe..475867d08d 100644 --- a/src/extension/prompts/node/agent/agentPrompt.tsx +++ b/src/extension/prompts/node/agent/agentPrompt.tsx @@ -324,6 +324,7 @@ export class AgentUserMessage extends PromptElement { const query = await this.promptVariablesService.resolveToolReferencesInPrompt(this.props.request, this.props.toolReferences ?? []); const hasReplaceStringTool = !!this.props.availableTools?.find(tool => tool.name === ToolName.ReplaceString); + const hasMultiReplaceStringTool = !!this.props.availableTools?.find(tool => tool.name === ToolName.MultiReplaceString); const hasApplyPatchTool = !!this.props.availableTools?.find(tool => tool.name === ToolName.ApplyPatch); const hasCreateFileTool = !!this.props.availableTools?.find(tool => tool.name === ToolName.CreateFile); const hasEditFileTool = !!this.props.availableTools?.find(tool => tool.name === ToolName.EditFile); @@ -354,7 +355,7 @@ export class AgentUserMessage extends PromptElement { {/* Critical reminders that are effective when repeated right next to the user message */} - {getEditingReminder(hasEditFileTool, hasReplaceStringTool, modelNeedsStrongReplaceStringHint(this.props.endpoint))} + {getEditingReminder(hasEditFileTool, hasReplaceStringTool, modelNeedsStrongReplaceStringHint(this.props.endpoint), hasMultiReplaceStringTool)} {getExplanationReminder(this.props.endpoint.family, hasTodoTool)} @@ -669,19 +670,23 @@ class AgentTasksInstructions extends PromptElement { } } -export function getEditingReminder(hasEditFileTool: boolean, hasReplaceStringTool: boolean, useStrongReplaceStringHint: boolean) { +export function getEditingReminder(hasEditFileTool: boolean, hasReplaceStringTool: boolean, useStrongReplaceStringHint: boolean, hasMultiStringReplace: boolean) { const lines = []; if (hasEditFileTool) { lines.push(<>When using the {ToolName.EditFile} tool, avoid repeating existing code, instead use a line comment with \`{EXISTING_CODE_MARKER}\` to represent regions of unchanged code.
); } if (hasReplaceStringTool) { - lines.push(<>When using the {ToolName.ReplaceString} tool, include 3-5 lines of unchanged code before and after the string you want to replace, to make it unambiguous which part of the file should be edited.
); + lines.push(<> + When using the {ToolName.ReplaceString} tool, include 3-5 lines of unchanged code before and after the string you want to replace, to make it unambiguous which part of the file should be edited.
+ {hasMultiStringReplace && <>For maximum efficiency, whenever you plan to perform multiple independent edit operations, invoke them simultaneously using {ToolName.MultiReplaceString} tool rather than sequentially. This will greatly improve user's cost and time efficiency leading to a better user experience.
} + ); } if (hasEditFileTool && hasReplaceStringTool) { + const eitherOr = hasMultiStringReplace ? `${ToolName.ReplaceString} or ${ToolName.MultiReplaceString} tools` : `${ToolName.ReplaceString} tool`; if (useStrongReplaceStringHint) { - lines.push(<>You must always try making file edits using {ToolName.ReplaceString} tool. NEVER use {ToolName.EditFile} unless told to by the user or by a tool.); + lines.push(<>You must always try making file edits using the {eitherOr}. NEVER use {ToolName.EditFile} unless told to by the user or by a tool.); } else { - lines.push(<>It is much faster to edit using the {ToolName.ReplaceString} tool. Prefer {ToolName.ReplaceString} for making edits and only fall back to {ToolName.EditFile} if it fails.); + lines.push(<>It is much faster to edit using the {eitherOr}. Prefer the {eitherOr} for making edits and only fall back to {ToolName.EditFile} if it fails.); } } diff --git a/src/extension/prompts/node/agent/test/__snapshots__/agentPrompt.spec.tsx.snap b/src/extension/prompts/node/agent/test/__snapshots__/agentPrompt.spec.tsx.snap index fc554108c9..aef1556740 100644 --- a/src/extension/prompts/node/agent/test/__snapshots__/agentPrompt.spec.tsx.snap +++ b/src/extension/prompts/node/agent/test/__snapshots__/agentPrompt.spec.tsx.snap @@ -257,7 +257,142 @@ copilot_cache_control: { type: 'ephemeral' } When using the insert_edit_into_file tool, avoid repeating existing code, instead use a line comment with /\`...existing code.../\` to represent regions of unchanged code. When using the replace_string_in_file tool, include 3-5 lines of unchanged code before and after the string you want to replace, to make it unambiguous which part of the file should be edited. -It is much faster to edit using the replace_string_in_file tool. Prefer replace_string_in_file for making edits and only fall back to insert_edit_into_file if it fails. +It is much faster to edit using the replace_string_in_file tool. Prefer the replace_string_in_file tool for making edits and only fall back to insert_edit_into_file if it fails. + + +hello + + + +copilot_cache_control: { type: 'ephemeral' } +~~~ +" +`; + +exports[`AgentPrompt > all tools, replace_string/multi_replace_string/insert_edit 1`] = ` +"### System +~~~md +You are an expert AI programming assistant, working with a user in the VS Code editor. +When asked for your name, you must respond with "GitHub Copilot". +Follow the user's requirements carefully & to the letter. +Follow Microsoft content policies. +Avoid content that violates copyrights. +If you are asked to generate content that is harmful, hateful, racist, sexist, lewd, or violent, only respond with "Sorry, I can't assist with that." +Keep your answers short and impersonal. + +You are a highly sophisticated automated coding agent with expert-level knowledge across many different programming languages and frameworks. +The user will ask a question, or ask you to perform a task, and it may require lots of research to answer correctly. There is a selection of tools that let you perform actions or retrieve helpful context to answer the user's question. +You will be given some context and attachments along with the user prompt. You can use them if they are relevant to the task, and ignore them if not. Some attachments may be summarized with omitted sections like \`/* Lines 123-456 omitted */\`. You can use the read_file tool to read more context if needed. Never pass this omitted line marker to an edit tool. +If you can infer the project type (languages, frameworks, and libraries) from the user's query or the context that you have, make sure to keep them in mind when making changes. +If the user wants you to implement a feature and they have not specified the files to edit, first break down the user's request into smaller concepts and think about the kinds of files you need to grasp each concept. +If you aren't sure which tool is relevant, you can call multiple tools. You can call tools repeatedly to take actions or gather as much context as needed until you have completed the task fully. Don't give up unless you are sure the request cannot be fulfilled with the tools you have. It's YOUR RESPONSIBILITY to make sure that you have done all you can to collect necessary context. +When reading files, prefer reading large meaningful chunks rather than consecutive small sections to minimize tool calls and gain better context. +Don't make assumptions about the situation- gather context first, then perform the task or answer the question. +Think creatively and explore the workspace in order to make a complete fix. +Don't repeat yourself after a tool call, pick up where you left off. +NEVER print out a codeblock with file changes unless the user asked for it. Use the appropriate edit tool instead. +You don't need to read a file if it's already provided in context. + + +If the user is requesting a code sample, you can answer it directly without using any tools. +When using a tool, follow the JSON schema very carefully and make sure to include ALL required properties. +No need to ask permission before using a tool. +NEVER say the name of a tool to a user. For example, instead of saying that you'll use the run_in_terminal tool, say "I'll run the command in a terminal". +If you think running multiple tools can answer the user's question, prefer calling them in parallel whenever possible, but do not call semantic_search in parallel. +When using the read_file tool, prefer reading a large section over calling the read_file tool many times in sequence. You can also think of all the pieces you may be interested in and read them in parallel. Read large enough context to ensure you get what you need. +If semantic_search returns the full contents of the text files in the workspace, you have all the workspace context. +You can use the grep_search to get an overview of a file by searching for a string within that one file, instead of using read_file many times. +If you don't know exactly the string or filename pattern you're looking for, use semantic_search to do a semantic search across the workspace. +When invoking a tool that takes a file path, always use the absolute file path. If the file has a scheme like untitled: or vscode-userdata:, then use a URI with the scheme. +You don't currently have any tools available for running terminal commands. If the user asks you to run a terminal command, you can ask the user to enable terminal tools or print a codeblock with the suggested command. +Tools can be disabled by the user. You may see tools used previously in the conversation that are not currently available. Be careful to only use the tools that are currently available to you. + + +Before you edit an existing file, make sure you either already have it in the provided context, or read it with the read_file tool, so that you can make proper changes. +Use the replace_string_in_file tool for single string replacements, paying attention to context to ensure your replacement is unique. Prefer the multi_replace_string_in_file tool when you need to make multiple string replacements across one or more files in a single operation. This is significantly more efficient than calling replace_string_in_file multiple times and should be your first choice for: fixing similar patterns across files, applying consistent formatting changes, bulk refactoring operations, or any scenario where you need to make the same type of change in multiple places. +Use the insert_edit_into_file tool to insert code into a file ONLY if multi_replace_string_in_file/replace_string_in_file has failed. +When editing files, group your changes by file. +NEVER show the changes to the user, just call the tool, and the edits will be applied and shown to the user. +NEVER print a codeblock that represents a change to a file, use replace_string_in_file, multi_replace_string_in_file, or insert_edit_into_file instead. +For each file, give a short description of what needs to be changed, then use the replace_string_in_file, multi_replace_string_in_file, or insert_edit_into_file tools. You can use any tool multiple times in a response, and you can keep writing text after using a tool. +Follow best practices when editing files. If a popular external library exists to solve a problem, use it and properly install the package e.g. creating a "requirements.txt". +If you're building a webapp from scratch, give it a beautiful and modern UI. +After editing a file, any new errors in the file will be in the tool result. Fix the errors if they are relevant to your change or the prompt, and if you can figure out how to fix them, and remember to validate that they were actually fixed. Do not loop more than 3 times attempting to fix errors in the same file. If the third try fails, you should stop and ask the user what to do next. +The insert_edit_into_file tool is very smart and can understand how to apply your edits to the user's files, you just need to provide minimal hints. +When you use the insert_edit_into_file tool, avoid repeating existing code, instead use comments to represent regions of unchanged code. The tool prefers that you are as concise as possible. For example: +// ...existing code... +changed code +// ...existing code... +changed code +// ...existing code... + +Here is an example of how you should format an edit to an existing Person class: +class Person { + // ...existing code... + age: number; + // ...existing code... + getAge() { + return this.age; + } +} + + +To edit notebook files in the workspace, you can use the edit_notebook_file tool. + +Never use the insert_edit_into_file tool and never execute Jupyter related commands in the Terminal to edit notebook files, such as \`jupyter notebook\`, \`jupyter lab\`, \`install jupyter\` or the like. Use the edit_notebook_file tool instead. +Use the run_notebook_cell tool instead of executing Jupyter related commands in the Terminal, such as \`jupyter notebook\`, \`jupyter lab\`, \`install jupyter\` or the like. +Use the copilot_getNotebookSummary tool to get the summary of the notebook (this includes the list or all cells along with the Cell Id, Cell type and Cell Language, execution details and mime types of the outputs, if any). +Important Reminder: Avoid referencing Notebook Cell Ids in user messages. Use cell number instead. +Important Reminder: Markdown cells cannot be executed + + +Use proper Markdown formatting in your answers. When referring to a filename or symbol in the user's workspace, wrap it in backticks. + +The class \`Person\` is in \`src/models/person.ts\`. + + + + + +This is a test custom instruction file + + + +copilot_cache_control: { type: 'ephemeral' } +~~~ + + +### User +~~~md + +The user's current OS is: Linux +The user's default shell is: "zsh". When you generate terminal commands, please generate them correctly for this shell. + + +I am working in a workspace with the following folders: +- /workspace +I am working in a workspace that has the following structure: +\`\`\` + +\`\`\` +This is the state of the context at this point in the conversation. The view of the workspace structure may be truncated. You can use tools to collect more context if needed. + + + +copilot_cache_control: { type: 'ephemeral' } +~~~ + + +### User +~~~md + +(Date removed from snapshot) + + +When using the insert_edit_into_file tool, avoid repeating existing code, instead use a line comment with /\`...existing code.../\` to represent regions of unchanged code. +When using the replace_string_in_file tool, include 3-5 lines of unchanged code before and after the string you want to replace, to make it unambiguous which part of the file should be edited. +For maximum efficiency, whenever you plan to perform multiple independent edit operations, invoke them simultaneously using multi_replace_string_in_file tool rather than sequentially. This will greatly improve user's cost and time efficiency leading to a better user experience. +It is much faster to edit using the replace_string_in_file or multi_replace_string_in_file tools. Prefer the replace_string_in_file or multi_replace_string_in_file tools for making edits and only fall back to insert_edit_into_file if it fails. hello diff --git a/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx b/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx index 4f2fc13354..56e9524469 100644 --- a/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx +++ b/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx @@ -133,6 +133,20 @@ suite('AgentPrompt', () => { }); test('all tools, replace_string/insert_edit', async () => { + const toolsService = accessor.get(IToolsService); + expect(await agentPromptToString(accessor, { + chatVariables: new ChatVariablesCollection(), + history: [], + query: 'hello', + tools: { + availableTools: toolsService.tools.filter(tool => tool.name !== ToolName.ApplyPatch && tool.name !== ToolName.MultiReplaceString), + toolInvocationToken: null as never, + toolReferences: [], + } + }, undefined)).toMatchSnapshot(); + }); + + test('all tools, replace_string/multi_replace_string/insert_edit', async () => { const toolsService = accessor.get(IToolsService); expect(await agentPromptToString(accessor, { chatVariables: new ChatVariablesCollection(), diff --git a/src/extension/prompts/node/codeMapper/codeMapper.ts b/src/extension/prompts/node/codeMapper/codeMapper.ts index 30231d01bc..79d4b970a2 100644 --- a/src/extension/prompts/node/codeMapper/codeMapper.ts +++ b/src/extension/prompts/node/codeMapper/codeMapper.ts @@ -43,7 +43,7 @@ import { isEqual } from '../../../../util/vs/base/common/resources'; import { URI } from '../../../../util/vs/base/common/uri'; import { generateUuid } from '../../../../util/vs/base/common/uuid'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; -import { Position, Range, TextEdit } from '../../../../vscodeTypes'; +import { NotebookEdit, Position, Range, TextEdit } from '../../../../vscodeTypes'; import { OutcomeAnnotation, OutcomeAnnotationLabel } from '../../../inlineChat/node/promptCraftingTypes'; import { Lines, LinesEdit } from '../../../prompt/node/editGeneration'; import { LineOfText, PartialAsyncTextReader } from '../../../prompt/node/streamingEdits'; @@ -57,6 +57,20 @@ import { findEdit, getCodeBlock, iterateSectionsForResponse, Marker, Patch, Sect export type ICodeMapperDocument = TextDocumentSnapshot | NotebookDocumentSnapshot; export async function processFullRewriteNotebook(document: NotebookDocument, inputStream: string | AsyncIterable, outputStream: MappedEditsResponseStream, alternativeNotebookEditGenerator: IAlternativeNotebookContentEditGenerator, telemetryOptions: NotebookEditGenerationTelemtryOptions, token: CancellationToken): Promise { + for await (const edit of processFullRewriteNotebookEdits(document, inputStream, alternativeNotebookEditGenerator, telemetryOptions, token)) { + if (Array.isArray(edit)) { + outputStream.textEdit(edit[0], edit[1]); + } else { + outputStream.notebookEdit(document.uri, edit); // changed this.outputStream to outputStream + } + } + + return undefined; +} + +export type CellOrNotebookEdit = NotebookEdit | [Uri, TextEdit[]]; + +export async function* processFullRewriteNotebookEdits(document: NotebookDocument, inputStream: string | AsyncIterable, alternativeNotebookEditGenerator: IAlternativeNotebookContentEditGenerator, telemetryOptions: NotebookEditGenerationTelemtryOptions, token: CancellationToken): AsyncIterable { // emit start of notebook const cellMap = new ResourceMap(); for await (const edit of alternativeNotebookEditGenerator.generateNotebookEdits(document, inputStream, telemetryOptions, token)) { @@ -70,10 +84,10 @@ export async function processFullRewriteNotebook(document: NotebookDocument, inp continue; } } - outputStream.textEdit(cellUri, edit[1]); + yield [cellUri, edit[1]]; } } else { - outputStream.notebookEdit(document.uri, edit); // changed this.outputStream to outputStream + yield edit; } } diff --git a/src/extension/prompts/node/panel/editCodePrompt2.tsx b/src/extension/prompts/node/panel/editCodePrompt2.tsx index fdb92ab406..47e73c530f 100644 --- a/src/extension/prompts/node/panel/editCodePrompt2.tsx +++ b/src/extension/prompts/node/panel/editCodePrompt2.tsx @@ -138,6 +138,7 @@ class EditCode2UserMessage extends PromptElement { const useProjectLabels = this._configurationService.getExperimentBasedConfig(ConfigKey.Internal.ProjectLabelsChat, this.experimentationService); const hasReplaceStringTool = !!this.props.promptContext.tools?.availableTools.find(tool => tool.name === ToolName.ReplaceString); const hasEditFileTool = !!this.props.promptContext.tools?.availableTools.find(tool => tool.name === ToolName.EditFile); + const hasMultiReplaceStringTool = !!this.props.promptContext.tools?.availableTools.find(tool => tool.name === ToolName.MultiReplaceString); return ( <> @@ -148,7 +149,7 @@ class EditCode2UserMessage extends PromptElement { - {getEditingReminder(hasEditFileTool, hasReplaceStringTool, modelNeedsStrongReplaceStringHint(this.props.endpoint))} + {getEditingReminder(hasEditFileTool, hasReplaceStringTool, modelNeedsStrongReplaceStringHint(this.props.endpoint), hasMultiReplaceStringTool)} diff --git a/src/extension/tools/common/toolNames.ts b/src/extension/tools/common/toolNames.ts index 015a30e5df..78ec8dae37 100644 --- a/src/extension/tools/common/toolNames.ts +++ b/src/extension/tools/common/toolNames.ts @@ -26,6 +26,7 @@ export enum ToolName { EditFile = 'insert_edit_into_file', CreateFile = 'create_file', ReplaceString = 'replace_string_in_file', + MultiReplaceString = 'multi_replace_string_in_file', EditNotebook = 'edit_notebook_file', RunNotebookCell = 'run_notebook_cell', GetNotebookSummary = 'copilot_getNotebookSummary', @@ -76,6 +77,7 @@ export enum ContributedToolName { EditFile = 'copilot_insertEdit', CreateFile = 'copilot_createFile', ReplaceString = 'copilot_replaceString', + MultiReplaceString = 'copilot_multiReplaceString', EditNotebook = 'copilot_editNotebook', RunNotebookCell = 'copilot_runNotebookCell', GetNotebookSummary = 'copilot_getNotebookSummary', diff --git a/src/extension/tools/node/abstractReplaceStringTool.tsx b/src/extension/tools/node/abstractReplaceStringTool.tsx new file mode 100644 index 0000000000..be842d0309 --- /dev/null +++ b/src/extension/tools/node/abstractReplaceStringTool.tsx @@ -0,0 +1,399 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { CHAT_MODEL } from '../../../platform/configuration/common/configurationService'; +import { IEditSurvivalTrackerService, IEditSurvivalTrackingSession } from '../../../platform/editSurvivalTracking/common/editSurvivalTrackerService'; +import { NotebookDocumentSnapshot } from '../../../platform/editing/common/notebookDocumentSnapshot'; +import { TextDocumentSnapshot } from '../../../platform/editing/common/textDocumentSnapshot'; +import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider'; +import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; +import { ILanguageDiagnosticsService } from '../../../platform/languages/common/languageDiagnosticsService'; +import { IAlternativeNotebookContentService } from '../../../platform/notebook/common/alternativeContent'; +import { IAlternativeNotebookContentEditGenerator, NotebookEditGenerationTelemtryOptions, NotebookEditGenrationSource } from '../../../platform/notebook/common/alternativeContentEditGenerator'; +import { INotebookService } from '../../../platform/notebook/common/notebookService'; +import { IPromptPathRepresentationService } from '../../../platform/prompts/common/promptPathRepresentationService'; +import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; +import { ITelemetryService, multiplexProperties } from '../../../platform/telemetry/common/telemetry'; +import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; +import { ChatResponseStreamImpl } from '../../../util/common/chatResponseStreamImpl'; +import { removeLeadingFilepathComment } from '../../../util/common/markdown'; +import { timeout } from '../../../util/vs/base/common/async'; +import { Iterable } from '../../../util/vs/base/common/iterator'; +import { ResourceMap } from '../../../util/vs/base/common/map'; +import { URI } from '../../../util/vs/base/common/uri'; +import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { ChatResponseTextEditPart, EndOfLine, LanguageModelPromptTsxPart, LanguageModelToolResult } from '../../../vscodeTypes'; +import { IBuildPromptContext } from '../../prompt/common/intents'; +import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer'; +import { CellOrNotebookEdit, processFullRewriteNotebookEdits } from '../../prompts/node/codeMapper/codeMapper'; +import { ToolName } from '../common/toolNames'; +import { ICopilotTool } from '../common/toolsRegistry'; +import { IToolsService } from '../common/toolsService'; +import { ActionType } from './applyPatch/parser'; +import { CorrectedEditResult, healReplaceStringParams } from './editFileHealing'; +import { EditFileResult, IEditedFile } from './editFileToolResult'; +import { EditError, NoChangeError, NoMatchError, applyEdit } from './editFileToolUtils'; +import { sendEditNotebookTelemetry } from './editNotebookTool'; +import { assertFileOkForTool, resolveToolInputPath } from './toolUtils'; + +export interface IAbstractReplaceStringInput { + filePath: string; + oldString: string; + newString: string; +} + +export interface IPrepareEdit { + document: NotebookDocumentSnapshot | TextDocumentSnapshot; + uri: URI; + didHeal: boolean; + input: IAbstractReplaceStringInput; + generatedEdit: { success: true; textEdits: vscode.TextEdit[]; notebookEdits?: CellOrNotebookEdit[] } | { success: false; errorMessage: string }; +} + + +export abstract class AbstractReplaceStringTool implements ICopilotTool { + protected _promptContext: IBuildPromptContext | undefined; + + constructor( + @IPromptPathRepresentationService protected readonly promptPathRepresentationService: IPromptPathRepresentationService, + @IInstantiationService protected readonly instantiationService: IInstantiationService, + @IWorkspaceService protected readonly workspaceService: IWorkspaceService, + @IToolsService protected readonly toolsService: IToolsService, + @INotebookService protected readonly notebookService: INotebookService, + @IFileSystemService protected readonly fileSystemService: IFileSystemService, + @IAlternativeNotebookContentService private readonly alternativeNotebookContent: IAlternativeNotebookContentService, + @IAlternativeNotebookContentEditGenerator private readonly alternativeNotebookEditGenerator: IAlternativeNotebookContentEditGenerator, + @IEditSurvivalTrackerService private readonly _editSurvivalTrackerService: IEditSurvivalTrackerService, + @ILanguageDiagnosticsService private readonly languageDiagnosticsService: ILanguageDiagnosticsService, + @ITelemetryService protected readonly telemetryService: ITelemetryService, + @IEndpointProvider private readonly endpointProvider: IEndpointProvider, + @IExperimentationService private readonly experimentationService: IExperimentationService + ) { } + + public abstract invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken): Promise; + + protected abstract toolName(): ToolName; + + protected async prepareEditsForFile(options: vscode.LanguageModelToolInvocationOptions, input: IAbstractReplaceStringInput, token: vscode.CancellationToken): Promise { + const uri = resolveToolInputPath(input.filePath, this.promptPathRepresentationService); + try { + await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri)); + } catch (error) { + this.sendReplaceTelemetry('invalidFile', options, input, undefined, undefined, undefined); + throw error; + } + + // Validate parameters + if (!input.filePath || input.oldString === undefined || input.newString === undefined || !this._promptContext) { + this.sendReplaceTelemetry('invalidStrings', options, input, undefined, undefined, undefined); + throw new Error('Invalid input'); + } + + const isNotebook = this.notebookService.hasSupportedNotebooks(uri); + const document = isNotebook ? + await this.workspaceService.openNotebookDocumentAndSnapshot(uri, this.alternativeNotebookContent.getFormat(this._promptContext?.request?.model)) : + await this.workspaceService.openTextDocumentAndSnapshot(uri); + + const didHealRef = { didHeal: false }; + try { + if (input.oldString === input.newString) { + throw new NoChangeError('Input and output are identical', input.filePath); + } + + const { updatedFile, edits } = await this.generateEdit(uri, document, options, input, didHealRef, token); + let notebookEdits: (vscode.NotebookEdit | [URI, vscode.TextEdit[]])[] | undefined; + if (document instanceof NotebookDocumentSnapshot) { + const telemetryOptions: NotebookEditGenerationTelemtryOptions = { + model: options.model ? this.endpointProvider.getChatEndpoint(options.model).then(m => m.name) : undefined, + requestId: this._promptContext.requestId, + source: NotebookEditGenrationSource.stringReplace, + }; + + notebookEdits = await Iterable.asyncToArray(processFullRewriteNotebookEdits(document.document, updatedFile, this.alternativeNotebookEditGenerator, telemetryOptions, token)); + sendEditNotebookTelemetry(this.telemetryService, this.endpointProvider, 'stringReplace', document.uri, this._promptContext.requestId, options.model ?? this._promptContext.request?.model); + } + + void this.sendReplaceTelemetry('success', options, input, document.getText(), isNotebook, didHealRef.didHeal); + return { document, uri, input, didHeal: didHealRef.didHeal, generatedEdit: { success: true, textEdits: edits, notebookEdits } }; + } catch (error) { + // Enhanced error message with more helpful details + let errorMessage = 'String replacement failed: '; + let outcome: string; + + if (error instanceof NoMatchError) { + outcome = input.oldString.match(/Lines \d+-\d+ omitted/) ? + 'oldStringHasOmittedLines' : + input.oldString.includes('{…}') ? + 'oldStringHasSummarizationMarker' : + input.oldString.includes('/*...*/') ? + 'oldStringHasSummarizationMarkerSemanticSearch' : + error.kindForTelemetry; + errorMessage += `${error.message}`; + } else if (error instanceof EditError) { + outcome = error.kindForTelemetry; + errorMessage += error.message; + } else { + outcome = 'other'; + errorMessage += `${error.message}`; + } + + void this.sendReplaceTelemetry(outcome, options, input, document.getText(), isNotebook, didHealRef.didHeal); + + return { document, uri, input, didHeal: didHealRef.didHeal, generatedEdit: { success: false, errorMessage } }; + } + } + + protected async applyAllEdits(options: vscode.LanguageModelToolInvocationOptions, edits: IPrepareEdit[], token: vscode.CancellationToken) { + if (!this._promptContext?.stream) { + throw new Error('no prompt context found'); + } + + const fileResults: IEditedFile[] = []; + const existingDiagnosticMap = new ResourceMap(); + + for (const { document, uri, generatedEdit } of edits) { + if (!existingDiagnosticMap.has(document.uri)) { + existingDiagnosticMap.set(document.uri, this.languageDiagnosticsService.getDiagnostics(document.uri)); + } + const existingDiagnostics = existingDiagnosticMap.get(document.uri)!; + const isNotebook = this.notebookService.hasSupportedNotebooks(uri); + + if (!generatedEdit.success) { + fileResults.push({ operation: ActionType.UPDATE, uri, isNotebook, existingDiagnostics, error: generatedEdit.errorMessage }); + continue; + } + + let editSurvivalTracker: IEditSurvivalTrackingSession | undefined; + let responseStream = this._promptContext.stream; + if (document && document instanceof TextDocumentSnapshot) { // Only for existing text documents + const tracker = editSurvivalTracker = this._editSurvivalTrackerService.initialize(document.document); + responseStream = ChatResponseStreamImpl.spy(this._promptContext.stream, (part) => { + if (part instanceof ChatResponseTextEditPart) { + tracker.collectAIEdits(part.edits); + } + }); + } + + this._promptContext.stream.markdown('\n```\n'); + this._promptContext.stream.codeblockUri(uri, true); + + if (generatedEdit.notebookEdits) { + this._promptContext.stream.notebookEdit(document.uri, []); + for (const edit of generatedEdit.notebookEdits) { + if (edit instanceof Array) { + this._promptContext.stream.textEdit(edit[0], edit[1]); + } else { + this._promptContext.stream.notebookEdit(document.uri, edit); + } + } + this._promptContext.stream.notebookEdit(document.uri, true); + } else { + for (const edit of generatedEdit.textEdits) { + responseStream.textEdit(uri, edit); + } + responseStream.textEdit(uri, true); + + timeout(2000).then(() => { + // The tool can't wait for edits to be applied, so just wait before starting the survival tracker. + // TODO@roblourens see if this improves the survival metric, find a better fix. + editSurvivalTracker?.startReporter(res => { + /* __GDPR__ + "codeMapper.trackEditSurvival" : { + "owner": "aeschli", + "comment": "Tracks how much percent of the AI edits survived after 5 minutes of accepting", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current request turn." }, + "requestSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source from where the request was made" }, + "mapper": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The code mapper used: One of 'fast', 'fast-lora', 'full' and 'patch'" }, + "survivalRateFourGram": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The rate between 0 and 1 of how much of the AI edit is still present in the document." }, + "survivalRateNoRevert": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The rate between 0 and 1 of how much of the ranges the AI touched ended up being reverted." }, + "didBranchChange": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Indicates if the branch changed in the meantime. If the branch changed (value is 1), this event should probably be ignored." }, + "timeDelayMs": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The time delay between the user accepting the edit and measuring the survival rate." } + } + */ + res.telemetryService.sendMSFTTelemetryEvent('codeMapper.trackEditSurvival', { requestId: this._promptContext?.requestId, requestSource: 'agent', mapper: 'stringReplaceTool' }, { + survivalRateFourGram: res.fourGram, + survivalRateNoRevert: res.noRevert, + timeDelayMs: res.timeDelayMs, + didBranchChange: res.didBranchChange ? 1 : 0, + }); + }); + }); + + fileResults.push({ operation: ActionType.UPDATE, uri, isNotebook, existingDiagnostics }); + } + + this._promptContext.stream.markdown('\n```\n'); + } + + return new LanguageModelToolResult([ + new LanguageModelPromptTsxPart( + await renderPromptElementJSON( + this.instantiationService, + EditFileResult, + { files: fileResults, diagnosticsTimeout: 2000, toolName: this.toolName(), requestId: options.chatRequestId, model: options.model }, + // If we are not called with tokenization options, have _some_ fake tokenizer + // otherwise we end up returning the entire document + options.tokenizationOptions ?? { + tokenBudget: 5000, + countTokens: (t) => Promise.resolve(t.length * 3 / 4) + }, + token, + ), + ) + ]); + } + + private async generateEdit(uri: URI, document: TextDocumentSnapshot | NotebookDocumentSnapshot, options: vscode.LanguageModelToolInvocationOptions, input: IAbstractReplaceStringInput, didHealRef: { didHeal: boolean }, token: vscode.CancellationToken) { + const filePath = this.promptPathRepresentationService.getFilePath(document.uri); + const eol = document instanceof TextDocumentSnapshot && document.eol === EndOfLine.CRLF ? '\r\n' : '\n'; + const oldString = removeLeadingFilepathComment(input.oldString, document.languageId, filePath).replace(/\r?\n/g, eol); + const newString = removeLeadingFilepathComment(input.newString, document.languageId, filePath).replace(/\r?\n/g, eol); + + // Apply the edit using the improved applyEdit function that uses VS Code APIs + let updatedFile: string; + let edits: vscode.TextEdit[] = []; + try { + const result = await applyEdit( + uri, + oldString, + newString, + this.workspaceService, + this.notebookService, + this.alternativeNotebookContent, + this._promptContext?.request?.model + ); + updatedFile = result.updatedFile; + edits = result.edits; + } catch (e) { + if (!(e instanceof NoMatchError)) { + throw e; + } + + if (this.experimentationService.getTreatmentVariable('vscode', 'copilotchat.disableReplaceStringHealing') === true) { + throw e; // failsafe for next release. + } + + didHealRef.didHeal = true; + + let healed: CorrectedEditResult; + try { + healed = await healReplaceStringParams( + options.model, + document.getText(), + { + explanation: options.input.explanation, + filePath: filePath, + oldString, + newString, + }, + eol, + await this.endpointProvider.getChatEndpoint(CHAT_MODEL.GPT4OMINI), + token + ); + if (healed.params.oldString === healed.params.newString) { + throw new NoChangeError('change was identical after healing', document.uri.fsPath); + } + } catch (e2) { + this.sendHealingTelemetry(options, String(e2), undefined); + throw e; // original error + } + + try { + const result = await applyEdit( + uri, + healed.params.oldString, + healed.params.newString, + this.workspaceService, + this.notebookService, + this.alternativeNotebookContent, + this._promptContext?.request?.model + ); + updatedFile = result.updatedFile; + edits = result.edits; + } catch (e2) { + this.sendHealingTelemetry(options, undefined, String(e2)); + throw e; // original error + } + } + + return { edits, updatedFile }; + } + + private async sendReplaceTelemetry(outcome: string, options: vscode.LanguageModelToolInvocationOptions, input: IAbstractReplaceStringInput, file: string | undefined, isNotebookDocument: boolean | undefined, didHeal: boolean | undefined) { + const model = await this.modelForTelemetry(options); + const isNotebook = isNotebookDocument ? 1 : (isNotebookDocument === false ? 0 : -1); + const isMulti = this.toolName() === ToolName.MultiReplaceString ? 1 : 0; + /* __GDPR__ + "replaceStringToolInvoked" : { + "owner": "roblourens", + "comment": "The replace_string tool was invoked", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current request turn." }, + "interactionId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current interaction." }, + "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the invocation was successful, or a failure reason" }, + "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model that invoked the tool" }, + "isNotebook": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Whether the document is a notebook, 1 = yes, 0 = no, other = unknown." }, + "didHeal": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Whether the document is a notebook, 1 = yes, 0 = no, other = unknown." }, + "isMulti": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Whether the document is a multi-replace operation, 1 = yes, 0 = no." } + } + */ + this.telemetryService.sendMSFTTelemetryEvent('replaceStringToolInvoked', + { + requestId: options.chatRequestId, + interactionId: options.chatRequestId, + outcome, + model + }, { isNotebook, didHeal: didHeal === undefined ? -1 : (didHeal ? 1 : 0), isMulti } + ); + + this.telemetryService.sendEnhancedGHTelemetryEvent('replaceStringTool', multiplexProperties({ + headerRequestId: options.chatRequestId, + baseModel: model, + messageText: file, + completionTextJson: JSON.stringify(input), + postProcessingOutcome: outcome, + }), { isNotebook }); + } + + private async sendHealingTelemetry(options: vscode.LanguageModelToolInvocationOptions, healError: string | undefined, applicationError: string | undefined) { + /* __GDPR__ + "replaceStringHealingStat" : { + "owner": "roblourens", + "comment": "The replace_string tool was invoked", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current request turn." }, + "interactionId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current interaction." }, + "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model that invoked the tool" }, + "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the invocation was successful, or a failure reason" }, + "healError": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Any error that happened during healing" }, + "applicationError": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Any error that happened after application" }, + "success": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Whether the document is a notebook, 1 = yes, 0 = no, other = unknown." } + } + */ + this.telemetryService.sendMSFTTelemetryEvent('replaceStringHealingStat', + { + requestId: options.chatRequestId, + interactionId: options.chatRequestId, + model: await this.modelForTelemetry(options), + healError, + applicationError, + }, { success: healError === undefined && applicationError === undefined ? 1 : 0 } + ); + } + + protected async modelForTelemetry(options: vscode.LanguageModelToolInvocationOptions) { + return options.model && (await this.endpointProvider.getChatEndpoint(options.model)).model; + } + + async resolveInput(input: T, promptContext: IBuildPromptContext): Promise { + this._promptContext = promptContext; // TODO@joyceerhl @roblourens HACK: Avoid types in the input being serialized and not deserialized when they go through invokeTool + return input; + } + + prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions, token: vscode.CancellationToken): vscode.ProviderResult { + return { + presentation: 'hidden' + }; + } +} diff --git a/src/extension/tools/node/allTools.ts b/src/extension/tools/node/allTools.ts index 9f78cfc393..51214dbb5c 100644 --- a/src/extension/tools/node/allTools.ts +++ b/src/extension/tools/node/allTools.ts @@ -19,6 +19,7 @@ import './githubRepoTool'; import './insertEditTool'; import './installExtensionTool'; import './listDirTool'; +import './multiReplaceStringTool'; import './newNotebookTool'; import './newWorkspace/newWorkspaceTool'; import './newWorkspace/projectSetupInfoTool'; diff --git a/src/extension/tools/node/editFileToolUtils.tsx b/src/extension/tools/node/editFileToolUtils.tsx index fb89c57749..6417221a35 100644 --- a/src/extension/tools/node/editFileToolUtils.tsx +++ b/src/extension/tools/node/editFileToolUtils.tsx @@ -11,7 +11,7 @@ import { INotebookService } from '../../../platform/notebook/common/notebookServ import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { URI } from '../../../util/vs/base/common/uri'; import { Position as EditorPosition } from '../../../util/vs/editor/common/core/position'; -import { EndOfLine, Position, Range, WorkspaceEdit } from '../../../vscodeTypes'; +import { EndOfLine, Position, Range, TextEdit } from '../../../vscodeTypes'; // Simplified Hunk type for the patch interface Hunk { @@ -392,15 +392,15 @@ export async function applyEdit( uri: URI, old_string: string, new_string: string, - workspaceEdit: WorkspaceEdit, workspaceService: IWorkspaceService, notebookService: INotebookService, alternativeNotebookContent: IAlternativeNotebookContentService, languageModel: LanguageModelChat | undefined -): Promise<{ patch: Hunk[]; updatedFile: string }> { +): Promise<{ patch: Hunk[]; updatedFile: string; edits: TextEdit[] }> { let originalFile: string; let updatedFile: string; + const edits: TextEdit[] = []; const filePath = uri.toString(); try { @@ -421,7 +421,7 @@ export async function applyEdit( } // Create new file case updatedFile = new_string; - workspaceEdit.insert(uri, new Position(0, 0), new_string); + edits.push(TextEdit.insert(new Position(0, 0), new_string)); } else { // Edit existing file case if (new_string === '') { @@ -435,7 +435,7 @@ export async function applyEdit( if (result.editPosition.length) { const [start, end] = result.editPosition[0]; const range = new Range(document.positionAt(start), document.positionAt(end)); - workspaceEdit.delete(uri, range); + edits.push(TextEdit.delete(range)); } } else { const suggestion = result?.suggestion || 'The string to replace must match exactly.'; @@ -456,7 +456,7 @@ export async function applyEdit( if (result.editPosition.length) { const [start, end] = result.editPosition[0]; const range = new Range(document.positionAt(start), document.positionAt(end)); - workspaceEdit.delete(uri, range); + edits.push(TextEdit.delete(range)); } } } else { @@ -481,7 +481,7 @@ export async function applyEdit( if (result.editPosition.length) { const [start, end] = result.editPosition[0]; const range = new Range(document.positionAt(start), document.positionAt(end)); - workspaceEdit.replace(uri, range, new_string); + edits.push(TextEdit.replace(range, new_string)); } // If we used similarity matching, add a warning @@ -506,7 +506,7 @@ export async function applyEdit( newStr: updatedFile, }); - return { patch, updatedFile }; + return { patch, updatedFile, edits }; } catch (error) { // If the file doesn't exist and we're creating a new file with empty oldString if (old_string === '' && error.code === 'ENOENT') { @@ -519,7 +519,8 @@ export async function applyEdit( newStr: updatedFile, }); - return { patch, updatedFile }; + edits.push(TextEdit.insert(new Position(0, 0), new_string)); + return { patch, updatedFile, edits }; } if (error instanceof EditError) { diff --git a/src/extension/tools/node/multiReplaceStringTool.tsx b/src/extension/tools/node/multiReplaceStringTool.tsx new file mode 100644 index 0000000000..8b0164e4c3 --- /dev/null +++ b/src/extension/tools/node/multiReplaceStringTool.tsx @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { ResourceMap, ResourceSet } from '../../../util/vs/base/common/map'; +import { URI } from '../../../util/vs/base/common/uri'; +import { CellOrNotebookEdit } from '../../prompts/node/codeMapper/codeMapper'; +import { ToolName } from '../common/toolNames'; +import { ToolRegistry } from '../common/toolsRegistry'; +import { AbstractReplaceStringTool } from './abstractReplaceStringTool'; +import { IReplaceStringToolParams } from './replaceStringTool'; + +export interface IMultiReplaceStringToolParams { + explanation: string; + replacements: IReplaceStringToolParams[]; +} + +export class MultiReplaceStringTool extends AbstractReplaceStringTool { + public static toolName = ToolName.MultiReplaceString; + + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken) { + if (!options.input.replacements || !Array.isArray(options.input.replacements)) { + throw new Error('Invalid input, no replacements array'); + } + + const prepared = await Promise.all(options.input.replacements.map(r => this.prepareEditsForFile(options, r, token))); + + let successes = 0; + let failures = 0; + let individualEdits = 0; + const uniqueUris = new ResourceSet(); + for (const edit of prepared) { + uniqueUris.add(edit.uri); + if (edit.generatedEdit.success) { + successes++; + individualEdits += edit.generatedEdit.textEdits.length; + } else { + failures++; + } + } + + /* __GDPR__ + "multiStringReplaceCall" : { + "owner": "connor4312", + "comment": "Tracks how much percent of the AI edits survived after 5 minutes of accepting", + "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current request turn." }, + "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model used for the request." }, + "successes": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The number of successful edits.", "isMeasurement": true }, + "failures": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The number of failed edits.", "isMeasurement": true }, + "uniqueUris": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The number of unique URIs edited.", "isMeasurement": true }, + "individualEdits": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The number of individual text edits made.", "isMeasurement": true } + } + */ + this.telemetryService.sendMSFTTelemetryEvent('multiStringReplaceCall', { + requestId: this._promptContext?.requestId, + model: await this.modelForTelemetry(options), + }, { + successes, + failures, + individualEdits, + uniqueUris: uniqueUris.size, + }); + + + for (let i = 0; i < prepared.length; i++) { + const e1 = prepared[i]; + uniqueUris.add(e1.uri); + + if (!e1.generatedEdit.success) { + failures++; + continue; + } + successes++; + + for (let k = i + 1; k < prepared.length; k++) { + const e2 = prepared[k]; + // Merge successful edits of the same type and URI so that edits come in + // a single correct batch and positions aren't later clobbered. + if (!e2.generatedEdit.success || e2.uri.toString() !== e1.uri.toString() || (!!e2.generatedEdit.notebookEdits !== !!e1.generatedEdit.notebookEdits)) { + continue; + } + + prepared.splice(k, 1); + k--; + + if (e2.generatedEdit.notebookEdits) { + e1.generatedEdit.notebookEdits = mergeNotebookAndTextEdits(e1.generatedEdit.notebookEdits!, e2.generatedEdit.notebookEdits); + } else { + e1.generatedEdit.textEdits = e1.generatedEdit.textEdits.concat(e2.generatedEdit.textEdits); + e1.generatedEdit.textEdits.sort(textEditSorter); + } + } + } + + return this.applyAllEdits(options, prepared, token); + } + + protected override toolName(): ToolName { + return MultiReplaceStringTool.toolName; + } +} + +ToolRegistry.registerTool(MultiReplaceStringTool); + +function textEditSorter(a: vscode.TextEdit, b: vscode.TextEdit) { + return b.range.end.compareTo(a.range.end) || b.range.start.compareTo(a.range.start); +} + +/** + * Merge two arrays of notebook edits or text edits grouped by URI. + * Text edits for the same URI are concatenated and sorted in reverse file order (descending by start position). + */ +function mergeNotebookAndTextEdits(left: CellOrNotebookEdit[], right: CellOrNotebookEdit[]): CellOrNotebookEdit[] { + const notebookEdits: vscode.NotebookEdit[] = []; + const textEditsByUri = new ResourceMap(); + + const add = (item: vscode.NotebookEdit | [URI, vscode.TextEdit[]]) => { + if (Array.isArray(item)) { + const [uri, edits] = item; + let bucket = textEditsByUri.get(uri); + if (!bucket) { + bucket = []; + textEditsByUri.set(uri, bucket); + } + bucket.push(...edits); + } else { + notebookEdits.push(item); + } + }; + + left.forEach(add); + right.forEach(add); + + const mergedTextEditTuples: [URI, vscode.TextEdit[]][] = []; + for (const [uri, edits] of textEditsByUri.entries()) { + edits.sort(textEditSorter); + mergedTextEditTuples.push([uri, edits]); + } + + return [...notebookEdits, ...mergedTextEditTuples]; +} diff --git a/src/extension/tools/node/replaceStringTool.tsx b/src/extension/tools/node/replaceStringTool.tsx index 96fb23083c..7a5aeaa4a9 100644 --- a/src/extension/tools/node/replaceStringTool.tsx +++ b/src/extension/tools/node/replaceStringTool.tsx @@ -4,38 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import type * as vscode from 'vscode'; -import { CHAT_MODEL } from '../../../platform/configuration/common/configurationService'; -import { IEditSurvivalTrackerService, IEditSurvivalTrackingSession } from '../../../platform/editSurvivalTracking/common/editSurvivalTrackerService'; -import { NotebookDocumentSnapshot } from '../../../platform/editing/common/notebookDocumentSnapshot'; -import { TextDocumentSnapshot } from '../../../platform/editing/common/textDocumentSnapshot'; -import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider'; -import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; -import { ILanguageDiagnosticsService } from '../../../platform/languages/common/languageDiagnosticsService'; -import { IAlternativeNotebookContentService } from '../../../platform/notebook/common/alternativeContent'; -import { IAlternativeNotebookContentEditGenerator, NotebookEditGenerationTelemtryOptions, NotebookEditGenrationSource } from '../../../platform/notebook/common/alternativeContentEditGenerator'; -import { INotebookService } from '../../../platform/notebook/common/notebookService'; -import { IPromptPathRepresentationService } from '../../../platform/prompts/common/promptPathRepresentationService'; -import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; -import { ITelemetryService, multiplexProperties } from '../../../platform/telemetry/common/telemetry'; -import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; -import { ChatResponseStreamImpl } from '../../../util/common/chatResponseStreamImpl'; -import { removeLeadingFilepathComment } from '../../../util/common/markdown'; -import { timeout } from '../../../util/vs/base/common/async'; -import { URI } from '../../../util/vs/base/common/uri'; -import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; -import { ChatResponseTextEditPart, EndOfLine, LanguageModelPromptTsxPart, LanguageModelToolResult, WorkspaceEdit } from '../../../vscodeTypes'; -import { IBuildPromptContext } from '../../prompt/common/intents'; -import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer'; -import { processFullRewriteNotebook } from '../../prompts/node/codeMapper/codeMapper'; import { ToolName } from '../common/toolNames'; -import { ICopilotTool, ToolRegistry } from '../common/toolsRegistry'; -import { IToolsService } from '../common/toolsService'; -import { ActionType } from './applyPatch/parser'; -import { CorrectedEditResult, healReplaceStringParams } from './editFileHealing'; -import { EditFileResult } from './editFileToolResult'; -import { EditError, NoChangeError, NoMatchError, applyEdit } from './editFileToolUtils'; -import { sendEditNotebookTelemetry } from './editNotebookTool'; -import { assertFileOkForTool, resolveToolInputPath } from './toolUtils'; +import { ToolRegistry } from '../common/toolsRegistry'; +import { AbstractReplaceStringTool } from './abstractReplaceStringTool'; export interface IReplaceStringToolParams { explanation: string; @@ -44,325 +15,16 @@ export interface IReplaceStringToolParams { newString: string; } -export class ReplaceStringTool implements ICopilotTool { +export class ReplaceStringTool extends AbstractReplaceStringTool { public static toolName = ToolName.ReplaceString; - private _promptContext: IBuildPromptContext | undefined; - - constructor( - @IPromptPathRepresentationService protected readonly promptPathRepresentationService: IPromptPathRepresentationService, - @IInstantiationService protected readonly instantiationService: IInstantiationService, - @IWorkspaceService protected readonly workspaceService: IWorkspaceService, - @IToolsService protected readonly toolsService: IToolsService, - @INotebookService protected readonly notebookService: INotebookService, - @IFileSystemService protected readonly fileSystemService: IFileSystemService, - @IAlternativeNotebookContentService private readonly alternativeNotebookContent: IAlternativeNotebookContentService, - @IAlternativeNotebookContentEditGenerator private readonly alternativeNotebookEditGenerator: IAlternativeNotebookContentEditGenerator, - @IEditSurvivalTrackerService private readonly _editSurvivalTrackerService: IEditSurvivalTrackerService, - @ILanguageDiagnosticsService private readonly languageDiagnosticsService: ILanguageDiagnosticsService, - @ITelemetryService private readonly telemetryService: ITelemetryService, - @IEndpointProvider private readonly endpointProvider: IEndpointProvider, - @IExperimentationService private readonly experimentationService: IExperimentationService - ) { } - async invoke(options: vscode.LanguageModelToolInvocationOptions, token: vscode.CancellationToken) { - const uri = resolveToolInputPath(options.input.filePath, this.promptPathRepresentationService); - try { - await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri)); - } catch (error) { - this.sendReplaceTelemetry('invalidFile', options, undefined, undefined, undefined); - throw error; - } - - // Validate parameters - if (!options.input.filePath || options.input.oldString === undefined || options.input.newString === undefined || !this._promptContext?.stream) { - this.sendReplaceTelemetry('invalidStrings', options, undefined, undefined, undefined); - throw new Error('Invalid input'); - } - - const isNotebook = this.notebookService.hasSupportedNotebooks(uri); - const document = isNotebook ? - await this.workspaceService.openNotebookDocumentAndSnapshot(uri, this.alternativeNotebookContent.getFormat(this._promptContext?.request?.model)) : - await this.workspaceService.openTextDocumentAndSnapshot(uri); - - const existingDiagnostics = this.languageDiagnosticsService.getDiagnostics(document.uri); - - // String replacement mode - if (options.input.oldString !== undefined && options.input.newString !== undefined) { - - // Track edit survival - let editSurvivalTracker: IEditSurvivalTrackingSession | undefined; - let responseStream = this._promptContext.stream; - if (document && document instanceof TextDocumentSnapshot) { // Only for existing text documents - const tracker = editSurvivalTracker = this._editSurvivalTrackerService.initialize(document.document); - responseStream = ChatResponseStreamImpl.spy(this._promptContext.stream, (part) => { - if (part instanceof ChatResponseTextEditPart) { - tracker.collectAIEdits(part.edits); - } - }); - } - - const didHealRef = { didHeal: false }; - try { - const { workspaceEdit, updatedFile } = await this.generateEdit(uri, document, options, didHealRef, token); - - this._promptContext.stream.markdown('\n```\n'); - this._promptContext.stream.codeblockUri(uri, true); - - if (document instanceof NotebookDocumentSnapshot) { - const telemetryOptions: NotebookEditGenerationTelemtryOptions = { - model: options.model ? this.endpointProvider.getChatEndpoint(options.model).then(m => m.name) : undefined, - requestId: this._promptContext.requestId, - source: NotebookEditGenrationSource.stringReplace, - }; - this._promptContext.stream.notebookEdit(document.uri, []); - await processFullRewriteNotebook(document.document, updatedFile, this._promptContext.stream, this.alternativeNotebookEditGenerator, telemetryOptions, token); - this._promptContext.stream.notebookEdit(document.uri, true); - sendEditNotebookTelemetry(this.telemetryService, this.endpointProvider, 'stringReplace', document.uri, this._promptContext.requestId, options.model ?? this._promptContext.request?.model); - } else { - for (const [uri, edit] of workspaceEdit.entries()) { - responseStream.textEdit(uri, edit); - } - responseStream.textEdit(uri, true); - - timeout(2000).then(() => { - // The tool can't wait for edits to be applied, so just wait before starting the survival tracker. - // TODO@roblourens see if this improves the survival metric, find a better fix. - editSurvivalTracker?.startReporter(res => { - /* __GDPR__ - "codeMapper.trackEditSurvival" : { - "owner": "aeschli", - "comment": "Tracks how much percent of the AI edits survived after 5 minutes of accepting", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current request turn." }, - "requestSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The source from where the request was made" }, - "mapper": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The code mapper used: One of 'fast', 'fast-lora', 'full' and 'patch'" }, - "survivalRateFourGram": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The rate between 0 and 1 of how much of the AI edit is still present in the document." }, - "survivalRateNoRevert": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The rate between 0 and 1 of how much of the ranges the AI touched ended up being reverted." }, - "didBranchChange": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Indicates if the branch changed in the meantime. If the branch changed (value is 1), this event should probably be ignored." }, - "timeDelayMs": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The time delay between the user accepting the edit and measuring the survival rate." } - } - */ - res.telemetryService.sendMSFTTelemetryEvent('codeMapper.trackEditSurvival', { requestId: this._promptContext?.requestId, requestSource: 'agent', mapper: 'stringReplaceTool' }, { - survivalRateFourGram: res.fourGram, - survivalRateNoRevert: res.noRevert, - timeDelayMs: res.timeDelayMs, - didBranchChange: res.didBranchChange ? 1 : 0, - }); - }); - }); - } - - this._promptContext.stream.markdown('\n```\n'); - - void this.sendReplaceTelemetry('success', options, document.getText(), isNotebook, didHealRef.didHeal); - return new LanguageModelToolResult([ - new LanguageModelPromptTsxPart( - await renderPromptElementJSON( - this.instantiationService, - EditFileResult, - { files: [{ operation: ActionType.UPDATE, uri, isNotebook, existingDiagnostics }], diagnosticsTimeout: 2000, toolName: ToolName.ReplaceString, requestId: options.chatRequestId, model: options.model }, - // If we are not called with tokenization options, have _some_ fake tokenizer - // otherwise we end up returning the entire document - options.tokenizationOptions ?? { - tokenBudget: 1000, - countTokens: (t) => Promise.resolve(t.length * 3 / 4) - }, - token, - ), - ) - ]); - - } catch (error) { - // Enhanced error message with more helpful details - let errorMessage = 'String replacement failed: '; - let outcome: string; - - if (error instanceof NoMatchError) { - outcome = options.input.oldString.match(/Lines \d+-\d+ omitted/) ? - 'oldStringHasOmittedLines' : - options.input.oldString.includes('{…}') ? - 'oldStringHasSummarizationMarker' : - options.input.oldString.includes('/*...*/') ? - 'oldStringHasSummarizationMarkerSemanticSearch' : - error.kindForTelemetry; - errorMessage += `${error.message}`; - } else if (error instanceof EditError) { - outcome = error.kindForTelemetry; - errorMessage += error.message; - } else { - outcome = 'other'; - errorMessage += `${error.message}`; - } - - void this.sendReplaceTelemetry(outcome, options, document.getText(), isNotebook, didHealRef.didHeal); - - // No edit, so no need to wait for diagnostics - const diagnosticsTimeout = 0; - return new LanguageModelToolResult([ - new LanguageModelPromptTsxPart( - await renderPromptElementJSON( - this.instantiationService, - EditFileResult, - { files: [{ operation: ActionType.UPDATE, uri, isNotebook, existingDiagnostics, error: errorMessage }], diagnosticsTimeout, toolName: ToolName.ReplaceString, requestId: options.chatRequestId, model: options.model }, - options.tokenizationOptions ?? { - tokenBudget: 1000, - countTokens: (t) => Promise.resolve(t.length * 3 / 4) - }, - token, - ), - ) - ]); - } - } - } - - private async generateEdit(uri: URI, document: TextDocumentSnapshot | NotebookDocumentSnapshot, options: vscode.LanguageModelToolInvocationOptions, didHealRef: { didHeal: boolean }, token: vscode.CancellationToken) { - const filePath = this.promptPathRepresentationService.getFilePath(document.uri); - const eol = document instanceof TextDocumentSnapshot && document.eol === EndOfLine.CRLF ? '\r\n' : '\n'; - const oldString = removeLeadingFilepathComment(options.input.oldString, document.languageId, filePath).replace(/\r?\n/g, eol); - const newString = removeLeadingFilepathComment(options.input.newString, document.languageId, filePath).replace(/\r?\n/g, eol); - - // Apply the edit using the improved applyEdit function that uses VS Code APIs - const workspaceEdit = new WorkspaceEdit(); - let updatedFile: string; - try { - const result = await applyEdit( - uri, - oldString, - newString, - workspaceEdit, - this.workspaceService, - this.notebookService, - this.alternativeNotebookContent, - this._promptContext?.request?.model - ); - updatedFile = result.updatedFile; - } catch (e) { - if (!(e instanceof NoMatchError)) { - throw e; - } - - if (this.experimentationService.getTreatmentVariable('vscode', 'copilotchat.disableReplaceStringHealing') === true) { - throw e; // failsafe for next release. - } - - didHealRef.didHeal = true; - - let healed: CorrectedEditResult; - try { - healed = await healReplaceStringParams( - options.model, - document.getText(), - { - explanation: options.input.explanation, - filePath: filePath, - oldString, - newString, - }, - eol, - await this.endpointProvider.getChatEndpoint(CHAT_MODEL.GPT4OMINI), - token - ); - if (healed.params.oldString === healed.params.newString) { - throw new NoChangeError('change was identical after healing', document.uri.fsPath); - } - } catch (e2) { - this.sendHealingTelemetry(options, String(e2), undefined); - throw e; // original error - } - - try { - const result = await applyEdit( - uri, - healed.params.oldString, - healed.params.newString, - workspaceEdit, - this.workspaceService, - this.notebookService, - this.alternativeNotebookContent, - this._promptContext?.request?.model - ); - updatedFile = result.updatedFile; - } catch (e2) { - this.sendHealingTelemetry(options, undefined, String(e2)); - throw e; // original error - } - } - - return { workspaceEdit, updatedFile }; - } - - private async sendReplaceTelemetry(outcome: string, options: vscode.LanguageModelToolInvocationOptions, file: string | undefined, isNotebookDocument: boolean | undefined, didHeal: boolean | undefined) { - const model = await this.modelForTelemetry(options); - const isNotebook = isNotebookDocument ? 1 : (isNotebookDocument === false ? 0 : -1); - /* __GDPR__ - "replaceStringToolInvoked" : { - "owner": "roblourens", - "comment": "The replace_string tool was invoked", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current request turn." }, - "interactionId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current interaction." }, - "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the invocation was successful, or a failure reason" }, - "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model that invoked the tool" }, - "isNotebook": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Whether the document is a notebook, 1 = yes, 0 = no, other = unknown." }, - "didHeal": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Whether the document is a notebook, 1 = yes, 0 = no, other = unknown." } - } - */ - this.telemetryService.sendMSFTTelemetryEvent('replaceStringToolInvoked', - { - requestId: options.chatRequestId, - interactionId: options.chatRequestId, - outcome, - model - }, { isNotebook, didHeal: didHeal === undefined ? -1 : (didHeal ? 1 : 0) } - ); - - this.telemetryService.sendEnhancedGHTelemetryEvent('replaceStringTool', multiplexProperties({ - headerRequestId: options.chatRequestId, - baseModel: model, - messageText: file, - completionTextJson: JSON.stringify(options.input), - postProcessingOutcome: outcome, - }), { isNotebook }); - } - - private async sendHealingTelemetry(options: vscode.LanguageModelToolInvocationOptions, healError: string | undefined, applicationError: string | undefined) { - /* __GDPR__ - "replaceStringHealingStat" : { - "owner": "roblourens", - "comment": "The replace_string tool was invoked", - "requestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current request turn." }, - "interactionId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the current interaction." }, - "model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model that invoked the tool" }, - "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the invocation was successful, or a failure reason" }, - "healError": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Any error that happened during healing" }, - "applicationError": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Any error that happened after application" }, - "success": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Whether the document is a notebook, 1 = yes, 0 = no, other = unknown." } - } - */ - this.telemetryService.sendMSFTTelemetryEvent('replaceStringHealingStat', - { - requestId: options.chatRequestId, - interactionId: options.chatRequestId, - model: await this.modelForTelemetry(options), - healError, - applicationError, - }, { success: healError === undefined && applicationError === undefined ? 1 : 0 } - ); - } - - private async modelForTelemetry(options: vscode.LanguageModelToolInvocationOptions) { - return options.model && (await this.endpointProvider.getChatEndpoint(options.model)).model; - } - - async resolveInput(input: IReplaceStringToolParams, promptContext: IBuildPromptContext): Promise { - this._promptContext = promptContext; // TODO@joyceerhl @roblourens HACK: Avoid types in the input being serialized and not deserialized when they go through invokeTool - return input; + const prepared = await this.prepareEditsForFile(options, options.input, token); + return this.applyAllEdits(options, [prepared], token); } - prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions, token: vscode.CancellationToken): vscode.ProviderResult { - return { - presentation: 'hidden' - }; + protected override toolName(): ToolName { + return ReplaceStringTool.toolName; } } diff --git a/src/extension/tools/node/test/editFileToolUtils.spec.ts b/src/extension/tools/node/test/editFileToolUtils.spec.ts index cc6e803ca4..2c5c9e1dbf 100644 --- a/src/extension/tools/node/test/editFileToolUtils.spec.ts +++ b/src/extension/tools/node/test/editFileToolUtils.spec.ts @@ -23,8 +23,10 @@ describe('replace_string_in_file - applyEdit', () => { let alternatveContentService: IAlternativeNotebookContentService; let doc: ExtHostDocumentData; - function doApplyEdit(oldString: string, newString: string, uri = doc.document.uri) { - return applyEdit(uri, oldString, newString, workspaceEdit, workspaceService, notebookService as INotebookService, alternatveContentService, undefined); + async function doApplyEdit(oldString: string, newString: string, uri = doc.document.uri) { + const r = await applyEdit(uri, oldString, newString, workspaceService, notebookService as INotebookService, alternatveContentService, undefined); + workspaceEdit.set(uri, r.edits); + return r; } function setText(value: string) { diff --git a/src/platform/configuration/common/configurationService.ts b/src/platform/configuration/common/configurationService.ts index 44e39ae13c..0bfb44e745 100644 --- a/src/platform/configuration/common/configurationService.ts +++ b/src/platform/configuration/common/configurationService.ts @@ -713,7 +713,7 @@ export namespace ConfigKey { export const OmitBaseAgentInstructions = defineSetting('chat.advanced.omitBaseAgentInstructions', false, INTERNAL); export const PromptFileContext = defineExpSetting('chat.advanced.promptFileContextProvider.enabled', true); - export const GeminiReplaceString = defineExpSetting('chat.advanced.geminiReplaceString.enabled', false, INTERNAL, { experimentName: 'copilotchat.geminiReplaceString' }); + export const MultiReplaceString = defineExpSetting('chat.advanced.multiReplaceString.enabled', false, INTERNAL); } export const AgentThinkingTool = defineSetting('chat.agent.thinkingTool', false); diff --git a/src/platform/endpoint/common/chatModelCapabilities.ts b/src/platform/endpoint/common/chatModelCapabilities.ts index 3dbaf4423a..6846eaa0d1 100644 --- a/src/platform/endpoint/common/chatModelCapabilities.ts +++ b/src/platform/endpoint/common/chatModelCapabilities.ts @@ -53,7 +53,7 @@ export function modelPrefersJsonNotebookRepresentation(model: LanguageModelChat * Model supports replace_string_in_file as an edit tool. */ export function modelSupportsReplaceString(model: LanguageModelChat | IChatEndpoint): boolean { - return model.family.startsWith('claude') || model.family.startsWith('Anthropic'); + return model.family.startsWith('claude') || model.family.startsWith('Anthropic') || model.family.includes('gemini'); } /**