From 8fda4b0ec74fdbc1c802844f7c14c88afb4000d2 Mon Sep 17 00:00:00 2001 From: Omer Machluf Date: Tue, 18 Nov 2025 15:30:02 +0200 Subject: [PATCH] Navigating the codebase like a developer: f12/symbolic navigation tools --- package.json | 168 +++++++++++ package.nls.json | 8 + .../node/agent/defaultAgentInstructions.tsx | 14 + .../node/panel/codebaseAgentPrompt.tsx | 5 + src/extension/tools/common/toolNames.ts | 12 + src/extension/tools/node/allTools.ts | 5 + src/extension/tools/node/definitionsTool.tsx | 229 ++++++++++++++ .../tools/node/documentSymbolsTool.tsx | 282 ++++++++++++++++++ .../tools/node/implementationsTool.tsx | 229 ++++++++++++++ src/extension/tools/node/referencesTool.tsx | 257 ++++++++++++++++ src/extension/tools/node/toolUtils.ts | 212 ++++++++++++- src/extension/tools/vscode-node/tools.ts | 4 + 12 files changed, 1424 insertions(+), 1 deletion(-) create mode 100644 src/extension/tools/node/definitionsTool.tsx create mode 100644 src/extension/tools/node/documentSymbolsTool.tsx create mode 100644 src/extension/tools/node/implementationsTool.tsx create mode 100644 src/extension/tools/node/referencesTool.tsx diff --git a/package.json b/package.json index adffe9a1d4..2128f98d52 100644 --- a/package.json +++ b/package.json @@ -186,6 +186,170 @@ ] } }, + { + "name": "copilot_documentSymbols", + "toolReferenceName": "documentSymbols", + "displayName": "%copilot.tools.documentSymbols.name%", + "icon": "$(symbol-structure)", + "userDescription": "%copilot.documentSymbols.tool.description%", + "modelDescription": "List symbols defined within a specific file using language services. Prefer this tool when the user needs an overview of a file's structure before navigating or editing.", + "tags": [ + "vscode_codesearch" + ], + "inputSchema": { + "type": "object", + "properties": { + "filePath": { + "type": "string", + "description": "Path to the file to inspect for document symbols." + }, + "pageSize": { + "type": "number", + "description": "Number of symbols per page (defaults to 40 and caps at 200)." + }, + "page": { + "type": "number", + "description": "1-based page number to retrieve. Defaults to 1." + }, + "reset": { + "type": "boolean", + "description": "Set to true to refresh cached symbols before paging." + } + }, + "required": [ + "filePath" + ] + } + }, + { + "name": "copilot_getDefinitions", + "toolReferenceName": "definitions", + "displayName": "%copilot.tools.getDefinitions.name%", + "icon": "$(go-to-file)", + "userDescription": "%copilot.getDefinitions.tool.description%", + "modelDescription": "Locate symbol definitions in a file using a specific line and symbol name. Use this tool to jump directly to the declaration the user is focused on.", + "tags": [ + "vscode_codesearch" + ], + "inputSchema": { + "type": "object", + "properties": { + "filePath": { + "type": "string", + "description": "Path to the file that contains the definition." + }, + "line": { + "type": "number", + "description": "1-based line number near the symbol to inspect." + }, + "symbolName": { + "type": "string", + "description": "Name of the symbol located on the specified line." + }, + "expectedKind": { + "type": "string", + "description": "The expected kind of symbol to disambiguate when multiple symbols with the same name exist on the line. Valid values: 'Type', 'Property', 'Method', 'Field', 'Local', 'Parameter', 'Namespace', 'Class', 'Interface', 'Enum', 'Function', 'Variable', 'Constant'.", + "enum": ["Type", "Property", "Method", "Field", "Local", "Parameter", "Namespace", "Class", "Interface", "Enum", "Function", "Variable", "Constant"] + }, + "symbolId": { + "type": "string", + "description": "Optional. A stable symbol identifier returned from a previous ambiguous response. When provided, this directly identifies the exact symbol to query, bypassing name-based resolution." + } + }, + "required": [ + "filePath", + "line", + "symbolName", + "expectedKind" + ] + } + }, + { + "name": "copilot_findImplementations", + "toolReferenceName": "implementations", + "displayName": "%copilot.tools.findImplementations.name%", + "icon": "$(symbol-class)", + "userDescription": "%copilot.findImplementations.tool.description%", + "modelDescription": "Locate implementations of an interface, abstract member, or virtual method at a specific position in a file. Use this tool to jump to concrete implementations when you have a line and symbol name for the target.", + "tags": [ + "vscode_codesearch" + ], + "inputSchema": { + "type": "object", + "properties": { + "filePath": { + "type": "string", + "description": "Path to the file containing the symbol to inspect." + }, + "line": { + "type": "number", + "description": "The 1-based line number of the symbol to inspect." + }, + "symbolName": { + "type": "string", + "description": "Name of the symbol located on the specified line." + }, + "expectedKind": { + "type": "string", + "description": "The expected kind of symbol to disambiguate when multiple symbols with the same name exist on the line. Valid values: 'Type', 'Property', 'Method', 'Field', 'Local', 'Parameter', 'Namespace', 'Class', 'Interface', 'Enum', 'Function', 'Variable', 'Constant'.", + "enum": ["Type", "Property", "Method", "Field", "Local", "Parameter", "Namespace", "Class", "Interface", "Enum", "Function", "Variable", "Constant"] + }, + "symbolId": { + "type": "string", + "description": "Optional. A stable symbol identifier returned from a previous ambiguous response. When provided, this directly identifies the exact symbol to query, bypassing name-based resolution." + } + }, + "required": [ + "filePath", + "line", + "symbolName", + "expectedKind" + ] + } + }, + { + "name": "copilot_findReferences", + "toolReferenceName": "references", + "displayName": "%copilot.tools.findReferences.name%", + "icon": "$(references)", + "userDescription": "%copilot.findReferences.tool.description%", + "modelDescription": "List references for a symbol at a specific position in a file. Use this tool to understand the usage surface when you have a line and symbol name for the symbol.", + "tags": [ + "vscode_codesearch" + ], + "inputSchema": { + "type": "object", + "properties": { + "filePath": { + "type": "string", + "description": "Path to the file containing the symbol to inspect." + }, + "line": { + "type": "number", + "description": "The 1-based line number of the symbol to inspect." + }, + "symbolName": { + "type": "string", + "description": "Name of the symbol located on the specified line." + }, + "expectedKind": { + "type": "string", + "description": "The expected kind of symbol to disambiguate when multiple symbols with the same name exist on the line. Valid values: 'Type', 'Property', 'Method', 'Field', 'Local', 'Parameter', 'Namespace', 'Class', 'Interface', 'Enum', 'Function', 'Variable', 'Constant'.", + "enum": ["Type", "Property", "Method", "Field", "Local", "Parameter", "Namespace", "Class", "Interface", "Enum", "Function", "Variable", "Constant"] + }, + "symbolId": { + "type": "string", + "description": "Optional. A stable symbol identifier returned from a previous ambiguous response. When provided, this directly identifies the exact symbol to query, bypassing name-based resolution." + } + }, + "required": [ + "filePath", + "line", + "symbolName", + "expectedKind" + ] + } + }, { "name": "copilot_listCodeUsages", "toolReferenceName": "usages", @@ -1162,6 +1326,10 @@ "textSearch", "listDirectory", "readFile", + "documentSymbols", + "definitions", + "references", + "implementations", "codebase", "searchResults" ] diff --git a/package.nls.json b/package.nls.json index eaa27aca16..41bcca0814 100644 --- a/package.nls.json +++ b/package.nls.json @@ -311,6 +311,10 @@ "copilot.tools.fetchWebPage.description": "Fetch the main content from a web page. You should include the URL of the page you want to fetch.", "copilot.tools.searchCodebase.name": "Codebase", "copilot.tools.searchWorkspaceSymbols.name": "Workspace Symbols", + "copilot.tools.documentSymbols.name": "Document Symbols", + "copilot.tools.getDefinitions.name": "Go to Definition", + "copilot.tools.findImplementations.name": "Find Implementations", + "copilot.tools.findReferences.name": "Find References", "copilot.tools.listCodeUsages.name": "Find Usages", "copilot.tools.getVSCodeAPI.name": "Get VS Code API References", @@ -341,6 +345,10 @@ "copilot.tools.getDocInfo.name": "Doc Info", "copilot.tools.createDirectory.name": "Create Directory", "copilot.tools.createDirectory.description": "Create new directories in your workspace", + "copilot.documentSymbols.tool.description": "List symbols defined in a file using language services so the model can understand its structure before editing.", + "copilot.getDefinitions.tool.description": "Locate symbol definitions in a file using a specified line and symbol name so the model can jump directly to the code.", + "copilot.findImplementations.tool.description": "Find concrete implementations of an interface member or virtual symbol using a specified line and symbol name.", + "copilot.findReferences.tool.description": "List references to a symbol using a specified line and symbol name to understand its usage surface.", "github.copilot.config.agent.currentEditorContext.enabled": "When enabled, Copilot will include the name of the current active editor in the context for agent mode.", "github.copilot.config.customInstructionsInSystemMessage": "When enabled, custom instructions and mode instructions will be appended to the system message instead of a user message.", "copilot.toolSet.editing.description": "Edit files in your workspace", diff --git a/src/extension/prompts/node/agent/defaultAgentInstructions.tsx b/src/extension/prompts/node/agent/defaultAgentInstructions.tsx index d736f1415e..be8f486aa4 100644 --- a/src/extension/prompts/node/agent/defaultAgentInstructions.tsx +++ b/src/extension/prompts/node/agent/defaultAgentInstructions.tsx @@ -74,6 +74,10 @@ export class DefaultAgentPrompt extends PromptElement { 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 {ToolName.CoreRunInTerminal} 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{tools[ToolName.Codebase] && <>, but do not call {ToolName.Codebase} in parallel.}
+ {tools[ToolName.DocumentSymbols] && <>When you already know which file matters, start with {ToolName.DocumentSymbols} to understand its structure. The tool caches results and paginates them—request additional symbols with "page" or adjust "pageSize", and set "reset": true to refresh from the beginning. + {tools[ToolName.Definitions] && <> Use {ToolName.Definitions} when you have a line and symbol name to jump directly to the relevant definition.} + {tools[ToolName.Implementations] && <> Use {ToolName.Implementations} to list concrete implementations or overrides for the symbol at that position.} + {tools[ToolName.References] && <> Use {ToolName.References} to gather usages from the same position.}
} {tools[ToolName.ReadFile] && <>When using the {ToolName.ReadFile} tool, prefer reading a large section over calling the {ToolName.ReadFile} 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.
} {tools[ToolName.Codebase] && <>If {ToolName.Codebase} returns the full contents of the text files in the workspace, you have all the workspace context.
} {tools[ToolName.FindTextInFiles] && <>You can use the {ToolName.FindTextInFiles} to get an overview of a file by searching for a string within that one file, instead of using {ToolName.ReadFile} many times.
} @@ -228,6 +232,10 @@ export class AlternateGPTPrompt extends PromptElement { 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 {ToolName.CoreRunInTerminal} 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{tools[ToolName.Codebase] && <>, but do not call {ToolName.Codebase} in parallel.}
+ {tools[ToolName.DocumentSymbols] && <>When you already know which file matters, start with {ToolName.DocumentSymbols} to understand its structure. The tool caches results and paginates them—request additional symbols with "page" or adjust "pageSize", and set "reset": true to refresh from the beginning.
} + {tools[ToolName.Definitions] && <> Use {ToolName.Definitions} when you have a line and symbol name to jump directly to the relevant definition, like a developer would have used f12.
} + {tools[ToolName.Implementations] && <> Use {ToolName.Implementations} to list concrete implementations or overrides for the symbol at that position.
} + {tools[ToolName.References] && <> Use {ToolName.References} to gather usages for a symbol at that position.
} {tools[ToolName.ReadFile] && <>When using the {ToolName.ReadFile} tool, prefer reading a large section over calling the {ToolName.ReadFile} 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.
} {tools[ToolName.Codebase] && <>If {ToolName.Codebase} returns the full contents of the text files in the workspace, you have all the workspace context.
} {tools[ToolName.FindTextInFiles] && <>You can use the {ToolName.FindTextInFiles} to get an overview of a file by searching for a string within that one file, instead of using {ToolName.ReadFile} many times.
} @@ -331,6 +339,7 @@ export class McpToolInstructions extends PromptElement<{ tools: readonly Languag */ export class CodesearchModeInstructions extends PromptElement { render(state: void, sizing: PromptSizing) { + const tools = detectToolCapabilities(this.props.availableTools); return <> These instructions only apply when the question is about the user's workspace.
@@ -350,6 +359,11 @@ export class CodesearchModeInstructions extends PromptElement Remember that you can call multiple tools in one response.
Use {ToolName.Codebase} to search for high level concepts or descriptions of functionality in the user's question. This is the best place to start if you don't know where to look or the exact strings found in the codebase.
+ Always prefer symbolic navigation when applicable. + Use {ToolName.DocumentSymbols} After you have a likely file, call {ToolName.DocumentSymbols} to review its structure. Page through results with the "page" and "pageSize" options, or set "reset": true to rebuild the cache before paging.
+ Use {ToolName.Definitions} When you know where you are in the file, use {ToolName.Definitions} to navigate straight to the target symbol, just like a developer would use f12.
+ Call {ToolName.Implementations} to enumerate concrete implementations when working or modifying with interfaces and you need to recognize their usages
+ Call {ToolName.References} to find referenced usages of a symbol across the codeobase. very useful when modifying public functions signatures or data structures
Prefer {ToolName.SearchWorkspaceSymbols} over {ToolName.FindTextInFiles} when you have precise code identifiers to search for.
Prefer {ToolName.FindTextInFiles} over {ToolName.Codebase} when you have precise keywords to search for.
The tools {ToolName.FindFiles}, {ToolName.FindTextInFiles}, and {ToolName.GetScmChanges} are deterministic and comprehensive, so do not repeatedly invoke them with the same arguments.
diff --git a/src/extension/prompts/node/panel/codebaseAgentPrompt.tsx b/src/extension/prompts/node/panel/codebaseAgentPrompt.tsx index 709421d8f1..49c8c312b0 100644 --- a/src/extension/prompts/node/panel/codebaseAgentPrompt.tsx +++ b/src/extension/prompts/node/panel/codebaseAgentPrompt.tsx @@ -54,6 +54,11 @@ export class CodebaseAgentPrompt extends PromptElement If you think running multiple tools can answer the user's question, prefer calling them in parallel whenever possible, but do not call `{ToolName.Codebase}` in parallel.
Use `{ToolName.Codebase}` to search for high level concepts or descriptions of functionality in the user's question.{!isCodesearchFast && ` Note that '${ToolName.Codebase}' is slow, so you should only run it if you are confident its results will be relevant.`}
+ Always prefer symbolic navigation when applicable. + Use {ToolName.DocumentSymbols} After you have a likely file, call {ToolName.DocumentSymbols} to review its structure, and get accurate symbols and their line numbers for further navigation. Page through results with the "page" and "pageSize" options, or set "reset": true to rebuild the cache before paging.
+ Use {ToolName.Definitions} When you know where you are in the file, use {ToolName.Definitions} to navigate straight to the target symbol, just like a developer would use f12. you must use the line number and symbol name as received from the {ToolName.DocumentSymbols} tool.
+ Call {ToolName.Implementations} to enumerate concrete implementations when working or modifying with interfaces and you need to recognize their usages. you must use the line number and symbol name as received from the {ToolName.DocumentSymbols} tool.
+ Call {ToolName.References} to find referenced usages of a symbol across the codeobase. very useful when modifying public functions signatures or data structures. you must use the line number and symbol name as received from the {ToolName.DocumentSymbols} tool.
Prefer `{ToolName.SearchWorkspaceSymbols}` over `{ToolName.FindTextInFiles}` when you have precise code identifiers to search for.
Prefer `{ToolName.FindTextInFiles}` over `{ToolName.Codebase}` when you have precise keywords to search for.
When using a tool, follow the JSON schema very carefully and make sure to include all required fields.
diff --git a/src/extension/tools/common/toolNames.ts b/src/extension/tools/common/toolNames.ts index a057a6a595..ef69833c7e 100644 --- a/src/extension/tools/common/toolNames.ts +++ b/src/extension/tools/common/toolNames.ts @@ -27,6 +27,10 @@ export enum ToolName { FindFiles = 'file_search', FindTextInFiles = 'grep_search', ReadFile = 'read_file', + DocumentSymbols = 'document_symbols', + Definitions = 'get_definitions', + Implementations = 'find_implementations', + References = 'find_references', ListDirectory = 'list_dir', GetErrors = 'get_errors', GetScmChanges = 'get_changed_files', @@ -84,6 +88,10 @@ export enum ContributedToolName { FindFiles = 'copilot_findFiles', FindTextInFiles = 'copilot_findTextInFiles', ReadFile = 'copilot_readFile', + DocumentSymbols = 'copilot_documentSymbols', + Definitions = 'copilot_getDefinitions', + Implementations = 'copilot_findImplementations', + References = 'copilot_findReferences', ListDirectory = 'copilot_listDirectory', GetErrors = 'copilot_getErrors', DocInfo = 'copilot_getDocInfo', @@ -161,6 +169,10 @@ export const toolCategories: Record = { [ToolName.Codebase]: ToolCategory.Core, [ToolName.FindTextInFiles]: ToolCategory.Core, [ToolName.ReadFile]: ToolCategory.Core, + [ToolName.DocumentSymbols]: ToolCategory.Core, + [ToolName.Definitions]: ToolCategory.Core, + [ToolName.Implementations]: ToolCategory.Core, + [ToolName.References]: ToolCategory.Core, [ToolName.CreateFile]: ToolCategory.Core, [ToolName.ApplyPatch]: ToolCategory.Core, [ToolName.ReplaceString]: ToolCategory.Core, diff --git a/src/extension/tools/node/allTools.ts b/src/extension/tools/node/allTools.ts index ae502d6977..643fddb922 100644 --- a/src/extension/tools/node/allTools.ts +++ b/src/extension/tools/node/allTools.ts @@ -7,7 +7,9 @@ import './applyPatchTool'; import './codebaseTool'; import './createDirectoryTool'; import './createFileTool'; +import './definitionsTool'; import './docTool'; +import './documentSymbolsTool'; import './editNotebookTool'; import './findFilesTool'; import './findTestsFilesTool'; @@ -16,6 +18,7 @@ import './getErrorsTool'; import './getNotebookCellOutputTool'; import './getSearchViewResultsTool'; import './githubRepoTool'; +import './implementationsTool'; import './insertEditTool'; import './installExtensionTool'; import './listDirTool'; @@ -28,6 +31,7 @@ import './newWorkspace/projectSetupInfoTool'; import './notebookSummaryTool'; import './readFileTool'; import './readProjectStructureTool'; +import './referencesTool'; import './replaceStringTool'; import './runNotebookCellTool'; import './scmChangesTool'; @@ -39,3 +43,4 @@ import './usagesTool'; import './userPreferencesTool'; import './vscodeAPITool'; import './vscodeCmdTool'; + diff --git a/src/extension/tools/node/definitionsTool.tsx b/src/extension/tools/node/definitionsTool.tsx new file mode 100644 index 0000000000..8d36944e4d --- /dev/null +++ b/src/extension/tools/node/definitionsTool.tsx @@ -0,0 +1,229 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { BasePromptElementProps, PromptElement, PromptElementProps, PromptPiece, PromptReference, PromptSizing, TextChunk } from '@vscode/prompt-tsx'; +import type * as vscode from 'vscode'; +import { ILanguageFeaturesService } from '../../../platform/languages/common/languageFeaturesService'; +import { IPromptPathRepresentationService } from '../../../platform/prompts/common/promptPathRepresentationService'; +import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; +import { CancellationToken } from '../../../util/vs/base/common/cancellation'; +import { URI } from '../../../util/vs/base/common/uri'; +import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { ExtendedLanguageModelToolResult, LanguageModelPromptTsxPart, Location, MarkdownString } from '../../../vscodeTypes'; +import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer'; +import { Tag } from '../../prompts/node/base/tag'; +import { ToolName } from '../common/toolNames'; +import { ICopilotTool, ToolRegistry } from '../common/toolsRegistry'; +import { NormalizedSymbolPosition, SymbolAmbiguityError, SymbolCandidate, assertFileOkForTool, checkCancellation, normalizeSymbolPosition, resolveToolInputPath } from './toolUtils'; + +interface IDefinitionsToolParams { + filePath: string; + line: number; + symbolName: string; + expectedKind: string; + symbolId?: string; +} + +class DefinitionsTool implements ICopilotTool { + public static readonly toolName = ToolName.Definitions; + + constructor( + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IWorkspaceService private readonly workspaceService: IWorkspaceService, + @IPromptPathRepresentationService private readonly promptPathService: IPromptPathRepresentationService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: CancellationToken) { + const { input } = options; + const uri = resolveToolInputPath(input.filePath, this.promptPathService); + + await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri)); + + const document = await this.workspaceService.openTextDocument(uri); + let normalizedPosition: NormalizedSymbolPosition; + try { + normalizedPosition = await this.normalizePosition(document, input.line, input.symbolName, input.expectedKind, input.symbolId, token); + } catch (error) { + if (error instanceof SymbolAmbiguityError) { + return this.returnAmbiguityError(error, uri, input.line, options.tokenizationOptions, token); + } + throw error; + } + + checkCancellation(token); + + const definitionsRaw = await this.languageFeaturesService.getDefinitions(uri, normalizedPosition.position) ?? []; + checkCancellation(token); + const locations = normalizeLocations(definitionsRaw); + const filePath = this.promptPathService.getFilePath(uri); + + const prompt = await renderPromptElementJSON( + this.instantiationService, + DefinitionsResult, + { + definitions: locations, + filePath, + requestedLine: normalizedPosition.line, + requestedColumn: normalizedPosition.column, + sourceUri: uri + }, + options.tokenizationOptions, + token + ); + + const result = new ExtendedLanguageModelToolResult([ + new LanguageModelPromptTsxPart(prompt) + ]); + result.toolResultDetails = locations; + result.toolResultMessage = new MarkdownString(buildDefinitionsMessage(uri, normalizedPosition.line, normalizedPosition.column, locations.length)); + + return result; + } + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions, _token: vscode.CancellationToken): Promise { + const { input } = options; + if (!input.filePath) { + return; + } + + try { + const uri = resolveToolInputPath(input.filePath, this.promptPathService); + await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri)); + const document = await this.workspaceService.openTextDocument(uri); + const normalized = await this.normalizePosition(document, input.line, input.symbolName, input.expectedKind, input.symbolId, CancellationToken.None); + const filePath = this.promptPathService.getFilePath(uri); + + return { + invocationMessage: new MarkdownString(l10n.t('Locating definitions for "{0}" in {1} at line {2}, column {3}.', input.symbolName, filePath, normalized.line, normalized.column)), + pastTenseMessage: new MarkdownString(l10n.t('Located definitions for "{0}" in {1}.', input.symbolName, filePath)) + }; + } catch { + return; + } + } + + private async normalizePosition(document: vscode.TextDocument, line: number, symbolName: string, expectedKind: string, symbolId: string | undefined, token: CancellationToken): Promise { + return await normalizeSymbolPosition(document, line, symbolName, expectedKind, symbolId, this.promptPathService, this.languageFeaturesService, token); + } + + private async returnAmbiguityError( + error: SymbolAmbiguityError, + uri: URI, + line: number, + tokenizationOptions: vscode.LanguageModelToolInvocationOptions['tokenizationOptions'], + token: CancellationToken + ): Promise { + const filePath = this.promptPathService.getFilePath(uri); + const prompt = await renderPromptElementJSON( + this.instantiationService, + AmbiguityResult, + { + error: error.message, + candidates: error.candidates, + filePath, + line + }, + tokenizationOptions, + token + ); + + const result = new ExtendedLanguageModelToolResult([ + new LanguageModelPromptTsxPart(prompt) + ]); + result.toolResultMessage = new MarkdownString(error.message); + return result; + } +} + +ToolRegistry.registerTool(DefinitionsTool); + +interface DefinitionsResultProps extends BasePromptElementProps { + readonly definitions: readonly Location[]; + readonly filePath: string; + readonly requestedLine: number; + readonly requestedColumn: number; + readonly sourceUri: URI; +} + +class DefinitionsResult extends PromptElement { + constructor( + props: PromptElementProps, + @IPromptPathRepresentationService private readonly promptPathService: IPromptPathRepresentationService, + ) { + super(props); + } + + override render(state: void, sizing: PromptSizing): PromptPiece | undefined { + const { definitions, requestedLine, requestedColumn, filePath } = this.props; + + if (definitions.length === 0) { + return + {l10n.t('No definitions found near line {0}, column {1}.', requestedLine, requestedColumn)} + ; + } + + const header = definitions.length === 1 ? + l10n.t('Found 1 definition near line {0}, column {1}.', requestedLine, requestedColumn) : + l10n.t('Found {0} definitions near line {1}, column {2}.', definitions.length, requestedLine, requestedColumn); + + return + {header} + {definitions.map((definition, index) => this.renderDefinition(definition, index))} + ; + } + + private renderDefinition(definition: Location, index: number): PromptPiece { + const targetPath = this.promptPathService.getFilePath(definition.uri); + const line = definition.range.start.line + 1; + const column = definition.range.start.character + 1; + + return + + {targetPath}, line {line}, col {column} + ; + } +} + +function normalizeLocations(locations: readonly (vscode.Location | vscode.LocationLink)[]): Location[] { + return locations.map(location => location instanceof Location ? location : new Location(location.targetUri, location.targetSelectionRange ?? location.targetRange)); +} + +function buildDefinitionsMessage(uri: URI, line: number, column: number, count: number): string { + const filePath = uri.toString(true); + if (count === 0) { + return l10n.t('No definitions found near line {0}, column {1} in {2}.', line, column, filePath); + } + + if (count === 1) { + return l10n.t('Found 1 definition near line {0}, column {1} in {2}.', line, column, filePath); + } + + return l10n.t('Found {0} definitions near line {1}, column {2} in {3}.', count, line, column, filePath); +} + +interface AmbiguityResultProps extends BasePromptElementProps { + readonly error: string; + readonly candidates: readonly SymbolCandidate[]; + readonly filePath: string; + readonly line: number; +} + +class AmbiguityResult extends PromptElement { + override render(): PromptPiece { + const { error, candidates, filePath, line } = this.props; + + return + {error} + Candidates: + {candidates.map((candidate, index) => ( + + - symbolId: {candidate.symbolId}, kind: {candidate.kind}, name: {candidate.name}, column: {candidate.column + 1} + + ))} + ; + } +} diff --git a/src/extension/tools/node/documentSymbolsTool.tsx b/src/extension/tools/node/documentSymbolsTool.tsx new file mode 100644 index 0000000000..a08757913a --- /dev/null +++ b/src/extension/tools/node/documentSymbolsTool.tsx @@ -0,0 +1,282 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { BasePromptElementProps, PromptElement, PromptPiece, PromptReference, PromptSizing, TextChunk } from '@vscode/prompt-tsx'; +import type * as vscode from 'vscode'; +import { ILanguageFeaturesService } from '../../../platform/languages/common/languageFeaturesService'; +import { IPromptPathRepresentationService } from '../../../platform/prompts/common/promptPathRepresentationService'; +import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; +import { CancellationToken } from '../../../util/vs/base/common/cancellation'; +import { URI } from '../../../util/vs/base/common/uri'; +import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { ExtendedLanguageModelToolResult, LanguageModelPromptTsxPart, Location, MarkdownString, SymbolKind } from '../../../vscodeTypes'; +import { IBuildPromptContext } from '../../prompt/common/intents'; +import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer'; +import { Tag } from '../../prompts/node/base/tag'; +import { ToolName } from '../common/toolNames'; +import { ICopilotTool, ToolRegistry } from '../common/toolsRegistry'; +import { assertFileOkForTool, checkCancellation, resolveToolInputPath } from './toolUtils'; + +interface IDocumentSymbolsToolParams { + filePath: string; + maxItems?: number; + pageSize?: number; + page?: number; + reset?: boolean; +} + +const DEFAULT_PAGE_SIZE = 40; +const MAX_PAGE_SIZE = 200; +interface FlattenedSymbol { + readonly name: string; + readonly kind: SymbolKind; + readonly detail: string | undefined; + readonly range: vscode.Range; + readonly depth: number; +} + +interface CachedSymbols { + readonly version: number; + readonly flattened: readonly FlattenedSymbol[]; +} + +class DocumentSymbolsTool implements ICopilotTool { + public static readonly toolName = ToolName.DocumentSymbols; + private _promptContext: IBuildPromptContext | undefined; + private readonly _symbolCache = new Map(); + + constructor( + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IWorkspaceService private readonly workspaceService: IWorkspaceService, + @IPromptPathRepresentationService private readonly promptPathService: IPromptPathRepresentationService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: CancellationToken) { + const { input } = options; + const uri = resolveToolInputPath(input.filePath, this.promptPathService); + const paging = normalizePagingOptions(input); + + await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri)); + + const document = await this.workspaceService.openTextDocument(uri); + const flattened = await this.getFlattenedSymbols(uri, document, paging.reset, token); + const totalSymbols = flattened.length; + const totalPages = Math.max(1, Math.ceil(totalSymbols / paging.pageSize)); + const currentPage = Math.min(paging.page, totalPages); + const pageStartIndex = totalSymbols === 0 ? 0 : (currentPage - 1) * paging.pageSize; + const pageEndExclusive = totalSymbols === 0 ? 0 : Math.min(pageStartIndex + paging.pageSize, totalSymbols); + const pageSymbols = flattened.slice(pageStartIndex, pageEndExclusive); + const hasMore = currentPage < totalPages; + const rangeStart = pageSymbols.length ? pageStartIndex + 1 : 0; + const rangeEnd = pageSymbols.length ? pageStartIndex + pageSymbols.length : 0; + const remainingPages = hasMore ? totalPages - currentPage : 0; + const filePath = this.promptPathService.getFilePath(uri); + + const prompt = await renderPromptElementJSON( + this.instantiationService, + DocumentSymbolsResult, + { + uri, + symbols: pageSymbols, + truncated: hasMore, + requested: totalSymbols, + maxItems: paging.pageSize, + filePath, + promptContext: this._promptContext, + page: currentPage, + totalPages, + totalSymbols, + pageSize: paging.pageSize, + rangeStart, + rangeEnd, + hasMore, + remainingPages, + }, + options.tokenizationOptions, + token + ); + + const result = new ExtendedLanguageModelToolResult([ + new LanguageModelPromptTsxPart(prompt) + ]); + + const toolMessage = buildResultMessage(uri, currentPage, totalPages, rangeStart, rangeEnd, totalSymbols, hasMore, remainingPages); + result.toolResultMessage = new MarkdownString(toolMessage); + result.toolResultDetails = pageSymbols.map(entry => new Location(uri, entry.range)); + + return result; + } + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions, _token: vscode.CancellationToken): Promise { + const { input } = options; + if (!input.filePath) { + return; + } + try { + const uri = resolveToolInputPath(input.filePath, this.promptPathService); + await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri)); + const document = await this.workspaceService.openTextDocument(uri); + const languageId = document.languageId; + const paging = normalizePagingOptions(input); + const filePath = this.promptPathService.getFilePath(uri); + return { + invocationMessage: new MarkdownString(l10n.t('Listing page {0} (size {1}) of document symbols from {2} ({3}).', paging.page, paging.pageSize, filePath, languageId)), + pastTenseMessage: new MarkdownString(l10n.t('Listed document symbols from {0}.', filePath)) + }; + } catch { + return; + } + } + + async resolveInput(input: IDocumentSymbolsToolParams, promptContext: IBuildPromptContext, _mode: unknown): Promise { + this._promptContext = promptContext; + const paging = normalizePagingOptions(input); + return { + ...input, + maxItems: paging.pageSize, + pageSize: paging.pageSize, + page: paging.page, + reset: paging.reset + }; + } + + private async getFlattenedSymbols(uri: URI, document: vscode.TextDocument, reset: boolean, token: CancellationToken): Promise { + const cacheKey = uri.toString(true); + const cached = this._symbolCache.get(cacheKey); + if (!reset && cached && cached.version === document.version) { + return cached.flattened; + } + + checkCancellation(token); + const symbols = await this.languageFeaturesService.getDocumentSymbols(uri) ?? []; + checkCancellation(token); + const flattened = flattenDocumentSymbols(symbols); + this._symbolCache.set(cacheKey, { version: document.version, flattened }); + return flattened; + } +} + +ToolRegistry.registerTool(DocumentSymbolsTool); + +interface DocumentSymbolsResultProps extends BasePromptElementProps { + readonly uri: URI; + readonly symbols: readonly FlattenedSymbol[]; + readonly truncated: boolean; + readonly requested: number; + readonly maxItems: number; + readonly filePath: string; + readonly promptContext: IBuildPromptContext | undefined; + readonly page: number; + readonly totalPages: number; + readonly totalSymbols: number; + readonly pageSize: number; + readonly rangeStart: number; + readonly rangeEnd: number; + readonly hasMore: boolean; + readonly remainingPages: number; +} + +class DocumentSymbolsResult extends PromptElement { + override render(state: void, sizing: PromptSizing): PromptPiece | undefined { + if (this.props.totalSymbols === 0) { + return <> + + {l10n.t('No document symbols were found in {0}.', this.props.filePath)} + + ; + } + + const header = this.props.rangeStart > 0 && this.props.rangeEnd > 0 ? + l10n.t('Page {0}/{1}: showing symbols {2}-{3} of {4}.', this.props.page, this.props.totalPages, this.props.rangeStart, this.props.rangeEnd, this.props.totalSymbols) : + l10n.t('Page {0}/{1}: no symbols in the requested range.', this.props.page, this.props.totalPages); + + return + {header} + {this.props.symbols.map((symbol: FlattenedSymbol, index: number) => this.renderSymbol(symbol, index))} + {this.props.hasMore && {l10n.t('More pages available ({0} remaining). Use "page": {1} to load the next page.', this.props.remainingPages, Math.min(this.props.page + 1, this.props.totalPages))}} + {this.props.page > 1 && {l10n.t('To refresh from the beginning, request "page": 1 or set "reset": true.')}} + ; + } + + private renderSymbol(symbol: FlattenedSymbol, index: number): PromptPiece { + const line = symbol.range.start.line + 1; + const character = symbol.range.start.character + 1; + const indent = ' '.repeat(symbol.depth); + const kind = symbolKindToString(symbol.kind); + const detail = symbol.detail ? ` – ${symbol.detail}` : ''; + + return + + {indent}- {symbol.name} ({kind}) at line {line}, col {character}{detail} + ; + } +} + +function flattenDocumentSymbols(symbols: readonly vscode.DocumentSymbol[], depth = 0): FlattenedSymbol[] { + const result: FlattenedSymbol[] = []; + for (const symbol of symbols) { + result.push({ + name: symbol.name, + kind: symbol.kind, + detail: symbol.detail, + range: symbol.range, + depth, + }); + + if (symbol.children && symbol.children.length) { + result.push(...flattenDocumentSymbols(symbol.children, depth + 1)); + } + } + + return result; +} + +function clampPageSize(value: number): number { + const normalized = Math.max(1, Math.floor(value)); + return Math.min(normalized, MAX_PAGE_SIZE); +} + +function clampPageNumber(value: number): number { + return Math.max(1, Math.floor(value)); +} + +function symbolKindToString(kind: vscode.SymbolKind): string { + const name = SymbolKind[kind]; + return name ?? 'Symbol'; +} + +function buildResultMessage(uri: URI, page: number, totalPages: number, rangeStart: number, rangeEnd: number, totalSymbols: number, hasMore: boolean, remainingPages: number): string { + const filePath = uri.toString(true); + if (totalSymbols === 0) { + return l10n.t('No document symbols found for {0}.', filePath); + } + + if (rangeStart === 0 || rangeEnd === 0) { + return l10n.t('Listed document symbols from {0} (page {1}/{2}).', filePath, page, totalPages); + } + + if (hasMore) { + return l10n.t('Listed symbols {0}-{1} of {2} from {3} (page {4}/{5}, {6} more pages available).', rangeStart, rangeEnd, totalSymbols, filePath, page, totalPages, remainingPages); + } + + return l10n.t('Listed symbols {0}-{1} of {2} from {3} (page {4}/{5}).', rangeStart, rangeEnd, totalSymbols, filePath, page, totalPages); +} + +interface NormalizedPagingOptions { + readonly pageSize: number; + readonly page: number; + readonly reset: boolean; +} + +function normalizePagingOptions(input: IDocumentSymbolsToolParams): NormalizedPagingOptions { + const requestedPageSize = input.pageSize ?? input.maxItems ?? DEFAULT_PAGE_SIZE; + const pageSize = clampPageSize(requestedPageSize); + const requestedPage = input.page ?? 1; + const page = clampPageNumber(requestedPage); + const reset = !!input.reset; + return { pageSize, page, reset }; +} diff --git a/src/extension/tools/node/implementationsTool.tsx b/src/extension/tools/node/implementationsTool.tsx new file mode 100644 index 0000000000..9ece9b0508 --- /dev/null +++ b/src/extension/tools/node/implementationsTool.tsx @@ -0,0 +1,229 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { BasePromptElementProps, PromptElement, PromptElementProps, PromptPiece, PromptReference, PromptSizing, TextChunk } from '@vscode/prompt-tsx'; +import type * as vscode from 'vscode'; +import { ILanguageFeaturesService } from '../../../platform/languages/common/languageFeaturesService'; +import { IPromptPathRepresentationService } from '../../../platform/prompts/common/promptPathRepresentationService'; +import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; +import { CancellationToken } from '../../../util/vs/base/common/cancellation'; +import { URI } from '../../../util/vs/base/common/uri'; +import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { ExtendedLanguageModelToolResult, LanguageModelPromptTsxPart, Location, MarkdownString } from '../../../vscodeTypes'; +import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer'; +import { Tag } from '../../prompts/node/base/tag'; +import { ToolName } from '../common/toolNames'; +import { ICopilotTool, ToolRegistry } from '../common/toolsRegistry'; +import { NormalizedSymbolPosition, SymbolAmbiguityError, SymbolCandidate, assertFileOkForTool, checkCancellation, normalizeSymbolPosition, resolveToolInputPath } from './toolUtils'; + +interface IImplementationsToolParams { + filePath: string; + line: number; + symbolName: string; + expectedKind: string; + symbolId?: string; +} + +class ImplementationsTool implements ICopilotTool { + public static readonly toolName = ToolName.Implementations; + + constructor( + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IWorkspaceService private readonly workspaceService: IWorkspaceService, + @IPromptPathRepresentationService private readonly promptPathService: IPromptPathRepresentationService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: CancellationToken) { + const { input } = options; + const uri = resolveToolInputPath(input.filePath, this.promptPathService); + + await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri)); + + const document = await this.workspaceService.openTextDocument(uri); + let normalizedPosition: NormalizedSymbolPosition; + try { + normalizedPosition = await this.normalizePosition(document, input.line, input.symbolName, input.expectedKind, input.symbolId, token); + } catch (error) { + if (error instanceof SymbolAmbiguityError) { + return this.returnAmbiguityError(error, uri, input.line, options.tokenizationOptions, token); + } + throw error; + } + + checkCancellation(token); + + const implementationsRaw = await this.languageFeaturesService.getImplementations(uri, normalizedPosition.position) ?? []; + checkCancellation(token); + const locations = normalizeLocations(implementationsRaw); + const filePath = this.promptPathService.getFilePath(uri); + + const prompt = await renderPromptElementJSON( + this.instantiationService, + ImplementationsResult, + { + implementations: locations, + filePath, + requestedLine: normalizedPosition.line, + requestedColumn: normalizedPosition.column, + sourceUri: uri + }, + options.tokenizationOptions, + token + ); + + const result = new ExtendedLanguageModelToolResult([ + new LanguageModelPromptTsxPart(prompt) + ]); + result.toolResultDetails = locations; + result.toolResultMessage = new MarkdownString(buildImplementationsMessage(uri, normalizedPosition.line, normalizedPosition.column, locations.length)); + + return result; + } + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions, _token: vscode.CancellationToken): Promise { + const { input } = options; + if (!input.filePath) { + return; + } + + try { + const uri = resolveToolInputPath(input.filePath, this.promptPathService); + await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri)); + const document = await this.workspaceService.openTextDocument(uri); + const normalized = await this.normalizePosition(document, input.line, input.symbolName, input.expectedKind, input.symbolId, CancellationToken.None); + const filePath = this.promptPathService.getFilePath(uri); + + return { + invocationMessage: new MarkdownString(l10n.t('Locating implementations for "{0}" in {1} at line {2}, column {3}.', input.symbolName, filePath, normalized.line, normalized.column)), + pastTenseMessage: new MarkdownString(l10n.t('Located implementations for "{0}" in {1}.', input.symbolName, filePath)) + }; + } catch { + return; + } + } + + private async normalizePosition(document: vscode.TextDocument, line: number, symbolName: string, expectedKind: string, symbolId: string | undefined, token: CancellationToken): Promise { + return await normalizeSymbolPosition(document, line, symbolName, expectedKind, symbolId, this.promptPathService, this.languageFeaturesService, token); + } + + private async returnAmbiguityError( + error: SymbolAmbiguityError, + uri: URI, + line: number, + tokenizationOptions: vscode.LanguageModelToolInvocationOptions['tokenizationOptions'], + token: CancellationToken + ): Promise { + const filePath = this.promptPathService.getFilePath(uri); + const prompt = await renderPromptElementJSON( + this.instantiationService, + AmbiguityResult, + { + error: error.message, + candidates: error.candidates, + filePath, + line + }, + tokenizationOptions, + token + ); + + const result = new ExtendedLanguageModelToolResult([ + new LanguageModelPromptTsxPart(prompt) + ]); + result.toolResultMessage = new MarkdownString(error.message); + return result; + } +} + +ToolRegistry.registerTool(ImplementationsTool); + +interface ImplementationsResultProps extends BasePromptElementProps { + readonly implementations: readonly Location[]; + readonly filePath: string; + readonly requestedLine: number; + readonly requestedColumn: number; + readonly sourceUri: URI; +} + +class ImplementationsResult extends PromptElement { + constructor( + props: PromptElementProps, + @IPromptPathRepresentationService private readonly promptPathService: IPromptPathRepresentationService, + ) { + super(props); + } + + override render(state: void, sizing: PromptSizing): PromptPiece | undefined { + const { implementations, requestedLine, requestedColumn, filePath } = this.props; + + if (implementations.length === 0) { + return + {l10n.t('No implementations found near line {0}, column {1}.', requestedLine, requestedColumn)} + ; + } + + const header = implementations.length === 1 ? + l10n.t('Found 1 implementation near line {0}, column {1}.', requestedLine, requestedColumn) : + l10n.t('Found {0} implementations near line {1}, column {2}.', implementations.length, requestedLine, requestedColumn); + + return + {header} + {implementations.map((implementation, index) => this.renderImplementation(implementation, index))} + ; + } + + private renderImplementation(implementation: Location, index: number): PromptPiece { + const targetPath = this.promptPathService.getFilePath(implementation.uri); + const line = implementation.range.start.line + 1; + const column = implementation.range.start.character + 1; + + return + + {targetPath}, line {line}, col {column} + ; + } +} + +function normalizeLocations(locations: readonly (vscode.Location | vscode.LocationLink)[]): Location[] { + return locations.map(location => location instanceof Location ? location : new Location(location.targetUri, location.targetSelectionRange ?? location.targetRange)); +} + +function buildImplementationsMessage(uri: URI, line: number, column: number, count: number): string { + const filePath = uri.toString(true); + if (count === 0) { + return l10n.t('No implementations found near line {0}, column {1} in {2}.', line, column, filePath); + } + + if (count === 1) { + return l10n.t('Found 1 implementation near line {0}, column {1} in {2}.', line, column, filePath); + } + + return l10n.t('Found {0} implementations near line {1}, column {2} in {3}.', count, line, column, filePath); +} + +interface AmbiguityResultProps extends BasePromptElementProps { + readonly error: string; + readonly candidates: readonly SymbolCandidate[]; + readonly filePath: string; + readonly line: number; +} + +class AmbiguityResult extends PromptElement { + override render(): PromptPiece { + const { error, candidates, filePath, line } = this.props; + + return + {error} + Candidates: + {candidates.map((candidate, index) => ( + + - symbolId: {candidate.symbolId}, kind: {candidate.kind}, name: {candidate.name}, column: {candidate.column + 1} + + ))} + ; + } +} diff --git a/src/extension/tools/node/referencesTool.tsx b/src/extension/tools/node/referencesTool.tsx new file mode 100644 index 0000000000..7133ad9aac --- /dev/null +++ b/src/extension/tools/node/referencesTool.tsx @@ -0,0 +1,257 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { BasePromptElementProps, PromptElement, PromptElementProps, PromptPiece, PromptReference, PromptSizing, TextChunk } from '@vscode/prompt-tsx'; +import type * as vscode from 'vscode'; +import { ILanguageFeaturesService } from '../../../platform/languages/common/languageFeaturesService'; +import { IPromptPathRepresentationService } from '../../../platform/prompts/common/promptPathRepresentationService'; +import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; +import { CancellationToken } from '../../../util/vs/base/common/cancellation'; +import { URI } from '../../../util/vs/base/common/uri'; +import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { ExtendedLanguageModelToolResult, LanguageModelPromptTsxPart, Location, MarkdownString } from '../../../vscodeTypes'; +import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer'; +import { Tag } from '../../prompts/node/base/tag'; +import { ToolName } from '../common/toolNames'; +import { ICopilotTool, ToolRegistry } from '../common/toolsRegistry'; +import { NormalizedSymbolPosition, SymbolAmbiguityError, SymbolCandidate, assertFileOkForTool, checkCancellation, normalizeSymbolPosition, resolveToolInputPath } from './toolUtils'; + +interface IReferencesToolParams { + filePath: string; + line: number; + symbolName: string; + expectedKind: string; + symbolId?: string; +} + +class ReferencesTool implements ICopilotTool { + public static readonly toolName = ToolName.References; + + constructor( + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IWorkspaceService private readonly workspaceService: IWorkspaceService, + @IPromptPathRepresentationService private readonly promptPathService: IPromptPathRepresentationService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { } + + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: CancellationToken) { + const { input } = options; + const uri = resolveToolInputPath(input.filePath, this.promptPathService); + + await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri)); + + const document = await this.workspaceService.openTextDocument(uri); + let normalizedPosition: NormalizedSymbolPosition; + try { + normalizedPosition = await this.normalizePosition(document, input.line, input.symbolName, input.expectedKind, input.symbolId, token); + } catch (error) { + if (error instanceof SymbolAmbiguityError) { + return this.returnAmbiguityError(error, uri, input.line, options.tokenizationOptions, token); + } + throw error; + } + + checkCancellation(token); + + const referencesRaw = await this.languageFeaturesService.getReferences(uri, normalizedPosition.position) ?? []; + checkCancellation(token); + const locations = uniqueLocations(referencesRaw.map(reference => new Location(reference.uri, reference.range))); + const sortedLocations = sortLocations(locations); + const filePath = this.promptPathService.getFilePath(uri); + + const prompt = await renderPromptElementJSON( + this.instantiationService, + ReferencesResult, + { + references: sortedLocations, + filePath, + requestedLine: normalizedPosition.line, + requestedColumn: normalizedPosition.column, + sourceUri: uri + }, + options.tokenizationOptions, + token + ); + + const result = new ExtendedLanguageModelToolResult([ + new LanguageModelPromptTsxPart(prompt) + ]); + result.toolResultDetails = sortedLocations; + result.toolResultMessage = new MarkdownString(buildReferencesMessage(uri, normalizedPosition.line, normalizedPosition.column, sortedLocations.length)); + + return result; + } + + async prepareInvocation(options: vscode.LanguageModelToolInvocationPrepareOptions, _token: vscode.CancellationToken): Promise { + const { input } = options; + if (!input.filePath) { + return; + } + + try { + const uri = resolveToolInputPath(input.filePath, this.promptPathService); + await this.instantiationService.invokeFunction(accessor => assertFileOkForTool(accessor, uri)); + const document = await this.workspaceService.openTextDocument(uri); + const normalized = await this.normalizePosition(document, input.line, input.symbolName, input.expectedKind, input.symbolId, CancellationToken.None); + const filePath = this.promptPathService.getFilePath(uri); + + return { + invocationMessage: new MarkdownString(l10n.t('Locating references for "{0}" in {1} at line {2}, column {3}.', input.symbolName, filePath, normalized.line, normalized.column)), + pastTenseMessage: new MarkdownString(l10n.t('Located references for "{0}" in {1}.', input.symbolName, filePath)) + }; + } catch { + return; + } + } + + private async normalizePosition(document: vscode.TextDocument, line: number, symbolName: string, expectedKind: string, symbolId: string | undefined, token: CancellationToken): Promise { + return await normalizeSymbolPosition(document, line, symbolName, expectedKind, symbolId, this.promptPathService, this.languageFeaturesService, token); + } + + private async returnAmbiguityError( + error: SymbolAmbiguityError, + uri: URI, + line: number, + tokenizationOptions: vscode.LanguageModelToolInvocationOptions['tokenizationOptions'], + token: CancellationToken + ): Promise { + const filePath = this.promptPathService.getFilePath(uri); + const prompt = await renderPromptElementJSON( + this.instantiationService, + AmbiguityResult, + { + error: error.message, + candidates: error.candidates, + filePath, + line + }, + tokenizationOptions, + token + ); + + const result = new ExtendedLanguageModelToolResult([ + new LanguageModelPromptTsxPart(prompt) + ]); + result.toolResultMessage = new MarkdownString(error.message); + return result; + } +} + +ToolRegistry.registerTool(ReferencesTool); + +interface ReferencesResultProps extends BasePromptElementProps { + readonly references: readonly Location[]; + readonly filePath: string; + readonly requestedLine: number; + readonly requestedColumn: number; + readonly sourceUri: URI; +} + +class ReferencesResult extends PromptElement { + constructor( + props: PromptElementProps, + @IPromptPathRepresentationService private readonly promptPathService: IPromptPathRepresentationService, + ) { + super(props); + } + + override render(state: void, sizing: PromptSizing): PromptPiece | undefined { + const { references, requestedLine, requestedColumn, filePath } = this.props; + + if (references.length === 0) { + return + {l10n.t('No references found near line {0}, column {1}.', requestedLine, requestedColumn)} + ; + } + + const header = references.length === 1 ? + l10n.t('Found 1 reference near line {0}, column {1}.', requestedLine, requestedColumn) : + l10n.t('Found {0} references near line {1}, column {2}.', references.length, requestedLine, requestedColumn); + + return + {header} + {references.map((reference, index) => this.renderReference(reference, index))} + ; + } + + private renderReference(reference: Location, index: number): PromptPiece { + const line = reference.range.start.line + 1; + const column = reference.range.start.character + 1; + const targetPath = this.promptPathService.getFilePath(reference.uri); + + return + + {targetPath}, line {line}, col {column} + ; + } +} + +function uniqueLocations(locations: readonly Location[]): Location[] { + const seen = new Set(); + const result: Location[] = []; + for (const location of locations) { + const key = `${location.uri.toString(true)}:${location.range.start.line}:${location.range.start.character}:${location.range.end.line}:${location.range.end.character}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(location); + } + + return result; +} + +function sortLocations(locations: readonly Location[]): Location[] { + return [...locations].sort((a, b) => { + const pathA = a.uri.toString(true); + const pathB = b.uri.toString(true); + if (pathA !== pathB) { + return pathA.localeCompare(pathB); + } + + if (a.range.start.line !== b.range.start.line) { + return a.range.start.line - b.range.start.line; + } + + return a.range.start.character - b.range.start.character; + }); +} + +function buildReferencesMessage(uri: URI, line: number, column: number, count: number): string { + const filePath = uri.toString(true); + if (count === 0) { + return l10n.t('No references found near line {0}, column {1} in {2}.', line, column, filePath); + } + + if (count === 1) { + return l10n.t('Found 1 reference near line {0}, column {1} in {2}.', line, column, filePath); + } + + return l10n.t('Found {0} references near line {1}, column {2} in {3}.', count, line, column, filePath); +} + +interface AmbiguityResultProps extends BasePromptElementProps { + readonly error: string; + readonly candidates: readonly SymbolCandidate[]; + readonly filePath: string; + readonly line: number; +} + +class AmbiguityResult extends PromptElement { + override render(): PromptPiece { + const { error, candidates, filePath, line } = this.props; + + return + {error} + Candidates: + {candidates.map((candidate, index) => ( + + - symbolId: {candidate.symbolId}, kind: {candidate.kind}, name: {candidate.name}, column: {candidate.column + 1} + + ))} + ; + } +} diff --git a/src/extension/tools/node/toolUtils.ts b/src/extension/tools/node/toolUtils.ts index ee397996d2..478f32ce2e 100644 --- a/src/extension/tools/node/toolUtils.ts +++ b/src/extension/tools/node/toolUtils.ts @@ -8,6 +8,7 @@ import type * as vscode from 'vscode'; import { ICustomInstructionsService } from '../../../platform/customInstructions/common/customInstructionsService'; import { RelativePattern } from '../../../platform/filesystem/common/fileTypes'; import { IIgnoreService } from '../../../platform/ignore/common/ignoreService'; +import { ILanguageFeaturesService } from '../../../platform/languages/common/languageFeaturesService'; import { IPromptPathRepresentationService } from '../../../platform/prompts/common/promptPathRepresentationService'; import { ITabsAndEditorsService } from '../../../platform/tabs/common/tabsAndEditorsService'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; @@ -18,7 +19,7 @@ import { isAbsolute } from '../../../util/vs/base/common/path'; import { isEqual, normalizePath } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation'; -import { LanguageModelPromptTsxPart, LanguageModelToolResult } from '../../../vscodeTypes'; +import { LanguageModelPromptTsxPart, LanguageModelToolResult, Position, SymbolKind } from '../../../vscodeTypes'; import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer'; export function checkCancellation(token: CancellationToken): void { @@ -123,3 +124,212 @@ export async function assertFileNotContentExcluded(accessor: ServicesAccessor, u throw new Error(`File ${promptPathRepresentationService.getFilePath(uri)} is configured to be ignored by Copilot`); } } + +export interface NormalizedSymbolPosition { + readonly position: Position; + readonly line: number; + readonly column: number; + readonly symbolId?: string; +} + +export interface SymbolCandidate { + readonly symbolId: string; + readonly kind: string; + readonly name: string; + readonly column: number; +} + +export class SymbolAmbiguityError extends Error { + constructor( + message: string, + public readonly candidates: SymbolCandidate[] + ) { + super(message); + this.name = 'SymbolAmbiguityError'; + } +} + +export async function normalizeSymbolPosition( + document: vscode.TextDocument, + line: number, + symbolName: string, + expectedKind: string, + symbolId: string | undefined, + promptPathService: IPromptPathRepresentationService, + languageFeaturesService: ILanguageFeaturesService, + token: CancellationToken +): Promise { + const normalizedLine = normalizePositiveInteger(line, 'line'); + const zeroBasedLine = normalizedLine - 1; + if (zeroBasedLine < 0 || zeroBasedLine >= document.lineCount) { + throw new Error(`Line ${normalizedLine} is outside the range of ${promptPathService.getFilePath(document.uri)} (file has ${document.lineCount} lines).`); + } + + const normalizedSymbolName = symbolName?.trim(); + if (!normalizedSymbolName) { + throw new Error(`A non-empty symbolName is required to locate the symbol in ${promptPathService.getFilePath(document.uri)}.`); + } + + // Get all document symbols + const allSymbols = await languageFeaturesService.getDocumentSymbols(document.uri); + checkCancellation(token); + + // Find all symbols on the requested line with matching name + const candidates = findSymbolsOnLine(allSymbols, zeroBasedLine, normalizedSymbolName); + + // If symbolId is provided, use it for direct lookup + if (symbolId) { + const match = candidates.find(c => c.symbolId === symbolId); + if (!match) { + throw new Error(`Symbol with ID "${symbolId}" was not found on line ${normalizedLine} of ${promptPathService.getFilePath(document.uri)}.`); + } + return { + position: new Position(zeroBasedLine, match.column), + line: normalizedLine, + column: match.column + 1, + symbolId: match.symbolId, + }; + } + + // Filter by expectedKind if provided + const filteredCandidates = candidates.filter(c => symbolKindMatches(c.kind, expectedKind)); + + // Case A: No candidates found + if (filteredCandidates.length === 0) { + if (candidates.length > 0) { + // Found symbols with the name but wrong kind + throw new Error(`No symbol named "${normalizedSymbolName}" with kind "${expectedKind}" found on line ${normalizedLine} of ${promptPathService.getFilePath(document.uri)}.`); + } + // Fallback to text search for backwards compatibility + const lineText = document.lineAt(zeroBasedLine).text; + const symbolColumn = findSymbolColumnIndex(lineText, normalizedSymbolName); + if (symbolColumn === undefined) { + throw new Error(`Symbol "${normalizedSymbolName}" was not found on line ${normalizedLine} of ${promptPathService.getFilePath(document.uri)}.`); + } + return { + position: new Position(zeroBasedLine, symbolColumn), + line: normalizedLine, + column: symbolColumn + 1, + }; + } + + // Case B: Exactly one candidate - use it + if (filteredCandidates.length === 1) { + const candidate = filteredCandidates[0]; + return { + position: new Position(zeroBasedLine, candidate.column), + line: normalizedLine, + column: candidate.column + 1, + symbolId: candidate.symbolId, + }; + } + + // Case C: Multiple candidates - return ambiguity error + throw new SymbolAmbiguityError( + `Multiple symbols named "${normalizedSymbolName}" found on line ${normalizedLine} of ${promptPathService.getFilePath(document.uri)}. Please specify expectedKind or use symbolId from this response.`, + filteredCandidates + ); +} + +function findSymbolsOnLine(symbols: vscode.DocumentSymbol[], line: number, name: string): SymbolCandidate[] { + const candidates: SymbolCandidate[] = []; + + function visit(symbol: vscode.DocumentSymbol, parentPath: string = '') { + // Check if symbol is on the target line + if (symbol.range.start.line <= line && symbol.range.end.line >= line) { + // Check if symbol name matches + if (symbol.name === name && symbol.selectionRange.start.line === line) { + const symbolPath = parentPath ? `${parentPath}.${symbol.name}` : symbol.name; + candidates.push({ + symbolId: `${symbolPath}:${symbol.range.start.line}:${symbol.range.start.character}`, + kind: SymbolKind[symbol.kind], + name: symbol.name, + column: symbol.selectionRange.start.character, + }); + } + + // Recursively check children + if (symbol.children) { + const symbolPath = parentPath ? `${parentPath}.${symbol.name}` : symbol.name; + for (const child of symbol.children) { + visit(child, symbolPath); + } + } + } + } + + for (const symbol of symbols) { + visit(symbol); + } + + return candidates; +} + +function symbolKindMatches(symbolKind: string, expectedKind: string): boolean { + // Normalize both to lowercase for comparison + const normalizedSymbolKind = symbolKind.toLowerCase(); + const normalizedExpectedKind = expectedKind.toLowerCase(); + + // Direct match + if (normalizedSymbolKind === normalizedExpectedKind) { + return true; + } + + // Map common aliases + const kindAliases: Record = { + 'type': ['class', 'interface', 'enum', 'struct'], + 'class': ['type'], + 'interface': ['type'], + 'enum': ['type'], + 'function': ['method'], + 'method': ['function'], + 'variable': ['field', 'property', 'local'], + 'field': ['variable', 'property'], + 'property': ['variable', 'field'], + 'local': ['variable'], + 'parameter': ['variable'], + 'constant': ['variable', 'field'], + }; + + const aliases = kindAliases[normalizedExpectedKind] || []; + return aliases.includes(normalizedSymbolKind); +} + +function findSymbolColumnIndex(lineText: string, symbolName: string): number | undefined { + const matches = collectSymbolMatches(lineText, symbolName); + for (const candidate of matches) { + if (isWholeIdentifierMatch(lineText, candidate, symbolName.length)) { + return candidate; + } + } + + return matches[0]; +} + +function collectSymbolMatches(lineText: string, symbolName: string): number[] { + const matches: number[] = []; + let index = lineText.indexOf(symbolName); + while (index !== -1) { + matches.push(index); + index = lineText.indexOf(symbolName, index + Math.max(symbolName.length, 1)); + } + return matches; +} + +function isWholeIdentifierMatch(lineText: string, start: number, length: number): boolean { + const before = start === 0 ? undefined : lineText.charAt(start - 1); + const after = start + length >= lineText.length ? undefined : lineText.charAt(start + length); + return !isIdentifierCharacter(before) && !isIdentifierCharacter(after); +} + +function isIdentifierCharacter(char: string | undefined): boolean { + return !!char && /[A-Za-z0-9_$]/.test(char); +} + +function normalizePositiveInteger(value: number, field: string): number { + if (!Number.isFinite(value)) { + throw new Error(`The ${field} value must be a finite number.`); + } + + return Math.max(1, Math.floor(value)); +} diff --git a/src/extension/tools/vscode-node/tools.ts b/src/extension/tools/vscode-node/tools.ts index e46425040b..6c45bc38dd 100644 --- a/src/extension/tools/vscode-node/tools.ts +++ b/src/extension/tools/vscode-node/tools.ts @@ -25,7 +25,11 @@ export class ToolsContribution extends Disposable { ) { super(); + const extensionVersion = vscode.extensions.getExtension('github.copilot-chat')?.packageJSON?.version; + console.log(`[CopilotTools] registering tools for github.copilot-chat@${extensionVersion ?? 'unknown'}`); + for (const [name, tool] of toolsService.copilotTools) { + console.log(`[CopilotTools] registering ${name}`); this._register(vscode.lm.registerTool(getContributedToolName(name), tool)); }