mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-29 15:40:10 +00:00
feat(context): Complete simplification work. (#27345)
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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").
|
||||
|
||||
142
packages/core/src/context/contextManager.incremental.test.ts
Normal file
142
packages/core/src/context/contextManager.incremental.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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]),
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user