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: 3 additions & 1 deletion core/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export {
type CodeExecutionResult,
type File,
} from './code_executors/code_execution_utils.js';
export {AnchoredContextCompactor} from './context/anchored_context_compactor.js';
export type {AnchoredContextCompactorOptions} from './context/anchored_context_compactor.js';
export type {BaseContextCompactor} from './context/base_context_compactor.js';
export type {BaseSummarizer} from './context/summarizers/base_summarizer.js';
export {LlmSummarizer} from './context/summarizers/llm_summarizer.js';
Expand All @@ -116,7 +118,7 @@ export {TrajectoryThoughtPruningCompactor} from './context/trajectory_thought_pr
export type {TrajectoryThoughtPruningCompactorOptions} from './context/trajectory_thought_pruning_compactor.js';
export {TruncatingContextCompactor} from './context/truncating_context_compactor.js';
export type {TruncatingContextCompactorOptions} from './context/truncating_context_compactor.js';
export {isCompactedEvent} from './events/compacted_event.js';
export {isCompactedEvent, isScratchpadEvent} from './events/compacted_event.js';
export type {CompactedEvent} from './events/compacted_event.js';
export {
createEvent,
Expand Down
165 changes: 165 additions & 0 deletions core/src/context/anchored_context_compactor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {InvocationContext} from '../agents/invocation_context.js';
import {CompactedEvent, isScratchpadEvent} from '../events/compacted_event.js';
import {
Event,
getEventTokens,
getFunctionCalls,
getFunctionResponses,
} from '../events/event.js';
import {BaseContextCompactor} from './base_context_compactor.js';
import {BaseSummarizer} from './summarizers/base_summarizer.js';

export interface AnchoredContextCompactorOptions {
/** The maximum number of tokens to retain in the session history before compaction. */
tokenThreshold: number;
/**
* The minimum number of raw events to keep at the end of the session.
* Compaction will not affect these tail events (unless needed for tool splits).
*/
eventRetentionSize: number;
/** The summarizer used to create the compacted event content. */
summarizer: BaseSummarizer;
}

/**
* A context compactor that maintains a single persistent 'Scratchpad' or
* 'State Tracker' event at the top of the context history.
*
* When compaction is triggered, it merges new raw events into the existing
* Scratchpad event and discards them from the active history view.
*/
export class AnchoredContextCompactor implements BaseContextCompactor {
private readonly tokenThreshold: number;
private readonly eventRetentionSize: number;
private readonly summarizer: BaseSummarizer;

constructor(options: AnchoredContextCompactorOptions) {
this.tokenThreshold = options.tokenThreshold;
this.eventRetentionSize = options.eventRetentionSize;
this.summarizer = options.summarizer;
}

private getActiveEvents(events: Event[]): Event[] {
let latestScratchpad: CompactedEvent | undefined = undefined;

for (let i = events.length - 1; i >= 0; i--) {
const e = events[i];
if (isScratchpadEvent(e)) {
latestScratchpad = e;
break;
}
}

if (!latestScratchpad) {
return events;
}

const activeRawEvents = events.filter(
(e) => e.timestamp > latestScratchpad!.endTime && !isScratchpadEvent(e),
);

return [latestScratchpad, ...activeRawEvents];
}

shouldCompact(
invocationContext: InvocationContext,
): boolean | Promise<boolean> {
const events = invocationContext.session.events;
const activeEvents = this.getActiveEvents(events);
const hasScratchpad =
activeEvents.length > 0 && isScratchpadEvent(activeEvents[0]);
const rawEvents = hasScratchpad ? activeEvents.slice(1) : activeEvents;

if (rawEvents.length <= this.eventRetentionSize) {
return false;
}

const totalTokens = activeEvents.reduce(
(sum, event) => sum + getEventTokens(event),
0,
);

return totalTokens > this.tokenThreshold;
}

async compact(invocationContext: InvocationContext): Promise<void> {
const events = invocationContext.session.events;
const activeEvents = this.getActiveEvents(events);
const hasScratchpad =
activeEvents.length > 0 && isScratchpadEvent(activeEvents[0]);
const rawEvents = hasScratchpad ? activeEvents.slice(1) : activeEvents;

if (rawEvents.length <= this.eventRetentionSize) {
return;
}

// Determine the baseline index to retain from the active raw events.
let retainStartIndex = Math.max(
0,
rawEvents.length - this.eventRetentionSize,
);

// Prevent splitting between a tool call and its response.
while (retainStartIndex > 0) {
const eventToRetain = rawEvents[retainStartIndex];
const previousEvent = rawEvents[retainStartIndex - 1];

if (
getFunctionResponses(eventToRetain).length > 0 &&
getFunctionCalls(previousEvent).length > 0
) {
retainStartIndex--;
} else {
// No conflict, safe to split here.
break;
}
}

if (retainStartIndex === 0) {
// Cannot compact if we have to retain everything
return;
}

// Extract raw events to compact.
const rawEventsToCompact = rawEvents.slice(0, retainStartIndex);

let scratchpadEvent: CompactedEvent;

if (hasScratchpad) {
const existingScratchpad = activeEvents[0] as CompactedEvent;
scratchpadEvent = await this.summarizer.summarize([
existingScratchpad,
...rawEventsToCompact,
]);
} else {
scratchpadEvent = await this.summarizer.summarize(rawEventsToCompact);
}

// Ensure the event is marked as scratchpad and has system author.
const updatedScratchpad = {
...scratchpadEvent,
isScratchpad: true,
author: 'system',
} as CompactedEvent;

// Reconstruct the events list: inactive events + new scratchpad + active retained events
const inactiveEvents = events.slice(0, events.indexOf(activeEvents[0]));
const retainedRawEvents = rawEvents.slice(retainStartIndex);

const newEventsList = [
...inactiveEvents,
updatedScratchpad,
...retainedRawEvents,
];

// Mutate the original session events array.
events.length = 0;
events.push(...newEventsList);
}
}
11 changes: 1 addition & 10 deletions core/src/context/token_based_context_compactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import {InvocationContext} from '../agents/invocation_context.js';
import {CompactedEvent, isCompactedEvent} from '../events/compacted_event.js';
import {Event, stringifyContent} from '../events/event.js';
import {Event, getEventTokens} from '../events/event.js';
import {BaseContextCompactor} from './base_context_compactor.js';
import {BaseSummarizer} from './summarizers/base_summarizer.js';

Expand Down Expand Up @@ -142,15 +142,6 @@ export class TokenBasedContextCompactor implements BaseContextCompactor {
}
}

function getEventTokens(event: Event): number {
if (event.usageMetadata?.promptTokenCount !== undefined) {
return event.usageMetadata.promptTokenCount;
}
// Estimate: 4 chars per token.
const contentStr = stringifyContent(event);
return Math.ceil(contentStr.length / 4);
}

function hasFunctionCall(event: Event): boolean {
return !!event.content?.parts?.some(
(part) => part.functionCall !== undefined,
Expand Down
17 changes: 17 additions & 0 deletions core/src/events/compacted_event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export interface CompactedEvent extends Event {
* The summarized content of the compacted events.
*/
compactedContent: string;

/**
* Identifies this compacted event as the persistent context scratchpad.
*/
isScratchpad?: boolean;
}

/**
Expand All @@ -39,6 +44,17 @@ export function isCompactedEvent(event: Event): event is CompactedEvent {
return 'isCompacted' in event && event.isCompacted === true;
}

/**
* Type guard to check if an event is a scratchpad CompactedEvent.
*/
export function isScratchpadEvent(
event: Event,
): event is CompactedEvent & {isScratchpad: true} {
return (
isCompactedEvent(event) && (event as CompactedEvent).isScratchpad === true
);
}

export function createCompactedEvent(
params: Partial<CompactedEvent> = {},
): CompactedEvent {
Expand All @@ -48,5 +64,6 @@ export function createCompactedEvent(
startTime: params.startTime!,
endTime: params.endTime!,
compactedContent: params.compactedContent!,
isScratchpad: params.isScratchpad,
};
}
12 changes: 12 additions & 0 deletions core/src/events/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ export function stringifyContent(event: Event): string {
.join('');
}

/**
* Estimates the number of tokens in the event based on usage metadata or characters.
*/
export function getEventTokens(event: Event): number {
if (event.usageMetadata?.promptTokenCount !== undefined) {
return event.usageMetadata.promptTokenCount;
}
// Estimate: 4 chars per token.
const contentStr = stringifyContent(event);
return Math.ceil(contentStr.length / 4);
}

/**
* Returns whether the event contains any thought parts.
*/
Expand Down
Loading
Loading