diff --git a/packages/core/src/context/pipeline.ts b/packages/core/src/context/pipeline.ts index 34619b1b88..870b4b097d 100644 --- a/packages/core/src/context/pipeline.ts +++ b/packages/core/src/context/pipeline.ts @@ -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; + process(args: ProcessArgs): Promise; } export interface ContextWorker { diff --git a/packages/core/src/context/processors/blobDegradationProcessor.test.ts b/packages/core/src/context/processors/blobDegradationProcessor.test.ts index 60c82f45d6..5bd5d38dcb 100644 --- a/packages/core/src/context/processors/blobDegradationProcessor.test.ts +++ b/packages/core/src/context/processors/blobDegradationProcessor.test.ts @@ -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); }); diff --git a/packages/core/src/context/processors/blobDegradationProcessor.ts b/packages/core/src/context/processors/blobDegradationProcessor.ts index 692c37065e..d75a9bf1a0 100644 --- a/packages/core/src/context/processors/blobDegradationProcessor.ts +++ b/packages/core/src/context/processors/blobDegradationProcessor.ts @@ -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; } diff --git a/packages/core/src/context/processors/historyTruncationProcessor.ts b/packages/core/src/context/processors/historyTruncationProcessor.ts index 8fdd56dbd5..813a77205d 100644 --- a/packages/core/src/context/processors/historyTruncationProcessor.ts +++ b/packages/core/src/context/processors/historyTruncationProcessor.ts @@ -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; } diff --git a/packages/core/src/context/processors/nodeDistillationProcessor.test.ts b/packages/core/src/context/processors/nodeDistillationProcessor.test.ts index 0ef16fcf88..74b7cc47a0 100644 --- a/packages/core/src/context/processors/nodeDistillationProcessor.test.ts +++ b/packages/core/src/context/processors/nodeDistillationProcessor.test.ts @@ -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); diff --git a/packages/core/src/context/processors/nodeDistillationProcessor.ts b/packages/core/src/context/processors/nodeDistillationProcessor.ts index 61bfa930c9..53c5c3f425 100644 --- a/packages/core/src/context/processors/nodeDistillationProcessor.ts +++ b/packages/core/src/context/processors/nodeDistillationProcessor.ts @@ -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; } diff --git a/packages/core/src/context/processors/nodeTruncationProcessor.test.ts b/packages/core/src/context/processors/nodeTruncationProcessor.test.ts index 1d88ba4e48..2724fc3445 100644 --- a/packages/core/src/context/processors/nodeTruncationProcessor.test.ts +++ b/packages/core/src/context/processors/nodeTruncationProcessor.test.ts @@ -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); diff --git a/packages/core/src/context/processors/nodeTruncationProcessor.ts b/packages/core/src/context/processors/nodeTruncationProcessor.ts index b52172d4d6..7176d4fd17 100644 --- a/packages/core/src/context/processors/nodeTruncationProcessor.ts +++ b/packages/core/src/context/processors/nodeTruncationProcessor.ts @@ -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; } diff --git a/packages/core/src/context/processors/rollingSummaryProcessor.test.ts b/packages/core/src/context/processors/rollingSummaryProcessor.test.ts index 4567d73d18..5e3ef3b4f3 100644 --- a/packages/core/src/context/processors/rollingSummaryProcessor.test.ts +++ b/packages/core/src/context/processors/rollingSummaryProcessor.test.ts @@ -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); diff --git a/packages/core/src/context/processors/rollingSummaryProcessor.ts b/packages/core/src/context/processors/rollingSummaryProcessor.ts index 632f2eb0d1..a90b35b4d6 100644 --- a/packages/core/src/context/processors/rollingSummaryProcessor.ts +++ b/packages/core/src/context/processors/rollingSummaryProcessor.ts @@ -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; } diff --git a/packages/core/src/context/processors/stateSnapshotProcessor.test.ts b/packages/core/src/context/processors/stateSnapshotProcessor.test.ts index 63aab68ff7..5103d3399f 100644 --- a/packages/core/src/context/processors/stateSnapshotProcessor.test.ts +++ b/packages/core/src/context/processors/stateSnapshotProcessor.test.ts @@ -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(); diff --git a/packages/core/src/context/processors/stateSnapshotProcessor.ts b/packages/core/src/context/processors/stateSnapshotProcessor.ts index 217a594e11..9ffc722c8f 100644 --- a/packages/core/src/context/processors/stateSnapshotProcessor.ts +++ b/packages/core/src/context/processors/stateSnapshotProcessor.ts @@ -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; } diff --git a/packages/core/src/context/processors/toolMaskingProcessor.test.ts b/packages/core/src/context/processors/toolMaskingProcessor.test.ts index 0356dabc90..8cfe50e4e6 100644 --- a/packages/core/src/context/processors/toolMaskingProcessor.test.ts +++ b/packages/core/src/context/processors/toolMaskingProcessor.test.ts @@ -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); diff --git a/packages/core/src/context/processors/toolMaskingProcessor.ts b/packages/core/src/context/processors/toolMaskingProcessor.ts index b08494a8e3..c4b402ad5a 100644 --- a/packages/core/src/context/processors/toolMaskingProcessor.ts +++ b/packages/core/src/context/processors/toolMaskingProcessor.ts @@ -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(''); - }; +): ContextProcessor { + const isAlreadyMasked = (text: string): boolean => text.includes(''); - 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; } diff --git a/packages/core/src/context/sidecar/SidecarLoader.test.ts b/packages/core/src/context/sidecar/SidecarLoader.test.ts index 9a3a06ee1f..1779dcc557 100644 --- a/packages/core/src/context/sidecar/SidecarLoader.test.ts +++ b/packages/core/src/context/sidecar/SidecarLoader.test.ts @@ -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'); }); }); diff --git a/packages/core/src/context/sidecar/SidecarLoader.ts b/packages/core/src/context/sidecar/SidecarLoader.ts index 55a287b724..8a3ac06833 100644 --- a/packages/core/src/context/sidecar/SidecarLoader.ts +++ b/packages/core/src/context/sidecar/SidecarLoader.ts @@ -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; + // 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; diff --git a/packages/core/src/context/sidecar/orchestrator.test.ts b/packages/core/src/context/sidecar/orchestrator.test.ts index 3e10c0b98d..0c946e0a20 100644 --- a/packages/core/src/context/sidecar/orchestrator.test.ts +++ b/packages/core/src/context/sidecar/orchestrator.test.ts @@ -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 => { - throw new Error('Processor failed intentionally'); +function createThrowingProcessor(id: string): ContextProcessor { + return { + id, + name: 'Throwing', + process: async (): Promise => { + throw new Error('Processor failed intentionally'); + } }; - Object.defineProperty(processorFn, 'name', { value: 'Throwing' }); - return Object.assign(processorFn, { id }); } // A mock worker that signals it ran diff --git a/packages/core/src/context/sidecar/orchestrator.ts b/packages/core/src/context/sidecar/orchestrator.ts index 4757720daf..030b32e2c7 100644 --- a/packages/core/src/context/sidecar/orchestrator.ts +++ b/packages/core/src/context/sidecar/orchestrator.ts @@ -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, diff --git a/packages/core/src/context/sidecar/profiles.ts b/packages/core/src/context/sidecar/profiles.ts index 38a262d108..a86534bd03 100644 --- a/packages/core/src/context/sidecar/profiles.ts +++ b/packages/core/src/context/sidecar/profiles.ts @@ -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 = (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 = (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' })) + ]; + } }; diff --git a/packages/core/src/context/sidecar/registry.ts b/packages/core/src/context/sidecar/registry.ts new file mode 100644 index 0000000000..f8793ff186 --- /dev/null +++ b/packages/core/src/context/sidecar/registry.ts @@ -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(); + private workers = new Map(); + + 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(); + } +} diff --git a/packages/core/src/context/sidecar/schema.ts b/packages/core/src/context/sidecar/schema.ts new file mode 100644 index 0000000000..99ec3178fb --- /dev/null +++ b/packages/core/src/context/sidecar/schema.ts @@ -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 + } + }, + }; +} diff --git a/packages/core/src/context/sidecar/types.ts b/packages/core/src/context/sidecar/types.ts index 8119880cf4..5d23688495 100644 --- a/packages/core/src/context/sidecar/types.ts +++ b/packages/core/src/context/sidecar/types.ts @@ -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; } diff --git a/packages/core/src/context/testing/contextTestUtils.ts b/packages/core/src/context/testing/contextTestUtils.ts index 7c8c8bdb2b..85695ba56c 100644 --- a/packages/core/src/context/testing/contextTestUtils.ts +++ b/packages/core/src/context/testing/contextTestUtils.ts @@ -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',