-
Notifications
You must be signed in to change notification settings - Fork 1.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
code snippet provider #13018
base: main
Are you sure you want to change the base?
code snippet provider #13018
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,185 @@ | ||
/* -------------------------------------------------------------------------------------------- | ||
* Copyright (c) Microsoft Corporation. All Rights Reserved. | ||
* See 'LICENSE' in the project root for license information. | ||
* ------------------------------------------------------------------------------------------ */ | ||
import * as vscode from 'vscode'; | ||
import { DocumentSelector } from 'vscode-languageserver-protocol'; | ||
import { getOutputChannelLogger, Logger } from '../logger'; | ||
import * as telemetry from '../telemetry'; | ||
import { CopilotContextTelemetry } from './copilotContextTelemetry'; | ||
import { getCopilotApi } from './copilotProviders'; | ||
import { clients } from './extension'; | ||
import { CodeSnippet, CompletionContext, ContextProviderApiV1, ContextResolver } from './tmp/contextProviderV1'; | ||
|
||
class DefaultValueFallback extends Error { | ||
static readonly DefaultValue = "DefaultValue"; | ||
constructor() { super(DefaultValueFallback.DefaultValue); } | ||
} | ||
|
||
class CancellationError extends Error { | ||
static readonly Cancelled = "Cancelled"; | ||
constructor() { super(CancellationError.Cancelled); } | ||
} | ||
|
||
// Mutually exclusive values for the kind of snippets. They either are: | ||
// - computed. | ||
// - obtained from the cache. | ||
// - missing and the computation is taking too long and no cache is present (cache miss). The value | ||
// is asynchronously computed and stored in cache. | ||
// - the token is signaled as cancelled, in which case all the operations are aborted. | ||
// - an unknown state. | ||
enum SnippetsKind { | ||
Computed = 'computed', | ||
GotFromCache = 'gotFromCacheHit', | ||
MissingCacheMiss = 'missingCacheMiss', | ||
Cancelled = 'cancelled', | ||
Unknown = 'unknown' | ||
} | ||
|
||
export class CopilotCompletionContextProvider implements ContextResolver<CodeSnippet> { | ||
private static readonly providerId = 'cppTools'; | ||
private readonly completionContextCache: Map<string, CodeSnippet[]> = new Map<string, CodeSnippet[]>(); | ||
private static readonly defaultCppDocumentSelector: DocumentSelector = [{ language: 'cpp' }, { language: 'c' }, { language: 'cuda-cpp' }]; | ||
private static readonly defaultTimeBudgetFactor: number = 0.5; | ||
private completionContextCancellation = new vscode.CancellationTokenSource(); | ||
|
||
// Get the default value if the timeout expires, but throws an exception if the token is cancelled. | ||
private async waitForCompletionWithTimeoutAndCancellation<T>(promise: Promise<T>, defaultValue: T | undefined, | ||
timeout: number, token: vscode.CancellationToken): Promise<[T | undefined, SnippetsKind]> { | ||
const defaultValuePromise = new Promise<T>((resolve, reject) => setTimeout(() => { | ||
if (token.isCancellationRequested) { | ||
reject(new CancellationError()); | ||
} else { | ||
reject(new DefaultValueFallback()); | ||
} | ||
}, timeout)); | ||
const cancellationPromise = new Promise<T>((_, reject) => { | ||
token.onCancellationRequested(() => { | ||
reject(new CancellationError()); | ||
}); | ||
}); | ||
let snippetsOrNothing: T | undefined; | ||
try { | ||
snippetsOrNothing = await Promise.race([promise, cancellationPromise, defaultValuePromise]); | ||
} catch (e) { | ||
if (e instanceof DefaultValueFallback) { | ||
return [defaultValue, defaultValue !== undefined ? SnippetsKind.GotFromCache : SnippetsKind.MissingCacheMiss]; | ||
} else if (e instanceof CancellationError) { | ||
return [undefined, SnippetsKind.Cancelled]; | ||
} else { | ||
throw e; | ||
} | ||
} | ||
|
||
return [snippetsOrNothing, SnippetsKind.Computed]; | ||
} | ||
|
||
// Get the completion context with a timeout and a cancellation token. | ||
// The cancellationToken indicates that the value should not be returned nor cached. | ||
private async getCompletionContextWithCancellation(documentUri: string, caretOffset: number, | ||
startTime: number, out: Logger, telemetry: CopilotContextTelemetry, token: vscode.CancellationToken): Promise<CodeSnippet[]> { | ||
try { | ||
const docUri = vscode.Uri.parse(documentUri); | ||
const snippets = await clients.getClientFor(docUri).getCompletionContext(docUri, caretOffset, token); | ||
|
||
const codeSnippets = snippets.context.map((item) => { | ||
if (token.isCancellationRequested) { | ||
telemetry.addCancelledLate(); | ||
throw new CancellationError(); | ||
} | ||
return { | ||
importance: item.importance, uri: item.uri, value: item.text | ||
}; | ||
}); | ||
|
||
this.completionContextCache.set(documentUri, codeSnippets); | ||
const duration: number = performance.now() - startTime; | ||
out.appendLine(`Copilot: getCompletionContextWithCancellation(): Cached in [ms]: ${duration}`); | ||
telemetry.addSnippetCount(codeSnippets?.length); | ||
telemetry.addCacheComputedElapsed(duration); | ||
|
||
return codeSnippets; | ||
} catch (e) { | ||
const err = e as Error; | ||
out.appendLine(`Copilot: getCompletionContextWithCancellation(): Error: '${err?.message}', stack '${err?.stack}`); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It doesn't seem like we should output an error message on Cancel, since Cancelling seems like a non-error. Also, it seems like telemetry would want that tracked differently too. |
||
telemetry.addError(); | ||
return []; | ||
} | ||
} | ||
|
||
private async fetchTimeBudgetFactor(context: CompletionContext): Promise<number> { | ||
const budgetFactor = context.activeExperiments.get("CppToolsCopilotTimeBudget"); | ||
return (budgetFactor as number) !== undefined ? budgetFactor as number : CopilotCompletionContextProvider.defaultTimeBudgetFactor; | ||
} | ||
|
||
public static async Create() { | ||
const copilotCompletionProvider = new CopilotCompletionContextProvider(); | ||
await copilotCompletionProvider.registerCopilotContextProvider(); | ||
return copilotCompletionProvider; | ||
} | ||
|
||
public removeFile(fileUri: string): void { | ||
this.completionContextCache.delete(fileUri); | ||
} | ||
|
||
public async resolve(context: CompletionContext, copilotAborts: vscode.CancellationToken): Promise<CodeSnippet[]> { | ||
const startTime = performance.now(); | ||
const out: Logger = getOutputChannelLogger(); | ||
const timeBudgetFactor = await this.fetchTimeBudgetFactor(context); | ||
const telemetry = new CopilotContextTelemetry(); | ||
let codeSnippets: CodeSnippet[] | undefined; | ||
let codeSnippetsKind: SnippetsKind = SnippetsKind.Unknown; | ||
try { | ||
this.completionContextCancellation.cancel(); | ||
this.completionContextCancellation = new vscode.CancellationTokenSource(); | ||
const docUri = context.documentContext.uri; | ||
const cachedValue: CodeSnippet[] | undefined = this.completionContextCache.get(docUri.toString()); | ||
const snippetsPromise = this.getCompletionContextWithCancellation(docUri, | ||
context.documentContext.offset, startTime, out, telemetry.fork(), this.completionContextCancellation.token); | ||
[codeSnippets, codeSnippetsKind] = await this.waitForCompletionWithTimeoutAndCancellation( | ||
snippetsPromise, cachedValue, context.timeBudget * timeBudgetFactor, copilotAborts); | ||
if (codeSnippetsKind === SnippetsKind.Cancelled) { | ||
const duration: number = performance.now() - startTime; | ||
out.appendLine(`Copilot: getCompletionContext(): cancelled, elapsed time (ms) : ${duration}`); | ||
telemetry.addCancelled(); | ||
telemetry.addCancellationElapsed(duration); | ||
throw new CancellationError(); | ||
} | ||
telemetry.addSnippetCount(codeSnippets?.length); | ||
return codeSnippets ?? []; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Won't matter if we're the only provider, but we may want to experiment with lowering the importance if the snippets were from the cache. |
||
} catch (e: any) { | ||
telemetry.addError(); | ||
throw e; | ||
} finally { | ||
telemetry.addKind(codeSnippetsKind.toString()); | ||
const duration: number = performance.now() - startTime; | ||
if (codeSnippets === undefined) { | ||
out.appendLine(`Copilot: getCompletionContext(): no snkppets provided (${codeSnippetsKind.toString()}), elapsed time (ms): ${duration}`); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. snkppets->snippets There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you round this duration to a whole number of ms? e.g. |
||
} else { | ||
out.appendLine(`Copilot: getCompletionContext(): provided ${codeSnippets?.length} snippets (${codeSnippetsKind.toString()}), elapsed time (ms): ${duration}`); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you able to log the id of the completion context request here? Or is that not valid here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Related to this I'm seeing "Copilot: getCompletionContext(): provided 148 snippets (gotFromCacheHit)" but then I see it make a request There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the cache just used to return results faster and it still sends a request to update the cache for later? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As mentioned elsewhere, feel free to not log the id if it's not easily available. I'm not sure if it's worth sending it in the return message just for logging -- it depends on if there's any case where the nearest server-side message may not match the TypeScript message. |
||
} | ||
telemetry.addResolvedElapsed(duration); | ||
telemetry.addCacheSize(this.completionContextCache.size); | ||
// //?? TODO telemetry.file(); | ||
} | ||
|
||
return []; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
} | ||
|
||
public async registerCopilotContextProvider(): Promise<void> { | ||
try { | ||
const isCustomSnippetProviderApiEnabled = await telemetry.isExperimentEnabled("CppToolsCustomSnippetsApi"); | ||
if (isCustomSnippetProviderApiEnabled) { | ||
const contextAPI = (await getCopilotApi() as any).getContextProviderAPI('v1') as ContextProviderApiV1; | ||
contextAPI.registerContextProvider({ | ||
id: CopilotCompletionContextProvider.providerId, | ||
selector: CopilotCompletionContextProvider.defaultCppDocumentSelector, | ||
resolver: this | ||
}); | ||
} | ||
} catch { | ||
console.warn("Failed to register the Copilot Context Provider."); | ||
telemetry.logCopilotEvent("registerCopilotContextProviderError", { "message": "Failed to register the Copilot Context Provider." }); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
/* -------------------------------------------------------------------------------------------- | ||
* Copyright (c) Microsoft Corporation. All Rights Reserved. | ||
* See 'LICENSE' in the project root for license information. | ||
* ------------------------------------------------------------------------------------------ */ | ||
import { randomUUID } from 'crypto'; | ||
import * as telemetry from '../telemetry'; | ||
|
||
export class CopilotContextTelemetry { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. more appropriate name should be CopilotCompletionContextTelemetry |
||
private static readonly correlationIdKey = 'correlationId'; | ||
private static readonly copilotEventName = 'copilotContextProvider'; | ||
private readonly metrics: Record<string, number> = {}; | ||
private readonly properties: Record<string, string> = {}; | ||
private readonly id: string; | ||
constructor(correlationId?: string) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There should be a blank newline before There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll add it manually Should this be enforced by whatever the formatter is? I think I have enabled ESLint and formatting the document should be the "correct" one. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure which formatter we're using for TypeScript. We have I think eslint formatting is deprecated. I'm not sure if there's a formatter rule for a newline before a member function, but feel free to add one if you know of a rule. |
||
this.id = correlationId ?? randomUUID().toString(); | ||
} | ||
|
||
private addMetric(key: string, value: number): void { | ||
this.metrics[key] = value; | ||
} | ||
|
||
private addProperty(key: string, value: string): void { | ||
this.properties[key] = value; | ||
} | ||
|
||
public addCancelled(): void { | ||
this.addProperty('cancelled', 'true'); | ||
} | ||
|
||
public addCancellationElapsed(duration: number): void { | ||
this.addMetric('cancellationElapsedMs', duration); | ||
} | ||
|
||
public addCancelledLate(): void { | ||
this.addProperty('cancelledLate', 'true'); | ||
} | ||
|
||
public addError(): void { | ||
this.addProperty('error', 'true'); | ||
} | ||
|
||
public addKind(snippetsKind: string): void { | ||
this.addProperty('kind', snippetsKind.toString()); | ||
} | ||
|
||
public addResolvedElapsed(duration: number): void { | ||
this.addMetric('overallResolveElapsedMs', duration); | ||
} | ||
|
||
public addCacheSize(size: number): void { | ||
this.addMetric('cacheSize', size); | ||
} | ||
|
||
public addCacheComputedElapsed(duration: number): void { | ||
this.addMetric('cacheComputedElapsedMs', duration); | ||
} | ||
|
||
// count can be undefined, in which case the count is set to -1 to indicate | ||
// snippets are not available (different than having 0 snippets). | ||
public addSnippetCount(count?: number) { | ||
this.addMetric('snippetsCount', count ?? -1); | ||
} | ||
|
||
public file(): void { | ||
this.properties[CopilotContextTelemetry.correlationIdKey] = this.id; | ||
telemetry.logCopilotEvent(CopilotContextTelemetry.copilotEventName, this.properties, this.metrics); | ||
} | ||
|
||
public fork(): CopilotContextTelemetry { | ||
return new CopilotContextTelemetry(this.id); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is not accurate anymore, needs an update