Skip to content

fix(core): memory-based chat compression to prevent heap OOM#4127

Open
Dinsmoor wants to merge 1 commit into
QwenLM:mainfrom
Dinsmoor:fix/memory-leak-clean
Open

fix(core): memory-based chat compression to prevent heap OOM#4127
Dinsmoor wants to merge 1 commit into
QwenLM:mainfrom
Dinsmoor:fix/memory-leak-clean

Conversation

@Dinsmoor
Copy link
Copy Markdown
Contributor

Long-lived interactive sessions (80+ minutes) can accumulate enough conversation history to hit Node's 4 GB heap limit. The existing 70% token compaction threshold can fail permanently when large file reads or shell outputs create a few huge entries.

This replaces entry-count caps with memory-based monitoring:

  • geminiChat.ts: force chat compaction when heapUsed exceeds 2 GB (a hard safety net independent of the 70% token threshold).

  • agent-core.ts: prune oldest agent messages when heapUsed exceeds 1.5 GB (pruning ~20% of oldest messages per round).

Both mechanisms check actual heap pressure rather than an arbitrary proxy (message count), catching the root cause regardless of whether memory ballooned from many small entries or few huge ones.


Summary

  • What changed: Two memory-based safety nets added: (1) tryCompress() in geminiChat.ts now forces compression when heapUsed exceeds 2 GB, (2) pruneMessages() in agent-core.ts trims ~20% of oldest messages when heapUsed exceeds 1.5 GB.
  • Why it changed: Long sessions (80+ min) accumulated unbounded history, hitting Node's 4 GB heap limit. The existing 70% token threshold can fail permanently when large file reads create huge individual entries.
  • Reviewer focus: Verify the 2 GB threshold triggers before the 4 GB kill boundary; confirm prune chunk size (~20%) provides proportional relief.

Validation

  • Commands run:
    npx tsc --noEmit --project packages/core/tsconfig.json
  • Prompts / inputs used: None (no behavioral change observable through CLI prompts; this is infrastructure code).
  • Expected result: Clean TypeScript compilation; no runtime behavior for normal sessions (thresholds are only hit in 80+ min sessions).
  • Observed result: TypeScript compilation passes with no errors. git diff shows 48 lines added across 3 files with only new logic (no deletions).
  • Quickest reviewer verification path: Read the if (!force && process.memoryUsage().heapUsed > threshold) guards in both files — they are simple numeric comparisons with clear comments explaining the thresholds.
  • Evidence: git diff HEAD^..HEAD --stat shows 3 files, +48 lines. No existing test files reference process.memoryUsage or heap thresholds, so no test modifications needed for this structural addition.

Scope / Risk

  • Main risk or tradeoff: The 2 GB threshold is hardcoded. If Node's default heap limit changes (e.g., newer runtime, container limits), the magic number may need adjustment. The 1.5 GB threshold for agent message pruning is more conservative — it triggers earlier but only during the existing round loop.
  • Not covered / not validated: Actual heap growth measurements in a real 80+ minute session. The thresholds were derived from the observed crash at ~81 minutes but not empirically verified with continuous monitoring.
  • Breaking changes / migration notes: None. This adds new state and guards only. Backward compatible — old clients won't see the thresholds, new ones benefit automatically.

Testing Matrix

🍏 🪟 🐧
npm run
npx
Docker
Podman N/A N/A N/A
Seatbelt N/A N/A N/A

Testing matrix notes:

  • npm run build & typecheck: ✅ tested on Linux (native dev environment)
  • npx / Docker / Seatbelt: not tested — this is an internal core package change, not a build-artifact or OS-specific feature. A downstream user running via npx or Docker would benefit automatically.

Linked Issues / Bugs

Fixes the crash observed during long interactive sessions (80+ minutes of continuous use).

Long-lived interactive sessions (80+ minutes) can accumulate enough
conversation history to hit Node's 4 GB heap limit. The existing 70%
token compaction threshold can fail permanently when large file reads
or shell outputs create a few huge entries.

This replaces entry-count caps with memory-based monitoring:

- geminiChat.ts: force chat compaction when heapUsed exceeds 2 GB
  (a hard safety net independent of the 70% token threshold).

- agent-core.ts: prune oldest agent messages when heapUsed exceeds
  1.5 GB (pruning ~20% of oldest messages per round).

Both mechanisms check actual heap pressure rather than an arbitrary
proxy (message count), catching the root cause regardless of whether
memory ballooned from many small entries or few huge ones.
* for one more GC cycle before the process is killed.
*/
private static readonly HEAP_MEMORY_THRESHOLD = 2 * 1024 * 1024 * 1024; // 2 GB

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Hardcoded 2GB threshold is a no-op on small-memory containers, and the safety net has zero observability.

In Docker/K8s with --max-old-space-size=512, heapUsed can never reach 2GB — the process is OOM-killed first. This also applies to the 1.5GB threshold in agent-core.ts. Derive thresholds from the actual heap limit:

Suggested change
private static readonly HEAP_MEMORY_THRESHOLD = (() => {
try {
return (v8.getHeapStatistics().heap_size_limit * 0.7) | 0;
} catch {
return 2 * 1024 * 1024 * 1024;
}
})();

Additionally, neither this heap trigger nor pruneMessages() produces any log output. Add debugLogger.warn(...) when these triggers activate so operators can tell if the safety nets fired.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

// AND few huge entries (large file reads, shell outputs).
if (
!force &&
process.memoryUsage().heapUsed > GeminiChat.HEAP_MEMORY_THRESHOLD
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Heap-based force races with compression's own memory allocation (OOM risk), AND bypasses the hasFailedCompressionAttempt circuit breaker (API cost amplification).

(1) OOM race: heapUsed is read BEFORE service.compress(). If heap is 1.95GB (below 2GB), force is NOT set and compression proceeds. During compression, serializing chat history + response parsing can push heap past 4GB before GC — killing the process. Lower the threshold to 1.5GB for genuine headroom.

(2) API retry loop: force=true bypasses hasFailedCompressionAttempt. If compression fails, the flag is set — but the next call still triggers force=true because heap remains >2GB. Result: unlimited compression API calls. Even successful compression may not reduce heap (if pressure is from cached files), causing redundant re-compression.

Suggested change
process.memoryUsage().heapUsed > GeminiChat.HEAP_MEMORY_THRESHOLD
if (
!force &&
!this.hasFailedHeapCompressionAttempt &&
process.memoryUsage().heapUsed > 1.5 * 1024 * 1024 * 1024
) {
force = true;
this.hasFailedHeapCompressionAttempt = true;
debugLogger.warn(
`Heap pressure (${(process.memoryUsage().heapUsed / 1024**3).toFixed(1)} GB) — forcing compression`,
);
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

* a hard count cap. This catches the actual root cause (heap usage)
* regardless of how the memory ballooned (many small entries vs few huge).
*/
pruneMessages(): number {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] pruneMessages() has two structural issues:

  1. Never called for headless agents. The sole call site is agent-interactive.ts:129. All subagents, background tasks, and AgentHeadless usage never prune — exactly the long-running scenarios that need it most.
  2. Drops system prompts indiscriminately. Removing the oldest 20% of messages also removes system prompts and tool definitions (at the front of history). After enough rounds, the agent loses its identity and tools.
Suggested change
pruneMessages(): number {
pruneMessages(): number {
const threshold = 1.5 * 1024 * 1024 * 1024;
if (process.memoryUsage().heapUsed < threshold) {
return 0;
}
const toPrune = Math.max(1, Math.ceil(this.messages.length * 0.2));
// Protect system/tool messages at the head of history.
const pruneFrom = Math.min(2, this.messages.length);
const actualPrune = Math.min(toPrune, this.messages.length - pruneFrom);
this.messages.splice(pruneFrom, actualPrune);
return actualPrune;
}

Also add this.core.pruneMessages() in AgentHeadless.execute() after each reasoning round.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

// Prune old messages to prevent unbounded memory growth in
// long-lived interactive sessions (81+ minute sessions with
// hundreds of rounds can hit 4 GB without pruning).
this.core.pruneMessages();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] pruneMessages() runs AFTER memory-intensive operations — fails to prevent OOM in the exact scenario it targets.

The sequence is: addMessagerunOneRound (model API + large tool outputs) → pruneMessages. If runOneRound pushes heap past 4GB, the process OOMs before pruneMessages executes. The safety net only helps if heap is 1.5–4GB BEFORE the round — but the dangerous case is a round that itself triggers the OOM.

Suggested change
this.core.pruneMessages();
while (message !== null && !this.masterAbortController.signal.aborted) {
// Prune BEFORE the round to ensure headroom for tool outputs.
this.core.pruneMessages();
this.addMessage('user', message);
await this.runOneRound(message);
message = this.queue.dequeue();
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

@Dinsmoor
Copy link
Copy Markdown
Contributor Author

Sorry, I don't know every intricacy with qwen code, but figured it'd be better to get at least the ball rolling since this has crashed for me a couple times for this reason

Output for you if it's helpful

  ⠸ The truth is in here... somewhere... (7m 16s · ↑ 684 tokens · esc to cancel)
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
>   Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  auto-accept edits (shift + tab to cycle)

<--- Last few GCs --->

[264032:0x17bca930]  4645507 ms: Mark-Compact 4020.7 (4134.3) -> 4005.9 (4130.8) MB, 759.48 / 0.75 ms  (average mu = 0.233, current mu = 0.042) allocation failure; scavenge might not succeed
[264032:0x17bca930]  4646214 ms: Mark-Compact 4022.3 (4131.1) -> 4006.2 (4130.3) MB, 670.26 / 0.89 ms  (average mu = 0.152, current mu = 0.051) allocation failure; scavenge might not succeed


<--- JS stacktrace --->

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----

 1: 0xb76db1 node::OOMErrorHandler(char const*, v8::OOMDetails const&) [/home/tyler/.nvm/versions/node/v20.20.0/bin/node]
 2: 0xee62f0 v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, v8::OOMDetails const&) [/home/tyler/.nvm/versions/node/v20.20.0/bin/node]
 3: 0xee65d7 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, v8::OOMDetails const&) [/home/tyler/.nvm/versions/node/v20.20.0/bin/node]
 4: 0x10f82d5  [/home/tyler/.nvm/versions/node/v20.20.0/bin/node]
 5: 0x10f8864 v8::internal::Heap::RecomputeLimits(v8::internal::GarbageCollector) [/home/tyler/.nvm/versions/node/v20.20.0/bin/node]
 6: 0x110f754 v8::internal::Heap::PerformGarbageCollection(v8::internal::GarbageCollector, v8::internal::GarbageCollectionReason, char const*) [/home/tyler/.nvm/versions/node/v20.20.0/bin/node]
 7: 0x110ff6c v8::internal::Heap::CollectGarbage(v8::internal::AllocationSpace, v8::internal::GarbageCollectionReason, v8::GCCallbackFlags) [/home/tyler/.nvm/versions/node/v20.20.0/bin/node]
 8: 0x10e6271 v8::internal::HeapAllocator::AllocateRawWithLightRetrySlowPath(int, v8::internal::AllocationType, v8::internal::AllocationOrigin, v8::internal::AllocationAlignment) [/home/tyler/.nvm/versions/node/v20.20.0/bin/node]
 9: 0x10e7405 v8::internal::HeapAllocator::AllocateRawWithRetryOrFailSlowPath(int, v8::internal::AllocationType, v8::internal::AllocationOrigin, v8::internal::AllocationAlignment) [/home/tyler/.nvm/versions/node/v20.20.0/bin/node]
10: 0x10c4a56 v8::internal::Factory::NewFillerObject(int, v8::internal::AllocationAlignment, v8::internal::AllocationType, v8::internal::AllocationOrigin) [/home/tyler/.nvm/versions/node/v20.20.0/bin/node]
11: 0x1520596 v8::internal::Runtime_AllocateInYoungGeneration(int, unsigned long*, v8::internal::Isolate*) [/home/tyler/.nvm/versions/node/v20.20.0/bin/node]
12: 0x1959ef6  [/home/tyler/.nvm/versions/node/v20.20.0/bin/node]

@LaZzyMan LaZzyMan added the type/bug Something isn't working as expected label May 14, 2026
if (process.memoryUsage().heapUsed < threshold) {
return 0;
}
// Remove ~20% of the oldest messages to relieve heap pressure.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] pruneMessages() operates on AgentCore.messages (UI display log, AgentMessage[]), but the actual memory-heavy data structure is GeminiChat.history (Content[] with full Part[] arrays). These are two independent arrays — pruneMessages() never touches GeminiChat.history, so the pruning is completely ineffective at reducing heap pressure. The method will return positive counts while the real memory consumer continues to grow unchecked.

Suggested change
// Remove ~20% of the oldest messages to relieve heap pressure.
pruneMessages(): number {
const threshold = 1.5 * 1024 * 1024 * 1024;
if (process.memoryUsage().heapUsed < threshold) {
return 0;
}
const toPrune = Math.max(1, Math.ceil(this.messages.length * 0.2));
// Also prune the corresponding GeminiChat history entries to actually
// reduce heap pressure. Without this, only the UI log shrinks while
// the real memory hog (Content[] with Part[]) keeps growing.
this.chat?.truncateHistory(toPrune);
this.messages.splice(0, toPrune);
return toPrune;
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

// regardless of `hasFailedCompressionAttempt`. This is a memory
// safety net — catches the actual root cause (heap pressure) rather
// than a proxy (entry count). Protects against both many small entries
// AND few huge entries (large file reads, shell outputs).
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Force compression death loop: when heap > 2GB, force is elevated to true here. But the hasFailedCompressionAttempt latch (line ~530) is only set when !force. Since the heap check already changed force to true, the latch is never set on failure — every subsequent sendMessageStream call sees heap > 2GB, elevates force again, compresses again, fails again, and the latch is skipped again. Each failed compression burns a model API call. This loops indefinitely until heap pressure subsides or the user's API quota is exhausted.

Suggested change
// AND few huge entries (large file reads, shell outputs).
const originalForce = force;
if (
!force &&
process.memoryUsage().heapUsed > GeminiChat.HEAP_MEMORY_THRESHOLD
) {
force = true;
}
// ... later, in the failure path:
// Use originalForce instead of force for the latch decision
if (!originalForce) {
this.hasFailedCompressionAttempt = true;
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

// Prune old messages to prevent unbounded memory growth in
// long-lived interactive sessions (81+ minute sessions with
// hundreds of rounds can hit 4 GB without pruning).
this.core.pruneMessages();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] 12 tests fail because createMockCore() is missing the new pruneMessages method. The call this.core.pruneMessages() added at this line throws TypeError: this.core.pruneMessages is not a function in all agent-interactive tests.

Suggested change
this.core.pruneMessages();
// In the test file's createMockCore():
pruneMessages: vi.fn().mockReturnValue(0),

— DeepSeek/deepseek-v4-pro via Qwen Code /review

// regardless of `hasFailedCompressionAttempt`. This is a memory
// safety net — catches the actual root cause (heap pressure) rather
// than a proxy (entry count). Protects against both many small entries
// AND few huge entries (large file reads, shell outputs).
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Heap-triggered force=true leaves trigger as undefined. This causes ChatCompressionService.compress() to classify the compression as 'manual' (user-initiated) via trigger ?? (force ? 'manual' : 'auto'), when it's actually automated memory-pressure response. Downstream hooks and telemetry will be misclassified.

Suggested change
// AND few huge entries (large file reads, shell outputs).
if (
!force &&
process.memoryUsage().heapUsed > GeminiChat.HEAP_MEMORY_THRESHOLD
) {
force = true;
options = { ...options, trigger: 'auto' };
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

if (process.memoryUsage().heapUsed < threshold) {
return 0;
}
// Remove ~20% of the oldest messages to relieve heap pressure.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] pruneMessages() silently splices messages from the UI transcript array (getMessages()) without emitting any event, log, or user notification. Messages are permanently removed with no recovery path and no indication that context was trimmed. Combined with the fact that this pruning doesn't reduce heap (see Critical issue above), the only effect is silently degrading the user's scrollback history.

Suggested change
// Remove ~20% of the oldest messages to relieve heap pressure.
pruneMessages(): number {
// ... existing threshold check ...
const toPrune = Math.max(1, Math.ceil(this.messages.length * 0.2));
this.messages.splice(0, toPrune);
// Notify the UI so users understand context was trimmed
this.pushMessage?.({
role: 'info',
content: `Trimmed ${toPrune} oldest messages to manage memory.`,
});
return toPrune;
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants