Skip to content
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3776,6 +3776,14 @@
"onExp"
]
},
"github.copilot.chat.halfContextSummarization": {
"type": "boolean",
"default": false,
"markdownDescription": "%github.copilot.config.halfContextSummarization%",
"tags": [
"experimental"
]
},
"github.copilot.chat.useResponsesApiTruncation": {
"type": "boolean",
"default": false,
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@
"github.copilot.config.agentHistorySummarizationMode": "Mode for agent history summarization.",
"github.copilot.config.agentHistorySummarizationWithPromptCache": "Use prompt caching for agent history summarization.",
"github.copilot.config.agentHistorySummarizationForceGpt41": "Force GPT-4.1 for agent history summarization.",
"github.copilot.config.halfContextSummarization": "Enable half-context summarization for agent conversations. When enabled, only half of the unsummarized conversation history is compressed at a time, preserving more recent context.",
"github.copilot.config.useResponsesApiTruncation": "Use Responses API for truncation.",
"github.copilot.config.enableReadFileV2": "Enable version 2 of the read file tool.",
"github.copilot.config.enableAskAgent": "Enable the Ask agent for answering questions.",
Expand Down
177 changes: 177 additions & 0 deletions src/extension/prompts/node/agent/summarizedConversationHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -685,17 +685,51 @@ export interface ISummarizedConversationHistoryInfo {
readonly summarizedThinking?: ThinkingData;
}

/**
* Represents a flattened round with its origin information.
*/
interface FlattenedRound {
readonly round: IToolCallRound;
readonly turnIndex: number; // -1 for current turn's toolCallRounds
readonly roundIndexInTurn: number;
}

// Half-context summarization is now controlled by ConfigKey.Advanced.HalfContextSummarization

/**
* Exported for test
*/
export class SummarizedConversationHistoryPropsBuilder {
constructor(
@IPromptPathRepresentationService private readonly _promptPathRepresentationService: IPromptPathRepresentationService,
@IWorkspaceService private readonly _workspaceService: IWorkspaceService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IExperimentationService private readonly _experimentationService: IExperimentationService,
) { }

getProps(
props: SummarizedAgentHistoryProps
): ISummarizedConversationHistoryInfo {
const enableHalfContext = this._configurationService.getExperimentBasedConfig(
ConfigKey.Advanced.HalfContextSummarization,
this._experimentationService
);
if (enableHalfContext) {
const halfContextProps = this.getPropsHalfContext(props);
if (halfContextProps) {
return halfContextProps;
}
}

return this.getPropsLegacy(props);
}

/**
* Original full-context summarization logic.
* Summarizes from the last round of the previous turn or excludes only the last round.
*/
private getPropsLegacy(
props: SummarizedAgentHistoryProps
): ISummarizedConversationHistoryInfo {
let toolCallRounds = props.promptContext.toolCallRounds;
let isContinuation = props.promptContext.isContinuation;
Expand Down Expand Up @@ -748,6 +782,149 @@ export class SummarizedConversationHistoryPropsBuilder {
return undefined;
}

/**
* Half-context summarization logic.
* Flattens all rounds across history and current turn, then summarizes only the first half
* of unsummarized rounds. This enables fine-grained compression that can cut through Turn boundaries.
*/
private getPropsHalfContext(
props: SummarizedAgentHistoryProps
): ISummarizedConversationHistoryInfo | null {
// Step 1: Flatten all rounds with origin tracking
const flattenedRounds: FlattenedRound[] = [];
for (let turnIndex = 0; turnIndex < props.promptContext.history.length; turnIndex++) {
const turn = props.promptContext.history[turnIndex];
const rounds = turn.rounds;
for (let roundIndex = 0; roundIndex < rounds.length; roundIndex++) {
flattenedRounds.push({
round: rounds[roundIndex],
turnIndex,
roundIndexInTurn: roundIndex
});
}
}
// Add current turn's toolCallRounds (turnIndex = -1)
const currentRounds = props.promptContext.toolCallRounds ?? [];
for (let roundIndex = 0; roundIndex < currentRounds.length; roundIndex++) {
flattenedRounds.push({
round: currentRounds[roundIndex],
turnIndex: -1,
roundIndexInTurn: roundIndex
});
}

// Skip rounds that already have summaries – we only want to summarize new material.
let lastSummarizedIndex = -1;
for (let i = flattenedRounds.length - 1; i >= 0; i--) {
if (flattenedRounds[i].round.summary) {
lastSummarizedIndex = i;
break;
}
}
const candidateRounds = flattenedRounds.slice(lastSummarizedIndex + 1);
if (candidateRounds.length <= 1) {
return null;
}

// Step 2: Calculate split point - keep half, summarize half
const keepCount = Math.ceil(candidateRounds.length / 2);
const summarizeCount = candidateRounds.length - keepCount;
if (summarizeCount <= 0) {
return null;
}
const toSummarize = candidateRounds.slice(0, summarizeCount);

// If the split lands on a turn that exceeded tool-call limit, the last round of that turn
// is typically the one that got interrupted and whose result lands in the next turn. Avoid
// compressing that interrupted round so the summary doesn't miss the corresponding result.
const lastRound = toSummarize.at(-1)!;
if (lastRound.round.summary === undefined) {
const turnIndex = lastRound.turnIndex === -1 ? props.promptContext.history.length : lastRound.turnIndex;
const turn = turnIndex >= 0 ? props.promptContext.history[turnIndex] : undefined;
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

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

The condition turnIndex >= 0 on line 843 is always true since turnIndex is either a valid array index (>= 0) or props.promptContext.history.length (also >= 0). This check doesn't protect against anything and could be simplified. Consider removing the ternary and just using props.promptContext.history[turnIndex], which will return undefined for out-of-bounds access as intended.

Suggested change
const turn = turnIndex >= 0 ? props.promptContext.history[turnIndex] : undefined;
const turn = props.promptContext.history[turnIndex];

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

You're correct that turnIndex >= 0 is always true given the preceding assignment. However, I intentionally wrote it this way to make the code's intent explicit:

// When lastRound.turnIndex === -1, turnIndex becomes history.length,
// which is an out-of-bounds index. We want `turn` to be undefined in this case.
const turnIndex = lastRound.turnIndex === -1 ? props.promptContext.history.length : lastRound.turnIndex;
const turn = turnIndex >= 0 ? props.promptContext.history[turnIndex] : undefined;

While JavaScript arrays return undefined for out-of-bounds access, relying on this implicit behavior feels fragile. The explicit ternary documents the intent: "if the index is valid, get the turn; otherwise, undefined."

const turnMetadata = turn?.responseChatResult?.metadata as IResultMetadata | undefined;
const isLastOfHistoricalTurn = turn && turn.rounds.at(-1) === lastRound.round;
const isLastOfCurrentTurn = !turn && lastRound.turnIndex === -1 && props.promptContext.toolCallRounds?.at(-1) === lastRound.round;
const isLastRoundOfTurn = isLastOfHistoricalTurn || isLastOfCurrentTurn;
if (turnMetadata?.maxToolCallsExceeded && isLastRoundOfTurn) {
toSummarize.pop();
if (!toSummarize.length) {
return null;
}
}
}

const summarizedToolCallRoundId = toSummarize.at(-1)!.round.id;

// Step 3: Reconstruct history and toolCallRounds for summarization
// Key insight: We don't need a VirtualTurn class!
// - Complete turns before split point: reuse as-is (read-only)
// - Split turn's rounds before split point: keep them inside the turn so user messages are preserved
// - Set isContinuation=true to skip rendering the current user message (if any)
const splitPoint = toSummarize.at(-1)!;
let virtualHistory: typeof props.promptContext.history;
let virtualToolCallRounds: IToolCallRound[];
let isContinuation: boolean | undefined;

if (splitPoint.turnIndex === -1) {
// Split point is in current turn's toolCallRounds
// Include all history turns as-is, slice current toolCallRounds
virtualHistory = props.promptContext.history;
virtualToolCallRounds = currentRounds.slice(0, splitPoint.roundIndexInTurn + 1);
isContinuation = props.promptContext.isContinuation;
} else {
// Split point is in a historical turn
// Reuse complete turns [0..splitPoint.turnIndex) as-is
// For the split turn, use Object.create to preserve prototype chain (getters like
// resultMetadata, responseChatResult, etc.) while overriding only the rounds getter.
// Using {...splitTurn} would lose the prototype and break those getters!
const splitTurn = props.promptContext.history[splitPoint.turnIndex];
const slicedRounds = splitTurn.rounds.slice(0, splitPoint.roundIndexInTurn + 1);
const truncatedTurn = Object.create(splitTurn);
Object.defineProperty(truncatedTurn, 'rounds', {
get: () => slicedRounds,
configurable: true,
enumerable: true
});
virtualHistory = [
...props.promptContext.history.slice(0, splitPoint.turnIndex),
truncatedTurn,
];
virtualToolCallRounds = [];
// Mark as continuation to avoid rendering the current user message; historical user messages remain via virtualHistory.
isContinuation = true;
}

const promptContext = {
...props.promptContext,
history: virtualHistory,
toolCallRounds: virtualToolCallRounds,
isContinuation,
};

// For Anthropic models with thinking enabled, find the last thinking block
// from the rounds being summarized (toSummarize), not from the full promptContext.
// This ensures we capture thinking from the summarized span, including historical rounds.
let summarizedThinking: ThinkingData | undefined;
if (isAnthropicFamily(props.endpoint)) {
for (let i = toSummarize.length - 1; i >= 0; i--) {
if (toSummarize[i].round.thinking) {
summarizedThinking = toSummarize[i].round.thinking;
break;
}
}
}

return {
props: {
...props,
workingNotebook: this.getWorkingNotebook(props),
promptContext
},
summarizedToolCallRoundId,
summarizedThinking
};
}

private getWorkingNotebook(props: SummarizedAgentHistoryProps): NotebookDocument | undefined {
const toolCallRound = props.promptContext.toolCallRounds && [...props.promptContext.toolCallRounds].reverse().find(round => round.toolCalls.some(call => call.name === ToolName.RunNotebookCell));
const toolCall = toolCallRound?.toolCalls.find(call => call.name === ToolName.RunNotebookCell);
Expand Down
Loading