Skip to content

Commit e6f92d6

Browse files
authored
feat(context): Complete simplification work. (google-gemini#27345)
1 parent d1fa323 commit e6f92d6

18 files changed

Lines changed: 928 additions & 227 deletions

docs/reference/configuration.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1974,6 +1974,11 @@ their corresponding top-level category object in your `settings.json` file.
19741974
- **Default:** `false`
19751975
- **Requires restart:** Yes
19761976

1977+
- **`experimental.powerUserProfile`** (boolean):
1978+
- **Description:** Less cache friendly version of the generalist profile.
1979+
- **Default:** `false`
1980+
- **Requires restart:** Yes
1981+
19771982
- **`experimental.contextManagement`** (boolean):
19781983
- **Description:** Enable logic for context management.
19791984
- **Default:** `false`

packages/cli/src/config/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,8 @@ export async function loadCliConfig(
936936
let profileSelector: string | undefined = undefined;
937937
if (settings.experimental?.stressTestProfile) {
938938
profileSelector = 'stressTestProfile';
939+
} else if (settings.experimental?.powerUserProfile) {
940+
profileSelector = 'powerUserProfile';
939941
} else if (
940942
settings.experimental?.generalistProfile ||
941943
settings.experimental?.contextManagement

packages/cli/src/config/settingsSchema.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2449,6 +2449,15 @@ const SETTINGS_SCHEMA = {
24492449
'Suitable for general coding and software development tasks.',
24502450
showInDialog: true,
24512451
},
2452+
powerUserProfile: {
2453+
type: 'boolean',
2454+
label: 'Use the power user profile to manage agent contexts.',
2455+
category: 'Experimental',
2456+
requiresRestart: true,
2457+
default: false,
2458+
description: 'Less cache friendly version of the generalist profile.',
2459+
showInDialog: false,
2460+
},
24522461
contextManagement: {
24532462
type: 'boolean',
24542463
label: 'Enable Context Management',

packages/core/src/context/config/configLoader.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { ContextManagementConfig } from './types.js';
1010
import {
1111
generalistProfile,
1212
stressTestProfile,
13+
powerUserProfile,
1314
type ContextProfile,
1415
} from './profiles.js';
1516
import { SchemaValidator } from '../../utils/schemaValidator.js';
@@ -80,6 +81,10 @@ export async function loadContextManagementConfig(
8081
return stressTestProfile;
8182
}
8283

84+
if (sidecarPath === 'powerUserProfile') {
85+
return powerUserProfile;
86+
}
87+
8388
if (sidecarPath === 'generalistProfile') {
8489
return generalistProfile;
8590
}

packages/core/src/context/config/profiles.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,130 @@ export const stressTestProfile: ContextProfile = {
206206
buildPipelines: generalistProfile.buildPipelines,
207207
buildAsyncPipelines: generalistProfile.buildAsyncPipelines,
208208
};
209+
210+
/**
211+
* An experimental profile for power users testing maximum context endurance.
212+
* Uses a three-stage pipeline (retained -> normalized -> archived) and incremental GC.
213+
*/
214+
export const powerUserProfile: ContextProfile = {
215+
name: 'Power User (Experimental)',
216+
sentinels: generalistProfile.sentinels,
217+
config: {
218+
budget: {
219+
retainedTokens: 65000,
220+
normalizedTokens: 100000,
221+
maxTokens: 150000,
222+
coalescingThresholdTokens: 5000,
223+
},
224+
gcStrategy: 'incremental',
225+
},
226+
buildPipelines: (
227+
env: ContextEnvironment,
228+
config?: ContextManagementConfig,
229+
): PipelineDef[] => [
230+
{
231+
name: 'Immediate Sanitization',
232+
triggers: ['new_message'],
233+
processors: [
234+
createToolMaskingProcessor(
235+
'ToolMasking',
236+
env,
237+
resolveProcessorOptions(config, 'ToolMasking', {
238+
stringLengthThresholdTokens: 8000,
239+
}),
240+
),
241+
createBlobDegradationProcessor('BlobDegradation', env),
242+
createNodeDistillationProcessor(
243+
'ImmediateNodeDistillation',
244+
env,
245+
resolveProcessorOptions(config, 'ImmediateNodeDistillation', {
246+
nodeThresholdTokens: 15000,
247+
}),
248+
),
249+
],
250+
},
251+
{
252+
name: 'Normalization',
253+
triggers: ['retained_exceeded'],
254+
processors: [
255+
createNodeDistillationProcessor(
256+
'NodeDistillation',
257+
env,
258+
resolveProcessorOptions(config, 'NodeDistillation', {
259+
nodeThresholdTokens: 3000,
260+
}),
261+
),
262+
createNodeTruncationProcessor(
263+
'NodeTruncation',
264+
env,
265+
resolveProcessorOptions(config, 'NodeTruncation', {
266+
maxTokensPerNode: 4000,
267+
}),
268+
),
269+
],
270+
},
271+
{
272+
name: 'Archiving',
273+
triggers: ['normalized_exceeded'],
274+
processors: [
275+
createNodeDistillationProcessor(
276+
'ArchiveNodeDistillation',
277+
env,
278+
resolveProcessorOptions(config, 'ArchiveNodeDistillation', {
279+
nodeThresholdTokens: 1000,
280+
}),
281+
),
282+
createNodeTruncationProcessor(
283+
'ArchiveNodeTruncation',
284+
env,
285+
resolveProcessorOptions(config, 'ArchiveNodeTruncation', {
286+
maxTokensPerNode: 1500,
287+
}),
288+
),
289+
],
290+
},
291+
{
292+
name: 'Emergency Backstop',
293+
triggers: ['gc_backstop'],
294+
processors: [
295+
createStateSnapshotProcessor(
296+
'StateSnapshotSync',
297+
env,
298+
resolveProcessorOptions(config, 'StateSnapshotSync', {
299+
target: 'max',
300+
maxStateTokens: 2000,
301+
maxSummaryTurns: 10,
302+
}),
303+
),
304+
// If we STILL exceed max tokens, aggressively truncate
305+
createNodeTruncationProcessor(
306+
'EmergencyNodeTruncation',
307+
env,
308+
resolveProcessorOptions(config, 'EmergencyNodeTruncation', {
309+
maxTokensPerNode: 500,
310+
}),
311+
),
312+
],
313+
},
314+
],
315+
buildAsyncPipelines: (
316+
env: ContextEnvironment,
317+
config?: ContextManagementConfig,
318+
): AsyncPipelineDef[] => [
319+
{
320+
name: 'Async Background GC',
321+
triggers: ['nodes_aged_out'],
322+
processors: [
323+
createStateSnapshotAsyncProcessor(
324+
'StateSnapshotAsync',
325+
env,
326+
resolveProcessorOptions(config, 'StateSnapshotAsync', {
327+
type: 'accumulate',
328+
maxStateTokens: 4000,
329+
maxSummaryTurns: 5,
330+
}),
331+
),
332+
],
333+
},
334+
],
335+
};

packages/core/src/context/config/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { ContextProcessor, AsyncContextProcessor } from '../pipeline.js';
99
export type PipelineTrigger =
1010
| 'new_message'
1111
| 'retained_exceeded'
12+
| 'normalized_exceeded'
1213
| 'gc_backstop'
1314
| 'nodes_added'
1415
| 'nodes_aged_out'
@@ -28,6 +29,7 @@ export interface AsyncPipelineDef {
2829

2930
export interface ContextBudget {
3031
retainedTokens: number;
32+
normalizedTokens?: number;
3133
maxTokens: number;
3234
/**
3335
* Only trigger background consolidation (snapshots) when at least this many
@@ -43,6 +45,13 @@ export interface ContextManagementConfig {
4345
/** Defines the token ceilings and limits for the pipeline. */
4446
budget: ContextBudget;
4547

48+
/**
49+
* Strategy for the GC backstop when maxTokens is exceeded.
50+
* 'bulk' (default): Processes all nodes that have aged out of retainedTokens.
51+
* 'incremental': Processes only the oldest nodes necessary to get back under maxTokens.
52+
*/
53+
gcStrategy?: 'bulk' | 'incremental';
54+
4655
/**
4756
* Dynamic hyperparameter overrides for individual ContextProcessors and AsyncProcessors.
4857
* Keys are named identifiers (e.g. "gentleTruncation").
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, vi, beforeEach } from 'vitest';
8+
import { ContextManager } from './contextManager.js';
9+
import {
10+
createMockEnvironment,
11+
createDummyNode,
12+
} from './testing/contextTestUtils.js';
13+
import type { ContextProfile } from './config/profiles.js';
14+
import { NodeType, type ConcreteNode } from './graph/types.js';
15+
import type { PipelineOrchestrator } from './pipeline/orchestrator.js';
16+
import type { AgentChatHistory } from '../core/agentChatHistory.js';
17+
import type { AdvancedTokenCalculator } from './utils/contextTokenCalculator.js';
18+
import type { ContextManagementConfig } from './config/types.js';
19+
import type { ContextEnvironment } from './pipeline/environment.js';
20+
import type { ContextWorkingBufferImpl } from './pipeline/contextWorkingBuffer.js';
21+
22+
describe('ContextManager - Multi-stage and Incremental GC', () => {
23+
let mockEnv: ReturnType<typeof createMockEnvironment>;
24+
let mockOrchestrator: PipelineOrchestrator;
25+
let mockChatHistory: AgentChatHistory;
26+
let mockAdvancedTokenCalculator: AdvancedTokenCalculator;
27+
28+
beforeEach(() => {
29+
mockEnv = createMockEnvironment();
30+
31+
mockOrchestrator = {
32+
setNodeProvider: vi.fn(),
33+
waitForPipelines: vi.fn().mockResolvedValue(undefined),
34+
executeTriggerSync: vi
35+
.fn()
36+
.mockImplementation(async (trigger, buffer) => buffer),
37+
executeIngestionPipeline: vi
38+
.fn()
39+
.mockImplementation(async (nodes) => nodes),
40+
shutdown: vi.fn(),
41+
} as unknown as PipelineOrchestrator;
42+
43+
mockChatHistory = {
44+
all: vi.fn().mockReturnValue([]),
45+
getHistory: vi.fn().mockReturnValue([]),
46+
get: vi.fn().mockReturnValue([]),
47+
subscribe: vi.fn(),
48+
} as unknown as AgentChatHistory;
49+
50+
mockAdvancedTokenCalculator = {
51+
getRawBaseUnits: vi.fn().mockReturnValue(0),
52+
getRawBaseUnitsForContent: vi.fn().mockReturnValue(0),
53+
calculateTokensAndBaseUnits: vi.fn(),
54+
} as unknown as AdvancedTokenCalculator;
55+
});
56+
57+
const setupManager = (config: ContextManagementConfig) => {
58+
const sidecar: ContextProfile = {
59+
name: 'test',
60+
config,
61+
buildPipelines: () => [],
62+
buildAsyncPipelines: () => [],
63+
};
64+
return new ContextManager(
65+
sidecar,
66+
mockEnv as unknown as ContextEnvironment,
67+
mockEnv.tracer,
68+
mockOrchestrator,
69+
mockChatHistory,
70+
mockAdvancedTokenCalculator,
71+
);
72+
};
73+
74+
it('should emit NormalizeNeeded when normalizedTokens budget is exceeded', async () => {
75+
const manager = setupManager({
76+
budget: {
77+
retainedTokens: 100,
78+
normalizedTokens: 150,
79+
maxTokens: 300,
80+
},
81+
} as unknown as ContextManagementConfig);
82+
83+
const normalizeSpy = vi.fn();
84+
mockEnv.eventBus.onNormalizeNeeded(normalizeSpy);
85+
const consolidationSpy = vi.fn();
86+
mockEnv.eventBus.onConsolidationNeeded(consolidationSpy);
87+
88+
// Mock token calculator for evaluateTriggers
89+
mockEnv.tokenCalculator.calculateConcreteListTokens = vi
90+
.fn()
91+
.mockImplementation((nodes: ConcreteNode[]) =>
92+
nodes.reduce(
93+
(sum: number, n: ConcreteNode) =>
94+
// Look for the mock tokens we attached to the dummy node
95+
sum + ((n as unknown as { _mockTokens: number })._mockTokens || 0),
96+
0,
97+
),
98+
);
99+
100+
const createNodeWithTokens = (
101+
id: string,
102+
type: NodeType,
103+
tokens: number,
104+
) => {
105+
const node = createDummyNode(id, type);
106+
// @ts-expect-error - attaching mock tokens for test
107+
node._mockTokens = tokens;
108+
return node;
109+
};
110+
111+
// Create 4 nodes, each 80 tokens. Total = 320 tokens.
112+
// Node 1 (oldest): prior=240. 240 > 150 -> Normalization (Archiving trigger)
113+
// Node 2: prior=160. 160 > 150 -> Normalization
114+
// Node 3: prior=80. 80 <= 100 -> Retained
115+
// Node 4 (newest): prior=0. 0 <= 100 -> Retained
116+
const nodes = [
117+
createNodeWithTokens('ep1', NodeType.USER_PROMPT, 80),
118+
createNodeWithTokens('ep2', NodeType.AGENT_THOUGHT, 80),
119+
createNodeWithTokens('ep3', NodeType.TOOL_EXECUTION, 80),
120+
createNodeWithTokens('ep4', NodeType.TOOL_EXECUTION, 80),
121+
];
122+
123+
// @ts-expect-error - access private method for testing
124+
manager.buffer = { nodes } as unknown as ContextWorkingBufferImpl;
125+
126+
// Trigger evaluation manually with a dummy "new node" to bypass the empty check
127+
// @ts-expect-error - access private method for testing
128+
await manager.evaluateTriggers(nodes, new Set([nodes[3].id]), new Set());
129+
130+
// Nodes 3 and 4 are retained.
131+
// Node 2 and Node 1 both fall out of normalizedTokens (160 > 150, 240 > 150).
132+
// Therefore they should trigger NormalizeNeeded. They should NOT trigger ConsolidationNeeded
133+
// because they exceeded normalized budget, so they skip the retained fallback.
134+
expect(consolidationSpy).not.toHaveBeenCalled();
135+
136+
expect(normalizeSpy).toHaveBeenCalledOnce();
137+
const normalizeEvent = normalizeSpy.mock.calls[0][0];
138+
expect(normalizeEvent.targetNodeIds.has(nodes[0].id)).toBe(true);
139+
expect(normalizeEvent.targetNodeIds.has(nodes[1].id)).toBe(true);
140+
expect(normalizeEvent.targetNodeIds.has(nodes[2].id)).toBe(false);
141+
});
142+
});

0 commit comments

Comments
 (0)