feat(context): Complete simplification work. (#27345)

This commit is contained in:
joshualitt
2026-05-22 11:06:40 -07:00
committed by GitHub
parent d1fa323cfb
commit e6f92d66f6
18 changed files with 928 additions and 227 deletions

View File

@@ -1974,6 +1974,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.powerUserProfile`** (boolean):
- **Description:** Less cache friendly version of the generalist profile.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.contextManagement`** (boolean):
- **Description:** Enable logic for context management.
- **Default:** `false`

View File

@@ -936,6 +936,8 @@ export async function loadCliConfig(
let profileSelector: string | undefined = undefined;
if (settings.experimental?.stressTestProfile) {
profileSelector = 'stressTestProfile';
} else if (settings.experimental?.powerUserProfile) {
profileSelector = 'powerUserProfile';
} else if (
settings.experimental?.generalistProfile ||
settings.experimental?.contextManagement

View File

@@ -2449,6 +2449,15 @@ const SETTINGS_SCHEMA = {
'Suitable for general coding and software development tasks.',
showInDialog: true,
},
powerUserProfile: {
type: 'boolean',
label: 'Use the power user profile to manage agent contexts.',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Less cache friendly version of the generalist profile.',
showInDialog: false,
},
contextManagement: {
type: 'boolean',
label: 'Enable Context Management',

View File

@@ -10,6 +10,7 @@ import type { ContextManagementConfig } from './types.js';
import {
generalistProfile,
stressTestProfile,
powerUserProfile,
type ContextProfile,
} from './profiles.js';
import { SchemaValidator } from '../../utils/schemaValidator.js';
@@ -80,6 +81,10 @@ export async function loadContextManagementConfig(
return stressTestProfile;
}
if (sidecarPath === 'powerUserProfile') {
return powerUserProfile;
}
if (sidecarPath === 'generalistProfile') {
return generalistProfile;
}

View File

@@ -206,3 +206,130 @@ export const stressTestProfile: ContextProfile = {
buildPipelines: generalistProfile.buildPipelines,
buildAsyncPipelines: generalistProfile.buildAsyncPipelines,
};
/**
* An experimental profile for power users testing maximum context endurance.
* Uses a three-stage pipeline (retained -> normalized -> archived) and incremental GC.
*/
export const powerUserProfile: ContextProfile = {
name: 'Power User (Experimental)',
sentinels: generalistProfile.sentinels,
config: {
budget: {
retainedTokens: 65000,
normalizedTokens: 100000,
maxTokens: 150000,
coalescingThresholdTokens: 5000,
},
gcStrategy: 'incremental',
},
buildPipelines: (
env: ContextEnvironment,
config?: ContextManagementConfig,
): PipelineDef[] => [
{
name: 'Immediate Sanitization',
triggers: ['new_message'],
processors: [
createToolMaskingProcessor(
'ToolMasking',
env,
resolveProcessorOptions(config, 'ToolMasking', {
stringLengthThresholdTokens: 8000,
}),
),
createBlobDegradationProcessor('BlobDegradation', env),
createNodeDistillationProcessor(
'ImmediateNodeDistillation',
env,
resolveProcessorOptions(config, 'ImmediateNodeDistillation', {
nodeThresholdTokens: 15000,
}),
),
],
},
{
name: 'Normalization',
triggers: ['retained_exceeded'],
processors: [
createNodeDistillationProcessor(
'NodeDistillation',
env,
resolveProcessorOptions(config, 'NodeDistillation', {
nodeThresholdTokens: 3000,
}),
),
createNodeTruncationProcessor(
'NodeTruncation',
env,
resolveProcessorOptions(config, 'NodeTruncation', {
maxTokensPerNode: 4000,
}),
),
],
},
{
name: 'Archiving',
triggers: ['normalized_exceeded'],
processors: [
createNodeDistillationProcessor(
'ArchiveNodeDistillation',
env,
resolveProcessorOptions(config, 'ArchiveNodeDistillation', {
nodeThresholdTokens: 1000,
}),
),
createNodeTruncationProcessor(
'ArchiveNodeTruncation',
env,
resolveProcessorOptions(config, 'ArchiveNodeTruncation', {
maxTokensPerNode: 1500,
}),
),
],
},
{
name: 'Emergency Backstop',
triggers: ['gc_backstop'],
processors: [
createStateSnapshotProcessor(
'StateSnapshotSync',
env,
resolveProcessorOptions(config, 'StateSnapshotSync', {
target: 'max',
maxStateTokens: 2000,
maxSummaryTurns: 10,
}),
),
// If we STILL exceed max tokens, aggressively truncate
createNodeTruncationProcessor(
'EmergencyNodeTruncation',
env,
resolveProcessorOptions(config, 'EmergencyNodeTruncation', {
maxTokensPerNode: 500,
}),
),
],
},
],
buildAsyncPipelines: (
env: ContextEnvironment,
config?: ContextManagementConfig,
): AsyncPipelineDef[] => [
{
name: 'Async Background GC',
triggers: ['nodes_aged_out'],
processors: [
createStateSnapshotAsyncProcessor(
'StateSnapshotAsync',
env,
resolveProcessorOptions(config, 'StateSnapshotAsync', {
type: 'accumulate',
maxStateTokens: 4000,
maxSummaryTurns: 5,
}),
),
],
},
],
};

View File

@@ -9,6 +9,7 @@ import type { ContextProcessor, AsyncContextProcessor } from '../pipeline.js';
export type PipelineTrigger =
| 'new_message'
| 'retained_exceeded'
| 'normalized_exceeded'
| 'gc_backstop'
| 'nodes_added'
| 'nodes_aged_out'
@@ -28,6 +29,7 @@ export interface AsyncPipelineDef {
export interface ContextBudget {
retainedTokens: number;
normalizedTokens?: number;
maxTokens: number;
/**
* Only trigger background consolidation (snapshots) when at least this many
@@ -43,6 +45,13 @@ export interface ContextManagementConfig {
/** Defines the token ceilings and limits for the pipeline. */
budget: ContextBudget;
/**
* Strategy for the GC backstop when maxTokens is exceeded.
* 'bulk' (default): Processes all nodes that have aged out of retainedTokens.
* 'incremental': Processes only the oldest nodes necessary to get back under maxTokens.
*/
gcStrategy?: 'bulk' | 'incremental';
/**
* Dynamic hyperparameter overrides for individual ContextProcessors and AsyncProcessors.
* Keys are named identifiers (e.g. "gentleTruncation").

View File

@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContextManager } from './contextManager.js';
import {
createMockEnvironment,
createDummyNode,
} from './testing/contextTestUtils.js';
import type { ContextProfile } from './config/profiles.js';
import { NodeType, type ConcreteNode } from './graph/types.js';
import type { PipelineOrchestrator } from './pipeline/orchestrator.js';
import type { AgentChatHistory } from '../core/agentChatHistory.js';
import type { AdvancedTokenCalculator } from './utils/contextTokenCalculator.js';
import type { ContextManagementConfig } from './config/types.js';
import type { ContextEnvironment } from './pipeline/environment.js';
import type { ContextWorkingBufferImpl } from './pipeline/contextWorkingBuffer.js';
describe('ContextManager - Multi-stage and Incremental GC', () => {
let mockEnv: ReturnType<typeof createMockEnvironment>;
let mockOrchestrator: PipelineOrchestrator;
let mockChatHistory: AgentChatHistory;
let mockAdvancedTokenCalculator: AdvancedTokenCalculator;
beforeEach(() => {
mockEnv = createMockEnvironment();
mockOrchestrator = {
setNodeProvider: vi.fn(),
waitForPipelines: vi.fn().mockResolvedValue(undefined),
executeTriggerSync: vi
.fn()
.mockImplementation(async (trigger, buffer) => buffer),
executeIngestionPipeline: vi
.fn()
.mockImplementation(async (nodes) => nodes),
shutdown: vi.fn(),
} as unknown as PipelineOrchestrator;
mockChatHistory = {
all: vi.fn().mockReturnValue([]),
getHistory: vi.fn().mockReturnValue([]),
get: vi.fn().mockReturnValue([]),
subscribe: vi.fn(),
} as unknown as AgentChatHistory;
mockAdvancedTokenCalculator = {
getRawBaseUnits: vi.fn().mockReturnValue(0),
getRawBaseUnitsForContent: vi.fn().mockReturnValue(0),
calculateTokensAndBaseUnits: vi.fn(),
} as unknown as AdvancedTokenCalculator;
});
const setupManager = (config: ContextManagementConfig) => {
const sidecar: ContextProfile = {
name: 'test',
config,
buildPipelines: () => [],
buildAsyncPipelines: () => [],
};
return new ContextManager(
sidecar,
mockEnv as unknown as ContextEnvironment,
mockEnv.tracer,
mockOrchestrator,
mockChatHistory,
mockAdvancedTokenCalculator,
);
};
it('should emit NormalizeNeeded when normalizedTokens budget is exceeded', async () => {
const manager = setupManager({
budget: {
retainedTokens: 100,
normalizedTokens: 150,
maxTokens: 300,
},
} as unknown as ContextManagementConfig);
const normalizeSpy = vi.fn();
mockEnv.eventBus.onNormalizeNeeded(normalizeSpy);
const consolidationSpy = vi.fn();
mockEnv.eventBus.onConsolidationNeeded(consolidationSpy);
// Mock token calculator for evaluateTriggers
mockEnv.tokenCalculator.calculateConcreteListTokens = vi
.fn()
.mockImplementation((nodes: ConcreteNode[]) =>
nodes.reduce(
(sum: number, n: ConcreteNode) =>
// Look for the mock tokens we attached to the dummy node
sum + ((n as unknown as { _mockTokens: number })._mockTokens || 0),
0,
),
);
const createNodeWithTokens = (
id: string,
type: NodeType,
tokens: number,
) => {
const node = createDummyNode(id, type);
// @ts-expect-error - attaching mock tokens for test
node._mockTokens = tokens;
return node;
};
// Create 4 nodes, each 80 tokens. Total = 320 tokens.
// Node 1 (oldest): prior=240. 240 > 150 -> Normalization (Archiving trigger)
// Node 2: prior=160. 160 > 150 -> Normalization
// Node 3: prior=80. 80 <= 100 -> Retained
// Node 4 (newest): prior=0. 0 <= 100 -> Retained
const nodes = [
createNodeWithTokens('ep1', NodeType.USER_PROMPT, 80),
createNodeWithTokens('ep2', NodeType.AGENT_THOUGHT, 80),
createNodeWithTokens('ep3', NodeType.TOOL_EXECUTION, 80),
createNodeWithTokens('ep4', NodeType.TOOL_EXECUTION, 80),
];
// @ts-expect-error - access private method for testing
manager.buffer = { nodes } as unknown as ContextWorkingBufferImpl;
// Trigger evaluation manually with a dummy "new node" to bypass the empty check
// @ts-expect-error - access private method for testing
await manager.evaluateTriggers(nodes, new Set([nodes[3].id]), new Set());
// Nodes 3 and 4 are retained.
// Node 2 and Node 1 both fall out of normalizedTokens (160 > 150, 240 > 150).
// Therefore they should trigger NormalizeNeeded. They should NOT trigger ConsolidationNeeded
// because they exceeded normalized budget, so they skip the retained fallback.
expect(consolidationSpy).not.toHaveBeenCalled();
expect(normalizeSpy).toHaveBeenCalledOnce();
const normalizeEvent = normalizeSpy.mock.calls[0][0];
expect(normalizeEvent.targetNodeIds.has(nodes[0].id)).toBe(true);
expect(normalizeEvent.targetNodeIds.has(nodes[1].id)).toBe(true);
expect(normalizeEvent.targetNodeIds.has(nodes[2].id)).toBe(false);
});
});

View File

@@ -16,6 +16,8 @@ import type {
} from '../core/agentChatHistory.js';
import type { AdvancedTokenCalculator } from './utils/contextTokenCalculator.js';
import { createMockEnvironment } from './testing/contextTestUtils.js';
import { ContextWorkingBufferImpl } from './pipeline/contextWorkingBuffer.js';
import { deriveStableId } from '../utils/cryptoUtils.js';
describe('ContextManager', () => {
let mockSidecar: ContextProfile;
@@ -43,7 +45,7 @@ describe('ContextManager', () => {
waitForPipelines: vi.fn().mockResolvedValue(undefined),
executeTriggerSync: vi
.fn()
.mockImplementation(async (trigger, nodes) => nodes),
.mockImplementation(async (trigger, buffer) => buffer),
shutdown: vi.fn(),
} as unknown as PipelineOrchestrator;
@@ -108,14 +110,15 @@ describe('ContextManager', () => {
expect(mockOrchestrator.executeTriggerSync).toHaveBeenCalledExactlyOnceWith(
'new_message',
expect.any(Array),
expect.any(ContextWorkingBufferImpl),
expect.any(Set),
);
// Check that the node passed to the orchestrator corresponds to our pendingRequest
const call = (mockOrchestrator.executeTriggerSync as unknown as Mock).mock
.calls[0];
const passedNodes = call[1];
const passedBuffer = call[1];
const passedNodes = passedBuffer.nodes;
const passedNodeIds = call[2];
expect(passedNodes).toHaveLength(1);
@@ -126,6 +129,61 @@ describe('ContextManager', () => {
expect(passedNodeIds.has(passedNodes[0].id)).toBe(true);
});
it('should correctly split historical context and pending prompt for late binding', async () => {
const envContextId = deriveStableId(['environment-context']);
const historicalTurn: HistoryTurn = {
id: `turn_${envContextId}`, // Turn 0
content: { role: 'user', parts: [{ text: 'System instruction' }] },
};
const organicTurn: HistoryTurn = {
id: 'turn-1',
content: { role: 'model', parts: [{ text: 'Previous model message' }] },
};
// Setup history with Turn 0 and Turn 1
(mockChatHistory.get as Mock).mockReturnValue([
historicalTurn,
organicTurn,
]);
const contextManager = new ContextManager(
mockSidecar,
mockEnv,
mockTracer,
mockOrchestrator,
mockChatHistory,
mockAdvancedTokenCalculator,
);
const pendingRequest: HistoryTurn = {
id: 'pending-turn',
content: { role: 'user', parts: [{ text: 'Active prompt' }] },
};
const { apiHistory, pendingApiHistory } =
await contextManager.renderHistory(pendingRequest);
// apiHistory should contain Turn 0 and the previous model message.
// Note: hardenHistory may inject a sentinel user turn if the history segment
// being hardened starts with a model turn.
expect(apiHistory.length).toBeGreaterThanOrEqual(2);
expect((apiHistory[0].parts![0] as unknown as { text: string }).text).toBe(
'System instruction',
);
// pendingApiHistory should contain ONLY the pending request
expect(pendingApiHistory).toHaveLength(1);
expect(
(pendingApiHistory[0].parts![0] as unknown as { text: string }).text,
).toBe('Active prompt');
// The total combined history should be a valid alternating sequence
const combined = [...apiHistory, ...pendingApiHistory];
for (let i = 1; i < combined.length; i++) {
expect(combined[i].role).not.toBe(combined[i - 1].role);
}
});
it('renderHistory should exclude pendingRequest from the result (late binding)', async () => {
const contextManager = new ContextManager(
mockSidecar,

View File

@@ -36,6 +36,7 @@ export class ContextManager {
// Hysteresis tracking to prevent utility call churn
private lastTriggeredDeficit = 0;
private lastTriggeredNormalizeDeficit = 0;
// Cache for Anomaly 3 (Redundant Renders)
private lastRenderCache?: {
@@ -64,19 +65,16 @@ export class ContextManager {
this.eventBus = env.eventBus;
this.orchestrator = orchestrator;
// Provide the orchestrator with a way to fetch the latest nodes from the live buffer
// Direct synchronization: ContextManager is the "Pull Master"
// and tells the orchestrator what to do.
this.orchestrator.setNodeProvider(() => this.buffer.nodes);
this.eventBus.onProcessorResult((event) => {
// Defensive: Verify all targets are still present in the buffer.
const currentIds = new Set(this.buffer.nodes.map((n) => n.id));
const allTargetsPresent = event.targets.every((t) =>
currentIds.has(t.id),
);
if (!allTargetsPresent) {
debugLogger.log(
`[ContextManager] Dropping stale processor result from ${event.processorId}. One or more targets were already removed.`,
const bufferIds = new Set(this.buffer.nodes.map((n) => n.id));
if (!event.targets.every((t) => bufferIds.has(t.id))) {
debugLogger.warn(
`[ContextManager] Dropping processor result from ${event.processorId}: targets no longer in buffer.`,
);
return;
}
@@ -89,145 +87,8 @@ export class ContextManager {
});
}
/**
* Returns a promise that resolves when all currently executing async pipelines have finished.
*/
async waitForPipelines(): Promise<void> {
return this.orchestrator.waitForPipelines();
}
/**
* Safely stops background async pipelines and clears event listeners.
*/
shutdown() {
this.orchestrator.shutdown();
}
/**
* Evaluates if the current working buffer exceeds configured budget thresholds,
* firing consolidation events if necessary.
*/
private async evaluateTriggers(newNodes: Set<string>) {
if (!this.sidecar.config.budget) return;
if (newNodes.size > 0) {
await this.orchestrator.executeTriggerSync(
'new_message',
this.buffer.nodes,
newNodes,
);
}
const currentTokens = this.env.tokenCalculator.calculateConcreteListTokens(
this.buffer.nodes,
);
if (currentTokens > this.sidecar.config.budget.retainedTokens) {
const agedOutNodes = new Set<string>();
let rollingTokens = 0;
// Identify nodes that must NEVER be truncated
const protectedIds = this.getProtectedNodeIds(this.buffer.nodes);
// Walk backwards finding nodes that fall out of the retained budget
for (let i = this.buffer.nodes.length - 1; i >= 0; i--) {
const node = this.buffer.nodes[i];
const priorTokens = rollingTokens;
rollingTokens += this.env.tokenCalculator.calculateConcreteListTokens([
node,
]);
if (priorTokens > this.sidecar.config.budget.retainedTokens) {
if (!protectedIds.has(node.id)) {
agedOutNodes.add(node.id);
}
}
}
if (agedOutNodes.size > 0) {
const targetDeficit =
currentTokens - this.sidecar.config.budget.retainedTokens;
if (targetDeficit < this.lastTriggeredDeficit) {
this.lastTriggeredDeficit = targetDeficit;
}
const threshold =
this.sidecar.config.budget.coalescingThresholdTokens || 0;
const growthSinceLast = targetDeficit - this.lastTriggeredDeficit;
if (
targetDeficit >= threshold &&
(growthSinceLast >= threshold || this.lastTriggeredDeficit === 0)
) {
this.lastTriggeredDeficit = targetDeficit;
this.env.tokenCalculator.garbageCollectCache(
new Set(this.buffer.nodes.map((n) => n.id)),
);
// Trigger synchronous consolidation for budget deficit
await this.orchestrator.executeTriggerSync(
'nodes_aged_out',
this.buffer.nodes,
agedOutNodes,
new Set(protectedIds.keys()),
);
}
} else {
this.lastTriggeredDeficit = 0;
}
}
}
private getProtectedNodeIds(
nodes: readonly ConcreteNode[],
extraProtectedIds: Set<string> = new Set(),
): Map<string, string> {
const protectionMap = new Map<string, string>();
if (nodes.length === 0) return protectionMap;
const lastNode = nodes[nodes.length - 1];
const lastTurnId = lastNode.turnId;
const envTurnId = `turn_${deriveStableId(['environment-context'])}`;
for (const node of nodes) {
if (node.turnId === lastTurnId) {
protectionMap.set(node.id, 'recent_turn');
} else if (node.turnId === envTurnId) {
protectionMap.set(node.id, 'environment_context');
}
}
for (const id of extraProtectedIds) {
protectionMap.set(id, 'external_active_task');
}
return protectionMap;
}
getPristineGraph(): readonly ConcreteNode[] {
const pristineSet = new Map<string, ConcreteNode>();
for (const node of this.buffer.nodes) {
const roots = this.buffer.getPristineNodes(node.id);
for (const root of roots) {
pristineSet.set(root.id, root);
}
}
return Array.from(pristineSet.values()).sort(
(a, b) => a.timestamp - b.timestamp,
);
}
getNodes(): readonly ConcreteNode[] {
return [...this.buffer.nodes];
}
/**
* Generates a virtual view of the pristine graph, substituting in variants
* up to the configured token budget.
*/
async renderHistory(
pendingRequest?: { id: string; content: Content },
pendingRequest?: HistoryTurn,
activeTaskIds: Set<string> = new Set(),
abortSignal?: AbortSignal,
): Promise<{
@@ -241,7 +102,6 @@ export class ContextManager {
this.tracer.logEvent('ContextManager', 'Starting rendering of LLM context');
// 1. Explicit Sync with the durable history.
// This replaces the background HistoryObserver.
const currentHistory = this.chatHistory.get();
const pristineNodes = this.env.graphMapper.sync(currentHistory);
@@ -259,19 +119,19 @@ export class ContextManager {
// 2. Preview the pending request.
let previewNodes: readonly ConcreteNode[] = [];
if (pendingRequest) {
previewNodes = this.env.graphMapper.sync([pendingRequest]);
const syncedNodes = this.env.graphMapper.sync([pendingRequest]);
const previewNodeIds = new Set(syncedNodes.map((n) => n.id));
const previewNodeIds = new Set(previewNodes.map((n) => n.id));
const previewBuffer = ContextWorkingBufferImpl.initialize(syncedNodes);
previewNodes = await this.orchestrator.executeTriggerSync(
const processedPreviewBuffer = await this.orchestrator.executeTriggerSync(
'new_message',
previewNodes,
previewBuffer,
previewNodeIds,
);
}
// 3. Trigger evaluation (Sync budget management).
await this.evaluateTriggers(newPrimalNodes);
previewNodes = processedPreviewBuffer.nodes;
}
// --- Hot Start Calibration ---
const hotStartPromise = (async () => {
@@ -284,30 +144,37 @@ export class ContextManager {
}
})();
// 3. Synchronous Pressure Barrier
await Promise.all([this.orchestrator.waitForPipelines(), hotStartPromise]);
let nodes = this.buffer.nodes;
const previewNodeIds = new Set<string>();
if (previewNodes.length > 0) {
for (const n of previewNodes) {
previewNodeIds.add(n.id);
}
nodes = [...nodes, ...previewNodes];
for (const node of previewNodes) {
previewNodeIds.add(node.id);
}
}
// 4. Trigger Management (GC/Distillation/Normalization)
await this.evaluateTriggers(nodes, newPrimalNodes, activeTaskIds);
// Re-fetch nodes from buffer (master) and combine with ephemeral previews
nodes = [...this.buffer.nodes, ...previewNodes];
// 5. Final Render
const header = this.headerProvider
? await this.headerProvider()
: undefined;
const graphHash = nodes.map((n) => n.id).join('|');
const headerHash = header ? JSON.stringify(header.parts) : 'no-header';
const totalHash = `${graphHash}::${headerHash}`;
const nodesHash = deriveStableId([
...nodes.map((n) => n.id),
header ? JSON.stringify(header.parts) : 'no-header',
]);
if (this.lastRenderCache?.nodesHash === totalHash) {
debugLogger.log(
'[ContextManager] Render cache hit. Skipping redundant render.',
);
if (this.lastRenderCache?.nodesHash === nodesHash) {
this.tracer.logEvent('ContextManager', 'Render Cache Hit', { nodesHash });
return this.lastRenderCache.result;
}
@@ -336,62 +203,269 @@ export class ContextManager {
} = renderResult;
if (didApplyManagement) {
// Commit the GC backstop results back to the master buffer.
// We must be careful to only apply results to the nodes that belong to the master buffer.
const masterIdsInResult = new Set(this.buffer.nodes.map((n) => n.id));
const processedMasterNodes = processedNodes.filter(
(n) => !previewNodeIds.has(n.id) || masterIdsInResult.has(n.id),
);
this.buffer = this.buffer.applyProcessorResult(
'sync_backstop',
this.buffer.nodes,
processedNodes.filter((n) => !previewNodeIds.has(n.id)),
processedMasterNodes,
);
}
// Structural validation
checkContextInvariants(this.buffer.nodes, 'RenderHistory');
this.tracer.logEvent('ContextManager', 'Finished rendering');
const fullHistoryToHarden = [...renderedHistory, ...pendingHistory];
const allHistory = [...renderedHistory, ...pendingHistory];
const hardenedAllHistory = hardenHistory(allHistory, {
const hardenedFullHistory = hardenHistory(fullHistoryToHarden, {
sentinels: this.sidecar.sentinels,
});
const firstPendingId = pendingHistory[0]?.id;
let splitIndex = renderedHistory.length;
if (firstPendingId) {
const foundIndex = hardenedAllHistory.findIndex(
(h) => h.id === firstPendingId,
);
if (foundIndex !== -1) {
splitIndex = foundIndex;
const envContextId = deriveStableId(['environment-context']);
const pendingIds = new Set(pendingHistory.map((t) => t.id));
const resultHistory: HistoryTurn[] = [];
const resultPending: HistoryTurn[] = [];
let foundPending = false;
for (const turn of hardenedFullHistory) {
if (
!foundPending &&
(pendingIds.has(turn.id) ||
(turn.id.startsWith('turn_') &&
pendingIds.has(turn.id.substring(5)))) &&
turn.id !== envContextId &&
turn.id !== `turn_${envContextId}`
) {
foundPending = true;
}
}
const apiHistory = hardenedAllHistory
.slice(0, splitIndex)
.map((h) => h.content);
const pendingApiHistory = hardenedAllHistory
.slice(splitIndex)
.map((h) => h.content);
if (header) {
apiHistory.unshift(header);
if (foundPending) {
resultPending.push(turn);
} else {
resultHistory.push(turn);
}
}
const result = {
history: renderedHistory,
apiHistory,
pendingApiHistory,
apiHistory: resultHistory.map((h) => h.content),
pendingApiHistory: resultPending.map((h) => h.content),
didApplyManagement,
baseUnits,
processedNodes,
};
this.lastRenderCache = {
nodesHash: totalHash,
result,
};
if (header) {
result.apiHistory.unshift(header);
}
this.lastRenderCache = { nodesHash, result };
this.tracer.logEvent('ContextManager', 'Rendering Complete', {
historySize: renderedHistory.length,
pendingSize: pendingHistory.length,
didApplyManagement,
});
return result;
}
async waitForPipelines(): Promise<void> {
await this.orchestrator.waitForPipelines();
}
shutdown() {
this.orchestrator.shutdown();
}
getNodes(): readonly ConcreteNode[] {
return this.buffer.nodes;
}
getEnvironment(): ContextEnvironment {
return this.env;
}
getPristineGraph(): readonly ConcreteNode[] {
const pristineSet = new Map<string, ConcreteNode>();
for (const node of this.buffer.nodes) {
const roots = this.buffer.getPristineNodes(node.id);
for (const root of roots) {
pristineSet.set(root.id, root);
}
}
return Array.from(pristineSet.values()).sort(
(a, b) => a.timestamp - b.timestamp,
);
}
private async evaluateTriggers(
nodes: readonly ConcreteNode[],
newPrimalNodes: ReadonlySet<string>,
activeTaskIds: Set<string>,
) {
if (newPrimalNodes.size > 0) {
this.buffer = await this.orchestrator.executeTriggerSync(
'nodes_added',
this.buffer,
newPrimalNodes,
);
}
// Identify ephemeral preview nodes that are NOT in the master buffer.
const bufferIds = new Set(this.buffer.nodes.map((n) => n.id));
const previewNodes = nodes.filter((n) => !bufferIds.has(n.id));
const currentNodes = [...this.buffer.nodes, ...previewNodes];
const currentTokens =
this.env.tokenCalculator.calculateConcreteListTokens(currentNodes);
if (currentTokens > this.sidecar.config.budget.retainedTokens) {
const agedOutRetainedNodes = new Set<string>();
const agedOutNormalizedNodes = new Set<string>();
const protectionMap = this.getProtectedNodeIds(
currentNodes,
activeTaskIds,
);
const protectedIds = new Set(protectionMap.keys());
// Also pin Turn 0 (Environment Context)
const envTurnId = `turn_${deriveStableId(['environment-context'])}`;
const turn0Nodes = currentNodes.filter((n) => n.turnId === envTurnId);
for (const n of turn0Nodes) {
protectedIds.add(n.id);
}
let rollingTokens = 0;
for (let i = currentNodes.length - 1; i >= 0; i--) {
const node = currentNodes[i];
const priorTokens = rollingTokens;
rollingTokens += this.env.tokenCalculator.calculateConcreteListTokens([
node,
]);
if (priorTokens > this.sidecar.config.budget.retainedTokens) {
if (!protectedIds.has(node.id)) {
const hasNormalizedTier =
this.sidecar.config.budget.normalizedTokens !== undefined;
if (
!hasNormalizedTier ||
priorTokens <= this.sidecar.config.budget.normalizedTokens!
) {
agedOutRetainedNodes.add(node.id);
}
if (
hasNormalizedTier &&
priorTokens > this.sidecar.config.budget.normalizedTokens!
) {
agedOutNormalizedNodes.add(node.id);
}
}
}
}
if (agedOutRetainedNodes.size > 0) {
const targetDeficit =
currentTokens - this.sidecar.config.budget.retainedTokens;
const threshold =
this.sidecar.config.budget.coalescingThresholdTokens || 0;
if (targetDeficit < this.lastTriggeredDeficit) {
this.lastTriggeredDeficit = targetDeficit;
}
if (targetDeficit > this.lastTriggeredDeficit + threshold) {
this.lastTriggeredDeficit = targetDeficit;
this.eventBus.emitConsolidationNeeded({
nodes: this.buffer.nodes,
targetDeficit,
targetNodeIds: agedOutRetainedNodes,
});
this.env.tokenCalculator.garbageCollectCache(
new Set(this.buffer.nodes.map((n) => n.id)),
);
this.buffer = await this.orchestrator.executeTriggerSync(
'nodes_aged_out',
this.buffer,
agedOutRetainedNodes,
protectedIds,
);
}
} else {
this.lastTriggeredDeficit = 0;
}
if (agedOutNormalizedNodes.size > 0) {
const targetDeficit =
currentTokens - this.sidecar.config.budget.normalizedTokens!;
const threshold =
this.sidecar.config.budget.coalescingThresholdTokens || 0;
if (targetDeficit < this.lastTriggeredNormalizeDeficit) {
this.lastTriggeredNormalizeDeficit = targetDeficit;
}
if (targetDeficit > this.lastTriggeredNormalizeDeficit + threshold) {
this.lastTriggeredNormalizeDeficit = targetDeficit;
this.eventBus.emitNormalizeNeeded({
nodes: this.buffer.nodes,
targetDeficit,
targetNodeIds: agedOutNormalizedNodes,
});
this.buffer = await this.orchestrator.executeTriggerSync(
'normalized_exceeded',
this.buffer,
agedOutNormalizedNodes,
protectedIds,
);
}
} else {
this.lastTriggeredNormalizeDeficit = 0;
}
}
}
private getProtectedNodeIds(
nodes: readonly ConcreteNode[],
extraProtectedIds: Set<string> = new Set(),
): Map<string, string> {
const protectionMap = new Map<string, string>();
if (nodes.length === 0) return protectionMap;
const lastNode = nodes[nodes.length - 1];
const lastTurnId = lastNode.turnId;
// Identify Environment Context (Turn 0) for pinning
const envContextId = deriveStableId(['environment-context']);
const envContextTurnId = `turn_${envContextId}`;
for (const node of nodes) {
if (node.turnId === envContextTurnId || node.turnId === envContextId) {
protectionMap.set(node.id, 'environment_context');
}
if (node.turnId === lastTurnId) {
protectionMap.set(node.id, 'recent_turn');
}
}
for (const id of extraProtectedIds) {
protectionMap.set(id, 'external_active_task');
}
return protectionMap;
}
private async performHotStartCalibration(
nodes: readonly ConcreteNode[],
abortSignal?: AbortSignal,
@@ -416,8 +490,4 @@ export class ContextManager {
debugLogger.warn('[ContextManager] Hot start calibration failed', e);
}
}
getEnvironment(): ContextEnvironment {
return this.env;
}
}

View File

@@ -34,6 +34,12 @@ export interface TokenGroundTruthEvent {
promptBaseUnits: number;
}
export interface NormalizeNeededEvent {
nodes: readonly ConcreteNode[];
targetDeficit: number;
targetNodeIds: Set<string>;
}
export class ContextEventBus extends EventEmitter {
emitTokenGroundTruth(event: TokenGroundTruthEvent) {
this.emit('TOKEN_GROUND_TRUTH', event);
@@ -69,6 +75,14 @@ export class ContextEventBus extends EventEmitter {
this.on('BUDGET_RETAINED_CROSSED', listener);
}
emitNormalizeNeeded(event: NormalizeNeededEvent) {
this.emit('BUDGET_NORMALIZED_CROSSED', event);
}
onNormalizeNeeded(listener: (event: NormalizeNeededEvent) => void) {
this.on('BUDGET_NORMALIZED_CROSSED', listener);
}
emitProcessorResult(event: ProcessorResultEvent) {
this.emit('PROCESSOR_RESULT', event);
}

View File

@@ -15,6 +15,8 @@ import type { ContextProfile } from '../config/profiles.js';
import type { PipelineOrchestrator } from '../pipeline/orchestrator.js';
import type { Part } from '@google/genai';
import { ContextWorkingBufferImpl } from '../pipeline/contextWorkingBuffer.js';
describe('render', () => {
it('should render all provided nodes', async () => {
const mockNodes: ConcreteNode[] = [
@@ -116,9 +118,12 @@ describe('render', () => {
};
const orchestrator = {
executeTriggerSync: vi.fn(async (trigger, nodes, agedOutNodes) =>
nodes.filter((n: ConcreteNode) => !agedOutNodes.has(n.id)),
),
executeTriggerSync: vi.fn(async (trigger, buffer, agedOutNodes) => {
const filteredNodes = buffer.nodes.filter(
(n: ConcreteNode) => !agedOutNodes.has(n.id),
);
return ContextWorkingBufferImpl.initialize(filteredNodes);
}),
} as unknown as PipelineOrchestrator;
const sidecar = {
@@ -215,9 +220,12 @@ describe('render', () => {
};
const orchestrator = {
executeTriggerSync: vi.fn(async (trigger, nodes, agedOutNodes) =>
nodes.filter((n: ConcreteNode) => !agedOutNodes.has(n.id)),
),
executeTriggerSync: vi.fn(async (trigger, buffer, agedOutNodes) => {
const filteredNodes = buffer.nodes.filter(
(n: ConcreteNode) => !agedOutNodes.has(n.id),
);
return ContextWorkingBufferImpl.initialize(filteredNodes);
}),
} as unknown as PipelineOrchestrator;
const sidecar = {
@@ -303,7 +311,7 @@ describe('render', () => {
];
const orchestrator = {
executeTriggerSync: vi.fn(async (trigger, nodes) => nodes),
executeTriggerSync: vi.fn(async (trigger, buffer) => buffer),
} as unknown as PipelineOrchestrator;
const sidecar = { config: {} } as ContextProfile; // No budget
const mockAdvancedTokenCalculator = {

View File

@@ -14,6 +14,7 @@ import type { ContextEnvironment } from '../pipeline/environment.js';
import { performCalibration } from '../utils/tokenCalibration.js';
import type { AdvancedTokenCalculator } from '../utils/contextTokenCalculator.js';
import type { HistoryTurn } from '../../core/agentChatHistory.js';
import { ContextWorkingBufferImpl } from '../pipeline/contextWorkingBuffer.js';
export interface RenderOptions {
protectionReasons?: Map<string, string>;
@@ -178,7 +179,20 @@ export async function render(
rollingTokens += nodeTokens;
if (priorTokens > sidecar.config.budget.retainedTokens) {
agedOutNodes.add(node.id);
if (sidecar.config.gcStrategy === 'incremental') {
// Only target enough of the oldest nodes to get back under maxTokens
// priorTokens represents tokens newer than this node.
// If the newer tokens alone are enough to push us over maxTokens, we MUST compress this node.
// If the newer tokens are under maxTokens, we can stop compressing.
if (priorTokens > maxTokens) {
agedOutNodes.add(node.id);
} else if (rollingTokens > maxTokens) {
// This is the boundary node that pushes us over maxTokens. Compress it.
agedOutNodes.add(node.id);
}
} else {
agedOutNodes.add(node.id);
}
}
}
@@ -190,13 +204,15 @@ export async function render(
}
}
const processedNodes = await orchestrator.executeTriggerSync(
const processedBuffer = await orchestrator.executeTriggerSync(
'gc_backstop',
nodes,
ContextWorkingBufferImpl.initialize(nodes),
agedOutNodes,
protectedIds,
);
const processedNodes = processedBuffer.nodes;
const skipList = new Set<string>();
for (const node of processedNodes) {
if (node.abstractsIds) {

View File

@@ -197,7 +197,13 @@ export class ContextGraphBuilder {
const msg = turn.content;
if (!msg.parts) continue;
const turnSalt = turn.id;
const hasEnvHeader = msg.parts?.some(
(p) => isTextPart(p) && p.text.trim().startsWith('<session_context>'),
);
const turnSalt =
hasEnvHeader && turnIdx === 0
? deriveStableId(['environment-context'])
: turn.id;
const turnId = turnSalt.startsWith('turn_')
? turnSalt
: `turn_${turnSalt}`;
@@ -207,12 +213,10 @@ export class ContextGraphBuilder {
const part = msg.parts[partIdx];
// Skip legacy session context headers if they appear later in history (after Turn 0).
// We identify Turn 0 by its deterministic ID.
const envTurnId = deriveStableId(['environment-context']);
if (
isTextPart(part) &&
part.text.trim().startsWith('<session_context>') &&
turnSalt !== envTurnId
turnIdx > 0
) {
debugLogger.log(
'[ContextGraphBuilder] Skipping legacy environment header turn from graph.',

View File

@@ -6,6 +6,7 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { PipelineOrchestrator } from './orchestrator.js';
import { ContextWorkingBufferImpl } from './contextWorkingBuffer.js';
import {
createMockEnvironment,
createDummyNode,
@@ -115,13 +116,15 @@ describe('PipelineOrchestrator (Component)', () => {
payload: { text: 'Original' },
});
const processed = await orchestrator.executeTriggerSync(
const processedBuffer = await orchestrator.executeTriggerSync(
'new_message',
[originalNode],
ContextWorkingBufferImpl.initialize([originalNode]),
new Set([originalNode.id]),
new Set(),
);
const processed = processedBuffer.nodes;
expect(processed.length).toBe(1);
const resultingNode = processed[0] as UserPrompt;
expect(resultingNode.payload.text).toBe('Original [modified]');
@@ -142,13 +145,15 @@ describe('PipelineOrchestrator (Component)', () => {
payload: { text: 'Original' },
});
const processed = await orchestrator.executeTriggerSync(
const processedBuffer = await orchestrator.executeTriggerSync(
'new_message',
[originalNode],
ContextWorkingBufferImpl.initialize([originalNode]),
new Set([originalNode.id]),
new Set(),
);
const processed = processedBuffer.nodes;
expect(processed).toEqual([originalNode]); // Untouched
});
@@ -170,13 +175,15 @@ describe('PipelineOrchestrator (Component)', () => {
});
// The throwing processor should be caught and logged, allowing Mod to still run.
const processed = await orchestrator.executeTriggerSync(
const processedBuffer = await orchestrator.executeTriggerSync(
'new_message',
[originalNode],
ContextWorkingBufferImpl.initialize([originalNode]),
new Set([originalNode.id]),
new Set(),
);
const processed = processedBuffer.nodes;
expect(processed.length).toBe(1);
const resultingNode = processed[0] as UserPrompt;
expect(resultingNode.payload.text).toBe('Original [modified]');
@@ -207,7 +214,7 @@ describe('PipelineOrchestrator (Component)', () => {
await orchestrator.executeTriggerSync(
'nodes_added',
[node1, node2],
ContextWorkingBufferImpl.initialize([node1, node2]),
new Set([node2.id]),
);

View File

@@ -67,18 +67,18 @@ export class PipelineOrchestrator {
async executeTriggerSync(
trigger: PipelineTrigger,
nodes: readonly ConcreteNode[],
buffer: ContextWorkingBufferImpl,
triggerTargets: ReadonlySet<string>,
protectedTurnIds: ReadonlySet<string> = new Set(),
): Promise<readonly ConcreteNode[]> {
): Promise<ContextWorkingBufferImpl> {
this.tracer.logEvent('Orchestrator', 'Strategy Intent', {
trigger,
totalNodes: nodes.length,
totalNodes: buffer.nodes.length,
targetNodes: triggerTargets.size,
});
// First, run any sync pipelines matching this trigger
let currentBuffer = ContextWorkingBufferImpl.initialize(nodes);
let currentBuffer = buffer;
const triggerPipelines = this.pipelines.filter((p) =>
p.triggers.includes(trigger),
);
@@ -148,7 +148,7 @@ export class PipelineOrchestrator {
// Success! Drain consumed messages
this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds());
return currentBuffer.nodes;
return currentBuffer;
}
private async executeTriggerAsync(

View File

@@ -0,0 +1,102 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Power User Lifecycle Tests > should correctly execute the three-tier budget pipeline 1`] = `
{
"baseUnits": 4688,
"finalProjection": [
{
"content": {
"parts": [
{
"text": "{"active_tasks":[],"discovered_facts":[],"constraints_and_preferences":[],"recent_arc":[]}",
},
],
"role": "user",
},
"id": "<UUID>",
},
{
"content": {
"parts": [
{
"text": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
},
],
"role": "model",
},
"id": "<UUID>",
},
{
"content": {
"parts": [
{
"text": "Mock response from: utility_compressor, for: {"text":"C...CCCCCCCC"}",
},
],
"role": "user",
},
"id": "<UUID>",
},
{
"content": {
"parts": [
{
"text": "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
},
],
"role": "model",
},
"id": "<UUID>",
},
{
"content": {
"parts": [
{
"text": "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE",
},
],
"role": "user",
},
"id": "<UUID>",
},
{
"content": {
"parts": [
{
"text": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
},
],
"role": "model",
},
"id": "<UUID>",
},
],
"tokenTrajectory": [
{
"tokensAfterBackground": 33,
"tokensBeforeBackground": 9,
"turnIndex": 0,
},
{
"tokensAfterBackground": 52,
"tokensBeforeBackground": 41,
"turnIndex": 1,
},
{
"tokensAfterBackground": 1262,
"tokensBeforeBackground": 457,
"turnIndex": 2,
},
{
"tokensAfterBackground": 3072,
"tokensBeforeBackground": 1867,
"turnIndex": 3,
},
{
"tokensAfterBackground": 4688,
"tokensBeforeBackground": 4077,
"turnIndex": 4,
},
],
}
`;

View File

@@ -0,0 +1,116 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import { SimulationHarness } from './simulationHarness.js';
import { createMockLlmClient } from '../testing/contextTestUtils.js';
import type { ContextProfile } from '../config/profiles.js';
import { powerUserProfile } from '../config/profiles.js';
expect.addSnapshotSerializer({
test: (val) =>
typeof val === 'string' &&
(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.test(
val,
) ||
/\b[0-9a-f]{32}\b/i.test(val) ||
/\bsynth_[a-zA-Z0-9_]+_[0-9a-f]{32}\b/.test(val) ||
/[\\/]tmp[\\/]sim/.test(val)),
print: (val) => {
if (typeof val !== 'string') return `"${val}"`;
let scrubbed = val
.replace(
/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi,
'<UUID>',
)
.replace(/\b[0-9a-f]{32}\b/gi, '<UUID>')
.replace(/\bsynth_[a-zA-Z0-9_]+_[0-9a-f]{32}\b/g, 'synth_<NAME>_<HASH>')
.replace(/[\\/]tmp[\\/]sim[^\s"'\]]*/g, '<MOCKED_DIR>');
// Also scrub timestamps in filenames like blob_1234567890_...
scrubbed = scrubbed.replace(/blob_\d+_/g, 'blob_<TIMESTAMP>_');
return `"${scrubbed}"`;
},
});
describe('Power User Lifecycle Tests', () => {
afterAll(async () => {
fs.rmSync('/tmp/sim', { recursive: true, force: true });
fs.rmSync('mock', { recursive: true, force: true });
});
beforeAll(() => {
vi.spyOn(Math, 'random').mockReturnValue(0.5);
});
afterAll(() => {
vi.restoreAllMocks();
});
const mockLlmClient = createMockLlmClient();
it('should correctly execute the three-tier budget pipeline', async () => {
// 1. Setup a Power User Stress Profile
const powerStressProfile: ContextProfile = {
...powerUserProfile,
config: {
...powerUserProfile.config,
budget: {
retainedTokens: 1000,
normalizedTokens: 2000,
maxTokens: 5000,
coalescingThresholdTokens: 200,
},
gcStrategy: 'incremental',
},
};
const harness = await SimulationHarness.create(
powerStressProfile,
mockLlmClient,
);
// Turn 0: System Prompt
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'System Instructions' }] },
{ role: 'model', parts: [{ text: 'Ack.' }] },
]);
// Turn 1: Normal conversation
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'Hello!' }] },
{ role: 'model', parts: [{ text: 'Hi!' }] },
]);
// Turn 2: Large message to cross retainedTokens (1000) but stay under normalizedTokens (2000)
// Should trigger 'retained_exceeded' (Normalization pipeline)
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'A'.repeat(800) }] },
{ role: 'model', parts: [{ text: 'B'.repeat(400) }] },
]);
// Turn 3: Large message to cross normalizedTokens (2000) but stay under maxTokens (5000)
// Should trigger 'normalized_exceeded' (Archiving pipeline)
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'C'.repeat(1200) }] },
{ role: 'model', parts: [{ text: 'D'.repeat(600) }] },
]);
// Turn 4: Large message to cross maxTokens (5000)
// Should trigger 'gc_backstop' (Emergency pipeline) AND 'nodes_aged_out' (Async BG)
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'E'.repeat(2500) }] },
{ role: 'model', parts: [{ text: 'F'.repeat(1000) }] },
]);
const goldenState = await harness.getGoldenState();
// Verify snapshots for token trajectory and final projection
expect(goldenState).toMatchSnapshot();
});
});

View File

@@ -3400,6 +3400,13 @@
"default": false,
"type": "boolean"
},
"powerUserProfile": {
"title": "Use the power user profile to manage agent contexts.",
"description": "Less cache friendly version of the generalist profile.",
"markdownDescription": "Less cache friendly version of the generalist profile.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"contextManagement": {
"title": "Enable Context Management",
"description": "Enable logic for context management.",