Path: blob/main/extensions/copilot/src/extension/prompt/node/pseudoStartStopConversationCallback.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 { disableErrorLogging, parse as parsePartialJson } from 'best-effort-json-parser';7import type { ChatResponseStream, ChatVulnerability } from 'vscode';8import { IResponsePart } from '../../../platform/chat/common/chatMLFetcher';9import { IResponseDelta } from '../../../platform/networking/common/fetch';10import { FilterReason } from '../../../platform/networking/common/openai';11import { isEncryptedThinkingDelta } from '../../../platform/thinking/common/thinking';12import { CancellationToken } from '../../../util/vs/base/common/cancellation';13import { URI } from '../../../util/vs/base/common/uri';14import { ChatResponseClearToPreviousToolInvocationReason } from '../../../vscodeTypes';15import { getContributedToolName } from '../../tools/common/toolNames';16import { IResponseProcessor, IResponseProcessorContext } from './intents';1718disableErrorLogging();1920export interface StartStopMapping {21readonly stop: string;22readonly start?: string;23}2425/**26* This IConversationCallback skips over text that is between a start and stop word and processes it for output if applicable.27*/28export class PseudoStopStartResponseProcessor implements IResponseProcessor {29private stagedDeltasToApply: IResponseDelta[] = [];30private currentStartStop: StartStopMapping | undefined = undefined;31private nonReportedDeltas: IResponseDelta[] = [];32private thinkingActive: boolean = false;3334private static readonly _toolStreamThrottleMs = 100;35private readonly _lastToolStreamUpdate = new Map<string, number>();36private readonly _pendingToolStreamUpdates = new Map<string, { id: string; arguments: string | undefined }>();3738constructor(39private readonly stopStartMappings: readonly StartStopMapping[],40private readonly processNonReportedDelta: ((deltas: IResponseDelta[]) => string[]) | undefined,41private readonly options?: { subagentInvocationId?: string }42) { }4344async processResponse(_context: IResponseProcessorContext, inputStream: AsyncIterable<IResponsePart>, outputStream: ChatResponseStream, token: CancellationToken): Promise<void> {45return this.doProcessResponse(inputStream, outputStream, token);46}4748async doProcessResponse(responseStream: AsyncIterable<IResponsePart>, progress: ChatResponseStream, token: CancellationToken): Promise<void> {49try {50for await (const { delta } of responseStream) {51if (token.isCancellationRequested) {52return;53}54this.applyDelta(delta, progress);55}56} finally {57if (token.isCancellationRequested) {58this._clearPendingToolStreamUpdates();59} else {60this._flushPendingToolStreamUpdates(progress);61}62}63}6465private _clearPendingToolStreamUpdates(): void {66this._pendingToolStreamUpdates.clear();67this._lastToolStreamUpdate.clear();68}6970private _flushPendingToolStreamUpdates(progress: ChatResponseStream): void {71for (const update of this._pendingToolStreamUpdates.values()) {72progress.updateToolInvocation(update.id, { partialInput: tryParsePartialToolInput(update.arguments) });73}74this._clearPendingToolStreamUpdates();75}7677protected applyDeltaToProgress(delta: IResponseDelta, progress: ChatResponseStream) {78if (delta.thinking) {79// Don't send parts that are only encrypted content80if (!isEncryptedThinkingDelta(delta.thinking) || delta.thinking.text) {81progress.thinkingProgress(delta.thinking);82this.thinkingActive = true;83}84} else if (this.thinkingActive) {85progress.thinkingProgress({ id: '', text: '', metadata: { vscodeReasoningDone: true, stopReason: delta.text ? 'text' : 'other' } });86this.thinkingActive = false;87}8889reportCitations(delta, progress);9091const vulnerabilities: ChatVulnerability[] | undefined = delta.codeVulnAnnotations?.map(a => ({ title: a.details.type, description: a.details.description }));92if (vulnerabilities?.length) {93progress.markdownWithVulnerabilities(delta.text ?? '', vulnerabilities);94} else if (delta.text) {95progress.markdown(delta.text);96}9798if (delta.beginToolCalls?.length) {99for (const beginCall of delta.beginToolCalls) {100progress.beginToolInvocation(beginCall.id ?? '', getContributedToolName(beginCall.name), { subagentInvocationId: this.options?.subagentInvocationId });101}102}103104if (delta.copilotToolCallStreamUpdates?.length) {105const now = Date.now();106for (const update of delta.copilotToolCallStreamUpdates) {107if (!update.name) {108continue;109}110const toolId = update.id ?? '';111const lastUpdate = this._lastToolStreamUpdate.get(toolId) ?? 0;112if (now - lastUpdate >= PseudoStopStartResponseProcessor._toolStreamThrottleMs) {113this._lastToolStreamUpdate.set(toolId, now);114this._pendingToolStreamUpdates.delete(toolId);115progress.updateToolInvocation(toolId, { partialInput: tryParsePartialToolInput(update.arguments) });116} else {117this._pendingToolStreamUpdates.set(toolId, { id: toolId, arguments: update.arguments });118}119}120}121}122123/**124* Update the stagedDeltasToApply list: consume deltas up to `idx` and return them, and delete `length` after that125*/126private updateStagedDeltasUpToIndex(stopWordIdx: number, length: number): IResponseDelta[] {127const result: IResponseDelta[] = [];128for (let deltaOffset = 0; deltaOffset < stopWordIdx + length;) {129const delta = this.stagedDeltasToApply.shift();130if (delta) {131if (deltaOffset + delta.text.length <= stopWordIdx) {132// This delta is in the prefix, return it133result.push(delta);134} else if (deltaOffset < stopWordIdx || deltaOffset < stopWordIdx + length) {135// This delta goes over the stop word, split it136if (deltaOffset < stopWordIdx) {137const prefixDelta = { ...delta };138prefixDelta.text = delta.text.substring(0, stopWordIdx - deltaOffset);139result.push(prefixDelta);140}141142// This is copying the annotation onto both sides of the split delta, better to be safe143const postfixDelta = { ...delta };144postfixDelta.text = delta.text.substring((stopWordIdx - deltaOffset) + length);145if (postfixDelta.text) {146this.stagedDeltasToApply.unshift(postfixDelta);147}148149} else {150// This one is already over the idx, delete it151}152153deltaOffset += delta.text.length;154} else {155break;156}157}158159return result;160}161162protected checkForKeyWords(pseudoStopWords: string[], delta: IResponseDelta, applyDeltaToProgress: (delta: IResponseDelta) => void): string | undefined {163const textDelta = this.stagedDeltasToApply.map(d => d.text).join('') + delta.text;164165// Find out if we have a complete stop word166for (const pseudoStopWord of pseudoStopWords) {167const stopWordIndex = textDelta.indexOf(pseudoStopWord);168if (stopWordIndex === -1) {169continue;170}171172// We have a stop word, so apply the text up to the stop word173this.stagedDeltasToApply.push(delta);174const deltasToReport = this.updateStagedDeltasUpToIndex(stopWordIndex, pseudoStopWord.length);175deltasToReport.forEach(item => applyDeltaToProgress(item));176177return pseudoStopWord;178}179180// We now need to find out if we have a partial stop word181for (const pseudoStopWord of pseudoStopWords) {182for (let i = pseudoStopWord.length - 1; i > 0; i--) {183const partialStopWord = pseudoStopWord.substring(0, i);184if (textDelta.endsWith(partialStopWord)) {185// We have a partial stop word, so we must stage the text and wait for the rest186this.stagedDeltasToApply = [...this.stagedDeltasToApply, delta];187return;188}189}190}191192// We have no stop word or partial, so apply the text to the progress and turn193[...this.stagedDeltasToApply, delta].forEach(item => {194applyDeltaToProgress(item);195});196this.stagedDeltasToApply = [];197198return;199}200201private postReportRecordProgress(delta: IResponseDelta) {202this.nonReportedDeltas.push(delta);203}204205protected applyDelta(delta: IResponseDelta, progress: ChatResponseStream): void {206if (delta.retryReason) {207this.stagedDeltasToApply = [];208this.currentStartStop = undefined;209this.nonReportedDeltas = [];210this.thinkingActive = false;211this._clearPendingToolStreamUpdates();212if (delta.retryReason === 'network_error' || delta.retryReason === 'server_error') {213progress.clearToPreviousToolInvocation(ChatResponseClearToPreviousToolInvocationReason.NoReason);214} else if (delta.retryReason === FilterReason.Copyright) {215progress.clearToPreviousToolInvocation(ChatResponseClearToPreviousToolInvocationReason.CopyrightContentRetry);216} else {217progress.clearToPreviousToolInvocation(ChatResponseClearToPreviousToolInvocationReason.FilteredContentRetry);218}219return;220}221if (this.currentStartStop === undefined) {222const stopWord = this.checkForKeyWords(this.stopStartMappings.map(e => e.stop), delta, delta => this.applyDeltaToProgress(delta, progress));223if (stopWord) {224this.currentStartStop = this.stopStartMappings.find(e => e.stop === stopWord);225}226return;227} else {228if (!this.currentStartStop.start) {229return;230}231const startWord = this.checkForKeyWords([this.currentStartStop.start], delta, this.postReportRecordProgress.bind(this));232if (startWord) {233if (this.processNonReportedDelta) {234const postProcessed = this.processNonReportedDelta(this.nonReportedDeltas);235postProcessed.forEach((text) => this.applyDeltaToProgress({ text }, progress)); // processNonReportedDelta should not return anything that would have annotations236}237238this.currentStartStop = undefined;239if (this.stagedDeltasToApply.length > 0) {240// since there's no guarantee that applyDelta will be called again, flush the stagedTextToApply by applying a blank string241this.applyDelta({ text: '' }, progress);242}243}244}245}246}247248/**249* Note- IPCitations (snippy) are disabled in non-prod builds. See packagejson.ts, isProduction.250*/251export function reportCitations(delta: IResponseDelta, progress: ChatResponseStream): void {252const citations = delta.ipCitations;253if (citations?.length) {254citations.forEach(c => {255const licenseLabel = c.citations.license === 'NOASSERTION' ?256l10n.t('unknown') :257c.citations.license;258progress.codeCitation(URI.parse(c.citations.url), licenseLabel, c.citations.snippet);259});260}261}262263/**264* Attempts to parse partial JSON using best-effort parsing.265* For streaming tool call arguments, the JSON arrives incrementally.266*/267function tryParsePartialToolInput(raw: string | undefined): unknown {268if (!raw) {269return raw;270}271272try {273// Certain patterns, especially partially-generated unicode escape sequences, cause this to throw.274return parsePartialJson(raw);275} catch {276return undefined;277}278}279280281