Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,10 @@
"isAgent": true,
"when": "config.chat.agent.enabled",
"commands": [
{
"name": "compact",
"description": "%copilot.agent.compact.description%"
},
{
"name": "error",
"description": "Make a model request which will result in an error",
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
"copilot.title": "Build with Agent",
"copilot.edits.description": "Edit files in your workspace",
"copilot.agent.description": "Edit files in your workspace in agent mode",
"copilot.agent.compact.description": "Summarize the conversation history to reduce context size",
"copilot.workspace.description": "Ask about your workspace",
"copilot.workspace.sampleRequest": "How do I build this project?",
"copilot.workspace.explain.description": "Explain how the code in your active editor works",
Expand Down
4 changes: 4 additions & 0 deletions src/extension/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const enum Intent {
SearchPanel = 'searchPanel',
SearchKeywords = 'searchKeywords',
AskAgent = 'askAgent',
Compact = 'compact',
}

export const GITHUB_PLATFORM_AGENT = 'github.copilot-dynamic.platform';
Expand All @@ -54,6 +55,9 @@ export const agentsToCommands: Partial<Record<Intent, Record<string, Intent>>> =
[Intent.Terminal]: {
'explain': Intent.TerminalExplain
},
[Intent.Agent]: {
'compact': Intent.Compact,
},
[Intent.Editor]: {
'doc': Intent.Doc,
'fix': Intent.Fix,
Expand Down
4 changes: 3 additions & 1 deletion src/extension/intents/node/allIntents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { InlineChatIntent } from '../../inlineChat/node/inlineChatIntent';
import { IntentRegistry } from '../../prompt/node/intentRegistry';
import { AgentIntent } from './agentIntent';
import { AskAgentIntent } from './askAgentIntent';
import { CompactIntent } from './compactIntent';
import { InlineDocIntent } from './docIntent';
import { EditCodeIntent } from './editCodeIntent';
import { EditCode2Intent } from './editCodeIntent2';
Expand Down Expand Up @@ -53,5 +54,6 @@ IntentRegistry.setIntents([
new SyncDescriptor(SearchKeywordsIntent),
new SyncDescriptor(AskAgentIntent),
new SyncDescriptor(NotebookEditorIntent),
new SyncDescriptor(InlineChatIntent)
new SyncDescriptor(InlineChatIntent),
new SyncDescriptor(CompactIntent)
]);
266 changes: 266 additions & 0 deletions src/extension/intents/node/compactIntent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as l10n from '@vscode/l10n';
import type * as vscode from 'vscode';
import { ChatFetchResponseType, ChatLocation } from '../../../platform/chat/common/commonTypes';
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider';
import { ILogService } from '../../../platform/log/common/logService';
import { IChatEndpoint } from '../../../platform/networking/common/networking';
import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
import { isCancellationError } from '../../../util/vs/base/common/errors';
import { Event } from '../../../util/vs/base/common/event';
import { StopWatch } from '../../../util/vs/base/common/stopwatch';
import { generateUuid } from '../../../util/vs/base/common/uuid';
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
import { Intent } from '../../common/constants';
import { ChatVariablesCollection } from '../../prompt/common/chatVariablesCollection';
import { Conversation, IResultMetadata, normalizeSummariesOnRounds, TurnStatus } from '../../prompt/common/conversation';
import { IBuildPromptContext } from '../../prompt/common/intents';
import { ChatTelemetryBuilder } from '../../prompt/node/chatParticipantTelemetry';
import { IDocumentContext } from '../../prompt/node/documentContext';
import { IIntent, IIntentInvocation, IIntentInvocationContext, IIntentSlashCommandInfo, NullIntentInvocation } from '../../prompt/node/intents';
import { ConversationHistorySummarizationPrompt, ConversationHistorySummarizationPromptProps, SummarizedAgentHistoryProps, SummarizedConversationHistoryPropsBuilder } from '../../prompts/node/agent/summarizedConversationHistory';
import { renderPromptElement } from '../../prompts/node/base/promptRenderer';
import { normalizeToolSchema } from '../../tools/common/toolSchemaNormalizer';
import { addCacheBreakpoints } from './cacheBreakpoints';
import { ToolCallingLoop } from './toolCallingLoop';

export class CompactIntent implements IIntent {

static readonly ID = Intent.Compact;

readonly id = CompactIntent.ID;
readonly locations = [ChatLocation.Panel, ChatLocation.Agent];
readonly description = l10n.t('Summarize the conversation history to reduce context size');
readonly commandInfo: IIntentSlashCommandInfo = { allowsEmptyArgs: true };
readonly isListedCapability = false;

constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IEndpointProvider private readonly endpointProvider: IEndpointProvider,
@ILogService private readonly logService: ILogService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IExperimentationService private readonly experimentationService: IExperimentationService,
@ITelemetryService private readonly telemetryService: ITelemetryService,
) { }

async handleRequest(
conversation: Conversation,
request: vscode.ChatRequest,
stream: vscode.ChatResponseStream,
token: CancellationToken,
_documentContext: IDocumentContext | undefined,
_agentName: string,
location: ChatLocation,
_chatTelemetry: ChatTelemetryBuilder,
_onPaused: Event<boolean>,
): Promise<vscode.ChatResult> {
const summarizationId = generateUuid();
const stopwatch = new StopWatch(true);

// Get history from conversation turns, excluding the current /compact request
const allTurns = [...conversation.turns];
// The current turn (the /compact request) is the last one, so get all previous turns
const turns = allTurns.slice(0, -1);
normalizeSummariesOnRounds(turns);

const successfulTurns = turns.filter(t => t.responseStatus === TurnStatus.Success);
if (successfulTurns.length === 0) {
stream.markdown(l10n.t('There is no conversation history to summarize.'));
this.sendTelemetry(summarizationId, 'no_history', '', stopwatch.elapsed());
return {};
}

// Get endpoint for summarization
const forceGpt41 = this.configurationService.getExperimentBasedConfig(ConfigKey.Advanced.AgentHistorySummarizationForceGpt41, this.experimentationService);
const baseEndpoint = await this.endpointProvider.getChatEndpoint(request);
const gpt41Endpoint = await this.endpointProvider.getChatEndpoint('gpt-4.1');
const endpoint = forceGpt41 && (gpt41Endpoint.modelMaxPromptTokens >= baseEndpoint.modelMaxPromptTokens) ?
gpt41Endpoint :
baseEndpoint;

// Build prompt context for summarization
const promptContext: IBuildPromptContext = {
requestId: `compact-${summarizationId}`,
query: '',
history: turns,
chatVariables: new ChatVariablesCollection(),
isContinuation: false,
toolCallRounds: undefined,
toolCallResults: undefined,
conversation,
};

// Get the props for summarization (determines which round to summarize from)
let propsInfo;
try {
propsInfo = this.instantiationService.createInstance(SummarizedConversationHistoryPropsBuilder).getProps({
priority: 0,
endpoint,
location,
promptContext,
maxToolResultLength: 2000,
});
} catch (e) {
this.logService.error(`[CompactIntent] Failed to get summarization props: ${e.message}`);
stream.markdown(l10n.t('There is no conversation history to summarize.'));
this.sendTelemetry(summarizationId, 'no_history', '', stopwatch.elapsed());
return {};
}

stream.progress(l10n.t('Summarizing conversation history...'));

try {
const summary = await this.getSummary(endpoint, propsInfo.props, token);

// Apply summary to the appropriate round in history
this.applySummaryToHistory(turns, summary, propsInfo.summarizedToolCallRoundId);

stream.markdown(l10n.t('Conversation history has been summarized. The summary will be used in place of the full history for subsequent messages.'));

this.sendTelemetry(summarizationId, 'success', endpoint.model, stopwatch.elapsed());

// Return result with summary metadata so it's persisted
return {
metadata: {
summary: {
toolCallRoundId: propsInfo.summarizedToolCallRoundId,
text: summary,
}
} satisfies Partial<IResultMetadata>
};
} catch (e) {
if (isCancellationError(e)) {
this.sendTelemetry(summarizationId, 'cancelled', endpoint.model, stopwatch.elapsed());
throw e;
}

this.logService.error(`[CompactIntent] Summarization failed: ${e.message}`);
stream.markdown(l10n.t('Failed to summarize conversation history. Please try again.'));
this.sendTelemetry(summarizationId, 'error', endpoint.model, stopwatch.elapsed(), e.message);
return {};
}
}

private async getSummary(
endpoint: IChatEndpoint,
props: SummarizedAgentHistoryProps,
token: CancellationToken
): Promise<string> {
// Try full mode first, fall back to simple mode
const forceMode = this.configurationService.getConfig<string | undefined>(ConfigKey.Advanced.AgentHistorySummarizationMode);

if (forceMode === 'simple') {
return this.getSummaryWithMode(endpoint, props, token, true);
}

try {
return await this.getSummaryWithMode(endpoint, props, token, false);
} catch (e) {
if (isCancellationError(e)) {
throw e;
}
this.logService.warn(`[CompactIntent] Full mode summarization failed, falling back to simple mode: ${e.message}`);
return this.getSummaryWithMode(endpoint, props, token, true);
}
}

private async getSummaryWithMode(
endpoint: IChatEndpoint,
props: SummarizedAgentHistoryProps,
token: CancellationToken,
simpleMode: boolean
): Promise<string> {
const summarizationProps: ConversationHistorySummarizationPromptProps = {
...props,
simpleMode
};
const rendered = await renderPromptElement(
this.instantiationService,
endpoint,
ConversationHistorySummarizationPrompt,
summarizationProps,
undefined,
token
);

const promptCacheMode = this.configurationService.getExperimentBasedConfig(ConfigKey.Advanced.AgentHistorySummarizationWithPromptCache, this.experimentationService);
if (promptCacheMode) {
addCacheBreakpoints(rendered.messages);
}

const toolOpts = !simpleMode ? {
tool_choice: 'none' as const,
tools: normalizeToolSchema(
endpoint.family,
undefined,
(tool, rule) => {
this.logService.warn(`Tool ${tool} failed validation: ${rule}`);
},
),
} : undefined;

const response = await endpoint.makeChatRequest2({
debugName: `compact-${simpleMode ? 'simple' : 'full'}`,
messages: ToolCallingLoop.stripInternalToolCallIds(rendered.messages),
finishedCb: undefined,
location: ChatLocation.Other,
requestOptions: {
temperature: 0,
stream: false,
...toolOpts
},
enableRetryOnFilter: true
}, token);

if (response.type !== ChatFetchResponseType.Success) {
throw new Error(`Summarization request failed: ${response.reason ?? response.type}`);
}

return response.value;
}

private applySummaryToHistory(turns: readonly import('../../prompt/common/conversation').Turn[], summary: string, toolCallRoundId: string): void {
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import type annotation here is unusual and appears to be auto-generated or incomplete. The type should be imported at the top of the file along with other types from the conversation module. This inline import makes the code less readable and deviates from standard TypeScript practices.

Copilot uses AI. Check for mistakes.
for (const turn of [...turns].reverse()) {
const round = turn.rounds.find(r => r.id === toolCallRoundId);
if (round) {
round.summary = summary;
return;
}
}
Comment on lines +230 to +236
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the target round is not found (e.g., if toolCallRoundId doesn't match any round in the history), the summary is silently discarded without any logging or error handling. This could make debugging difficult if the summarization appears to fail silently. Consider adding a log warning when the round is not found to help with troubleshooting.

Suggested change
for (const turn of [...turns].reverse()) {
const round = turn.rounds.find(r => r.id === toolCallRoundId);
if (round) {
round.summary = summary;
return;
}
}
let found = false;
for (const turn of [...turns].reverse()) {
const round = turn.rounds.find(r => r.id === toolCallRoundId);
if (round) {
round.summary = summary;
found = true;
break;
}
}
if (!found) {
this.logService.warn(`Failed to apply conversation summary: no round found with id ${toolCallRoundId}.`);
}

Copilot uses AI. Check for mistakes.
}
Comment on lines +229 to +237
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The applySummaryToHistory method mutates the rounds in the turns array, but the turns parameter is typed as readonly. While the implementation creates a new array with [...turns].reverse(), it still mutates the round.summary property of objects in the original turns array. This violates the readonly contract and could lead to unexpected side effects. Consider either removing the readonly modifier from the parameter type or creating deep copies of the turns to avoid mutation.

Copilot uses AI. Check for mistakes.

private sendTelemetry(summarizationId: string, outcome: string, model: string, duration: number, errorMessage?: string): void {
/* __GDPR__
"compactCommand" : {
"owner": "lramos15",
"comment": "Tracks when /compact command is used for manual conversation summarization",
"summarizationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "An ID to identify this summarization task." },
"outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The success state or failure reason of the summarization." },
"model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model ID used for the summarization." },
"duration": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "The duration of the summarization in ms." },
"errorMessage": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Error message if summarization failed." }
}
*/
this.telemetryService.sendMSFTTelemetryEvent('compactCommand', {
summarizationId,
outcome,
model,
errorMessage
}, {
duration
});
}

async invoke(invocationContext: IIntentInvocationContext): Promise<IIntentInvocation> {
// handleRequest is used instead, but we need to return something
const endpoint = await this.endpointProvider.getChatEndpoint(invocationContext.request);
return new NullIntentInvocation(this, invocationContext.location, endpoint);
}
}
Loading