Compare commits

...

3 Commits

Author SHA1 Message Date
Gal Zahavi 8aef2a400b feat(core): implement bounded history retention for GeminiChat
- Improved ToolOutputMaskingService to mask exceptionally large outputs (> 2x threshold) even in the latest turn.
- Implemented pruneHistory in GeminiChat with configurable turn and token limits to provide a hard safety net against OOM.
- Added getters and initialization for experimental history truncation settings in Config.
- Added comprehensive unit tests in geminiChat_pruning.test.ts.
- Verified with npm test and build.
2026-04-03 02:48:40 +00:00
Gal Zahavi 57eea87d41 perf(memory): implement cache eviction in ChatRecordingService and fix leak during resets 2026-04-03 02:36:05 +00:00
Gal Zahavi 21ecd3ca9f perf(memory): implement simulation script and identify growth sources 2026-04-03 02:29:16 +00:00
10 changed files with 746 additions and 10 deletions
+8 -3
View File
@@ -1,15 +1,20 @@
{
"experimental": {
"plan": true,
"extensionReloading": true,
"modelSteering": true,
"memoryManager": true,
"topicUpdateNarration": true
},
"general": {
"devtools": true
"devtools": true,
"plan": {
"enabled": true
}
},
"security": {
"toolSandboxing": true
},
"agents": {
"overrides": {}
}
}
}
+180
View File
@@ -0,0 +1,180 @@
# Product Requirements Document (PRD): Gemini CLI Memory Optimization
## 1. Objective
Reduce the memory footprint of `gemini-cli` during long-running sessions
(multi-hour) from the current peak of ~2GB down to a sustainable baseline (e.g.,
< 500MB), without degrading existing functionality, user experience, or context
awareness.
## 2. Problem Statement
Users experience high memory consumption (up to 2GB) when running `gemini-cli`
for extended periods. High memory usage leads to sluggish terminal
responsiveness, system swapping, increased GC (Garbage Collection) pauses, and
eventually OOM (Out of Memory) crashes. Node.js applications that retain large
amounts of execution history, tool results (like large shell outputs or file
reads), and conversational context in memory often suffer from "soft memory
leaks" (unbounded data growth).
## 3. Scope
**In Scope:**
- Analyzing and profiling the memory usage of the `@google/gemini-cli-core` and
`@google/gemini-cli` packages.
- Identifying and resolving memory leaks (e.g., un-deregistered event
listeners).
- Implementing bounded memory for unbounded data structures (e.g., chat history,
activity logs, tool execution results).
- Optimizing data serialization/deserialization and large string handling.
- Creating automated memory profiling scripts and validation workflows.
**Out of Scope:**
- Rewriting the CLI in another language (e.g., Rust/Go).
- Removing core features or aggressively truncating the LLM context window
(unless specifically configured by the user).
## 4. Key Results & Metrics
- **Peak Memory Usage:** Reduce peak memory usage (`RSS`) during a 4-hour
simulated session from ~2.0GB to < 500MB.
- **Baseline Memory:** Ensure baseline memory after forced garbage collection
remains flat (does not grow linearly with the number of turns).
- **Quality Gates:** 100% of existing unit, integration (E2E), and preflight
tests (`npm run preflight`) must pass.
## 5. Technical Approach & Hypotheses
1. **Unbounded History Retention:** The agent's session history stores full
payloads of every tool execution (e.g., `read_file` of a 5MB file, or verbose
`run_shell_command` outputs).
- _Mitigation:_ Implement aggressive in-memory truncation for older turns
that are no longer sent to the model, or offload historical payloads to
temporary disk files.
2. **React/Ink Memory Leaks in CLI UI:** Unmounted Ink components might not be
garbage collected if references are held in global state, context providers,
or event listeners.
- _Mitigation:_ Audit `useEffect` cleanup functions and global event listener
deregistration in UI components.
3. **DevTools / Logger Retention:** The `activityLogger.ts` or telemetry systems
might buffer unbounded amounts of events in memory before flushing.
- _Mitigation:_ Ensure logs are streamed directly to disk or the WebSocket
without retaining a massive ring buffer in memory.
## 6. Testing & Validation Strategy
To validate memory usage, we must simulate a heavy session, measure memory, and
ensure correctness.
### 6.1 Creating the Memory Profiling Script
Create a script `scripts/simulate-long-session.ts` to programmatically drive the
CLI and measure memory growth.
```typescript
// scripts/simulate-long-session.ts
import { exec } from 'child_process';
import * as v8 from 'v8';
import * as fs from 'fs';
// Helper to force GC if run with --expose-gc
const runGC = () => {
if (global.gc) {
global.gc();
}
};
const printMemory = (turn: number) => {
runGC();
const usage = process.memoryUsage();
console.log(`Turn ${turn} - RSS: ${(usage.rss / 1024 / 1024).toFixed(2)} MB, HeapUsed: ${(usage.heapUsed / 1024 / 1024).toFixed(2)} MB`);
};
async function runSimulation() {
console.log("Starting memory simulation...");
// Simulate 100 heavy turns
for (let i = 1; i <= 100; i++) {
// Inject mock messages or trigger SDK agent actions here
// e.g. agent.processInput("Read a large file and summarize it")
// Simulate heavy string allocation
const dummyData = "A".repeat(1024 * 1024 * 10); // 10MB dummy data
printMemory(i);
// Periodically take heap snapshots
if (i % 25 === 0) {
const snapshotName = \`heap-snapshot-turn-\${i}.heapsnapshot\`;
v8.writeHeapSnapshot(snapshotName);
console.log(\`Saved \${snapshotName}\`);
}
}
}
runSimulation();
```
### 6.2 Steps to Validate Memory Usage
1. **Establish the Baseline:**
- Run the simulation script on the `main` branch to capture the baseline
metrics.
- `NODE_OPTIONS="--expose-gc" npx tsx scripts/simulate-long-session.ts`
2. **Heap Snapshot Analysis:**
- Run the CLI manually with the inspector enabled: `npm run debug` (or
`NODE_OPTIONS="--inspect" npm start`).
- Open Chrome DevTools (`chrome://inspect`).
- Take a baseline heap snapshot at startup.
- Run heavy tasks (e.g., `read_file` on large files, `run_shell_command` with
huge outputs).
- Take a second heap snapshot.
- Compare the two snapshots in DevTools. Look for retained objects, detached
DOM nodes (Ink elements), or massive string allocations.
3. **Verify the Fixes:**
- Apply the memory optimizations.
- Re-run the simulation script. The printed `HeapUsed` and `RSS` should
flatline after a certain number of turns rather than growing linearly.
- Compare the final heap snapshot size to the baseline.
### 6.3 Ensuring Build and Tests Pass
Memory optimization can inadvertently break functionality if data is truncated
too aggressively.
1. **Run Targeted Tests:** During development, verify core logic using targeted
tests:
- `npm test -w @google/gemini-cli-core`
- `npm run test:e2e`
2. **Run the Preflight Checks:** Before creating a PR, run the exhaustive
validation suite to ensure no regressions:
- `npm run preflight`
3. **E2E Validation:** The existing E2E tests
(`packages/cli/integration-tests/`) will verify that the CLI still behaves
correctly from a user's perspective, ensuring that history truncation or
memory offloading doesn't break multi-turn context.
## 7. Execution Plan
- [x] **Phase 1: Instrumentation & Baselines**
- [x] Implement `scripts/simulate-long-session.ts` or add an eval script.
- [x] Capture baseline memory metrics and initial heap snapshots.
2. **Phase 2: Analysis & Implementation**
- [x] Identify the top 3 memory retainers using Chrome DevTools (Identified
ChatRecordingService and GeminiChat history).
- [x] Implement bounded retention for ChatRecordingService (implemented
memory-based cache eviction and leak prevention during resets).
- [x] Implement bounded retention for GeminiChat (improve Tool Output Masking
or add hard history bounds). - Improved `ToolOutputMaskingService` to
mask massive outputs (> 2x threshold) even in the latest turn. -
Implemented `pruneHistory` in `GeminiChat` with configurable turn and
token limits to provide a hard memory safety net.
- [ ] Audit React/Ink components for event listener leaks.
3. **Phase 3: Validation & CI**
- Run E2E tests to ensure behavioral parity.
- Run `npm run preflight`.
- Consider adding a lightweight memory-growth check to the CI pipeline to
prevent future regressions.
+25
View File
@@ -957,6 +957,10 @@ export class Config implements McpContext, AgentLoopContext {
readonly injectionService: InjectionService;
private approvedPlanPath: string | undefined;
private readonly experimentalAgentHistoryTruncation: boolean;
private readonly experimentalAgentHistoryTruncationThreshold: number;
private readonly experimentalAgentHistoryRetainedMessages: number;
constructor(params: ConfigParameters) {
this._sessionId = params.sessionId;
this.clientName = params.clientName;
@@ -964,6 +968,12 @@ export class Config implements McpContext, AgentLoopContext {
this.approvedPlanPath = undefined;
this.embeddingModel =
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
this.experimentalAgentHistoryTruncation =
params.experimentalAgentHistoryTruncation ?? false;
this.experimentalAgentHistoryTruncationThreshold =
params.experimentalAgentHistoryTruncationThreshold ?? 100000;
this.experimentalAgentHistoryRetainedMessages =
params.experimentalAgentHistoryRetainedMessages ?? 20;
this.sandbox = params.sandbox
? {
enabled: params.sandbox.enabled || params.toolSandboxing || false,
@@ -3624,6 +3634,21 @@ export class Config implements McpContext, AgentLoopContext {
return this.disabledHooks;
}
/**
* Get experimental agent history truncation settings
*/
isExperimentalAgentHistoryTruncationEnabled(): boolean {
return this.experimentalAgentHistoryTruncation;
}
getExperimentalAgentHistoryTruncationThreshold(): number {
return this.experimentalAgentHistoryTruncationThreshold;
}
getExperimentalAgentHistoryRetainedMessages(): number {
return this.experimentalAgentHistoryRetainedMessages;
}
/**
* Get experiments configuration
*/
+57
View File
@@ -49,6 +49,7 @@ import { isFunctionResponse } from '../utils/messageInspectors.js';
import { partListUnionToString } from './geminiRequest.js';
import type { ModelConfigKey } from '../services/modelConfigService.js';
import { estimateTokenCountSync } from '../utils/tokenCalculation.js';
import { debugLogger } from '../utils/debugLogger.js';
import {
applyModelSelection,
createAvailabilityContextProvider,
@@ -744,16 +745,71 @@ export class GeminiChat {
*/
addHistory(content: Content): void {
this.history.push(content);
this.pruneHistory();
}
setHistory(history: readonly Content[]): void {
this.history = [...history];
this.pruneHistory();
this.lastPromptTokenCount = estimateTokenCountSync(
this.history.flatMap((c) => c.parts || []),
);
this.chatRecordingService.updateMessagesFromHistory(history);
}
/**
* Prunes the conversation history to stay within memory-safe bounds.
* This is a "hard" truncation that acts as a safety net against OOM
* when context management or compression is disabled or insufficient.
*/
private pruneHistory(): void {
const config = this.context.config;
if (!config.isExperimentalAgentHistoryTruncationEnabled()) {
return;
}
const maxTokens = config.getExperimentalAgentHistoryTruncationThreshold();
const maxMessages = config.getExperimentalAgentHistoryRetainedMessages();
// Check if we need to prune at all
const totalTokens = estimateTokenCountSync(
this.history.flatMap((c) => c.parts || []),
);
if (this.history.length <= maxMessages && totalTokens <= maxTokens) {
return;
}
// Always keep at least the last 'maxMessages' messages
let prunedHistory =
this.history.length > maxMessages
? this.history.slice(-maxMessages)
: [...this.history];
// Further prune based on token count if still over threshold
let currentTokens = estimateTokenCountSync(
prunedHistory.flatMap((c) => c.parts || []),
);
while (
prunedHistory.length > 2 && // Keep at least one exchange (user + model)
currentTokens > maxTokens
) {
// Remove the oldest message from the pruned history
prunedHistory = prunedHistory.slice(1);
currentTokens = estimateTokenCountSync(
prunedHistory.flatMap((c) => c.parts || []),
);
}
if (prunedHistory.length !== this.history.length) {
debugLogger.debug(
`[GeminiChat] Pruning history: ${this.history.length} -> ${prunedHistory.length} messages. Tokens: ${currentTokens.toLocaleString()}`,
);
this.history = prunedHistory;
}
}
stripThoughtsFromHistory(): void {
this.history = this.history.map((content) => {
const newContent = { ...content };
@@ -994,6 +1050,7 @@ export class GeminiChat {
}
this.history.push({ role: 'model', parts: consolidatedParts });
this.pruneHistory();
}
getLastPromptTokenCount(): number {
@@ -0,0 +1,233 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { GeminiChat } from './geminiChat.js';
import type { Config } from '../config/config.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { estimateTokenCountSync } from '../utils/tokenCalculation.js';
import { ToolOutputMaskingService } from '../services/toolOutputMaskingService.js';
import type { Content, Part } from '@google/genai';
// Mock token calculation to be predictable
vi.mock('../utils/tokenCalculation.js', () => ({
estimateTokenCountSync: vi.fn(),
}));
describe('GeminiChat Pruning and Masking', () => {
let mockConfig: Partial<Config>;
let context: AgentLoopContext;
let chat: GeminiChat;
beforeEach(() => {
vi.clearAllMocks();
mockConfig = {
isExperimentalAgentHistoryTruncationEnabled: vi
.fn()
.mockReturnValue(true),
getExperimentalAgentHistoryTruncationThreshold: vi
.fn()
.mockReturnValue(1000),
getExperimentalAgentHistoryRetainedMessages: vi.fn().mockReturnValue(10),
getToolOutputMaskingConfig: vi.fn().mockResolvedValue({
enabled: true,
toolProtectionThreshold: 50,
minPrunableTokensThreshold: 10,
protectLatestTurn: true,
}),
getSessionId: vi.fn().mockReturnValue('test-session'),
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }),
isInteractive: vi.fn().mockReturnValue(false),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/test'),
} as unknown as Config['storage'],
modelConfigService: {
getResolvedConfig: vi.fn().mockReturnValue({ model: 'gemini-pro' }),
} as unknown as Config['modelConfigService'],
};
context = {
config: mockConfig as Config,
promptId: 'test-session',
} as unknown as AgentLoopContext;
chat = new GeminiChat(context);
// Default token estimation: 1 token per message for simplicity
vi.mocked(estimateTokenCountSync).mockImplementation(() => 1);
});
describe('History Pruning', () => {
it('should prune history when turn limit is exceeded', () => {
(
mockConfig.getExperimentalAgentHistoryRetainedMessages as Mock
).mockReturnValue(4);
// Add 6 messages (3 turns: user + model)
for (let i = 0; i < 6; i++) {
chat.addHistory({
role: i % 2 === 0 ? 'user' : 'model',
parts: [{ text: `msg ${i}` }],
});
}
const history = chat.getHistory();
expect(history.length).toBe(4);
expect(history[0].parts![0].text).toBe('msg 2');
expect(history[3].parts![0].text).toBe('msg 5');
});
it('should prune history when token limit is exceeded', () => {
(
mockConfig.getExperimentalAgentHistoryRetainedMessages as Mock
).mockReturnValue(10);
(
mockConfig.getExperimentalAgentHistoryTruncationThreshold as Mock
).mockReturnValue(5);
// Mock token count: each message is 2 tokens
vi.mocked(estimateTokenCountSync).mockImplementation(
(parts: readonly Part[]) => {
if (parts.length === 0) return 0;
// If it's a list of parts from multiple messages
if (Array.isArray(parts) && parts.length > 1) {
return parts.length * 2;
}
return 2;
},
);
// Add 6 messages. Total tokens should be 12.
for (let i = 0; i < 6; i++) {
chat.addHistory({
role: i % 2 === 0 ? 'user' : 'model',
parts: [{ text: `msg ${i}` }],
});
}
const history = chat.getHistory();
// Threshold is 5.
// 3 messages = 6 tokens (over)
// 2 messages = 4 tokens (under)
// So it should prune to 2 messages.
expect(history.length).toBe(2);
expect(history[0].parts![0].text).toBe('msg 4');
expect(history[1].parts![0].text).toBe('msg 5');
});
it('should NOT prune if experimental feature is disabled', () => {
(
mockConfig.isExperimentalAgentHistoryTruncationEnabled as Mock
).mockReturnValue(false);
(
mockConfig.getExperimentalAgentHistoryRetainedMessages as Mock
).mockReturnValue(4);
for (let i = 0; i < 6; i++) {
chat.addHistory({
role: i % 2 === 0 ? 'user' : 'model',
parts: [{ text: `msg ${i}` }],
});
}
const history = chat.getHistory();
expect(history.length).toBe(6);
});
});
describe('Tool Output Masking (Improved)', () => {
it('should mask large outputs even in the latest turn if they exceed 2x threshold', async () => {
const maskingService = new ToolOutputMaskingService();
(mockConfig.getToolOutputMaskingConfig as Mock).mockResolvedValue({
enabled: true,
toolProtectionThreshold: 50,
minPrunableTokensThreshold: 10,
protectLatestTurn: true,
});
const history: Content[] = [
{
role: 'user',
parts: [
{
functionResponse: {
name: 'huge_tool',
response: { output: 'X'.repeat(1000) },
},
},
],
},
];
// Mock token count: huge_tool output is 200 tokens (> 50 * 2)
vi.mocked(estimateTokenCountSync).mockImplementation(
(parts: readonly Part[]) => {
const response = parts[0]?.functionResponse?.response as Record<
string,
unknown
>;
if (
typeof response === 'object' &&
typeof response?.output === 'string' &&
response.output.includes('tool_output_masked')
) {
return 5; // Small value for masked content
}
if (parts[0]?.functionResponse?.name === 'huge_tool') return 200;
return 1;
},
);
const result = await maskingService.mask(history, mockConfig as Config);
expect(result.maskedCount).toBe(1);
expect(JSON.stringify(result.newHistory)).toContain('tool_output_masked');
});
it('should NOT mask latest turn if it is below 2x threshold and protectLatestTurn is true', async () => {
const maskingService = new ToolOutputMaskingService();
(mockConfig.getToolOutputMaskingConfig as Mock).mockResolvedValue({
enabled: true,
toolProtectionThreshold: 50,
minPrunableTokensThreshold: 10,
protectLatestTurn: true,
});
const history: Content[] = [
{
role: 'user',
parts: [
{
functionResponse: {
name: 'normal_tool',
response: { output: 'normal' },
},
},
],
},
];
// Mock token count: normal_tool output is 60 tokens (> 50 but < 50 * 2)
vi.mocked(estimateTokenCountSync).mockImplementation(
(parts: readonly Part[]) => {
if (parts[0]?.functionResponse?.name === 'normal_tool') return 60;
return 1;
},
);
const result = await maskingService.mask(history, mockConfig as Config);
expect(result.maskedCount).toBe(0);
expect(JSON.stringify(result.newHistory)).not.toContain(
'tool_output_masked',
);
});
});
});
@@ -1117,4 +1117,74 @@ describe('ChatRecordingService', () => {
writeFileSyncSpy.mockRestore();
});
});
describe('Memory management (cache eviction)', () => {
beforeEach(() => {
chatRecordingService.initialize();
});
it('should clear in-memory cache when conversation exceeds 50MB', () => {
// 1. Create a large message (> 50MB)
const largeContent = 'A'.repeat(50 * 1024 * 1024 + 1024);
chatRecordingService.recordMessage({
type: 'user',
content: largeContent,
model: 'gemini-pro',
});
// 2. Check private cache properties
// @ts-expect-error private property
expect(chatRecordingService.cachedConversation).toBeNull();
// @ts-expect-error private property
expect(chatRecordingService.cachedLastConvData).toBeNull();
// 3. Subsequent read should reload from disk
const readFileSyncSpy = vi.spyOn(fs, 'readFileSync');
const conversation = chatRecordingService.getConversation();
expect(conversation).not.toBeNull();
expect(conversation!.messages).toHaveLength(1);
expect(readFileSyncSpy).toHaveBeenCalled();
readFileSyncSpy.mockRestore();
});
it('should keep in-memory cache when conversation is small', () => {
// 1. Create a small message
chatRecordingService.recordMessage({
type: 'user',
content: 'Small message',
model: 'gemini-pro',
});
// 2. Check private cache properties
// @ts-expect-error private property
expect(chatRecordingService.cachedConversation).not.toBeNull();
// @ts-expect-error private property
expect(chatRecordingService.cachedLastConvData).not.toBeNull();
// 3. Subsequent read should NOT reload from disk
const readFileSyncSpy = vi.spyOn(fs, 'readFileSync');
const conversation = chatRecordingService.getConversation();
expect(conversation).not.toBeNull();
expect(readFileSyncSpy).not.toHaveBeenCalled();
readFileSyncSpy.mockRestore();
});
it('should verify writeConversation stringification calls', () => {
const stringifySpy = vi.spyOn(JSON, 'stringify');
// Clear calls from initialize
stringifySpy.mockClear();
chatRecordingService.recordMessage({
type: 'user',
content: 'ping',
model: 'm',
});
// It is called twice: once for comparison with cachedLastConvData,
// and once for writing to disk with the updated lastUpdated timestamp.
expect(stringifySpy).toHaveBeenCalledTimes(2);
stringifySpy.mockRestore();
});
});
});
@@ -27,6 +27,13 @@ import type { AgentLoopContext } from '../config/agent-loop-context.js';
export const SESSION_FILE_PREFIX = 'session-';
/**
* Maximum size of the in-memory chat history cache (50MB).
* When the conversation record exceeds this size, it will be cleared from memory
* after being written to disk to bound the memory footprint.
*/
const MAX_CACHE_SIZE_BYTES = 50 * 1024 * 1024;
/**
* Warning message shown when recording is disabled due to disk full.
*/
@@ -128,6 +135,10 @@ export interface ResumedSessionData {
* - Assistant thoughts and reasoning
*
* Sessions are stored as JSON files in ~/.gemini/tmp/<project_hash>/chats/
*
* Memory Optimization: To prevent unbounded memory growth in long-running
* sessions, this service implements a memory-based eviction policy for its
* in-memory JSON cache.
*/
export class ChatRecordingService {
private conversationFile: string | null = null;
@@ -165,12 +176,15 @@ export class ChatRecordingService {
this.sessionId = resumedSessionData.conversation.sessionId;
this.kind = resumedSessionData.conversation.kind;
// Update the session ID in the existing file
// Use the conversation data for a one-time setup if needed.
// We don't cache it permanently here to save memory; it will be reloaded
// if/when needed by readConversation().
this.updateConversation((conversation) => {
conversation.sessionId = this.sessionId;
});
// Clear any cached data to force fresh reads
// Memory Management: Clear the cache after the initial update
// since it might be huge and we want to allow it to be GC'ed.
this.cachedLastConvData = null;
this.cachedConversation = null;
} else {
@@ -527,13 +541,31 @@ export class ChatRecordingService {
// Compare before updating lastUpdated so the timestamp doesn't
// cause a false diff.
if (this.cachedLastConvData === newContent) return;
this.cachedConversation = conversation;
conversation.lastUpdated = new Date().toISOString();
const contentToWrite = JSON.stringify(conversation, null, 2);
this.cachedLastConvData = contentToWrite;
// Ensure directory exists before writing (handles cases where temp dir was cleaned)
fs.mkdirSync(path.dirname(this.conversationFile), { recursive: true });
fs.writeFileSync(this.conversationFile, contentToWrite);
// Memory Management: If the conversation is large, clear the in-memory cache
// to bound the heap usage. Subsequent reads will reload from disk.
if (contentToWrite.length > MAX_CACHE_SIZE_BYTES) {
debugLogger.debug(
`[ChatRecordingService] Conversation too large (${(
contentToWrite.length /
1024 /
1024
).toFixed(2)}MB). Evicting from memory cache.`,
);
this.cachedConversation = null;
this.cachedLastConvData = null;
} else {
this.cachedConversation = conversation;
this.cachedLastConvData = contentToWrite;
}
} catch (error) {
// Handle disk full (ENOSPC) gracefully - disable recording but allow conversation to continue
if (
@@ -544,6 +576,7 @@ export class ChatRecordingService {
) {
this.conversationFile = null;
this.cachedConversation = null;
this.cachedLastConvData = null;
debugLogger.warn(ENOSPC_WARNING_MESSAGE);
return; // Don't throw - allow the conversation to continue
}
@@ -90,10 +90,9 @@ export class ToolOutputMaskingService {
}> = [];
// Decide where to start scanning.
// If PROTECT_LATEST_TURN is true, we skip the most recent message (index history.length - 1).
const scanStartIdx = maskingConfig.protectLatestTurn
? history.length - 2
: history.length - 1;
// If PROTECT_LATEST_TURN is true, we still scan the latest turn but are more
// conservative about masking it.
const scanStartIdx = history.length - 1;
// Backward scan to identify prunable tool outputs
for (let i = scanStartIdx; i >= 0; i--) {
@@ -121,8 +120,22 @@ export class ToolOutputMaskingService {
}
const partTokens = estimateTokenCountSync([part]);
const isLatestTurn = i === history.length - 1;
if (!protectionBoundaryReached) {
// If we are in the latest turn and protectLatestTurn is enabled,
// we only mask if the part itself is exceptionally large (> 2x threshold).
// This ensures that the model usually has full context for its current
// task while preventing massive outputs from causing OOM or context overflow.
if (
isLatestTurn &&
maskingConfig.protectLatestTurn &&
partTokens <= maskingConfig.toolProtectionThreshold * 2
) {
cumulativeToolTokens += partTokens;
continue;
}
cumulativeToolTokens += partTokens;
if (cumulativeToolTokens > maskingConfig.toolProtectionThreshold) {
protectionBoundaryReached = true;
+3
View File
@@ -0,0 +1,3 @@
2026-04-03: Completed Phase 1. Implemented scripts/simulate-long-session.ts to reproduce memory growth. Identified ChatRecordingService and GeminiChat history as primary growth sources. Captured baseline metrics showing ~180MB growth per 200MB of tool output data.
2026-04-03: Implemented memory-based cache eviction in ChatRecordingService (50MB threshold). Optimized initialization to prevent carrying over large session records in memory across chat resets. Verified with new unit tests.
2026-04-03: Implemented bounded retention for GeminiChat. Improved ToolOutputMaskingService to allow masking massive outputs in the latest turn. Added hard history pruning in GeminiChat with configurable token and turn limits. Verified with new unit tests and successful build.
+117
View File
@@ -0,0 +1,117 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as crypto from 'node:crypto';
import { GeminiChat } from '../packages/core/src/core/geminiChat.js';
import { Config } from '../packages/core/src/config/config.js';
import { ToolRegistry } from '../packages/core/src/tools/tool-registry.js';
import { MessageBus } from '../packages/core/src/confirmation-bus/message-bus.js';
import { PromptRegistry } from '../packages/core/src/prompts/prompt-registry.js';
import { ResourceRegistry } from '../packages/core/src/resources/resource-registry.js';
import { NoopSandboxManager } from '../packages/core/src/services/sandboxManager.js';
import type { AgentLoopContext } from '../packages/core/src/config/agent-loop-context.js';
// Helper to force GC if run with --expose-gc
const runGC = () => {
if (global.gc) {
global.gc();
}
};
const printMemory = (turn: number) => {
runGC();
const usage = process.memoryUsage();
console.log(
`Turn ${turn} - RSS: ${(usage.rss / 1024 / 1024).toFixed(2)} MB, ` +
`HeapUsed: ${(usage.heapUsed / 1024 / 1024).toFixed(2)} MB, ` +
`External: ${(usage.external / 1024 / 1024).toFixed(2)} MB`,
);
};
async function runReproduction() {
console.log(
'Starting memory growth reproduction (ChatRecordingService focus)...',
);
const config = new Config({
sessionId: 'reproduction-session',
targetDir: process.cwd(),
cwd: process.cwd(),
debugMode: false,
model: 'gemini-2.0-flash',
});
await config.initialize();
const context: AgentLoopContext = {
config,
promptId: 'reproduction-session',
toolRegistry: new ToolRegistry(config),
promptRegistry: new PromptRegistry(),
resourceRegistry: new ResourceRegistry(),
messageBus: new MessageBus(),
sandboxManager: new NoopSandboxManager(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
geminiClient: null as any,
};
const chat = new GeminiChat(context);
for (let i = 1; i <= 200; i++) {
const LARGE_STRING = crypto.randomBytes(512 * 1024).toString('hex'); // 1MB string
// 1. User message
chat.addHistory({
role: 'user',
parts: [{ text: `Turn ${i}: Get the large data.` }],
});
// 2. Model message with tool call
chat.addHistory({
role: 'model',
parts: [
{
functionCall: {
name: 'get_large_data',
args: {},
},
},
],
});
// 3. User message with tool response (LARGE)
chat.addHistory({
role: 'user',
parts: [
{
functionResponse: {
name: 'get_large_data',
response: { output: LARGE_STRING },
},
},
],
});
// 4. Model message with final text
chat.addHistory({
role: 'model',
parts: [{ text: 'I have processed the large data.' }],
});
// Trigger ChatRecordingService update (as GeminiClient does)
await chat
.getChatRecordingService()
?.updateMessagesFromHistory(chat.getHistory());
if (i % 20 === 0) {
const history = chat.getHistory();
console.log(`Turn ${i}: History size: ${history.length}`);
printMemory(i);
}
}
console.log('Reproduction complete.');
}
runReproduction().catch(console.error);