Path: blob/main/extensions/copilot/src/extension/prompt/common/conversation.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 { PromptReference, Raw } from '@vscode/prompt-tsx';6import type { ChatRequest, ChatRequestEditedFileEvent, ChatResponseStream, ChatResult, LanguageModelToolResult } from 'vscode';7import { FilterReason } from '../../../platform/networking/common/openai';8import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';9import { isLocation, toLocation } from '../../../util/common/types';10import { ResourceMap } from '../../../util/vs/base/common/map';11import { assertType } from '../../../util/vs/base/common/types';12import { URI } from '../../../util/vs/base/common/uri';13import { generateUuid } from '../../../util/vs/base/common/uuid';14import { ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation';15import { Location, Range } from '../../../vscodeTypes';16import { InternalToolReference, IToolCallRound } from '../common/intents';17import { ChatVariablesCollection } from './chatVariablesCollection';18import { isContinueOnError, isSwitchToAutoOnRateLimit, isToolCallLimitAcceptance } from './specialRequestTypes';19import { ToolCallRound } from './toolCallRound';20export { PromptReference } from '@vscode/prompt-tsx';2122export enum TurnStatus {23InProgress = 'in-progress',24Success = 'success',25Cancelled = 'cancelled',26OffTopic = 'off-topic',27Filtered = 'filtered',28PromptFiltered = 'prompt-filtered',29Error = 'error',30}3132export type TurnMessage = {33readonly type: 'user' | 'follow-up' | 'template' | 'offtopic-detection' | 'model' | 'meta' | 'server';34readonly name?: string;35/* readonly */message: string;36};373839export abstract class PromptMetadata {40readonly _marker: undefined;41toString(): string {42return Object.getPrototypeOf(this).constructor.name;43}44}4546export class RequestDebugInformation {47constructor(48readonly uri: URI,49readonly intentId: string,50readonly languageId: string,51readonly initialDocumentText: string,52readonly userPrompt: string,53readonly userSelection: Range54) { }55}5657export class Turn {5859private _references: readonly PromptReference[] = [];6061private _responseInfo?: { message: TurnMessage | undefined; status: TurnStatus; responseId: string | undefined; chatResult?: ChatResult };6263private readonly _metadata = new Map<unknown, unknown[]>();6465/** Summaries applied during the tool-call loop, before setResponse is called. */66private _pendingSummaries: { toolCallRoundId: string; text: string }[] = [];6768public readonly startTime = Date.now();6970static fromRequest(71id: string | undefined,72request: ChatRequest73) {74return new Turn(75id,76{ message: request.prompt, type: 'user' },77new ChatVariablesCollection(request.references),78request.toolReferences.map(InternalToolReference.from),79request.editedFileEvents,80request.acceptedConfirmationData,81isToolCallLimitAcceptance(request) || isContinueOnError(request) || isSwitchToAutoOnRateLimit(request),82request.modeInstructions2,83);84}8586constructor(87readonly id: string = generateUuid(),88readonly request: TurnMessage,89private readonly _promptVariables: ChatVariablesCollection | undefined = undefined,90private readonly _toolReferences: readonly InternalToolReference[] = [],91readonly editedFileEvents?: ChatRequestEditedFileEvent[],92readonly acceptedConfirmationData?: unknown[],93readonly isContinuation = false,94readonly modeInstructions?: ChatRequest['modeInstructions2'],95) { }9697get promptVariables(): ChatVariablesCollection | undefined {98return this._promptVariables;99}100101get toolReferences(): readonly InternalToolReference[] {102return this._toolReferences;103}104105get references(): readonly PromptReference[] {106return this._references;107}108109addReferences(newReferences: readonly PromptReference[]) {110this._references = getUniqueReferences([...this._references, ...newReferences]);111}112113// --- response114115get responseMessage(): TurnMessage | undefined {116return this._responseInfo?.message;117}118119get responseStatus(): TurnStatus {120return this._responseInfo?.status ?? TurnStatus.InProgress;121}122123get responseId(): string | undefined {124return this._responseInfo?.responseId;125}126127get responseChatResult(): ChatResult | undefined {128return this._responseInfo?.chatResult;129}130131get resultMetadata(): Partial<IResultMetadata> | undefined {132return this._responseInfo?.chatResult?.metadata;133}134135get renderedUserMessage(): string | Raw.ChatCompletionContentPart[] | undefined {136const metadata = this.resultMetadata;137return metadata?.renderedUserMessage;138}139140// TODO@roblourens Tracking result data in "agent as chat participant" is difficult and will be replaced in the future.141// This is likely a Turn from Ask mode that does not have tool call rounds.142// Use consistent instances so we can save state on them.143private _filledInMissingRounds: IToolCallRound[] | undefined;144145get rounds(): readonly IToolCallRound[] {146const metadata = this.resultMetadata;147const rounds = metadata?.toolCallRounds;148if (!rounds || rounds.length === 0) {149if (this._filledInMissingRounds?.length) {150return this._filledInMissingRounds;151}152153// Should always have at least one round154const response = this.responseMessage?.message ?? '';155this._filledInMissingRounds = [new ToolCallRound(response, [], undefined, this.id)];156return this._filledInMissingRounds;157}158159return rounds;160}161162setResponse(status: TurnStatus, message: TurnMessage | undefined, responseId: string | undefined, chatResult: ChatResult | undefined) {163if (this._responseInfo?.status === TurnStatus.Cancelled) {164// The cancelled result can be assigned from inside ToolCallingLoop165return;166}167168assertType(!this._responseInfo);169this._responseInfo = { message, status, responseId, chatResult };170}171172173// --- metadata174// Using 'any' for constructor args here because TS will complain about passing any class if 'unknown' is used, I'm not totally sure why.175// The idea of this is that you pass in a class and we return instances of that class.176177// eslint-disable-next-line @typescript-eslint/no-explicit-any178getMetadata<T extends object>(key: new (...args: any[]) => T): T | undefined {179return this._metadata.get(key)?.at(-1) as T | undefined;180}181182// eslint-disable-next-line @typescript-eslint/no-explicit-any183getAllMetadata<T extends object>(key: new (...args: any[]) => T): T[] | undefined {184return this._metadata.get(key) as T[] | undefined;185}186187setMetadata<T extends object>(value: T): void {188const key = Object.getPrototypeOf(value).constructor;189const arr = this._metadata.get(key) ?? [];190arr.push(value);191this._metadata.set(key, arr);192}193194/**195* Store a background-compaction summary on this turn so it can be picked up196* by `normalizeSummariesOnRounds` even before `setResponse` is called197* (i.e. while the tool-call loop is still running).198*/199addPendingSummary(toolCallRoundId: string, text: string): void {200this._pendingSummaries.push({ toolCallRoundId, text });201}202203get pendingSummaries(): readonly { toolCallRoundId: string; text: string }[] {204return this._pendingSummaries;205}206}207208// TODO handle persisted 'previous' and '' IDs (?)209// 'previous' -> last tool call round of previous turn210// '' -> current turn, but with user message211/**212* Move summaries from metadata onto rounds.213* This is needed for summaries that were produced for a different turn than the current one, because we can only214* return resultMetadata from a particular request for the current turn, and can't modify the data for previous turns.215*/216export function normalizeSummariesOnRounds(turns: readonly Turn[]): void {217for (const [idx, turn] of turns.entries()) {218// Try persisted summaries from resultMetadata first, fall back to pending219// summaries that were stored during the tool-call loop (before setResponse).220const turnSummaries = turn.resultMetadata?.summaries ?? (turn.resultMetadata?.summary ? [turn.resultMetadata.summary] : turn.pendingSummaries);221// Each summary supersedes all previous ones, so only the last one matters for restoration222const turnSummary = turnSummaries.at(-1);223if (!turnSummary) {224continue;225}226const roundInTurn = turn.rounds.find(round => round.id === turnSummary.toolCallRoundId);227if (roundInTurn) {228roundInTurn.summary = turnSummary.text;229} else {230const previousTurns = turns.slice(0, idx);231for (const turn of previousTurns) {232const roundInPreviousTurn = turn.rounds.find(round => round.id === turnSummary.toolCallRoundId);233if (roundInPreviousTurn) {234roundInPreviousTurn.summary = turnSummary.text;235break;236}237}238}239}240}241242export interface IConversationState {243readonly turns: Turn[];244}245246export class Conversation {247248private readonly _turns: Turn[] = [];249250constructor(251readonly sessionId: string,252turns: Turn[]253) {254assertType(turns.length > 0, 'A conversation must have at least one turn');255this._turns = turns;256}257258get turns(): readonly Turn[] {259return this._turns;260}261262getLatestTurn(): Turn {263return this._turns.at(-1)!; // safe, we checked for length in the ctor264}265}266267268export type ResponseStreamParticipant = (inStream: ChatResponseStream) => ChatResponseStream;269270export function getUniqueReferences(references: PromptReference[]): PromptReference[] {271const groupedPromptReferences: ResourceMap<PromptReference[] | PromptReference> = new ResourceMap();272const variableReferences: PromptReference[] = [];273274const getCombinedRange = (a: Range, b: Range): Range | undefined => {275if (a.contains(b)) {276return a;277}278279if (b.contains(a)) {280return b;281}282283const [firstRange, lastRange] = (a.start.line < b.start.line) ? [a, b] : [b, a];284// check if a is before b285if (firstRange.end.line >= (lastRange.start.line - 1)) {286return new Range(firstRange.start, lastRange.end);287}288289return undefined;290};291292// remove overlaps from within the same promptContext293references.forEach(targetReference => {294const refAnchor = targetReference.anchor;295if ('variableName' in refAnchor) {296variableReferences.push(targetReference);297} else if (!isLocation(refAnchor)) {298groupedPromptReferences.set(refAnchor, targetReference);299} else {300// reference is a range301const existingRefs = groupedPromptReferences.get(refAnchor.uri);302const asValidLocation = toLocation(refAnchor);303if (!asValidLocation) {304return;305}306if (!existingRefs) {307groupedPromptReferences.set(refAnchor.uri, [new PromptReference(asValidLocation, undefined, targetReference.options)]);308} else if (!(existingRefs instanceof PromptReference)) {309// check if existingRefs isn't already a full file310const oldLocationsToKeep: Location[] = [];311let newRange = asValidLocation.range;312existingRefs.forEach(existingRef => {313if ('variableName' in existingRef.anchor) {314return;315}316317if (!isLocation(existingRef.anchor)) {318// this shouldn't be the case, since all PromptReferences added as part of an array should be ranges319return;320}321const existingRange = toLocation(existingRef.anchor);322if (!existingRange) {323return;324}325const combinedRange = getCombinedRange(newRange, existingRange.range);326if (combinedRange) {327// if we can consume this range, incorporate it into the new range and don't add it to the locations to keep328newRange = combinedRange;329} else {330oldLocationsToKeep.push(existingRange);331}332});333const newRangeLocation: Location = {334uri: refAnchor.uri,335range: newRange,336};337groupedPromptReferences.set(338refAnchor.uri,339[...oldLocationsToKeep, newRangeLocation]340.sort((a, b) => a.range.start.line - b.range.start.line || a.range.end.line - b.range.end.line)341.map(location => new PromptReference(location, undefined, targetReference.options)));342343}344}345});346347// sort values348const finalValues = Array.from(groupedPromptReferences.keys())349.sort((a, b) => a.toString().localeCompare(b.toString()))350.map(e => {351const values = groupedPromptReferences.get(e);352if (!values) {353// should not happen, these are all keys354return [];355}356return values;357}).flat();358359return [360...finalValues,361...variableReferences362];363}364365export type CodeBlock = { readonly code: string; readonly language?: string; readonly resource?: URI; readonly markdownBeforeBlock?: string };366367export interface IResultMetadata {368modelMessageId: string;369responseId: string;370sessionId: string;371agentId: string;372/** The user message exactly as it must be rendered in history. Should not be optional, but not every prompt will adopt this immediately */373renderedUserMessage?: Raw.ChatCompletionContentPart[];374renderedGlobalContext?: Raw.ChatCompletionContentPart[];375globalContextCacheKey?: string;376command?: string;377filterCategory?: FilterReason;378379/**380* All code blocks that were in the response381*/382codeBlocks?: readonly CodeBlock[];383384toolCallRounds?: readonly IToolCallRound[];385toolCallResults?: Record<string, LanguageModelToolResult>;386maxToolCallsExceeded?: boolean;387/**388* @deprecated Use `summaries` instead. Kept for backward compatibility with389* persisted messages that were saved before `summaries` was introduced.390* `normalizeSummariesOnRounds` falls back to this field when `summaries` is absent.391* Safe to remove once all persisted conversations have migrated.392*/393summary?: {394toolCallRoundId: string;395text: string;396source?: 'foreground' | 'background';397outcome?: string;398model?: string;399summarizationMode?: string;400durationMs?: number;401contextLengthBefore?: number;402numRounds?: number;403numRoundsSinceLastSummarization?: number;404usage?: { prompt_tokens: number; completion_tokens: number; prompt_tokens_details?: { cached_tokens?: number } };405};406summaries?: readonly {407toolCallRoundId: string;408text: string;409source?: 'foreground' | 'background';410outcome?: string;411model?: string;412summarizationMode?: string;413durationMs?: number;414contextLengthBefore?: number;415numRounds?: number;416numRoundsSinceLastSummarization?: number;417usage?: { prompt_tokens: number; completion_tokens: number; prompt_tokens_details?: { cached_tokens?: number } };418}[];419resolvedModel?: string;420promptTokens?: number;421outputTokens?: number;422shouldAutoSwitchToAuto?: boolean;423}424425/** There may be no metadata for results coming from old persisted messages, or from messages that are currently in progress (TODO, try to handle this case) */426export interface ICopilotChatResultIn extends ChatResult {427metadata?: Partial<IResultMetadata>;428}429430export interface ICopilotChatResult extends ChatResult {431metadata: IResultMetadata;432}433434export class RenderedUserMessageMetadata {435constructor(436readonly renderedUserMessage: Raw.ChatCompletionContentPart[],437) { }438}439440export class GlobalContextMessageMetadata {441constructor(442readonly renderedGlobalContext: Raw.ChatCompletionContentPart[],443readonly cacheKey: string444) { }445}446447/**448* Metadata capturing token usage information from Anthropic Messages API.449* Stores prompt tokens and output tokens for each turn.450* This metadata is used to trigger summarization when token usage exceeds thresholds.451*/452export class AnthropicTokenUsageMetadata {453constructor(454/** Total number of prompt input tokens */455readonly promptTokens: number,456/** Number of output/completion tokens */457readonly outputTokens: number,458) { }459}460461export function getGlobalContextCacheKey(accessor: ServicesAccessor): string {462const workspaceService = accessor.get(IWorkspaceService);463return workspaceService.getWorkspaceFolders().map(folder => folder.toString()).join(',');464}465466467