Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/prompt/common/conversation.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { PromptReference, Raw } from '@vscode/prompt-tsx';
7
import type { ChatRequest, ChatRequestEditedFileEvent, ChatResponseStream, ChatResult, LanguageModelToolResult } from 'vscode';
8
import { FilterReason } from '../../../platform/networking/common/openai';
9
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
10
import { isLocation, toLocation } from '../../../util/common/types';
11
import { ResourceMap } from '../../../util/vs/base/common/map';
12
import { assertType } from '../../../util/vs/base/common/types';
13
import { URI } from '../../../util/vs/base/common/uri';
14
import { generateUuid } from '../../../util/vs/base/common/uuid';
15
import { ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation';
16
import { Location, Range } from '../../../vscodeTypes';
17
import { InternalToolReference, IToolCallRound } from '../common/intents';
18
import { ChatVariablesCollection } from './chatVariablesCollection';
19
import { isContinueOnError, isSwitchToAutoOnRateLimit, isToolCallLimitAcceptance } from './specialRequestTypes';
20
import { ToolCallRound } from './toolCallRound';
21
export { PromptReference } from '@vscode/prompt-tsx';
22
23
export enum TurnStatus {
24
InProgress = 'in-progress',
25
Success = 'success',
26
Cancelled = 'cancelled',
27
OffTopic = 'off-topic',
28
Filtered = 'filtered',
29
PromptFiltered = 'prompt-filtered',
30
Error = 'error',
31
}
32
33
export type TurnMessage = {
34
readonly type: 'user' | 'follow-up' | 'template' | 'offtopic-detection' | 'model' | 'meta' | 'server';
35
readonly name?: string;
36
/* readonly */message: string;
37
};
38
39
40
export abstract class PromptMetadata {
41
readonly _marker: undefined;
42
toString(): string {
43
return Object.getPrototypeOf(this).constructor.name;
44
}
45
}
46
47
export class RequestDebugInformation {
48
constructor(
49
readonly uri: URI,
50
readonly intentId: string,
51
readonly languageId: string,
52
readonly initialDocumentText: string,
53
readonly userPrompt: string,
54
readonly userSelection: Range
55
) { }
56
}
57
58
export class Turn {
59
60
private _references: readonly PromptReference[] = [];
61
62
private _responseInfo?: { message: TurnMessage | undefined; status: TurnStatus; responseId: string | undefined; chatResult?: ChatResult };
63
64
private readonly _metadata = new Map<unknown, unknown[]>();
65
66
/** Summaries applied during the tool-call loop, before setResponse is called. */
67
private _pendingSummaries: { toolCallRoundId: string; text: string }[] = [];
68
69
public readonly startTime = Date.now();
70
71
static fromRequest(
72
id: string | undefined,
73
request: ChatRequest
74
) {
75
return new Turn(
76
id,
77
{ message: request.prompt, type: 'user' },
78
new ChatVariablesCollection(request.references),
79
request.toolReferences.map(InternalToolReference.from),
80
request.editedFileEvents,
81
request.acceptedConfirmationData,
82
isToolCallLimitAcceptance(request) || isContinueOnError(request) || isSwitchToAutoOnRateLimit(request),
83
request.modeInstructions2,
84
);
85
}
86
87
constructor(
88
readonly id: string = generateUuid(),
89
readonly request: TurnMessage,
90
private readonly _promptVariables: ChatVariablesCollection | undefined = undefined,
91
private readonly _toolReferences: readonly InternalToolReference[] = [],
92
readonly editedFileEvents?: ChatRequestEditedFileEvent[],
93
readonly acceptedConfirmationData?: unknown[],
94
readonly isContinuation = false,
95
readonly modeInstructions?: ChatRequest['modeInstructions2'],
96
) { }
97
98
get promptVariables(): ChatVariablesCollection | undefined {
99
return this._promptVariables;
100
}
101
102
get toolReferences(): readonly InternalToolReference[] {
103
return this._toolReferences;
104
}
105
106
get references(): readonly PromptReference[] {
107
return this._references;
108
}
109
110
addReferences(newReferences: readonly PromptReference[]) {
111
this._references = getUniqueReferences([...this._references, ...newReferences]);
112
}
113
114
// --- response
115
116
get responseMessage(): TurnMessage | undefined {
117
return this._responseInfo?.message;
118
}
119
120
get responseStatus(): TurnStatus {
121
return this._responseInfo?.status ?? TurnStatus.InProgress;
122
}
123
124
get responseId(): string | undefined {
125
return this._responseInfo?.responseId;
126
}
127
128
get responseChatResult(): ChatResult | undefined {
129
return this._responseInfo?.chatResult;
130
}
131
132
get resultMetadata(): Partial<IResultMetadata> | undefined {
133
return this._responseInfo?.chatResult?.metadata;
134
}
135
136
get renderedUserMessage(): string | Raw.ChatCompletionContentPart[] | undefined {
137
const metadata = this.resultMetadata;
138
return metadata?.renderedUserMessage;
139
}
140
141
// TODO@roblourens Tracking result data in "agent as chat participant" is difficult and will be replaced in the future.
142
// This is likely a Turn from Ask mode that does not have tool call rounds.
143
// Use consistent instances so we can save state on them.
144
private _filledInMissingRounds: IToolCallRound[] | undefined;
145
146
get rounds(): readonly IToolCallRound[] {
147
const metadata = this.resultMetadata;
148
const rounds = metadata?.toolCallRounds;
149
if (!rounds || rounds.length === 0) {
150
if (this._filledInMissingRounds?.length) {
151
return this._filledInMissingRounds;
152
}
153
154
// Should always have at least one round
155
const response = this.responseMessage?.message ?? '';
156
this._filledInMissingRounds = [new ToolCallRound(response, [], undefined, this.id)];
157
return this._filledInMissingRounds;
158
}
159
160
return rounds;
161
}
162
163
setResponse(status: TurnStatus, message: TurnMessage | undefined, responseId: string | undefined, chatResult: ChatResult | undefined) {
164
if (this._responseInfo?.status === TurnStatus.Cancelled) {
165
// The cancelled result can be assigned from inside ToolCallingLoop
166
return;
167
}
168
169
assertType(!this._responseInfo);
170
this._responseInfo = { message, status, responseId, chatResult };
171
}
172
173
174
// --- metadata
175
// Using 'any' for constructor args here because TS will complain about passing any class if 'unknown' is used, I'm not totally sure why.
176
// The idea of this is that you pass in a class and we return instances of that class.
177
178
// eslint-disable-next-line @typescript-eslint/no-explicit-any
179
getMetadata<T extends object>(key: new (...args: any[]) => T): T | undefined {
180
return this._metadata.get(key)?.at(-1) as T | undefined;
181
}
182
183
// eslint-disable-next-line @typescript-eslint/no-explicit-any
184
getAllMetadata<T extends object>(key: new (...args: any[]) => T): T[] | undefined {
185
return this._metadata.get(key) as T[] | undefined;
186
}
187
188
setMetadata<T extends object>(value: T): void {
189
const key = Object.getPrototypeOf(value).constructor;
190
const arr = this._metadata.get(key) ?? [];
191
arr.push(value);
192
this._metadata.set(key, arr);
193
}
194
195
/**
196
* Store a background-compaction summary on this turn so it can be picked up
197
* by `normalizeSummariesOnRounds` even before `setResponse` is called
198
* (i.e. while the tool-call loop is still running).
199
*/
200
addPendingSummary(toolCallRoundId: string, text: string): void {
201
this._pendingSummaries.push({ toolCallRoundId, text });
202
}
203
204
get pendingSummaries(): readonly { toolCallRoundId: string; text: string }[] {
205
return this._pendingSummaries;
206
}
207
}
208
209
// TODO handle persisted 'previous' and '' IDs (?)
210
// 'previous' -> last tool call round of previous turn
211
// '' -> current turn, but with user message
212
/**
213
* Move summaries from metadata onto rounds.
214
* This is needed for summaries that were produced for a different turn than the current one, because we can only
215
* return resultMetadata from a particular request for the current turn, and can't modify the data for previous turns.
216
*/
217
export function normalizeSummariesOnRounds(turns: readonly Turn[]): void {
218
for (const [idx, turn] of turns.entries()) {
219
// Try persisted summaries from resultMetadata first, fall back to pending
220
// summaries that were stored during the tool-call loop (before setResponse).
221
const turnSummaries = turn.resultMetadata?.summaries ?? (turn.resultMetadata?.summary ? [turn.resultMetadata.summary] : turn.pendingSummaries);
222
// Each summary supersedes all previous ones, so only the last one matters for restoration
223
const turnSummary = turnSummaries.at(-1);
224
if (!turnSummary) {
225
continue;
226
}
227
const roundInTurn = turn.rounds.find(round => round.id === turnSummary.toolCallRoundId);
228
if (roundInTurn) {
229
roundInTurn.summary = turnSummary.text;
230
} else {
231
const previousTurns = turns.slice(0, idx);
232
for (const turn of previousTurns) {
233
const roundInPreviousTurn = turn.rounds.find(round => round.id === turnSummary.toolCallRoundId);
234
if (roundInPreviousTurn) {
235
roundInPreviousTurn.summary = turnSummary.text;
236
break;
237
}
238
}
239
}
240
}
241
}
242
243
export interface IConversationState {
244
readonly turns: Turn[];
245
}
246
247
export class Conversation {
248
249
private readonly _turns: Turn[] = [];
250
251
constructor(
252
readonly sessionId: string,
253
turns: Turn[]
254
) {
255
assertType(turns.length > 0, 'A conversation must have at least one turn');
256
this._turns = turns;
257
}
258
259
get turns(): readonly Turn[] {
260
return this._turns;
261
}
262
263
getLatestTurn(): Turn {
264
return this._turns.at(-1)!; // safe, we checked for length in the ctor
265
}
266
}
267
268
269
export type ResponseStreamParticipant = (inStream: ChatResponseStream) => ChatResponseStream;
270
271
export function getUniqueReferences(references: PromptReference[]): PromptReference[] {
272
const groupedPromptReferences: ResourceMap<PromptReference[] | PromptReference> = new ResourceMap();
273
const variableReferences: PromptReference[] = [];
274
275
const getCombinedRange = (a: Range, b: Range): Range | undefined => {
276
if (a.contains(b)) {
277
return a;
278
}
279
280
if (b.contains(a)) {
281
return b;
282
}
283
284
const [firstRange, lastRange] = (a.start.line < b.start.line) ? [a, b] : [b, a];
285
// check if a is before b
286
if (firstRange.end.line >= (lastRange.start.line - 1)) {
287
return new Range(firstRange.start, lastRange.end);
288
}
289
290
return undefined;
291
};
292
293
// remove overlaps from within the same promptContext
294
references.forEach(targetReference => {
295
const refAnchor = targetReference.anchor;
296
if ('variableName' in refAnchor) {
297
variableReferences.push(targetReference);
298
} else if (!isLocation(refAnchor)) {
299
groupedPromptReferences.set(refAnchor, targetReference);
300
} else {
301
// reference is a range
302
const existingRefs = groupedPromptReferences.get(refAnchor.uri);
303
const asValidLocation = toLocation(refAnchor);
304
if (!asValidLocation) {
305
return;
306
}
307
if (!existingRefs) {
308
groupedPromptReferences.set(refAnchor.uri, [new PromptReference(asValidLocation, undefined, targetReference.options)]);
309
} else if (!(existingRefs instanceof PromptReference)) {
310
// check if existingRefs isn't already a full file
311
const oldLocationsToKeep: Location[] = [];
312
let newRange = asValidLocation.range;
313
existingRefs.forEach(existingRef => {
314
if ('variableName' in existingRef.anchor) {
315
return;
316
}
317
318
if (!isLocation(existingRef.anchor)) {
319
// this shouldn't be the case, since all PromptReferences added as part of an array should be ranges
320
return;
321
}
322
const existingRange = toLocation(existingRef.anchor);
323
if (!existingRange) {
324
return;
325
}
326
const combinedRange = getCombinedRange(newRange, existingRange.range);
327
if (combinedRange) {
328
// if we can consume this range, incorporate it into the new range and don't add it to the locations to keep
329
newRange = combinedRange;
330
} else {
331
oldLocationsToKeep.push(existingRange);
332
}
333
});
334
const newRangeLocation: Location = {
335
uri: refAnchor.uri,
336
range: newRange,
337
};
338
groupedPromptReferences.set(
339
refAnchor.uri,
340
[...oldLocationsToKeep, newRangeLocation]
341
.sort((a, b) => a.range.start.line - b.range.start.line || a.range.end.line - b.range.end.line)
342
.map(location => new PromptReference(location, undefined, targetReference.options)));
343
344
}
345
}
346
});
347
348
// sort values
349
const finalValues = Array.from(groupedPromptReferences.keys())
350
.sort((a, b) => a.toString().localeCompare(b.toString()))
351
.map(e => {
352
const values = groupedPromptReferences.get(e);
353
if (!values) {
354
// should not happen, these are all keys
355
return [];
356
}
357
return values;
358
}).flat();
359
360
return [
361
...finalValues,
362
...variableReferences
363
];
364
}
365
366
export type CodeBlock = { readonly code: string; readonly language?: string; readonly resource?: URI; readonly markdownBeforeBlock?: string };
367
368
export interface IResultMetadata {
369
modelMessageId: string;
370
responseId: string;
371
sessionId: string;
372
agentId: string;
373
/** The user message exactly as it must be rendered in history. Should not be optional, but not every prompt will adopt this immediately */
374
renderedUserMessage?: Raw.ChatCompletionContentPart[];
375
renderedGlobalContext?: Raw.ChatCompletionContentPart[];
376
globalContextCacheKey?: string;
377
command?: string;
378
filterCategory?: FilterReason;
379
380
/**
381
* All code blocks that were in the response
382
*/
383
codeBlocks?: readonly CodeBlock[];
384
385
toolCallRounds?: readonly IToolCallRound[];
386
toolCallResults?: Record<string, LanguageModelToolResult>;
387
maxToolCallsExceeded?: boolean;
388
/**
389
* @deprecated Use `summaries` instead. Kept for backward compatibility with
390
* persisted messages that were saved before `summaries` was introduced.
391
* `normalizeSummariesOnRounds` falls back to this field when `summaries` is absent.
392
* Safe to remove once all persisted conversations have migrated.
393
*/
394
summary?: {
395
toolCallRoundId: string;
396
text: string;
397
source?: 'foreground' | 'background';
398
outcome?: string;
399
model?: string;
400
summarizationMode?: string;
401
durationMs?: number;
402
contextLengthBefore?: number;
403
numRounds?: number;
404
numRoundsSinceLastSummarization?: number;
405
usage?: { prompt_tokens: number; completion_tokens: number; prompt_tokens_details?: { cached_tokens?: number } };
406
};
407
summaries?: readonly {
408
toolCallRoundId: string;
409
text: string;
410
source?: 'foreground' | 'background';
411
outcome?: string;
412
model?: string;
413
summarizationMode?: string;
414
durationMs?: number;
415
contextLengthBefore?: number;
416
numRounds?: number;
417
numRoundsSinceLastSummarization?: number;
418
usage?: { prompt_tokens: number; completion_tokens: number; prompt_tokens_details?: { cached_tokens?: number } };
419
}[];
420
resolvedModel?: string;
421
promptTokens?: number;
422
outputTokens?: number;
423
shouldAutoSwitchToAuto?: boolean;
424
}
425
426
/** 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) */
427
export interface ICopilotChatResultIn extends ChatResult {
428
metadata?: Partial<IResultMetadata>;
429
}
430
431
export interface ICopilotChatResult extends ChatResult {
432
metadata: IResultMetadata;
433
}
434
435
export class RenderedUserMessageMetadata {
436
constructor(
437
readonly renderedUserMessage: Raw.ChatCompletionContentPart[],
438
) { }
439
}
440
441
export class GlobalContextMessageMetadata {
442
constructor(
443
readonly renderedGlobalContext: Raw.ChatCompletionContentPart[],
444
readonly cacheKey: string
445
) { }
446
}
447
448
/**
449
* Metadata capturing token usage information from Anthropic Messages API.
450
* Stores prompt tokens and output tokens for each turn.
451
* This metadata is used to trigger summarization when token usage exceeds thresholds.
452
*/
453
export class AnthropicTokenUsageMetadata {
454
constructor(
455
/** Total number of prompt input tokens */
456
readonly promptTokens: number,
457
/** Number of output/completion tokens */
458
readonly outputTokens: number,
459
) { }
460
}
461
462
export function getGlobalContextCacheKey(accessor: ServicesAccessor): string {
463
const workspaceService = accessor.get(IWorkspaceService);
464
return workspaceService.getWorkspaceFolders().map(folder => folder.toString()).join(',');
465
}
466
467