Path: blob/main/extensions/copilot/src/extension/prompt/node/chatParticipantRequestHandler.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as l10n from '@vscode/l10n';6import type { ChatRequest, ChatRequestTurn2, ChatResponseStream, ChatResult, Location } from 'vscode';7import { IAuthenticationService } from '../../../platform/authentication/common/authentication';8import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade';9import { getChatParticipantNameFromId } from '../../../platform/chat/common/chatAgents';10import { CanceledMessage, ChatLocation } from '../../../platform/chat/common/commonTypes';11import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider';12import { IIgnoreService } from '../../../platform/ignore/common/ignoreService';13import { ILogService } from '../../../platform/log/common/logService';14import { FilterReason } from '../../../platform/networking/common/openai';15import { ITabsAndEditorsService } from '../../../platform/tabs/common/tabsAndEditorsService';16import { getWorkspaceFileDisplayPath, IWorkspaceService } from '../../../platform/workspace/common/workspaceService';17import { ChatResponseStreamImpl } from '../../../util/common/chatResponseStreamImpl';18import { fileTreePartToMarkdown } from '../../../util/common/fileTree';19import { isLocation, isSymbolInformation } from '../../../util/common/types';20import { coalesce } from '../../../util/vs/base/common/arrays';21import { CancellationToken } from '../../../util/vs/base/common/cancellation';22import { Schemas } from '../../../util/vs/base/common/network';23import { mixin } from '../../../util/vs/base/common/objects';24import { isEqual } from '../../../util/vs/base/common/resources';25import { URI } from '../../../util/vs/base/common/uri';26import { generateUuid } from '../../../util/vs/base/common/uuid';27import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation';28import { ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatResponseAnchorPart, ChatResponseFileTreePart, ChatResponseMarkdownPart, ChatResponseProgressPart2, ChatResponseReferencePart, ChatResponseTurn, ChatLocation as VSChatLocation } from '../../../vscodeTypes';29import { ICommandService } from '../../commands/node/commandService';30import { getAgentForIntent, Intent } from '../../common/constants';31import { IConversationStore } from '../../conversationStore/node/conversationStore';32import { IIntentService } from '../../intents/node/intentService';33import { UnknownIntent } from '../../intents/node/unknownIntent';34import { ContributedToolName } from '../../tools/common/toolNames';35import { ChatVariablesCollection } from '../common/chatVariablesCollection';36import { AnthropicTokenUsageMetadata, Conversation, getGlobalContextCacheKey, GlobalContextMessageMetadata, ICopilotChatResult, ICopilotChatResultIn, normalizeSummariesOnRounds, RenderedUserMessageMetadata, Turn, TurnStatus } from '../common/conversation';37import { InternalToolReference } from '../common/intents';38import { ChatTelemetryBuilder } from './chatParticipantTelemetry';39import { DefaultIntentRequestHandler } from './defaultIntentRequestHandler';40import { IDocumentContext } from './documentContext';41import { IntentDetector } from './intentDetector';42import { CommandDetails } from './intentRegistry';43import { IIntent } from './intents';4445export interface IChatAgentArgs {46agentName: string;47agentId: string;48intentId?: string;49}5051/**52* Handles a single chat request:53* 1) selects intent54* 2) invoke intent via `IIntentRequestHandler/AbstractIntentRequestHandler`55*/56export class ChatParticipantRequestHandler {5758public readonly conversation: Conversation;5960private readonly location: ChatLocation;61private readonly stream: ChatResponseStream;62private readonly documentContext: IDocumentContext | undefined;63private readonly intentDetector: IntentDetector;64private readonly turn: Turn;6566private readonly chatTelemetry: ChatTelemetryBuilder;6768constructor(69private readonly rawHistory: ReadonlyArray<ChatRequestTurn | ChatResponseTurn>,70private request: ChatRequest,71stream: ChatResponseStream,72private readonly token: CancellationToken,73private readonly chatAgentArgs: IChatAgentArgs,74private readonly yieldRequested: () => boolean,75telemetryMessageId: string | undefined,76@IInstantiationService private readonly _instantiationService: IInstantiationService,77@IEndpointProvider private readonly _endpointProvider: IEndpointProvider,78@ICommandService private readonly _commandService: ICommandService,79@IIgnoreService private readonly _ignoreService: IIgnoreService,80@IIntentService private readonly _intentService: IIntentService,81@IConversationStore private readonly _conversationStore: IConversationStore,82@ITabsAndEditorsService tabsAndEditorsService: ITabsAndEditorsService,83@ILogService private readonly _logService: ILogService,84@IAuthenticationService private readonly _authService: IAuthenticationService,85@IAuthenticationChatUpgradeService private readonly _authenticationUpgradeService: IAuthenticationChatUpgradeService,86) {87this.location = this.getLocation(request);8889this.intentDetector = this._instantiationService.createInstance(IntentDetector);9091this.stream = stream;9293if (request.location2 instanceof ChatRequestEditorData) {9495// don't send back references that are the same as the document as the one from which96// the request has been made9798const documentUri = request.location2.document.uri;99100this.stream = ChatResponseStreamImpl.filter(stream, part => {101if (part instanceof ChatResponseReferencePart || part instanceof ChatResponseProgressPart2) {102const uri = URI.isUri(part.value) ? part.value : (<Location>part.value).uri;103return !isEqual(uri, documentUri);104}105return true;106});107}108109const { turns, sessionId } = _instantiationService.invokeFunction(accessor => addHistoryToConversation(accessor, rawHistory));110normalizeSummariesOnRounds(turns);111// Use session ID from history, then VS Code's request.sessionId, then fallback to UUID112const actualSessionId = sessionId ?? request.sessionId ?? generateUuid();113114this.documentContext = IDocumentContext.inferDocumentContext(request, tabsAndEditorsService.activeTextEditor, turns);115116this.chatTelemetry = this._instantiationService.createInstance(ChatTelemetryBuilder,117Date.now(),118actualSessionId,119this.documentContext,120turns.length === 0,121this.request,122telemetryMessageId123);124125const latestTurn = Turn.fromRequest(126this.chatTelemetry.telemetryMessageId,127this.request);128129this.conversation = new Conversation(actualSessionId, turns.concat(latestTurn));130131this.turn = latestTurn;132}133134private getLocation(request: ChatRequest) {135if (request.location2 instanceof ChatRequestEditorData) {136return ChatLocation.Editor;137} else if (request.location2 instanceof ChatRequestNotebookData) {138return ChatLocation.Notebook;139}140switch (request.location) { // deprecated, but location2 does not yet allow to distinguish between panel, editing session and others141case VSChatLocation.Editor:142return ChatLocation.Editor;143case VSChatLocation.Panel:144return ChatLocation.Panel;145case VSChatLocation.Terminal:146return ChatLocation.Terminal;147default:148return ChatLocation.Other;149}150}151152private async sanitizeVariables(): Promise<ChatRequest> {153const variablePromises = this.request.references.map(async (ref) => {154const uri = isLocation(ref.value) ? ref.value.uri : URI.isUri(ref.value) ? ref.value : undefined;155if (!uri) {156return ref;157}158159if (uri.scheme === Schemas.untitled) {160return ref;161}162163let removeVariable;164try {165// Filter out variables which contain paths which are ignored166removeVariable = await this._ignoreService.isCopilotIgnored(uri);167} catch {168// Non-existent files will be handled elsewhere. This might be a virtual document so it's ok if the fs service can't find it.169}170171if (removeVariable && ref.range) {172// Also sanitize the user message since file paths are sensitive173this.turn.request.message = this.turn.request.message.slice(0, ref.range[0]) + this.turn.request.message.slice(ref.range[1]);174}175176return removeVariable ? null : ref;177});178179const newVariables = coalesce(await Promise.all(variablePromises));180181return { ...this.request, references: newVariables };182}183184private async _shouldAskForPermissiveAuth(): Promise<boolean> {185// The user has confirmed that they want to auth, so prompt them.186const findConfirmRequest = this.request.acceptedConfirmationData?.find(ref => ref?.authPermissionPrompted);187if (findConfirmRequest) {188this.request = await this._authenticationUpgradeService.handleConfirmationRequest(this.stream, this.request, this.rawHistory);189this.turn.request.message = this.request.prompt;190return false;191}192193// Only ask for confirmation if we're invoking the codebase tool or workspace chat participant194const isWorkspaceCall = this.request.toolReferences.some(ref => ref.name === ContributedToolName.Codebase);195// and only if we can't access all repos in the workspace196if (isWorkspaceCall && await this._authenticationUpgradeService.shouldRequestPermissiveSessionUpgrade()) {197this._authenticationUpgradeService.showPermissiveSessionUpgradeInChat(this.stream, this.request);198return true;199}200return false;201}202203async getResult(): Promise<ICopilotChatResult> {204if (await this._shouldAskForPermissiveAuth()) {205// Return a random response206return {207metadata: {208modelMessageId: this.turn.responseId ?? '',209responseId: this.turn.id,210sessionId: this.conversation.sessionId,211agentId: this.chatAgentArgs.agentId,212command: this.request.command,213}214};215}216this._logService.trace(`[${ChatLocation.toStringShorter(this.location)}] chat request received from extension host`);217try {218219// sanitize the variables of all requests220// this is done here because all intents must honor ignored files221this.request = await this.sanitizeVariables();222223const command = this.chatAgentArgs.intentId ?224this._commandService.getCommand(this.chatAgentArgs.intentId, this.location) :225undefined;226227let result = this.checkCommandUsage(command);228229if (!result) {230// this is norm-case, e.g checkCommandUsage didn't produce an error-result231// and we proceed with the actual intent invocation232233const history = this.conversation.turns.slice(0, -1);234const intent = await this.selectIntent(command, history);235236let chatResult: Promise<ChatResult>;237if (typeof intent.handleRequest === 'function') {238chatResult = intent.handleRequest(this.conversation, this.request, this.stream, this.token, this.documentContext, this.chatAgentArgs.agentName, this.location, this.chatTelemetry, this.yieldRequested);239} else {240const intentHandler = this._instantiationService.createInstance(DefaultIntentRequestHandler, intent, this.conversation, this.request, this.stream, this.token, this.documentContext, this.location, this.chatTelemetry, undefined, this.yieldRequested);241chatResult = intentHandler.getResult();242}243244if (!this.request.isParticipantDetected) {245this.intentDetector.collectIntentDetectionContextInternal(246this.turn.request.message,247this.request.enableCommandDetection ? intent.id : 'none',248new ChatVariablesCollection(this.request.references),249this.location,250history,251this.documentContext?.document252);253}254255result = await chatResult;256const endpoint = await this._endpointProvider.getChatEndpoint(this.request);257result.details = this._authService.copilotToken?.isNoAuthUser || endpoint.multiplier === undefined ?258`${endpoint.name}` :259`${endpoint.name} • ${endpoint.multiplier}x`;260}261262this._conversationStore.addConversation(this.turn.id, this.conversation);263264// mixin fixed metadata shape into result. Modified in place because the object is already265// cached in the conversation store and we want the full information when looking this up266// later267mixin(result, {268metadata: {269modelMessageId: this.turn.responseId ?? '',270responseId: this.turn.id,271sessionId: this.conversation.sessionId,272agentId: this.chatAgentArgs.agentId,273command: this.request.command274}275} satisfies ICopilotChatResult, true);276277return <ICopilotChatResult>result;278279} catch (err) {280// TODO This method should not throw at all, but return a result with errorDetails, and call the IConversationStore281throw err;282}283}284285private async selectIntent(command: CommandDetails | undefined, history: Turn[]): Promise<IIntent> {286if (!command?.intent && this.location === ChatLocation.Editor) { // TODO@jrieken do away with location specific code287288let preferredIntent: Intent | undefined;289if (this.documentContext && this.request.attempt === 0 && history.length === 0) {290if (this.documentContext.selection.isEmpty && this.documentContext.document.lineAt(this.documentContext.selection.start.line).text.trim() === '') {291preferredIntent = Intent.Generate;292} else if (!this.documentContext.selection.isEmpty && this.documentContext.selection.start.line !== this.documentContext.selection.end.line) {293preferredIntent = Intent.Edit;294}295}296if (preferredIntent) {297return this._intentService.getIntent(preferredIntent, this.location) ?? this._intentService.unknownIntent;298}299}300301return command?.intent ?? this._intentService.unknownIntent;302}303304private checkCommandUsage(command: CommandDetails | undefined): ChatResult | undefined {305if (command?.intent && !(command.intent.commandInfo?.allowsEmptyArgs ?? true) && !this.turn.request.message) {306const commandAgent = getAgentForIntent(command.intent.id as Intent, this.location);307let usage = '';308if (commandAgent) {309// If the command was used, it must have an agent310usage = `@${commandAgent.agent} `;311if (commandAgent.command) {312usage += ` /${commandAgent.command}`;313}314usage += ` ${command.details}`;315316}317318const message = l10n.t(`Please specify a question when using this command.\n\nUsage: {0}`, usage);319const chatResult = { errorDetails: { message } };320this.turn.setResponse(TurnStatus.Error, { type: 'meta', message }, undefined, chatResult);321return chatResult;322}323}324}325326327export function addHistoryToConversation(accessor: ServicesAccessor, history: ReadonlyArray<ChatRequestTurn | ChatResponseTurn>): { turns: Turn[]; sessionId: string | undefined } {328const instaService = accessor.get(IInstantiationService);329330const turns: Turn[] = [];331let sessionId: string | undefined;332let previousChatRequestTurn: ChatRequestTurn | undefined;333334for (const entry of history) {335// The extension API model technically supports arbitrary requests/responses not in pairs, but this isn't used anywhere,336// so we can just fit this to our Conversation model for now.337if (entry instanceof ChatRequestTurn) {338previousChatRequestTurn = entry;339} else {340const existingTurn = instaService.invokeFunction(findExistingTurnFromVSCodeChatHistoryTurn, entry);341if (existingTurn) {342turns.push(existingTurn);343} else {344if (previousChatRequestTurn) {345const deserializedTurn = instaService.invokeFunction(createTurnFromVSCodeChatHistoryTurns, previousChatRequestTurn, entry);346previousChatRequestTurn = undefined;347turns.push(deserializedTurn);348}349}350351const copilotResult = entry.result as ICopilotChatResultIn;352if (typeof copilotResult.metadata?.sessionId === 'string') {353sessionId = copilotResult.metadata.sessionId;354}355}356}357358return { turns, sessionId };359}360361/**362* Try to find an existing `Turn` instance that we created previously based on the responseId of a vscode turn.363*/364function findExistingTurnFromVSCodeChatHistoryTurn(accessor: ServicesAccessor, turn: ChatRequestTurn | ChatResponseTurn): Turn | undefined {365const conversationStore = accessor.get(IConversationStore);366const responseId = getResponseIdFromVSCodeChatHistoryTurn(turn);367const conversation = responseId ? conversationStore.getConversation(responseId) : undefined;368return conversation?.turns.find(turn => turn.id === responseId);369}370371function getResponseIdFromVSCodeChatHistoryTurn(turn: ChatRequestTurn | ChatResponseTurn): string | undefined {372if (turn instanceof ChatResponseTurn) {373const lastEntryResult = turn.result as ICopilotChatResultIn | undefined;374return lastEntryResult?.metadata?.responseId;375}376return undefined;377}378379/**380* Try as best as possible to create a `Turn` object from data that comes from vscode.381*/382function createTurnFromVSCodeChatHistoryTurns(383accessor: ServicesAccessor,384chatRequestTurn: ChatRequestTurn,385chatResponseTurn: ChatResponseTurn386): Turn {387const commandService = accessor.get(ICommandService);388const workspaceService = accessor.get(IWorkspaceService);389const instaService = accessor.get(IInstantiationService);390391const chatRequestAsTurn2 = chatRequestTurn as ChatRequestTurn2;392const currentTurn = new Turn(393undefined,394{ message: chatRequestTurn.prompt, type: 'user' },395new ChatVariablesCollection(chatRequestTurn.references),396chatRequestTurn.toolReferences.map(InternalToolReference.from),397chatRequestAsTurn2.editedFileEvents,398undefined,399false,400chatRequestAsTurn2.modeInstructions2,401);402403// Take just the content messages404const content = chatResponseTurn.response.map(r => {405if (r instanceof ChatResponseMarkdownPart) {406return r.value.value;407} else if (r instanceof ChatResponseFileTreePart) {408return fileTreePartToMarkdown(r);409} else if ('content' in r) {410return r.content;411} else if (r instanceof ChatResponseAnchorPart) {412return anchorPartToMarkdown(workspaceService, r);413} else {414return null;415}416}).filter(Boolean).join('');417const intentId = chatResponseTurn.command || getChatParticipantNameFromId(chatResponseTurn.participant);418const command = commandService.getCommand(intentId, ChatLocation.Panel);419let status: TurnStatus;420if (!chatResponseTurn.result.errorDetails) {421status = TurnStatus.Success;422} else if (chatResponseTurn.result.errorDetails?.responseIsFiltered) {423if (chatResponseTurn.result.metadata?.category === FilterReason.Prompt) {424status = TurnStatus.PromptFiltered;425} else {426status = TurnStatus.Filtered;427}428} else if (chatResponseTurn.result.errorDetails.message === 'Cancelled' || chatResponseTurn.result.errorDetails.message === CanceledMessage.message) {429status = TurnStatus.Cancelled;430} else {431status = TurnStatus.Error;432}433434currentTurn.setResponse(status, { message: content, type: 'model', name: command?.commandId || UnknownIntent.ID }, undefined, chatResponseTurn.result);435const turnMetadata = (chatResponseTurn.result as ICopilotChatResultIn).metadata;436if (turnMetadata?.renderedGlobalContext) {437const cacheKey = turnMetadata.globalContextCacheKey ?? instaService.invokeFunction(getGlobalContextCacheKey);438currentTurn.setMetadata(new GlobalContextMessageMetadata(turnMetadata?.renderedGlobalContext, cacheKey));439}440if (turnMetadata?.renderedUserMessage) {441currentTurn.setMetadata(new RenderedUserMessageMetadata(turnMetadata.renderedUserMessage));442}443if (turnMetadata?.promptTokens && turnMetadata?.outputTokens) {444currentTurn.setMetadata(new AnthropicTokenUsageMetadata(turnMetadata.promptTokens, turnMetadata.outputTokens));445}446447return currentTurn;448}449450function anchorPartToMarkdown(workspaceService: IWorkspaceService, anchor: ChatResponseAnchorPart): string {451let text: string;452let path: string;453454if (URI.isUri(anchor.value)) {455path = getWorkspaceFileDisplayPath(workspaceService, anchor.value);456const label = anchor.title ?? path;457text = `\`${label}\``;458} else if (isLocation(anchor.value)) {459path = getWorkspaceFileDisplayPath(workspaceService, anchor.value.uri);460const label = anchor.title ?? `${path}#L${anchor.value.range.start.line + 1}${anchor.value.range.start.line === anchor.value.range.end.line ? '' : `-${anchor.value.range.end.line + 1}`}`;461text = `\`${label}\``;462} else if (isSymbolInformation(anchor.value)) {463path = getWorkspaceFileDisplayPath(workspaceService, anchor.value.location.uri);464text = `\`${anchor.value.name}\``;465} else {466// Unknown anchor type467return '';468}469470return `[${text}](${path} ${anchor.title ? `"${anchor.title}"` : ''})`;471}472473474