diff --git a/chat-sample/.vscode/settings.json b/chat-sample/.vscode/settings.json new file mode 100644 index 00000000..1d83e076 --- /dev/null +++ b/chat-sample/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "search.exclude": { + "out": true + }, + "git.branchProtection": [ + "main" + ], +} \ No newline at end of file diff --git a/chat-sample/README.md b/chat-sample/README.md index ba8b4373..250d00f6 100644 --- a/chat-sample/README.md +++ b/chat-sample/README.md @@ -8,8 +8,9 @@ When an extension uses the Chat or the Language Model API, we call it a GitHub C This GitHub Copilot Extension sample shows: -- How to contribute a chat participant to the GitHub Copilot Chat view. +- How to contribute a simple chat participant to the GitHub Copilot Chat view. - How to use the Language Model API to request access to the Language Model (gpt-4o, gpt-3.5-turbo, gpt-4). +- How to contribute a more sophisticated chat participant view that uses the LanguageModelTool API to contribute and invoke tools. ![demo](./demo.png) @@ -24,3 +25,11 @@ Documentation can be found here: - Start a task `npm: watch` to compile the code - Run the extension in a new VS Code window - You will see the @cat chat participant show in the GitHub Copilot Chat view + +## About this sample + +This sample shows two different ways to build a chat participant in VS Code: + +See [simple.ts](src/simple.ts) for an example of a simple chat participant that makes requests and responds to user queries. It shows how you can create chat participants with or without the [@vscode/prompt-tsx](https://www.npmjs.com/package/@vscode/prompt-tsx) library. + +See [toolParticipant.ts](src/toolParticipant.ts) for an example of a chat participant that invokes tools, either dynamically or using the `toolReferences` that are attached to the request. This is a more advanced example that shows how you can use the [@vscode/prompt-tsx](https://www.npmjs.com/package/@vscode/prompt-tsx) library to implement the LLM tool calling flow and tries to implement all the features of the chat API. \ No newline at end of file diff --git a/chat-sample/package-lock.json b/chat-sample/package-lock.json index a7f6123f..c9aaaa3f 100644 --- a/chat-sample/package-lock.json +++ b/chat-sample/package-lock.json @@ -8,13 +8,12 @@ "name": "chat-sample", "version": "0.1.0", "dependencies": { - "@vscode/prompt-tsx": "^0.3.0-alpha" + "@vscode/prompt-tsx": "^0.3.0-alpha.12" }, "devDependencies": { "@eslint/js": "^9.13.0", "@stylistic/eslint-plugin": "^2.9.0", "@types/node": "^20", - "@types/vscode": "1.94.0", "eslint": "^9.13.0", "typescript": "^5.6.2", "typescript-eslint": "^8.11.0" @@ -279,12 +278,6 @@ "undici-types": "~5.26.4" } }, - "node_modules/@types/vscode": { - "version": "1.94.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.94.0.tgz", - "integrity": "sha512-UyQOIUT0pb14XSqJskYnRwD2aG0QrPVefIfrW1djR+/J4KeFQ0i1+hjZoaAmeNf3Z2jleK+R2hv+EboG/m8ruw==", - "dev": true - }, "node_modules/@typescript-eslint/type-utils": { "version": "8.11.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.11.0.tgz", diff --git a/chat-sample/package.json b/chat-sample/package.json index 460c5e34..d40977ba 100644 --- a/chat-sample/package.json +++ b/chat-sample/package.json @@ -55,6 +55,89 @@ ] } ] + }, + { + "id": "chat-tools-sample.tools", + "fullName": "Tool User", + "name": "tools", + "description": "I use tools", + "isSticky": true, + "commands": [ + { + "name": "list", + "description": "List all available tools" + }, + { + "name": "all", + "description": "Use all registered tools. By default, only this extension's tools are used." + } + ] + } + ], + "languageModelTools": [ + { + "name": "chat-tools-sample_tabCount", + "tags": [ + "editors", + "chat-tools-sample" + ], + "toolReferenceName": "tabCount", + "displayName": "Tab Count", + "modelDescription": "The number of active tabs in a tab group", + "icon": "$(files)", + "inputSchema": { + "type": "object", + "properties": { + "tabGroup": { + "type": "number", + "description": "The index of the tab group to check. This is optional- if not specified, the active tab group will be checked.", + "default": 0 + } + } + } + }, + { + "name": "chat-tools-sample_findFiles", + "tags": [ + "files", + "search", + "chat-tools-sample" + ], + "displayName": "Find Files", + "modelDescription": "Search for files in the current workspace", + "inputSchema": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Search for files that match this glob pattern" + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "chat-tools-sample_runInTerminal", + "tags": [ + "terminal", + "chat-tools-sample" + ], + "displayName": "Run in Terminal", + "modelDescription": "Run a command in a terminal and return the output", + "inputSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The command to run" + } + }, + "required": [ + "command" + ] + } } ], "commands": [ @@ -72,15 +155,14 @@ "watch": "tsc -watch -p ./" }, "dependencies": { - "@vscode/prompt-tsx": "^0.3.0-alpha" + "@vscode/prompt-tsx": "^0.3.0-alpha.12" }, "devDependencies": { "@eslint/js": "^9.13.0", "@stylistic/eslint-plugin": "^2.9.0", "@types/node": "^20", - "@types/vscode": "1.94.0", "eslint": "^9.13.0", - "typescript-eslint": "^8.11.0", - "typescript": "^5.6.2" + "typescript": "^5.6.2", + "typescript-eslint": "^8.11.0" } -} \ No newline at end of file +} diff --git a/chat-sample/src/extension.ts b/chat-sample/src/extension.ts index 486e1ac5..24b5927a 100644 --- a/chat-sample/src/extension.ts +++ b/chat-sample/src/extension.ts @@ -1,224 +1,18 @@ -import { renderPrompt } from '@vscode/prompt-tsx'; import * as vscode from 'vscode'; -import { PlayPrompt } from './play'; - -const CAT_NAMES_COMMAND_ID = 'cat.namesInEditor'; -const CAT_PARTICIPANT_ID = 'chat-sample.cat'; - -interface ICatChatResult extends vscode.ChatResult { - metadata: { - command: string; - } -} +import { FindFilesTool, RunInTerminalTool, TabCountTool } from './tools'; +import { registerToolUserChatParticipant } from './toolParticipant'; +import { registerSimpleParticipant } from './simple'; export function activate(context: vscode.ExtensionContext) { - - // Define a Cat chat handler. - const handler: vscode.ChatRequestHandler = async (request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise => { - // To talk to an LLM in your subcommand handler implementation, your - // extension can use VS Code's `requestChatAccess` API to access the Copilot API. - // The GitHub Copilot Chat extension implements this provider. - if (request.command === 'randomTeach') { - stream.progress('Picking the right topic to teach...'); - const topic = getTopic(context.history); - try { - const messages = [ - vscode.LanguageModelChatMessage.User('You are a cat! Your job is to explain computer science concepts in the funny manner of a cat. Always start your response by stating what concept you are explaining. Always include code samples.'), - vscode.LanguageModelChatMessage.User(topic) - ]; - - const chatResponse = await request.model.sendRequest(messages, {}, token); - for await (const fragment of chatResponse.text) { - stream.markdown(fragment); - } - - } catch (err) { - handleError(logger, err, stream); - } - - stream.button({ - command: CAT_NAMES_COMMAND_ID, - title: vscode.l10n.t('Use Cat Names in Editor') - }); - - logger.logUsage('request', { kind: 'randomTeach' }); - return { metadata: { command: 'randomTeach' } }; - } else if (request.command === 'play') { - stream.progress('Throwing away the computer science books and preparing to play with some Python code...'); - try { - // Here's an example of how to use the prompt-tsx library to build a prompt - const { messages } = await renderPrompt( - PlayPrompt, - { userQuery: request.prompt }, - { modelMaxPromptTokens: request.model.maxInputTokens }, - request.model); - - const chatResponse = await request.model.sendRequest(messages, {}, token); - for await (const fragment of chatResponse.text) { - stream.markdown(fragment); - } - - } catch (err) { - handleError(logger, err, stream); - } - - logger.logUsage('request', { kind: 'play' }); - return { metadata: { command: 'play' } }; - } else { - try { - const messages = [ - vscode.LanguageModelChatMessage.User(`You are a cat! Think carefully and step by step like a cat would. - Your job is to explain computer science concepts in the funny manner of a cat, using cat metaphors. Always start your response by stating what concept you are explaining. Always include code samples.`), - vscode.LanguageModelChatMessage.User(request.prompt) - ]; - - const chatResponse = await request.model.sendRequest(messages, {}, token); - for await (const fragment of chatResponse.text) { - // Process the output from the language model - // Replace all python function definitions with cat sounds to make the user stop looking at the code and start playing with the cat - const catFragment = fragment.replaceAll('def', 'meow'); - stream.markdown(catFragment); - } - } catch (err) { - handleError(logger, err, stream); - } - - logger.logUsage('request', { kind: '' }); - return { metadata: { command: '' } }; - } - }; - - // Chat participants appear as top-level options in the chat input - // when you type `@`, and can contribute sub-commands in the chat input - // that appear when you type `/`. - const cat = vscode.chat.createChatParticipant(CAT_PARTICIPANT_ID, handler); - cat.iconPath = vscode.Uri.joinPath(context.extensionUri, 'cat.jpeg'); - cat.followupProvider = { - provideFollowups(result: ICatChatResult, context: vscode.ChatContext, token: vscode.CancellationToken) { - return [{ - prompt: 'let us play', - label: vscode.l10n.t('Play with the cat'), - command: 'play' - } satisfies vscode.ChatFollowup]; - } - }; - - const logger = vscode.env.createTelemetryLogger({ - sendEventData(eventName, data) { - // Capture event telemetry - console.log(`Event: ${eventName}`); - console.log(`Data: ${JSON.stringify(data)}`); - }, - sendErrorData(error, data) { - // Capture error telemetry - console.error(`Error: ${error}`); - console.error(`Data: ${JSON.stringify(data)}`); - } - }); - - context.subscriptions.push(cat.onDidReceiveFeedback((feedback: vscode.ChatResultFeedback) => { - // Log chat result feedback to be able to compute the success matric of the participant - // unhelpful / totalRequests is a good success metric - logger.logUsage('chatResultFeedback', { - kind: feedback.kind - }); - })); - - context.subscriptions.push( - cat, - // Register the command handler for the /meow followup - vscode.commands.registerTextEditorCommand(CAT_NAMES_COMMAND_ID, async (textEditor: vscode.TextEditor) => { - // Replace all variables in active editor with cat names and words - const text = textEditor.document.getText(); - - let chatResponse: vscode.LanguageModelChatResponse | undefined; - try { - // Use gpt-4o since it is fast and high quality. - const [model] = await vscode.lm.selectChatModels({ vendor: 'copilot', family: 'gpt-4o' }); - if (!model) { - console.log('Model not found. Please make sure the GitHub Copilot Chat extension is installed and enabled.'); - return; - } - - const messages = [ - vscode.LanguageModelChatMessage.User(`You are a cat! Think carefully and step by step like a cat would. - Your job is to replace all variable names in the following code with funny cat variable names. Be creative. IMPORTANT respond just with code. Do not use markdown!`), - vscode.LanguageModelChatMessage.User(text) - ]; - chatResponse = await model.sendRequest(messages, {}, new vscode.CancellationTokenSource().token); - - } catch (err) { - if (err instanceof vscode.LanguageModelError) { - console.log(err.message, err.code, err.cause); - } else { - throw err; - } - return; - } - - // Clear the editor content before inserting new content - await textEditor.edit(edit => { - const start = new vscode.Position(0, 0); - const end = new vscode.Position(textEditor.document.lineCount - 1, textEditor.document.lineAt(textEditor.document.lineCount - 1).text.length); - edit.delete(new vscode.Range(start, end)); - }); - - // Stream the code into the editor as it is coming in from the Language Model - try { - for await (const fragment of chatResponse.text) { - await textEditor.edit(edit => { - const lastLine = textEditor.document.lineAt(textEditor.document.lineCount - 1); - const position = new vscode.Position(lastLine.lineNumber, lastLine.text.length); - edit.insert(position, fragment); - }); - } - } catch (err) { - // async response stream may fail, e.g network interruption or server side error - await textEditor.edit(edit => { - const lastLine = textEditor.document.lineAt(textEditor.document.lineCount - 1); - const position = new vscode.Position(lastLine.lineNumber, lastLine.text.length); - edit.insert(position, (err).message); - }); - } - }), - ); + registerSimpleParticipant(context); + registerChatTools(context); + registerToolUserChatParticipant(context); } -function handleError(logger: vscode.TelemetryLogger, err: any, stream: vscode.ChatResponseStream): void { - // making the chat request might fail because - // - model does not exist - // - user consent not given - // - quote limits exceeded - logger.logError(err); - - if (err instanceof vscode.LanguageModelError) { - console.log(err.message, err.code, err.cause); - if (err.cause instanceof Error && err.cause.message.includes('off_topic')) { - stream.markdown(vscode.l10n.t('I\'m sorry, I can only explain computer science concepts.')); - } - } else { - // re-throw other errors so they show up in the UI - throw err; - } +function registerChatTools(context: vscode.ExtensionContext) { + context.subscriptions.push(vscode.lm.registerTool('chat-tools-sample_tabCount', new TabCountTool())); + context.subscriptions.push(vscode.lm.registerTool('chat-tools-sample_findFiles', new FindFilesTool())); + context.subscriptions.push(vscode.lm.registerTool('chat-tools-sample_runInTerminal', new RunInTerminalTool())); } -// Get a random topic that the cat has not taught in the chat history yet -function getTopic(history: ReadonlyArray): string { - const topics = ['linked list', 'recursion', 'stack', 'queue', 'pointers']; - // Filter the chat history to get only the responses from the cat - const previousCatResponses = history.filter(h => { - return h instanceof vscode.ChatResponseTurn && h.participant === CAT_PARTICIPANT_ID; - }) as vscode.ChatResponseTurn[]; - // Filter the topics to get only the topics that have not been taught by the cat yet - const topicsNoRepetition = topics.filter(topic => { - return !previousCatResponses.some(catResponse => { - return catResponse.response.some(r => { - return r instanceof vscode.ChatResponseMarkdownPart && r.value.value.includes(topic); - }); - }); - }); - - return topicsNoRepetition[Math.floor(Math.random() * topicsNoRepetition.length)] || 'I have taught you everything I know. Meow!'; -} - -export function deactivate() { } \ No newline at end of file +export function deactivate() { } diff --git a/chat-sample/src/simple.ts b/chat-sample/src/simple.ts new file mode 100644 index 00000000..1dd86d8f --- /dev/null +++ b/chat-sample/src/simple.ts @@ -0,0 +1,223 @@ +import { renderPrompt } from '@vscode/prompt-tsx'; +import * as vscode from 'vscode'; +import { PlayPrompt } from './play'; + +const CAT_NAMES_COMMAND_ID = 'cat.namesInEditor'; +const CAT_PARTICIPANT_ID = 'chat-sample.cat'; + +interface ICatChatResult extends vscode.ChatResult { + metadata: { + command: string; + } +} + +export function registerSimpleParticipant(context: vscode.ExtensionContext) { + + // Define a Cat chat handler. + const handler: vscode.ChatRequestHandler = async (request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise => { + // To talk to an LLM in your subcommand handler implementation, your + // extension can use VS Code's `requestChatAccess` API to access the Copilot API. + // The GitHub Copilot Chat extension implements this provider. + if (request.command === 'randomTeach') { + stream.progress('Picking the right topic to teach...'); + const topic = getTopic(context.history); + try { + const messages = [ + vscode.LanguageModelChatMessage.User('You are a cat! Your job is to explain computer science concepts in the funny manner of a cat. Always start your response by stating what concept you are explaining. Always include code samples.'), + vscode.LanguageModelChatMessage.User(topic) + ]; + + const chatResponse = await request.model.sendRequest(messages, {}, token); + for await (const fragment of chatResponse.text) { + stream.markdown(fragment); + } + + } catch (err) { + handleError(logger, err, stream); + } + + stream.button({ + command: CAT_NAMES_COMMAND_ID, + title: vscode.l10n.t('Use Cat Names in Editor') + }); + + logger.logUsage('request', { kind: 'randomTeach' }); + return { metadata: { command: 'randomTeach' } }; + } else if (request.command === 'play') { + stream.progress('Throwing away the computer science books and preparing to play with some Python code...'); + try { + // Here's an example of how to use the prompt-tsx library to build a prompt + const { messages } = await renderPrompt( + PlayPrompt, + { userQuery: request.prompt }, + { modelMaxPromptTokens: request.model.maxInputTokens }, + request.model); + + const chatResponse = await request.model.sendRequest(messages, {}, token); + for await (const fragment of chatResponse.text) { + stream.markdown(fragment); + } + + } catch (err) { + handleError(logger, err, stream); + } + + logger.logUsage('request', { kind: 'play' }); + return { metadata: { command: 'play' } }; + } else { + try { + const messages = [ + vscode.LanguageModelChatMessage.User(`You are a cat! Think carefully and step by step like a cat would. + Your job is to explain computer science concepts in the funny manner of a cat, using cat metaphors. Always start your response by stating what concept you are explaining. Always include code samples.`), + vscode.LanguageModelChatMessage.User(request.prompt) + ]; + + const chatResponse = await request.model.sendRequest(messages, {}, token); + for await (const fragment of chatResponse.text) { + // Process the output from the language model + // Replace all python function definitions with cat sounds to make the user stop looking at the code and start playing with the cat + const catFragment = fragment.replaceAll('def', 'meow'); + stream.markdown(catFragment); + } + } catch (err) { + handleError(logger, err, stream); + } + + logger.logUsage('request', { kind: '' }); + return { metadata: { command: '' } }; + } + }; + + // Chat participants appear as top-level options in the chat input + // when you type `@`, and can contribute sub-commands in the chat input + // that appear when you type `/`. + const cat = vscode.chat.createChatParticipant(CAT_PARTICIPANT_ID, handler); + cat.iconPath = vscode.Uri.joinPath(context.extensionUri, 'cat.jpeg'); + cat.followupProvider = { + provideFollowups(_result: ICatChatResult, _context: vscode.ChatContext, _token: vscode.CancellationToken) { + return [{ + prompt: 'let us play', + label: vscode.l10n.t('Play with the cat'), + command: 'play' + } satisfies vscode.ChatFollowup]; + } + }; + + const logger = vscode.env.createTelemetryLogger({ + sendEventData(eventName, data) { + // Capture event telemetry + console.log(`Event: ${eventName}`); + console.log(`Data: ${JSON.stringify(data)}`); + }, + sendErrorData(error, data) { + // Capture error telemetry + console.error(`Error: ${error}`); + console.error(`Data: ${JSON.stringify(data)}`); + } + }); + + context.subscriptions.push(cat.onDidReceiveFeedback((feedback: vscode.ChatResultFeedback) => { + // Log chat result feedback to be able to compute the success matric of the participant + // unhelpful / totalRequests is a good success metric + logger.logUsage('chatResultFeedback', { + kind: feedback.kind + }); + })); + + context.subscriptions.push( + cat, + // Register the command handler for the /meow followup + vscode.commands.registerTextEditorCommand(CAT_NAMES_COMMAND_ID, async (textEditor: vscode.TextEditor) => { + // Replace all variables in active editor with cat names and words + const text = textEditor.document.getText(); + + let chatResponse: vscode.LanguageModelChatResponse | undefined; + try { + // Use gpt-4o since it is fast and high quality. + const [model] = await vscode.lm.selectChatModels({ vendor: 'copilot', family: 'gpt-4o' }); + if (!model) { + console.log('Model not found. Please make sure the GitHub Copilot Chat extension is installed and enabled.'); + return; + } + + const messages = [ + vscode.LanguageModelChatMessage.User(`You are a cat! Think carefully and step by step like a cat would. + Your job is to replace all variable names in the following code with funny cat variable names. Be creative. IMPORTANT respond just with code. Do not use markdown!`), + vscode.LanguageModelChatMessage.User(text) + ]; + chatResponse = await model.sendRequest(messages, {}, new vscode.CancellationTokenSource().token); + + } catch (err) { + if (err instanceof vscode.LanguageModelError) { + console.log(err.message, err.code, err.cause); + } else { + throw err; + } + return; + } + + // Clear the editor content before inserting new content + await textEditor.edit(edit => { + const start = new vscode.Position(0, 0); + const end = new vscode.Position(textEditor.document.lineCount - 1, textEditor.document.lineAt(textEditor.document.lineCount - 1).text.length); + edit.delete(new vscode.Range(start, end)); + }); + + // Stream the code into the editor as it is coming in from the Language Model + try { + for await (const fragment of chatResponse.text) { + await textEditor.edit(edit => { + const lastLine = textEditor.document.lineAt(textEditor.document.lineCount - 1); + const position = new vscode.Position(lastLine.lineNumber, lastLine.text.length); + edit.insert(position, fragment); + }); + } + } catch (err) { + // async response stream may fail, e.g network interruption or server side error + await textEditor.edit(edit => { + const lastLine = textEditor.document.lineAt(textEditor.document.lineCount - 1); + const position = new vscode.Position(lastLine.lineNumber, lastLine.text.length); + edit.insert(position, (err as Error).message); + }); + } + }), + ); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function handleError(logger: vscode.TelemetryLogger, err: any, stream: vscode.ChatResponseStream): void { + // making the chat request might fail because + // - model does not exist + // - user consent not given + // - quote limits exceeded + logger.logError(err); + + if (err instanceof vscode.LanguageModelError) { + console.log(err.message, err.code, err.cause); + if (err.cause instanceof Error && err.cause.message.includes('off_topic')) { + stream.markdown(vscode.l10n.t('I\'m sorry, I can only explain computer science concepts.')); + } + } else { + // re-throw other errors so they show up in the UI + throw err; + } +} + +// Get a random topic that the cat has not taught in the chat history yet +function getTopic(history: ReadonlyArray): string { + const topics = ['linked list', 'recursion', 'stack', 'queue', 'pointers']; + // Filter the chat history to get only the responses from the cat + const previousCatResponses = history.filter(h => { + return h instanceof vscode.ChatResponseTurn && h.participant === CAT_PARTICIPANT_ID; + }) as vscode.ChatResponseTurn[]; + // Filter the topics to get only the topics that have not been taught by the cat yet + const topicsNoRepetition = topics.filter(topic => { + return !previousCatResponses.some(catResponse => { + return catResponse.response.some(r => { + return r instanceof vscode.ChatResponseMarkdownPart && r.value.value.includes(topic); + }); + }); + }); + + return topicsNoRepetition[Math.floor(Math.random() * topicsNoRepetition.length)] || 'I have taught you everything I know. Meow!'; +} diff --git a/chat-sample/src/toolParticipant.ts b/chat-sample/src/toolParticipant.ts new file mode 100644 index 00000000..39d41e36 --- /dev/null +++ b/chat-sample/src/toolParticipant.ts @@ -0,0 +1,138 @@ +import { renderPrompt } from '@vscode/prompt-tsx'; +import * as vscode from 'vscode'; +import { ToolCallRound, ToolResultMetadata, ToolUserPrompt } from './toolsPrompt'; + +export interface TsxToolUserMetadata { + toolCallsMetadata: ToolCallsMetadata; +} + +export interface ToolCallsMetadata { + toolCallRounds: ToolCallRound[]; + toolCallResults: Record; +} + +export function isTsxToolUserMetadata(obj: unknown): obj is TsxToolUserMetadata { + // If you change the metadata format, you would have to make this stricter or handle old objects in old ChatRequest metadata + return !!obj && + !!(obj as TsxToolUserMetadata).toolCallsMetadata && + Array.isArray((obj as TsxToolUserMetadata).toolCallsMetadata.toolCallRounds); +} + +export function registerToolUserChatParticipant(context: vscode.ExtensionContext) { + const handler: vscode.ChatRequestHandler = async (request: vscode.ChatRequest, chatContext: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => { + if (request.command === 'list') { + stream.markdown(`Available tools: ${vscode.lm.tools.map(tool => tool.name).join(', ')}\n\n`); + return; + } + + let model = request.model; + if (model.vendor === 'copilot' && model.family.startsWith('o1')) { + // The o1 models do not currently support tools + const models = await vscode.lm.selectChatModels({ + vendor: 'copilot', + family: 'gpt-4o' + }); + model = models[0]; + } + + // Use all tools, or tools with the tags that are relevant. + const tools = request.command === 'all' ? + vscode.lm.tools : + vscode.lm.tools.filter(tool => tool.tags.includes('chat-tools-sample')); + const options: vscode.LanguageModelChatRequestOptions = { + justification: 'To make a request to @toolsTSX', + }; + + // Render the initial prompt + const result = await renderPrompt( + ToolUserPrompt, + { + context: chatContext, + request, + toolCallRounds: [], + toolCallResults: {} + }, + { modelMaxPromptTokens: model.maxInputTokens }, + model); + let messages = result.messages; + result.references.forEach(ref => { + if (ref.anchor instanceof vscode.Uri || ref.anchor instanceof vscode.Location) { + stream.reference(ref.anchor); + } + }); + + const toolReferences = [...request.toolReferences]; + const accumulatedToolResults: Record = {}; + const toolCallRounds: ToolCallRound[] = []; + const runWithTools = async (): Promise => { + // If a toolReference is present, force the model to call that tool + const requestedTool = toolReferences.shift(); + if (requestedTool) { + options.toolMode = vscode.LanguageModelChatToolMode.Required; + options.tools = vscode.lm.tools.filter(tool => tool.name === requestedTool.name); + } else { + options.toolMode = undefined; + options.tools = [...tools]; + } + + // Send the request to the LanguageModelChat + const response = await model.sendRequest(messages, options, token); + + // Stream text output and collect tool calls from the response + const toolCalls: vscode.LanguageModelToolCallPart[] = []; + let responseStr = ''; + for await (const part of response.stream) { + if (part instanceof vscode.LanguageModelTextPart) { + stream.markdown(part.value); + responseStr += part.value; + } else if (part instanceof vscode.LanguageModelToolCallPart) { + toolCalls.push(part); + } + } + + if (toolCalls.length) { + // If the model called any tools, then we do another round- render the prompt with those tool calls (rendering the PromptElements will invoke the tools) + // and include the tool results in the prompt for the next request. + toolCallRounds.push({ + response: responseStr, + toolCalls + }); + const result = (await renderPrompt( + ToolUserPrompt, + { + context: chatContext, + request, + toolCallRounds, + toolCallResults: accumulatedToolResults + }, + { modelMaxPromptTokens: model.maxInputTokens }, + model)); + messages = result.messages; + const toolResultMetadata = result.metadatas.getAll(ToolResultMetadata); + if (toolResultMetadata?.length) { + // Cache tool results for later, so they can be incorporated into later prompts without calling the tool again + toolResultMetadata.forEach(meta => accumulatedToolResults[meta.toolCallId] = meta.result); + } + + // This loops until the model doesn't want to call any more tools, then the request is done. + return runWithTools(); + } + }; + + await runWithTools(); + + return { + metadata: { + // Return tool call metadata so it can be used in prompt history on the next request + toolCallsMetadata: { + toolCallResults: accumulatedToolResults, + toolCallRounds + } + } satisfies TsxToolUserMetadata, + }; + }; + + const toolUser = vscode.chat.createChatParticipant('chat-tools-sample.tools', handler); + toolUser.iconPath = new vscode.ThemeIcon('tools'); + context.subscriptions.push(toolUser); +} \ No newline at end of file diff --git a/chat-sample/src/tools.ts b/chat-sample/src/tools.ts new file mode 100644 index 00000000..ae0328ff --- /dev/null +++ b/chat-sample/src/tools.ts @@ -0,0 +1,155 @@ +import * as vscode from 'vscode'; + +interface ITabCountParameters { + tabGroup?: number; +} + +export class TabCountTool implements vscode.LanguageModelTool { + async invoke( + options: vscode.LanguageModelToolInvocationOptions, + token: vscode.CancellationToken + ) { + const params = options.input; + if (typeof params.tabGroup === 'number') { + const group = vscode.window.tabGroups.all[Math.max(params.tabGroup - 1, 0)]; + const nth = + params.tabGroup === 1 + ? '1st' + : params.tabGroup === 2 + ? '2nd' + : params.tabGroup === 3 + ? '3rd' + : `${params.tabGroup}th`; + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(`There are ${group.tabs.length} tabs open in the ${nth} tab group.`)]); + } else { + const group = vscode.window.tabGroups.activeTabGroup; + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(`There are ${group.tabs.length} tabs open.`)]); + } + } + + async prepareInvocation( + options: vscode.LanguageModelToolInvocationPrepareOptions, + token: vscode.CancellationToken + ) { + const confirmationMessages = { + title: 'Count the number of open tabs', + message: new vscode.MarkdownString( + `Count the number of open tabs?` + + (options.input.tabGroup !== undefined + ? ` in tab group ${options.input.tabGroup}` + : '') + ), + }; + + return { + invocationMessage: 'Counting the number of tabs', + confirmationMessages, + }; + } +} + +interface IFindFilesParameters { + pattern: string; +} + +export class FindFilesTool implements vscode.LanguageModelTool { + async invoke( + options: vscode.LanguageModelToolInvocationOptions, + token: vscode.CancellationToken + ) { + const params = options.input as IFindFilesParameters; + const files = await vscode.workspace.findFiles( + params.pattern, + '**/node_modules/**', + undefined, + token + ); + + const strFiles = files.map((f) => f.fsPath).join('\n'); + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(`Found ${files.length} files matching "${params.pattern}":\n${strFiles}`)]); + } + + async prepareInvocation( + options: vscode.LanguageModelToolInvocationPrepareOptions, + token: vscode.CancellationToken + ) { + return { + invocationMessage: `Searching workspace for "${options.input.pattern}"`, + }; + } +} + +interface IRunInTerminalParameters { + command: string; +} + +async function waitForShellIntegration( + terminal: vscode.Terminal, + timeout: number +): Promise { + let resolve: () => void; + let reject: (e: Error) => void; + let p = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + const timer = setTimeout(() => reject(new Error('Could not run terminal command: shell integration is not enabled')), timeout); + + const listener = vscode.window.onDidChangeTerminalShellIntegration((e) => { + if (e.terminal === terminal) { + clearTimeout(timer); + listener.dispose(); + resolve(); + } + }); + + await p; +} + +export class RunInTerminalTool + implements vscode.LanguageModelTool +{ + async invoke( + options: vscode.LanguageModelToolInvocationOptions, + token: vscode.CancellationToken + ) { + const params = options.input as IRunInTerminalParameters; + + const terminal = vscode.window.createTerminal('Language Model Tool User'); + terminal.show(); + try { + await waitForShellIntegration(terminal, 5000); + } catch(e) { + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart((e as Error).message)]); + } + + const execution = terminal.shellIntegration!.executeCommand(params.command); + const terminalStream = execution.read(); + + let terminalResult = ''; + for await (const chunk of terminalStream) { + terminalResult += chunk; + } + + return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(terminalResult)]); + } + + async prepareInvocation( + options: vscode.LanguageModelToolInvocationPrepareOptions, + token: vscode.CancellationToken + ) { + const confirmationMessages = { + title: 'Run command in terminal', + message: new vscode.MarkdownString( + `Run this command in a terminal?` + + `\n\n\`\`\`\n${options.input.command}\n\`\`\`\n` + ), + }; + + return { + invocationMessage: `Running command in terminal`, + confirmationMessages, + }; + } +} diff --git a/chat-sample/src/toolsPrompt.tsx b/chat-sample/src/toolsPrompt.tsx new file mode 100644 index 00000000..37779bde --- /dev/null +++ b/chat-sample/src/toolsPrompt.tsx @@ -0,0 +1,280 @@ +import { + AssistantMessage, + BasePromptElementProps, + Chunk, + PrioritizedList, + PromptElement, + PromptElementProps, + PromptMetadata, + PromptPiece, + PromptReference, + PromptSizing, + ToolCall, + ToolMessage, + UserMessage +} from '@vscode/prompt-tsx'; +import { ToolResult } from '@vscode/prompt-tsx/dist/base/promptElements'; +import * as vscode from 'vscode'; +import { isTsxToolUserMetadata } from './toolParticipant'; + +export interface ToolCallRound { + response: string; + toolCalls: vscode.LanguageModelToolCallPart[]; +} + +export interface ToolUserProps extends BasePromptElementProps { + request: vscode.ChatRequest; + context: vscode.ChatContext; + toolCallRounds: ToolCallRound[]; + toolCallResults: Record; +} + +export class ToolUserPrompt extends PromptElement { + render(_state: void, _sizing: PromptSizing) { + return ( + <> + + Instructions:
+ - 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.
+ - 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.
+ - Don't make assumptions about the situation- gather context first, then + perform the task or answer the question.
+ - Don't ask the user for confirmation to use tools, just use them. +
+ + + {this.props.request.prompt} + + + ); + } +} + +interface ToolCallsProps extends BasePromptElementProps { + toolCallRounds: ToolCallRound[]; + toolCallResults: Record; + toolInvocationToken: vscode.ChatParticipantToolToken | undefined; +} + +const dummyCancellationToken: vscode.CancellationToken = new vscode.CancellationTokenSource().token; + +/** + * Render a set of tool calls, which look like an AssistantMessage with a set of tool calls followed by the associated UserMessages containing results. + */ +class ToolCalls extends PromptElement { + async render(_state: void, _sizing: PromptSizing) { + if (!this.props.toolCallRounds.length) { + return undefined; + } + + // Note- for the copilot models, the final prompt must end with a non-tool-result UserMessage + return <> + {this.props.toolCallRounds.map(round => this.renderOneToolCallRound(round))} + Above is the result of calling one or more tools. The user cannot see the results, so you should explain them to the user if referencing them in your answer. + ; + } + + private renderOneToolCallRound(round: ToolCallRound) { + const assistantToolCalls: ToolCall[] = round.toolCalls.map(tc => ({ type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.input) }, id: tc.callId })); + return ( + + {round.response} + {round.toolCalls.map(toolCall => + )} + ); + } +} + +interface ToolResultElementProps extends BasePromptElementProps { + toolCall: vscode.LanguageModelToolCallPart; + toolInvocationToken: vscode.ChatParticipantToolToken | undefined; + toolCallResult: vscode.LanguageModelToolResult | undefined; +} + +/** + * One tool call result, which either comes from the cache or from invoking the tool. + */ +class ToolResultElement extends PromptElement { + async render(state: void, sizing: PromptSizing): Promise { + const tool = vscode.lm.tools.find(t => t.name === this.props.toolCall.name); + if (!tool) { + console.error(`Tool not found: ${this.props.toolCall.name}`); + return Tool not found; + } + + const tokenizationOptions: vscode.LanguageModelToolTokenizationOptions = { + tokenBudget: sizing.tokenBudget, + countTokens: async (content: string) => sizing.countTokens(content), + }; + + const toolResult = this.props.toolCallResult ?? + await vscode.lm.invokeTool(this.props.toolCall.name, { input: this.props.toolCall.input, toolInvocationToken: this.props.toolInvocationToken, tokenizationOptions }, dummyCancellationToken); + + return ( + + + + + ); + } +} + +export class ToolResultMetadata extends PromptMetadata { + constructor( + public toolCallId: string, + public result: vscode.LanguageModelToolResult, + ) { + super(); + } +} + +interface HistoryProps extends BasePromptElementProps { + priority: number; + context: vscode.ChatContext; +} + +/** + * Render the chat history, including previous tool call/results. + */ +class History extends PromptElement { + render(_state: void, _sizing: PromptSizing) { + return ( + + {this.props.context.history.map((message) => { + if (message instanceof vscode.ChatRequestTurn) { + return ( + <> + {} + {message.prompt} + + ); + } else if (message instanceof vscode.ChatResponseTurn) { + const metadata = message.result.metadata; + if (isTsxToolUserMetadata(metadata) && metadata.toolCallsMetadata.toolCallRounds.length > 0) { + return ; + } + + return {chatResponseToString(message)}; + } + })} + + ); + } +} + +/** + * Convert the stream of chat response parts into something that can be rendered in the prompt. + */ +function chatResponseToString(response: vscode.ChatResponseTurn): string { + return response.response + .map((r) => { + if (r instanceof vscode.ChatResponseMarkdownPart) { + return r.value.value; + } else if (r instanceof vscode.ChatResponseAnchorPart) { + if (r.value instanceof vscode.Uri) { + return r.value.fsPath; + } else { + return r.value.uri.fsPath; + } + } + + return ''; + }) + .join(''); +} + +interface PromptReferencesProps extends BasePromptElementProps { + references: ReadonlyArray; + excludeReferences?: boolean; +} + +/** + * Render references that were included in the user's request, eg files and selections. + */ +class PromptReferences extends PromptElement { + render(_state: void, _sizing: PromptSizing): PromptPiece { + return ( + + {this.props.references.map(ref => ( + + ))} + + ); + } +} + +interface PromptReferenceProps extends BasePromptElementProps { + ref: vscode.ChatPromptReference; + excludeReferences?: boolean; +} + +class PromptReferenceElement extends PromptElement { + async render(_state: void, _sizing: PromptSizing): Promise { + const value = this.props.ref.value; + if (value instanceof vscode.Uri) { + const fileContents = (await vscode.workspace.fs.readFile(value)).toString(); + return ( + + {!this.props.excludeReferences && } + {value.fsPath}:
+ ```
+ {fileContents}
+ ```
+
+ ); + } else if (value instanceof vscode.Location) { + const rangeText = (await vscode.workspace.openTextDocument(value.uri)).getText(value.range); + return ( + + {!this.props.excludeReferences && } + {value.uri.fsPath}:{value.range.start.line + 1}-$
+ {value.range.end.line + 1}:
+ ```
+ {rangeText}
+ ``` +
+ ); + } else if (typeof value === 'string') { + return {value}; + } + } +} + +type TagProps = PromptElementProps<{ + name: string; +}>; + +class Tag extends PromptElement { + private static readonly _regex = /^[a-zA-Z_][\w.-]*$/; + + render() { + const { name } = this.props; + + if (!Tag._regex.test(name)) { + throw new Error(`Invalid tag name: ${this.props.name}`); + } + + return ( + <> + {'<' + name + '>'}
+ <> + {this.props.children}
+ + {''}
+ + ); + } +} diff --git a/chat-sample/vscode.d.ts b/chat-sample/vscode.d.ts index 2ce6f773..f356b973 100644 --- a/chat-sample/vscode.d.ts +++ b/chat-sample/vscode.d.ts @@ -19096,7 +19096,7 @@ declare module 'vscode' { * The list of tools that the user attached to their request. * * When a tool reference is present, the chat participant should make a chat request using - * {@link LanguageModelChatToolMode.Required} to force the language model to generate parameters for the tool. Then, the + * {@link LanguageModelChatToolMode.Required} to force the language model to generate input for the tool. Then, the * participant can use {@link lm.invokeTool} to use the tool attach the result to its request for the user's prompt. The * tool may contribute useful extra context for the user's request. */ @@ -19703,12 +19703,12 @@ declare module 'vscode' { /** * A list of all available tools that were registered by all extensions using {@link lm.registerTool}. They can be called - * with {@link lm.invokeTool} with a set of parameters that match their declared `parametersSchema`. + * with {@link lm.invokeTool} with input that match their declared `inputSchema`. */ export const tools: readonly LanguageModelToolInformation[]; /** - * Invoke a tool listed in {@link lm.tools} by name with the given parameters. The parameters will be validated against + * Invoke a tool listed in {@link lm.tools} by name with the given input. The input will be validated against * the schema declared by the tool * * A tool can be invoked by a chat participant, in the context of handling a chat request, or globally by any extension in @@ -19774,9 +19774,9 @@ declare module 'vscode' { description: string; /** - * A JSON schema for the parameters this tool accepts. + * A JSON schema for the input this tool accepts. */ - parametersSchema?: object; + inputSchema?: object; } /** @@ -19800,25 +19800,53 @@ declare module 'vscode' { * included as a content part on a {@link LanguageModelChatMessage}, to represent a previous tool call in a chat request. */ export class LanguageModelToolCallPart { - /** - * The name of the tool to call. - */ - name: string; - /** * The ID of the tool call. This is a unique identifier for the tool call within the chat request. */ callId: string; /** - * The parameters with which to call the tool. + * The name of the tool to call. */ - parameters: object; + name: string; + + /** + * The input with which to call the tool. + */ + input: object; /** * Create a new LanguageModelToolCallPart. + * + * @param callId The ID of the tool call. + * @param name The name of the tool to call. + * @param input The input with which to call the tool. */ - constructor(name: string, callId: string, parameters: object); + constructor(callId: string, name: string, input: object); + } + + /** + * The result of a tool call. This is the counterpart of a {@link LanguageModelToolCallPart tool call} and + * it can only be included in the content of a User message + */ + export class LanguageModelToolResultPart { + /** + * The ID of the tool call. + * + * *Note* that this should match the {@link LanguageModelToolCallPart.callId callId} of a tool call part. + */ + callId: string; + + /** + * The value of the tool result. + */ + content: (LanguageModelTextPart | LanguageModelPromptTsxPart | unknown)[]; + + /** + * @param callId The ID of the tool call. + * @param content The content of the tool result. + */ + constructor(callId: string, content: (LanguageModelTextPart | LanguageModelPromptTsxPart | unknown)[]); } /** @@ -19854,27 +19882,6 @@ declare module 'vscode' { constructor(value: unknown); } - /** - * The result of a tool call. Can only be included in the content of a User message. - */ - export class LanguageModelToolResultPart { - /** - * The ID of the tool call. - */ - callId: string; - - /** - * The value of the tool result. - */ - content: (LanguageModelTextPart | LanguageModelPromptTsxPart | unknown)[]; - - /** - * @param callId The ID of the tool call. - * @param content The content of the tool result. - */ - constructor(callId: string, content: (LanguageModelTextPart | LanguageModelPromptTsxPart | unknown)[]); - } - /** * A result returned from a tool invocation. If using `@vscode/prompt-tsx`, this result may be rendered using a `ToolResult`. */ @@ -19896,27 +19903,31 @@ declare module 'vscode' { /** * A token that can be passed to {@link lm.invokeTool} when invoking a tool inside the context of handling a chat request. */ - export type ChatParticipantToolToken = unknown; + export type ChatParticipantToolToken = never; /** * Options provided for tool invocation. */ export interface LanguageModelToolInvocationOptions { /** - * When this tool is being invoked by a {@link ChatParticipant} within the context of a chat request, this token should be - * passed from {@link ChatRequest.toolInvocationToken}. In that case, a progress bar will be automatically shown for the - * tool invocation in the chat response view, and if the tool requires user confirmation, it will show up inline in the - * chat view. If the tool is being invoked outside of a chat request, `undefined` should be passed instead. + * An opaque object that ties a tool invocation to a chat request from a {@link ChatParticipant chat participant}. * - * If a tool invokes another tool during its invocation, it can pass along the `toolInvocationToken` that it received. + * The _only_ way to get a valid tool invocation token is using the provided {@link ChatRequest.toolInvocationToken toolInvocationToken} + * from a chat request. In that case, a progress bar will be automatically shown for the tool invocation in the chat response view, and if + * the tool requires user confirmation, it will show up inline in the chat view. + * + * If the tool is being invoked outside of a chat request, `undefined` should be passed instead, and no special UI except for + * confirmations will be shown. + * + * *Note* that a tool that invokes another tool during its invocation, can pass along the `toolInvocationToken` that it received. */ toolInvocationToken: ChatParticipantToolToken | undefined; /** - * The parameters with which to invoke the tool. The parameters must match the schema defined in - * {@link LanguageModelToolInformation.parametersSchema} + * The input with which to invoke the tool. The input must match the schema defined in + * {@link LanguageModelToolInformation.inputSchema} */ - parameters: T; + input: T; /** * Options to hint at how many tokens the tool should return in its response, and enable the tool to count tokens @@ -19958,9 +19969,9 @@ declare module 'vscode' { readonly description: string; /** - * A JSON schema for the parameters this tool accepts. + * A JSON schema for the input this tool accepts. */ - readonly parametersSchema: object | undefined; + readonly inputSchema: object | undefined; /** * A set of tags, declared by the tool, that roughly describe the tool's capabilities. A tool user may use these to filter @@ -19974,9 +19985,9 @@ declare module 'vscode' { */ export interface LanguageModelToolInvocationPrepareOptions { /** - * The parameters that the tool is being invoked with. + * The input that the tool is being invoked with. */ - parameters: T; + input: T; } /** @@ -19984,15 +19995,15 @@ declare module 'vscode' { */ export interface LanguageModelTool { /** - * Invoke the tool with the given parameters and return a result. + * Invoke the tool with the given input and return a result. * - * The provided {@link LanguageModelToolInvocationOptions.parameters} have been validated against the declared schema. + * The provided {@link LanguageModelToolInvocationOptions.input} has been validated against the declared schema. */ invoke(options: LanguageModelToolInvocationOptions, token: CancellationToken): ProviderResult; /** * Called once before a tool is invoked. It's recommended to implement this to customize the progress message that appears - * while the tool is running, and to provide a more useful message with context from the invocation parameters. Can also + * while the tool is running, and to provide a more useful message with context from the invocation input. Can also * signal that a tool needs user confirmation before running, if appropriate. * * * *Note 1:* Must be free of side-effects.