fix: clear tool output and attachments when pruning to prevent memory leak
Fixes #3013
Summary
Clear tool output and attachments when compaction prunes old tool results, actually freeing memory instead of just flagging.
Evidence
In a real session working on this repo:
| Metric | Value |
|---|---|
| OpenCode process RAM | 16.4 GB |
| Tool parts on disk | 34,887 files |
| Session duration | ~3 hours |
Sample tool output sizes from this session:
-
webfetchof docs page: 10 KB -
readof source file: 5-50 KB -
git diff/gh pr diff: 10-100 KB -
bashcommand outputs: 1-50 KB
All of these outputs stay in memory even after compaction marks them "old".
The Problem
In SessionCompaction.prune(), when tool outputs are pruned:
// BEFORE: Only sets a flag, output data stays in memory
part.state.time.compacted = Date.now()
await Session.updatePart(part)
The compacted timestamp is used by toModelMessage() to replace output with placeholder text like "(Old tool result content cleared)" - but the actual data never gets freed.
Over a long session:
- 34,887 tool calls × average output size = hundreds of MB to GBs retained
- Memory never decreases, even after compaction runs
- Eventually Bun runs out of memory and crashes
The Fix
// AFTER: Actually clear the data
part.state.time.compacted = Date.now()
part.state.output = "" // Free the output string
part.state.attachments = undefined // Free any attachments
await Session.updatePart(part)
Now when compaction runs, the memory is actually freed. The placeholder text is already shown to the LLM (that logic exists), we just were not clearing the source data.
Testing
Existing compaction tests pass. Added test verifying output/attachments are cleared after prune.
The following comment was made by an LLM, it may be inaccurate:
No duplicate PRs found
Addressed review comment about test not verifying behavior when pruning is disabled (commit 488173440).
The test now:
- Creates a tool part with large output (200,000 chars) and an attachment (similar to the first test)
- Creates additional user messages to get past turn protection
- Calls
prune()with pruning disabled via config - Verifies that:
-
output.lengthremains 200,000 (unchanged) -
attachments.lengthremains 1 (unchanged) -
time.compactedis undefined (not set)
-
this would be really helpful