Skip to content

Commit daf5006

Browse files
authored
feat(core): introduce decoupled ContextManager and Sidecar architecture (#24752)
1 parent 706d4d4 commit daf5006

54 files changed

Lines changed: 6454 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/core/src/config/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,7 @@ export interface ConfigParameters {
699699
experimentalJitContext?: boolean;
700700
autoDistillation?: boolean;
701701
experimentalMemoryManager?: boolean;
702+
experimentalContextManagementConfig?: string;
702703
experimentalAgentHistoryTruncation?: boolean;
703704
experimentalAgentHistoryTruncationThreshold?: number;
704705
experimentalAgentHistoryRetainedMessages?: number;
@@ -939,6 +940,7 @@ export class Config implements McpContext, AgentLoopContext {
939940
private readonly adminSkillsEnabled: boolean;
940941
private readonly experimentalJitContext: boolean;
941942
private readonly experimentalMemoryManager: boolean;
943+
private readonly experimentalContextManagementConfig?: string;
942944
private readonly memoryBoundaryMarkers: readonly string[];
943945
private readonly topicUpdateNarration: boolean;
944946
private readonly disableLLMCorrection: boolean;
@@ -1150,6 +1152,8 @@ export class Config implements McpContext, AgentLoopContext {
11501152

11511153
this.experimentalJitContext = params.experimentalJitContext ?? false;
11521154
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
1155+
this.experimentalContextManagementConfig =
1156+
params.experimentalContextManagementConfig;
11531157
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
11541158
this.contextManagement = {
11551159
enabled: params.contextManagement?.enabled ?? false,
@@ -2434,6 +2438,10 @@ export class Config implements McpContext, AgentLoopContext {
24342438
return this.experimentalMemoryManager;
24352439
}
24362440

2441+
getExperimentalContextManagementConfig(): string | undefined {
2442+
return this.experimentalContextManagementConfig;
2443+
}
2444+
24372445
getContextManagementConfig(): ContextManagementConfig {
24382446
return this.contextManagement;
24392447
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
8+
import { loadContextManagementConfig } from './configLoader.js';
9+
import { defaultContextProfile } from './profiles.js';
10+
import { ContextProcessorRegistry } from './registry.js';
11+
import * as fs from 'node:fs/promises';
12+
import * as path from 'node:path';
13+
import * as os from 'node:os';
14+
import type { Config } from '../../config/config.js';
15+
import type { JSONSchemaType } from 'ajv';
16+
17+
describe('SidecarLoader (Real FS)', () => {
18+
let tmpDir: string;
19+
let registry: ContextProcessorRegistry;
20+
let sidecarPath: string;
21+
let mockConfig: Config;
22+
23+
beforeEach(async () => {
24+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-sidecar-test-'));
25+
sidecarPath = path.join(tmpDir, 'sidecar.json');
26+
registry = new ContextProcessorRegistry();
27+
registry.registerProcessor({
28+
id: 'NodeTruncation',
29+
schema: {
30+
type: 'object',
31+
properties: { maxTokens: { type: 'number' } },
32+
required: ['maxTokens'],
33+
} as unknown as JSONSchemaType<{ maxTokens: number }>,
34+
});
35+
36+
mockConfig = {
37+
getExperimentalContextManagementConfig: () => sidecarPath,
38+
} as unknown as Config;
39+
});
40+
41+
afterEach(async () => {
42+
await fs.rm(tmpDir, { recursive: true, force: true });
43+
});
44+
45+
it('returns default profile if file does not exist', async () => {
46+
const result = await loadContextManagementConfig(mockConfig, registry);
47+
expect(result).toBe(defaultContextProfile);
48+
});
49+
50+
it('returns default profile if file exists but is 0 bytes', async () => {
51+
await fs.writeFile(sidecarPath, '');
52+
const result = await loadContextManagementConfig(mockConfig, registry);
53+
expect(result).toBe(defaultContextProfile);
54+
});
55+
56+
it('returns parsed config if file is valid', async () => {
57+
const validConfig = {
58+
budget: { retainedTokens: 1000, maxTokens: 2000 },
59+
processorOptions: {
60+
myTruncation: {
61+
type: 'NodeTruncation',
62+
options: { maxTokens: 500 },
63+
},
64+
},
65+
};
66+
await fs.writeFile(sidecarPath, JSON.stringify(validConfig));
67+
const result = await loadContextManagementConfig(mockConfig, registry);
68+
expect(result.config.budget?.maxTokens).toBe(2000);
69+
expect(result.config.processorOptions?.['myTruncation']).toBeDefined();
70+
});
71+
72+
it('throws validation error if processorOptions contains invalid data for the schema', async () => {
73+
const invalidConfig = {
74+
budget: { retainedTokens: 1000, maxTokens: 2000 },
75+
processorOptions: {
76+
myTruncation: {
77+
type: 'NodeTruncation',
78+
options: { maxTokens: 'this should be a number' },
79+
},
80+
},
81+
};
82+
await fs.writeFile(sidecarPath, JSON.stringify(invalidConfig));
83+
await expect(
84+
loadContextManagementConfig(mockConfig, registry),
85+
).rejects.toThrow('Validation error');
86+
});
87+
88+
it('throws validation error if file is empty whitespace', async () => {
89+
await fs.writeFile(sidecarPath, ' \n ');
90+
await expect(
91+
loadContextManagementConfig(mockConfig, registry),
92+
).rejects.toThrow('Unexpected end of JSON input');
93+
});
94+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type { Config } from '../../config/config.js';
8+
import * as fsSync from 'node:fs';
9+
import * as fs from 'node:fs/promises';
10+
import type { ContextManagementConfig } from './types.js';
11+
import { defaultContextProfile, type ContextProfile } from './profiles.js';
12+
import { SchemaValidator } from '../../utils/schemaValidator.js';
13+
import { getContextManagementConfigSchema } from './schema.js';
14+
import type { ContextProcessorRegistry } from './registry.js';
15+
import { getErrorMessage } from '../../utils/errors.js';
16+
17+
/**
18+
* Loads and validates a sidecar config from a specific file path.
19+
* Throws an error if the file cannot be read, parsed, or fails schema validation.
20+
*/
21+
async function loadConfigFromFile(
22+
sidecarPath: string,
23+
registry: ContextProcessorRegistry,
24+
): Promise<ContextProfile> {
25+
const fileContent = await fs.readFile(sidecarPath, 'utf8');
26+
let parsed: unknown;
27+
try {
28+
parsed = JSON.parse(fileContent);
29+
} catch (error) {
30+
throw new Error(
31+
`Failed to parse Sidecar configuration file at ${sidecarPath}: ${getErrorMessage(
32+
error,
33+
)}`,
34+
);
35+
}
36+
37+
// Validate the complete structure, including deep options
38+
const validationError = SchemaValidator.validate(
39+
getContextManagementConfigSchema(registry),
40+
parsed,
41+
);
42+
43+
if (validationError) {
44+
throw new Error(
45+
`Invalid sidecar configuration in ${sidecarPath}. Validation error: ${validationError}`,
46+
);
47+
}
48+
49+
// Extract strictly what we need.
50+
// Why this unsafe cast is acceptable:
51+
// SchemaValidator just ran \`getSidecarConfigSchema(registry)\` against \`parsed\`.
52+
// That function dynamically maps the \`processorOptions\` to strict JSON schema definitions,
53+
// so we know with absolute certainty at runtime that \`parsed\` conforms to this shape.
54+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
55+
const validConfig = parsed as ContextManagementConfig;
56+
return {
57+
...defaultContextProfile,
58+
config: {
59+
...defaultContextProfile.config,
60+
...(validConfig.budget ? { budget: validConfig.budget } : {}),
61+
...(validConfig.processorOptions
62+
? { processorOptions: validConfig.processorOptions }
63+
: {}),
64+
},
65+
};
66+
}
67+
68+
/**
69+
* Generates a Sidecar JSON graph from the experimental config file path or defaults.
70+
* If a config file is present but invalid, this will THROW to prevent silent misconfiguration.
71+
*/
72+
export async function loadContextManagementConfig(
73+
config: Config,
74+
registry: ContextProcessorRegistry,
75+
): Promise<ContextProfile> {
76+
const sidecarPath = config.getExperimentalContextManagementConfig();
77+
78+
if (sidecarPath && fsSync.existsSync(sidecarPath)) {
79+
const size = fsSync.statSync(sidecarPath).size;
80+
// If the file exists but is completely empty (0 bytes), it's safe to fallback.
81+
if (size === 0) {
82+
return defaultContextProfile;
83+
}
84+
85+
// If the file has content, enforce strict validation and throw on failure.
86+
return loadConfigFromFile(sidecarPath, registry);
87+
}
88+
89+
return defaultContextProfile;
90+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type {
8+
AsyncPipelineDef,
9+
ContextManagementConfig,
10+
PipelineDef,
11+
} from './types.js';
12+
import type { ContextEnvironment } from '../pipeline/environment.js';
13+
14+
// Import factories
15+
import { createToolMaskingProcessor } from '../processors/toolMaskingProcessor.js';
16+
import { createBlobDegradationProcessor } from '../processors/blobDegradationProcessor.js';
17+
import { createNodeTruncationProcessor } from '../processors/nodeTruncationProcessor.js';
18+
import { createNodeDistillationProcessor } from '../processors/nodeDistillationProcessor.js';
19+
import { createStateSnapshotProcessor } from '../processors/stateSnapshotProcessor.js';
20+
import { createStateSnapshotAsyncProcessor } from '../processors/stateSnapshotAsyncProcessor.js';
21+
22+
/**
23+
* Helper to safely merge static default options with dynamically loaded
24+
* JSON overrides from the SidecarConfig.
25+
*
26+
* Why the unsafe cast is acceptable here:
27+
* Before the \`config\` object ever reaches this function, \`SidecarLoader.ts\`
28+
* passes the raw JSON through \`SchemaValidator\`. The schema dynamically generates
29+
* a \`oneOf\` map linking every \`type\` discriminator to its corresponding processor
30+
* schema definition. By the time we access \`options\` here, its shape has been
31+
* strictly validated against the corresponding Zod/JSONSchema definition at runtime,
32+
* making the generic cast to \`<T>\` structurally safe.
33+
*/
34+
function resolveProcessorOptions<T>(
35+
config: ContextManagementConfig | undefined,
36+
id: string,
37+
defaultOptions: T,
38+
): T {
39+
if (config?.processorOptions && config.processorOptions[id]) {
40+
return {
41+
...defaultOptions,
42+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
43+
...(config.processorOptions[id].options as T),
44+
};
45+
}
46+
return defaultOptions;
47+
}
48+
49+
export interface ContextProfile {
50+
config: ContextManagementConfig;
51+
buildPipelines: (
52+
env: ContextEnvironment,
53+
config?: ContextManagementConfig,
54+
) => PipelineDef[];
55+
buildAsyncPipelines: (
56+
env: ContextEnvironment,
57+
config?: ContextManagementConfig,
58+
) => AsyncPipelineDef[];
59+
}
60+
61+
/**
62+
* The standard default context management profile.
63+
* Optimized for safety, precision, and reliable summarization.
64+
*/
65+
export const defaultContextProfile: ContextProfile = {
66+
config: {
67+
budget: {
68+
retainedTokens: 65000,
69+
maxTokens: 150000,
70+
},
71+
},
72+
73+
buildPipelines: (
74+
env: ContextEnvironment,
75+
config?: ContextManagementConfig,
76+
): PipelineDef[] =>
77+
// Helper to merge default options with dynamically loaded processorOptions by ID
78+
[
79+
{
80+
name: 'Immediate Sanitization',
81+
triggers: ['new_message'],
82+
processors: [
83+
createToolMaskingProcessor(
84+
'ToolMasking',
85+
env,
86+
resolveProcessorOptions(config, 'ToolMasking', {
87+
stringLengthThresholdTokens: 8000,
88+
}),
89+
),
90+
createBlobDegradationProcessor('BlobDegradation', env), // No options
91+
],
92+
},
93+
{
94+
name: 'Normalization',
95+
triggers: ['retained_exceeded'],
96+
processors: [
97+
createNodeTruncationProcessor(
98+
'NodeTruncation',
99+
env,
100+
resolveProcessorOptions(config, 'NodeTruncation', {
101+
maxTokensPerNode: 3000,
102+
}),
103+
),
104+
createNodeDistillationProcessor(
105+
'NodeDistillation',
106+
env,
107+
resolveProcessorOptions(config, 'NodeDistillation', {
108+
nodeThresholdTokens: 5000,
109+
}),
110+
),
111+
],
112+
},
113+
{
114+
name: 'Emergency Backstop',
115+
triggers: ['gc_backstop'],
116+
processors: [
117+
createStateSnapshotProcessor(
118+
'StateSnapshotSync',
119+
env,
120+
resolveProcessorOptions(config, 'StateSnapshotSync', {
121+
target: 'max',
122+
}),
123+
),
124+
],
125+
},
126+
],
127+
buildAsyncPipelines: (
128+
env: ContextEnvironment,
129+
config?: ContextManagementConfig,
130+
): AsyncPipelineDef[] => [
131+
{
132+
name: 'Async Background GC',
133+
triggers: ['nodes_aged_out'],
134+
processors: [
135+
createStateSnapshotAsyncProcessor(
136+
'StateSnapshotAsync',
137+
env,
138+
resolveProcessorOptions(config, 'StateSnapshotAsync', {
139+
type: 'accumulate',
140+
}),
141+
),
142+
],
143+
},
144+
],
145+
};

0 commit comments

Comments
 (0)