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:
Bhavya U
2026-03-17 23:06:21 -07:00
committed by GitHub
parent 8ab4b1ac24
commit 35e83ba656
7 changed files with 102 additions and 2 deletions
+9
View File
@@ -3235,6 +3235,15 @@
"preview",
"onExp"
]
},
"github.copilot.chat.conversationTranscriptLookup.enabled": {
"type": "boolean",
"default": false,
"description": "%github.copilot.config.conversationTranscriptLookup.enabled%",
"tags": [
"preview",
"onExp"
]
}
}
},
+1
View File
@@ -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) {
@@ -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);