mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-15 22:33:05 -07:00
refactor(context): implement Named Processor Configuration architecture for context pipelines
This commit transitions the Context Manager to a statically-typed, functional pipeline architecture while strictly preserving dynamic hyperparameter capabilities via JSON Sidecar configs.
Key Changes:
- **Functional Processors:** Processors are now pure closure-based HOFs returning a clean `{ id, name, process }` interface, eliminating pseudo-class Object.assign hacks.
- **Named Configurations:** The `SidecarConfig` schema now validates a dictionary of named `processorOptions`.
- **Static Pipeline Wiring:** `profiles.ts` hardcodes the execution order of pipelines in TS, injecting dynamically-loaded (and pre-validated) hyperparameter overrides by looking up their named configuration identifier.
- **Deep Validation:** `schema.ts` dynamically flattens the `SchemaRegistry` using `oneOf` blocks, allowing external JSON validators (like IDEs) to natively understand and validate context hyperparameter schemas.
This commit is contained in:
@@ -39,14 +39,14 @@ export interface ProcessArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* A ContextProcessor is now a pure function that returns a modified subset of nodes
|
||||
* A ContextProcessor is a pure, closure-based object that returns a modified subset of nodes
|
||||
* (or the original targets if no changes are needed).
|
||||
* The Orchestrator will use this to generate a new graph delta.
|
||||
*/
|
||||
export interface ContextProcessorFn {
|
||||
export interface ContextProcessor {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
(args: ProcessArgs): Promise<readonly ConcreteNode[]>;
|
||||
process(args: ProcessArgs): Promise<readonly ConcreteNode[]>;
|
||||
}
|
||||
|
||||
export interface ContextWorker {
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('BlobDegradationProcessor', () => {
|
||||
|
||||
const targets = [prompt];
|
||||
|
||||
const result = await processor(createMockProcessArgs(targets));
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
const modifiedPrompt = result[0] as UserPrompt;
|
||||
@@ -78,7 +78,7 @@ describe('BlobDegradationProcessor', () => {
|
||||
|
||||
const targets = [prompt];
|
||||
|
||||
const result = await processor(createMockProcessArgs(targets));
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
const modifiedPrompt = result[0] as UserPrompt;
|
||||
expect(modifiedPrompt.semanticParts.length).toBe(2);
|
||||
@@ -94,7 +94,7 @@ describe('BlobDegradationProcessor', () => {
|
||||
const processor = createBlobDegradationProcessor('BlobDegradationProcessor', env);
|
||||
const targets: ConcreteNode[] = [];
|
||||
|
||||
const result = await processor(createMockProcessArgs(targets));
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
expect(result).toBe(targets);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ProcessArgs, ContextProcessorFn } from '../pipeline.js';
|
||||
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';
|
||||
@@ -11,8 +11,11 @@ import { sanitizeFilenamePart } from '../../utils/fileUtils.js';
|
||||
export function createBlobDegradationProcessor(
|
||||
id: string,
|
||||
env: ContextEnvironment,
|
||||
): ContextProcessorFn {
|
||||
const processor: any = async ({ targets }: ProcessArgs) => {
|
||||
): ContextProcessor {
|
||||
return {
|
||||
id,
|
||||
name: 'BlobDegradationProcessor',
|
||||
process: async ({ targets }: ProcessArgs) => {
|
||||
if (targets.length === 0) {
|
||||
return targets;
|
||||
}
|
||||
@@ -142,10 +145,6 @@ export function createBlobDegradationProcessor(
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
},
|
||||
};
|
||||
|
||||
processor.id = id;
|
||||
Object.defineProperty(processor, 'name', { value: 'BlobDegradationProcessor' });
|
||||
|
||||
return processor;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
ContextProcessorFn,
|
||||
ContextProcessor,
|
||||
BackstopTargetOptions,
|
||||
ProcessArgs,
|
||||
} from '../pipeline.js';
|
||||
@@ -18,8 +18,11 @@ export function createHistoryTruncationProcessor(
|
||||
id: string,
|
||||
env: ContextEnvironment,
|
||||
options: HistoryTruncationProcessorOptions,
|
||||
): ContextProcessorFn {
|
||||
const processor: any = async ({ targets }: ProcessArgs) => {
|
||||
): ContextProcessor {
|
||||
return {
|
||||
id,
|
||||
name: 'HistoryTruncationProcessor',
|
||||
process: async ({ targets }: ProcessArgs) => {
|
||||
// Calculate how many tokens we need to remove based on the configured knob
|
||||
let targetTokensToRemove = 0;
|
||||
const strategy = options.target ?? 'max';
|
||||
@@ -49,10 +52,6 @@ export function createHistoryTruncationProcessor(
|
||||
}
|
||||
|
||||
return keptNodes;
|
||||
},
|
||||
};
|
||||
|
||||
processor.id = id;
|
||||
Object.defineProperty(processor, 'name', { value: 'HistoryTruncationProcessor' });
|
||||
|
||||
return processor;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('NodeDistillationProcessor', () => {
|
||||
|
||||
const targets = [prompt, thought, tool];
|
||||
|
||||
const result = await processor(createMockProcessArgs(targets));
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('NodeDistillationProcessor', () => {
|
||||
|
||||
const targets = [prompt, thought];
|
||||
|
||||
const result = await processor(createMockProcessArgs(targets));
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ContextProcessorFn, ProcessArgs } from '../pipeline.js';
|
||||
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';
|
||||
@@ -19,7 +19,7 @@ export function createNodeDistillationProcessor(
|
||||
id: string,
|
||||
env: ContextEnvironment,
|
||||
options: NodeDistillationProcessorOptions,
|
||||
): ContextProcessorFn {
|
||||
): ContextProcessor {
|
||||
const generateSummary = async (
|
||||
text: string,
|
||||
contextInfo: string,
|
||||
@@ -55,7 +55,10 @@ export function createNodeDistillationProcessor(
|
||||
}
|
||||
};
|
||||
|
||||
const processor: any = async ({ targets }: ProcessArgs) => {
|
||||
return {
|
||||
id,
|
||||
name: 'NodeDistillationProcessor',
|
||||
process: async ({ targets }: ProcessArgs) => {
|
||||
const semanticConfig = options;
|
||||
const limitTokens = semanticConfig.nodeThresholdTokens;
|
||||
const thresholdChars = env.tokenCalculator.tokensToChars(limitTokens);
|
||||
@@ -192,10 +195,6 @@ export function createNodeDistillationProcessor(
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
}
|
||||
};
|
||||
|
||||
processor.id = id;
|
||||
Object.defineProperty(processor, 'name', { value: 'NodeDistillationProcessor' });
|
||||
|
||||
return processor;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('NodeTruncationProcessor', () => {
|
||||
|
||||
const targets = [prompt, thought, yieldNode];
|
||||
|
||||
const result = await processor(createMockProcessArgs(targets));
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('NodeTruncationProcessor', () => {
|
||||
|
||||
const targets = [prompt, thought];
|
||||
|
||||
const result = await processor(createMockProcessArgs(targets));
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ContextProcessorFn, ProcessArgs } from '../pipeline.js';
|
||||
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';
|
||||
@@ -16,7 +16,7 @@ export function createNodeTruncationProcessor(
|
||||
id: string,
|
||||
env: ContextEnvironment,
|
||||
options: NodeTruncationProcessorOptions,
|
||||
): ContextProcessorFn {
|
||||
): ContextProcessor {
|
||||
const tryApplySquash = (
|
||||
text: string,
|
||||
limitChars: number,
|
||||
@@ -49,7 +49,10 @@ export function createNodeTruncationProcessor(
|
||||
return null;
|
||||
};
|
||||
|
||||
const processor: any = async ({ targets }: ProcessArgs) => {
|
||||
return {
|
||||
id,
|
||||
name: 'NodeTruncationProcessor',
|
||||
process: async ({ targets }: ProcessArgs) => {
|
||||
if (targets.length === 0) {
|
||||
return targets;
|
||||
}
|
||||
@@ -126,10 +129,6 @@ export function createNodeTruncationProcessor(
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
}
|
||||
};
|
||||
|
||||
processor.id = id;
|
||||
Object.defineProperty(processor, 'name', { value: 'NodeTruncationProcessor' });
|
||||
|
||||
return processor;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('RollingSummaryProcessor', () => {
|
||||
createDummyNode('ep1', 'AGENT_YIELD', 50, { text: text50 }, 'id3'),
|
||||
];
|
||||
|
||||
const result = await processor(createMockProcessArgs(targets));
|
||||
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.
|
||||
@@ -76,7 +76,7 @@ describe('RollingSummaryProcessor', () => {
|
||||
createDummyNode('ep1', 'AGENT_THOUGHT', 10, { text: text10 }, 'id2'),
|
||||
];
|
||||
|
||||
const result = await processor(createMockProcessArgs(targets));
|
||||
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);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type {
|
||||
ContextProcessorFn,
|
||||
ContextProcessor,
|
||||
ProcessArgs,
|
||||
BackstopTargetOptions,
|
||||
} from '../pipeline.js';
|
||||
@@ -21,10 +21,13 @@ export function createRollingSummaryProcessor(
|
||||
id: string,
|
||||
env: ContextEnvironment,
|
||||
options: RollingSummaryProcessorOptions,
|
||||
): ContextProcessorFn {
|
||||
): ContextProcessor {
|
||||
const generator = new SnapshotGenerator(env);
|
||||
|
||||
const processor: any = async ({ targets }: ProcessArgs) => {
|
||||
return {
|
||||
id,
|
||||
name: 'RollingSummaryProcessor',
|
||||
process: async ({ targets }: ProcessArgs) => {
|
||||
if (targets.length === 0) return targets;
|
||||
|
||||
const strategy = options.target ?? 'max';
|
||||
@@ -96,10 +99,6 @@ export function createRollingSummaryProcessor(
|
||||
debugLogger.error('RollingSummaryProcessor failed sync backstop', e);
|
||||
return targets;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processor.id = id;
|
||||
Object.defineProperty(processor, 'name', { value: 'RollingSummaryProcessor' });
|
||||
|
||||
return processor;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('StateSnapshotProcessor', () => {
|
||||
target: 'incremental',
|
||||
});
|
||||
const targets = [createDummyNode('ep1', 'USER_PROMPT')];
|
||||
const result = await processor(createMockProcessArgs(targets));
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
expect(result).toBe(targets); // Strict equality
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('StateSnapshotProcessor', () => {
|
||||
];
|
||||
|
||||
const processArgs = createMockProcessArgs(targets, [], messages);
|
||||
const result = await processor(processArgs);
|
||||
const result = await processor.process(processArgs);
|
||||
|
||||
// Should remove A and B, insert Snapshot, keep C
|
||||
expect(result.length).toBe(2);
|
||||
@@ -87,7 +87,7 @@ describe('StateSnapshotProcessor', () => {
|
||||
];
|
||||
|
||||
const processArgs = createMockProcessArgs(targets, [], messages);
|
||||
const result = await processor(processArgs);
|
||||
const result = await processor.process(processArgs);
|
||||
|
||||
// Because deficit is 0, and Inbox was rejected, nothing should change
|
||||
expect(result.length).toBe(1);
|
||||
@@ -105,7 +105,7 @@ describe('StateSnapshotProcessor', () => {
|
||||
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(createMockProcessArgs(targets));
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
// Should synthesize a new snapshot synchronously
|
||||
expect(env.llmClient.generateContent).toHaveBeenCalled();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type {
|
||||
ContextProcessorFn,
|
||||
ContextProcessor,
|
||||
ProcessArgs,
|
||||
BackstopTargetOptions,
|
||||
} from '../pipeline.js';
|
||||
@@ -22,10 +22,13 @@ export function createStateSnapshotProcessor(
|
||||
id: string,
|
||||
env: ContextEnvironment,
|
||||
options: StateSnapshotProcessorOptions,
|
||||
): ContextProcessorFn {
|
||||
): ContextProcessor {
|
||||
const generator = new SnapshotGenerator(env);
|
||||
|
||||
const processor: any = async ({
|
||||
return {
|
||||
id,
|
||||
name: 'StateSnapshotProcessor',
|
||||
process: async ({
|
||||
targets,
|
||||
inbox,
|
||||
}: ProcessArgs) => {
|
||||
@@ -160,10 +163,6 @@ export function createStateSnapshotProcessor(
|
||||
debugLogger.error('StateSnapshotProcessor failed sync backstop', e);
|
||||
return targets;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processor.id = id;
|
||||
Object.defineProperty(processor, 'name', { value: 'StateSnapshotProcessor' });
|
||||
|
||||
return processor;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('ToolMaskingProcessor', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await processor(createMockProcessArgs([toolStep]));
|
||||
const result = await processor.process(createMockProcessArgs([toolStep]));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
const masked = result[0] as ToolExecution;
|
||||
@@ -60,7 +60,7 @@ describe('ToolMaskingProcessor', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await processor(createMockProcessArgs([toolStep]));
|
||||
const result = await processor.process(createMockProcessArgs([toolStep]));
|
||||
|
||||
// Returned the exact same object reference
|
||||
expect(result[0]).toBe(toolStep);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ContextProcessorFn, ProcessArgs } from '../pipeline.js';
|
||||
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';
|
||||
@@ -67,12 +67,13 @@ export function createToolMaskingProcessor(
|
||||
id: string,
|
||||
env: ContextEnvironment,
|
||||
options: ToolMaskingProcessorOptions,
|
||||
): ContextProcessorFn {
|
||||
const isAlreadyMasked = (text: string): boolean => {
|
||||
return text.includes('<tool_output_masked>');
|
||||
};
|
||||
): ContextProcessor {
|
||||
const isAlreadyMasked = (text: string): boolean => text.includes('<tool_output_masked>');
|
||||
|
||||
const processor: any = async ({ targets }: ProcessArgs) => {
|
||||
return {
|
||||
id,
|
||||
name: 'ToolMaskingProcessor',
|
||||
process: async ({ targets }: ProcessArgs) => {
|
||||
const maskingConfig = options;
|
||||
if (!maskingConfig) return targets;
|
||||
if (targets.length === 0) return targets;
|
||||
@@ -255,10 +256,6 @@ export function createToolMaskingProcessor(
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
}
|
||||
};
|
||||
|
||||
processor.id = id;
|
||||
Object.defineProperty(processor, 'name', { value: 'ToolMaskingProcessor' });
|
||||
|
||||
return processor;
|
||||
}
|
||||
|
||||
@@ -7,14 +7,18 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { SidecarLoader } from './SidecarLoader.js';
|
||||
import { defaultSidecarProfile } from './profiles.js';
|
||||
import { SidecarRegistry } from './registry.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();
|
||||
registry.registerProcessor({ id: 'NodeTruncation', schema: { type: 'object', properties: { maxTokens: { type: 'number' } } }});
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
@@ -22,36 +26,59 @@ describe('SidecarLoader (Fake FS)', () => {
|
||||
} as unknown as Config;
|
||||
|
||||
it('returns default profile if file does not exist', () => {
|
||||
const result = SidecarLoader.fromConfig(mockConfig, fileSystem);
|
||||
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, fileSystem);
|
||||
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, fileSystem),
|
||||
SidecarLoader.fromConfig(mockConfig, registry, fileSystem),
|
||||
).toThrow('is empty');
|
||||
});
|
||||
|
||||
it('returns parsed config if file is valid', () => {
|
||||
const validConfig = {
|
||||
budget: { retainedTokens: 1000, maxTokens: 2000 },
|
||||
processorOptions: {
|
||||
myTruncation: {
|
||||
type: 'NodeTruncation',
|
||||
options: { maxTokens: 500 }
|
||||
}
|
||||
}
|
||||
};
|
||||
fileSystem.setFile('/path/to/sidecar.json', JSON.stringify(validConfig));
|
||||
const result = SidecarLoader.fromConfig(mockConfig, fileSystem);
|
||||
const result = SidecarLoader.fromConfig(mockConfig, registry, fileSystem);
|
||||
expect(result.config.budget?.maxTokens).toBe(2000);
|
||||
expect(result.config.processorOptions?.['myTruncation']).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws validation error if processorOptions contains invalid data for the schema', () => {
|
||||
const invalidConfig = {
|
||||
budget: { retainedTokens: 1000, maxTokens: 2000 },
|
||||
processorOptions: {
|
||||
myTruncation: {
|
||||
type: 'NodeTruncation',
|
||||
options: { maxTokens: "this should be a number" }
|
||||
}
|
||||
}
|
||||
};
|
||||
fileSystem.setFile('/path/to/sidecar.json', JSON.stringify(invalidConfig));
|
||||
expect(() =>
|
||||
SidecarLoader.fromConfig(mockConfig, registry, fileSystem),
|
||||
).toThrow('Validation error');
|
||||
});
|
||||
|
||||
it('throws validation error if file is empty whitespace', () => {
|
||||
fileSystem.setFile('/path/to/sidecar.json', ' \n ');
|
||||
expect(() =>
|
||||
SidecarLoader.fromConfig(mockConfig, fileSystem),
|
||||
SidecarLoader.fromConfig(mockConfig, registry, fileSystem),
|
||||
).toThrow('is empty');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { SidecarConfig } from './types.js';
|
||||
import { defaultSidecarProfile, type ContextProfile } from './profiles.js';
|
||||
import { SchemaValidator } from '../../utils/schemaValidator.js';
|
||||
import { getSidecarConfigSchema } from './schema.js';
|
||||
import type { SidecarRegistry } from './registry.js';
|
||||
import type { IFileSystem } from '../system/IFileSystem.js';
|
||||
import { NodeFileSystem } from '../system/NodeFileSystem.js';
|
||||
|
||||
@@ -17,6 +20,7 @@ export class SidecarLoader {
|
||||
*/
|
||||
static loadFromFile(
|
||||
sidecarPath: string,
|
||||
registry: SidecarRegistry,
|
||||
fileSystem: IFileSystem = new NodeFileSystem(),
|
||||
): ContextProfile {
|
||||
const fileContent = fileSystem.readFileSync(sidecarPath, 'utf8');
|
||||
@@ -36,14 +40,32 @@ export class SidecarLoader {
|
||||
);
|
||||
}
|
||||
|
||||
const customConfig = parsed as Partial<SidecarConfig>;
|
||||
// Validate the complete structure, including deep options
|
||||
const validationError = SchemaValidator.validate(
|
||||
getSidecarConfigSchema(registry),
|
||||
parsed,
|
||||
);
|
||||
|
||||
if (validationError) {
|
||||
throw new Error(
|
||||
`Invalid sidecar configuration in ${sidecarPath}. Validation error: ${validationError}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract strictly what we need since we just validated it.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const validConfig = parsed as {
|
||||
budget?: SidecarConfig['budget'];
|
||||
processorOptions?: SidecarConfig['processorOptions'];
|
||||
};
|
||||
|
||||
return {
|
||||
...defaultSidecarProfile,
|
||||
config: {
|
||||
...defaultSidecarProfile.config,
|
||||
...(customConfig.budget ? { budget: customConfig.budget } : {})
|
||||
}
|
||||
...(validConfig.budget ? { budget: validConfig.budget } : {}),
|
||||
...(validConfig.processorOptions ? { processorOptions: validConfig.processorOptions } : {})
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +75,7 @@ export class SidecarLoader {
|
||||
*/
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
registry: SidecarRegistry,
|
||||
fileSystem: IFileSystem = new NodeFileSystem(),
|
||||
): ContextProfile {
|
||||
const sidecarPath = config.getExperimentalContextSidecarConfig();
|
||||
@@ -65,7 +88,7 @@ export class SidecarLoader {
|
||||
}
|
||||
|
||||
// If the file has content, enforce strict validation and throw on failure.
|
||||
return this.loadFromFile(sidecarPath, fileSystem);
|
||||
return this.loadFromFile(sidecarPath, registry, fileSystem);
|
||||
}
|
||||
|
||||
return defaultSidecarProfile;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '../testing/contextTestUtils.js';
|
||||
import type { ContextEnvironment } from './environment.js';
|
||||
import type {
|
||||
ContextProcessorFn,
|
||||
ContextProcessor,
|
||||
ContextWorker,
|
||||
InboxSnapshot,
|
||||
ProcessArgs,
|
||||
@@ -23,38 +23,42 @@ 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
|
||||
function createModifyingProcessor(id: string): ContextProcessorFn {
|
||||
const processorFn = async (args: ProcessArgs) => {
|
||||
const newTargets = [...args.targets];
|
||||
if (newTargets.length > 0 && newTargets[0].type === 'USER_PROMPT') {
|
||||
const prompt = newTargets[0] as UserPrompt;
|
||||
const newParts = [...prompt.semanticParts];
|
||||
if (newParts.length > 0 && newParts[0].type === 'text') {
|
||||
newParts[0] = {
|
||||
...newParts[0],
|
||||
text: newParts[0].text + ' [modified]',
|
||||
function createModifyingProcessor(id: string): ContextProcessor {
|
||||
return {
|
||||
id,
|
||||
name: 'ModifyingProcessor',
|
||||
process: async (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,
|
||||
};
|
||||
}
|
||||
newTargets[0] = {
|
||||
...prompt,
|
||||
id: prompt.id + '-modified',
|
||||
replacesId: prompt.id,
|
||||
semanticParts: newParts,
|
||||
};
|
||||
return newTargets;
|
||||
}
|
||||
return newTargets;
|
||||
};
|
||||
Object.defineProperty(processorFn, 'name', { value: 'ModifyingProcessor' });
|
||||
return Object.assign(processorFn, { id });
|
||||
}
|
||||
|
||||
// A processor that just throws an error
|
||||
function createThrowingProcessor(id: string): ContextProcessorFn {
|
||||
const processorFn = async (): Promise<readonly ConcreteNode[]> => {
|
||||
throw new Error('Processor failed intentionally');
|
||||
function createThrowingProcessor(id: string): ContextProcessor {
|
||||
return {
|
||||
id,
|
||||
name: 'Throwing',
|
||||
process: async (): Promise<readonly ConcreteNode[]> => {
|
||||
throw new Error('Processor failed intentionally');
|
||||
}
|
||||
};
|
||||
Object.defineProperty(processorFn, 'name', { value: 'Throwing' });
|
||||
return Object.assign(processorFn, { id });
|
||||
}
|
||||
|
||||
// A mock worker that signals it ran
|
||||
|
||||
@@ -165,7 +165,7 @@ export class PipelineOrchestrator {
|
||||
this.isNodeAllowed(n, triggerTargets, protectedLogicalIds),
|
||||
);
|
||||
|
||||
const returnedNodes = await processor({
|
||||
const returnedNodes = await processor.process({
|
||||
buffer: currentBuffer,
|
||||
targets: allowedTargets,
|
||||
inbox: inboxSnapshot,
|
||||
@@ -219,7 +219,7 @@ export class PipelineOrchestrator {
|
||||
this.isNodeAllowed(n, triggerTargets, protectedLogicalIds),
|
||||
);
|
||||
|
||||
const returnedNodes = await processor({
|
||||
const returnedNodes = await processor.process({
|
||||
buffer: currentBuffer,
|
||||
targets: allowedTargets,
|
||||
inbox: inboxSnapshot,
|
||||
|
||||
@@ -18,8 +18,8 @@ import { createStateSnapshotWorker } from '../processors/stateSnapshotWorker.js'
|
||||
|
||||
export interface ContextProfile {
|
||||
config: SidecarConfig;
|
||||
buildPipelines: (env: ContextEnvironment) => PipelineDef[];
|
||||
buildWorkers: (env: ContextEnvironment) => ContextWorker[];
|
||||
buildPipelines: (env: ContextEnvironment, config?: SidecarConfig) => PipelineDef[];
|
||||
buildWorkers: (env: ContextEnvironment, config?: SidecarConfig) => ContextWorker[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,33 +34,54 @@ export const defaultSidecarProfile: ContextProfile = {
|
||||
},
|
||||
},
|
||||
|
||||
buildPipelines: (env: ContextEnvironment): PipelineDef[] => [
|
||||
{
|
||||
name: 'Immediate Sanitization',
|
||||
triggers: ['new_message'],
|
||||
processors: [
|
||||
createToolMaskingProcessor('ToolMasking', env, { stringLengthThresholdTokens: 8000 }),
|
||||
createBlobDegradationProcessor('BlobDegradation', env),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Normalization',
|
||||
triggers: ['retained_exceeded'],
|
||||
processors: [
|
||||
createNodeTruncationProcessor('NodeTruncation', env, { maxTokensPerNode: 3000 }),
|
||||
createNodeDistillationProcessor('NodeDistillation', env, { nodeThresholdTokens: 5000 }),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Emergency Backstop',
|
||||
triggers: ['gc_backstop'],
|
||||
processors: [
|
||||
createStateSnapshotProcessor('StateSnapshotSync', env, { target: 'max' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
buildPipelines: (env: ContextEnvironment, config?: SidecarConfig): PipelineDef[] => {
|
||||
// Helper to merge default options with dynamically loaded processorOptions by ID
|
||||
const getOptions = <T>(id: string, defaultOptions: T): T => {
|
||||
if (config?.processorOptions && config.processorOptions[id]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return { ...defaultOptions, ...(config.processorOptions[id].options as T) };
|
||||
}
|
||||
return defaultOptions;
|
||||
};
|
||||
|
||||
buildWorkers: (env: ContextEnvironment): ContextWorker[] => [
|
||||
createStateSnapshotWorker('StateSnapshotAsync', env, { type: 'accumulate' })
|
||||
]
|
||||
return [
|
||||
{
|
||||
name: 'Immediate Sanitization',
|
||||
triggers: ['new_message'],
|
||||
processors: [
|
||||
createToolMaskingProcessor('ToolMasking', env, getOptions('ToolMasking', { stringLengthThresholdTokens: 8000 })),
|
||||
createBlobDegradationProcessor('BlobDegradation', env), // No options
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Normalization',
|
||||
triggers: ['retained_exceeded'],
|
||||
processors: [
|
||||
createNodeTruncationProcessor('NodeTruncation', env, getOptions('NodeTruncation', { maxTokensPerNode: 3000 })),
|
||||
createNodeDistillationProcessor('NodeDistillation', env, getOptions('NodeDistillation', { nodeThresholdTokens: 5000 })),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Emergency Backstop',
|
||||
triggers: ['gc_backstop'],
|
||||
processors: [
|
||||
createStateSnapshotProcessor('StateSnapshotSync', env, getOptions('StateSnapshotSync', { target: 'max' })),
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
buildWorkers: (env: ContextEnvironment, config?: SidecarConfig): ContextWorker[] => {
|
||||
const getOptions = <T>(id: string, defaultOptions: T): T => {
|
||||
if (config?.processorOptions && config.processorOptions[id]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return { ...defaultOptions, ...(config.processorOptions[id].options as T) };
|
||||
}
|
||||
return defaultOptions;
|
||||
};
|
||||
|
||||
return [
|
||||
createStateSnapshotWorker('StateSnapshotAsync', env, getOptions('StateSnapshotAsync', { type: 'accumulate' }))
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface ContextProcessorDef {
|
||||
readonly id: string;
|
||||
readonly schema: object;
|
||||
}
|
||||
|
||||
export interface ContextWorkerDef {
|
||||
readonly id: string;
|
||||
readonly schema: object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry for validating declarative sidecar configuration schemas.
|
||||
* (Dynamic instantiation has been replaced by static ContextProfiles)
|
||||
*/
|
||||
export class SidecarRegistry {
|
||||
private processors = new Map<string, ContextProcessorDef>();
|
||||
private workers = new Map<string, ContextWorkerDef>();
|
||||
|
||||
registerProcessor(def: ContextProcessorDef) {
|
||||
this.processors.set(def.id, def);
|
||||
}
|
||||
|
||||
registerWorker(def: ContextWorkerDef) {
|
||||
this.workers.set(def.id, def);
|
||||
}
|
||||
|
||||
getSchema(id: string): object | undefined {
|
||||
return this.processors.get(id)?.schema || this.workers.get(id)?.schema;
|
||||
}
|
||||
|
||||
getSchemaDefs(): { id: string; schema: object }[] {
|
||||
const defs: { id: string; schema: object }[] = [];
|
||||
for (const def of this.processors.values()) {
|
||||
if (def.schema) defs.push({ id: def.id, schema: def.schema });
|
||||
}
|
||||
for (const def of this.workers.values()) {
|
||||
if (def.schema) defs.push({ id: def.id, schema: def.schema });
|
||||
}
|
||||
return defs;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.processors.clear();
|
||||
this.workers.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SidecarRegistry } from './registry.js';
|
||||
|
||||
export function getSidecarConfigSchema(registry?: SidecarRegistry) {
|
||||
// If a registry is provided, we can deeply validate processor overrides.
|
||||
// We do this by generating a `oneOf` list that matches the `type` discriminator
|
||||
// to the specific processor `options` schema.
|
||||
const processorOptionSchemas = registry ? registry.getSchemaDefs().map(def => ({
|
||||
type: 'object',
|
||||
required: ['type', 'options'],
|
||||
properties: {
|
||||
type: { const: def.id },
|
||||
options: def.schema
|
||||
}
|
||||
})) : [];
|
||||
|
||||
const processorOptionsMapping = processorOptionSchemas.length > 0
|
||||
? { oneOf: processorOptionSchemas }
|
||||
: {
|
||||
type: 'object',
|
||||
required: ['type', 'options'],
|
||||
properties: {
|
||||
type: { type: 'string', description: 'The registry type of the processor (e.g. NodeTruncation)' },
|
||||
options: { type: 'object', description: 'The hyperparameter overrides' }
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
title: 'SidecarConfig',
|
||||
description: 'The Hyperparameter schema for a Context Profile.',
|
||||
type: 'object',
|
||||
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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
processorOptions: {
|
||||
type: 'object',
|
||||
description: 'Named hyperparameter configurations for ContextProcessors and Workers.',
|
||||
additionalProperties: processorOptionsMapping
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ContextProcessorFn } from '../pipeline.js';
|
||||
import type { ContextProcessor } from '../pipeline.js';
|
||||
|
||||
export type PipelineTrigger =
|
||||
| 'new_message'
|
||||
@@ -15,7 +15,7 @@ export type PipelineTrigger =
|
||||
export interface PipelineDef {
|
||||
name: string;
|
||||
triggers: PipelineTrigger[];
|
||||
processors: ContextProcessorFn[];
|
||||
processors: ContextProcessor[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,4 +27,9 @@ export interface SidecarConfig {
|
||||
retainedTokens: number;
|
||||
maxTokens: number;
|
||||
};
|
||||
/**
|
||||
* Dynamic hyperparameter overrides for individual ContextProcessors and Workers.
|
||||
* Keys are named identifiers (e.g. "gentleTruncation").
|
||||
*/
|
||||
processorOptions?: Record<string, { type: string; options: unknown }>;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { ContextTracer } from '../tracer.js';
|
||||
import { ContextEnvironmentImpl } from '../sidecar/environmentImpl.js';
|
||||
import { SidecarLoader } from '../sidecar/SidecarLoader.js';
|
||||
import { SidecarRegistry } from '../sidecar/registry.js';
|
||||
import { ContextEventBus } from '../eventBus.js';
|
||||
import { PipelineOrchestrator } from '../sidecar/orchestrator.js';
|
||||
import type { ConcreteNode, ToolExecution } from '../ir/types.js';
|
||||
@@ -246,7 +247,8 @@ export function setupContextComponentTest(
|
||||
sidecarOverride?: ContextProfile,
|
||||
): { chatHistory: AgentChatHistory; contextManager: ContextManager } {
|
||||
const chatHistory = new AgentChatHistory();
|
||||
const sidecar = sidecarOverride || SidecarLoader.fromConfig(config);
|
||||
const registry = new SidecarRegistry(); // Provide an empty registry for tests, or one pre-filled by the caller if needed later
|
||||
const sidecar = sidecarOverride || SidecarLoader.fromConfig(config, registry);
|
||||
const tracer = new ContextTracer({
|
||||
targetDir: '/tmp',
|
||||
sessionId: 'test-session',
|
||||
|
||||
Reference in New Issue
Block a user