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:
Your Name
2026-04-10 00:53:32 +00:00
parent 0ebc369d2c
commit be4bee5f1a
23 changed files with 334 additions and 146 deletions
+3 -3
View File
@@ -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,
+51 -30
View File
@@ -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
}
},
};
}
+7 -2
View File
@@ -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',