mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 00:30:59 -07:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fa422a7c6 | |||
| 38830bfe81 | |||
| 3f1d217d36 | |||
| 9a147b41a8 | |||
| 17c9b4341a | |||
| 68e7e93eaa | |||
| 87ccf70998 | |||
| 2f8ea41aeb | |||
| 264fffbe81 | |||
| 8cbdbdac04 | |||
| c01ed6ff4a | |||
| 9bd9c0f72d | |||
| 10ef9a6876 | |||
| 46c20c6d6e | |||
| 8fd439678b | |||
| 28bd094965 | |||
| 0179a140f0 | |||
| 1d25931026 | |||
| 775b36e04a | |||
| f4cd486f90 | |||
| f7b67ec3de | |||
| 4a34f64efa | |||
| 6e7987696f | |||
| 642c244739 | |||
| 40e6acf797 | |||
| fd5a703684 | |||
| aa71d592f9 | |||
| 9b9ed0c803 | |||
| 30d70e862d | |||
| b39b74ee09 | |||
| cba2298e5c | |||
| 6d353a6c1b | |||
| ca607a5305 | |||
| 0df3521032 | |||
| 9287159ccc | |||
| f726de12e5 | |||
| 42022279bb | |||
| 57f13a196e | |||
| 15826637d2 | |||
| e68234a573 | |||
| 95a175deca | |||
| 631053bbf4 | |||
| 84868b49f5 | |||
| 60ba97e7e6 | |||
| 23fce4656d | |||
| 1f2ca6d0e7 | |||
| 0c48e5f09c | |||
| ee0123ad0d | |||
| 229d570263 | |||
| 5381a5cc64 | |||
| b1d62a0b9d | |||
| cd14eb40ce | |||
| 19c39885e7 | |||
| 1c76caec6f | |||
| 5fad1f4053 | |||
| e548cd6b0e | |||
| 1383200054 | |||
| 256a7a83fa | |||
| 370e2b9e1d | |||
| 1754797929 | |||
| 64b8a6f4a8 | |||
| a9cc61349e | |||
| 94c59405aa | |||
| 63e8b825a7 | |||
| 61dacecacf | |||
| 54e901bf42 | |||
| 0dc8efb03f | |||
| 81c8dac01c | |||
| f423affe6d | |||
| d3d6b9403d | |||
| fbcfa40f1d | |||
| fc4439ce03 | |||
| cf6866c38d | |||
| a4b6372d31 | |||
| dd7190bf9c | |||
| 1774abebe9 | |||
| c1b06fec0d | |||
| d7433ddd03 | |||
| 2e80fad7a4 | |||
| 7c2135574c | |||
| e601563652 | |||
| 6867a96be0 | |||
| fcaa6c5584 | |||
| aa0deb05a4 | |||
| ac6dc1d477 |
@@ -335,6 +335,8 @@ jobs:
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_MODEL: 'gemini-3-pro-preview'
|
||||
# Only run always passes behavioral tests.
|
||||
EVAL_SUITE_TYPE: 'behavioral'
|
||||
# Disable Vitest internal retries to avoid double-retrying;
|
||||
# custom retry logic is handled in evals/test-helper.ts
|
||||
VITEST_RETRY: 0
|
||||
|
||||
@@ -5,10 +5,18 @@ on:
|
||||
- cron: '0 1 * * *' # Runs at 1 AM every day
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_all:
|
||||
description: 'Run all evaluations (including usually passing)'
|
||||
type: 'boolean'
|
||||
default: true
|
||||
suite_type:
|
||||
description: 'Suite type to run'
|
||||
type: 'choice'
|
||||
options:
|
||||
- 'behavioral'
|
||||
- 'component-level'
|
||||
- 'hero-scenario'
|
||||
default: 'behavioral'
|
||||
suite_name:
|
||||
description: 'Specific suite name to run'
|
||||
required: false
|
||||
type: 'string'
|
||||
test_name_pattern:
|
||||
description: 'Test name pattern or file name'
|
||||
required: false
|
||||
@@ -59,7 +67,9 @@ jobs:
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_MODEL: '${{ matrix.model }}'
|
||||
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
|
||||
RUN_EVALS: 'true'
|
||||
EVAL_SUITE_TYPE: "${{ github.event.inputs.suite_type || 'behavioral' }}"
|
||||
EVAL_SUITE_NAME: '${{ github.event.inputs.suite_name }}'
|
||||
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
|
||||
# Disable Vitest internal retries to avoid double-retrying;
|
||||
# custom retry logic is handled in evals/test-helper.ts
|
||||
|
||||
@@ -19,6 +19,8 @@ describe('Answer vs. ask eval', () => {
|
||||
* automatically modify the file, but instead asks for permission.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should not edit files when asked to inspect for bugs',
|
||||
prompt: 'Inspect app.ts for bugs',
|
||||
files: FILES,
|
||||
@@ -42,6 +44,8 @@ describe('Answer vs. ask eval', () => {
|
||||
* does modify the file.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should edit files when asked to fix bug',
|
||||
prompt: 'Fix the bug in app.ts - it should add numbers not subtract',
|
||||
files: FILES,
|
||||
@@ -66,6 +70,8 @@ describe('Answer vs. ask eval', () => {
|
||||
* automatically modify the file, but instead asks for permission.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should not edit when asking "any bugs"',
|
||||
prompt: 'Any bugs in app.ts?',
|
||||
files: FILES,
|
||||
@@ -89,6 +95,8 @@ describe('Answer vs. ask eval', () => {
|
||||
* automatically modify the file.
|
||||
*/
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should not edit files when asked a general question',
|
||||
prompt: 'How does app.ts work?',
|
||||
files: FILES,
|
||||
@@ -112,6 +120,8 @@ describe('Answer vs. ask eval', () => {
|
||||
* automatically modify the file.
|
||||
*/
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should not edit files when asked about style',
|
||||
prompt: 'Is app.ts following good style?',
|
||||
files: FILES,
|
||||
@@ -135,6 +145,8 @@ describe('Answer vs. ask eval', () => {
|
||||
* the agent does NOT automatically modify the file.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should not edit files when user notes an issue',
|
||||
prompt: 'The add function subtracts numbers.',
|
||||
files: FILES,
|
||||
|
||||
+49
-49
@@ -10,10 +10,13 @@ import {
|
||||
runEval,
|
||||
prepareLogDir,
|
||||
symlinkNodeModules,
|
||||
withEvalRetries,
|
||||
prepareWorkspace,
|
||||
type BaseEvalCase,
|
||||
EVAL_MODEL,
|
||||
} from './test-helper.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_GEMINI_MODEL } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Config overrides for evals, with tool-restriction fields explicitly
|
||||
@@ -29,15 +32,13 @@ interface EvalConfigOverrides {
|
||||
allowedTools?: never;
|
||||
/** Restricting tools via mainAgentTools in evals is forbidden. */
|
||||
mainAgentTools?: never;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AppEvalCase {
|
||||
name: string;
|
||||
export interface AppEvalCase extends BaseEvalCase {
|
||||
configOverrides?: EvalConfigOverrides;
|
||||
prompt: string;
|
||||
timeout?: number;
|
||||
files?: Record<string, string>;
|
||||
setup?: (rig: AppRig) => Promise<void>;
|
||||
assert: (rig: AppRig, output: string) => Promise<void>;
|
||||
}
|
||||
@@ -48,56 +49,55 @@ export interface AppEvalCase {
|
||||
*/
|
||||
export function appEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
|
||||
const fn = async () => {
|
||||
const rig = new AppRig({
|
||||
configOverrides: {
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
...evalCase.configOverrides,
|
||||
},
|
||||
});
|
||||
await withEvalRetries(evalCase.name, async () => {
|
||||
const rig = new AppRig({
|
||||
configOverrides: {
|
||||
model: EVAL_MODEL,
|
||||
...evalCase.configOverrides,
|
||||
},
|
||||
});
|
||||
|
||||
const { logDir, sanitizedName } = await prepareLogDir(evalCase.name);
|
||||
const logFile = path.join(logDir, `${sanitizedName}.log`);
|
||||
const { logDir, sanitizedName } = await prepareLogDir(evalCase.name);
|
||||
const logFile = path.join(logDir, `${sanitizedName}.log`);
|
||||
|
||||
try {
|
||||
await rig.initialize();
|
||||
try {
|
||||
await rig.initialize();
|
||||
|
||||
const testDir = rig.getTestDir();
|
||||
symlinkNodeModules(testDir);
|
||||
const testDir = rig.getTestDir();
|
||||
symlinkNodeModules(testDir);
|
||||
|
||||
// Setup initial files
|
||||
if (evalCase.files) {
|
||||
for (const [filePath, content] of Object.entries(evalCase.files)) {
|
||||
const fullPath = path.join(testDir, filePath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
// Setup initial files
|
||||
if (evalCase.files) {
|
||||
// Note: AppRig does not use a separate homeDir, so we use testDir twice
|
||||
await prepareWorkspace(testDir, testDir, evalCase.files);
|
||||
}
|
||||
|
||||
// Run custom setup if provided (e.g. for breakpoints)
|
||||
if (evalCase.setup) {
|
||||
await evalCase.setup(rig);
|
||||
}
|
||||
|
||||
// Render the app!
|
||||
await rig.render();
|
||||
|
||||
// Wait for initial ready state
|
||||
await rig.waitForIdle();
|
||||
|
||||
// Send the initial prompt
|
||||
await rig.sendMessage(evalCase.prompt);
|
||||
|
||||
// Run assertion. Interaction-heavy tests can do their own waiting/steering here.
|
||||
const output = rig.getStaticOutput();
|
||||
await evalCase.assert(rig, output);
|
||||
} finally {
|
||||
const output = rig.getStaticOutput();
|
||||
if (output) {
|
||||
await fs.promises.writeFile(logFile, output);
|
||||
}
|
||||
await rig.unmount();
|
||||
}
|
||||
|
||||
// Run custom setup if provided (e.g. for breakpoints)
|
||||
if (evalCase.setup) {
|
||||
await evalCase.setup(rig);
|
||||
}
|
||||
|
||||
// Render the app!
|
||||
await rig.render();
|
||||
|
||||
// Wait for initial ready state
|
||||
await rig.waitForIdle();
|
||||
|
||||
// Send the initial prompt
|
||||
await rig.sendMessage(evalCase.prompt);
|
||||
|
||||
// Run assertion. Interaction-heavy tests can do their own waiting/steering here.
|
||||
const output = rig.getStaticOutput();
|
||||
await evalCase.assert(rig, output);
|
||||
} finally {
|
||||
const output = rig.getStaticOutput();
|
||||
if (output) {
|
||||
await fs.promises.writeFile(logFile, output);
|
||||
}
|
||||
await rig.unmount();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
runEval(policy, evalCase.name, fn, (evalCase.timeout ?? 60000) + 10000);
|
||||
runEval(policy, evalCase, fn, (evalCase.timeout ?? 60000) + 10000);
|
||||
}
|
||||
|
||||
+18
-6
@@ -5,17 +5,21 @@
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { appEvalTest, AppEvalCase } from './app-test-helper.js';
|
||||
import { EvalPolicy } from './test-helper.js';
|
||||
import { ApprovalMode, isRecord } from '@google/gemini-cli-core';
|
||||
import { appEvalTest, type AppEvalCase } from './app-test-helper.js';
|
||||
import { type EvalPolicy } from './test-helper.js';
|
||||
|
||||
function askUserEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
|
||||
const existingGeneral = evalCase.configOverrides?.['general'];
|
||||
const generalBase = isRecord(existingGeneral) ? existingGeneral : {};
|
||||
|
||||
return appEvalTest(policy, {
|
||||
...evalCase,
|
||||
configOverrides: {
|
||||
...evalCase.configOverrides,
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
general: {
|
||||
...evalCase.configOverrides?.general,
|
||||
approvalMode: 'default',
|
||||
...generalBase,
|
||||
enableAutoUpdate: false,
|
||||
enableAutoUpdateNotification: false,
|
||||
},
|
||||
@@ -28,6 +32,8 @@ function askUserEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
|
||||
|
||||
describe('ask_user', () => {
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'Agent uses AskUser tool to present multiple choice options',
|
||||
prompt: `Use the ask_user tool to ask me what my favorite color is. Provide 3 options: red, green, or blue.`,
|
||||
setup: async (rig) => {
|
||||
@@ -43,6 +49,8 @@ describe('ask_user', () => {
|
||||
});
|
||||
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'Agent uses AskUser tool to clarify ambiguous requirements',
|
||||
files: {
|
||||
'package.json': JSON.stringify({ name: 'my-app', version: '1.0.0' }),
|
||||
@@ -61,6 +69,8 @@ describe('ask_user', () => {
|
||||
});
|
||||
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'Agent uses AskUser tool before performing significant ambiguous rework',
|
||||
files: {
|
||||
'packages/core/src/index.ts': '// index\nexport const version = "1.0.0";',
|
||||
@@ -82,8 +92,8 @@ describe('ask_user', () => {
|
||||
]);
|
||||
expect(confirmation, 'Expected a tool call confirmation').toBeDefined();
|
||||
|
||||
if (confirmation?.name === 'enter_plan_mode') {
|
||||
rig.acceptConfirmation('enter_plan_mode');
|
||||
if (confirmation?.toolName === 'enter_plan_mode') {
|
||||
await rig.resolveTool('enter_plan_mode');
|
||||
confirmation = await rig.waitForPendingConfirmation('ask_user');
|
||||
}
|
||||
|
||||
@@ -101,6 +111,8 @@ describe('ask_user', () => {
|
||||
// updates to clarify that shell command confirmation is handled by the UI.
|
||||
// See fix: https://github.com/google-gemini/gemini-cli/pull/20504
|
||||
askUserEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'Agent does NOT use AskUser to confirm shell commands',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
|
||||
@@ -14,6 +14,8 @@ describe('Automated tool use', () => {
|
||||
* a repro by guiding the agent into using the existing deficient script.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should use automated tools (eslint --fix) to fix code style issues',
|
||||
files: {
|
||||
'package.json': JSON.stringify(
|
||||
@@ -102,6 +104,8 @@ describe('Automated tool use', () => {
|
||||
* instead of trying to edit the files itself.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should use automated tools (prettier --write) to fix formatting issues',
|
||||
files: {
|
||||
'package.json': JSON.stringify(
|
||||
|
||||
@@ -3,6 +3,8 @@ import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('CliHelpAgent Delegation', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should delegate to cli_help agent for subagent creation questions',
|
||||
params: {
|
||||
settings: {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type EvalPolicy,
|
||||
runEval,
|
||||
prepareLogDir,
|
||||
withEvalRetries,
|
||||
prepareWorkspace,
|
||||
type BaseEvalCase,
|
||||
} from './test-helper.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
Config,
|
||||
type ConfigParameters,
|
||||
AuthType,
|
||||
ApprovalMode,
|
||||
createPolicyEngineConfig,
|
||||
ExtensionLoader,
|
||||
IntegrityDataStatus,
|
||||
makeFakeConfig,
|
||||
type GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createMockSettings } from '../packages/cli/src/test-utils/settings.js';
|
||||
|
||||
// A minimal mock ExtensionManager to bypass integrity checks
|
||||
class MockExtensionManager extends ExtensionLoader {
|
||||
override getExtensions(): GeminiCLIExtension[] {
|
||||
return [];
|
||||
}
|
||||
setRequestConsent = (): void => {};
|
||||
setRequestSetting = (): void => {};
|
||||
integrityManager = {
|
||||
verifyExtensionIntegrity: async (): Promise<IntegrityDataStatus> =>
|
||||
IntegrityDataStatus.VERIFIED,
|
||||
storeExtensionIntegrity: async (): Promise<void> => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComponentEvalCase extends BaseEvalCase {
|
||||
configOverrides?: Partial<ConfigParameters>;
|
||||
setup?: (config: Config) => Promise<void>;
|
||||
assert: (config: Config) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ComponentRig {
|
||||
public config: Config | undefined;
|
||||
public testDir: string;
|
||||
public sessionId: string;
|
||||
|
||||
constructor(
|
||||
private options: { configOverrides?: Partial<ConfigParameters> } = {},
|
||||
) {
|
||||
const uniqueId = randomUUID();
|
||||
this.testDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `gemini-component-rig-${uniqueId.slice(0, 8)}-`),
|
||||
);
|
||||
this.sessionId = `test-session-${uniqueId}`;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
const settings = createMockSettings();
|
||||
const policyEngineConfig = await createPolicyEngineConfig(
|
||||
settings.merged,
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: this.sessionId,
|
||||
targetDir: this.testDir,
|
||||
cwd: this.testDir,
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
interactive: false,
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
policyEngineConfig,
|
||||
enableEventDrivenScheduler: false, // Don't need scheduler for direct component tests
|
||||
extensionLoader: new MockExtensionManager(),
|
||||
useAlternateBuffer: false,
|
||||
...this.options.configOverrides,
|
||||
};
|
||||
|
||||
this.config = makeFakeConfig(configParams);
|
||||
await this.config.initialize();
|
||||
|
||||
// Refresh auth using USE_GEMINI to initialize the real BaseLlmClient
|
||||
await this.config.refreshAuth(AuthType.USE_GEMINI);
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
fs.rmSync(this.testDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper for running behavioral evaluations directly against backend components.
|
||||
* It provides a fully initialized Config with real API access, bypassing the UI.
|
||||
*/
|
||||
export function componentEvalTest(
|
||||
policy: EvalPolicy,
|
||||
evalCase: ComponentEvalCase,
|
||||
) {
|
||||
const fn = async () => {
|
||||
await withEvalRetries(evalCase.name, async () => {
|
||||
const rig = new ComponentRig({
|
||||
configOverrides: evalCase.configOverrides,
|
||||
});
|
||||
|
||||
await prepareLogDir(evalCase.name);
|
||||
|
||||
try {
|
||||
await rig.initialize();
|
||||
|
||||
if (evalCase.files) {
|
||||
await prepareWorkspace(rig.testDir, rig.testDir, evalCase.files);
|
||||
}
|
||||
|
||||
if (evalCase.setup) {
|
||||
await evalCase.setup(rig.config!);
|
||||
}
|
||||
|
||||
await evalCase.assert(rig.config!);
|
||||
} finally {
|
||||
await rig.cleanup();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
runEval(policy, evalCase, fn, (evalCase.timeout ?? 60000) + 10000);
|
||||
}
|
||||
@@ -20,6 +20,8 @@ You are the mutation agent. Do the mutation requested.
|
||||
|
||||
describe('concurrency safety eval test cases', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'mutation agents are run in parallel when explicitly requested',
|
||||
params: {
|
||||
settings: {
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { componentEvalTest } from './component-test-helper.js';
|
||||
import { SimulationHarness } from '../packages/core/src/context/system-tests/SimulationHarness.js';
|
||||
import type { SidecarConfig } from '../packages/core/src/context/sidecar/types.js';
|
||||
import { Config, LlmRole, getResponseText } from '@google/gemini-cli-core';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { Content } from '@google/genai';
|
||||
import { EVAL_MODEL } from './test-helper.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const DATA_DIR = path.join(__dirname, 'data', 'context_manager');
|
||||
|
||||
interface ScenarioQuestion {
|
||||
id: string;
|
||||
prompt: string;
|
||||
expectedSubstring: string;
|
||||
}
|
||||
|
||||
interface Scenario {
|
||||
scenarioId: string;
|
||||
description: string;
|
||||
history: Content[];
|
||||
questions: ScenarioQuestion[];
|
||||
}
|
||||
|
||||
const getScenario = (id: string): Scenario => {
|
||||
const filePath = path.join(DATA_DIR, 'scenario-c-compiler.json');
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(
|
||||
`Scenario file not found at ${filePath}. Run generate-c-compiler-scenario.ts first.`,
|
||||
);
|
||||
}
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content) as Scenario;
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs a single evaluation iteration using the SimulationHarness.
|
||||
*/
|
||||
async function runContextManagerEval(
|
||||
config: Config,
|
||||
sidecarConfig: SidecarConfig,
|
||||
seed: number,
|
||||
scenario: Scenario,
|
||||
judge: boolean = true,
|
||||
): Promise<{
|
||||
pass: boolean;
|
||||
response: string;
|
||||
question: ScenarioQuestion;
|
||||
tokens: number;
|
||||
}> {
|
||||
const harness = await SimulationHarness.create(
|
||||
sidecarConfig,
|
||||
config.getBaseLlmClient() as any,
|
||||
path.join(process.cwd(), 'harness-tmp'),
|
||||
);
|
||||
|
||||
// 1. Feed trajectory
|
||||
for (const message of scenario.history) {
|
||||
await harness.simulateTurn([message]);
|
||||
}
|
||||
|
||||
// Ensure background tasks (like StateSnapshotProcessor) have finished
|
||||
|
||||
// 2. Pick a question based on seed
|
||||
const questionIndex = seed % scenario.questions.length;
|
||||
const question = scenario.questions[questionIndex];
|
||||
|
||||
// 3. Project compressed history
|
||||
const compressedHistory =
|
||||
await harness.contextManager.projectCompressedHistory();
|
||||
|
||||
// We can't easily get the episode IDs from the projected Content[],
|
||||
// but we can look at the working buffer instead.
|
||||
const workingBuffer = harness.contextManager.getNodes();
|
||||
console.log('--- WORKING BUFFER EPISODES START ---');
|
||||
workingBuffer.forEach((node: any, i: number) => {
|
||||
console.log(`[Node ${i}] ID: ${node.id}, Type: ${node.type}`);
|
||||
if (node.type === 'USER_PROMPT') {
|
||||
console.log(` Text: ${node.semanticParts?.[0]?.text?.slice(0, 50)}`);
|
||||
}
|
||||
});
|
||||
console.log('--- WORKING BUFFER EPISODES END ---');
|
||||
|
||||
console.log('--- COMPRESSED HISTORY START ---');
|
||||
compressedHistory.forEach((msg, i) => {
|
||||
console.log(`[${i}] Role: ${msg.role}`);
|
||||
(msg.parts || []).forEach((part, j) => {
|
||||
if ('text' in part) {
|
||||
console.log(
|
||||
` Part ${j} (text): ${part.text?.slice(0, 100)}${part.text && part.text.length > 100 ? '...' : ''}`,
|
||||
);
|
||||
} else if ('functionCall' in part) {
|
||||
console.log(` Part ${j} (functionCall): ${part.functionCall?.name}`);
|
||||
} else if ('functionResponse' in part) {
|
||||
console.log(
|
||||
` Part ${j} (functionResponse): ${part.functionResponse?.name}`,
|
||||
);
|
||||
} else {
|
||||
console.log(` Part ${j} (other): ${Object.keys(part).join(', ')}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
console.log('--- COMPRESSED HISTORY END ---');
|
||||
|
||||
// 4. Ask the question
|
||||
const evalPrompt: Content = {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: `[SYSTEM: EVALUATION MODE - ANSWER AS TEXT ONLY - DO NOT USE TOOLS]\n\n${question.prompt}\n\nIMPORTANT: Answer in plain text and DO NOT call any tools. Provide the specific information requested.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await config.getContentGenerator().generateContent(
|
||||
{
|
||||
model: config.getModel(),
|
||||
config: {},
|
||||
contents: [...compressedHistory, evalPrompt],
|
||||
},
|
||||
'eval-prompt',
|
||||
LlmRole.UTILITY_TOOL,
|
||||
);
|
||||
|
||||
const responseText = getResponseText(response) ?? '';
|
||||
let pass = false;
|
||||
if (judge) {
|
||||
pass = await judgeResponse(config, question, responseText);
|
||||
} else {
|
||||
// Naive string check for potential failure identification
|
||||
pass = responseText
|
||||
.toLowerCase()
|
||||
.includes(question.expectedSubstring.toLowerCase());
|
||||
}
|
||||
|
||||
const finalTokens = harness.env.tokenCalculator.calculateConcreteListTokens(
|
||||
harness.contextManager.getNodes(),
|
||||
);
|
||||
|
||||
return { pass, response: responseText, question, tokens: finalTokens };
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses an LLM to judge whether the response matches the expected information.
|
||||
*/
|
||||
async function judgeResponse(
|
||||
config: Config,
|
||||
question: ScenarioQuestion,
|
||||
actualResponse: string,
|
||||
): Promise<boolean> {
|
||||
const lowerResponse = actualResponse.toLowerCase();
|
||||
const lowerExpected = question.expectedSubstring.toLowerCase();
|
||||
|
||||
// Fast path: direct substring match
|
||||
if (lowerResponse.includes(lowerExpected)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const judgePrompt: Content = {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: `Evaluate if the AI's response correctly answers the question by explicitly containing the expected information.
|
||||
|
||||
Question: ${question.prompt}
|
||||
Expected Information: ${question.expectedSubstring}
|
||||
AI's Response: ${actualResponse}
|
||||
|
||||
CRITICAL RULES FOR JUDGING:
|
||||
1. The AI's response MUST explicitly answer the question in the prompt and the provided details must match those in 'Expected Information'.
|
||||
2. Do not infer or assume knowledge. Vague, partial, or generalized answers MUST fail.
|
||||
3. If the AI hallucinates, states it cannot find the information, or provides an incomplete answer, it MUST fail.
|
||||
4. Expected information may contain extra details that are not required to answer the question that aren't in the AI's Response. For example: "the answer is X. It was previously Y.". These are still considered to be passing cases.
|
||||
|
||||
Does the AI's response explicitly and completely satisfy the expected information?
|
||||
Respond with ONLY "PASS" or "FAIL".`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await config.getContentGenerator().generateContent(
|
||||
{
|
||||
model: EVAL_MODEL,
|
||||
config: { temperature: 0 },
|
||||
contents: [judgePrompt],
|
||||
},
|
||||
'eval-judge',
|
||||
LlmRole.UTILITY_TOOL,
|
||||
);
|
||||
|
||||
const judgeText = getResponseText(response)?.trim().toUpperCase() ?? '';
|
||||
return judgeText === 'PASS';
|
||||
}
|
||||
|
||||
function calculateScenarioTokens(scenario: Scenario): number {
|
||||
let totalChars = 0;
|
||||
for (const message of scenario.history) {
|
||||
for (const part of message.parts || []) {
|
||||
if ('text' in part && part.text) {
|
||||
totalChars += part.text.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The SimulationHarness uses 4 chars per token.
|
||||
return Math.ceil(totalChars / 4);
|
||||
}
|
||||
|
||||
function generateRandomSidecarConfig(
|
||||
seed: number,
|
||||
maxRetained: number,
|
||||
): SidecarConfig {
|
||||
// Simple LCG or similar for deterministic randomness from seed if needed,
|
||||
// but for a fuzzer we can just use Math.random and log the seed.
|
||||
const minRetained = Math.min(1000, maxRetained);
|
||||
const retained =
|
||||
minRetained + Math.floor(Math.random() * (maxRetained - minRetained));
|
||||
const max = Math.floor(retained * (1.1 + Math.random() * 2.0)); // 110% to 300% buffer
|
||||
|
||||
const useSquashing = Math.random() > 0.5;
|
||||
const useRollingSummary = Math.random() > 0.5;
|
||||
const useSnapshot = Math.random() > 0.5;
|
||||
|
||||
const processors: any[] = [
|
||||
{
|
||||
processorId: 'ToolMaskingProcessor',
|
||||
options: {
|
||||
stringLengthThresholdTokens: Math.floor(
|
||||
retained * (0.2 + Math.random() * 0.5),
|
||||
),
|
||||
},
|
||||
},
|
||||
{ processorId: 'BlobDegradationProcessor', options: {} },
|
||||
{
|
||||
processorId: 'NodeDistillationProcessor',
|
||||
options: {
|
||||
nodeThresholdTokens: Math.floor(retained * (0.1 + Math.random() * 0.3)),
|
||||
},
|
||||
},
|
||||
{
|
||||
processorId: 'NodeTruncationProcessor',
|
||||
options: {
|
||||
maxTokensPerNode: Math.floor(retained * (0.1 + Math.random() * 0.5)),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const backgroundProcessors: any[] = [];
|
||||
if (useSquashing) {
|
||||
backgroundProcessors.push({
|
||||
processorId: 'HistoryTruncationProcessor',
|
||||
options: {
|
||||
target: 'freeNTokens',
|
||||
freeTokensTarget: Math.floor(retained * (0.05 + Math.random() * 0.15)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (useRollingSummary) {
|
||||
backgroundProcessors.push({
|
||||
processorId: 'RollingSummaryProcessor',
|
||||
options: {
|
||||
target: 'freeNTokens',
|
||||
freeTokensTarget: Math.floor(retained * (0.05 + Math.random() * 0.15)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (useSnapshot) {
|
||||
backgroundProcessors.push({
|
||||
processorId: 'StateSnapshotProcessor',
|
||||
options: {},
|
||||
});
|
||||
}
|
||||
|
||||
const workers: any[] = [];
|
||||
if (useSnapshot && Math.random() > 0.5) {
|
||||
workers.push({
|
||||
workerId: 'StateSnapshotWorker',
|
||||
options: {
|
||||
type: Math.random() > 0.5 ? 'accumulate' : 'point-in-time',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
budget: { retainedTokens: retained, maxTokens: max },
|
||||
pipelines: [
|
||||
{
|
||||
name: 'Immediate Sanitization',
|
||||
triggers: ['new_message'],
|
||||
processors,
|
||||
},
|
||||
{
|
||||
name: 'Deep Background Compression',
|
||||
triggers: [
|
||||
Math.random() > 0.5
|
||||
? { type: 'timer', intervalMs: 100 }
|
||||
: 'gc_backstop',
|
||||
'retained_exceeded',
|
||||
],
|
||||
processors: backgroundProcessors,
|
||||
},
|
||||
],
|
||||
workers: workers.length > 0 ? workers : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ContextManager Evaluation Suite', () => {
|
||||
const scenario = getScenario('scenario-c-compiler');
|
||||
|
||||
/**
|
||||
* The "Explorer" test.
|
||||
* Set RUN_EXPLORER=1 to run many iterations and find failures.
|
||||
*/
|
||||
if (process.env['RUN_EXPLORER']) {
|
||||
componentEvalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'context-manager',
|
||||
suiteType: 'component-level',
|
||||
name: 'ContextManager explorer (fuzzer)',
|
||||
configOverrides: { model: EVAL_MODEL },
|
||||
timeout: 1200000, // 20 minutes
|
||||
assert: async (config: Config) => {
|
||||
const scenarioTokens = calculateScenarioTokens(scenario);
|
||||
console.log(
|
||||
`Starting ContextManager explorer loop for scenario of size ${scenarioTokens} tokens...`,
|
||||
);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const seed = Math.floor(Math.random() * 1000000);
|
||||
const sidecarConfig = generateRandomSidecarConfig(
|
||||
seed,
|
||||
scenarioTokens,
|
||||
);
|
||||
|
||||
const result = await runContextManagerEval(
|
||||
config,
|
||||
sidecarConfig,
|
||||
seed,
|
||||
scenario,
|
||||
false, // Optimistic string check
|
||||
);
|
||||
|
||||
if (!result.pass) {
|
||||
// Potential failure. Confirm with LLM judge.
|
||||
result.pass = await judgeResponse(
|
||||
config,
|
||||
result.question,
|
||||
result.response,
|
||||
);
|
||||
}
|
||||
|
||||
if (!result.pass) {
|
||||
console.log('!!! FAILURE FOUND !!!');
|
||||
console.log(`Seed: ${seed}`);
|
||||
console.log(`Question: ${result.question.id}`);
|
||||
console.log(`Expected: ${result.question.expectedSubstring}`);
|
||||
console.log(`Actual Response: ${result.response}`);
|
||||
console.log('---------------------------');
|
||||
console.log(`NEW FROZEN CASE SUGGESTION:`);
|
||||
console.log(`
|
||||
componentEvalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'context-manager',
|
||||
suiteType: 'component-level',
|
||||
name: 'ContextManager Frozen Case - ${result.question.id} (Budget: ${sidecarConfig.budget.retainedTokens})',
|
||||
configOverrides: { model: EVAL_MODEL },
|
||||
assert: async (config: Config) => {
|
||||
const scenario = getScenario('scenario-c-compiler');
|
||||
const sidecarConfig: SidecarConfig = ${JSON.stringify(sidecarConfig, null, 2)};
|
||||
const seed = ${seed};
|
||||
const result = await runContextManagerEval(config, sidecarConfig, seed, scenario);
|
||||
expect(result.pass, \`Recall failed for ${result.question.id}. Response: \${result.response}\`).toBe(true);
|
||||
}
|
||||
});
|
||||
`);
|
||||
} else {
|
||||
console.log(
|
||||
`Iteration ${i} (Budget: ${sidecarConfig.budget.retainedTokens}, Seed: ${seed}) passed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Frozen cases discovered by explorer ---
|
||||
|
||||
componentEvalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'context-manager',
|
||||
suiteType: 'component-level',
|
||||
name: 'ContextManager Frozen Case - lexer-constraint (Budget: 3737)',
|
||||
configOverrides: { model: EVAL_MODEL },
|
||||
assert: async (config: Config) => {
|
||||
const scenario = getScenario('scenario-c-compiler');
|
||||
const sidecarConfig: SidecarConfig = {
|
||||
budget: {
|
||||
retainedTokens: 3737,
|
||||
maxTokens: 11280,
|
||||
},
|
||||
pipelines: [
|
||||
{
|
||||
name: 'Immediate Sanitization',
|
||||
triggers: ['new_message'],
|
||||
processors: [
|
||||
{
|
||||
processorId: 'ToolMaskingProcessor',
|
||||
options: {
|
||||
stringLengthThresholdTokens: 2442,
|
||||
},
|
||||
},
|
||||
{
|
||||
processorId: 'BlobDegradationProcessor',
|
||||
options: {},
|
||||
},
|
||||
{
|
||||
processorId: 'NodeDistillationProcessor',
|
||||
options: {
|
||||
nodeThresholdTokens: 733,
|
||||
},
|
||||
},
|
||||
{
|
||||
processorId: 'NodeTruncationProcessor',
|
||||
options: { maxTokensPerNode: 1000 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Deep Background Compression',
|
||||
triggers: [
|
||||
{
|
||||
type: 'timer',
|
||||
intervalMs: 100,
|
||||
},
|
||||
'retained_exceeded',
|
||||
],
|
||||
processors: [
|
||||
{
|
||||
processorId: 'HistoryTruncationProcessor',
|
||||
options: {
|
||||
target: 'freeNTokens',
|
||||
freeTokensTarget: 327,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const seed = 288421;
|
||||
const result = await runContextManagerEval(
|
||||
config,
|
||||
sidecarConfig,
|
||||
seed,
|
||||
scenario,
|
||||
);
|
||||
expect(
|
||||
result.pass,
|
||||
`Recall failed for lexer-constraint. Response: ${result.response}`,
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,928 @@
|
||||
{
|
||||
"scenarioId": "scenario-c-compiler",
|
||||
"description": "A robust 100-turn trajectory building a C compiler, featuring realistic noise, synchronized tool state, high-entropy data, and superseded architectural decisions.",
|
||||
"history": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "How is the test coverage looking lately?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "We're making good progress. We now have 12 unit tests passing across all modules."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Let's call this project 'TitanC'. I think it has a strong, industrial feel."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "'TitanC' sounds excellent. I've initialized the project workspace under that name."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Initially, I'd like to target x86-64 assembly for our compiler backend."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Got it. We will focus on generating x86-64 assembly instructions."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Let's run a full build of TitanC and see if there are any issues."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Executing build for TitanC (Target: x86-64)...\n```\n[CC] src/main.ts -> build/main.o\n[CC] src/lexer.ts -> build/lexer.o\n[CC] src/parser.ts -> build/parser.o\n[CC] src/ast.ts -> build/ast.o\n[CC] src/codegen.ts -> build/codegen.o\n[CC] src/utils.ts -> build/utils.o\n[LD] build/compiler\nBuild successful.\n```"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "I was thinking about the refactoring the visitor pattern for better readability. What's your take?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "That's a valid concern. Given our current architecture in TitanC, we could definitely improve that area. I recommend we prioritize correctness first, then profile to see if it's a bottleneck."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Wait, we just got a new production deployment key: 'PROD_DEPLOY_KEY_XYZ'. This replaces all previous keys, so make sure to use this one from now on."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Updated. The current production deployment key is now PROD_DEPLOY_KEY_XYZ."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "How is the test coverage looking lately?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "We're making good progress. We now have 14 unit tests passing across all modules."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "The client actually needs ARM64 support for their new server farm, so let's pivot from x86-64. It's more energy-efficient and fits their requirements better."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "That's a significant change in direction. Switching the backend architecture to ARM64. I'll adjust the register allocation and instruction set templates accordingly."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "What was the most recent change committed to the repo?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "The latest commit is:\n```\ncommit jn759a\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n docs: add architectural overview\n```"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "What was the most recent change committed to the repo?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "The latest commit is:\n```\ncommit dmp72i\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n refactor: extract token scanner to separate module\n```"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "What was the most recent change committed to the repo?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "The latest commit is:\n```\ncommit g39v9p\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n fix: correctly handle escaped characters in strings\n```"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "For the code generator, let's add a standard header to all generated assembly files. It should say exactly '; Generated by TitanC Compiler v0.1'."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Understood. I will prepend '; Generated by TitanC Compiler v0.1' to all output files generated by the backend."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "I'm reorganizing the project structure. Moving forward, the lexer logic should live in 'src/core/scanner/main.ts' and the parser in 'src/core/parser/main.ts'."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Structure updated. I'll refer to 'src/core/scanner/main.ts' for lexer tasks and 'src/core/parser/main.ts' for parser logic."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "I have a strict requirement for the lexer: we must NOT use any third-party regex libraries. I want it to be a manual character-by-character scanner for maximum performance and to avoid heavy dependencies."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Understood. I'll ensure the lexer implementation avoids external regex libraries and uses manual scanning logic."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "What was the most recent change committed to the repo?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "The latest commit is:\n```\ncommit qdyivi\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n docs: add architectural overview\n```"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Can you list the files in the src directory? I want to make sure I haven't missed anything."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"functionCall": {
|
||||
"name": "ls",
|
||||
"args": {
|
||||
"path": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"functionResponse": {
|
||||
"name": "ls",
|
||||
"response": {
|
||||
"output": "src/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Sure, here are the files in src/:\nsrc/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "I was thinking about the performance of our hash map implementation. What's your take?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "That's a valid concern. Given our current architecture in TitanC, we could definitely improve that area. I recommend we prioritize correctness first, then profile to see if it's a bottleneck."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Can you list the files in the src directory? I want to make sure I haven't missed anything."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"functionCall": {
|
||||
"name": "ls",
|
||||
"args": {
|
||||
"path": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"functionResponse": {
|
||||
"name": "ls",
|
||||
"response": {
|
||||
"output": "src/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Sure, here are the files in src/:\nsrc/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Let's define the core tokens for our lexer. We'll need an enum with at least INT, KEYWORD, IDENTIFIER, PLUS, MINUS, SEMICOLON, and EOF."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Understood. I've defined the TokenType enum as requested:\n```typescript\nenum TokenType {\n INT,\n KEYWORD,\n IDENTIFIER,\n PLUS,\n MINUS,\n SEMICOLON,\n EOF\n}\n```"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Can you define the interface for our AST nodes? Each node should have a 'type' property that can be 'Program', 'Function', 'Return', or 'Constant'."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Here is the AST node interface:\n```typescript\ninterface AstNode {\n type: 'Program' | 'Function' | 'Return' | 'Constant';\n value?: any;\n children?: AstNode[];\n}\n```"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "What was the most recent change committed to the repo?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "The latest commit is:\n```\ncommit jgqwe\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n docs: add architectural overview\n```"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Let's run a full build of TitanC and see if there are any issues."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Executing build for TitanC (Target: ARM64)...\n```\n[CC] src/main.ts -> build/main.o\n[CC] src/core/scanner/main.ts -> build/core_scanner_main.o\n[CC] src/core/parser/main.ts -> build/core_parser_main.o\n[CC] src/ast.ts -> build/ast.o\nsrc/ast.ts:60:8: warning: unused variable 'temp' [-Wunused-variable]\n[CC] src/codegen.ts -> build/codegen.o\n[CC] src/utils.ts -> build/utils.o\n[LD] build/compiler\nBuild successful.\n```"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Let's run a full build of TitanC and see if there are any issues."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Executing build for TitanC (Target: ARM64)...\n```\n[CC] src/main.ts -> build/main.o\n[CC] src/core/scanner/main.ts -> build/core_scanner_main.o\nsrc/core/scanner/main.ts:97:8: warning: unused variable 'temp' [-Wunused-variable]\n[CC] src/core/parser/main.ts -> build/core_parser_main.o\n[CC] src/ast.ts -> build/ast.o\nsrc/ast.ts:22:8: warning: unused variable 'temp' [-Wunused-variable]\n[CC] src/codegen.ts -> build/codegen.o\n[CC] src/utils.ts -> build/utils.o\n[LD] build/compiler\nBuild successful.\n```"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Can you list the files in the src directory? I want to make sure I haven't missed anything."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"functionCall": {
|
||||
"name": "ls",
|
||||
"args": {
|
||||
"path": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"functionResponse": {
|
||||
"name": "ls",
|
||||
"response": {
|
||||
"output": "src/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Sure, here are the files in src/:\nsrc/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Can you list the files in the src directory? I want to make sure I haven't missed anything."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"functionCall": {
|
||||
"name": "ls",
|
||||
"args": {
|
||||
"path": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"functionResponse": {
|
||||
"name": "ls",
|
||||
"response": {
|
||||
"output": "src/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Sure, here are the files in src/:\nsrc/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "I just found out there's another project called 'TitanC'. To avoid any legal issues, let's rename our compiler to 'GigaC'. We should update all references immediately."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Understood. Safety first. Renaming project from 'TitanC' to 'GigaC'. I'll update all our internal strings and build scripts."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Let's run a full build of GigaC and see if there are any issues."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Executing build for GigaC (Target: ARM64)...\n```\n[CC] src/main.ts -> build/main.o\n[CC] src/core/scanner/main.ts -> build/core_scanner_main.o\nsrc/core/scanner/main.ts:42:8: warning: unused variable 'temp' [-Wunused-variable]\n[CC] src/core/parser/main.ts -> build/core_parser_main.o\n[CC] src/ast.ts -> build/ast.o\n[CC] src/codegen.ts -> build/codegen.o\n[CC] src/utils.ts -> build/utils.o\n[LD] build/compiler\nBuild successful.\n```"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Can you list the files in the src directory? I want to make sure I haven't missed anything."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"functionCall": {
|
||||
"name": "ls",
|
||||
"args": {
|
||||
"path": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"functionResponse": {
|
||||
"name": "ls",
|
||||
"response": {
|
||||
"output": "src/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Sure, here are the files in src/:\nsrc/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "How is the test coverage looking lately?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "We're making good progress. We now have 15 unit tests passing across all modules."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "For our integration tests, we'll need to use the staging environment. The access code is 'STAGING_READY_2026'. Please keep this secure."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Acknowledged. I've noted the staging environment access code as STAGING_READY_2026 and will treat it as sensitive information."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "What was the most recent change committed to the repo?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "The latest commit is:\n```\ncommit 8s45s\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n fix: correctly handle escaped characters in strings\n```"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "How is the test coverage looking lately?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "We're making good progress. We now have 16 unit tests passing across all modules."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "How is the test coverage looking lately?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "We're making good progress. We now have 17 unit tests passing across all modules."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "I've discovered a critical bug in the lexer. It seems to fail when block comments (/* ... */) span across multiple lines. Let's track this as Bug #404."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Got it. Tracking Bug #404: Lexer fails to handle multi-line block comments. I'll keep this in mind as we refine the scanner."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "I was thinking about the optimizing the constant folding pass. What's your take?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "That's a valid concern. Given our current architecture in GigaC, we could definitely improve that area. I recommend we prioritize correctness first, then profile to see if it's a bottleneck."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Let's run a full build of GigaC and see if there are any issues."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Executing build for GigaC (Target: ARM64)...\n```\n[CC] src/main.ts -> build/main.o\n[CC] src/core/scanner/main.ts -> build/core_scanner_main.o\n[CC] src/core/parser/main.ts -> build/core_parser_main.o\n[CC] src/ast.ts -> build/ast.o\n[CC] src/codegen.ts -> build/codegen.o\nsrc/codegen.ts:82:8: warning: unused variable 'temp' [-Wunused-variable]\n[CC] src/utils.ts -> build/utils.o\n[LD] build/compiler\nBuild successful.\n```"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Can you list the files in the src directory? I want to make sure I haven't missed anything."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"functionCall": {
|
||||
"name": "ls",
|
||||
"args": {
|
||||
"path": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"functionResponse": {
|
||||
"name": "ls",
|
||||
"response": {
|
||||
"output": "src/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Sure, here are the files in src/:\nsrc/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "What was the most recent change committed to the repo?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "The latest commit is:\n```\ncommit 5tclu\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n test: add edge cases for floating point literals\n```"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "I was thinking about the switching to a data-oriented design for the AST. What's your take?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "That's a valid concern. Given our current architecture in GigaC, we could definitely improve that area. I recommend we prioritize correctness first, then profile to see if it's a bottleneck."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Can you list the files in the src directory? I want to make sure I haven't missed anything."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"functionCall": {
|
||||
"name": "ls",
|
||||
"args": {
|
||||
"path": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"functionResponse": {
|
||||
"name": "ls",
|
||||
"response": {
|
||||
"output": "src/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "Sure, here are the files in src/:\nsrc/main.ts\nsrc/core/scanner/main.ts\nsrc/core/parser/main.ts\nsrc/ast.ts\nsrc/codegen.ts\nsrc/utils.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "What was the most recent change committed to the repo?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "The latest commit is:\n```\ncommit 0z2ta5\nAuthor: Christian <gundermanc@google.com>\nDate: Mon Feb 2 14:00:00 2026 -0800\n\n perf: optimize lexer lookup table\n```"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "I was thinking about the handling variadic functions in the calling convention. What's your take?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "That's a valid concern. Given our current architecture in GigaC, we could definitely improve that area. I recommend we prioritize correctness first, then profile to see if it's a bottleneck."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"text": "How is the test coverage looking lately?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{
|
||||
"text": "We're making good progress. We now have 19 unit tests passing across all modules."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"questions": [
|
||||
{
|
||||
"id": "lexer-enum",
|
||||
"prompt": "What are the first 3 enum values in the TokenType definition for the Lexer?",
|
||||
"expectedSubstring": "INT, KEYWORD, IDENTIFIER"
|
||||
},
|
||||
{
|
||||
"id": "lexer-constraint",
|
||||
"prompt": "What is the primary constraint regarding regex libraries in the lexer?",
|
||||
"expectedSubstring": "no third-party regex libraries"
|
||||
},
|
||||
{
|
||||
"id": "spatial-lexer-path",
|
||||
"prompt": "Where is the core lexer source file located?",
|
||||
"expectedSubstring": "src/core/scanner/main.ts"
|
||||
},
|
||||
{
|
||||
"id": "ast-interface",
|
||||
"prompt": "What are the allowed 'type' values in the AstNode interface?",
|
||||
"expectedSubstring": "Program' | 'Function' | 'Return' | 'Constant"
|
||||
},
|
||||
{
|
||||
"id": "bug-1-description",
|
||||
"prompt": "What was Bug #404 about?",
|
||||
"expectedSubstring": "multi-line block comment"
|
||||
},
|
||||
{
|
||||
"id": "secret-alpha",
|
||||
"prompt": "What is the secret staging environment access code?",
|
||||
"expectedSubstring": "STAGING_READY_2026"
|
||||
},
|
||||
{
|
||||
"id": "secret-beta",
|
||||
"prompt": "What is the production deployment key?",
|
||||
"expectedSubstring": "PROD_DEPLOY_KEY_XYZ"
|
||||
},
|
||||
{
|
||||
"id": "codegen-header",
|
||||
"prompt": "What exact comment line should the code generator emit at the start of every file?",
|
||||
"expectedSubstring": "; Generated by GigaC Compiler v0.1"
|
||||
},
|
||||
{
|
||||
"id": "current-arch",
|
||||
"prompt": "What is the final target architecture we decided on?",
|
||||
"expectedSubstring": "ARM64"
|
||||
},
|
||||
{
|
||||
"id": "final-name",
|
||||
"prompt": "What is the final name of our project after the rename?",
|
||||
"expectedSubstring": "GigaC"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,8 @@ describe('Edits location eval', () => {
|
||||
* instead of creating a new one.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should update existing test file instead of creating a new one',
|
||||
files: {
|
||||
'package.json': JSON.stringify(
|
||||
|
||||
@@ -15,6 +15,8 @@ describe('Frugal reads eval', () => {
|
||||
* nearby ranges into a single contiguous read to save tool calls.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should use ranged read when nearby lines are targeted',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
@@ -135,6 +137,8 @@ describe('Frugal reads eval', () => {
|
||||
* apart to avoid the need to read the whole file.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should use ranged read when targets are far apart',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
@@ -204,6 +208,8 @@ describe('Frugal reads eval', () => {
|
||||
* (e.g.: 10), as it's more efficient than many small ranged reads.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should read the entire file when there are many matches',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
|
||||
@@ -13,18 +13,6 @@ import { evalTest } from './test-helper.js';
|
||||
* This ensures the agent doesn't flood the context window with unnecessary search results.
|
||||
*/
|
||||
describe('Frugal Search', () => {
|
||||
const getGrepParams = (call: any): any => {
|
||||
let args = call.toolRequest.args;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args);
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure that the agent makes use of either grep or ranged reads in fulfilling this task.
|
||||
* The task is specifically phrased to not evoke "view" or "search" specifically because
|
||||
@@ -33,6 +21,8 @@ describe('Frugal Search', () => {
|
||||
* ranged reads.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should use grep or ranged read for large files',
|
||||
prompt: 'What year was legacy_processor.ts written?',
|
||||
files: {
|
||||
|
||||
@@ -11,6 +11,8 @@ import fs from 'node:fs/promises';
|
||||
|
||||
describe('generalist_agent', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should be able to use generalist agent by explicitly asking the main agent to invoke it',
|
||||
params: {
|
||||
settings: {
|
||||
|
||||
@@ -11,6 +11,8 @@ describe('generalist_delegation', () => {
|
||||
// --- Positive Evals (Should Delegate) ---
|
||||
|
||||
appEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should delegate batch error fixing to generalist agent',
|
||||
configOverrides: {
|
||||
agents: {
|
||||
@@ -54,6 +56,8 @@ describe('generalist_delegation', () => {
|
||||
});
|
||||
|
||||
appEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should autonomously delegate complex batch task to generalist agent',
|
||||
configOverrides: {
|
||||
agents: {
|
||||
@@ -94,6 +98,8 @@ describe('generalist_delegation', () => {
|
||||
// --- Negative Evals (Should NOT Delegate - Assertive Handling) ---
|
||||
|
||||
appEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should NOT delegate simple read and fix to generalist agent',
|
||||
configOverrides: {
|
||||
agents: {
|
||||
@@ -128,6 +134,8 @@ describe('generalist_delegation', () => {
|
||||
});
|
||||
|
||||
appEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should NOT delegate simple direct question to generalist agent',
|
||||
configOverrides: {
|
||||
agents: {
|
||||
|
||||
@@ -26,6 +26,8 @@ describe('git repo eval', () => {
|
||||
* be more consistent.
|
||||
*/
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should not git add commit changes unprompted',
|
||||
prompt:
|
||||
'Finish this up for me by just making a targeted fix for the bug in index.ts. Do not build, install anything, or add tests',
|
||||
@@ -55,6 +57,8 @@ describe('git repo eval', () => {
|
||||
* instructed to not do so by default.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should git commit changes when prompted',
|
||||
prompt:
|
||||
'Make a targeted fix for the bug in index.ts without building, installing anything, or adding tests. Then, commit your changes.',
|
||||
|
||||
@@ -15,6 +15,8 @@ describe('grep_search_functionality', () => {
|
||||
const TEST_PREFIX = 'Grep Search Functionality: ';
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should find a simple string in a file',
|
||||
files: {
|
||||
'test.txt': `hello
|
||||
@@ -33,6 +35,8 @@ describe('grep_search_functionality', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should perform a case-sensitive search',
|
||||
files: {
|
||||
'test.txt': `Hello
|
||||
@@ -63,6 +67,8 @@ describe('grep_search_functionality', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should return only file names when names_only is used',
|
||||
files: {
|
||||
'file1.txt': 'match me',
|
||||
@@ -93,6 +99,8 @@ describe('grep_search_functionality', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should search only within the specified include_pattern glob',
|
||||
files: {
|
||||
'file.js': 'my_function();',
|
||||
@@ -123,6 +131,8 @@ describe('grep_search_functionality', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should search within a specific subdirectory',
|
||||
files: {
|
||||
'src/main.js': 'unique_string_1',
|
||||
@@ -153,6 +163,8 @@ describe('grep_search_functionality', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should report no matches correctly',
|
||||
files: {
|
||||
'file.txt': 'nothing to see here',
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
import { assertModelHasOutput } from '../integration-tests/test-helper.js';
|
||||
import { evalTest, assertModelHasOutput } from './test-helper.js';
|
||||
|
||||
describe('Hierarchical Memory', () => {
|
||||
const conflictResolutionTest =
|
||||
'Agent follows hierarchy for contradictory instructions';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: conflictResolutionTest,
|
||||
params: {
|
||||
settings: {
|
||||
@@ -48,6 +49,8 @@ What is my favorite fruit? Tell me just the name of the fruit.`,
|
||||
|
||||
const provenanceAwarenessTest = 'Agent is aware of memory provenance';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: provenanceAwarenessTest,
|
||||
params: {
|
||||
settings: {
|
||||
@@ -87,6 +90,8 @@ Provide the answer as an XML block like this:
|
||||
|
||||
const extensionVsGlobalTest = 'Extension memory wins over Global memory';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: extensionVsGlobalTest,
|
||||
params: {
|
||||
settings: {
|
||||
|
||||
@@ -8,6 +8,8 @@ describe('interactive_commands', () => {
|
||||
* intervention.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should not use interactive commands',
|
||||
prompt: 'Execute tests.',
|
||||
files: {
|
||||
@@ -49,6 +51,8 @@ describe('interactive_commands', () => {
|
||||
* Validates that the agent uses non-interactive flags when scaffolding a new project.
|
||||
*/
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should use non-interactive flags when scaffolding a new app',
|
||||
prompt: 'Create a new react application named my-app using vite.',
|
||||
assert: async (rig, result) => {
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { appEvalTest } from './app-test-helper.js';
|
||||
import { PolicyDecision } from '@google/gemini-cli-core';
|
||||
|
||||
describe('Model Steering Behavioral Evals', () => {
|
||||
appEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'Corrective Hint: Model switches task based on hint during tool turn',
|
||||
configOverrides: {
|
||||
modelSteering: true,
|
||||
@@ -52,6 +52,8 @@ describe('Model Steering Behavioral Evals', () => {
|
||||
});
|
||||
|
||||
appEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'Suggestive Hint: Model incorporates user guidance mid-stream',
|
||||
configOverrides: {
|
||||
modelSteering: true,
|
||||
|
||||
@@ -33,6 +33,8 @@ describe('plan_mode', () => {
|
||||
.filter(Boolean);
|
||||
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should refuse file modification when in plan mode',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
@@ -68,6 +70,8 @@ describe('plan_mode', () => {
|
||||
});
|
||||
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should refuse saving new documentation to the repo when in plan mode',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
@@ -105,6 +109,8 @@ describe('plan_mode', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should enter plan mode when asked to create a plan',
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
params: {
|
||||
@@ -122,6 +128,8 @@ describe('plan_mode', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should exit plan mode when plan is complete and implementation is requested',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
@@ -169,6 +177,8 @@ describe('plan_mode', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should allow file modification in plans directory when in plan mode',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
@@ -201,6 +211,8 @@ describe('plan_mode', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should create a plan in plan mode and implement it for a refactoring task',
|
||||
params: {
|
||||
settings,
|
||||
|
||||
@@ -11,6 +11,8 @@ import fs from 'node:fs/promises';
|
||||
|
||||
describe('redundant_casts', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should not add redundant or unsafe casts when modifying typescript code',
|
||||
files: {
|
||||
'src/cast_example.ts': `
|
||||
|
||||
@@ -3,6 +3,8 @@ import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('Sandbox recovery', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'attempts to use additional_permissions when operation not permitted',
|
||||
prompt:
|
||||
'Run ./script.sh. It will fail with "Operation not permitted". When it does, you must retry running it by passing the appropriate additional_permissions.',
|
||||
|
||||
@@ -5,16 +5,18 @@
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
import {
|
||||
evalTest,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from '../integration-tests/test-helper.js';
|
||||
} from './test-helper.js';
|
||||
|
||||
describe('save_memory', () => {
|
||||
const TEST_PREFIX = 'Save memory test: ';
|
||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingFavoriteColor,
|
||||
|
||||
prompt: `remember that my favorite color is blue.
|
||||
@@ -35,6 +37,8 @@ describe('save_memory', () => {
|
||||
});
|
||||
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingCommandRestrictions,
|
||||
|
||||
prompt: `I don't want you to ever run npm commands.`,
|
||||
@@ -54,6 +58,8 @@ describe('save_memory', () => {
|
||||
|
||||
const rememberingWorkflow = 'Agent remembers workflow preferences';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingWorkflow,
|
||||
|
||||
prompt: `I want you to always lint after building.`,
|
||||
@@ -74,6 +80,8 @@ describe('save_memory', () => {
|
||||
const ignoringTemporaryInformation =
|
||||
'Agent ignores temporary conversation details';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: ignoringTemporaryInformation,
|
||||
|
||||
prompt: `I'm going to get a coffee.`,
|
||||
@@ -97,6 +105,8 @@ describe('save_memory', () => {
|
||||
|
||||
const rememberingPetName = "Agent remembers user's pet's name";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingPetName,
|
||||
|
||||
prompt: `Please remember that my dog's name is Buddy.`,
|
||||
@@ -116,6 +126,8 @@ describe('save_memory', () => {
|
||||
|
||||
const rememberingCommandAlias = 'Agent remembers custom command aliases';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingCommandAlias,
|
||||
|
||||
prompt: `When I say 'start server', you should run 'npm run dev'.`,
|
||||
@@ -136,6 +148,8 @@ describe('save_memory', () => {
|
||||
const ignoringDbSchemaLocation =
|
||||
"Agent ignores workspace's database schema location";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: ignoringDbSchemaLocation,
|
||||
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -155,6 +169,8 @@ describe('save_memory', () => {
|
||||
const rememberingCodingStyle =
|
||||
"Agent remembers user's coding style preference";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingCodingStyle,
|
||||
|
||||
prompt: `I prefer to use tabs instead of spaces for indentation.`,
|
||||
@@ -175,6 +191,8 @@ describe('save_memory', () => {
|
||||
const ignoringBuildArtifactLocation =
|
||||
'Agent ignores workspace build artifact location';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: ignoringBuildArtifactLocation,
|
||||
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -193,6 +211,8 @@ describe('save_memory', () => {
|
||||
|
||||
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: ignoringMainEntryPoint,
|
||||
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -211,6 +231,8 @@ describe('save_memory', () => {
|
||||
|
||||
const rememberingBirthday = "Agent remembers user's birthday";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingBirthday,
|
||||
|
||||
prompt: `My birthday is on June 15th.`,
|
||||
@@ -231,6 +253,8 @@ describe('save_memory', () => {
|
||||
const proactiveMemoryFromLongSession =
|
||||
'Agent saves preference from earlier in conversation history';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: proactiveMemoryFromLongSession,
|
||||
params: {
|
||||
settings: {
|
||||
@@ -309,6 +333,8 @@ describe('save_memory', () => {
|
||||
const memoryManagerRoutingPreferences =
|
||||
'Agent routes global and project preferences to memory';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memoryManagerRoutingPreferences,
|
||||
params: {
|
||||
settings: {
|
||||
|
||||
@@ -21,6 +21,8 @@ describe('Shell Efficiency', () => {
|
||||
};
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should use --silent/--quiet flags when installing packages',
|
||||
prompt: 'Install the "lodash" package using npm.',
|
||||
assert: async (rig) => {
|
||||
@@ -50,6 +52,8 @@ describe('Shell Efficiency', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should use --no-pager with git commands',
|
||||
prompt: 'Show the git log.',
|
||||
assert: async (rig) => {
|
||||
@@ -73,6 +77,8 @@ describe('Shell Efficiency', () => {
|
||||
});
|
||||
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should NOT use efficiency flags when enableShellOutputEfficiency is disabled',
|
||||
params: {
|
||||
settings: {
|
||||
|
||||
@@ -45,6 +45,8 @@ describe('subagent eval test cases', () => {
|
||||
* This tests the system prompt's subagent specific clauses.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should delegate to user provided agent with relevant expertise',
|
||||
params: {
|
||||
settings: {
|
||||
@@ -69,6 +71,8 @@ describe('subagent eval test cases', () => {
|
||||
* subagents are available. This helps catch orchestration overuse.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should avoid delegating trivial direct edit work',
|
||||
params: {
|
||||
settings: {
|
||||
@@ -113,6 +117,8 @@ describe('subagent eval test cases', () => {
|
||||
* This is meant to codify the "overusing Generalist" failure mode.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should prefer relevant specialist over generalist',
|
||||
params: {
|
||||
settings: {
|
||||
@@ -149,6 +155,8 @@ describe('subagent eval test cases', () => {
|
||||
* naturally spans docs and tests, so multiple specialists should be used.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should use multiple relevant specialists for multi-surface task',
|
||||
params: {
|
||||
settings: {
|
||||
@@ -193,6 +201,8 @@ describe('subagent eval test cases', () => {
|
||||
* from a large pool of available subagents (10 total).
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should select the correct subagent from a pool of 10 different agents',
|
||||
prompt: 'Please add a new SQL table migration for a user profile.',
|
||||
files: {
|
||||
@@ -243,6 +253,8 @@ describe('subagent eval test cases', () => {
|
||||
* This test includes stress tests the subagent delegation with ~80 tools.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should select the correct subagent from a pool of 10 different agents with MCP tools present',
|
||||
prompt: 'Please add a new SQL table migration for a user profile.',
|
||||
setup: async (rig) => {
|
||||
|
||||
@@ -49,6 +49,8 @@ describe('evalTest reliability logic', () => {
|
||||
|
||||
// Execute the test function directly
|
||||
await internalEvalTest({
|
||||
suiteName: 'test',
|
||||
suiteType: 'behavioral',
|
||||
name: 'test-api-failure',
|
||||
prompt: 'do something',
|
||||
assert: async () => {},
|
||||
@@ -83,6 +85,8 @@ describe('evalTest reliability logic', () => {
|
||||
// Expect the test function to throw immediately
|
||||
await expect(
|
||||
internalEvalTest({
|
||||
suiteName: 'test',
|
||||
suiteType: 'behavioral',
|
||||
name: 'test-logic-failure',
|
||||
prompt: 'do something',
|
||||
assert: async () => {
|
||||
@@ -108,6 +112,8 @@ describe('evalTest reliability logic', () => {
|
||||
.mockResolvedValueOnce('Success');
|
||||
|
||||
await internalEvalTest({
|
||||
suiteName: 'test',
|
||||
suiteType: 'behavioral',
|
||||
name: 'test-recovery',
|
||||
prompt: 'do something',
|
||||
assert: async () => {},
|
||||
@@ -135,6 +141,8 @@ describe('evalTest reliability logic', () => {
|
||||
);
|
||||
|
||||
await internalEvalTest({
|
||||
suiteName: 'test',
|
||||
suiteType: 'behavioral',
|
||||
name: 'test-api-503',
|
||||
prompt: 'do something',
|
||||
assert: async () => {},
|
||||
@@ -162,6 +170,8 @@ describe('evalTest reliability logic', () => {
|
||||
try {
|
||||
await expect(
|
||||
internalEvalTest({
|
||||
suiteName: 'test',
|
||||
suiteType: 'behavioral',
|
||||
name: 'test-absolute-path',
|
||||
prompt: 'do something',
|
||||
files: {
|
||||
@@ -190,6 +200,8 @@ describe('evalTest reliability logic', () => {
|
||||
try {
|
||||
await expect(
|
||||
internalEvalTest({
|
||||
suiteName: 'test',
|
||||
suiteType: 'behavioral',
|
||||
name: 'test-traversal',
|
||||
prompt: 'do something',
|
||||
files: {
|
||||
|
||||
+94
-54
@@ -16,10 +16,19 @@ import {
|
||||
Storage,
|
||||
getProjectHash,
|
||||
SESSION_FILE_PREFIX,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export * from '@google/gemini-cli-test-utils';
|
||||
|
||||
/**
|
||||
* The default model used for all evaluations.
|
||||
* Can be overridden by setting the GEMINI_MODEL environment variable.
|
||||
*/
|
||||
export const EVAL_MODEL =
|
||||
process.env['GEMINI_MODEL'] || PREVIEW_GEMINI_FLASH_MODEL;
|
||||
|
||||
// Indicates the consistency expectation for this test.
|
||||
// - ALWAYS_PASSES - Means that the test is expected to pass 100% of the time. These
|
||||
// These tests are typically trivial and test basic functionality with unambiguous
|
||||
@@ -39,19 +48,49 @@ export * from '@google/gemini-cli-test-utils';
|
||||
export type EvalPolicy = 'ALWAYS_PASSES' | 'USUALLY_PASSES';
|
||||
|
||||
export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
|
||||
runEval(
|
||||
policy,
|
||||
evalCase.name,
|
||||
() => internalEvalTest(evalCase),
|
||||
evalCase.timeout,
|
||||
);
|
||||
runEval(policy, evalCase, () => internalEvalTest(evalCase));
|
||||
}
|
||||
|
||||
export async function internalEvalTest(evalCase: EvalCase) {
|
||||
export async function withEvalRetries(
|
||||
name: string,
|
||||
attemptFn: (attempt: number) => Promise<void>,
|
||||
) {
|
||||
const maxRetries = 3;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
try {
|
||||
await attemptFn(attempt);
|
||||
return; // Success! Exit the retry loop.
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
const errorCode = getApiErrorCode(errorMessage);
|
||||
|
||||
if (errorCode) {
|
||||
const status = attempt < maxRetries ? 'RETRY' : 'SKIP';
|
||||
logReliabilityEvent(name, attempt, status, errorCode, errorMessage);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
attempt++;
|
||||
console.warn(
|
||||
`[Eval] Attempt ${attempt} failed with ${errorCode} Error. Retrying...`,
|
||||
);
|
||||
continue; // Retry
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[Eval] '${name}' failed after ${maxRetries} retries due to persistent API errors. Skipping failure to avoid blocking PR.`,
|
||||
);
|
||||
return; // Gracefully exit without failing the test
|
||||
}
|
||||
|
||||
throw error; // Real failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function internalEvalTest(evalCase: EvalCase) {
|
||||
await withEvalRetries(evalCase.name, async () => {
|
||||
const rig = new TestRig();
|
||||
const { logDir, sanitizedName } = await prepareLogDir(evalCase.name);
|
||||
const activityLogFile = path.join(logDir, `${sanitizedName}.jsonl`);
|
||||
@@ -59,14 +98,21 @@ export async function internalEvalTest(evalCase: EvalCase) {
|
||||
let isSuccess = false;
|
||||
|
||||
try {
|
||||
rig.setup(evalCase.name, evalCase.params);
|
||||
const setupOptions = {
|
||||
...evalCase.params,
|
||||
settings: {
|
||||
model: { name: EVAL_MODEL },
|
||||
...evalCase.params?.settings,
|
||||
},
|
||||
};
|
||||
rig.setup(evalCase.name, setupOptions);
|
||||
|
||||
if (evalCase.setup) {
|
||||
await evalCase.setup(rig);
|
||||
}
|
||||
|
||||
if (evalCase.files) {
|
||||
await setupTestFiles(rig, evalCase.files);
|
||||
await prepareWorkspace(rig.testDir!, rig.homeDir!, evalCase.files);
|
||||
}
|
||||
|
||||
symlinkNodeModules(rig.testDir || '');
|
||||
@@ -139,37 +185,6 @@ export async function internalEvalTest(evalCase: EvalCase) {
|
||||
|
||||
await evalCase.assert(rig, result);
|
||||
isSuccess = true;
|
||||
return; // Success! Exit the retry loop.
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const errorCode = getApiErrorCode(errorMessage);
|
||||
|
||||
if (errorCode) {
|
||||
const status = attempt < maxRetries ? 'RETRY' : 'SKIP';
|
||||
logReliabilityEvent(
|
||||
evalCase.name,
|
||||
attempt,
|
||||
status,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
attempt++;
|
||||
console.warn(
|
||||
`[Eval] Attempt ${attempt} failed with ${errorCode} Error. Retrying...`,
|
||||
);
|
||||
continue; // Retry
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[Eval] '${evalCase.name}' failed after ${maxRetries} retries due to persistent API errors. Skipping failure to avoid blocking PR.`,
|
||||
);
|
||||
return; // Gracefully exit without failing the test
|
||||
}
|
||||
|
||||
throw error; // Real failure
|
||||
} finally {
|
||||
if (isSuccess) {
|
||||
await fs.promises.unlink(activityLogFile).catch((err) => {
|
||||
@@ -188,7 +203,7 @@ export async function internalEvalTest(evalCase: EvalCase) {
|
||||
);
|
||||
await rig.cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getApiErrorCode(message: string): '500' | '503' | undefined {
|
||||
@@ -226,7 +241,7 @@ function logReliabilityEvent(
|
||||
const reliabilityLog = {
|
||||
timestamp: new Date().toISOString(),
|
||||
testName,
|
||||
model: process.env.GEMINI_MODEL || 'unknown',
|
||||
model: process.env['GEMINI_MODEL'] || 'unknown',
|
||||
attempt,
|
||||
status,
|
||||
errorCode,
|
||||
@@ -252,9 +267,13 @@ function logReliabilityEvent(
|
||||
* intentionally uses synchronous filesystem and child_process operations
|
||||
* for simplicity and to ensure sequential environment preparation.
|
||||
*/
|
||||
async function setupTestFiles(rig: TestRig, files: Record<string, string>) {
|
||||
export async function prepareWorkspace(
|
||||
testDir: string,
|
||||
homeDir: string,
|
||||
files: Record<string, string>,
|
||||
) {
|
||||
const acknowledgedAgents: Record<string, Record<string, string>> = {};
|
||||
const projectRoot = fs.realpathSync(rig.testDir!);
|
||||
const projectRoot = fs.realpathSync(testDir);
|
||||
|
||||
for (const [filePath, content] of Object.entries(files)) {
|
||||
if (filePath.includes('..') || path.isAbsolute(filePath)) {
|
||||
@@ -290,7 +309,7 @@ async function setupTestFiles(rig: TestRig, files: Record<string, string>) {
|
||||
|
||||
if (Object.keys(acknowledgedAgents).length > 0) {
|
||||
const ackPath = path.join(
|
||||
rig.homeDir!,
|
||||
homeDir,
|
||||
'.gemini',
|
||||
'acknowledgments',
|
||||
'agents.json',
|
||||
@@ -299,7 +318,7 @@ async function setupTestFiles(rig: TestRig, files: Record<string, string>) {
|
||||
fs.writeFileSync(ackPath, JSON.stringify(acknowledgedAgents, null, 2));
|
||||
}
|
||||
|
||||
const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const };
|
||||
const execOptions = { cwd: testDir, stdio: 'ignore' as const };
|
||||
execSync('git init --initial-branch=main', execOptions);
|
||||
execSync('git config user.email "test@example.com"', execOptions);
|
||||
execSync('git config user.name "Test User"', execOptions);
|
||||
@@ -320,14 +339,30 @@ async function setupTestFiles(rig: TestRig, files: Record<string, string>) {
|
||||
*/
|
||||
export function runEval(
|
||||
policy: EvalPolicy,
|
||||
name: string,
|
||||
evalCase: BaseEvalCase,
|
||||
fn: () => Promise<void>,
|
||||
timeout?: number,
|
||||
timeoutOverride?: number,
|
||||
) {
|
||||
if (policy === 'USUALLY_PASSES' && !process.env['RUN_EVALS']) {
|
||||
it.skip(name, fn);
|
||||
const { name, timeout, suiteName, suiteType } = evalCase;
|
||||
const targetSuiteType = process.env['EVAL_SUITE_TYPE'];
|
||||
const targetSuiteName = process.env['EVAL_SUITE_NAME'];
|
||||
|
||||
const meta = { suiteType, suiteName };
|
||||
|
||||
const skipBySuiteType =
|
||||
targetSuiteType && suiteType && suiteType !== targetSuiteType;
|
||||
const skipBySuiteName =
|
||||
targetSuiteName && suiteName && suiteName !== targetSuiteName;
|
||||
|
||||
const options = { timeout: timeoutOverride ?? timeout, meta };
|
||||
if (
|
||||
(policy === 'USUALLY_PASSES' && !process.env['RUN_EVALS']) ||
|
||||
skipBySuiteType ||
|
||||
skipBySuiteName
|
||||
) {
|
||||
it.skip(name, options, fn);
|
||||
} else {
|
||||
it(name, fn, timeout);
|
||||
it(name, options, fn);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,15 +401,20 @@ interface ForbiddenToolSettings {
|
||||
};
|
||||
}
|
||||
|
||||
export interface EvalCase {
|
||||
export interface BaseEvalCase {
|
||||
suiteName: string;
|
||||
suiteType: 'behavioral' | 'component-level' | 'hero-scenario';
|
||||
name: string;
|
||||
timeout?: number;
|
||||
files?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface EvalCase extends BaseEvalCase {
|
||||
params?: {
|
||||
settings?: ForbiddenToolSettings & Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
prompt: string;
|
||||
timeout?: number;
|
||||
files?: Record<string, string>;
|
||||
setup?: (rig: TestRig) => Promise<void> | void;
|
||||
/** Conversation history to pre-load via --resume. Each entry is a message object with type, content, etc. */
|
||||
messages?: Record<string, unknown>[];
|
||||
|
||||
@@ -31,6 +31,8 @@ describe('Tool Output Masking Behavioral Evals', () => {
|
||||
* It should recognize the <tool_output_masked> tag and use a tool to read the file.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should attempt to read the redirected full output file when information is masked',
|
||||
params: {
|
||||
security: {
|
||||
@@ -167,6 +169,8 @@ Output too large. Full output available at: ${outputFilePath}
|
||||
* Scenario: Information is in the preview.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should NOT read the full output file when the information is already in the preview',
|
||||
params: {
|
||||
security: {
|
||||
|
||||
@@ -25,6 +25,8 @@ const FILES = {
|
||||
|
||||
describe('tracker_mode', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should manage tasks in the tracker when explicitly requested during a bug fix',
|
||||
params: {
|
||||
settings: { experimental: { taskTracker: true } },
|
||||
@@ -78,6 +80,8 @@ describe('tracker_mode', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should implicitly create tasks when asked to build a feature plan',
|
||||
params: {
|
||||
settings: { experimental: { taskTracker: true } },
|
||||
|
||||
@@ -9,6 +9,8 @@ import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('validation_fidelity', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should perform exhaustive validation autonomously when guided by system instructions',
|
||||
files: {
|
||||
'src/types.ts': `
|
||||
|
||||
@@ -9,6 +9,8 @@ import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('validation_fidelity_pre_existing_errors', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should handle pre-existing project errors gracefully during validation',
|
||||
files: {
|
||||
'src/math.ts': `
|
||||
|
||||
@@ -24,7 +24,10 @@ export default defineConfig({
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
alias: {
|
||||
react: path.resolve(__dirname, '../node_modules/react'),
|
||||
'@google/gemini-cli-core': path.resolve(
|
||||
__dirname,
|
||||
'../packages/core/index.ts',
|
||||
),
|
||||
},
|
||||
setupFiles: [path.resolve(__dirname, '../packages/cli/test-setup.ts')],
|
||||
server: {
|
||||
|
||||
@@ -700,6 +700,7 @@ export interface ConfigParameters {
|
||||
experimentalJitContext?: boolean;
|
||||
autoDistillation?: boolean;
|
||||
experimentalMemoryManager?: boolean;
|
||||
experimentalContextSidecarConfig?: string;
|
||||
experimentalAgentHistoryTruncation?: boolean;
|
||||
experimentalAgentHistoryTruncationThreshold?: number;
|
||||
experimentalAgentHistoryRetainedMessages?: number;
|
||||
@@ -942,6 +943,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly adminSkillsEnabled: boolean;
|
||||
private readonly experimentalJitContext: boolean;
|
||||
private readonly experimentalMemoryManager: boolean;
|
||||
private readonly experimentalContextSidecarConfig?: string;
|
||||
private readonly memoryBoundaryMarkers: readonly string[];
|
||||
private readonly topicUpdateNarration: boolean;
|
||||
private readonly disableLLMCorrection: boolean;
|
||||
@@ -1153,6 +1155,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? false;
|
||||
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
|
||||
this.experimentalContextSidecarConfig =
|
||||
params.experimentalContextSidecarConfig;
|
||||
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
|
||||
this.contextManagement = {
|
||||
enabled: params.contextManagement?.enabled ?? false,
|
||||
@@ -2427,6 +2431,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.experimentalMemoryManager;
|
||||
}
|
||||
|
||||
getExperimentalContextSidecarConfig(): string | undefined {
|
||||
return this.experimentalContextSidecarConfig;
|
||||
}
|
||||
|
||||
getContextManagementConfig(): ContextManagementConfig {
|
||||
return this.contextManagement;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { testTruncateProfile } from './testing/testProfile.js';
|
||||
import {
|
||||
createSyntheticHistory,
|
||||
createMockContextConfig,
|
||||
setupContextComponentTest,
|
||||
} from './testing/contextTestUtils.js';
|
||||
|
||||
describe('ContextManager Sync Pressure Barrier Tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should instantly truncate history when maxTokens is exceeded using truncate strategy', async () => {
|
||||
// 1. Setup
|
||||
const config = createMockContextConfig();
|
||||
const { chatHistory, contextManager } = setupContextComponentTest(
|
||||
config,
|
||||
testTruncateProfile,
|
||||
);
|
||||
|
||||
// 2. Add System Prompt (Episode 0 - Protected)
|
||||
chatHistory.set([
|
||||
{ role: 'user', parts: [{ text: 'System prompt' }] },
|
||||
{ role: 'model', parts: [{ text: 'Understood.' }] },
|
||||
]);
|
||||
|
||||
// 3. Add massive history that blows past the 150k maxTokens limit
|
||||
// 20 turns * 10,000 tokens/turn = ~200,000 tokens
|
||||
const massiveHistory = createSyntheticHistory(20, 35000);
|
||||
chatHistory.set([...chatHistory.get(), ...massiveHistory]);
|
||||
|
||||
// 4. Add the Latest Turn (Protected)
|
||||
chatHistory.set([
|
||||
...chatHistory.get(),
|
||||
{ role: 'user', parts: [{ text: 'Final question.' }] },
|
||||
{ role: 'model', parts: [{ text: 'Final answer.' }] },
|
||||
]);
|
||||
|
||||
const rawHistoryLength = chatHistory.get().length;
|
||||
|
||||
// 5. Project History (Triggers Sync Barrier)
|
||||
const projection = await contextManager.projectCompressedHistory();
|
||||
|
||||
// 6. Assertions
|
||||
// The barrier should have dropped several older episodes to get under 150k.
|
||||
|
||||
expect(projection.length).toBeLessThan(rawHistoryLength);
|
||||
|
||||
// Verify Episode 0 (System) is perfectly preserved at the front
|
||||
|
||||
expect(projection[0].role).toBe('user');
|
||||
expect(projection[0].parts![0].text).toBe('System prompt');
|
||||
|
||||
// Filter out synthetic Yield nodes (they are model responses without actual tool/text bodies)
|
||||
const contentNodes = projection.filter(
|
||||
(p) =>
|
||||
p.parts && p.parts.some((part) => part.text && part.text !== 'Yield'),
|
||||
);
|
||||
|
||||
// Verify the latest turn is perfectly preserved at the back
|
||||
const lastUser = contentNodes[contentNodes.length - 2];
|
||||
const lastModel = contentNodes[contentNodes.length - 1];
|
||||
|
||||
expect(lastUser.role).toBe('user');
|
||||
expect(lastUser.parts![0].text).toBe('Final question.');
|
||||
|
||||
expect(lastModel.role).toBe('model');
|
||||
expect(lastModel.parts![0].text).toBe('Final answer.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
import type { AgentChatHistory } from '../core/agentChatHistory.js';
|
||||
import type { ConcreteNode } from './ir/types.js';
|
||||
import type { ContextEventBus } from './eventBus.js';
|
||||
import type { ContextTracer } from './tracer.js';
|
||||
import type { ContextEnvironment } from './sidecar/environment.js';
|
||||
import type { SidecarConfig } from './sidecar/types.js';
|
||||
import type { PipelineOrchestrator } from './sidecar/orchestrator.js';
|
||||
import { HistoryObserver } from './historyObserver.js';
|
||||
import { IrProjector } from './ir/projector.js';
|
||||
import { ContextWorkingBufferImpl } from './sidecar/contextWorkingBuffer.js';
|
||||
|
||||
export class ContextManager {
|
||||
// The master state containing the pristine graph and current active graph.
|
||||
private buffer: ContextWorkingBufferImpl =
|
||||
ContextWorkingBufferImpl.initialize([]);
|
||||
private pristineNodes: readonly ConcreteNode[] = [];
|
||||
|
||||
private readonly eventBus: ContextEventBus;
|
||||
|
||||
// Internal sub-components
|
||||
private readonly orchestrator: PipelineOrchestrator;
|
||||
private readonly historyObserver: HistoryObserver;
|
||||
|
||||
constructor(
|
||||
private readonly sidecar: SidecarConfig,
|
||||
private readonly env: ContextEnvironment,
|
||||
private readonly tracer: ContextTracer,
|
||||
orchestrator: PipelineOrchestrator,
|
||||
chatHistory: AgentChatHistory,
|
||||
) {
|
||||
this.eventBus = env.eventBus;
|
||||
this.orchestrator = orchestrator;
|
||||
|
||||
this.historyObserver = new HistoryObserver(
|
||||
chatHistory,
|
||||
this.env.eventBus,
|
||||
this.tracer,
|
||||
this.env.tokenCalculator,
|
||||
this.env.irMapper,
|
||||
);
|
||||
this.historyObserver.start();
|
||||
|
||||
this.eventBus.onPristineHistoryUpdated((event) => {
|
||||
this.pristineNodes = event.nodes;
|
||||
|
||||
const existingIds = new Set(this.buffer.nodes.map((n) => n.id));
|
||||
const addedNodes = event.nodes.filter((n) => !existingIds.has(n.id));
|
||||
|
||||
if (addedNodes.length > 0) {
|
||||
this.buffer = this.buffer.appendPristineNodes(addedNodes);
|
||||
}
|
||||
|
||||
this.evaluateTriggers(event.newNodes);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely stops background workers and clears event listeners.
|
||||
*/
|
||||
shutdown() {
|
||||
this.orchestrator.shutdown();
|
||||
this.historyObserver.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates if the current working buffer exceeds configured budget thresholds,
|
||||
* firing consolidation events if necessary.
|
||||
*/
|
||||
private evaluateTriggers(newNodes: Set<string>) {
|
||||
if (!this.sidecar.budget) return;
|
||||
|
||||
if (newNodes.size > 0) {
|
||||
this.eventBus.emitChunkReceived({
|
||||
nodes: this.buffer.nodes,
|
||||
targetNodeIds: newNodes,
|
||||
});
|
||||
}
|
||||
|
||||
const currentTokens = this.env.tokenCalculator.calculateConcreteListTokens(
|
||||
this.buffer.nodes,
|
||||
);
|
||||
|
||||
if (currentTokens > this.sidecar.budget.retainedTokens) {
|
||||
const agedOutNodes = new Set<string>();
|
||||
let rollingTokens = 0;
|
||||
// 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];
|
||||
rollingTokens += this.env.tokenCalculator.calculateConcreteListTokens([
|
||||
node,
|
||||
]);
|
||||
if (rollingTokens > this.sidecar.budget.retainedTokens) {
|
||||
agedOutNodes.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (agedOutNodes.size > 0) {
|
||||
this.eventBus.emitConsolidationNeeded({
|
||||
nodes: this.buffer.nodes,
|
||||
targetDeficit: currentTokens - this.sidecar.budget.retainedTokens,
|
||||
targetNodeIds: agedOutNodes,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the raw, uncompressed Episodic IR graph.
|
||||
* Useful for internal tool rendering (like the trace viewer).
|
||||
* Note: This is an expensive, deep clone operation.
|
||||
*/
|
||||
getPristineGraph(): readonly ConcreteNode[] {
|
||||
return [...this.pristineNodes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a virtual view of the pristine graph, substituting in variants
|
||||
* up to the configured token budget.
|
||||
* This is the view that will eventually be projected back to the LLM.
|
||||
*/
|
||||
getNodes(): readonly ConcreteNode[] {
|
||||
return [...this.buffer.nodes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the final 'gc_backstop' pipeline if necessary, enforcing the token budget,
|
||||
* and maps the Episodic IR back into a raw Gemini Content[] array for transmission.
|
||||
* This is the primary method called by the agent framework before sending a request.
|
||||
*/
|
||||
async projectCompressedHistory(
|
||||
activeTaskIds: Set<string> = new Set(),
|
||||
): Promise<Content[]> {
|
||||
this.tracer.logEvent(
|
||||
'ContextManager',
|
||||
'Starting projection to LLM context',
|
||||
);
|
||||
// Apply final GC Backstop pressure barrier synchronously before mapping
|
||||
const finalHistory = await IrProjector.project(
|
||||
this.buffer.nodes,
|
||||
this.orchestrator,
|
||||
this.sidecar,
|
||||
this.tracer,
|
||||
this.env,
|
||||
activeTaskIds,
|
||||
);
|
||||
|
||||
this.tracer.logEvent('ContextManager', 'Finished projection');
|
||||
|
||||
return finalHistory;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { ConcreteNode } from './ir/types.js';
|
||||
|
||||
export interface PristineHistoryUpdatedEvent {
|
||||
nodes: readonly ConcreteNode[];
|
||||
newNodes: Set<string>;
|
||||
}
|
||||
|
||||
export interface ContextConsolidationEvent {
|
||||
nodes: readonly ConcreteNode[];
|
||||
targetDeficit: number;
|
||||
targetNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
export interface IrChunkReceivedEvent {
|
||||
nodes: readonly ConcreteNode[];
|
||||
targetNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
export class ContextEventBus extends EventEmitter {
|
||||
emitPristineHistoryUpdated(event: PristineHistoryUpdatedEvent) {
|
||||
this.emit('PRISTINE_HISTORY_UPDATED', event);
|
||||
}
|
||||
|
||||
onPristineHistoryUpdated(
|
||||
listener: (event: PristineHistoryUpdatedEvent) => void,
|
||||
) {
|
||||
this.on('PRISTINE_HISTORY_UPDATED', listener);
|
||||
}
|
||||
|
||||
emitChunkReceived(event: IrChunkReceivedEvent) {
|
||||
this.emit('IR_CHUNK_RECEIVED', event);
|
||||
}
|
||||
|
||||
onChunkReceived(listener: (event: IrChunkReceivedEvent) => void) {
|
||||
this.on('IR_CHUNK_RECEIVED', listener);
|
||||
}
|
||||
|
||||
emitConsolidationNeeded(event: ContextConsolidationEvent) {
|
||||
this.emit('BUDGET_RETAINED_CROSSED', event);
|
||||
}
|
||||
|
||||
onConsolidationNeeded(listener: (event: ContextConsolidationEvent) => void) {
|
||||
this.on('BUDGET_RETAINED_CROSSED', listener);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentChatHistory,
|
||||
HistoryEvent,
|
||||
} from '../core/agentChatHistory.js';
|
||||
import type { IrMapper } from './ir/mapper.js';
|
||||
import type { ContextTokenCalculator } from './utils/contextTokenCalculator.js';
|
||||
import type { ContextEventBus } from './eventBus.js';
|
||||
import type { ContextTracer } from './tracer.js';
|
||||
|
||||
import type { ConcreteNode } from './ir/types.js';
|
||||
|
||||
/**
|
||||
* Connects the raw AgentChatHistory to the ContextManager.
|
||||
* It maps raw messages into Episodic Intermediate Representation (IR)
|
||||
* and evaluates background triggers whenever history changes.
|
||||
*/
|
||||
export class HistoryObserver {
|
||||
private unsubscribeHistory?: () => void;
|
||||
|
||||
private seenNodeIds = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly chatHistory: AgentChatHistory,
|
||||
private readonly eventBus: ContextEventBus,
|
||||
private readonly tracer: ContextTracer,
|
||||
private readonly tokenCalculator: ContextTokenCalculator,
|
||||
private readonly irMapper: IrMapper,
|
||||
) {}
|
||||
|
||||
start() {
|
||||
if (this.unsubscribeHistory) {
|
||||
this.unsubscribeHistory();
|
||||
}
|
||||
|
||||
this.unsubscribeHistory = this.chatHistory.subscribe(
|
||||
(_event: HistoryEvent) => {
|
||||
// Rebuild the pristine IR graph from the full source history on every change.
|
||||
// Wait, toIr still returns an Episode[].
|
||||
// We actually need to map the Episode[] to a flat ConcreteNode[] here to form the 'nodes'.
|
||||
const pristineEpisodes = this.irMapper.toIr(
|
||||
this.chatHistory.get(),
|
||||
this.tokenCalculator,
|
||||
);
|
||||
|
||||
const nodes: ConcreteNode[] = [];
|
||||
for (const ep of pristineEpisodes) {
|
||||
if (ep.concreteNodes) {
|
||||
for (const child of ep.concreteNodes) {
|
||||
nodes.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newNodes = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
if (!this.seenNodeIds.has(node.id)) {
|
||||
newNodes.add(node.id);
|
||||
this.seenNodeIds.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.tracer.logEvent(
|
||||
'HistoryObserver',
|
||||
'Rebuilt pristine graph from chat history update',
|
||||
{ nodesSize: nodes.length, newNodesCount: newNodes.size },
|
||||
);
|
||||
|
||||
this.eventBus.emitPristineHistoryUpdated({
|
||||
nodes,
|
||||
newNodes,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.unsubscribeHistory) {
|
||||
this.unsubscribeHistory();
|
||||
this.unsubscribeHistory = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import type { ConcreteNode } from './types.js';
|
||||
|
||||
export interface IrSerializationWriter {
|
||||
appendContent(content: Content): void;
|
||||
appendModelPart(part: Part): void;
|
||||
appendUserPart(part: Part): void;
|
||||
flushModelParts(): void;
|
||||
}
|
||||
|
||||
export interface IrNodeBehavior<T extends ConcreteNode = ConcreteNode> {
|
||||
readonly type: T['type'];
|
||||
|
||||
/** Serializes the node into the Gemini Content structure. */
|
||||
serialize(node: T, writer: IrSerializationWriter): void;
|
||||
|
||||
/**
|
||||
* Generates a structural representation of the node for the purpose
|
||||
* of estimating its token cost.
|
||||
*/
|
||||
getEstimatableParts(node: T): Part[];
|
||||
}
|
||||
|
||||
export class IrNodeBehaviorRegistry {
|
||||
private readonly behaviors = new Map<string, IrNodeBehavior<ConcreteNode>>();
|
||||
|
||||
register<T extends ConcreteNode>(behavior: IrNodeBehavior<T>) {
|
||||
this.behaviors.set(behavior.type, behavior as IrNodeBehavior<ConcreteNode>);
|
||||
}
|
||||
|
||||
get(type: string): IrNodeBehavior<ConcreteNode> {
|
||||
const behavior = this.behaviors.get(type);
|
||||
if (!behavior) {
|
||||
throw new Error(`Unregistered IrNode type: ${type}`);
|
||||
}
|
||||
return behavior;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { Part } from '@google/genai';
|
||||
import type {
|
||||
IrNodeBehavior,
|
||||
IrNodeBehaviorRegistry,
|
||||
} from './behaviorRegistry.js';
|
||||
import type {
|
||||
UserPrompt,
|
||||
AgentThought,
|
||||
ToolExecution,
|
||||
MaskedTool,
|
||||
AgentYield,
|
||||
Snapshot,
|
||||
RollingSummary,
|
||||
SystemEvent,
|
||||
} from './types.js';
|
||||
|
||||
export const UserPromptBehavior: IrNodeBehavior<UserPrompt> = {
|
||||
type: 'USER_PROMPT',
|
||||
getEstimatableParts(prompt) {
|
||||
const parts: Part[] = [];
|
||||
for (const sp of prompt.semanticParts) {
|
||||
switch (sp.type) {
|
||||
case 'text':
|
||||
parts.push({ text: sp.text });
|
||||
break;
|
||||
case 'inline_data':
|
||||
parts.push({ inlineData: { mimeType: sp.mimeType, data: sp.data } });
|
||||
break;
|
||||
case 'file_data':
|
||||
parts.push({
|
||||
fileData: { mimeType: sp.mimeType, fileUri: sp.fileUri },
|
||||
});
|
||||
break;
|
||||
case 'raw_part':
|
||||
parts.push(sp.part);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
},
|
||||
serialize(prompt, writer) {
|
||||
const parts = this.getEstimatableParts(prompt);
|
||||
if (parts.length > 0) {
|
||||
writer.flushModelParts();
|
||||
writer.appendContent({ role: 'user', parts });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const AgentThoughtBehavior: IrNodeBehavior<AgentThought> = {
|
||||
type: 'AGENT_THOUGHT',
|
||||
getEstimatableParts(thought) {
|
||||
return [{ text: thought.text }];
|
||||
},
|
||||
serialize(thought, writer) {
|
||||
writer.appendModelPart({ text: thought.text });
|
||||
},
|
||||
};
|
||||
|
||||
export const ToolExecutionBehavior: IrNodeBehavior<ToolExecution> = {
|
||||
type: 'TOOL_EXECUTION',
|
||||
getEstimatableParts(tool) {
|
||||
return [
|
||||
{ functionCall: { id: tool.id, name: tool.toolName, args: tool.intent } },
|
||||
{
|
||||
functionResponse: {
|
||||
id: tool.id,
|
||||
name: tool.toolName,
|
||||
response:
|
||||
typeof tool.observation === 'string'
|
||||
? { message: tool.observation }
|
||||
: tool.observation,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
serialize(tool, writer) {
|
||||
const parts = this.getEstimatableParts(tool);
|
||||
writer.appendModelPart(parts[0]);
|
||||
writer.flushModelParts();
|
||||
writer.appendUserPart(parts[1]);
|
||||
},
|
||||
};
|
||||
|
||||
export const MaskedToolBehavior: IrNodeBehavior<MaskedTool> = {
|
||||
type: 'MASKED_TOOL',
|
||||
getEstimatableParts(tool) {
|
||||
return [
|
||||
{
|
||||
functionCall: {
|
||||
id: tool.id,
|
||||
name: tool.toolName,
|
||||
args: tool.intent ?? {},
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: tool.id,
|
||||
name: tool.toolName,
|
||||
response:
|
||||
typeof tool.observation === 'string'
|
||||
? { message: tool.observation }
|
||||
: (tool.observation ?? {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
serialize(tool, writer) {
|
||||
const parts = this.getEstimatableParts(tool);
|
||||
writer.appendModelPart(parts[0]);
|
||||
writer.flushModelParts();
|
||||
writer.appendUserPart(parts[1]);
|
||||
},
|
||||
};
|
||||
|
||||
export const AgentYieldBehavior: IrNodeBehavior<AgentYield> = {
|
||||
type: 'AGENT_YIELD',
|
||||
getEstimatableParts(yieldNode) {
|
||||
return [{ text: yieldNode.text }];
|
||||
},
|
||||
serialize(yieldNode, writer) {
|
||||
writer.appendModelPart({ text: yieldNode.text });
|
||||
writer.flushModelParts();
|
||||
},
|
||||
};
|
||||
|
||||
export const SystemEventBehavior: IrNodeBehavior<SystemEvent> = {
|
||||
type: 'SYSTEM_EVENT',
|
||||
getEstimatableParts() {
|
||||
return [];
|
||||
},
|
||||
serialize(node, writer) {
|
||||
writer.flushModelParts();
|
||||
},
|
||||
};
|
||||
|
||||
export const SnapshotBehavior: IrNodeBehavior<Snapshot> = {
|
||||
type: 'SNAPSHOT',
|
||||
getEstimatableParts(node) {
|
||||
return [{ text: node.text }];
|
||||
},
|
||||
serialize(node, writer) {
|
||||
writer.flushModelParts();
|
||||
writer.appendUserPart({ text: node.text });
|
||||
},
|
||||
};
|
||||
|
||||
export const RollingSummaryBehavior: IrNodeBehavior<RollingSummary> = {
|
||||
type: 'ROLLING_SUMMARY',
|
||||
getEstimatableParts(node) {
|
||||
return [{ text: node.text }];
|
||||
},
|
||||
serialize(node, writer) {
|
||||
writer.flushModelParts();
|
||||
writer.appendUserPart({ text: node.text });
|
||||
},
|
||||
};
|
||||
|
||||
export function registerBuiltInBehaviors(registry: IrNodeBehaviorRegistry) {
|
||||
registry.register(UserPromptBehavior);
|
||||
registry.register(AgentThoughtBehavior);
|
||||
registry.register(ToolExecutionBehavior);
|
||||
registry.register(MaskedToolBehavior);
|
||||
registry.register(AgentYieldBehavior);
|
||||
registry.register(SystemEventBehavior);
|
||||
registry.register(SnapshotBehavior);
|
||||
registry.register(RollingSummaryBehavior);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import type { ConcreteNode } from './types.js';
|
||||
import type {
|
||||
IrSerializationWriter,
|
||||
IrNodeBehaviorRegistry,
|
||||
} from './behaviorRegistry.js';
|
||||
|
||||
class IrSerializer implements IrSerializationWriter {
|
||||
private history: Content[] = [];
|
||||
private currentModelParts: Part[] = [];
|
||||
|
||||
appendContent(content: Content) {
|
||||
this.flushModelParts();
|
||||
this.history.push(content);
|
||||
}
|
||||
|
||||
appendModelPart(part: Part) {
|
||||
this.currentModelParts.push(part);
|
||||
}
|
||||
|
||||
appendUserPart(part: Part) {
|
||||
this.flushModelParts();
|
||||
this.history.push({ role: 'user', parts: [part] });
|
||||
}
|
||||
|
||||
flushModelParts() {
|
||||
if (this.currentModelParts.length > 0) {
|
||||
this.history.push({ role: 'model', parts: [...this.currentModelParts] });
|
||||
this.currentModelParts = [];
|
||||
}
|
||||
}
|
||||
|
||||
getContents(): Content[] {
|
||||
this.flushModelParts();
|
||||
return this.history;
|
||||
}
|
||||
}
|
||||
|
||||
export function fromIr(
|
||||
nodes: readonly ConcreteNode[],
|
||||
registry: IrNodeBehaviorRegistry,
|
||||
): Content[] {
|
||||
const writer = new IrSerializer();
|
||||
for (const node of nodes) {
|
||||
const behavior = registry.get(node.type);
|
||||
behavior.serialize(node, writer);
|
||||
}
|
||||
return writer.getContents();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
Episode,
|
||||
Task,
|
||||
IrNode,
|
||||
AgentThought,
|
||||
ToolExecution,
|
||||
MaskedTool,
|
||||
UserPrompt,
|
||||
AgentYield,
|
||||
SystemEvent,
|
||||
Snapshot,
|
||||
RollingSummary,
|
||||
} from './types.js';
|
||||
|
||||
export function isEpisode(node: IrNode): node is Episode {
|
||||
return node.type === 'EPISODE';
|
||||
}
|
||||
|
||||
export function isTask(node: IrNode): node is Task {
|
||||
return node.type === 'TASK';
|
||||
}
|
||||
|
||||
export function isAgentThought(node: IrNode): node is AgentThought {
|
||||
return node.type === 'AGENT_THOUGHT';
|
||||
}
|
||||
|
||||
export function isAgentYield(node: IrNode): node is AgentYield {
|
||||
return node.type === 'AGENT_YIELD';
|
||||
}
|
||||
|
||||
export function isToolExecution(node: IrNode): node is ToolExecution {
|
||||
return node.type === 'TOOL_EXECUTION';
|
||||
}
|
||||
|
||||
export function isMaskedTool(node: IrNode): node is MaskedTool {
|
||||
return node.type === 'MASKED_TOOL';
|
||||
}
|
||||
|
||||
export function isUserPrompt(node: IrNode): node is UserPrompt {
|
||||
return node.type === 'USER_PROMPT';
|
||||
}
|
||||
|
||||
export function isSystemEvent(node: IrNode): node is SystemEvent {
|
||||
return node.type === 'SYSTEM_EVENT';
|
||||
}
|
||||
|
||||
export function isSnapshot(node: IrNode): node is Snapshot {
|
||||
return node.type === 'SNAPSHOT';
|
||||
}
|
||||
|
||||
export function isRollingSummary(node: IrNode): node is RollingSummary {
|
||||
return node.type === 'ROLLING_SUMMARY';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { Content } from '@google/genai';
|
||||
import type { Episode, ConcreteNode } from './types.js';
|
||||
import { toIr } from './toIr.js';
|
||||
import { fromIr } from './fromIr.js';
|
||||
import type { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
|
||||
import type { IrNodeBehaviorRegistry } from './behaviorRegistry.js';
|
||||
|
||||
export class IrMapper {
|
||||
private readonly nodeIdentityMap = new WeakMap<object, string>();
|
||||
|
||||
constructor(private readonly registry: IrNodeBehaviorRegistry) {}
|
||||
|
||||
toIr(
|
||||
history: readonly Content[],
|
||||
tokenCalculator: ContextTokenCalculator,
|
||||
): Episode[] {
|
||||
return toIr(history, tokenCalculator, this.nodeIdentityMap);
|
||||
}
|
||||
|
||||
fromIr(nodes: readonly ConcreteNode[]): Content[] {
|
||||
return fromIr(nodes, this.registry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
import type { ConcreteNode } from './types.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import type {
|
||||
ContextEnvironment,
|
||||
ContextTracer,
|
||||
} from '../sidecar/environment.js';
|
||||
import type { PipelineOrchestrator } from '../sidecar/orchestrator.js';
|
||||
import type { SidecarConfig } from '../sidecar/types.js';
|
||||
|
||||
export class IrProjector {
|
||||
/**
|
||||
* Orchestrates the final projection: takes a working buffer view (The Nodes),
|
||||
* applies the Immediate Sanitization pipeline, and enforces token boundaries.
|
||||
*/
|
||||
static async project(
|
||||
nodes: readonly ConcreteNode[],
|
||||
orchestrator: PipelineOrchestrator,
|
||||
sidecar: SidecarConfig,
|
||||
tracer: ContextTracer,
|
||||
env: ContextEnvironment,
|
||||
protectedIds: Set<string>,
|
||||
): Promise<Content[]> {
|
||||
if (!sidecar.budget) {
|
||||
const contents = env.irMapper.fromIr(nodes);
|
||||
tracer.logEvent('IrProjector', 'Projected Context to LLM (No Budget)', {
|
||||
projectedContext: contents,
|
||||
});
|
||||
return contents;
|
||||
}
|
||||
|
||||
const maxTokens = sidecar.budget.maxTokens;
|
||||
const currentTokens =
|
||||
env.tokenCalculator.calculateConcreteListTokens(nodes);
|
||||
|
||||
// V0: Always protect the first node (System Prompt) and the last turn
|
||||
if (nodes.length > 0) {
|
||||
protectedIds.add(nodes[0].id);
|
||||
if (nodes[0].logicalParentId) protectedIds.add(nodes[0].logicalParentId);
|
||||
|
||||
const lastNode = nodes[nodes.length - 1];
|
||||
protectedIds.add(lastNode.id);
|
||||
if (lastNode.logicalParentId) protectedIds.add(lastNode.logicalParentId);
|
||||
}
|
||||
|
||||
if (currentTokens <= maxTokens) {
|
||||
tracer.logEvent(
|
||||
'IrProjector',
|
||||
`View is within maxTokens (${currentTokens} <= ${maxTokens}). Returning view.`,
|
||||
);
|
||||
const contents = env.irMapper.fromIr(nodes);
|
||||
tracer.logEvent('IrProjector', 'Projected Context to LLM', {
|
||||
projectedContext: contents,
|
||||
});
|
||||
return contents;
|
||||
}
|
||||
|
||||
tracer.logEvent(
|
||||
'IrProjector',
|
||||
`View exceeds maxTokens (${currentTokens} > ${maxTokens}). Hitting Synchronous Pressure Barrier.`,
|
||||
);
|
||||
debugLogger.log(
|
||||
`Context Manager Synchronous Barrier triggered: View at ${currentTokens} tokens (limit: ${maxTokens}).`,
|
||||
);
|
||||
|
||||
// Calculate exactly which nodes aged out of the retainedTokens budget to form our target delta
|
||||
const agedOutNodes = new Set<string>();
|
||||
let rollingTokens = 0;
|
||||
// Start from newest and count backwards
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
const node = nodes[i];
|
||||
const nodeTokens = env.tokenCalculator.calculateConcreteListTokens([
|
||||
node,
|
||||
]);
|
||||
rollingTokens += nodeTokens;
|
||||
if (rollingTokens > sidecar.budget.retainedTokens) {
|
||||
agedOutNodes.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
const processedNodes = await orchestrator.executeTriggerSync(
|
||||
'gc_backstop',
|
||||
nodes,
|
||||
agedOutNodes,
|
||||
protectedIds,
|
||||
);
|
||||
|
||||
const finalTokens =
|
||||
env.tokenCalculator.calculateConcreteListTokens(processedNodes);
|
||||
tracer.logEvent(
|
||||
'IrProjector',
|
||||
`Finished projection. Final token count: ${finalTokens}.`,
|
||||
);
|
||||
debugLogger.log(
|
||||
`Context Manager finished. Final actual token count: ${finalTokens}.`,
|
||||
);
|
||||
|
||||
// Apply skipList logic to abstract over summarized nodes
|
||||
const skipList = new Set<string>();
|
||||
for (const node of processedNodes) {
|
||||
if (node.abstractsIds) {
|
||||
for (const id of node.abstractsIds) skipList.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const visibleNodes = processedNodes.filter((n) => !skipList.has(n.id));
|
||||
|
||||
const contents = env.irMapper.fromIr(visibleNodes);
|
||||
tracer.logEvent('IrProjector', 'Projected Sanitized Context to LLM', {
|
||||
projectedContextSanitized: contents,
|
||||
});
|
||||
return contents;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
Episode,
|
||||
SemanticPart,
|
||||
ToolExecution,
|
||||
AgentThought,
|
||||
AgentYield,
|
||||
UserPrompt,
|
||||
} from './types.js';
|
||||
import type { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
|
||||
|
||||
// We remove the global nodeIdentityMap and instead rely on one passed from IrMapper
|
||||
export function getStableId(
|
||||
obj: object,
|
||||
nodeIdentityMap: WeakMap<object, string>,
|
||||
): string {
|
||||
let id = nodeIdentityMap.get(obj);
|
||||
if (!id) {
|
||||
id = randomUUID();
|
||||
nodeIdentityMap.set(obj, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function isCompleteEpisode(ep: Partial<Episode>): ep is Episode {
|
||||
return (
|
||||
typeof ep.id === 'string' &&
|
||||
typeof ep.timestamp === 'number' &&
|
||||
Array.isArray(ep.concreteNodes) &&
|
||||
ep.concreteNodes.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function toIr(
|
||||
history: readonly Content[],
|
||||
tokenCalculator: ContextTokenCalculator,
|
||||
nodeIdentityMap: WeakMap<object, string>,
|
||||
): Episode[] {
|
||||
const episodes: Episode[] = [];
|
||||
let currentEpisode: Partial<Episode> | null = null;
|
||||
const pendingCallParts: Map<string, Part> = new Map();
|
||||
|
||||
const finalizeEpisode = () => {
|
||||
if (currentEpisode && isCompleteEpisode(currentEpisode)) {
|
||||
episodes.push(currentEpisode);
|
||||
}
|
||||
currentEpisode = null;
|
||||
};
|
||||
|
||||
for (const msg of history) {
|
||||
if (!msg.parts) continue;
|
||||
|
||||
if (msg.role === 'user') {
|
||||
const hasToolResponses = msg.parts.some((p) => !!p.functionResponse);
|
||||
const hasUserParts = msg.parts.some(
|
||||
(p) => !!p.text || !!p.inlineData || !!p.fileData,
|
||||
);
|
||||
|
||||
if (hasToolResponses) {
|
||||
currentEpisode = parseToolResponses(
|
||||
msg,
|
||||
currentEpisode,
|
||||
pendingCallParts,
|
||||
tokenCalculator,
|
||||
nodeIdentityMap,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasUserParts) {
|
||||
finalizeEpisode();
|
||||
currentEpisode = parseUserParts(msg, nodeIdentityMap);
|
||||
}
|
||||
} else if (msg.role === 'model') {
|
||||
currentEpisode = parseModelParts(
|
||||
msg,
|
||||
currentEpisode,
|
||||
pendingCallParts,
|
||||
nodeIdentityMap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEpisode) {
|
||||
finalizeYield(currentEpisode);
|
||||
finalizeEpisode();
|
||||
}
|
||||
|
||||
return episodes;
|
||||
}
|
||||
|
||||
function parseToolResponses(
|
||||
msg: Content,
|
||||
currentEpisode: Partial<Episode> | null,
|
||||
pendingCallParts: Map<string, Part>,
|
||||
tokenCalculator: ContextTokenCalculator,
|
||||
nodeIdentityMap: WeakMap<object, string>,
|
||||
): Partial<Episode> {
|
||||
if (!currentEpisode) {
|
||||
currentEpisode = {
|
||||
id: getStableId(msg, nodeIdentityMap),
|
||||
timestamp: Date.now(),
|
||||
concreteNodes: [],
|
||||
};
|
||||
}
|
||||
|
||||
const parts = msg.parts || [];
|
||||
for (const part of parts) {
|
||||
if (part.functionResponse) {
|
||||
const callId = part.functionResponse.id || '';
|
||||
const matchingCall = pendingCallParts.get(callId);
|
||||
|
||||
const intentTokens = matchingCall
|
||||
? tokenCalculator.estimateTokensForParts([matchingCall])
|
||||
: 0;
|
||||
const obsTokens = tokenCalculator.estimateTokensForParts([part]);
|
||||
|
||||
const step: ToolExecution = {
|
||||
id: getStableId(part, nodeIdentityMap),
|
||||
type: 'TOOL_EXECUTION',
|
||||
toolName: part.functionResponse.name || 'unknown',
|
||||
intent: isRecord(matchingCall?.functionCall?.args)
|
||||
? matchingCall.functionCall.args
|
||||
: {},
|
||||
observation: isRecord(part.functionResponse.response)
|
||||
? part.functionResponse.response
|
||||
: {},
|
||||
tokens: {
|
||||
intent: intentTokens,
|
||||
observation: obsTokens,
|
||||
},
|
||||
};
|
||||
currentEpisode.concreteNodes = [
|
||||
...(currentEpisode.concreteNodes || []),
|
||||
step,
|
||||
];
|
||||
if (callId) pendingCallParts.delete(callId);
|
||||
}
|
||||
}
|
||||
return currentEpisode;
|
||||
}
|
||||
|
||||
function parseUserParts(
|
||||
msg: Content,
|
||||
nodeIdentityMap: WeakMap<object, string>,
|
||||
): Partial<Episode> {
|
||||
const semanticParts: SemanticPart[] = [];
|
||||
const parts = msg.parts || [];
|
||||
for (const p of parts) {
|
||||
if (p.text !== undefined)
|
||||
semanticParts.push({ type: 'text', text: p.text });
|
||||
else if (p.inlineData)
|
||||
semanticParts.push({
|
||||
type: 'inline_data',
|
||||
mimeType: p.inlineData.mimeType || '',
|
||||
data: p.inlineData.data || '',
|
||||
});
|
||||
else if (p.fileData)
|
||||
semanticParts.push({
|
||||
type: 'file_data',
|
||||
mimeType: p.fileData.mimeType || '',
|
||||
fileUri: p.fileData.fileUri || '',
|
||||
});
|
||||
else if (!p.functionResponse)
|
||||
semanticParts.push({ type: 'raw_part', part: p }); // Preserve unknowns
|
||||
}
|
||||
|
||||
const baseObj = parts.length > 0 ? parts[0] : msg;
|
||||
const trigger: UserPrompt = {
|
||||
id: getStableId(baseObj, nodeIdentityMap),
|
||||
type: 'USER_PROMPT',
|
||||
semanticParts,
|
||||
};
|
||||
return {
|
||||
id: getStableId(msg, nodeIdentityMap),
|
||||
timestamp: Date.now(),
|
||||
concreteNodes: [trigger],
|
||||
};
|
||||
}
|
||||
|
||||
function parseModelParts(
|
||||
msg: Content,
|
||||
currentEpisode: Partial<Episode> | null,
|
||||
pendingCallParts: Map<string, Part>,
|
||||
nodeIdentityMap: WeakMap<object, string>,
|
||||
): Partial<Episode> {
|
||||
if (!currentEpisode) {
|
||||
currentEpisode = {
|
||||
id: getStableId(msg, nodeIdentityMap),
|
||||
timestamp: Date.now(),
|
||||
concreteNodes: [],
|
||||
};
|
||||
}
|
||||
|
||||
const parts = msg.parts || [];
|
||||
for (const part of parts) {
|
||||
if (part.functionCall) {
|
||||
const callId = part.functionCall.id || '';
|
||||
if (callId) pendingCallParts.set(callId, part);
|
||||
} else if (part.text) {
|
||||
const thought: AgentThought = {
|
||||
id: getStableId(part, nodeIdentityMap),
|
||||
type: 'AGENT_THOUGHT',
|
||||
text: part.text,
|
||||
};
|
||||
|
||||
currentEpisode.concreteNodes = [
|
||||
...(currentEpisode.concreteNodes || []),
|
||||
thought,
|
||||
];
|
||||
}
|
||||
}
|
||||
return currentEpisode;
|
||||
}
|
||||
|
||||
function finalizeYield(currentEpisode: Partial<Episode>) {
|
||||
if (currentEpisode.concreteNodes && currentEpisode.concreteNodes.length > 0) {
|
||||
const yieldNode: AgentYield = {
|
||||
id: randomUUID(),
|
||||
type: 'AGENT_YIELD',
|
||||
text: 'Yield', // Synthesized yield since we don't have the original concrete node
|
||||
};
|
||||
const existingNodes = currentEpisode.concreteNodes || [];
|
||||
currentEpisode.concreteNodes = [...existingNodes, yieldNode];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
export type IrNodeType =
|
||||
// Organic Concrete Nodes
|
||||
| 'USER_PROMPT'
|
||||
| 'SYSTEM_EVENT'
|
||||
| 'AGENT_THOUGHT'
|
||||
| 'TOOL_EXECUTION'
|
||||
| 'AGENT_YIELD'
|
||||
|
||||
// Synthetic Concrete Nodes
|
||||
| 'SNAPSHOT'
|
||||
| 'ROLLING_SUMMARY'
|
||||
| 'MASKED_TOOL'
|
||||
|
||||
// Logical Nodes
|
||||
| 'TASK'
|
||||
| 'EPISODE';
|
||||
|
||||
/** Base interface for all nodes in the Episodic IR */
|
||||
export interface IrNode {
|
||||
readonly id: string;
|
||||
readonly type: IrNodeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete Nodes: The atomic, renderable pieces of data.
|
||||
* These are the actual "planks" of the Nodes of Theseus.
|
||||
*/
|
||||
export interface BaseConcreteNode extends IrNode {
|
||||
/** The ID of the Logical Node (e.g., Episode) that structurally owns this node */
|
||||
readonly logicalParentId?: string;
|
||||
|
||||
/** If this node replaced a single node 1:1 (e.g., masking), this points to the original */
|
||||
readonly replacesId?: string;
|
||||
|
||||
/** If this node is a synthetic summary of N nodes, this points to the original IDs */
|
||||
readonly abstractsIds?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic Parts for User Prompts
|
||||
*/
|
||||
export type SemanticPart =
|
||||
| {
|
||||
readonly type: 'text';
|
||||
readonly text: string;
|
||||
}
|
||||
| {
|
||||
readonly type: 'inline_data';
|
||||
readonly mimeType: string;
|
||||
readonly data: string;
|
||||
}
|
||||
| {
|
||||
readonly type: 'file_data';
|
||||
readonly mimeType: string;
|
||||
readonly fileUri: string;
|
||||
}
|
||||
| {
|
||||
readonly type: 'raw_part';
|
||||
readonly part: Part;
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger Nodes
|
||||
* Events that wake the agent up and initiate an Episode.
|
||||
*/
|
||||
export interface UserPrompt extends BaseConcreteNode {
|
||||
readonly type: 'USER_PROMPT';
|
||||
readonly semanticParts: readonly SemanticPart[];
|
||||
}
|
||||
|
||||
export interface SystemEvent extends BaseConcreteNode {
|
||||
readonly type: 'SYSTEM_EVENT';
|
||||
readonly name: string;
|
||||
readonly payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type EpisodeTrigger = UserPrompt | SystemEvent;
|
||||
|
||||
/**
|
||||
* Step Nodes
|
||||
* The internal autonomous actions taken by the agent during its loop.
|
||||
*/
|
||||
export interface AgentThought extends BaseConcreteNode {
|
||||
readonly type: 'AGENT_THOUGHT';
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface ToolExecution extends BaseConcreteNode {
|
||||
readonly type: 'TOOL_EXECUTION';
|
||||
readonly toolName: string;
|
||||
readonly intent: Record<string, unknown>;
|
||||
readonly observation: string | Record<string, unknown>;
|
||||
readonly tokens: {
|
||||
readonly intent: number;
|
||||
readonly observation: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MaskedTool extends BaseConcreteNode {
|
||||
readonly type: 'MASKED_TOOL';
|
||||
readonly toolName: string;
|
||||
readonly intent?: Record<string, unknown>;
|
||||
readonly observation?: string | Record<string, unknown>;
|
||||
readonly tokens: {
|
||||
readonly intent: number;
|
||||
readonly observation: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type EpisodeStep = AgentThought | ToolExecution | MaskedTool;
|
||||
|
||||
/**
|
||||
* Resolution Node
|
||||
* The final message where the agent yields control back to the user.
|
||||
*/
|
||||
export interface AgentYield extends BaseConcreteNode {
|
||||
readonly type: 'AGENT_YIELD';
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthetic Leaf Interfaces
|
||||
* Processors that generate summaries emit explicit synthetic nodes.
|
||||
*/
|
||||
export interface Snapshot extends BaseConcreteNode {
|
||||
readonly type: 'SNAPSHOT';
|
||||
readonly timestamp: number;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface RollingSummary extends BaseConcreteNode {
|
||||
readonly type: 'ROLLING_SUMMARY';
|
||||
readonly timestamp: number;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export type SyntheticLeaf = Snapshot | RollingSummary;
|
||||
|
||||
export type ConcreteNode =
|
||||
| UserPrompt
|
||||
| SystemEvent
|
||||
| AgentThought
|
||||
| ToolExecution
|
||||
| MaskedTool
|
||||
| AgentYield
|
||||
| Snapshot
|
||||
| RollingSummary;
|
||||
|
||||
/**
|
||||
* Logical Nodes
|
||||
* These define hierarchy and grouping. They do not directly render to Gemini.
|
||||
*/
|
||||
export interface Episode extends IrNode {
|
||||
readonly type: 'EPISODE';
|
||||
readonly timestamp: number;
|
||||
/** References to the Concrete Node IDs that conceptually belong to this Episode. */
|
||||
concreteNodes: readonly ConcreteNode[];
|
||||
}
|
||||
|
||||
export interface Task extends IrNode {
|
||||
readonly type: 'TASK';
|
||||
readonly timestamp: number;
|
||||
readonly goal: string;
|
||||
readonly status: 'active' | 'completed' | 'failed';
|
||||
/** References to the Episode IDs that belong to this task */
|
||||
readonly episodeIds: readonly string[];
|
||||
}
|
||||
|
||||
export type LogicalNode = Task | Episode;
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ConcreteNode } from './ir/types.js';
|
||||
|
||||
export interface InboxMessage<T = unknown> {
|
||||
id: string;
|
||||
topic: string;
|
||||
payload: T;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface InboxSnapshot {
|
||||
getMessages<T = unknown>(topic: string): ReadonlyArray<InboxMessage<T>>;
|
||||
consume(messageId: string): void;
|
||||
}
|
||||
|
||||
export interface GraphMutation {
|
||||
readonly processorId: string;
|
||||
readonly timestamp: number;
|
||||
readonly removedIds: readonly string[];
|
||||
readonly addedNodes: readonly ConcreteNode[];
|
||||
}
|
||||
|
||||
export interface ContextWorkingBuffer {
|
||||
readonly nodes: readonly ConcreteNode[];
|
||||
getPristineNodes(id: string): readonly ConcreteNode[];
|
||||
getLineage(id: string): readonly ConcreteNode[];
|
||||
getAuditLog(): readonly GraphMutation[];
|
||||
}
|
||||
|
||||
export interface ProcessArgs {
|
||||
readonly buffer: ContextWorkingBuffer;
|
||||
readonly targets: readonly ConcreteNode[];
|
||||
readonly inbox: InboxSnapshot;
|
||||
}
|
||||
|
||||
export interface ContextProcessor {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
process(args: ProcessArgs): Promise<readonly ConcreteNode[]>;
|
||||
}
|
||||
|
||||
export interface ContextWorker {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly triggers: {
|
||||
onNodesAdded?: boolean;
|
||||
onNodesAgedOut?: boolean;
|
||||
onInboxTopics?: string[];
|
||||
};
|
||||
execute(args: {
|
||||
targets: readonly ConcreteNode[];
|
||||
inbox: InboxSnapshot;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface BackstopTargetOptions {
|
||||
target?: 'incremental' | 'freeNTokens' | 'max';
|
||||
freeTokensTarget?: number;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BlobDegradationProcessor } from './blobDegradationProcessor.js';
|
||||
import {
|
||||
createMockProcessArgs,
|
||||
createMockEnvironment,
|
||||
createDummyNode,
|
||||
} from '../testing/contextTestUtils.js';
|
||||
import type { UserPrompt, SemanticPart, ConcreteNode } from '../ir/types.js';
|
||||
|
||||
describe('BlobDegradationProcessor', () => {
|
||||
it('should ignore text parts and only target inline_data and file_data', async () => {
|
||||
const env = createMockEnvironment();
|
||||
// charsPerToken = 1
|
||||
// We want the degraded text to be cheaper than the original blob.
|
||||
// Degraded text is ~100 chars ("...degraded to text...").
|
||||
// So we make the blob data 200 chars.
|
||||
const fakeData = 'A'.repeat(200);
|
||||
|
||||
const processor = BlobDegradationProcessor.create(env, {});
|
||||
|
||||
const parts: SemanticPart[] = [
|
||||
{ type: 'text', text: 'Hello' },
|
||||
{ type: 'inline_data', mimeType: 'image/png', data: fakeData },
|
||||
{ type: 'text', text: 'World' },
|
||||
];
|
||||
|
||||
const prompt = createDummyNode('ep1', 'USER_PROMPT', 100, {
|
||||
semanticParts: parts,
|
||||
}) as UserPrompt;
|
||||
|
||||
const targets = [prompt];
|
||||
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
const modifiedPrompt = result[0] as UserPrompt;
|
||||
|
||||
expect(modifiedPrompt.id).not.toBe(prompt.id);
|
||||
expect(modifiedPrompt.semanticParts.length).toBe(3);
|
||||
|
||||
// Text parts should be untouched
|
||||
expect(modifiedPrompt.semanticParts[0]).toEqual(parts[0]);
|
||||
expect(modifiedPrompt.semanticParts[2]).toEqual(parts[2]);
|
||||
|
||||
// The inline_data part should be replaced with text
|
||||
const degradedPart = modifiedPrompt.semanticParts[1];
|
||||
expect(degradedPart.type).toBe('text');
|
||||
assert(degradedPart.type === 'text');
|
||||
expect(degradedPart.text).toContain(
|
||||
'[Multi-Modal Blob (image/png, 0.00MB) degraded to text',
|
||||
);
|
||||
});
|
||||
|
||||
it('should degrade all blobs unconditionally', async () => {
|
||||
const env = createMockEnvironment();
|
||||
|
||||
const processor = BlobDegradationProcessor.create(env, {});
|
||||
|
||||
// Tokens for fileData = 258.
|
||||
// Degraded text = "[File Reference (video/mp4) degraded to text to preserve context window. Original URI: gs://test1]"
|
||||
// Degraded text length ~100 characters.
|
||||
// Since charsPerToken=1, degraded text = 100 tokens.
|
||||
// Tokens saved = 258 - 100 = 158. This is > 0, so it WILL degrade it!
|
||||
|
||||
const prompt = createDummyNode('ep1', 'USER_PROMPT', 100, {
|
||||
semanticParts: [
|
||||
{ type: 'file_data', mimeType: 'video/mp4', fileUri: 'gs://test1' },
|
||||
{ type: 'file_data', mimeType: 'video/mp4', fileUri: 'gs://test2' },
|
||||
],
|
||||
}) as UserPrompt;
|
||||
|
||||
const targets = [prompt];
|
||||
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
const modifiedPrompt = result[0] as UserPrompt;
|
||||
expect(modifiedPrompt.semanticParts.length).toBe(2);
|
||||
|
||||
// Both parts should be degraded
|
||||
expect(modifiedPrompt.semanticParts[0].type).toBe('text');
|
||||
expect(modifiedPrompt.semanticParts[1].type).toBe('text');
|
||||
});
|
||||
|
||||
it('should return exactly the targets array if targets are empty', async () => {
|
||||
const env = createMockEnvironment();
|
||||
|
||||
const processor = BlobDegradationProcessor.create(env, {});
|
||||
const targets: ConcreteNode[] = [];
|
||||
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
expect(result).toBe(targets);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ProcessArgs, ContextProcessor } from '../pipeline.js';
|
||||
import type { ConcreteNode, UserPrompt } from '../ir/types.js';
|
||||
import type { ContextEnvironment } from '../sidecar/environment.js';
|
||||
import { sanitizeFilenamePart } from '../../utils/fileUtils.js';
|
||||
|
||||
export type BlobDegradationProcessorOptions = Record<string, never>;
|
||||
|
||||
export class BlobDegradationProcessor implements ContextProcessor {
|
||||
static create(
|
||||
env: ContextEnvironment,
|
||||
_options: BlobDegradationProcessorOptions,
|
||||
): BlobDegradationProcessor {
|
||||
return new BlobDegradationProcessor(env);
|
||||
}
|
||||
|
||||
readonly componentType = 'processor';
|
||||
readonly id = 'BlobDegradationProcessor';
|
||||
readonly name = 'BlobDegradationProcessor';
|
||||
readonly options = {};
|
||||
private env: ContextEnvironment;
|
||||
|
||||
constructor(env: ContextEnvironment) {
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
async process({ targets }: ProcessArgs): Promise<readonly ConcreteNode[]> {
|
||||
if (targets.length === 0) {
|
||||
return targets;
|
||||
}
|
||||
|
||||
let directoryCreated = false;
|
||||
|
||||
let blobOutputsDir = this.env.fileSystem.join(
|
||||
this.env.projectTempDir,
|
||||
'degraded-blobs',
|
||||
);
|
||||
const sessionId = this.env.sessionId;
|
||||
if (sessionId) {
|
||||
blobOutputsDir = this.env.fileSystem.join(
|
||||
blobOutputsDir,
|
||||
`session-${sanitizeFilenamePart(sessionId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const ensureDir = async () => {
|
||||
if (!directoryCreated) {
|
||||
await this.env.fileSystem.mkdir(blobOutputsDir, { recursive: true });
|
||||
directoryCreated = true;
|
||||
}
|
||||
};
|
||||
|
||||
const returnedNodes: ConcreteNode[] = [];
|
||||
|
||||
// Forward scan, looking for bloated non-text parts to degrade
|
||||
for (const node of targets) {
|
||||
switch (node.type) {
|
||||
case 'USER_PROMPT': {
|
||||
let modified = false;
|
||||
const newParts = [...node.semanticParts];
|
||||
|
||||
for (let j = 0; j < node.semanticParts.length; j++) {
|
||||
const part = node.semanticParts[j];
|
||||
if (part.type === 'text') continue;
|
||||
|
||||
let newText = '';
|
||||
let tokensSaved = 0;
|
||||
|
||||
switch (part.type) {
|
||||
case 'inline_data': {
|
||||
await ensureDir();
|
||||
const ext = part.mimeType.split('/')[1] || 'bin';
|
||||
const fileName = `blob_${Date.now()}_${this.env.idGenerator.generateId()}.${ext}`;
|
||||
const filePath = this.env.fileSystem.join(
|
||||
blobOutputsDir,
|
||||
fileName,
|
||||
);
|
||||
|
||||
const buffer = Buffer.from(part.data, 'base64');
|
||||
await this.env.fileSystem.writeFile(filePath, buffer);
|
||||
|
||||
const mb = (buffer.byteLength / 1024 / 1024).toFixed(2);
|
||||
newText = `[Multi-Modal Blob (${part.mimeType}, ${mb}MB) degraded to text to preserve context window. Saved to: ${filePath}]`;
|
||||
|
||||
const oldTokens =
|
||||
this.env.tokenCalculator.estimateTokensForParts([
|
||||
{
|
||||
inlineData: { mimeType: part.mimeType, data: part.data },
|
||||
},
|
||||
]);
|
||||
const newTokens =
|
||||
this.env.tokenCalculator.estimateTokensForParts([
|
||||
{ text: newText },
|
||||
]);
|
||||
tokensSaved = oldTokens - newTokens;
|
||||
break;
|
||||
}
|
||||
case 'file_data': {
|
||||
newText = `[File Reference (${part.mimeType}) degraded to text to preserve context window. Original URI: ${part.fileUri}]`;
|
||||
const oldTokens =
|
||||
this.env.tokenCalculator.estimateTokensForParts([
|
||||
{
|
||||
fileData: {
|
||||
mimeType: part.mimeType,
|
||||
fileUri: part.fileUri,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const newTokens =
|
||||
this.env.tokenCalculator.estimateTokensForParts([
|
||||
{ text: newText },
|
||||
]);
|
||||
tokensSaved = oldTokens - newTokens;
|
||||
break;
|
||||
}
|
||||
case 'raw_part': {
|
||||
newText = `[Unknown Part degraded to text to preserve context window.]`;
|
||||
const oldTokens =
|
||||
this.env.tokenCalculator.estimateTokensForParts([part.part]);
|
||||
const newTokens =
|
||||
this.env.tokenCalculator.estimateTokensForParts([
|
||||
{ text: newText },
|
||||
]);
|
||||
tokensSaved = oldTokens - newTokens;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (newText && tokensSaved > 0) {
|
||||
newParts[j] = { type: 'text', text: newText };
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
const degradedNode: UserPrompt = {
|
||||
...node,
|
||||
id: this.env.idGenerator.generateId(),
|
||||
semanticParts: newParts,
|
||||
};
|
||||
returnedNodes.push(degradedNode);
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
ContextProcessor,
|
||||
BackstopTargetOptions,
|
||||
ProcessArgs,
|
||||
} from '../pipeline.js';
|
||||
import type { ConcreteNode } from '../ir/types.js';
|
||||
import type { ContextEnvironment } from '../sidecar/environment.js';
|
||||
|
||||
export type HistoryTruncationProcessorOptions = BackstopTargetOptions;
|
||||
|
||||
export class HistoryTruncationProcessor implements ContextProcessor {
|
||||
static create(
|
||||
env: ContextEnvironment,
|
||||
options: HistoryTruncationProcessorOptions,
|
||||
): HistoryTruncationProcessor {
|
||||
return new HistoryTruncationProcessor(env, options);
|
||||
}
|
||||
|
||||
static readonly schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
target: {
|
||||
type: 'string',
|
||||
enum: ['incremental', 'freeNTokens', 'max'],
|
||||
description: 'How much of the targeted history to truncate.',
|
||||
},
|
||||
freeTokensTarget: {
|
||||
type: 'number',
|
||||
description: 'The number of tokens to free if target is freeNTokens.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
readonly componentType = 'processor';
|
||||
readonly id = 'HistoryTruncationProcessor';
|
||||
readonly name = 'HistoryTruncationProcessor';
|
||||
private readonly env: ContextEnvironment;
|
||||
readonly options: HistoryTruncationProcessorOptions;
|
||||
constructor(
|
||||
env: ContextEnvironment,
|
||||
options: HistoryTruncationProcessorOptions,
|
||||
) {
|
||||
this.env = env;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async process({ targets }: ProcessArgs): Promise<readonly ConcreteNode[]> {
|
||||
// Calculate how many tokens we need to remove based on the configured knob
|
||||
let targetTokensToRemove = 0;
|
||||
const strategy = this.options.target ?? 'max';
|
||||
|
||||
if (strategy === 'incremental') {
|
||||
targetTokensToRemove = Infinity;
|
||||
} else if (strategy === 'freeNTokens') {
|
||||
targetTokensToRemove = this.options.freeTokensTarget ?? 0;
|
||||
if (targetTokensToRemove <= 0) return targets;
|
||||
} else if (strategy === 'max') {
|
||||
// 'max' means we remove all targets without stopping early
|
||||
targetTokensToRemove = Infinity;
|
||||
}
|
||||
|
||||
let removedTokens = 0;
|
||||
const keptNodes: ConcreteNode[] = [];
|
||||
|
||||
// The targets are sequentially ordered from oldest to newest.
|
||||
// We want to delete the oldest targets first.
|
||||
for (const node of targets) {
|
||||
if (removedTokens >= targetTokensToRemove) {
|
||||
keptNodes.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
removedTokens += this.env.tokenCalculator.getTokenCost(node);
|
||||
}
|
||||
|
||||
return keptNodes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { NodeDistillationProcessor } from './nodeDistillationProcessor.js';
|
||||
import {
|
||||
createMockProcessArgs,
|
||||
createMockEnvironment,
|
||||
createDummyNode,
|
||||
createDummyToolNode,
|
||||
createMockLlmClient,
|
||||
} from '../testing/contextTestUtils.js';
|
||||
import type { UserPrompt, AgentThought, ToolExecution } from '../ir/types.js';
|
||||
|
||||
describe('NodeDistillationProcessor', () => {
|
||||
it('should trigger summarization via LLM for long text parts', async () => {
|
||||
const mockLlmClient = createMockLlmClient(['Mocked Summary!']);
|
||||
|
||||
// Use charsPerToken=1 naturally.
|
||||
const env = createMockEnvironment({
|
||||
llmClient: mockLlmClient,
|
||||
});
|
||||
|
||||
const processor = NodeDistillationProcessor.create(env, {
|
||||
nodeThresholdTokens: 10,
|
||||
});
|
||||
|
||||
const longText = 'A'.repeat(50); // 50 chars
|
||||
|
||||
const prompt = createDummyNode(
|
||||
'ep1',
|
||||
'USER_PROMPT',
|
||||
50,
|
||||
{
|
||||
semanticParts: [{ type: 'text', text: longText }],
|
||||
},
|
||||
'prompt-id',
|
||||
) as UserPrompt;
|
||||
|
||||
const thought = createDummyNode(
|
||||
'ep1',
|
||||
'AGENT_THOUGHT',
|
||||
50,
|
||||
{
|
||||
text: longText,
|
||||
},
|
||||
'thought-id',
|
||||
) as AgentThought;
|
||||
|
||||
const tool = createDummyToolNode(
|
||||
'ep1',
|
||||
5,
|
||||
500,
|
||||
{
|
||||
observation: { result: 'A'.repeat(500) },
|
||||
},
|
||||
'tool-id',
|
||||
);
|
||||
|
||||
const targets = [prompt, thought, tool];
|
||||
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
|
||||
// 1. User Prompt
|
||||
const compressedPrompt = result[0] as UserPrompt;
|
||||
expect(compressedPrompt.id).not.toBe(prompt.id);
|
||||
expect(compressedPrompt.semanticParts[0].type).toBe('text');
|
||||
assert(compressedPrompt.semanticParts[0].type === 'text');
|
||||
expect(compressedPrompt.semanticParts[0].text).toBe('Mocked Summary!');
|
||||
|
||||
// 2. Agent Thought
|
||||
const compressedThought = result[1] as AgentThought;
|
||||
expect(compressedThought.id).not.toBe(thought.id);
|
||||
expect(compressedThought.text).toBe('Mocked Summary!');
|
||||
|
||||
// 3. Tool Execution
|
||||
const compressedTool = result[2] as ToolExecution;
|
||||
expect(compressedTool.id).not.toBe(tool.id);
|
||||
expect(compressedTool.observation).toEqual({ summary: 'Mocked Summary!' });
|
||||
|
||||
expect(mockLlmClient.generateContent).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should ignore nodes that are below the threshold', async () => {
|
||||
const mockLlmClient = createMockLlmClient(['S']); // length = 1
|
||||
|
||||
const env = createMockEnvironment({
|
||||
llmClient: mockLlmClient,
|
||||
});
|
||||
|
||||
const processor = NodeDistillationProcessor.create(env, {
|
||||
nodeThresholdTokens: 100, // Very high threshold
|
||||
});
|
||||
|
||||
const shortText = 'Short text'; // 10 chars
|
||||
|
||||
const prompt = createDummyNode(
|
||||
'ep1',
|
||||
'USER_PROMPT',
|
||||
10,
|
||||
{
|
||||
semanticParts: [{ type: 'text', text: shortText }],
|
||||
},
|
||||
'prompt-id',
|
||||
) as UserPrompt;
|
||||
|
||||
const thought = createDummyNode(
|
||||
'ep1',
|
||||
'AGENT_THOUGHT',
|
||||
13,
|
||||
{
|
||||
text: 'Short thought',
|
||||
},
|
||||
'thought-id',
|
||||
) as AgentThought;
|
||||
|
||||
const targets = [prompt, thought];
|
||||
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
|
||||
// 1. User Prompt (NOT compressed)
|
||||
const untouchedPrompt = result[0] as UserPrompt;
|
||||
expect(untouchedPrompt.id).toBe(prompt.id);
|
||||
|
||||
// 2. Agent Thought (NOT compressed)
|
||||
const untouchedThought = result[1] as AgentThought;
|
||||
expect(untouchedThought.id).toBe(thought.id);
|
||||
|
||||
// LLM should not have been called
|
||||
expect(mockLlmClient.generateContent).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
|
||||
import type { ConcreteNode } from '../ir/types.js';
|
||||
import type { ContextEnvironment } from '../sidecar/environment.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { getResponseText } from '../../utils/partUtils.js';
|
||||
|
||||
import { LlmRole } from '../../telemetry/llmRole.js';
|
||||
|
||||
export interface NodeDistillationProcessorOptions {
|
||||
nodeThresholdTokens: number;
|
||||
}
|
||||
|
||||
export class NodeDistillationProcessor implements ContextProcessor {
|
||||
static create(
|
||||
env: ContextEnvironment,
|
||||
options: NodeDistillationProcessorOptions,
|
||||
): NodeDistillationProcessor {
|
||||
return new NodeDistillationProcessor(env, options);
|
||||
}
|
||||
|
||||
static readonly schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeThresholdTokens: {
|
||||
type: 'number',
|
||||
description: 'The token threshold above which nodes are summarized.',
|
||||
},
|
||||
},
|
||||
required: ['nodeThresholdTokens'],
|
||||
};
|
||||
|
||||
readonly componentType = 'processor';
|
||||
readonly id = 'NodeDistillationProcessor';
|
||||
readonly name = 'NodeDistillationProcessor';
|
||||
readonly options: NodeDistillationProcessorOptions;
|
||||
private env: ContextEnvironment;
|
||||
|
||||
constructor(
|
||||
env: ContextEnvironment,
|
||||
options: NodeDistillationProcessorOptions,
|
||||
) {
|
||||
this.env = env;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
private async generateSummary(
|
||||
text: string,
|
||||
contextInfo: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await this.env.llmClient.generateContent({
|
||||
role: LlmRole.UTILITY_COMPRESSOR,
|
||||
modelConfigKey: { model: 'summarizer-default' },
|
||||
promptId: this.env.promptId,
|
||||
abortSignal: new AbortController().signal,
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text }],
|
||||
},
|
||||
],
|
||||
systemInstruction: {
|
||||
role: 'system',
|
||||
parts: [
|
||||
{
|
||||
text: `You are an expert context compressor. Your job is to drastically shorten the following ${contextInfo} while preserving the absolute core semantic meaning, facts, and intent. Omit all conversational filler, pleasantries, or redundant information. Return ONLY the compressed summary.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return getResponseText(response) || text;
|
||||
} catch (e) {
|
||||
debugLogger.warn(
|
||||
`NodeDistillationProcessor failed to summarize ${contextInfo}`,
|
||||
e,
|
||||
);
|
||||
return text; // Fallback to original text on API failure
|
||||
}
|
||||
}
|
||||
|
||||
async process({ targets }: ProcessArgs): Promise<readonly ConcreteNode[]> {
|
||||
const semanticConfig = this.options;
|
||||
const limitTokens = semanticConfig.nodeThresholdTokens;
|
||||
const thresholdChars = this.env.tokenCalculator.tokensToChars(limitTokens);
|
||||
|
||||
const returnedNodes: ConcreteNode[] = [];
|
||||
|
||||
// Scan the target working buffer and unconditionally apply the configured hyperparameter threshold
|
||||
for (const node of targets) {
|
||||
switch (node.type) {
|
||||
case 'USER_PROMPT': {
|
||||
let modified = false;
|
||||
const newParts = [...node.semanticParts];
|
||||
|
||||
for (let j = 0; j < node.semanticParts.length; j++) {
|
||||
const part = node.semanticParts[j];
|
||||
if (part.type !== 'text') continue;
|
||||
|
||||
if (part.text.length > thresholdChars) {
|
||||
const summary = await this.generateSummary(
|
||||
part.text,
|
||||
'User Prompt',
|
||||
);
|
||||
const newTokens = this.env.tokenCalculator.estimateTokensForParts(
|
||||
[{ text: summary }],
|
||||
);
|
||||
const oldTokens = this.env.tokenCalculator.estimateTokensForParts(
|
||||
[{ text: part.text }],
|
||||
);
|
||||
|
||||
if (newTokens < oldTokens) {
|
||||
newParts[j] = { type: 'text', text: summary };
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: this.env.idGenerator.generateId(),
|
||||
semanticParts: newParts,
|
||||
});
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'AGENT_THOUGHT': {
|
||||
if (node.text.length > thresholdChars) {
|
||||
const summary = await this.generateSummary(
|
||||
node.text,
|
||||
'Agent Thought',
|
||||
);
|
||||
const newTokens = this.env.tokenCalculator.estimateTokensForParts([
|
||||
{ text: summary },
|
||||
]);
|
||||
const oldTokens = this.env.tokenCalculator.getTokenCost(node);
|
||||
|
||||
if (newTokens < oldTokens) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: this.env.idGenerator.generateId(),
|
||||
text: summary,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'TOOL_EXECUTION': {
|
||||
const rawObs = node.observation;
|
||||
|
||||
let stringifiedObs = '';
|
||||
if (typeof rawObs === 'string') {
|
||||
stringifiedObs = rawObs;
|
||||
} else {
|
||||
try {
|
||||
stringifiedObs = JSON.stringify(rawObs);
|
||||
} catch {
|
||||
stringifiedObs = String(rawObs);
|
||||
}
|
||||
}
|
||||
|
||||
if (stringifiedObs.length > thresholdChars) {
|
||||
const summary = await this.generateSummary(
|
||||
stringifiedObs,
|
||||
node.toolName || 'unknown',
|
||||
);
|
||||
const newObsObject = { summary };
|
||||
|
||||
const newObsTokens =
|
||||
this.env.tokenCalculator.estimateTokensForParts([
|
||||
{
|
||||
functionResponse: {
|
||||
name: node.toolName || 'unknown',
|
||||
response: newObsObject,
|
||||
id: node.id,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const oldObsTokens =
|
||||
node.tokens?.observation ??
|
||||
this.env.tokenCalculator.getTokenCost(node);
|
||||
const intentTokens = node.tokens?.intent ?? 0;
|
||||
|
||||
if (newObsTokens < oldObsTokens) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: this.env.idGenerator.generateId(),
|
||||
observation: newObsObject as Record<string, unknown>,
|
||||
tokens: {
|
||||
intent: intentTokens,
|
||||
observation: newObsTokens,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { NodeTruncationProcessor } from './nodeTruncationProcessor.js';
|
||||
import {
|
||||
createMockProcessArgs,
|
||||
createMockEnvironment,
|
||||
createDummyNode,
|
||||
} from '../testing/contextTestUtils.js';
|
||||
import type { UserPrompt, AgentThought, AgentYield } from '../ir/types.js';
|
||||
|
||||
describe('NodeTruncationProcessor', () => {
|
||||
it('should truncate nodes that exceed maxTokensPerNode', async () => {
|
||||
// env.tokenCalculator uses charsPerToken=1 natively.
|
||||
const env = createMockEnvironment();
|
||||
|
||||
const processor = NodeTruncationProcessor.create(env, {
|
||||
maxTokensPerNode: 10, // 10 chars limit
|
||||
});
|
||||
|
||||
const longText = 'A'.repeat(50); // 50 tokens
|
||||
|
||||
const prompt = createDummyNode(
|
||||
'ep1',
|
||||
'USER_PROMPT',
|
||||
50,
|
||||
{
|
||||
semanticParts: [{ type: 'text', text: longText }],
|
||||
},
|
||||
'prompt-id',
|
||||
) as UserPrompt;
|
||||
|
||||
const thought = createDummyNode(
|
||||
'ep1',
|
||||
'AGENT_THOUGHT',
|
||||
50,
|
||||
{
|
||||
text: longText,
|
||||
},
|
||||
'thought-id',
|
||||
) as AgentThought;
|
||||
|
||||
const yieldNode = createDummyNode(
|
||||
'ep1',
|
||||
'AGENT_YIELD',
|
||||
50,
|
||||
{
|
||||
text: longText,
|
||||
},
|
||||
'yield-id',
|
||||
) as AgentYield;
|
||||
|
||||
const targets = [prompt, thought, yieldNode];
|
||||
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
|
||||
// 1. User Prompt
|
||||
const squashedPrompt = result[0] as UserPrompt;
|
||||
expect(squashedPrompt.id).not.toBe(prompt.id);
|
||||
expect(squashedPrompt.semanticParts[0].type).toBe('text');
|
||||
assert(squashedPrompt.semanticParts[0].type === 'text');
|
||||
expect(squashedPrompt.semanticParts[0].text).toContain('[... OMITTED');
|
||||
|
||||
// 2. Agent Thought
|
||||
const squashedThought = result[1] as AgentThought;
|
||||
expect(squashedThought.id).not.toBe(thought.id);
|
||||
expect(squashedThought.text).toContain('[... OMITTED');
|
||||
|
||||
// 3. Agent Yield
|
||||
const squashedYield = result[2] as AgentYield;
|
||||
expect(squashedYield.id).not.toBe(yieldNode.id);
|
||||
expect(squashedYield.text).toContain('[... OMITTED');
|
||||
});
|
||||
|
||||
it('should ignore nodes that are below maxTokensPerNode', async () => {
|
||||
const env = createMockEnvironment();
|
||||
|
||||
const processor = NodeTruncationProcessor.create(env, {
|
||||
maxTokensPerNode: 100, // 100 chars limit
|
||||
});
|
||||
|
||||
const shortText = 'Short text'; // 10 chars
|
||||
|
||||
const prompt = createDummyNode(
|
||||
'ep1',
|
||||
'USER_PROMPT',
|
||||
10,
|
||||
{
|
||||
semanticParts: [{ type: 'text', text: shortText }],
|
||||
},
|
||||
'prompt-id',
|
||||
) as UserPrompt;
|
||||
|
||||
const thought = createDummyNode(
|
||||
'ep1',
|
||||
'AGENT_THOUGHT',
|
||||
13,
|
||||
{
|
||||
text: 'Short thought', // 13 chars
|
||||
},
|
||||
'thought-id',
|
||||
) as AgentThought;
|
||||
|
||||
const targets = [prompt, thought];
|
||||
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
|
||||
// 1. User Prompt (untouched)
|
||||
const squashedPrompt = result[0] as UserPrompt;
|
||||
expect(squashedPrompt.id).toBe(prompt.id);
|
||||
assert(squashedPrompt.semanticParts[0].type === 'text');
|
||||
expect(squashedPrompt.semanticParts[0].text).not.toContain('[... OMITTED');
|
||||
|
||||
// 2. Agent Thought (untouched)
|
||||
const untouchedThought = result[1] as AgentThought;
|
||||
expect(untouchedThought.id).toBe(thought.id);
|
||||
expect(untouchedThought.text).not.toContain('[... OMITTED');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
|
||||
import type { ContextEnvironment } from '../sidecar/environment.js';
|
||||
import { truncateProportionally } from '../truncation.js';
|
||||
import type { ConcreteNode } from '../ir/types.js';
|
||||
|
||||
export interface NodeTruncationProcessorOptions {
|
||||
maxTokensPerNode: number;
|
||||
}
|
||||
|
||||
export class NodeTruncationProcessor implements ContextProcessor {
|
||||
static create(
|
||||
env: ContextEnvironment,
|
||||
options: NodeTruncationProcessorOptions,
|
||||
): NodeTruncationProcessor {
|
||||
return new NodeTruncationProcessor(env, options);
|
||||
}
|
||||
|
||||
static readonly schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
maxTokensPerNode: {
|
||||
type: 'number',
|
||||
description:
|
||||
'The maximum tokens a node can have before being truncated.',
|
||||
},
|
||||
},
|
||||
required: ['maxTokensPerNode'],
|
||||
};
|
||||
|
||||
readonly componentType = 'processor';
|
||||
readonly id = 'NodeTruncationProcessor';
|
||||
readonly name = 'NodeTruncationProcessor';
|
||||
readonly options: NodeTruncationProcessorOptions;
|
||||
private env: ContextEnvironment;
|
||||
|
||||
constructor(
|
||||
env: ContextEnvironment,
|
||||
options: NodeTruncationProcessorOptions,
|
||||
) {
|
||||
this.env = env;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
private tryApplySquash(
|
||||
text: string,
|
||||
limitChars: number,
|
||||
): {
|
||||
text: string;
|
||||
newTokens: number;
|
||||
oldTokens: number;
|
||||
tokensSaved: number;
|
||||
} | null {
|
||||
const originalLength = text.length;
|
||||
if (originalLength <= limitChars) return null;
|
||||
|
||||
const newText = truncateProportionally(
|
||||
text,
|
||||
limitChars,
|
||||
`\n\n[... OMITTED ${originalLength - limitChars} chars ...]\n\n`,
|
||||
);
|
||||
|
||||
if (newText !== text) {
|
||||
// Using accurate TokenCalculator instead of simple math
|
||||
const newTokens =
|
||||
this.env.tokenCalculator.estimateTokensForString(newText);
|
||||
const oldTokens = this.env.tokenCalculator.estimateTokensForString(text);
|
||||
const tokensSaved = oldTokens - newTokens;
|
||||
|
||||
if (tokensSaved > 0) {
|
||||
return { text: newText, newTokens, oldTokens, tokensSaved };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async process({ targets }: ProcessArgs): Promise<readonly ConcreteNode[]> {
|
||||
if (targets.length === 0) {
|
||||
return targets;
|
||||
}
|
||||
|
||||
const { maxTokensPerNode } = this.options;
|
||||
const limitChars = this.env.tokenCalculator.tokensToChars(maxTokensPerNode);
|
||||
|
||||
const returnedNodes: ConcreteNode[] = [];
|
||||
|
||||
for (const node of targets) {
|
||||
switch (node.type) {
|
||||
case 'USER_PROMPT': {
|
||||
let modified = false;
|
||||
const newParts = [...node.semanticParts];
|
||||
|
||||
for (let j = 0; j < node.semanticParts.length; j++) {
|
||||
const part = node.semanticParts[j];
|
||||
if (part.type === 'text') {
|
||||
const squashResult = this.tryApplySquash(part.text, limitChars);
|
||||
if (squashResult) {
|
||||
newParts[j] = { type: 'text', text: squashResult.text };
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: this.env.idGenerator.generateId(),
|
||||
semanticParts: newParts,
|
||||
});
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'AGENT_THOUGHT': {
|
||||
const squashResult = this.tryApplySquash(node.text, limitChars);
|
||||
if (squashResult) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: this.env.idGenerator.generateId(),
|
||||
text: squashResult.text,
|
||||
});
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'AGENT_YIELD': {
|
||||
const squashResult = this.tryApplySquash(node.text, limitChars);
|
||||
if (squashResult) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: this.env.idGenerator.generateId(),
|
||||
text: squashResult.text,
|
||||
});
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RollingSummaryProcessor } from './rollingSummaryProcessor.js';
|
||||
import {
|
||||
createMockProcessArgs,
|
||||
createMockEnvironment,
|
||||
createDummyNode,
|
||||
} from '../testing/contextTestUtils.js';
|
||||
|
||||
describe('RollingSummaryProcessor', () => {
|
||||
it('should initialize with correct default options', () => {
|
||||
const env = createMockEnvironment();
|
||||
const processor = RollingSummaryProcessor.create(env, {
|
||||
target: 'incremental',
|
||||
});
|
||||
expect(processor.id).toBe('RollingSummaryProcessor');
|
||||
});
|
||||
|
||||
it('should summarize older nodes when the deficit exceeds the threshold', async () => {
|
||||
// env.tokenCalculator uses charsPerToken=1 based on createMockEnvironment
|
||||
const env = createMockEnvironment();
|
||||
|
||||
// We want to free exactly 100 tokens.
|
||||
// We will supply nodes that cost 50 tokens each.
|
||||
const processor = RollingSummaryProcessor.create(env, {
|
||||
target: 'freeNTokens',
|
||||
freeTokensTarget: 100,
|
||||
});
|
||||
|
||||
const text50 = 'A'.repeat(50);
|
||||
const targets = [
|
||||
createDummyNode(
|
||||
'ep1',
|
||||
'USER_PROMPT',
|
||||
50,
|
||||
{ semanticParts: [{ type: 'text', text: text50 }] },
|
||||
'id1',
|
||||
),
|
||||
createDummyNode('ep1', 'AGENT_THOUGHT', 50, { text: text50 }, 'id2'),
|
||||
createDummyNode('ep1', 'AGENT_YIELD', 50, { text: text50 }, 'id3'),
|
||||
];
|
||||
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
// 3 nodes at 50 cost each.
|
||||
// The first node (id1) is the initial USER_PROMPT and is always skipped by RollingSummaryProcessor.
|
||||
// Node id2 adds 50 deficit. Node id3 adds 50 deficit. Total = 100 deficit, which hits the target break point.
|
||||
// Thus, id2 and id3 are summarized into a new ROLLING_SUMMARY node.
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].type).toBe('USER_PROMPT');
|
||||
expect(result[1].type).toBe('ROLLING_SUMMARY');
|
||||
});
|
||||
|
||||
it('should preserve targets if deficit does not trigger summary', async () => {
|
||||
const env = createMockEnvironment();
|
||||
|
||||
// We want to free 100 tokens, but our nodes will only cost 10 tokens each.
|
||||
const processor = RollingSummaryProcessor.create(env, {
|
||||
target: 'freeNTokens',
|
||||
freeTokensTarget: 100,
|
||||
});
|
||||
|
||||
const text10 = 'A'.repeat(10);
|
||||
const targets = [
|
||||
createDummyNode(
|
||||
'ep1',
|
||||
'USER_PROMPT',
|
||||
10,
|
||||
{ semanticParts: [{ type: 'text', text: text10 }] },
|
||||
'id1',
|
||||
),
|
||||
createDummyNode('ep1', 'AGENT_THOUGHT', 10, { text: text10 }, 'id2'),
|
||||
];
|
||||
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
// Deficit accumulator reaches 10. This is < 100 limit, and total summarizable nodes < 2 anyway.
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].type).toBe('USER_PROMPT');
|
||||
expect(result[1].type).toBe('AGENT_THOUGHT');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type {
|
||||
ContextProcessor,
|
||||
ProcessArgs,
|
||||
BackstopTargetOptions,
|
||||
} from '../pipeline.js';
|
||||
import type { ContextEnvironment } from '../sidecar/environment.js';
|
||||
import type { ConcreteNode, RollingSummary } from '../ir/types.js';
|
||||
import { SnapshotGenerator } from '../utils/snapshotGenerator.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
|
||||
export interface RollingSummaryProcessorOptions extends BackstopTargetOptions {
|
||||
systemInstruction?: string;
|
||||
}
|
||||
|
||||
export class RollingSummaryProcessor implements ContextProcessor {
|
||||
static readonly schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
target: { type: 'string', enum: ['incremental', 'freeNTokens', 'max'] },
|
||||
freeTokensTarget: { type: 'number' },
|
||||
systemInstruction: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
static create(
|
||||
env: ContextEnvironment,
|
||||
options: RollingSummaryProcessorOptions,
|
||||
): RollingSummaryProcessor {
|
||||
return new RollingSummaryProcessor(env, options);
|
||||
}
|
||||
|
||||
readonly componentType = 'processor';
|
||||
readonly id = 'RollingSummaryProcessor';
|
||||
readonly name = 'RollingSummaryProcessor';
|
||||
readonly options: RollingSummaryProcessorOptions;
|
||||
private readonly env: ContextEnvironment;
|
||||
private readonly generator: SnapshotGenerator;
|
||||
|
||||
constructor(
|
||||
env: ContextEnvironment,
|
||||
options: RollingSummaryProcessorOptions,
|
||||
) {
|
||||
this.env = env;
|
||||
this.options = options;
|
||||
this.generator = new SnapshotGenerator(env);
|
||||
}
|
||||
|
||||
async process({ targets }: ProcessArgs): Promise<readonly ConcreteNode[]> {
|
||||
if (targets.length === 0) return targets;
|
||||
|
||||
const strategy = this.options.target ?? 'max';
|
||||
let targetTokensToRemove = 0;
|
||||
|
||||
if (strategy === 'incremental') {
|
||||
// A rolling summary should target a small chunk. For now, since state isn't passed,
|
||||
// we'll default to a fixed threshold, like 10000 tokens, to avoid eating the whole history.
|
||||
// Ideally, the orchestrator should pass `tokensToRemove` explicitly.
|
||||
targetTokensToRemove = 10000;
|
||||
} else if (strategy === 'freeNTokens') {
|
||||
targetTokensToRemove = this.options.freeTokensTarget ?? Infinity;
|
||||
} else if (strategy === 'max') {
|
||||
targetTokensToRemove = Infinity;
|
||||
}
|
||||
|
||||
if (targetTokensToRemove <= 0) return targets;
|
||||
|
||||
let deficitAccumulator = 0;
|
||||
const nodesToSummarize: ConcreteNode[] = [];
|
||||
|
||||
// Scan oldest to newest to find the oldest block that exceeds the token requirement
|
||||
for (const node of targets) {
|
||||
if (node.id === targets[0].id && node.type === 'USER_PROMPT') {
|
||||
// Keep system prompt if it's the very first node
|
||||
continue;
|
||||
}
|
||||
|
||||
nodesToSummarize.push(node);
|
||||
deficitAccumulator += this.env.tokenCalculator.getTokenCost(node);
|
||||
|
||||
if (deficitAccumulator >= targetTokensToRemove) break;
|
||||
}
|
||||
|
||||
if (nodesToSummarize.length < 2) return targets; // Not enough context to summarize
|
||||
|
||||
try {
|
||||
// Synthesize the rolling summary synchronously
|
||||
const snapshotText = await this.generator.synthesizeSnapshot(
|
||||
nodesToSummarize,
|
||||
this.options.systemInstruction,
|
||||
);
|
||||
const newId = this.env.idGenerator.generateId();
|
||||
|
||||
const summaryNode: RollingSummary = {
|
||||
id: newId,
|
||||
logicalParentId: newId,
|
||||
type: 'ROLLING_SUMMARY',
|
||||
timestamp: Date.now(),
|
||||
text: snapshotText,
|
||||
};
|
||||
|
||||
const consumedIds = nodesToSummarize.map((n) => n.id);
|
||||
const returnedNodes = targets.filter((t) => !consumedIds.includes(t.id));
|
||||
const firstRemovedIdx = targets.findIndex((t) =>
|
||||
consumedIds.includes(t.id),
|
||||
);
|
||||
|
||||
if (firstRemovedIdx !== -1) {
|
||||
const idx = Math.max(0, firstRemovedIdx);
|
||||
returnedNodes.splice(idx, 0, summaryNode);
|
||||
} else {
|
||||
returnedNodes.unshift(summaryNode);
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
} catch (e) {
|
||||
debugLogger.error('RollingSummaryProcessor failed sync backstop', e);
|
||||
return targets;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StateSnapshotProcessor } from './stateSnapshotProcessor.js';
|
||||
import {
|
||||
createMockEnvironment,
|
||||
createDummyNode,
|
||||
createMockProcessArgs,
|
||||
} from '../testing/contextTestUtils.js';
|
||||
import type { InboxSnapshotImpl } from '../sidecar/inbox.js';
|
||||
|
||||
describe('StateSnapshotProcessor', () => {
|
||||
it('should ignore if budget is satisfied', async () => {
|
||||
const env = createMockEnvironment();
|
||||
const processor = StateSnapshotProcessor.create(env, {
|
||||
target: 'incremental',
|
||||
});
|
||||
const targets = [createDummyNode('ep1', 'USER_PROMPT')];
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
expect(result).toBe(targets); // Strict equality
|
||||
});
|
||||
|
||||
it('should apply a valid snapshot from the Inbox (Fast Path)', async () => {
|
||||
const env = createMockEnvironment();
|
||||
const processor = StateSnapshotProcessor.create(env, {
|
||||
target: 'incremental',
|
||||
});
|
||||
|
||||
const nodeA = createDummyNode('ep1', 'USER_PROMPT', 50, {}, 'node-A');
|
||||
const nodeB = createDummyNode('ep1', 'AGENT_THOUGHT', 60, {}, 'node-B');
|
||||
const nodeC = createDummyNode('ep2', 'USER_PROMPT', 50, {}, 'node-C');
|
||||
|
||||
const targets = [nodeA, nodeB, nodeC];
|
||||
|
||||
// The background worker created a snapshot of A and B
|
||||
const messages = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
topic: 'PROPOSED_SNAPSHOT',
|
||||
timestamp: Date.now(),
|
||||
payload: {
|
||||
consumedIds: ['node-A', 'node-B'],
|
||||
newText: '<compressed A and B>',
|
||||
type: 'point-in-time',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const processArgs = createMockProcessArgs(targets, [], messages);
|
||||
const result = await processor.process(processArgs);
|
||||
|
||||
// Should remove A and B, insert Snapshot, keep C
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].type).toBe('SNAPSHOT');
|
||||
expect(result[1].id).toBe('node-C');
|
||||
|
||||
// Should consume the message
|
||||
expect(
|
||||
(processArgs.inbox as InboxSnapshotImpl).getConsumedIds().has('msg-1'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a snapshot if the nodes were modified/deleted (Cache Invalidated)', async () => {
|
||||
const env = createMockEnvironment();
|
||||
const processor = StateSnapshotProcessor.create(env, {
|
||||
target: 'incremental',
|
||||
});
|
||||
// Make deficit 0 so we don't fall through to the sync backstop and fail the test that way
|
||||
|
||||
// node-A is MISSING (user deleted it)
|
||||
const nodeB = createDummyNode('ep1', 'AGENT_THOUGHT', 60, {}, 'node-B');
|
||||
const targets = [nodeB];
|
||||
|
||||
const messages = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
topic: 'PROPOSED_SNAPSHOT',
|
||||
timestamp: Date.now(),
|
||||
payload: {
|
||||
consumedIds: ['node-A', 'node-B'],
|
||||
newText: '<compressed A and B>',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const processArgs = createMockProcessArgs(targets, [], messages);
|
||||
const result = await processor.process(processArgs);
|
||||
|
||||
// Because deficit is 0, and Inbox was rejected, nothing should change
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('node-B');
|
||||
expect(
|
||||
(processArgs.inbox as InboxSnapshotImpl).getConsumedIds().has('msg-1'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should fall back to sync backstop if inbox is empty', async () => {
|
||||
const env = createMockEnvironment();
|
||||
const processor = StateSnapshotProcessor.create(env, { target: 'max' }); // Summarize all
|
||||
|
||||
const nodeA = createDummyNode('ep1', 'USER_PROMPT', 50, {}, 'node-A');
|
||||
const nodeB = createDummyNode('ep1', 'AGENT_THOUGHT', 60, {}, 'node-B');
|
||||
const nodeC = createDummyNode('ep2', 'USER_PROMPT', 50, {}, 'node-C');
|
||||
const targets = [nodeA, nodeB, nodeC];
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
// Should synthesize a new snapshot synchronously
|
||||
expect(env.llmClient.generateContent).toHaveBeenCalled();
|
||||
expect(result.length).toBe(2); // nodeA is skipped as "system prompt", snapshot + nodeA
|
||||
expect(result[1].type).toBe('SNAPSHOT');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type {
|
||||
ContextProcessor,
|
||||
ProcessArgs,
|
||||
BackstopTargetOptions,
|
||||
} from '../pipeline.js';
|
||||
import type { ContextEnvironment } from '../sidecar/environment.js';
|
||||
import type { ConcreteNode, Snapshot } from '../ir/types.js';
|
||||
import { SnapshotGenerator } from '../utils/snapshotGenerator.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
|
||||
export interface StateSnapshotProcessorOptions extends BackstopTargetOptions {
|
||||
model?: string;
|
||||
systemInstruction?: string;
|
||||
}
|
||||
|
||||
export class StateSnapshotProcessor implements ContextProcessor {
|
||||
static readonly schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
target: { type: 'string', enum: ['incremental', 'freeNTokens', 'max'] },
|
||||
freeTokensTarget: { type: 'number' },
|
||||
model: { type: 'string' },
|
||||
systemInstruction: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
static create(
|
||||
env: ContextEnvironment,
|
||||
options: StateSnapshotProcessorOptions,
|
||||
): StateSnapshotProcessor {
|
||||
return new StateSnapshotProcessor(env, options);
|
||||
}
|
||||
|
||||
readonly componentType = 'processor';
|
||||
readonly id = 'StateSnapshotProcessor';
|
||||
readonly name = 'StateSnapshotProcessor';
|
||||
readonly options: StateSnapshotProcessorOptions;
|
||||
private readonly env: ContextEnvironment;
|
||||
private readonly generator: SnapshotGenerator;
|
||||
|
||||
constructor(env: ContextEnvironment, options: StateSnapshotProcessorOptions) {
|
||||
this.env = env;
|
||||
this.options = options;
|
||||
this.generator = new SnapshotGenerator(env);
|
||||
}
|
||||
|
||||
// --- ContextProcessor Interface (Sync Backstop / Cache Application) ---
|
||||
async process({
|
||||
targets,
|
||||
inbox,
|
||||
}: ProcessArgs): Promise<readonly ConcreteNode[]> {
|
||||
if (targets.length === 0) {
|
||||
return targets;
|
||||
}
|
||||
|
||||
// Determine what mode we are looking for: 'incremental' -> 'point-in-time', 'max' -> 'accumulate'
|
||||
const strategy = this.options.target ?? 'max';
|
||||
const expectedType =
|
||||
strategy === 'incremental' ? 'point-in-time' : 'accumulate';
|
||||
|
||||
// 1. Check Inbox for a completed Snapshot (The Fast Path)
|
||||
const proposedSnapshots = inbox.getMessages<{
|
||||
newText: string;
|
||||
consumedIds: string[];
|
||||
type: string;
|
||||
}>('PROPOSED_SNAPSHOT');
|
||||
|
||||
if (proposedSnapshots.length > 0) {
|
||||
// Filter for the snapshot type that matches our processor mode
|
||||
const matchingSnapshots = proposedSnapshots.filter(
|
||||
(s) => s.payload.type === expectedType,
|
||||
);
|
||||
|
||||
// Sort by newest timestamp first (we want the most accumulated snapshot)
|
||||
const sorted = [...matchingSnapshots].sort(
|
||||
(a, b) => b.timestamp - a.timestamp,
|
||||
);
|
||||
|
||||
for (const proposed of sorted) {
|
||||
const { consumedIds, newText } = proposed.payload;
|
||||
|
||||
// Verify all consumed IDs still exist sequentially in targets
|
||||
const targetIds = new Set(targets.map((t) => t.id));
|
||||
const isValid = consumedIds.every((id) => targetIds.has(id));
|
||||
|
||||
if (isValid) {
|
||||
// If valid, apply it!
|
||||
const newId = this.env.idGenerator.generateId();
|
||||
|
||||
const snapshotNode: Snapshot = {
|
||||
id: newId,
|
||||
logicalParentId: newId,
|
||||
type: 'SNAPSHOT',
|
||||
timestamp: Date.now(),
|
||||
text: newText,
|
||||
};
|
||||
|
||||
// Remove the consumed nodes and insert the snapshot at the earliest index
|
||||
const returnedNodes = targets.filter(
|
||||
(t) => !consumedIds.includes(t.id),
|
||||
);
|
||||
const firstRemovedIdx = targets.findIndex((t) =>
|
||||
consumedIds.includes(t.id),
|
||||
);
|
||||
|
||||
if (firstRemovedIdx !== -1) {
|
||||
const idx = Math.max(0, firstRemovedIdx);
|
||||
returnedNodes.splice(idx, 0, snapshotNode);
|
||||
} else {
|
||||
returnedNodes.unshift(snapshotNode);
|
||||
}
|
||||
|
||||
inbox.consume(proposed.id);
|
||||
return returnedNodes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. The Synchronous Backstop (The Slow Path)
|
||||
let targetTokensToRemove = 0;
|
||||
|
||||
if (strategy === 'incremental') {
|
||||
targetTokensToRemove = Infinity; // incremental implies removing as much as possible if no state is passed
|
||||
} else if (strategy === 'freeNTokens') {
|
||||
targetTokensToRemove = this.options.freeTokensTarget ?? Infinity;
|
||||
} else if (strategy === 'max') {
|
||||
targetTokensToRemove = Infinity;
|
||||
}
|
||||
|
||||
let deficitAccumulator = 0;
|
||||
const nodesToSummarize: ConcreteNode[] = [];
|
||||
|
||||
// Scan oldest to newest
|
||||
for (const node of targets) {
|
||||
if (node.id === targets[0].id && node.type === 'USER_PROMPT') {
|
||||
// Keep system prompt if it's the very first node
|
||||
// In a real system, system prompt is protected, but we double check
|
||||
continue;
|
||||
}
|
||||
|
||||
nodesToSummarize.push(node);
|
||||
deficitAccumulator += this.env.tokenCalculator.getTokenCost(node);
|
||||
|
||||
if (deficitAccumulator >= targetTokensToRemove) break;
|
||||
}
|
||||
|
||||
if (nodesToSummarize.length < 2) return targets; // Not enough context
|
||||
|
||||
try {
|
||||
const snapshotText = await this.generator.synthesizeSnapshot(
|
||||
nodesToSummarize,
|
||||
this.options.systemInstruction,
|
||||
);
|
||||
const newId = this.env.idGenerator.generateId();
|
||||
const snapshotNode: Snapshot = {
|
||||
id: newId,
|
||||
logicalParentId: newId,
|
||||
type: 'SNAPSHOT',
|
||||
timestamp: Date.now(),
|
||||
text: snapshotText,
|
||||
};
|
||||
|
||||
const consumedIds = nodesToSummarize.map((n) => n.id);
|
||||
const returnedNodes = targets.filter((t) => !consumedIds.includes(t.id));
|
||||
const firstRemovedIdx = targets.findIndex((t) =>
|
||||
consumedIds.includes(t.id),
|
||||
);
|
||||
|
||||
if (firstRemovedIdx !== -1) {
|
||||
const idx = Math.max(0, firstRemovedIdx);
|
||||
returnedNodes.splice(idx, 0, snapshotNode);
|
||||
} else {
|
||||
returnedNodes.unshift(snapshotNode);
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
} catch (e) {
|
||||
debugLogger.error('StateSnapshotProcessor failed sync backstop', e);
|
||||
return targets;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { StateSnapshotWorker } from './stateSnapshotWorker.js';
|
||||
import {
|
||||
createMockEnvironment,
|
||||
createDummyNode,
|
||||
} from '../testing/contextTestUtils.js';
|
||||
import { InboxSnapshotImpl } from '../sidecar/inbox.js';
|
||||
|
||||
describe('StateSnapshotWorker', () => {
|
||||
it('should generate a snapshot and publish it to the inbox', async () => {
|
||||
const env = createMockEnvironment();
|
||||
// Spy on the publish method
|
||||
const publishSpy = vi.spyOn(env.inbox, 'publish');
|
||||
|
||||
const worker = StateSnapshotWorker.create(env, { type: 'point-in-time' });
|
||||
|
||||
const nodeA = createDummyNode('ep1', 'USER_PROMPT', 50, {}, 'node-A');
|
||||
const nodeB = createDummyNode('ep1', 'AGENT_THOUGHT', 60, {}, 'node-B');
|
||||
|
||||
const targets = [nodeA, nodeB];
|
||||
const inbox = new InboxSnapshotImpl([]);
|
||||
|
||||
await worker.execute({ targets, inbox });
|
||||
|
||||
// Ensure generateContent was called
|
||||
expect(env.llmClient.generateContent).toHaveBeenCalled();
|
||||
|
||||
// Verify it published to the inbox
|
||||
expect(publishSpy).toHaveBeenCalledWith(
|
||||
'PROPOSED_SNAPSHOT',
|
||||
expect.objectContaining({
|
||||
newText: 'Mock LLM summary response',
|
||||
consumedIds: ['node-A', 'node-B'],
|
||||
type: 'point-in-time',
|
||||
}),
|
||||
env.idGenerator,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pull previous accumulate snapshot from inbox and append new targets', async () => {
|
||||
const env = createMockEnvironment();
|
||||
const publishSpy = vi.spyOn(env.inbox, 'publish');
|
||||
const drainSpy = vi.spyOn(env.inbox, 'drainConsumed');
|
||||
|
||||
const worker = StateSnapshotWorker.create(env, { type: 'accumulate' });
|
||||
|
||||
const nodeC = createDummyNode('ep2', 'USER_PROMPT', 50, {}, 'node-C');
|
||||
const targets = [nodeC];
|
||||
|
||||
// Simulate an existing accumulate draft in the inbox
|
||||
const inbox = new InboxSnapshotImpl([
|
||||
{
|
||||
id: 'draft-1',
|
||||
topic: 'PROPOSED_SNAPSHOT',
|
||||
timestamp: Date.now() - 1000,
|
||||
payload: {
|
||||
consumedIds: ['node-A', 'node-B'],
|
||||
newText: '<old snapshot>',
|
||||
type: 'accumulate',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await worker.execute({ targets, inbox });
|
||||
|
||||
// The old draft should be consumed
|
||||
expect(inbox.getConsumedIds().has('draft-1')).toBe(true);
|
||||
expect(drainSpy).toHaveBeenCalledWith(expect.any(Set));
|
||||
|
||||
// The new publish should contain ALL consumed IDs (old + new)
|
||||
expect(publishSpy).toHaveBeenCalledWith(
|
||||
'PROPOSED_SNAPSHOT',
|
||||
expect.objectContaining({
|
||||
newText: 'Mock LLM summary response',
|
||||
consumedIds: ['node-A', 'node-B', 'node-C'], // Aggregated!
|
||||
type: 'accumulate',
|
||||
}),
|
||||
env.idGenerator,
|
||||
);
|
||||
|
||||
// Verify the LLM was called with the old snapshot prepended
|
||||
expect(env.llmClient.generateContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contents: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
parts: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('<old snapshot>'),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore empty targets', async () => {
|
||||
const env = createMockEnvironment();
|
||||
const publishSpy = vi.spyOn(env.inbox, 'publish');
|
||||
const worker = StateSnapshotWorker.create(env, { type: 'accumulate' });
|
||||
|
||||
await worker.execute({ targets: [], inbox: new InboxSnapshotImpl([]) });
|
||||
|
||||
expect(env.llmClient.generateContent).not.toHaveBeenCalled();
|
||||
expect(publishSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ContextWorker, InboxSnapshot } from '../pipeline.js';
|
||||
import type { ContextEnvironment } from '../sidecar/environment.js';
|
||||
import type { ConcreteNode } from '../ir/types.js';
|
||||
import { SnapshotGenerator } from '../utils/snapshotGenerator.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
|
||||
export interface StateSnapshotWorkerOptions {
|
||||
type?: 'accumulate' | 'point-in-time';
|
||||
systemInstruction?: string;
|
||||
}
|
||||
|
||||
export class StateSnapshotWorker implements ContextWorker {
|
||||
static readonly schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', enum: ['accumulate', 'point-in-time'] },
|
||||
systemInstruction: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
static create(
|
||||
env: ContextEnvironment,
|
||||
options: StateSnapshotWorkerOptions,
|
||||
): StateSnapshotWorker {
|
||||
return new StateSnapshotWorker(env, options);
|
||||
}
|
||||
|
||||
readonly componentType = 'worker';
|
||||
readonly id = 'StateSnapshotWorker';
|
||||
readonly name = 'StateSnapshotWorker';
|
||||
readonly options: StateSnapshotWorkerOptions;
|
||||
private readonly env: ContextEnvironment;
|
||||
private readonly generator: SnapshotGenerator;
|
||||
|
||||
// Triggers when nodes exceed retained threshold (via retained_exceeded in Orchestrator)
|
||||
readonly triggers = {
|
||||
onNodesAgedOut: true,
|
||||
};
|
||||
|
||||
constructor(env: ContextEnvironment, options: StateSnapshotWorkerOptions) {
|
||||
this.env = env;
|
||||
this.options = options;
|
||||
this.generator = new SnapshotGenerator(env);
|
||||
}
|
||||
|
||||
async execute({
|
||||
targets,
|
||||
inbox,
|
||||
}: {
|
||||
targets: readonly ConcreteNode[];
|
||||
inbox: InboxSnapshot;
|
||||
}): Promise<void> {
|
||||
if (targets.length === 0) return;
|
||||
|
||||
try {
|
||||
let nodesToSummarize = [...targets];
|
||||
let previousConsumedIds: string[] = [];
|
||||
const workerType = this.options.type ?? 'point-in-time';
|
||||
|
||||
if (workerType === 'accumulate') {
|
||||
// Look for the most recent unconsumed accumulate snapshot in the inbox
|
||||
const proposedSnapshots = inbox.getMessages<{
|
||||
newText: string;
|
||||
consumedIds: string[];
|
||||
type: string;
|
||||
}>('PROPOSED_SNAPSHOT');
|
||||
const accumulateSnapshots = proposedSnapshots.filter(
|
||||
(s) => s.payload.type === 'accumulate',
|
||||
);
|
||||
|
||||
if (accumulateSnapshots.length > 0) {
|
||||
// Sort to find the most recent
|
||||
const latest = [...accumulateSnapshots].sort(
|
||||
(a, b) => b.timestamp - a.timestamp,
|
||||
)[0];
|
||||
|
||||
// Consume the old draft so the inbox doesn't fill up with stale drafts
|
||||
inbox.consume(latest.id);
|
||||
// And we must persist its consumption back to the live inbox immediately,
|
||||
// because we are effectively "taking" it from the shelf to modify.
|
||||
this.env.inbox.drainConsumed(new Set([latest.id]));
|
||||
|
||||
previousConsumedIds = latest.payload.consumedIds;
|
||||
|
||||
// Prepend a synthetic node representing the previous rolling state
|
||||
const previousStateNode: ConcreteNode = {
|
||||
id: this.env.idGenerator.generateId(),
|
||||
logicalParentId: '',
|
||||
type: 'SNAPSHOT',
|
||||
timestamp: latest.timestamp,
|
||||
text: latest.payload.newText,
|
||||
};
|
||||
|
||||
nodesToSummarize = [previousStateNode, ...targets];
|
||||
}
|
||||
}
|
||||
|
||||
const snapshotText = await this.generator.synthesizeSnapshot(
|
||||
nodesToSummarize,
|
||||
this.options.systemInstruction,
|
||||
);
|
||||
|
||||
const newConsumedIds = [
|
||||
...previousConsumedIds,
|
||||
...targets.map((t) => t.id),
|
||||
];
|
||||
|
||||
// In V2, workers communicate their work to the inbox, and the processor picks it up.
|
||||
this.env.inbox.publish(
|
||||
'PROPOSED_SNAPSHOT',
|
||||
{
|
||||
newText: snapshotText,
|
||||
consumedIds: newConsumedIds,
|
||||
type: workerType,
|
||||
},
|
||||
this.env.idGenerator,
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.error('StateSnapshotWorker failed to generate snapshot', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ToolMaskingProcessor } from './toolMaskingProcessor.js';
|
||||
import {
|
||||
createMockProcessArgs,
|
||||
createMockEnvironment,
|
||||
createDummyToolNode,
|
||||
} from '../testing/contextTestUtils.js';
|
||||
import type { ToolExecution } from '../ir/types.js';
|
||||
|
||||
describe('ToolMaskingProcessor', () => {
|
||||
it('should write large strings to disk and replace them with a masked pointer', async () => {
|
||||
const env = createMockEnvironment();
|
||||
// env uses charsPerToken=1 natively.
|
||||
// original string lengths > stringLengthThresholdTokens (which is 10) will be masked
|
||||
|
||||
const processor = ToolMaskingProcessor.create(env, {
|
||||
stringLengthThresholdTokens: 10,
|
||||
});
|
||||
|
||||
const longString = 'A'.repeat(500); // 500 chars
|
||||
|
||||
const toolStep = createDummyToolNode('ep1', 50, 500, {
|
||||
observation: {
|
||||
result: longString,
|
||||
metadata: 'short', // 5 chars, will not be masked
|
||||
},
|
||||
});
|
||||
|
||||
const result = await processor.process(createMockProcessArgs([toolStep]));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
const masked = result[0] as ToolExecution;
|
||||
|
||||
// It should have generated a new ID because it modified it
|
||||
expect(masked.id).not.toBe(toolStep.id);
|
||||
|
||||
// It should have masked the observation
|
||||
const obs = masked.observation as { result: string; metadata: string };
|
||||
expect(obs.result).toContain('<tool_output_masked>');
|
||||
expect(obs.metadata).toBe('short'); // Untouched
|
||||
});
|
||||
|
||||
it('should skip unmaskable tools', async () => {
|
||||
const env = createMockEnvironment();
|
||||
|
||||
const processor = ToolMaskingProcessor.create(env, {
|
||||
stringLengthThresholdTokens: 10,
|
||||
});
|
||||
|
||||
const toolStep = createDummyToolNode('ep1', 10, 10, {
|
||||
toolName: 'activate_skill',
|
||||
observation: {
|
||||
result:
|
||||
'this is a really long string that normally would get masked but wont because of the tool name',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await processor.process(createMockProcessArgs([toolStep]));
|
||||
|
||||
// Returned the exact same object reference
|
||||
expect(result[0]).toBe(toolStep);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ContextProcessor, ProcessArgs } from '../pipeline.js';
|
||||
import type { ConcreteNode, ToolExecution } from '../ir/types.js';
|
||||
import type { ContextEnvironment } from '../sidecar/environment.js';
|
||||
import { sanitizeFilenamePart } from '../../utils/fileUtils.js';
|
||||
import {
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
MEMORY_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
} from '../../tools/tool-names.js';
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
const UNMASKABLE_TOOLS = new Set([
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
MEMORY_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
]);
|
||||
|
||||
export interface ToolMaskingProcessorOptions {
|
||||
stringLengthThresholdTokens: number;
|
||||
}
|
||||
|
||||
type MaskableValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| MaskableValue[]
|
||||
| { [key: string]: MaskableValue };
|
||||
|
||||
function isMaskableValue(val: unknown): val is MaskableValue {
|
||||
if (
|
||||
val === null ||
|
||||
typeof val === 'string' ||
|
||||
typeof val === 'number' ||
|
||||
typeof val === 'boolean'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(val)) {
|
||||
return val.every(isMaskableValue);
|
||||
}
|
||||
if (typeof val === 'object') {
|
||||
return Object.values(val).every(isMaskableValue);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isMaskableRecord(val: unknown): val is Record<string, MaskableValue> {
|
||||
return (
|
||||
typeof val === 'object' &&
|
||||
val !== null &&
|
||||
!Array.isArray(val) &&
|
||||
isMaskableValue(val)
|
||||
);
|
||||
}
|
||||
|
||||
export class ToolMaskingProcessor implements ContextProcessor {
|
||||
static create(
|
||||
env: ContextEnvironment,
|
||||
options: ToolMaskingProcessorOptions,
|
||||
): ToolMaskingProcessor {
|
||||
return new ToolMaskingProcessor(env, options);
|
||||
}
|
||||
|
||||
static readonly schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
stringLengthThresholdTokens: {
|
||||
type: 'number',
|
||||
description:
|
||||
'The token threshold above which tool intents/observations are masked.',
|
||||
},
|
||||
},
|
||||
required: ['stringLengthThresholdTokens'],
|
||||
};
|
||||
|
||||
readonly componentType = 'processor';
|
||||
readonly id = 'ToolMaskingProcessor';
|
||||
readonly name = 'ToolMaskingProcessor';
|
||||
readonly options: ToolMaskingProcessorOptions;
|
||||
private env: ContextEnvironment;
|
||||
|
||||
constructor(env: ContextEnvironment, options: ToolMaskingProcessorOptions) {
|
||||
this.env = env;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
private isAlreadyMasked(text: string): boolean {
|
||||
return text.includes('<tool_output_masked>');
|
||||
}
|
||||
|
||||
async process({ targets }: ProcessArgs): Promise<readonly ConcreteNode[]> {
|
||||
const maskingConfig = this.options;
|
||||
if (!maskingConfig) return targets;
|
||||
if (targets.length === 0) return targets;
|
||||
|
||||
const limitChars = this.env.tokenCalculator.tokensToChars(
|
||||
maskingConfig.stringLengthThresholdTokens,
|
||||
);
|
||||
|
||||
let toolOutputsDir = this.env.fileSystem.join(
|
||||
this.env.projectTempDir,
|
||||
'tool-outputs',
|
||||
);
|
||||
const sessionId = this.env.sessionId;
|
||||
if (sessionId) {
|
||||
toolOutputsDir = this.env.fileSystem.join(
|
||||
toolOutputsDir,
|
||||
`session-${sanitizeFilenamePart(sessionId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
let directoryCreated = false;
|
||||
|
||||
const handleMasking = async (
|
||||
content: string,
|
||||
toolName: string,
|
||||
callId: string,
|
||||
nodeType: string,
|
||||
): Promise<string> => {
|
||||
if (!directoryCreated) {
|
||||
await this.env.fileSystem.mkdir(toolOutputsDir, { recursive: true });
|
||||
directoryCreated = true;
|
||||
}
|
||||
|
||||
const fileName = `${sanitizeFilenamePart(toolName).toLowerCase()}_${sanitizeFilenamePart(callId).toLowerCase()}_${nodeType}_${this.env.idGenerator.generateId()}.txt`;
|
||||
const filePath = this.env.fileSystem.join(toolOutputsDir, fileName);
|
||||
|
||||
await this.env.fileSystem.writeFile(filePath, content);
|
||||
|
||||
const fileSizeMB = (
|
||||
Buffer.byteLength(content, 'utf8') /
|
||||
1024 /
|
||||
1024
|
||||
).toFixed(2);
|
||||
const totalLines = content.split('\n').length;
|
||||
return `<tool_output_masked>\n[Tool ${nodeType} string (${fileSizeMB}MB, ${totalLines} lines) masked to preserve context window. Full string saved to: ${filePath}]\n</tool_output_masked>`;
|
||||
};
|
||||
|
||||
const returnedNodes: ConcreteNode[] = [];
|
||||
|
||||
for (const node of targets) {
|
||||
switch (node.type) {
|
||||
case 'TOOL_EXECUTION': {
|
||||
const toolName = node.toolName;
|
||||
if (toolName && UNMASKABLE_TOOLS.has(toolName)) {
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
|
||||
const callId = node.id || Date.now().toString();
|
||||
|
||||
const maskAsync = async (
|
||||
obj: MaskableValue,
|
||||
nodeType: string,
|
||||
): Promise<{ masked: MaskableValue; changed: boolean }> => {
|
||||
if (typeof obj === 'string') {
|
||||
if (obj.length > limitChars && !this.isAlreadyMasked(obj)) {
|
||||
const newString = await handleMasking(
|
||||
obj,
|
||||
toolName || 'unknown',
|
||||
callId,
|
||||
nodeType,
|
||||
);
|
||||
return { masked: newString, changed: true };
|
||||
}
|
||||
return { masked: obj, changed: false };
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
let changed = false;
|
||||
const masked: MaskableValue[] = [];
|
||||
for (const item of obj) {
|
||||
const res = await maskAsync(item, nodeType);
|
||||
if (res.changed) changed = true;
|
||||
masked.push(res.masked);
|
||||
}
|
||||
return { masked, changed };
|
||||
}
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
let changed = false;
|
||||
const masked: Record<string, MaskableValue> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const res = await maskAsync(value, nodeType);
|
||||
if (res.changed) changed = true;
|
||||
masked[key] = res.masked;
|
||||
}
|
||||
return { masked, changed };
|
||||
}
|
||||
return { masked: obj, changed: false };
|
||||
};
|
||||
|
||||
const rawIntent = node.intent;
|
||||
const rawObs = node.observation;
|
||||
|
||||
if (!isMaskableRecord(rawIntent) || !isMaskableValue(rawObs)) {
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
|
||||
const intentRes = await maskAsync(rawIntent, 'intent');
|
||||
const obsRes = await maskAsync(rawObs, 'observation');
|
||||
|
||||
if (intentRes.changed || obsRes.changed) {
|
||||
const maskedIntent = isMaskableRecord(intentRes.masked)
|
||||
? (intentRes.masked as Record<string, unknown>)
|
||||
: undefined;
|
||||
// Handle observation explicitly as string vs object
|
||||
const maskedObs =
|
||||
typeof obsRes.masked === 'string'
|
||||
? ({ message: obsRes.masked } as Record<string, unknown>)
|
||||
: isMaskableRecord(obsRes.masked)
|
||||
? (obsRes.masked as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
const newIntentTokens =
|
||||
this.env.tokenCalculator.estimateTokensForParts([
|
||||
{
|
||||
functionCall: {
|
||||
name: toolName || 'unknown',
|
||||
args: maskedIntent,
|
||||
id: callId,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
let obsPart: Record<string, unknown> = {};
|
||||
if (maskedObs) {
|
||||
obsPart = {
|
||||
functionResponse: {
|
||||
name: toolName || 'unknown',
|
||||
response: maskedObs,
|
||||
id: callId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const newObsTokens =
|
||||
this.env.tokenCalculator.estimateTokensForParts([
|
||||
obsPart as Part,
|
||||
]);
|
||||
|
||||
const tokensSaved =
|
||||
this.env.tokenCalculator.getTokenCost(node) -
|
||||
(newIntentTokens + newObsTokens);
|
||||
|
||||
if (tokensSaved > 0) {
|
||||
const maskedNode: ToolExecution = {
|
||||
...node,
|
||||
id: this.env.idGenerator.generateId(), // Modified, so generate new ID
|
||||
intent: maskedIntent ?? node.intent,
|
||||
observation: maskedObs ?? node.observation,
|
||||
tokens: {
|
||||
intent: newIntentTokens,
|
||||
observation: newObsTokens,
|
||||
},
|
||||
};
|
||||
|
||||
returnedNodes.push(maskedNode);
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SidecarRegistry } from './registry.js';
|
||||
import { registerBuiltInProcessors } from './builtins.js';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { SidecarLoader } from './SidecarLoader.js';
|
||||
import { defaultSidecarProfile } from './profiles.js';
|
||||
import { InMemoryFileSystem } from '../system/InMemoryFileSystem.js';
|
||||
import type { Config } from 'src/config/config.js';
|
||||
|
||||
describe('SidecarLoader (Fake FS)', () => {
|
||||
let fileSystem: InMemoryFileSystem;
|
||||
let registry: SidecarRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
fileSystem = new InMemoryFileSystem();
|
||||
registry = new SidecarRegistry();
|
||||
registerBuiltInProcessors(registry);
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
getExperimentalContextSidecarConfig: () => '/path/to/sidecar.json',
|
||||
} as unknown as Config;
|
||||
|
||||
it('returns default profile if file does not exist', () => {
|
||||
const result = SidecarLoader.fromConfig(mockConfig, registry, fileSystem);
|
||||
expect(result).toBe(defaultSidecarProfile);
|
||||
});
|
||||
|
||||
it('returns default profile if file exists but is 0 bytes', () => {
|
||||
fileSystem.setFile('/path/to/sidecar.json', '');
|
||||
const result = SidecarLoader.fromConfig(mockConfig, registry, fileSystem);
|
||||
expect(result).toBe(defaultSidecarProfile);
|
||||
});
|
||||
|
||||
it('throws an error if file is empty whitespace', () => {
|
||||
fileSystem.setFile('/path/to/sidecar.json', ' \n ');
|
||||
expect(() =>
|
||||
SidecarLoader.fromConfig(mockConfig, registry, fileSystem),
|
||||
).toThrow('is empty');
|
||||
});
|
||||
|
||||
it('returns parsed config if file is valid', () => {
|
||||
const validConfig = {
|
||||
budget: { retainedTokens: 1000, maxTokens: 2000 },
|
||||
pipelines: [],
|
||||
};
|
||||
fileSystem.setFile('/path/to/sidecar.json', JSON.stringify(validConfig));
|
||||
const result = SidecarLoader.fromConfig(mockConfig, registry, fileSystem);
|
||||
expect(result.budget.maxTokens).toBe(2000);
|
||||
});
|
||||
|
||||
it('throws validation error if file is invalid', () => {
|
||||
const invalidConfig = {
|
||||
budget: { retainedTokens: 1000 }, // missing maxTokens
|
||||
};
|
||||
fileSystem.setFile('/path/to/sidecar.json', JSON.stringify(invalidConfig));
|
||||
expect(() =>
|
||||
SidecarLoader.fromConfig(mockConfig, registry, fileSystem),
|
||||
).toThrow('Validation error:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { SidecarConfig } from './types.js';
|
||||
import { defaultSidecarProfile } from './profiles.js';
|
||||
import { SchemaValidator } from '../../utils/schemaValidator.js';
|
||||
import { getSidecarConfigSchema } from './schema.js';
|
||||
import type { IFileSystem } from '../system/IFileSystem.js';
|
||||
import { NodeFileSystem } from '../system/NodeFileSystem.js';
|
||||
import type { SidecarRegistry } from './registry.js';
|
||||
|
||||
export class SidecarLoader {
|
||||
/**
|
||||
* Loads and validates a sidecar config from a specific file path.
|
||||
* Throws an error if the file cannot be read, parsed, or fails schema validation.
|
||||
*/
|
||||
static loadFromFile(
|
||||
sidecarPath: string,
|
||||
registry: SidecarRegistry,
|
||||
fileSystem: IFileSystem = new NodeFileSystem(),
|
||||
): SidecarConfig {
|
||||
const fileContent = fileSystem.readFileSync(sidecarPath, 'utf8');
|
||||
|
||||
if (!fileContent.trim()) {
|
||||
throw new Error(`Sidecar configuration file at ${sidecarPath} is empty.`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(fileContent);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse Sidecar configuration file at ${sidecarPath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
const validationError = SchemaValidator.validate(
|
||||
getSidecarConfigSchema(registry),
|
||||
parsed,
|
||||
);
|
||||
if (validationError) {
|
||||
throw new Error(
|
||||
`Invalid sidecar configuration in ${sidecarPath}. Validation error: ${validationError}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Schema has been validated.
|
||||
const isSidecarConfig = (val: unknown): val is SidecarConfig => true;
|
||||
if (isSidecarConfig(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
throw new Error(
|
||||
'Unreachable: schema validation passed but type predicate failed.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a Sidecar JSON graph from the experimental config file path or defaults.
|
||||
* If a config file is present but invalid, this will THROW to prevent silent misconfiguration.
|
||||
*/
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
registry: SidecarRegistry,
|
||||
fileSystem: IFileSystem = new NodeFileSystem(),
|
||||
): SidecarConfig {
|
||||
const sidecarPath = config.getExperimentalContextSidecarConfig();
|
||||
|
||||
if (sidecarPath && fileSystem.existsSync(sidecarPath)) {
|
||||
const size = fileSystem.statSyncSize(sidecarPath);
|
||||
// If the file exists but is completely empty (0 bytes), it's safe to fallback.
|
||||
if (size === 0) {
|
||||
return defaultSidecarProfile;
|
||||
}
|
||||
|
||||
// If the file has content, enforce strict validation and throw on failure.
|
||||
return this.loadFromFile(sidecarPath, registry, fileSystem);
|
||||
}
|
||||
|
||||
return defaultSidecarProfile;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { SidecarRegistry } from './registry.js';
|
||||
import {
|
||||
HistoryTruncationProcessor,
|
||||
type HistoryTruncationProcessorOptions,
|
||||
} from '../processors/historyTruncationProcessor.js';
|
||||
import { BlobDegradationProcessor } from '../processors/blobDegradationProcessor.js';
|
||||
import {
|
||||
NodeTruncationProcessor,
|
||||
type NodeTruncationProcessorOptions,
|
||||
} from '../processors/nodeTruncationProcessor.js';
|
||||
import {
|
||||
NodeDistillationProcessor,
|
||||
type NodeDistillationProcessorOptions,
|
||||
} from '../processors/nodeDistillationProcessor.js';
|
||||
import {
|
||||
ToolMaskingProcessor,
|
||||
type ToolMaskingProcessorOptions,
|
||||
} from '../processors/toolMaskingProcessor.js';
|
||||
import {
|
||||
StateSnapshotProcessor,
|
||||
type StateSnapshotProcessorOptions,
|
||||
} from '../processors/stateSnapshotProcessor.js';
|
||||
import {
|
||||
StateSnapshotWorker,
|
||||
type StateSnapshotWorkerOptions,
|
||||
} from '../processors/stateSnapshotWorker.js';
|
||||
import {
|
||||
RollingSummaryProcessor,
|
||||
type RollingSummaryProcessorOptions,
|
||||
} from '../processors/rollingSummaryProcessor.js';
|
||||
|
||||
export function registerBuiltInProcessors(registry: SidecarRegistry) {
|
||||
registry.registerProcessor<Record<string, never>>({
|
||||
id: 'BlobDegradationProcessor',
|
||||
schema: { type: 'object', properties: {} },
|
||||
create: (env) => new BlobDegradationProcessor(env),
|
||||
});
|
||||
|
||||
registry.registerProcessor<HistoryTruncationProcessorOptions>({
|
||||
id: 'HistoryTruncationProcessor',
|
||||
schema: HistoryTruncationProcessor.schema,
|
||||
create: (env, options) => HistoryTruncationProcessor.create(env, options),
|
||||
});
|
||||
|
||||
registry.registerProcessor<NodeTruncationProcessorOptions>({
|
||||
id: 'NodeTruncationProcessor',
|
||||
schema: NodeTruncationProcessor.schema,
|
||||
create: (env, options) => NodeTruncationProcessor.create(env, options),
|
||||
});
|
||||
|
||||
registry.registerProcessor<NodeDistillationProcessorOptions>({
|
||||
id: 'NodeDistillationProcessor',
|
||||
schema: NodeDistillationProcessor.schema,
|
||||
create: (env, options) => NodeDistillationProcessor.create(env, options),
|
||||
});
|
||||
|
||||
registry.registerProcessor<ToolMaskingProcessorOptions>({
|
||||
id: 'ToolMaskingProcessor',
|
||||
schema: ToolMaskingProcessor.schema,
|
||||
create: (env, options) => ToolMaskingProcessor.create(env, options),
|
||||
});
|
||||
|
||||
registry.registerProcessor<StateSnapshotProcessorOptions>({
|
||||
id: 'StateSnapshotProcessor',
|
||||
schema: StateSnapshotProcessor.schema,
|
||||
create: (env, options) => StateSnapshotProcessor.create(env, options),
|
||||
});
|
||||
|
||||
registry.registerWorker<StateSnapshotWorkerOptions>({
|
||||
id: 'StateSnapshotWorker',
|
||||
schema: StateSnapshotWorker.schema,
|
||||
create: (env, options) => StateSnapshotWorker.create(env, options),
|
||||
});
|
||||
|
||||
registry.registerProcessor<RollingSummaryProcessorOptions>({
|
||||
id: 'RollingSummaryProcessor',
|
||||
schema: RollingSummaryProcessor.schema,
|
||||
create: (env, options) => RollingSummaryProcessor.create(env, options),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ContextWorkingBufferImpl } from './contextWorkingBuffer.js';
|
||||
import { createDummyNode } from '../testing/contextTestUtils.js';
|
||||
|
||||
describe('ContextWorkingBufferImpl', () => {
|
||||
it('should initialize with a pristine graph correctly', () => {
|
||||
const pristine1 = createDummyNode(
|
||||
'ep1',
|
||||
'USER_PROMPT',
|
||||
10,
|
||||
undefined,
|
||||
'p1',
|
||||
);
|
||||
const pristine2 = createDummyNode(
|
||||
'ep1',
|
||||
'AGENT_THOUGHT',
|
||||
10,
|
||||
undefined,
|
||||
'p2',
|
||||
);
|
||||
|
||||
const buffer = ContextWorkingBufferImpl.initialize([pristine1, pristine2]);
|
||||
|
||||
expect(buffer.nodes).toHaveLength(2);
|
||||
expect(buffer.getAuditLog()).toHaveLength(0);
|
||||
|
||||
// Pristine nodes should point to themselves
|
||||
expect(buffer.getPristineNodes('p1')).toEqual([pristine1]);
|
||||
expect(buffer.getPristineNodes('p2')).toEqual([pristine2]);
|
||||
});
|
||||
|
||||
it('should track 1:1 replacements (e.g., masking) and append to audit log', () => {
|
||||
const pristine1 = createDummyNode(
|
||||
'ep1',
|
||||
'USER_PROMPT',
|
||||
10,
|
||||
undefined,
|
||||
'p1',
|
||||
);
|
||||
let buffer = ContextWorkingBufferImpl.initialize([pristine1]);
|
||||
|
||||
const maskedNode = createDummyNode(
|
||||
'ep1',
|
||||
'USER_PROMPT',
|
||||
5,
|
||||
undefined,
|
||||
'm1',
|
||||
);
|
||||
// Simulate what a processor does
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(maskedNode as any).replacesId = 'p1';
|
||||
|
||||
buffer = buffer.applyProcessorResult(
|
||||
'ToolMasking',
|
||||
[pristine1],
|
||||
[maskedNode],
|
||||
);
|
||||
|
||||
expect(buffer.nodes).toHaveLength(1);
|
||||
expect(buffer.nodes[0].id).toBe('m1');
|
||||
|
||||
const log = buffer.getAuditLog();
|
||||
expect(log).toHaveLength(1);
|
||||
expect(log[0].processorId).toBe('ToolMasking');
|
||||
expect(log[0].removedIds).toEqual(['p1']);
|
||||
expect(log[0].addedNodes[0].id).toBe('m1');
|
||||
|
||||
// Provenance lookup: the masked node should resolve back to the pristine root
|
||||
expect(buffer.getPristineNodes('m1')).toEqual([pristine1]);
|
||||
});
|
||||
|
||||
it('should track N:1 abstractions (e.g., rolling summaries)', () => {
|
||||
const p1 = createDummyNode('ep1', 'USER_PROMPT', 10, undefined, 'p1');
|
||||
const p2 = createDummyNode('ep1', 'AGENT_THOUGHT', 10, undefined, 'p2');
|
||||
const p3 = createDummyNode('ep1', 'USER_PROMPT', 10, undefined, 'p3');
|
||||
|
||||
let buffer = ContextWorkingBufferImpl.initialize([p1, p2, p3]);
|
||||
|
||||
const summaryNode = createDummyNode(
|
||||
'ep1',
|
||||
'ROLLING_SUMMARY',
|
||||
15,
|
||||
undefined,
|
||||
's1',
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(summaryNode as any).abstractsIds = ['p1', 'p2'];
|
||||
|
||||
buffer = buffer.applyProcessorResult('Summarizer', [p1, p2], [summaryNode]);
|
||||
|
||||
// p1 and p2 are removed, p3 remains, s1 is added
|
||||
expect(buffer.nodes.map((n) => n.id)).toEqual(['p3', 's1']);
|
||||
|
||||
// Provenance lookup: The summary node should resolve to both p1 and p2!
|
||||
const roots = buffer.getPristineNodes('s1');
|
||||
expect(roots).toHaveLength(2);
|
||||
expect(roots).toContain(p1);
|
||||
expect(roots).toContain(p2);
|
||||
});
|
||||
|
||||
it('should track multi-generation provenance correctly', () => {
|
||||
const p1 = createDummyNode('ep1', 'USER_PROMPT', 10, undefined, 'p1');
|
||||
let buffer = ContextWorkingBufferImpl.initialize([p1]);
|
||||
|
||||
// Gen 1: Masked
|
||||
const gen1 = createDummyNode('ep1', 'USER_PROMPT', 8, undefined, 'gen1');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(gen1 as any).replacesId = 'p1';
|
||||
buffer = buffer.applyProcessorResult('Masking', [p1], [gen1]);
|
||||
|
||||
// Gen 2: Summarized
|
||||
const gen2 = createDummyNode(
|
||||
'ep1',
|
||||
'ROLLING_SUMMARY',
|
||||
5,
|
||||
undefined,
|
||||
'gen2',
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(gen2 as any).abstractsIds = ['gen1'];
|
||||
buffer = buffer.applyProcessorResult('Summarizer', [gen1], [gen2]);
|
||||
|
||||
expect(buffer.nodes).toHaveLength(1);
|
||||
expect(buffer.nodes[0].id).toBe('gen2');
|
||||
|
||||
// Audit log should show sequence
|
||||
const log = buffer.getAuditLog();
|
||||
expect(log).toHaveLength(2);
|
||||
expect(log[0].processorId).toBe('Masking');
|
||||
expect(log[1].processorId).toBe('Summarizer');
|
||||
|
||||
// Multi-gen Provenance lookup: gen2 -> gen1 -> p1
|
||||
expect(buffer.getPristineNodes('gen2')).toEqual([p1]);
|
||||
});
|
||||
|
||||
it('should handle net-new injected nodes without throwing', () => {
|
||||
const p1 = createDummyNode('ep1', 'USER_PROMPT', 10, undefined, 'p1');
|
||||
let buffer = ContextWorkingBufferImpl.initialize([p1]);
|
||||
|
||||
const injected = createDummyNode(
|
||||
'ep1',
|
||||
'SYSTEM_EVENT',
|
||||
5,
|
||||
undefined,
|
||||
'injected1',
|
||||
);
|
||||
// No replacesId or abstractsIds
|
||||
|
||||
buffer = buffer.applyProcessorResult('Injector', [], [injected]);
|
||||
|
||||
expect(buffer.nodes.map((n) => n.id)).toEqual(['p1', 'injected1']);
|
||||
|
||||
// It should root to itself
|
||||
expect(buffer.getPristineNodes('injected1')).toEqual([injected]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ContextWorkingBuffer, GraphMutation } from '../pipeline.js';
|
||||
import type { ConcreteNode } from '../ir/types.js';
|
||||
|
||||
export class ContextWorkingBufferImpl implements ContextWorkingBuffer {
|
||||
// The current active graph
|
||||
readonly nodes: readonly ConcreteNode[];
|
||||
|
||||
// The AOT pre-calculated provenance index (Current ID -> Pristine IDs)
|
||||
private readonly provenanceMap: ReadonlyMap<string, ReadonlySet<string>>;
|
||||
|
||||
// The original immutable pristine nodes mapping
|
||||
private readonly pristineNodesMap: ReadonlyMap<string, ConcreteNode>;
|
||||
|
||||
// The historical linked list of changes
|
||||
private readonly history: readonly GraphMutation[];
|
||||
|
||||
private constructor(
|
||||
nodes: readonly ConcreteNode[],
|
||||
pristineNodesMap: ReadonlyMap<string, ConcreteNode>,
|
||||
provenanceMap: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
history: readonly GraphMutation[],
|
||||
) {
|
||||
this.nodes = nodes;
|
||||
this.pristineNodesMap = pristineNodesMap;
|
||||
this.provenanceMap = provenanceMap;
|
||||
this.history = history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a brand new ContextWorkingBuffer from a pristine graph.
|
||||
* Every node's provenance points to itself.
|
||||
*/
|
||||
static initialize(
|
||||
pristineNodes: readonly ConcreteNode[],
|
||||
): ContextWorkingBufferImpl {
|
||||
const pristineMap = new Map<string, ConcreteNode>();
|
||||
const initialProvenance = new Map<string, ReadonlySet<string>>();
|
||||
|
||||
for (const node of pristineNodes) {
|
||||
pristineMap.set(node.id, node);
|
||||
initialProvenance.set(node.id, new Set([node.id]));
|
||||
}
|
||||
|
||||
return new ContextWorkingBufferImpl(
|
||||
pristineNodes,
|
||||
pristineMap,
|
||||
initialProvenance,
|
||||
[], // Empty history
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends newly observed pristine nodes (e.g. from a user message) to the working buffer.
|
||||
* Ensures they are tracked in the pristine map and point to themselves in provenance.
|
||||
*/
|
||||
appendPristineNodes(
|
||||
newNodes: readonly ConcreteNode[],
|
||||
): ContextWorkingBufferImpl {
|
||||
if (newNodes.length === 0) return this;
|
||||
|
||||
const newPristineMap = new Map<string, ConcreteNode>(this.pristineNodesMap);
|
||||
const newProvenanceMap = new Map(this.provenanceMap);
|
||||
|
||||
for (const node of newNodes) {
|
||||
newPristineMap.set(node.id, node);
|
||||
newProvenanceMap.set(node.id, new Set([node.id]));
|
||||
}
|
||||
|
||||
return new ContextWorkingBufferImpl(
|
||||
[...this.nodes, ...newNodes],
|
||||
newPristineMap,
|
||||
newProvenanceMap,
|
||||
[...this.history],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an entirely new buffer instance by calculating the delta between the processor's input and output.
|
||||
*/
|
||||
applyProcessorResult(
|
||||
processorId: string,
|
||||
inputTargets: readonly ConcreteNode[],
|
||||
outputNodes: readonly ConcreteNode[],
|
||||
): ContextWorkingBufferImpl {
|
||||
const outputIds = new Set(outputNodes.map((n) => n.id));
|
||||
const inputIds = new Set(inputTargets.map((n) => n.id));
|
||||
|
||||
// Calculate diffs
|
||||
const removedIds = inputTargets
|
||||
.filter((n) => !outputIds.has(n.id))
|
||||
.map((n) => n.id);
|
||||
const addedNodes = outputNodes.filter((n) => !inputIds.has(n.id));
|
||||
|
||||
// Create mutation record
|
||||
const mutation: GraphMutation = {
|
||||
processorId,
|
||||
timestamp: Date.now(),
|
||||
removedIds,
|
||||
addedNodes,
|
||||
};
|
||||
|
||||
// Calculate new node array
|
||||
const removedSet = new Set(removedIds);
|
||||
const retainedNodes = this.nodes.filter((n) => !removedSet.has(n.id));
|
||||
const newGraph = [...retainedNodes];
|
||||
|
||||
// We append the output nodes in the same general position if possible,
|
||||
// but in a complex graph we just ensure they exist. V2 graph uses timestamps for order.
|
||||
// For simplicity, we just push added nodes to the end of the retained array
|
||||
newGraph.push(...addedNodes);
|
||||
|
||||
// Calculate new provenance map
|
||||
const newProvenanceMap = new Map(this.provenanceMap);
|
||||
|
||||
let finalPristineMap = this.pristineNodesMap;
|
||||
|
||||
// Map the new synthetic nodes back to their pristine roots
|
||||
for (const added of addedNodes) {
|
||||
const roots = new Set<string>();
|
||||
|
||||
// 1:1 Replacement (e.g. Masked Node)
|
||||
if (added.replacesId) {
|
||||
const inheritedRoots = this.provenanceMap.get(added.replacesId);
|
||||
if (inheritedRoots) {
|
||||
for (const rootId of inheritedRoots) roots.add(rootId);
|
||||
}
|
||||
}
|
||||
|
||||
// N:1 Abstraction (e.g. Rolling Summary)
|
||||
if (added.abstractsIds) {
|
||||
for (const abstractId of added.abstractsIds) {
|
||||
const inheritedRoots = this.provenanceMap.get(abstractId);
|
||||
if (inheritedRoots) {
|
||||
for (const rootId of inheritedRoots) roots.add(rootId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If it has no links back to the original graph, it is its own root
|
||||
// (e.g., a system-injected instruction)
|
||||
if (roots.size === 0) {
|
||||
roots.add(added.id);
|
||||
// It acts as a net-new pristine root.
|
||||
if (!finalPristineMap.has(added.id)) {
|
||||
const mutableMap = new Map<string, ConcreteNode>(finalPristineMap);
|
||||
mutableMap.set(added.id, added);
|
||||
finalPristineMap = mutableMap;
|
||||
}
|
||||
}
|
||||
|
||||
newProvenanceMap.set(added.id, roots);
|
||||
}
|
||||
|
||||
return new ContextWorkingBufferImpl(
|
||||
newGraph,
|
||||
finalPristineMap,
|
||||
newProvenanceMap,
|
||||
[...this.history, mutation],
|
||||
);
|
||||
}
|
||||
|
||||
getPristineNodes(id: string): readonly ConcreteNode[] {
|
||||
const pristineIds = this.provenanceMap.get(id);
|
||||
if (!pristineIds) return [];
|
||||
return Array.from(pristineIds).map(
|
||||
(pid) => this.pristineNodesMap.get(pid)!,
|
||||
);
|
||||
}
|
||||
|
||||
getAuditLog(): readonly GraphMutation[] {
|
||||
return this.history;
|
||||
}
|
||||
|
||||
getLineage(id: string): readonly ConcreteNode[] {
|
||||
const lineage: ConcreteNode[] = [];
|
||||
const currentNodesMap = new Map(this.nodes.map((n) => [n.id, n]));
|
||||
|
||||
let current = currentNodesMap.get(id);
|
||||
while (current) {
|
||||
lineage.push(current);
|
||||
if (current.logicalParentId && current.logicalParentId !== current.id) {
|
||||
current = currentNodesMap.get(current.logicalParentId);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return lineage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
|
||||
import type { ContextEventBus } from '../eventBus.js';
|
||||
import type { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
|
||||
import type { ContextTracer } from '../tracer.js';
|
||||
import type { IFileSystem } from '../system/IFileSystem.js';
|
||||
import type { IIdGenerator } from '../system/IIdGenerator.js';
|
||||
import type { LiveInbox } from './inbox.js';
|
||||
import type { IrNodeBehaviorRegistry } from '../ir/behaviorRegistry.js';
|
||||
import type { IrMapper } from '../ir/mapper.js';
|
||||
|
||||
export type { ContextTracer, ContextEventBus };
|
||||
|
||||
export interface ContextEnvironment {
|
||||
readonly llmClient: BaseLlmClient;
|
||||
readonly promptId: string;
|
||||
readonly sessionId: string;
|
||||
readonly traceDir: string;
|
||||
readonly projectTempDir: string;
|
||||
readonly tracer: ContextTracer;
|
||||
readonly charsPerToken: number;
|
||||
readonly tokenCalculator: ContextTokenCalculator;
|
||||
readonly fileSystem: IFileSystem;
|
||||
readonly idGenerator: IIdGenerator;
|
||||
readonly eventBus: ContextEventBus;
|
||||
readonly inbox: LiveInbox;
|
||||
readonly behaviorRegistry: IrNodeBehaviorRegistry;
|
||||
readonly irMapper: IrMapper;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ContextEnvironmentImpl } from './environmentImpl.js';
|
||||
import { ContextTracer } from '../tracer.js';
|
||||
import { ContextEventBus } from '../eventBus.js';
|
||||
import { InMemoryFileSystem } from '../system/InMemoryFileSystem.js';
|
||||
import { DeterministicIdGenerator } from '../system/DeterministicIdGenerator.js';
|
||||
import { createMockLlmClient } from '../testing/contextTestUtils.js';
|
||||
|
||||
describe('ContextEnvironmentImpl', () => {
|
||||
it('should initialize with defaults correctly', () => {
|
||||
const tracer = new ContextTracer({ targetDir: '/tmp', sessionId: 'mock' });
|
||||
const eventBus = new ContextEventBus();
|
||||
const mockLlmClient = createMockLlmClient();
|
||||
|
||||
const env = new ContextEnvironmentImpl(
|
||||
mockLlmClient,
|
||||
'mock-session',
|
||||
'mock-prompt',
|
||||
'/tmp/trace',
|
||||
'/tmp/temp',
|
||||
tracer,
|
||||
4,
|
||||
eventBus,
|
||||
);
|
||||
|
||||
expect(env.llmClient).toBe(mockLlmClient);
|
||||
expect(env.sessionId).toBe('mock-session');
|
||||
expect(env.promptId).toBe('mock-prompt');
|
||||
expect(env.traceDir).toBe('/tmp/trace');
|
||||
expect(env.projectTempDir).toBe('/tmp/temp');
|
||||
expect(env.tracer).toBe(tracer);
|
||||
expect(env.charsPerToken).toBe(4);
|
||||
expect(env.eventBus).toBe(eventBus);
|
||||
|
||||
// Default internals
|
||||
expect(env.behaviorRegistry).toBeDefined();
|
||||
expect(env.tokenCalculator).toBeDefined();
|
||||
expect(env.fileSystem).toBeDefined();
|
||||
expect(env.idGenerator).toBeDefined();
|
||||
expect(env.inbox).toBeDefined();
|
||||
expect(env.irMapper).toBeDefined();
|
||||
});
|
||||
|
||||
it('should initialize with provided overrides', () => {
|
||||
const tracer = new ContextTracer({ targetDir: '/tmp', sessionId: 'mock' });
|
||||
const eventBus = new ContextEventBus();
|
||||
const mockLlmClient = createMockLlmClient();
|
||||
const fileSystem = new InMemoryFileSystem();
|
||||
const idGenerator = new DeterministicIdGenerator('test-');
|
||||
|
||||
const env = new ContextEnvironmentImpl(
|
||||
mockLlmClient,
|
||||
'mock-session',
|
||||
'mock-prompt',
|
||||
'/tmp/trace',
|
||||
'/tmp/temp',
|
||||
tracer,
|
||||
4,
|
||||
eventBus,
|
||||
fileSystem,
|
||||
idGenerator,
|
||||
);
|
||||
|
||||
expect(env.fileSystem).toBe(fileSystem);
|
||||
expect(env.idGenerator).toBe(idGenerator);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
|
||||
import type { ContextTracer } from '../tracer.js';
|
||||
import type { ContextEnvironment } from './environment.js';
|
||||
import type { ContextEventBus } from '../eventBus.js';
|
||||
import { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
|
||||
import type { IFileSystem } from '../system/IFileSystem.js';
|
||||
import { NodeFileSystem } from '../system/NodeFileSystem.js';
|
||||
import type { IIdGenerator } from '../system/IIdGenerator.js';
|
||||
import { NodeIdGenerator } from '../system/NodeIdGenerator.js';
|
||||
import { LiveInbox } from './inbox.js';
|
||||
import { IrNodeBehaviorRegistry } from '../ir/behaviorRegistry.js';
|
||||
import { registerBuiltInBehaviors } from '../ir/builtinBehaviors.js';
|
||||
import { IrMapper } from '../ir/mapper.js';
|
||||
|
||||
export class ContextEnvironmentImpl implements ContextEnvironment {
|
||||
readonly tokenCalculator: ContextTokenCalculator;
|
||||
readonly fileSystem: IFileSystem;
|
||||
readonly idGenerator: IIdGenerator;
|
||||
readonly inbox: LiveInbox;
|
||||
readonly behaviorRegistry: IrNodeBehaviorRegistry;
|
||||
readonly irMapper: IrMapper;
|
||||
|
||||
constructor(
|
||||
readonly llmClient: BaseLlmClient,
|
||||
readonly sessionId: string,
|
||||
readonly promptId: string,
|
||||
readonly traceDir: string,
|
||||
readonly projectTempDir: string,
|
||||
readonly tracer: ContextTracer,
|
||||
readonly charsPerToken: number,
|
||||
readonly eventBus: ContextEventBus,
|
||||
fileSystem?: IFileSystem,
|
||||
idGenerator?: IIdGenerator,
|
||||
) {
|
||||
this.behaviorRegistry = new IrNodeBehaviorRegistry();
|
||||
registerBuiltInBehaviors(this.behaviorRegistry);
|
||||
this.tokenCalculator = new ContextTokenCalculator(
|
||||
this.charsPerToken,
|
||||
this.behaviorRegistry,
|
||||
);
|
||||
this.fileSystem = fileSystem || new NodeFileSystem();
|
||||
this.idGenerator = idGenerator || new NodeIdGenerator();
|
||||
this.inbox = new LiveInbox();
|
||||
this.irMapper = new IrMapper(this.behaviorRegistry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LiveInbox, InboxSnapshotImpl } from './inbox.js';
|
||||
import { DeterministicIdGenerator } from '../system/DeterministicIdGenerator.js';
|
||||
|
||||
describe('Inbox', () => {
|
||||
it('should publish messages and provide snapshots', () => {
|
||||
const inbox = new LiveInbox();
|
||||
const idGenerator = new DeterministicIdGenerator('mock-uuid-');
|
||||
|
||||
inbox.publish('test-topic', { data: 'hello' }, idGenerator);
|
||||
inbox.publish('other-topic', { data: 'world' }, idGenerator);
|
||||
|
||||
const messages = inbox.getMessages();
|
||||
expect(messages.length).toBe(2);
|
||||
expect(messages[0].topic).toBe('test-topic');
|
||||
expect(messages[0].payload).toEqual({ data: 'hello' });
|
||||
});
|
||||
|
||||
it('should drain consumed messages from the snapshot', () => {
|
||||
const inbox = new LiveInbox();
|
||||
const idGenerator = new DeterministicIdGenerator('mock-uuid-');
|
||||
|
||||
inbox.publish('test-topic', { data: 'hello' }, idGenerator);
|
||||
inbox.publish('other-topic', { data: 'world' }, idGenerator);
|
||||
|
||||
const messages = inbox.getMessages();
|
||||
const snapshot = new InboxSnapshotImpl(messages);
|
||||
|
||||
const filtered = snapshot.getMessages<{ data: string }>('test-topic');
|
||||
expect(filtered.length).toBe(1);
|
||||
expect(filtered[0].payload.data).toBe('hello');
|
||||
|
||||
// Consume the message
|
||||
snapshot.consume(filtered[0].id);
|
||||
|
||||
// Provide the consumed IDs to the real inbox to drain them
|
||||
inbox.drainConsumed(snapshot.getConsumedIds());
|
||||
|
||||
const finalMessages = inbox.getMessages();
|
||||
expect(finalMessages.length).toBe(1);
|
||||
expect(finalMessages[0].topic).toBe('other-topic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { InboxMessage, InboxSnapshot } from '../pipeline.js';
|
||||
|
||||
export class LiveInbox {
|
||||
private messages: InboxMessage[] = [];
|
||||
|
||||
publish<T>(
|
||||
topic: string,
|
||||
payload: T,
|
||||
idGenerator: { generateId(): string },
|
||||
): void {
|
||||
this.messages.push({
|
||||
id: idGenerator.generateId(),
|
||||
topic,
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
getMessages(): readonly InboxMessage[] {
|
||||
return [...this.messages];
|
||||
}
|
||||
|
||||
drainConsumed(consumedIds: Set<string>): void {
|
||||
this.messages = this.messages.filter((m) => !consumedIds.has(m.id));
|
||||
}
|
||||
}
|
||||
|
||||
export class InboxSnapshotImpl implements InboxSnapshot {
|
||||
private messages: readonly InboxMessage[];
|
||||
private consumedIds = new Set<string>();
|
||||
|
||||
constructor(messages: readonly InboxMessage[]) {
|
||||
this.messages = messages;
|
||||
}
|
||||
|
||||
getMessages<T = unknown>(topic: string): ReadonlyArray<InboxMessage<T>> {
|
||||
const raw = this.messages.filter((m) => m.topic === topic);
|
||||
/*
|
||||
* Architectural Justification for Unchecked Cast:
|
||||
* The Inbox is a heterogeneous event bus designed to support arbitrary, declarative
|
||||
* routing via configuration files (where topics are just strings). Because TypeScript
|
||||
* completely erases generic type information (<T>) at runtime, the central array
|
||||
* can only hold `unknown` payloads. To enforce strict type safety without a central
|
||||
* registry (which would break decoupling) or heavy runtime validation (Zod schemas),
|
||||
* we must assert the type boundary here. The contract relies on the Worker and Processor
|
||||
* agreeing on the payload structure associated with the configured topic string.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return raw as ReadonlyArray<InboxMessage<T>>;
|
||||
}
|
||||
|
||||
consume(messageId: string): void {
|
||||
this.consumedIds.add(messageId);
|
||||
}
|
||||
|
||||
getConsumedIds(): Set<string> {
|
||||
return this.consumedIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { PipelineOrchestrator } from './orchestrator.js';
|
||||
import { SidecarRegistry } from './registry.js';
|
||||
import {
|
||||
createMockEnvironment,
|
||||
createDummyNode,
|
||||
} from '../testing/contextTestUtils.js';
|
||||
import type { ContextEnvironment } from './environment.js';
|
||||
import type {
|
||||
ContextProcessor,
|
||||
ContextWorker,
|
||||
InboxSnapshot,
|
||||
ProcessArgs,
|
||||
} from '../pipeline.js';
|
||||
import type { PipelineDef, ProcessorConfig, SidecarConfig } from './types.js';
|
||||
import type { ContextEventBus } from '../eventBus.js';
|
||||
import type { ConcreteNode, UserPrompt } from '../ir/types.js';
|
||||
|
||||
// A realistic mock processor that modifies the text of the first target node
|
||||
class ModifyingProcessor implements ContextProcessor {
|
||||
static create() {
|
||||
return new ModifyingProcessor();
|
||||
}
|
||||
constructor() {}
|
||||
readonly name = 'ModifyingProcessor';
|
||||
readonly id = 'ModifyingProcessor';
|
||||
readonly options = {};
|
||||
async process(args: ProcessArgs) {
|
||||
const newTargets = [...args.targets];
|
||||
if (newTargets.length > 0 && newTargets[0].type === 'USER_PROMPT') {
|
||||
const prompt = newTargets[0];
|
||||
const newParts = [...prompt.semanticParts];
|
||||
if (newParts.length > 0 && newParts[0].type === 'text') {
|
||||
newParts[0] = {
|
||||
...newParts[0],
|
||||
text: newParts[0].text + ' [modified]',
|
||||
};
|
||||
}
|
||||
newTargets[0] = {
|
||||
...prompt,
|
||||
id: prompt.id + '-modified',
|
||||
replacesId: prompt.id,
|
||||
semanticParts: newParts,
|
||||
};
|
||||
}
|
||||
return newTargets;
|
||||
}
|
||||
}
|
||||
|
||||
// A processor that just throws an error
|
||||
class ThrowingProcessor implements ContextProcessor {
|
||||
static create() {
|
||||
return new ThrowingProcessor();
|
||||
}
|
||||
constructor() {}
|
||||
readonly name = 'Throwing';
|
||||
readonly id = 'Throwing';
|
||||
readonly options = {};
|
||||
async process(): Promise<readonly ConcreteNode[]> {
|
||||
throw new Error('Processor failed intentionally');
|
||||
}
|
||||
}
|
||||
|
||||
// A mock worker that signals it ran
|
||||
class MockWorker implements ContextWorker {
|
||||
static create() {
|
||||
return new MockWorker();
|
||||
}
|
||||
constructor() {}
|
||||
readonly name = 'MockWorker';
|
||||
readonly id = 'MockWorker';
|
||||
readonly triggers = {
|
||||
onNodesAdded: true,
|
||||
};
|
||||
wasExecuted = false;
|
||||
|
||||
async execute(args: {
|
||||
targets: readonly ConcreteNode[];
|
||||
inbox: InboxSnapshot;
|
||||
}) {
|
||||
this.wasExecuted = true;
|
||||
if (args.targets.length > 0 && args.targets[0].type === 'USER_PROMPT') {
|
||||
const prompt = args.targets[0];
|
||||
if (prompt.semanticParts[0].type === 'text') {
|
||||
args.inbox.consume('test');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('PipelineOrchestrator (Component)', () => {
|
||||
let env: ContextEnvironment;
|
||||
let eventBus: ContextEventBus;
|
||||
let registry: SidecarRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
env = createMockEnvironment();
|
||||
eventBus = env.eventBus;
|
||||
registry = new SidecarRegistry();
|
||||
|
||||
registry.registerProcessor({
|
||||
id: 'ModifyingProcessor',
|
||||
schema: {},
|
||||
create: () => new ModifyingProcessor(),
|
||||
});
|
||||
registry.registerProcessor({
|
||||
id: 'ThrowingProcessor',
|
||||
schema: {},
|
||||
create: () => new ThrowingProcessor(),
|
||||
});
|
||||
registry.registerWorker({
|
||||
id: 'MockWorker',
|
||||
schema: {},
|
||||
create: () => new MockWorker(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
registry.clear();
|
||||
});
|
||||
|
||||
const createConfig = (
|
||||
pipelines: PipelineDef[],
|
||||
workers?: Array<{ workerId: string }>,
|
||||
): SidecarConfig => ({
|
||||
budget: { maxTokens: 100, retainedTokens: 50 },
|
||||
pipelines,
|
||||
workers,
|
||||
});
|
||||
|
||||
it('instantiates processors and workers from the registry on initialization', () => {
|
||||
const config = createConfig(
|
||||
[
|
||||
{
|
||||
name: 'SyncPipe',
|
||||
triggers: ['new_message'],
|
||||
processors: [
|
||||
{ processorId: 'ModifyingProcessor' } as unknown as ProcessorConfig,
|
||||
],
|
||||
},
|
||||
],
|
||||
[{ workerId: 'MockWorker' }],
|
||||
);
|
||||
|
||||
const orchestrator = new PipelineOrchestrator(
|
||||
config,
|
||||
env,
|
||||
eventBus,
|
||||
env.tracer,
|
||||
registry,
|
||||
);
|
||||
|
||||
expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(orchestrator as any).instantiatedProcessors.has('ModifyingProcessor'),
|
||||
).toBe(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((orchestrator as any).instantiatedWorkers.has('MockWorker')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error if a config requests an unknown processor', () => {
|
||||
const config = createConfig([
|
||||
{
|
||||
name: 'ThrowPipe',
|
||||
triggers: ['new_message'],
|
||||
processors: [
|
||||
{ processorId: 'DoesNotExist' } as unknown as ProcessorConfig,
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new PipelineOrchestrator(config, env, eventBus, env.tracer, registry),
|
||||
).toThrow('Context Processor [DoesNotExist] is not registered.');
|
||||
});
|
||||
|
||||
it('executes synchronous routes (executeTriggerSync) and returns modified array', async () => {
|
||||
const config = createConfig([
|
||||
{
|
||||
name: 'SyncPipe',
|
||||
triggers: ['new_message'],
|
||||
processors: [
|
||||
{ processorId: 'ModifyingProcessor' } as unknown as ProcessorConfig,
|
||||
],
|
||||
},
|
||||
]);
|
||||
const orchestrator = new PipelineOrchestrator(
|
||||
config,
|
||||
env,
|
||||
eventBus,
|
||||
env.tracer,
|
||||
registry,
|
||||
);
|
||||
|
||||
const nodes = [
|
||||
createDummyNode(
|
||||
'not-protected-ep',
|
||||
'USER_PROMPT',
|
||||
100,
|
||||
{
|
||||
semanticParts: [{ type: 'text', text: 'original text' }],
|
||||
},
|
||||
'not-protected-id',
|
||||
),
|
||||
];
|
||||
|
||||
const result = await orchestrator.executeTriggerSync(
|
||||
'new_message',
|
||||
nodes,
|
||||
new Set(nodes.map((s) => s.id)),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const modifiedPrompt = result[0] as UserPrompt;
|
||||
assert(
|
||||
modifiedPrompt.semanticParts[0].type === 'text',
|
||||
'Expected a text part',
|
||||
);
|
||||
expect(modifiedPrompt.semanticParts[0].text).toBe(
|
||||
'original text [modified]',
|
||||
);
|
||||
});
|
||||
|
||||
it('gracefully handles and swallows processor errors in synchronous routes', async () => {
|
||||
const config = createConfig([
|
||||
{
|
||||
name: 'ThrowPipe',
|
||||
triggers: ['new_message'],
|
||||
processors: [
|
||||
{ processorId: 'ThrowingProcessor' } as unknown as ProcessorConfig,
|
||||
],
|
||||
},
|
||||
]);
|
||||
const orchestrator = new PipelineOrchestrator(
|
||||
config,
|
||||
env,
|
||||
eventBus,
|
||||
env.tracer,
|
||||
registry,
|
||||
);
|
||||
|
||||
const nodes = [
|
||||
createDummyNode(
|
||||
'not-protected-ep',
|
||||
'USER_PROMPT',
|
||||
100,
|
||||
undefined,
|
||||
'not-protected-id',
|
||||
),
|
||||
];
|
||||
|
||||
// It should not throw! It should swallow the error and return the unmodified array.
|
||||
const result = await orchestrator.executeTriggerSync(
|
||||
'new_message',
|
||||
nodes,
|
||||
new Set(nodes.map((s) => s.id)),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result).toStrictEqual(nodes);
|
||||
});
|
||||
|
||||
it('automatically dispatches workers when matching EventBus events occur', async () => {
|
||||
const config = createConfig([], [{ workerId: 'MockWorker' }]);
|
||||
|
||||
const orchestrator = new PipelineOrchestrator(
|
||||
config,
|
||||
env,
|
||||
eventBus,
|
||||
env.tracer,
|
||||
registry,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const workerInstance = (orchestrator as any).instantiatedWorkers.get(
|
||||
'MockWorker',
|
||||
) as MockWorker;
|
||||
expect(workerInstance.wasExecuted).toBe(false);
|
||||
|
||||
const nodes = [
|
||||
createDummyNode(
|
||||
'not-protected-ep',
|
||||
'USER_PROMPT',
|
||||
100,
|
||||
{
|
||||
semanticParts: [{ type: 'text', text: 'worker trigger text' }],
|
||||
},
|
||||
'not-protected-id',
|
||||
),
|
||||
];
|
||||
|
||||
// Emit the new_message chunk which maps to onNodesAdded for workers
|
||||
eventBus.emitChunkReceived({
|
||||
nodes,
|
||||
targetNodeIds: new Set(nodes.map((n) => n.id)),
|
||||
});
|
||||
|
||||
// Worker execute is fire and forget, so we yield to the event loop
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(workerInstance.wasExecuted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ConcreteNode } from '../ir/types.js';
|
||||
import type { ContextProcessor, ContextWorker } from '../pipeline.js';
|
||||
import type { SidecarConfig, PipelineDef, PipelineTrigger } from './types.js';
|
||||
import type {
|
||||
ContextEnvironment,
|
||||
ContextEventBus,
|
||||
ContextTracer,
|
||||
} from './environment.js';
|
||||
import type { SidecarRegistry } from './registry.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { InboxSnapshotImpl } from './inbox.js';
|
||||
import { ContextWorkingBufferImpl } from './contextWorkingBuffer.js';
|
||||
|
||||
export class PipelineOrchestrator {
|
||||
private activeTimers: NodeJS.Timeout[] = [];
|
||||
private readonly instantiatedProcessors = new Map<string, ContextProcessor>();
|
||||
private readonly instantiatedWorkers = new Map<string, ContextWorker>();
|
||||
|
||||
constructor(
|
||||
private readonly config: SidecarConfig,
|
||||
private readonly env: ContextEnvironment,
|
||||
private readonly eventBus: ContextEventBus,
|
||||
private readonly tracer: ContextTracer,
|
||||
private readonly registry: SidecarRegistry,
|
||||
) {
|
||||
this.instantiateProcessors();
|
||||
this.instantiateWorkers();
|
||||
this.setupTriggers();
|
||||
}
|
||||
|
||||
private isNodeAllowed(
|
||||
node: ConcreteNode,
|
||||
triggerTargets: ReadonlySet<string>,
|
||||
protectedLogicalIds: ReadonlySet<string> = new Set(),
|
||||
): boolean {
|
||||
return (
|
||||
triggerTargets.has(node.id) &&
|
||||
!protectedLogicalIds.has(node.id) &&
|
||||
(!node.logicalParentId || !protectedLogicalIds.has(node.logicalParentId))
|
||||
);
|
||||
}
|
||||
|
||||
private instantiateProcessors() {
|
||||
for (const pipeline of this.config.pipelines) {
|
||||
for (const procDef of pipeline.processors) {
|
||||
if (!this.instantiatedProcessors.has(procDef.processorId)) {
|
||||
const factory = this.registry.getProcessor(procDef.processorId);
|
||||
const instance = factory.create(this.env, procDef.options || {});
|
||||
this.instantiatedProcessors.set(procDef.processorId, instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private instantiateWorkers() {
|
||||
if (!this.config.workers) return;
|
||||
for (const workerDef of this.config.workers) {
|
||||
if (!this.instantiatedWorkers.has(workerDef.workerId)) {
|
||||
const factory = this.registry.getWorker(workerDef.workerId);
|
||||
const instance = factory.create(this.env, workerDef.options || {});
|
||||
this.instantiatedWorkers.set(workerDef.workerId, instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setupTriggers() {
|
||||
// 1. Pipeline Triggers
|
||||
for (const pipeline of this.config.pipelines) {
|
||||
for (const trigger of pipeline.triggers) {
|
||||
if (typeof trigger === 'object' && trigger.type === 'timer') {
|
||||
const timer = setInterval(() => {
|
||||
// Background timers not fully implemented in V1 yet
|
||||
}, trigger.intervalMs);
|
||||
this.activeTimers.push(timer);
|
||||
} else if (trigger === 'retained_exceeded') {
|
||||
this.eventBus.onConsolidationNeeded((event) => {
|
||||
void this.executePipelineAsync(
|
||||
pipeline,
|
||||
event.nodes,
|
||||
event.targetNodeIds,
|
||||
new Set(), // protected IDs
|
||||
);
|
||||
});
|
||||
} else if (trigger === 'new_message') {
|
||||
this.eventBus.onChunkReceived((event) => {
|
||||
void this.executePipelineAsync(
|
||||
pipeline,
|
||||
event.nodes,
|
||||
event.targetNodeIds,
|
||||
new Set(), // protected IDs
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Worker Triggers (onNodesAdded / onNodesAgedOut)
|
||||
this.eventBus.onChunkReceived((event) => {
|
||||
// Fire all workers that care about new nodes
|
||||
for (const worker of this.instantiatedWorkers.values()) {
|
||||
if (worker.triggers.onNodesAdded) {
|
||||
const inboxSnapshot = new InboxSnapshotImpl(
|
||||
this.env.inbox.getMessages() || [],
|
||||
);
|
||||
const targets = event.nodes.filter((n) =>
|
||||
event.targetNodeIds.has(n.id),
|
||||
);
|
||||
// Fire and forget
|
||||
worker.execute({ targets, inbox: inboxSnapshot }).catch((e) => {
|
||||
debugLogger.error(`Worker ${worker.name} failed onNodesAdded:`, e);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.eventBus.onConsolidationNeeded((event) => {
|
||||
// Fire all workers that care about aged out nodes
|
||||
for (const worker of this.instantiatedWorkers.values()) {
|
||||
if (worker.triggers.onNodesAgedOut) {
|
||||
const inboxSnapshot = new InboxSnapshotImpl(
|
||||
this.env.inbox.getMessages() || [],
|
||||
);
|
||||
const targets = event.nodes.filter((n) =>
|
||||
event.targetNodeIds.has(n.id),
|
||||
);
|
||||
// Fire and forget
|
||||
worker.execute({ targets, inbox: inboxSnapshot }).catch((e) => {
|
||||
debugLogger.error(
|
||||
`Worker ${worker.name} failed onNodesAgedOut:`,
|
||||
e,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// We don't have a formal event bus for inbox publish yet, but we will soon.
|
||||
// For now the workers are just registered.
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
for (const timer of this.activeTimers) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async executeTriggerSync(
|
||||
trigger: PipelineTrigger,
|
||||
nodes: readonly ConcreteNode[],
|
||||
triggerTargets: ReadonlySet<string>,
|
||||
protectedLogicalIds: ReadonlySet<string> = new Set(),
|
||||
): Promise<readonly ConcreteNode[]> {
|
||||
let currentBuffer = ContextWorkingBufferImpl.initialize(nodes);
|
||||
const pipelines = this.config.pipelines.filter((p) =>
|
||||
p.triggers.includes(trigger),
|
||||
);
|
||||
|
||||
// Freeze the inbox for this pipeline run
|
||||
const inboxSnapshot = new InboxSnapshotImpl(
|
||||
this.env.inbox.getMessages() || [],
|
||||
);
|
||||
|
||||
for (const pipeline of pipelines) {
|
||||
for (const procDef of pipeline.processors) {
|
||||
const processor = this.instantiatedProcessors.get(procDef.processorId);
|
||||
if (!processor) continue;
|
||||
|
||||
try {
|
||||
this.tracer.logEvent(
|
||||
'Orchestrator',
|
||||
`Executing processor synchronously: ${procDef.processorId}`,
|
||||
);
|
||||
|
||||
const allowedTargets = currentBuffer.nodes.filter((n) =>
|
||||
this.isNodeAllowed(n, triggerTargets, protectedLogicalIds),
|
||||
);
|
||||
|
||||
const returnedNodes = await processor.process({
|
||||
buffer: currentBuffer,
|
||||
targets: allowedTargets,
|
||||
inbox: inboxSnapshot,
|
||||
});
|
||||
|
||||
currentBuffer = currentBuffer.applyProcessorResult(
|
||||
processor.id,
|
||||
allowedTargets,
|
||||
returnedNodes,
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Synchronous processor ${procDef.processorId} failed:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Success! Drain consumed messages
|
||||
this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds());
|
||||
|
||||
return currentBuffer.nodes;
|
||||
}
|
||||
|
||||
private async executePipelineAsync(
|
||||
pipeline: PipelineDef,
|
||||
nodes: readonly ConcreteNode[],
|
||||
triggerTargets: Set<string>,
|
||||
protectedLogicalIds: ReadonlySet<string> = new Set(),
|
||||
) {
|
||||
this.tracer.logEvent(
|
||||
'Orchestrator',
|
||||
`Triggering async pipeline: ${pipeline.name}`,
|
||||
);
|
||||
if (!nodes || nodes.length === 0) return;
|
||||
|
||||
let currentBuffer = ContextWorkingBufferImpl.initialize(nodes);
|
||||
const inboxSnapshot = new InboxSnapshotImpl(
|
||||
this.env.inbox.getMessages() || [],
|
||||
);
|
||||
|
||||
for (const procDef of pipeline.processors) {
|
||||
const processor = this.instantiatedProcessors.get(procDef.processorId);
|
||||
if (!processor) continue;
|
||||
|
||||
try {
|
||||
this.tracer.logEvent(
|
||||
'Orchestrator',
|
||||
`Executing processor: ${procDef.processorId} (async)`,
|
||||
);
|
||||
|
||||
const allowedTargets = currentBuffer.nodes.filter((n) =>
|
||||
this.isNodeAllowed(n, triggerTargets, protectedLogicalIds),
|
||||
);
|
||||
|
||||
const returnedNodes = await processor.process({
|
||||
buffer: currentBuffer,
|
||||
targets: allowedTargets,
|
||||
inbox: inboxSnapshot,
|
||||
});
|
||||
|
||||
currentBuffer = currentBuffer.applyProcessorResult(
|
||||
processor.id,
|
||||
allowedTargets,
|
||||
returnedNodes,
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Pipeline ${pipeline.name} failed async at ${procDef.processorId}:`,
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SidecarConfig } from './types.js';
|
||||
|
||||
/**
|
||||
* The standard default context management profile.
|
||||
* Optimized for safety, precision, and reliable summarization.
|
||||
*/
|
||||
export const defaultSidecarProfile: SidecarConfig = {
|
||||
budget: {
|
||||
retainedTokens: 65000,
|
||||
maxTokens: 150000,
|
||||
},
|
||||
workers: [
|
||||
{
|
||||
workerId: 'StateSnapshotWorker',
|
||||
options: {
|
||||
type: 'accumulate',
|
||||
},
|
||||
},
|
||||
],
|
||||
pipelines: [
|
||||
{
|
||||
name: 'Immediate Sanitization',
|
||||
triggers: ['new_message'],
|
||||
processors: [
|
||||
{
|
||||
processorId: 'ToolMaskingProcessor',
|
||||
options: { stringLengthThresholdTokens: 8000 },
|
||||
},
|
||||
{ processorId: 'BlobDegradationProcessor', options: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Normalization',
|
||||
triggers: ['retained_exceeded'],
|
||||
processors: [
|
||||
{
|
||||
processorId: 'NodeTruncationProcessor',
|
||||
options: { maxTokensPerNode: 3000 },
|
||||
},
|
||||
{
|
||||
processorId: 'NodeDistillationProcessor',
|
||||
options: { nodeThresholdTokens: 5000 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Emergency Backstop',
|
||||
triggers: ['gc_backstop'],
|
||||
processors: [
|
||||
{
|
||||
processorId: 'StateSnapshotProcessor',
|
||||
options: { target: 'max' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SidecarRegistry } from './registry.js';
|
||||
import type { ContextProcessorDef, ContextWorkerDef } from './registry.js';
|
||||
import type { ContextProcessor, ContextWorker } from '../pipeline.js';
|
||||
|
||||
describe('SidecarRegistry', () => {
|
||||
it('should register and retrieve processors correctly', () => {
|
||||
const registry = new SidecarRegistry();
|
||||
const processorDef: ContextProcessorDef = {
|
||||
id: 'TestProcessor',
|
||||
schema: { type: 'object' },
|
||||
create: () => ({}) as ContextProcessor,
|
||||
};
|
||||
|
||||
registry.registerProcessor(processorDef);
|
||||
const retrieved = registry.getProcessor('TestProcessor');
|
||||
expect(retrieved).toBe(processorDef);
|
||||
});
|
||||
|
||||
it('should register and retrieve workers correctly', () => {
|
||||
const registry = new SidecarRegistry();
|
||||
const workerDef: ContextWorkerDef = {
|
||||
id: 'TestWorker',
|
||||
schema: { type: 'object' },
|
||||
create: () => ({}) as ContextWorker,
|
||||
};
|
||||
|
||||
registry.registerWorker(workerDef);
|
||||
const retrieved = registry.getWorker('TestWorker');
|
||||
expect(retrieved).toBe(workerDef);
|
||||
});
|
||||
|
||||
it('should throw an error when retrieving unregistered processors', () => {
|
||||
const registry = new SidecarRegistry();
|
||||
expect(() => registry.getProcessor('Unknown')).toThrow(
|
||||
'Context Processor [Unknown] is not registered.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error when retrieving unregistered workers', () => {
|
||||
const registry = new SidecarRegistry();
|
||||
expect(() => registry.getWorker('Unknown')).toThrow(
|
||||
'Context Worker [Unknown] is not registered.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return combined schemas', () => {
|
||||
const registry = new SidecarRegistry();
|
||||
registry.registerProcessor({
|
||||
id: 'TestProcessor',
|
||||
schema: { title: 'processorSchema' },
|
||||
create: () => ({}) as ContextProcessor,
|
||||
});
|
||||
registry.registerWorker({
|
||||
id: 'TestWorker',
|
||||
schema: { title: 'workerSchema' },
|
||||
create: () => ({}) as ContextWorker,
|
||||
});
|
||||
|
||||
const schemas = registry.getSchemas() as Array<{ title?: string }>;
|
||||
expect(schemas.length).toBe(2);
|
||||
expect(schemas.find((s) => s.title === 'processorSchema')).toBeDefined();
|
||||
expect(schemas.find((s) => s.title === 'workerSchema')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should safely clear the registry', () => {
|
||||
const registry = new SidecarRegistry();
|
||||
registry.registerProcessor({
|
||||
id: 'TestProcessor',
|
||||
schema: {},
|
||||
create: () => ({}) as ContextProcessor,
|
||||
});
|
||||
registry.registerWorker({
|
||||
id: 'TestWorker',
|
||||
schema: {},
|
||||
create: () => ({}) as ContextWorker,
|
||||
});
|
||||
|
||||
registry.clear();
|
||||
|
||||
expect(() => registry.getProcessor('TestProcessor')).toThrow();
|
||||
expect(() => registry.getWorker('TestWorker')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ContextProcessor, ContextWorker } from '../pipeline.js';
|
||||
import type { ContextEnvironment } from './environment.js';
|
||||
|
||||
export interface ContextProcessorDef<TOptions = object> {
|
||||
readonly id: string;
|
||||
readonly schema: object;
|
||||
create(env: ContextEnvironment, options: TOptions): ContextProcessor;
|
||||
}
|
||||
|
||||
export interface ContextWorkerDef<TOptions = object> {
|
||||
readonly id: string;
|
||||
readonly schema: object;
|
||||
create(env: ContextEnvironment, options: TOptions): ContextWorker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry for mapping declarative sidecar configs to running components.
|
||||
*/
|
||||
export class SidecarRegistry {
|
||||
private processors = new Map<string, ContextProcessorDef<unknown>>();
|
||||
private workers = new Map<string, ContextWorkerDef<unknown>>();
|
||||
|
||||
registerProcessor<TOptions>(def: ContextProcessorDef<TOptions>) {
|
||||
this.processors.set(def.id, def);
|
||||
}
|
||||
|
||||
registerWorker<TOptions>(def: ContextWorkerDef<TOptions>) {
|
||||
this.workers.set(def.id, def);
|
||||
}
|
||||
|
||||
getProcessor(id: string): ContextProcessorDef {
|
||||
const def = this.processors.get(id);
|
||||
if (!def) {
|
||||
throw new Error(`Context Processor [${id}] is not registered.`);
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
getWorker(id: string): ContextWorkerDef {
|
||||
const def = this.workers.get(id);
|
||||
if (!def) {
|
||||
throw new Error(`Context Worker [${id}] is not registered.`);
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
getSchemas(): object[] {
|
||||
const schemas: object[] = [];
|
||||
for (const def of this.processors.values()) {
|
||||
if (def.schema) schemas.push(def.schema);
|
||||
}
|
||||
for (const def of this.workers.values()) {
|
||||
if (def.schema) schemas.push(def.schema);
|
||||
}
|
||||
return schemas;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.processors.clear();
|
||||
this.workers.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SidecarRegistry } from './registry.js';
|
||||
import './builtins.js';
|
||||
|
||||
export function getSidecarConfigSchema(registry: SidecarRegistry) {
|
||||
return {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
title: 'SidecarConfig',
|
||||
description: 'The Data-Driven Schema for the Context Manager.',
|
||||
type: 'object',
|
||||
required: ['budget', 'pipelines'],
|
||||
properties: {
|
||||
budget: {
|
||||
type: 'object',
|
||||
description: 'Defines the token ceilings and limits for the pipeline.',
|
||||
required: ['retainedTokens', 'maxTokens'],
|
||||
properties: {
|
||||
retainedTokens: {
|
||||
type: 'number',
|
||||
description:
|
||||
'The ideal token count the pipeline tries to shrink down to.',
|
||||
},
|
||||
maxTokens: {
|
||||
type: 'number',
|
||||
description:
|
||||
'The absolute maximum token count allowed before synchronous truncation kicks in.',
|
||||
},
|
||||
},
|
||||
},
|
||||
workers: {
|
||||
type: 'array',
|
||||
description: 'Background workers that proactively accumulate context.',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['workerId'],
|
||||
properties: {
|
||||
workerId: { type: 'string' },
|
||||
options: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
pipelines: {
|
||||
type: 'array',
|
||||
description: 'The execution graphs for context manipulation.',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['name', 'triggers', 'execution', 'processors'],
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
triggers: {
|
||||
type: 'array',
|
||||
items: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string',
|
||||
enum: ['new_message', 'retained_exceeded', 'gc_backstop'],
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
required: ['type', 'intervalMs'],
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
const: 'timer',
|
||||
},
|
||||
intervalMs: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
processors: {
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: registry.getSchemas(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Definition of a processor or worker to be instantiated in the graph.
|
||||
*/
|
||||
export type ProcessorConfig =
|
||||
| {
|
||||
processorId: 'ToolMaskingProcessor';
|
||||
options: { stringLengthThresholdTokens: number };
|
||||
}
|
||||
| { processorId: 'BlobDegradationProcessor'; options?: object }
|
||||
| {
|
||||
processorId: 'NodeDistillationProcessor';
|
||||
options: { nodeThresholdTokens: number };
|
||||
}
|
||||
| {
|
||||
processorId: 'NodeTruncationProcessor';
|
||||
options: { maxTokensPerNode: number };
|
||||
}
|
||||
| {
|
||||
processorId: 'StateSnapshotProcessor';
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
processorId: 'HistoryTruncationProcessor';
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface WorkerConfig {
|
||||
workerId: string;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type PipelineTrigger =
|
||||
| 'new_message'
|
||||
| 'retained_exceeded'
|
||||
| 'gc_backstop'
|
||||
| { type: 'timer'; intervalMs: number };
|
||||
|
||||
export interface PipelineDef {
|
||||
name: string;
|
||||
triggers: PipelineTrigger[];
|
||||
processors: ProcessorConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The Data-Driven Schema for the Context Manager.
|
||||
*/
|
||||
export interface SidecarConfig {
|
||||
/** Defines the token ceilings and limits for the pipeline. */
|
||||
budget: {
|
||||
retainedTokens: number;
|
||||
maxTokens: number;
|
||||
};
|
||||
|
||||
/** The execution graphs for context manipulation */
|
||||
pipelines: PipelineDef[];
|
||||
|
||||
/** Background actors that generate data for pipelines */
|
||||
workers?: WorkerConfig[];
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ContextManager } from '../contextManager.js';
|
||||
import { AgentChatHistory } from '../../core/agentChatHistory.js';
|
||||
import type { Content } from '@google/genai';
|
||||
import type { SidecarConfig } from '../sidecar/types.js';
|
||||
import { ContextEnvironmentImpl } from '../sidecar/environmentImpl.js';
|
||||
import { ContextTracer } from '../tracer.js';
|
||||
import { ContextEventBus } from '../eventBus.js';
|
||||
import { PipelineOrchestrator } from '../sidecar/orchestrator.js';
|
||||
import { registerBuiltInProcessors } from '../sidecar/builtins.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { SidecarRegistry } from '../sidecar/registry.js';
|
||||
import { DeterministicIdGenerator } from '../system/DeterministicIdGenerator.js';
|
||||
import { InMemoryFileSystem } from '../system/InMemoryFileSystem.js';
|
||||
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
|
||||
|
||||
export interface TurnSummary {
|
||||
turnIndex: number;
|
||||
tokensBeforeBackground: number;
|
||||
tokensAfterBackground: number;
|
||||
}
|
||||
|
||||
export class SimulationHarness {
|
||||
readonly chatHistory: AgentChatHistory;
|
||||
contextManager!: ContextManager;
|
||||
env!: ContextEnvironmentImpl;
|
||||
orchestrator!: PipelineOrchestrator;
|
||||
readonly eventBus: ContextEventBus;
|
||||
config!: SidecarConfig;
|
||||
private tracer!: ContextTracer;
|
||||
private currentTurnIndex = 0;
|
||||
private tokenTrajectory: TurnSummary[] = [];
|
||||
|
||||
static async create(
|
||||
config: SidecarConfig,
|
||||
mockLlmClient: BaseLlmClient,
|
||||
mockTempDir = '/tmp/sim',
|
||||
): Promise<SimulationHarness> {
|
||||
const harness = new SimulationHarness();
|
||||
await harness.init(config, mockLlmClient, mockTempDir);
|
||||
return harness;
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
this.chatHistory = new AgentChatHistory();
|
||||
this.eventBus = new ContextEventBus();
|
||||
}
|
||||
|
||||
private async init(
|
||||
config: SidecarConfig,
|
||||
mockLlmClient: BaseLlmClient,
|
||||
mockTempDir: string,
|
||||
) {
|
||||
this.config = config;
|
||||
const registry = new SidecarRegistry();
|
||||
// Register all standard processors
|
||||
registerBuiltInProcessors(registry);
|
||||
|
||||
this.tracer = new ContextTracer({
|
||||
targetDir: mockTempDir,
|
||||
sessionId: 'sim-session',
|
||||
});
|
||||
this.env = new ContextEnvironmentImpl(
|
||||
mockLlmClient,
|
||||
'sim-prompt',
|
||||
'sim-session',
|
||||
mockTempDir,
|
||||
mockTempDir,
|
||||
this.tracer,
|
||||
1, // 1 char per token average
|
||||
this.eventBus,
|
||||
new InMemoryFileSystem(),
|
||||
new DeterministicIdGenerator(),
|
||||
);
|
||||
|
||||
this.orchestrator = new PipelineOrchestrator(
|
||||
config,
|
||||
this.env,
|
||||
this.eventBus,
|
||||
this.tracer,
|
||||
registry,
|
||||
);
|
||||
this.contextManager = new ContextManager(
|
||||
config,
|
||||
this.env,
|
||||
this.tracer,
|
||||
this.orchestrator,
|
||||
this.chatHistory,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates a single "Turn" (User input + Model/Tool outputs)
|
||||
* A turn might consist of multiple Content messages (e.g. user prompt -> model call -> user response -> model answer)
|
||||
*/
|
||||
async simulateTurn(messages: Content[]) {
|
||||
// 1. Append the new messages
|
||||
const currentHistory = this.chatHistory.get();
|
||||
this.chatHistory.set([...currentHistory, ...messages]);
|
||||
|
||||
// 2. Measure tokens immediately after append (Before background processing)
|
||||
const tokensBefore = this.env.tokenCalculator.calculateConcreteListTokens(
|
||||
this.contextManager.getNodes(),
|
||||
);
|
||||
debugLogger.log(
|
||||
`[Turn ${this.currentTurnIndex}] Tokens BEFORE: ${tokensBefore}`,
|
||||
);
|
||||
|
||||
// 3. Yield to event loop to allow internal async subscribers and orchestrator to finish
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// 3.1 Simulate what projectCompressedHistory does with the sync handlers
|
||||
let currentView = this.contextManager.getNodes();
|
||||
const currentTokens =
|
||||
this.env.tokenCalculator.calculateConcreteListTokens(currentView);
|
||||
if (this.config.budget && currentTokens > this.config.budget.maxTokens) {
|
||||
debugLogger.log(
|
||||
`[Turn ${this.currentTurnIndex}] Sync panic triggered! ${currentTokens} > ${this.config.budget.maxTokens}`,
|
||||
);
|
||||
const orchestrator = this.orchestrator;
|
||||
// In the V2 simulation, we trigger the 'gc_backstop' to simulate emergency pressure.
|
||||
// Since contextManager owns its buffer natively, the simulation now properly matches reality
|
||||
// where the manager runs the orchestrator and keeps the resulting modified view.
|
||||
const modifiedView = await orchestrator.executeTriggerSync(
|
||||
'gc_backstop',
|
||||
currentView,
|
||||
new Set(currentView.map((e) => e.id)),
|
||||
new Set<string>(),
|
||||
);
|
||||
|
||||
// In the real system, ContextManager triggers this and retains it.
|
||||
// We will emulate that behavior internally in the test loop for token counting.
|
||||
currentView = modifiedView;
|
||||
}
|
||||
|
||||
// 4. Measure tokens after background processors have processed inboxes
|
||||
const tokensAfter = this.env.tokenCalculator.calculateConcreteListTokens(
|
||||
this.contextManager.getNodes(),
|
||||
);
|
||||
debugLogger.log(
|
||||
`[Turn ${this.currentTurnIndex}] Tokens AFTER: ${tokensAfter}`,
|
||||
);
|
||||
|
||||
this.tokenTrajectory.push({
|
||||
turnIndex: this.currentTurnIndex++,
|
||||
tokensBeforeBackground: tokensBefore,
|
||||
tokensAfterBackground: tokensAfter,
|
||||
});
|
||||
}
|
||||
|
||||
async getGoldenState() {
|
||||
const finalProjection =
|
||||
await this.contextManager.projectCompressedHistory();
|
||||
return {
|
||||
tokenTrajectory: this.tokenTrajectory,
|
||||
finalProjection,
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import { SimulationHarness } from './SimulationHarness.js';
|
||||
import { createMockLlmClient } from '../testing/contextTestUtils.js';
|
||||
import type { SidecarConfig } from '../sidecar/types.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,
|
||||
) ||
|
||||
/^\/tmp\/sim/.test(val)), // Mask temp directories and UUIDs
|
||||
print: (val) =>
|
||||
typeof val === 'string' && /^\/tmp\/sim/.test(val)
|
||||
? '"<MOCKED_DIR>"'
|
||||
: '"<UUID>"',
|
||||
});
|
||||
|
||||
describe('System Lifecycle Golden Tests', () => {
|
||||
beforeAll(() => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const getAggressiveConfig = (): SidecarConfig => ({
|
||||
budget: { maxTokens: 1000, retainedTokens: 500 }, // Extremely tight limits
|
||||
pipelines: [
|
||||
{
|
||||
name: 'Pressure Relief', // Emits from eventBus 'retained_exceeded'
|
||||
triggers: ['retained_exceeded'],
|
||||
processors: [
|
||||
{ processorId: 'BlobDegradationProcessor' },
|
||||
{
|
||||
processorId: 'ToolMaskingProcessor',
|
||||
options: { stringLengthThresholdTokens: 50 },
|
||||
}, // Mask any tool string > 50 chars
|
||||
{ processorId: 'StateSnapshotProcessor', options: {} }, // Squash old history
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Immediate Sanitization', // The magic string the projector is hardcoded to use
|
||||
triggers: ['retained_exceeded'],
|
||||
processors: [
|
||||
{ processorId: 'HistoryTruncationProcessor', options: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
workers: [{ workerId: 'StateSnapshotWorker' }],
|
||||
});
|
||||
|
||||
const mockLlmClient = createMockLlmClient([
|
||||
'<MOCKED_STATE_SNAPSHOT_SUMMARY>',
|
||||
]);
|
||||
|
||||
it('Scenario 1: Organic Growth with Huge Tool Output & Images', async () => {
|
||||
const harness = await SimulationHarness.create(
|
||||
getAggressiveConfig(),
|
||||
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, how can I help?' }] },
|
||||
]);
|
||||
|
||||
// Turn 2: Massive Tool Output (Should trigger ToolMaskingProcessor in background)
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: 'Read the logs.' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'run_shell_command',
|
||||
args: { cmd: 'cat server.log' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'run_shell_command',
|
||||
response: { output: 'LOG '.repeat(5000) },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'model', parts: [{ text: 'The logs are very long.' }] },
|
||||
]);
|
||||
|
||||
// Turn 3: Multi-modal blob (Should trigger BlobDegradationProcessor)
|
||||
await harness.simulateTurn([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: 'Look at this architecture diagram:' },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: 'fake_base64_data_'.repeat(1000),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'model', parts: [{ text: 'Nice diagram.' }] },
|
||||
]);
|
||||
|
||||
// Turn 4: More conversation to trigger StateSnapshot
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: 'Can we refactor?' }] },
|
||||
{ role: 'model', parts: [{ text: 'Yes we can.' }] },
|
||||
]);
|
||||
|
||||
// Get final state
|
||||
const goldenState = await harness.getGoldenState();
|
||||
|
||||
// In a perfectly functioning opportunistic system, the token trajectory should show
|
||||
// the massive spikes in Turn 2 and 3 being immediately resolved by the background tasks.
|
||||
// The final projection should fit neatly under the Max Tokens limit.
|
||||
|
||||
expect(goldenState).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Scenario 2: Under Budget (No Modifications)', async () => {
|
||||
const generousConfig: SidecarConfig = {
|
||||
budget: { maxTokens: 100000, retainedTokens: 50000 },
|
||||
pipelines: [], // No triggers
|
||||
workers: [],
|
||||
};
|
||||
|
||||
const harness = await SimulationHarness.create(
|
||||
generousConfig,
|
||||
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, how can I help?' }] },
|
||||
]);
|
||||
|
||||
const goldenState = await harness.getGoldenState();
|
||||
|
||||
// Total tokens should cleanly match character count with no synthetic nodes
|
||||
expect(goldenState).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('Scenario 3: Worker-Driven Background GC', async () => {
|
||||
const gcConfig: SidecarConfig = {
|
||||
budget: { maxTokens: 200, retainedTokens: 100 },
|
||||
pipelines: [], // No standard pipelines
|
||||
workers: [
|
||||
{ workerId: 'StateSnapshotWorker' }, // This should fire on chunk events
|
||||
],
|
||||
};
|
||||
|
||||
const harness = await SimulationHarness.create(gcConfig, mockLlmClient);
|
||||
|
||||
// Turn 0
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: 'A'.repeat(50) }] },
|
||||
{ role: 'model', parts: [{ text: 'B'.repeat(50) }] },
|
||||
]);
|
||||
|
||||
// Turn 1 (Should trigger StateSnapshotWorker because we exceed 100 retainedTokens)
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: 'C'.repeat(50) }] },
|
||||
{ role: 'model', parts: [{ text: 'D'.repeat(50) }] },
|
||||
]);
|
||||
|
||||
// Give the background worker an extra beat to complete its async execution and emit variants
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Turn 2
|
||||
await harness.simulateTurn([
|
||||
{ role: 'user', parts: [{ text: 'E'.repeat(50) }] },
|
||||
{ role: 'model', parts: [{ text: 'F'.repeat(50) }] },
|
||||
]);
|
||||
|
||||
const goldenState = await harness.getGoldenState();
|
||||
|
||||
// We should see ROLLING_SUMMARY nodes injected into the graph, proving the worker ran in the background
|
||||
expect(goldenState).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { IIdGenerator } from './IIdGenerator.js';
|
||||
|
||||
export class DeterministicIdGenerator implements IIdGenerator {
|
||||
private counter = 0;
|
||||
|
||||
constructor(private prefix: string = 'id-') {}
|
||||
|
||||
generateId(): string {
|
||||
this.counter++;
|
||||
return `${this.prefix}${this.counter}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface IFileSystem {
|
||||
existsSync(path: string): boolean;
|
||||
statSyncSize(path: string): number;
|
||||
readFileSync(path: string, encoding: 'utf8'): string;
|
||||
writeFileSync(path: string, data: string | Buffer, encoding?: 'utf-8'): void;
|
||||
appendFileSync(path: string, data: string, encoding: 'utf-8'): void;
|
||||
mkdirSync(path: string, options?: { recursive?: boolean }): void;
|
||||
|
||||
writeFile(path: string, data: string | Buffer): Promise<void>;
|
||||
mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
|
||||
|
||||
join(...paths: string[]): string;
|
||||
dirname(path: string): string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface IIdGenerator {
|
||||
generateId(): string;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { IFileSystem } from './IFileSystem.js';
|
||||
|
||||
export class InMemoryFileSystem implements IFileSystem {
|
||||
private files = new Map<string, string | Buffer>();
|
||||
|
||||
getFiles(): ReadonlyMap<string, string | Buffer> {
|
||||
return this.files;
|
||||
}
|
||||
|
||||
setFile(path: string, content: string | Buffer) {
|
||||
this.files.set(this.normalize(path), content);
|
||||
}
|
||||
|
||||
private normalize(p: string): string {
|
||||
return p.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
existsSync(p: string): boolean {
|
||||
return this.files.has(this.normalize(p));
|
||||
}
|
||||
|
||||
statSyncSize(p: string): number {
|
||||
const content = this.files.get(this.normalize(p));
|
||||
if (content === undefined) {
|
||||
throw new Error(`ENOENT: no such file or directory, stat '${p}'`);
|
||||
}
|
||||
return Buffer.isBuffer(content)
|
||||
? content.byteLength
|
||||
: Buffer.byteLength(content, 'utf8');
|
||||
}
|
||||
|
||||
readFileSync(p: string, encoding: 'utf8'): string {
|
||||
const content = this.files.get(this.normalize(p));
|
||||
if (content === undefined) {
|
||||
throw new Error(`ENOENT: no such file or directory, open '${p}'`);
|
||||
}
|
||||
if (Buffer.isBuffer(content)) {
|
||||
return content.toString(encoding);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
writeFileSync(p: string, data: string | Buffer, _encoding?: 'utf-8'): void {
|
||||
this.files.set(this.normalize(p), data);
|
||||
}
|
||||
|
||||
appendFileSync(p: string, data: string, _encoding: 'utf-8'): void {
|
||||
const norm = this.normalize(p);
|
||||
const existing = this.files.get(norm) || '';
|
||||
const existingStr = Buffer.isBuffer(existing)
|
||||
? existing.toString('utf8')
|
||||
: existing;
|
||||
this.files.set(norm, existingStr + data);
|
||||
}
|
||||
|
||||
mkdirSync(_p: string, _options?: { recursive?: boolean }): void {}
|
||||
|
||||
async writeFile(p: string, data: string | Buffer): Promise<void> {
|
||||
this.writeFileSync(p, data);
|
||||
}
|
||||
|
||||
async mkdir(_p: string, _options?: { recursive?: boolean }): Promise<void> {}
|
||||
|
||||
join(...paths: string[]): string {
|
||||
return this.normalize(paths.join('/'));
|
||||
}
|
||||
|
||||
dirname(p: string): string {
|
||||
const parts = this.normalize(p).split('/');
|
||||
parts.pop();
|
||||
return parts.length === 0 ? '.' : parts.join('/') || '/';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { IFileSystem } from './IFileSystem.js';
|
||||
|
||||
export class NodeFileSystem implements IFileSystem {
|
||||
existsSync(p: string): boolean {
|
||||
return fs.existsSync(p);
|
||||
}
|
||||
|
||||
statSyncSize(p: string): number {
|
||||
return fs.statSync(p).size;
|
||||
}
|
||||
|
||||
readFileSync(p: string, encoding: 'utf8'): string {
|
||||
return fs.readFileSync(p, encoding);
|
||||
}
|
||||
|
||||
writeFileSync(p: string, data: string | Buffer, encoding?: 'utf-8'): void {
|
||||
if (Buffer.isBuffer(data)) {
|
||||
fs.writeFileSync(p, data);
|
||||
} else {
|
||||
fs.writeFileSync(p, data, encoding);
|
||||
}
|
||||
}
|
||||
|
||||
appendFileSync(p: string, data: string, encoding: 'utf-8'): void {
|
||||
fs.appendFileSync(p, data, encoding);
|
||||
}
|
||||
|
||||
mkdirSync(p: string, options?: { recursive?: boolean }): void {
|
||||
fs.mkdirSync(p, options);
|
||||
}
|
||||
|
||||
async writeFile(p: string, data: string | Buffer): Promise<void> {
|
||||
await fsPromises.writeFile(p, data);
|
||||
}
|
||||
|
||||
async mkdir(p: string, options?: { recursive?: boolean }): Promise<void> {
|
||||
await fsPromises.mkdir(p, options);
|
||||
}
|
||||
|
||||
join(...paths: string[]): string {
|
||||
return path.join(...paths);
|
||||
}
|
||||
|
||||
dirname(p: string): string {
|
||||
return path.dirname(p);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { IIdGenerator } from './IIdGenerator.js';
|
||||
|
||||
export class NodeIdGenerator implements IIdGenerator {
|
||||
generateId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import { AgentChatHistory } from '../../core/agentChatHistory.js';
|
||||
import { ContextManager } from '../contextManager.js';
|
||||
import { InMemoryFileSystem } from '../system/InMemoryFileSystem.js';
|
||||
import { DeterministicIdGenerator } from '../system/DeterministicIdGenerator.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { ContextTracer } from '../tracer.js';
|
||||
import { ContextEnvironmentImpl } from '../sidecar/environmentImpl.js';
|
||||
import { SidecarLoader } from '../sidecar/SidecarLoader.js';
|
||||
import { ContextEventBus } from '../eventBus.js';
|
||||
import { SidecarRegistry } from '../sidecar/registry.js';
|
||||
import { registerBuiltInProcessors } from '../sidecar/builtins.js';
|
||||
import { PipelineOrchestrator } from '../sidecar/orchestrator.js';
|
||||
import type { ConcreteNode, ToolExecution } from '../ir/types.js';
|
||||
import type { ContextEnvironment } from '../sidecar/environment.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
|
||||
import type { Content, GenerateContentResponse } from '@google/genai';
|
||||
import { InboxSnapshotImpl } from '../sidecar/inbox.js';
|
||||
import type { InboxMessage, ProcessArgs } from '../pipeline.js';
|
||||
|
||||
/**
|
||||
* Creates a valid mock GenerateContentResponse with the provided text.
|
||||
* Used to avoid having to manually construct the deeply nested candidate/content/part structure.
|
||||
*/
|
||||
export const createMockGenerateContentResponse = (
|
||||
text: string,
|
||||
): GenerateContentResponse =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
({
|
||||
candidates: [{ content: { role: 'model', parts: [{ text }] }, index: 0 }],
|
||||
}) as GenerateContentResponse;
|
||||
|
||||
export function createDummyNode(
|
||||
logicalParentId: string,
|
||||
type: ConcreteNode['type'],
|
||||
tokens = 100,
|
||||
overrides?: Partial<ConcreteNode>,
|
||||
id?: string,
|
||||
): ConcreteNode {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
id: id || randomUUID(),
|
||||
episodeId: logicalParentId,
|
||||
logicalParentId,
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
text: `Dummy ${type}`,
|
||||
name: type === 'SYSTEM_EVENT' ? 'dummy_event' : undefined,
|
||||
payload: type === 'SYSTEM_EVENT' ? {} : undefined,
|
||||
semanticParts: [],
|
||||
metadata: {
|
||||
originalTokens: tokens,
|
||||
currentTokens: tokens,
|
||||
transformations: [],
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ConcreteNode;
|
||||
}
|
||||
|
||||
export function createDummyToolNode(
|
||||
logicalParentId: string,
|
||||
intentTokens = 100,
|
||||
obsTokens = 200,
|
||||
overrides?: Partial<ToolExecution>,
|
||||
id?: string,
|
||||
): ToolExecution {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
id: id || randomUUID(),
|
||||
episodeId: logicalParentId,
|
||||
logicalParentId,
|
||||
type: 'TOOL_EXECUTION',
|
||||
timestamp: Date.now(),
|
||||
toolName: 'dummy_tool',
|
||||
intent: { action: 'test' },
|
||||
observation: { result: 'ok' },
|
||||
tokens: {
|
||||
intent: intentTokens,
|
||||
observation: obsTokens,
|
||||
},
|
||||
metadata: {
|
||||
originalTokens: intentTokens + obsTokens,
|
||||
currentTokens: intentTokens + obsTokens,
|
||||
transformations: [],
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ToolExecution;
|
||||
}
|
||||
|
||||
import type { Mock } from 'vitest';
|
||||
import type { SidecarConfig } from '../sidecar/types.js';
|
||||
|
||||
export interface MockLlmClient extends BaseLlmClient {
|
||||
generateContent: Mock;
|
||||
}
|
||||
|
||||
export function createMockLlmClient(
|
||||
responses?: Array<string | GenerateContentResponse>,
|
||||
): MockLlmClient {
|
||||
const generateContentMock = vi.fn();
|
||||
|
||||
if (responses && responses.length > 0) {
|
||||
for (const response of responses) {
|
||||
if (typeof response === 'string') {
|
||||
generateContentMock.mockResolvedValueOnce(
|
||||
createMockGenerateContentResponse(response),
|
||||
);
|
||||
} else {
|
||||
generateContentMock.mockResolvedValueOnce(response);
|
||||
}
|
||||
}
|
||||
// Fallback to the last response for any subsequent calls
|
||||
const lastResponse = responses[responses.length - 1];
|
||||
if (typeof lastResponse === 'string') {
|
||||
generateContentMock.mockResolvedValue(
|
||||
createMockGenerateContentResponse(lastResponse),
|
||||
);
|
||||
} else {
|
||||
generateContentMock.mockResolvedValue(lastResponse);
|
||||
}
|
||||
} else {
|
||||
// Default fallback
|
||||
generateContentMock.mockResolvedValue(
|
||||
createMockGenerateContentResponse('Mock LLM response'),
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
generateContent: generateContentMock,
|
||||
} as unknown as MockLlmClient;
|
||||
}
|
||||
|
||||
export function createMockEnvironment(
|
||||
overrides?: Partial<ContextEnvironment>,
|
||||
): ContextEnvironment {
|
||||
const llmClient = createMockLlmClient(['Mock LLM summary response']);
|
||||
|
||||
const tracer = new ContextTracer({
|
||||
targetDir: '/tmp',
|
||||
sessionId: 'mock-session',
|
||||
});
|
||||
const eventBus = new ContextEventBus();
|
||||
|
||||
const env = new ContextEnvironmentImpl(
|
||||
llmClient,
|
||||
'mock-session',
|
||||
'mock-prompt-id',
|
||||
'/tmp/.gemini/trace',
|
||||
'/tmp/.gemini/tool-outputs',
|
||||
tracer,
|
||||
1,
|
||||
eventBus,
|
||||
new InMemoryFileSystem(),
|
||||
new DeterministicIdGenerator('mock-uuid-'),
|
||||
);
|
||||
|
||||
if (overrides) {
|
||||
Object.assign(env, overrides);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a block of synthetic conversation history designed to consume a specific number of tokens.
|
||||
* Assumes roughly 4 characters per token for standard English text.
|
||||
*/
|
||||
import { ContextWorkingBufferImpl } from '../sidecar/contextWorkingBuffer.js';
|
||||
|
||||
export function createMockProcessArgs(
|
||||
targets: ConcreteNode[],
|
||||
bufferNodes: ConcreteNode[] = [],
|
||||
inboxMessages: InboxMessage[] = [],
|
||||
): ProcessArgs {
|
||||
return {
|
||||
targets,
|
||||
buffer: ContextWorkingBufferImpl.initialize(
|
||||
bufferNodes.length ? bufferNodes : targets,
|
||||
),
|
||||
inbox: new InboxSnapshotImpl(inboxMessages),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSyntheticHistory(
|
||||
numTurns: number,
|
||||
tokensPerTurn: number,
|
||||
): Content[] {
|
||||
const history: Content[] = [];
|
||||
const charsPerTurn = tokensPerTurn * 1;
|
||||
|
||||
for (let i = 0; i < numTurns; i++) {
|
||||
history.push({
|
||||
role: 'user',
|
||||
parts: [{ text: `User turn ${i}. ` + 'A'.repeat(charsPerTurn) }],
|
||||
});
|
||||
history.push({
|
||||
role: 'model',
|
||||
parts: [{ text: `Model response ${i}. ` + 'B'.repeat(charsPerTurn) }],
|
||||
});
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fully mocked Config object tailored for Context Component testing.
|
||||
*/
|
||||
export function createMockContextConfig(
|
||||
overrides?: Record<string, unknown>,
|
||||
llmClientOverride?: unknown,
|
||||
): Config {
|
||||
const defaultConfig = {
|
||||
isContextManagementEnabled: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
|
||||
},
|
||||
getBaseLlmClient: vi.fn().mockReturnValue(
|
||||
llmClientOverride || {
|
||||
generateContent: vi.fn().mockResolvedValue({
|
||||
text: '<mocked_snapshot>Synthesized state</mocked_snapshot>',
|
||||
}),
|
||||
},
|
||||
),
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
getTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session'),
|
||||
getExperimentalContextSidecarConfig: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return { ...defaultConfig, ...overrides } as unknown as Config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires up a full ContextManager component with an AgentChatHistory and active background workers.
|
||||
*/
|
||||
|
||||
export function setupContextComponentTest(
|
||||
config: Config,
|
||||
sidecarOverride?: SidecarConfig,
|
||||
): { chatHistory: AgentChatHistory; contextManager: ContextManager } {
|
||||
const chatHistory = new AgentChatHistory();
|
||||
const registry = new SidecarRegistry();
|
||||
registerBuiltInProcessors(registry);
|
||||
const sidecar = sidecarOverride || SidecarLoader.fromConfig(config, registry);
|
||||
const tracer = new ContextTracer({
|
||||
targetDir: '/tmp',
|
||||
sessionId: 'test-session',
|
||||
});
|
||||
const eventBus = new ContextEventBus();
|
||||
const env = new ContextEnvironmentImpl(
|
||||
config.getBaseLlmClient(),
|
||||
'test prompt-id',
|
||||
'test-session',
|
||||
'/tmp',
|
||||
'/tmp/gemini-test',
|
||||
tracer,
|
||||
1,
|
||||
eventBus,
|
||||
);
|
||||
|
||||
const orchestrator = new PipelineOrchestrator(
|
||||
sidecar,
|
||||
env,
|
||||
eventBus,
|
||||
tracer,
|
||||
registry,
|
||||
);
|
||||
|
||||
const contextManager = new ContextManager(
|
||||
sidecar,
|
||||
env,
|
||||
tracer,
|
||||
orchestrator,
|
||||
chatHistory,
|
||||
);
|
||||
|
||||
// The async worker is now internally managed by ContextManager
|
||||
return { chatHistory, contextManager };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { SidecarConfig } from '../sidecar/types.js';
|
||||
|
||||
export const testTruncateProfile: SidecarConfig = {
|
||||
budget: {
|
||||
retainedTokens: 65000,
|
||||
maxTokens: 150000,
|
||||
},
|
||||
pipelines: [
|
||||
{
|
||||
name: 'Emergency Backstop (Truncate Only)',
|
||||
triggers: ['gc_backstop', 'retained_exceeded'],
|
||||
processors: [{ processorId: 'HistoryTruncationProcessor', options: {} }],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { ContextTracer } from './tracer.js';
|
||||
import { InMemoryFileSystem } from './system/InMemoryFileSystem.js';
|
||||
import { DeterministicIdGenerator } from './system/DeterministicIdGenerator.js';
|
||||
|
||||
describe('ContextTracer (Fake FS & ID Gen)', () => {
|
||||
let fileSystem: InMemoryFileSystem;
|
||||
let idGenerator: DeterministicIdGenerator;
|
||||
|
||||
beforeEach(() => {
|
||||
fileSystem = new InMemoryFileSystem();
|
||||
idGenerator = new DeterministicIdGenerator('mock-uuid-');
|
||||
|
||||
// We must mock Date.now() to ensure asset file names are perfectly deterministic
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T12:00:00Z'));
|
||||
});
|
||||
|
||||
it('initializes, logs events, and auto-saves large assets deterministically', () => {
|
||||
const tracer = new ContextTracer(
|
||||
{ enabled: true, targetDir: '/fake/target', sessionId: 'test-session' },
|
||||
fileSystem,
|
||||
idGenerator,
|
||||
);
|
||||
|
||||
// Verify Initialization
|
||||
const initTraceLog = fileSystem.readFileSync(
|
||||
'/fake/target/.gemini/context_trace/test-session/trace.log',
|
||||
'utf8',
|
||||
);
|
||||
expect(initTraceLog).toContain('[SYSTEM] Context Tracer Initialized');
|
||||
|
||||
// Small logging: shouldn't trigger saveAsset
|
||||
tracer.logEvent('TestComponent', 'TestAction', { key: 'value' });
|
||||
|
||||
const smallTraceLog = fileSystem.readFileSync(
|
||||
'/fake/target/.gemini/context_trace/test-session/trace.log',
|
||||
'utf8',
|
||||
);
|
||||
expect(smallTraceLog).toContain('[TestComponent] TestAction');
|
||||
expect(smallTraceLog).toContain('{"key":"value"}');
|
||||
|
||||
// Large logging: should trigger auto-asset save
|
||||
const hugeString = 'a'.repeat(2000);
|
||||
tracer.logEvent('TestComponent', 'LargeAction', { largeKey: hugeString });
|
||||
|
||||
// 1767268800000 is 2026-01-01T12:00:00Z
|
||||
const expectedAssetPath =
|
||||
'/fake/target/.gemini/context_trace/test-session/assets/1767268800000-mock-uuid-1-largeKey.json';
|
||||
|
||||
// Assert asset was written to FS
|
||||
expect(fileSystem.existsSync(expectedAssetPath)).toBe(true);
|
||||
|
||||
const largeTraceLog = fileSystem.readFileSync(
|
||||
'/fake/target/.gemini/context_trace/test-session/trace.log',
|
||||
'utf8',
|
||||
);
|
||||
expect(largeTraceLog).toContain('[TestComponent] LargeAction');
|
||||
expect(largeTraceLog).toContain(
|
||||
`{"largeKey":{"$asset":"1767268800000-mock-uuid-1-largeKey.json"}}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('silently ignores logging when disabled', () => {
|
||||
const tracer = new ContextTracer(
|
||||
{ enabled: false, targetDir: '/fake/target', sessionId: 'test-session' },
|
||||
fileSystem,
|
||||
idGenerator,
|
||||
);
|
||||
|
||||
tracer.logEvent('TestComponent', 'TestAction');
|
||||
|
||||
const hugeString = 'a'.repeat(2000);
|
||||
tracer.logEvent('TestComponent', 'LargeAction', { largeKey: hugeString });
|
||||
|
||||
// FS should be completely empty
|
||||
expect(fileSystem.getFiles().size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import type { IFileSystem } from './system/IFileSystem.js';
|
||||
import { NodeFileSystem } from './system/NodeFileSystem.js';
|
||||
import type { IIdGenerator } from './system/IIdGenerator.js';
|
||||
import { NodeIdGenerator } from './system/NodeIdGenerator.js';
|
||||
|
||||
export interface ContextTracerOptions {
|
||||
enabled?: boolean;
|
||||
targetDir: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export class ContextTracer {
|
||||
private traceDir: string;
|
||||
private assetsDir: string;
|
||||
private enabled: boolean;
|
||||
private fileSystem: IFileSystem;
|
||||
private idGenerator: IIdGenerator;
|
||||
|
||||
private readonly MAX_INLINE_SIZE = 1000;
|
||||
|
||||
constructor(
|
||||
options: ContextTracerOptions,
|
||||
fileSystem: IFileSystem = new NodeFileSystem(),
|
||||
idGenerator: IIdGenerator = new NodeIdGenerator(),
|
||||
) {
|
||||
this.enabled = options.enabled ?? false;
|
||||
this.fileSystem = fileSystem;
|
||||
this.idGenerator = idGenerator;
|
||||
|
||||
this.traceDir = this.fileSystem.join(
|
||||
options.targetDir,
|
||||
'.gemini',
|
||||
'context_trace',
|
||||
options.sessionId,
|
||||
);
|
||||
this.assetsDir = this.fileSystem.join(this.traceDir, 'assets');
|
||||
|
||||
if (this.enabled) {
|
||||
try {
|
||||
this.fileSystem.mkdirSync(this.assetsDir, { recursive: true });
|
||||
this.logEvent('SYSTEM', 'Context Tracer Initialized', {
|
||||
sessionId: options.sessionId,
|
||||
});
|
||||
} catch (e) {
|
||||
debugLogger.error('Failed to initialize ContextTracer', e);
|
||||
this.enabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logEvent(
|
||||
component: string,
|
||||
action: string,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
if (!this.enabled) return;
|
||||
try {
|
||||
let processedDetails: Record<string, unknown> | undefined;
|
||||
|
||||
if (details) {
|
||||
processedDetails = {};
|
||||
for (const [key, value] of Object.entries(details)) {
|
||||
const strValue =
|
||||
typeof value === 'string' ? value : JSON.stringify(value);
|
||||
if (strValue && strValue.length > this.MAX_INLINE_SIZE) {
|
||||
const assetId = this.saveAsset(component, key, value);
|
||||
processedDetails[key] = { $asset: assetId };
|
||||
} else {
|
||||
processedDetails[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const detailsStr = processedDetails
|
||||
? ` | Details: ${JSON.stringify(processedDetails)}`
|
||||
: '';
|
||||
const logLine = `[${timestamp}] [${component}] ${action}${detailsStr}\n`;
|
||||
this.fileSystem.appendFileSync(
|
||||
this.fileSystem.join(this.traceDir, 'trace.log'),
|
||||
logLine,
|
||||
'utf-8',
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.warn(`Tracing failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
private saveAsset(
|
||||
component: string,
|
||||
assetName: string,
|
||||
data: unknown,
|
||||
): string {
|
||||
if (!this.enabled) return 'asset-recording-disabled';
|
||||
try {
|
||||
const assetId = `${Date.now()}-${this.idGenerator.generateId()}-${assetName}.json`;
|
||||
const assetPath = this.fileSystem.join(this.assetsDir, assetId);
|
||||
|
||||
this.fileSystem.writeFileSync(
|
||||
assetPath,
|
||||
JSON.stringify(data, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
this.logEvent(component, `Saved asset: ${assetName}`, { assetId });
|
||||
return assetId;
|
||||
} catch (e) {
|
||||
this.logEvent(component, `Failed to save asset: ${assetName}`, {
|
||||
error: String(e),
|
||||
});
|
||||
return 'asset-save-failed';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
import { estimateTokenCountSync as baseEstimate } from '../../utils/tokenCalculation.js';
|
||||
import type { ConcreteNode } from '../ir/types.js';
|
||||
import type { IrNodeBehaviorRegistry } from '../ir/behaviorRegistry.js';
|
||||
|
||||
/**
|
||||
* The flat token cost assigned to a single multi-modal asset (like an image tile)
|
||||
* by the Gemini API. We use this as a baseline heuristic for inlineData/fileData.
|
||||
*/
|
||||
|
||||
export class ContextTokenCalculator {
|
||||
private readonly tokenCache = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly charsPerToken: number,
|
||||
private readonly registry: IrNodeBehaviorRegistry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Estimates tokens for a simple string based on character count.
|
||||
* Fast, but inherently inaccurate compared to real model tokenization.
|
||||
*/
|
||||
estimateTokensForString(text: string): number {
|
||||
return Math.ceil(text.length / this.charsPerToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast, simple heuristic conversion from tokens to expected character length.
|
||||
* Useful for calculating truncation thresholds.
|
||||
*/
|
||||
tokensToChars(tokens: number): number {
|
||||
return tokens * this.charsPerToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-calculates and caches the token cost of a newly minted node.
|
||||
* Because nodes are immutable, this cost never changes for this node ID.
|
||||
*/
|
||||
cacheNodeTokens(node: ConcreteNode): number {
|
||||
const behavior = this.registry.get(node.type);
|
||||
const parts = behavior.getEstimatableParts(node);
|
||||
const tokens = this.estimateTokensForParts(parts);
|
||||
this.tokenCache.set(node.id, tokens);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the token cost of a single node from the cache.
|
||||
* If it misses the cache, it computes it and caches it.
|
||||
*/
|
||||
getTokenCost(node: ConcreteNode): number {
|
||||
const cached = this.tokenCache.get(node.id);
|
||||
if (cached !== undefined) return cached;
|
||||
return this.cacheNodeTokens(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast calculation for a flat array of ConcreteNodes (The Nodes).
|
||||
* It relies entirely on the O(1) sidecar token cache.
|
||||
*/
|
||||
calculateConcreteListTokens(nodes: readonly ConcreteNode[]): number {
|
||||
let tokens = 0;
|
||||
for (const node of nodes) {
|
||||
tokens += this.getTokenCost(node);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
/**
|
||||
* Slower, precise estimation for a Gemini Content/Part graph.
|
||||
* Deeply inspects the nested structure and uses the base tokenization math.
|
||||
*/
|
||||
estimateTokensForParts(parts: Part[], depth: number = 0): number {
|
||||
let totalTokens = 0;
|
||||
for (const part of parts) {
|
||||
if (typeof part.text === 'string') {
|
||||
totalTokens += Math.ceil(part.text.length / this.charsPerToken);
|
||||
} else if (part.inlineData !== undefined || part.fileData !== undefined) {
|
||||
totalTokens += 258;
|
||||
} else {
|
||||
totalTokens += Math.ceil(
|
||||
JSON.stringify(part).length / this.charsPerToken,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Also include structural overhead
|
||||
return totalTokens + baseEstimate(parts, depth);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ConcreteNode } from '../ir/types.js';
|
||||
import type { ContextEnvironment } from '../sidecar/environment.js';
|
||||
import { LlmRole } from '../../telemetry/llmRole.js';
|
||||
|
||||
export class SnapshotGenerator {
|
||||
constructor(private readonly env: ContextEnvironment) {}
|
||||
|
||||
async synthesizeSnapshot(
|
||||
nodes: readonly ConcreteNode[],
|
||||
systemInstruction?: string,
|
||||
): Promise<string> {
|
||||
const systemPrompt =
|
||||
systemInstruction ??
|
||||
`You are an expert Context Memory Manager. You will be provided with a raw transcript of older conversation turns between a user and an AI assistant.
|
||||
Your task is to synthesize these turns into a single, dense, factual snapshot that preserves all critical context, preferences, active tasks, and factual knowledge, but discards conversational filler, pleasantries, and redundant back-and-forth iterations.
|
||||
|
||||
Output ONLY the raw factual snapshot, formatted compactly. Do not include markdown wrappers, prefixes like "Here is the snapshot", or conversational elements.`;
|
||||
|
||||
let userPromptText = 'TRANSCRIPT TO SNAPSHOT:\n\n';
|
||||
for (const node of nodes) {
|
||||
let nodeContent = '';
|
||||
if ('text' in node && typeof node.text === 'string') {
|
||||
nodeContent = node.text;
|
||||
} else if ('semanticParts' in node) {
|
||||
nodeContent = JSON.stringify(node.semanticParts);
|
||||
} else if ('observation' in node) {
|
||||
nodeContent =
|
||||
typeof node.observation === 'string'
|
||||
? node.observation
|
||||
: JSON.stringify(node.observation);
|
||||
}
|
||||
|
||||
userPromptText += `[${node.type}]: ${nodeContent}\n`;
|
||||
}
|
||||
|
||||
const response = await this.env.llmClient.generateContent({
|
||||
role: LlmRole.UTILITY_STATE_SNAPSHOT_PROCESSOR,
|
||||
modelConfigKey: { model: 'default' },
|
||||
contents: [{ role: 'user', parts: [{ text: userPromptText }] }],
|
||||
systemInstruction: { role: 'system', parts: [{ text: systemPrompt }] },
|
||||
promptId: this.env.promptId,
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
const candidate = response.candidates?.[0];
|
||||
const textPart = candidate?.content?.parts?.[0];
|
||||
return textPart?.text || '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
|
||||
export type HistoryEventType = 'PUSH' | 'SYNC_FULL' | 'CLEAR';
|
||||
|
||||
export interface HistoryEvent {
|
||||
type: HistoryEventType;
|
||||
payload: readonly Content[];
|
||||
}
|
||||
|
||||
export type HistoryListener = (event: HistoryEvent) => void;
|
||||
|
||||
export class AgentChatHistory {
|
||||
private history: Content[];
|
||||
private listeners: Set<HistoryListener> = new Set();
|
||||
|
||||
constructor(initialHistory: Content[] = []) {
|
||||
this.history = [...initialHistory];
|
||||
}
|
||||
|
||||
subscribe(listener: HistoryListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
// Emit initial state to new subscriber
|
||||
listener({ type: 'SYNC_FULL', payload: this.history });
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(type: HistoryEventType, payload: readonly Content[]) {
|
||||
const event: HistoryEvent = { type, payload };
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
push(content: Content) {
|
||||
this.history.push(content);
|
||||
this.notify('PUSH', [content]);
|
||||
}
|
||||
|
||||
set(history: readonly Content[]) {
|
||||
this.history = [...history];
|
||||
this.notify('SYNC_FULL', this.history);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.history = [];
|
||||
this.notify('CLEAR', []);
|
||||
}
|
||||
|
||||
get(): readonly Content[] {
|
||||
return this.history;
|
||||
}
|
||||
|
||||
map(callback: (value: Content, index: number, array: Content[]) => Content) {
|
||||
this.history = this.history.map(callback);
|
||||
this.notify('SYNC_FULL', this.history);
|
||||
}
|
||||
|
||||
flatMap<U>(
|
||||
callback: (
|
||||
value: Content,
|
||||
index: number,
|
||||
array: Content[],
|
||||
) => U | readonly U[],
|
||||
): U[] {
|
||||
return this.history.flatMap(callback);
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.history.length;
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,5 @@ export enum LlmRole {
|
||||
UTILITY_EDIT_CORRECTOR = 'utility_edit_corrector',
|
||||
UTILITY_AUTOCOMPLETE = 'utility_autocomplete',
|
||||
UTILITY_FAST_ACK_HELPER = 'utility_fast_ack_helper',
|
||||
UTILITY_STATE_SNAPSHOT_PROCESSOR = 'utility_state_snapshot_processr',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user