mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-10 18:10:43 -07:00
feat(core): introduce decoupled ContextManager and Sidecar architecture (#24752)
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import { AgentChatHistory } from '../../core/agentChatHistory.js';
|
||||
import { ContextManager } from '../contextManager.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { ContextTracer } from '../tracer.js';
|
||||
import { ContextEnvironmentImpl } from '../pipeline/environmentImpl.js';
|
||||
import { ContextEventBus } from '../eventBus.js';
|
||||
import { PipelineOrchestrator } from '../pipeline/orchestrator.js';
|
||||
import type { ConcreteNode, ToolExecution } from '../graph/types.js';
|
||||
import type { ContextEnvironment } from '../pipeline/environment.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
|
||||
import type { Content, GenerateContentResponse } from '@google/genai';
|
||||
import { InboxSnapshotImpl } from '../pipeline/inbox.js';
|
||||
import type { InboxMessage, ProcessArgs } from '../pipeline.js';
|
||||
import type { ContextProfile } from '../config/profiles.js';
|
||||
import type { Mock } from 'vitest';
|
||||
import { ContextWorkingBufferImpl } from '../pipeline/contextWorkingBuffer.js';
|
||||
import { testTruncateProfile } from './testProfile.js';
|
||||
|
||||
/**
|
||||
* Creates a valid mock GenerateContentResponse with the provided text.
|
||||
* Used to avoid having to manually construct the deeply nested candidate/content/part structure.
|
||||
*/
|
||||
export const createMockGenerateContentResponse = (
|
||||
text: string,
|
||||
): GenerateContentResponse =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
({
|
||||
candidates: [{ content: { role: 'model', parts: [{ text }] }, index: 0 }],
|
||||
}) as GenerateContentResponse;
|
||||
|
||||
export function createDummyNode(
|
||||
logicalParentId: string,
|
||||
type: ConcreteNode['type'],
|
||||
tokens = 100,
|
||||
overrides?: Partial<ConcreteNode>,
|
||||
id?: string,
|
||||
): ConcreteNode {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
id: id || randomUUID(),
|
||||
episodeId: logicalParentId,
|
||||
logicalParentId,
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
text: `Dummy ${type}`,
|
||||
name: type === 'SYSTEM_EVENT' ? 'dummy_event' : undefined,
|
||||
payload: type === 'SYSTEM_EVENT' ? {} : undefined,
|
||||
semanticParts: [],
|
||||
metadata: {
|
||||
originalTokens: tokens,
|
||||
currentTokens: tokens,
|
||||
transformations: [],
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ConcreteNode;
|
||||
}
|
||||
|
||||
export function createDummyToolNode(
|
||||
logicalParentId: string,
|
||||
intentTokens = 100,
|
||||
obsTokens = 200,
|
||||
overrides?: Partial<ToolExecution>,
|
||||
id?: string,
|
||||
): ToolExecution {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
id: id || randomUUID(),
|
||||
episodeId: logicalParentId,
|
||||
logicalParentId,
|
||||
type: 'TOOL_EXECUTION',
|
||||
timestamp: Date.now(),
|
||||
toolName: 'dummy_tool',
|
||||
intent: { action: 'test' },
|
||||
observation: { result: 'ok' },
|
||||
tokens: {
|
||||
intent: intentTokens,
|
||||
observation: obsTokens,
|
||||
},
|
||||
metadata: {
|
||||
originalTokens: intentTokens + obsTokens,
|
||||
currentTokens: intentTokens + obsTokens,
|
||||
transformations: [],
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ToolExecution;
|
||||
}
|
||||
|
||||
export interface MockLlmClient extends BaseLlmClient {
|
||||
generateContent: Mock;
|
||||
}
|
||||
|
||||
export function createMockLlmClient(
|
||||
responses?: Array<string | GenerateContentResponse>,
|
||||
): MockLlmClient {
|
||||
const generateContentMock = vi.fn();
|
||||
|
||||
if (responses && responses.length > 0) {
|
||||
for (const response of responses) {
|
||||
if (typeof response === 'string') {
|
||||
generateContentMock.mockResolvedValueOnce(
|
||||
createMockGenerateContentResponse(response),
|
||||
);
|
||||
} else {
|
||||
generateContentMock.mockResolvedValueOnce(response);
|
||||
}
|
||||
}
|
||||
// Fallback to the last response for any subsequent calls
|
||||
const lastResponse = responses[responses.length - 1];
|
||||
if (typeof lastResponse === 'string') {
|
||||
generateContentMock.mockResolvedValue(
|
||||
createMockGenerateContentResponse(lastResponse),
|
||||
);
|
||||
} else {
|
||||
generateContentMock.mockResolvedValue(lastResponse);
|
||||
}
|
||||
} else {
|
||||
// Default fallback
|
||||
generateContentMock.mockResolvedValue(
|
||||
createMockGenerateContentResponse('Mock LLM response'),
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
generateContent: generateContentMock,
|
||||
} as unknown as MockLlmClient;
|
||||
}
|
||||
|
||||
export function createMockEnvironment(
|
||||
overrides?: Partial<ContextEnvironment>,
|
||||
): ContextEnvironment {
|
||||
const llmClient = createMockLlmClient(['Mock LLM summary response']);
|
||||
|
||||
const tracer = new ContextTracer({
|
||||
targetDir: '/tmp',
|
||||
sessionId: 'mock-session',
|
||||
});
|
||||
const eventBus = new ContextEventBus();
|
||||
|
||||
const env = new ContextEnvironmentImpl(
|
||||
llmClient,
|
||||
'mock-session',
|
||||
'mock-prompt-id',
|
||||
'/tmp/.gemini/trace',
|
||||
'/tmp/.gemini/tool-outputs',
|
||||
tracer,
|
||||
1,
|
||||
eventBus,
|
||||
);
|
||||
|
||||
if (overrides) {
|
||||
Object.assign(env, overrides);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a block of synthetic conversation history designed to consume a specific number of tokens.
|
||||
* Assumes roughly 4 characters per token for standard English text.
|
||||
*/
|
||||
|
||||
export function createMockProcessArgs(
|
||||
targets: ConcreteNode[],
|
||||
bufferNodes: ConcreteNode[] = [],
|
||||
inboxMessages: InboxMessage[] = [],
|
||||
): ProcessArgs {
|
||||
return {
|
||||
targets,
|
||||
buffer: ContextWorkingBufferImpl.initialize(
|
||||
bufferNodes.length ? bufferNodes : targets,
|
||||
),
|
||||
inbox: new InboxSnapshotImpl(inboxMessages),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSyntheticHistory(
|
||||
numTurns: number,
|
||||
tokensPerTurn: number,
|
||||
): Content[] {
|
||||
const history: Content[] = [];
|
||||
const charsPerTurn = tokensPerTurn * 1;
|
||||
|
||||
for (let i = 0; i < numTurns; i++) {
|
||||
history.push({
|
||||
role: 'user',
|
||||
parts: [{ text: `User turn ${i}. ` + 'A'.repeat(charsPerTurn) }],
|
||||
});
|
||||
history.push({
|
||||
role: 'model',
|
||||
parts: [{ text: `Model response ${i}. ` + 'B'.repeat(charsPerTurn) }],
|
||||
});
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fully mocked Config object tailored for Context Component testing.
|
||||
*/
|
||||
export function createMockContextConfig(
|
||||
overrides?: Record<string, unknown>,
|
||||
llmClientOverride?: unknown,
|
||||
): Config {
|
||||
const defaultConfig = {
|
||||
isContextManagementEnabled: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
|
||||
},
|
||||
getBaseLlmClient: vi.fn().mockReturnValue(
|
||||
llmClientOverride || {
|
||||
generateContent: vi.fn().mockResolvedValue({
|
||||
text: '<mocked_snapshot>Synthesized state</mocked_snapshot>',
|
||||
}),
|
||||
},
|
||||
),
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
getTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session'),
|
||||
getExperimentalContextManagementConfig: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return { ...defaultConfig, ...overrides } as unknown as Config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires up a full ContextManager component with an AgentChatHistory and active background async pipelines.
|
||||
*/
|
||||
|
||||
export function setupContextComponentTest(
|
||||
config: Config,
|
||||
sidecarOverride?: ContextProfile,
|
||||
): { chatHistory: AgentChatHistory; contextManager: ContextManager } {
|
||||
const chatHistory = new AgentChatHistory();
|
||||
const sidecar = sidecarOverride || testTruncateProfile;
|
||||
const tracer = new ContextTracer({
|
||||
targetDir: '/tmp',
|
||||
sessionId: 'test-session',
|
||||
});
|
||||
const eventBus = new ContextEventBus();
|
||||
const env = new ContextEnvironmentImpl(
|
||||
config.getBaseLlmClient(),
|
||||
'test prompt-id',
|
||||
'test-session',
|
||||
'/tmp',
|
||||
'/tmp/gemini-test',
|
||||
tracer,
|
||||
1,
|
||||
eventBus,
|
||||
);
|
||||
|
||||
const orchestrator = new PipelineOrchestrator(
|
||||
sidecar.buildPipelines(env),
|
||||
sidecar.buildAsyncPipelines(env),
|
||||
env,
|
||||
eventBus,
|
||||
tracer,
|
||||
);
|
||||
|
||||
const contextManager = new ContextManager(
|
||||
sidecar,
|
||||
env,
|
||||
tracer,
|
||||
orchestrator,
|
||||
chatHistory,
|
||||
);
|
||||
|
||||
// The async async pipeline is now internally managed by ContextManager
|
||||
return { chatHistory, contextManager };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ContextProfile } from '../config/profiles.js';
|
||||
import type { PipelineDef } from '../config/types.js';
|
||||
import type { ContextEnvironment } from '../pipeline/environment.js';
|
||||
import { createHistoryTruncationProcessor } from '../processors/historyTruncationProcessor.js';
|
||||
|
||||
export const testTruncateProfile: ContextProfile = {
|
||||
config: {
|
||||
budget: {
|
||||
retainedTokens: 65000,
|
||||
maxTokens: 150000,
|
||||
},
|
||||
},
|
||||
buildPipelines: (env: ContextEnvironment): PipelineDef[] => [
|
||||
{
|
||||
name: 'Emergency Backstop (Truncate Only)',
|
||||
triggers: ['gc_backstop', 'retained_exceeded'],
|
||||
processors: [
|
||||
createHistoryTruncationProcessor('HistoryTruncation', env, {}),
|
||||
],
|
||||
},
|
||||
],
|
||||
buildAsyncPipelines: () => [],
|
||||
};
|
||||
Reference in New Issue
Block a user