mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-30 12:15:32 +01:00
feat: add conversation transcript lookup after summarization (#4475)
* feat: add conversation transcript lookup after summarization After conversation history is compacted, inform the model it can look up the full pre-compaction transcript via read_file. The transcript is a JSONL file produced by ISessionTranscriptService. Key changes: - Add isTranscriptUri() to ISessionTranscriptService for read_file allowlisting - Allowlist transcript URIs in assertFileOkForTool and isFileExternalAndNeedsConfirmation - Lazily start transcript session in SummarizedConversationHistory before summarization runs (idempotent if hooks already started it) - After summarization, flush transcript and pass path to SummaryMessageElement which tells the model about the file - Gate behind ConfigKey.ConversationTranscriptLookup (ExperimentBased, default off) - Add setting in package.json preview section with onExp tag * fix: update transcript lookup instruction to use ToolName.ReadFile
This commit is contained in:
@@ -3235,6 +3235,15 @@
|
||||
"preview",
|
||||
"onExp"
|
||||
]
|
||||
},
|
||||
"github.copilot.chat.conversationTranscriptLookup.enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "%github.copilot.config.conversationTranscriptLookup.enabled%",
|
||||
"tags": [
|
||||
"preview",
|
||||
"onExp"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"github.copilot.config.editsNewNotebook.enabled": "Whether to enable the new notebook tool in Copilot Edits.",
|
||||
"github.copilot.config.notebook.inlineEditAgent.enabled": "Enable agent-like behavior from the notebook inline chat widget.",
|
||||
"github.copilot.config.summarizeAgentConversationHistory.enabled": "Whether to auto-compact agent conversation history once the context window is filled.",
|
||||
"github.copilot.config.conversationTranscriptLookup.enabled": "When enabled, after conversation history is summarized the model is informed it can look up the full conversation transcript via read_file.",
|
||||
"github.copilot.tools.createNewWorkspace.name": "Create New Workspace",
|
||||
"github.copilot.tools.openEmptyFolder.name": "Open an empty folder as VS Code workspace",
|
||||
"github.copilot.tools.getProjectSetupInfo.name": "Get Project Setup Info",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { IEnvService } from '../../../platform/env/common/envService';
|
||||
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
|
||||
import { IFileSystemService, createDirectoryIfNotExists } from '../../../platform/filesystem/common/fileSystemService';
|
||||
import { ILogService } from '../../../platform/log/common/logService';
|
||||
import { extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources';
|
||||
import { URI } from '../../../util/vs/base/common/uri';
|
||||
import { generateUuid } from '../../../util/vs/base/common/uuid';
|
||||
|
||||
@@ -215,6 +216,14 @@ export class SessionTranscriptService implements ISessionTranscriptService {
|
||||
return this._activeSessions.get(sessionId)?.uri;
|
||||
}
|
||||
|
||||
isTranscriptUri(uri: URI): boolean {
|
||||
const dir = this._getTranscriptsDir();
|
||||
if (!dir) {
|
||||
return false;
|
||||
}
|
||||
return extUriBiasedIgnorePathCase.isEqualOrParent(uri, dir);
|
||||
}
|
||||
|
||||
async cleanupOldTranscripts(maxRetained: number = DEFAULT_MAX_RETAINED): Promise<void> {
|
||||
const dir = this._getTranscriptsDir();
|
||||
if (!dir) {
|
||||
|
||||
+66
-2
@@ -10,6 +10,7 @@ import { ChatMessage } from '@vscode/prompt-tsx/dist/base/output/rawTypes';
|
||||
import type { ChatResponsePart, ChatResultPromptTokenDetail, LanguageModelToolInformation, NotebookDocument, Progress } from 'vscode';
|
||||
import { IChatHookService, PreCompactHookInput } from '../../../../platform/chat/common/chatHookService';
|
||||
import { ChatFetchResponseType, ChatLocation, ChatResponse, FetchSuccess } from '../../../../platform/chat/common/commonTypes';
|
||||
import { IHistoricalTurn, ISessionTranscriptService } from '../../../../platform/chat/common/sessionTranscriptService';
|
||||
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
|
||||
import { isAnthropicFamily } from '../../../../platform/endpoint/common/chatModelCapabilities';
|
||||
import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider';
|
||||
@@ -248,7 +249,7 @@ class ConversationHistory extends PromptElement<SummarizedAgentHistoryProps> {
|
||||
}
|
||||
|
||||
if (summaryForCurrentTurn) {
|
||||
history.push(<SummaryMessageElement endpoint={this.props.endpoint} summaryText={summaryForCurrentTurn} />);
|
||||
history.push(<SummaryMessageElement endpoint={this.props.endpoint} summaryText={summaryForCurrentTurn} transcriptPath={this.props.transcriptPath} />);
|
||||
|
||||
return (<PrioritizedList priority={this.props.priority} descending={false} passPriority={true}>
|
||||
{history.reverse()}
|
||||
@@ -308,7 +309,7 @@ class ConversationHistory extends PromptElement<SummarizedAgentHistoryProps> {
|
||||
|
||||
if (summaryForTurn) {
|
||||
// We have a summary for a tool call round that was part of this turn
|
||||
turnComponents.push(<SummaryMessageElement endpoint={this.props.endpoint} summaryText={summaryForTurn.text} />);
|
||||
turnComponents.push(<SummaryMessageElement endpoint={this.props.endpoint} summaryText={summaryForTurn.text} transcriptPath={this.props.transcriptPath} />);
|
||||
} else if (!turn.isContinuation) {
|
||||
turnComponents.push(<AgentUserMessage flexGrow={1} {...getUserMessagePropsFromTurn(turn, this.props.endpoint, {
|
||||
userQueryTagName: this.props.userQueryTagName,
|
||||
@@ -408,6 +409,8 @@ export interface SummarizedAgentHistoryProps extends BasePromptElementProps, Age
|
||||
readonly summarizationInstructions?: string;
|
||||
/** Whether this summarization was triggered as a background or foreground operation. Defaults to 'foreground'. */
|
||||
readonly summarizationSource?: 'background' | 'foreground';
|
||||
/** Path to the conversation transcript JSONL file, used to inform the model after summarization */
|
||||
readonly transcriptPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -417,6 +420,9 @@ export class SummarizedConversationHistory extends PromptElement<SummarizedAgent
|
||||
constructor(
|
||||
props: SummarizedAgentHistoryProps,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@ISessionTranscriptService private readonly sessionTranscriptService: ISessionTranscriptService,
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@IExperimentationService private readonly experimentationService: IExperimentationService,
|
||||
) {
|
||||
super(props);
|
||||
}
|
||||
@@ -424,7 +430,16 @@ export class SummarizedConversationHistory extends PromptElement<SummarizedAgent
|
||||
override async render(state: void, sizing: PromptSizing, progress: Progress<ChatResponsePart> | undefined, token: CancellationToken | undefined) {
|
||||
const promptContext = { ...this.props.promptContext };
|
||||
let historyMetadata: SummarizedConversationHistoryMetadata | undefined;
|
||||
const transcriptLookupEnabled = this.configurationService.getExperimentBasedConfig(ConfigKey.ConversationTranscriptLookup, this.experimentationService);
|
||||
|
||||
if (this.props.triggerSummarize) {
|
||||
// If transcript lookup is enabled, lazily start the transcript session now
|
||||
// (before summarization) so it captures the full pre-compaction conversation.
|
||||
// startSession is idempotent — if hooks already started it, this is a no-op.
|
||||
if (transcriptLookupEnabled) {
|
||||
await this.ensureTranscriptSession();
|
||||
}
|
||||
|
||||
const summarizer = this.instantiationService.createInstance(ConversationHistorySummarizer, this.props, sizing, progress, token);
|
||||
const summResult = await summarizer.summarizeHistory();
|
||||
if (summResult) {
|
||||
@@ -442,15 +457,62 @@ export class SummarizedConversationHistory extends PromptElement<SummarizedAgent
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve transcript path and flush to disk so the model can read the up-to-date file
|
||||
let transcriptPath: string | undefined;
|
||||
if (transcriptLookupEnabled) {
|
||||
const sessionId = this.props.promptContext.conversation?.sessionId;
|
||||
if (sessionId) {
|
||||
const transcriptUri = this.sessionTranscriptService.getTranscriptPath(sessionId);
|
||||
if (transcriptUri) {
|
||||
await this.sessionTranscriptService.flush(sessionId);
|
||||
transcriptPath = transcriptUri.fsPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
{historyMetadata && <meta value={historyMetadata} />}
|
||||
<ConversationHistory
|
||||
{...this.props}
|
||||
promptContext={promptContext}
|
||||
transcriptPath={transcriptPath}
|
||||
enableCacheBreakpoints={this.props.enableCacheBreakpoints} />
|
||||
</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily starts a transcript session with the full conversation history.
|
||||
* This is called just before summarization so that the transcript file
|
||||
* contains the complete pre-compaction conversation. If a session was
|
||||
* already started (e.g. by hooks), this is a no-op.
|
||||
*/
|
||||
private async ensureTranscriptSession(): Promise<void> {
|
||||
const sessionId = this.props.promptContext.conversation?.sessionId;
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build IHistoricalTurn[] from the prompt context's Turn[] history
|
||||
const history: IHistoricalTurn[] = this.props.promptContext.history.map(turn => ({
|
||||
userMessage: turn.request.message,
|
||||
timestamp: turn.startTime,
|
||||
rounds: turn.rounds.map(round => ({
|
||||
response: round.response,
|
||||
toolCalls: round.toolCalls.map(tc => ({
|
||||
name: tc.name,
|
||||
arguments: tc.arguments,
|
||||
id: tc.id,
|
||||
})),
|
||||
reasoningText: round.thinking
|
||||
? (Array.isArray(round.thinking.text) ? round.thinking.text.join('') : round.thinking.text)
|
||||
: undefined,
|
||||
timestamp: round.timestamp,
|
||||
})),
|
||||
}));
|
||||
|
||||
await this.sessionTranscriptService.startSession(sessionId, undefined, history.length > 0 ? history : undefined);
|
||||
}
|
||||
|
||||
private addSummaryToHistory(summary: string, toolCallRoundId: string, thinking?: ThinkingData): void {
|
||||
const round = this.props.promptContext.toolCallRounds?.find(round => round.id === toolCallRoundId);
|
||||
if (round) {
|
||||
@@ -918,6 +980,7 @@ export class SummarizedConversationHistoryPropsBuilder {
|
||||
interface SummaryMessageProps extends BasePromptElementProps {
|
||||
readonly summaryText: string;
|
||||
readonly endpoint: IChatEndpoint;
|
||||
readonly transcriptPath?: string;
|
||||
}
|
||||
|
||||
class SummaryMessageElement extends PromptElement<SummaryMessageProps> {
|
||||
@@ -926,6 +989,7 @@ class SummaryMessageElement extends PromptElement<SummaryMessageProps> {
|
||||
<Tag name='conversation-summary'>
|
||||
{this.props.summaryText}
|
||||
</Tag>
|
||||
{this.props.transcriptPath && <><br />If you need specific details from before compaction (such as exact code snippets, error messages, tool results, or content you previously generated), use the {ToolName.ReadFile} tool to look up the full uncompacted conversation transcript at: {this.props.transcriptPath}</>}
|
||||
{this.props.endpoint.family === 'gpt-4.1' && <Tag name='reminderInstructions'>
|
||||
<DefaultOpenAIKeepGoingReminder />
|
||||
</Tag>}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { PromptElement, PromptPiece } from '@vscode/prompt-tsx';
|
||||
import type * as vscode from 'vscode';
|
||||
import { IChatDebugFileLoggerService } from '../../../platform/chat/common/chatDebugFileLoggerService';
|
||||
import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService';
|
||||
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
|
||||
import { ICustomInstructionsService, IInstructionIndexFile } from '../../../platform/customInstructions/common/customInstructionsService';
|
||||
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
|
||||
@@ -171,6 +172,7 @@ export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI,
|
||||
const diskSessionResources = accessor.get(IChatDiskSessionResources);
|
||||
const configurationService = accessor.get(IConfigurationService);
|
||||
const chatDebugFileLogger = accessor.get(IChatDebugFileLoggerService);
|
||||
const sessionTranscriptService = accessor.get(ISessionTranscriptService);
|
||||
|
||||
await assertFileNotContentExcluded(accessor, uri);
|
||||
|
||||
@@ -194,6 +196,9 @@ export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI,
|
||||
if (chatDebugFileLogger.isDebugLogUri(normalizedUri)) {
|
||||
return;
|
||||
}
|
||||
if (sessionTranscriptService.isTranscriptUri(normalizedUri)) {
|
||||
return;
|
||||
}
|
||||
if (await isExternalInstructionsFile(normalizedUri, customInstructionsService, buildPromptContext)) {
|
||||
return;
|
||||
}
|
||||
@@ -269,6 +274,7 @@ export async function isFileExternalAndNeedsConfirmation(accessor: ServicesAcces
|
||||
const configurationService = accessor.get(IConfigurationService);
|
||||
const fileSystemService = accessor.get(IFileSystemService);
|
||||
const chatDebugFileLogger = accessor.get(IChatDebugFileLoggerService);
|
||||
const sessionTranscriptService = accessor.get(ISessionTranscriptService);
|
||||
|
||||
const normalizedUri = normalizePath(uri);
|
||||
|
||||
@@ -291,6 +297,9 @@ export async function isFileExternalAndNeedsConfirmation(accessor: ServicesAcces
|
||||
if (chatDebugFileLogger.isDebugLogUri(normalizedUri)) {
|
||||
return false;
|
||||
}
|
||||
if (sessionTranscriptService.isTranscriptUri(normalizedUri)) {
|
||||
return false;
|
||||
}
|
||||
if (tabsAndEditorsService.tabs.some(tab => isEqual(tab.uri, uri))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -242,6 +242,12 @@ export interface ISessionTranscriptService {
|
||||
* keeping at most `maxRetained` most-recent ended sessions.
|
||||
*/
|
||||
cleanupOldTranscripts(maxRetained?: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check whether a URI is under the transcripts storage directory.
|
||||
* Used by {@link assertFileOkForTool} to allowlist tool reads.
|
||||
*/
|
||||
isTranscriptUri(uri: URI): boolean;
|
||||
}
|
||||
|
||||
export class NullSessionTranscriptService implements ISessionTranscriptService {
|
||||
@@ -258,4 +264,5 @@ export class NullSessionTranscriptService implements ISessionTranscriptService {
|
||||
async endSession(): Promise<void> { }
|
||||
getTranscriptPath(): URI | undefined { return undefined; }
|
||||
async cleanupOldTranscripts(): Promise<void> { }
|
||||
isTranscriptUri(): boolean { return false; }
|
||||
}
|
||||
|
||||
@@ -933,6 +933,7 @@ export namespace ConfigKey {
|
||||
export const NewWorkspaceCreationAgentEnabled = defineSetting<boolean>('chat.newWorkspaceCreation.enabled', ConfigType.Simple, true);
|
||||
export const NewWorkspaceUseContext7 = defineSetting<boolean>('chat.newWorkspace.useContext7', ConfigType.Simple, false);
|
||||
export const SummarizeAgentConversationHistory = defineSetting<boolean>('chat.summarizeAgentConversationHistory.enabled', ConfigType.Simple, true);
|
||||
export const ConversationTranscriptLookup = defineSetting<boolean>('chat.conversationTranscriptLookup.enabled', ConfigType.ExperimentBased, false);
|
||||
export const BackgroundCompaction = defineSetting<boolean>('chat.backgroundCompaction', ConfigType.ExperimentBased, false);
|
||||
export const VirtualToolThreshold = defineSetting<number>('chat.virtualTools.threshold', ConfigType.ExperimentBased, HARD_TOOL_LIMIT);
|
||||
export const CurrentEditorAgentContext = defineSetting<boolean>('chat.agent.currentEditorContext.enabled', ConfigType.Simple, true);
|
||||
|
||||
Reference in New Issue
Block a user