findCutPoint silently keeps the whole window when a turn's tool results exceed keepRecentTokens, so compaction becomes a no-op #1555
Replies: 1 comment
|
I confirmed a real-world recurrence of the quiet second manifestation described here in a daemon-backed Sanitized journal evidence:
This was not only an uncapped footer percentage. Compaction repeatedly recorded success without advancing the retained boundary or reducing context. Current I think we should land @Jiaaqiliu's fix while preserving authorship, then add a post-compaction progress invariant and regression coverage: a successful compaction must advance the retained boundary or reduce the estimated context. Otherwise Prime should stop automatic retries and surface an actionable terminal error instead of continuing indefinitely. I am not attaching the raw journal because it contains private prompts and environment details. |
Uh oh!
There was an error while loading. Please reload this page.
Environment. prime-agent 0.7.3,
mainat f8f0036, Node 22.17.0, macOS 15 (darwin 25.5.0).Summary
When one turn's trailing tool results alone exceed
keepRecentTokens,findCutPointsilently falls back to the oldest cut point in the window, which means keep everything.prepareCompactionthen finds nothing to summarize and returnsundefined, and the user is told the session is too short to compact while the context is above 95%.Two tool calls in a single turn are enough to reach it.
Root cause
core/compaction/compaction.ts:378:findValidCutPoints(:282) deliberately excludestoolResultentries, so there is never a cut point after the last assistant message. Threshold compaction is triggered at exactly that boundary, where the branch tail is[assistant(toolCalls), toolResult, toolResult, ...]. If the backwards scan crosseskeepRecentTokenswhile still inside those results, nocutPoints[c] >= iexists, the inner loop completes without assigning, andcutIndexkeeps itscutPoints[0]initializer.The threshold is easy to reach:
keepRecentTokensdefaults to 20000 (:113),estimateTokensischars/4, and bash output is capped at 50KB per call (tools/truncate.ts:12), i.e. about 12800 estimated tokens each.Reproduction
Against the exported function, with
DEFAULT_COMPACTION_SETTINGS:Observed behaviour
prepareCompaction(:614) computeshistoryEnd = 0, somessagesToSummarizeis empty and it returnsundefined._performCompactionthen throwsCompactionSkippedError, and the user sees:while
/usageshows the context near full. It repeats every turn. When the real provider overflow arrives,_checkCompactionruns_runAutoCompaction("overflow", true), which reaches the sameprepareCompactionand skips again, so_overflowRecoveryadvances to"reported"and the session ends with "Context overflow recovery failed after one compact-and-retry attempt."/compactcannot rescue it either, since it funnels through the same code.There is a quieter second manifestation: once a
previousSummaryexists, themessagesToSummarize.length === 0guard no longer returnsundefined, so compaction "succeeds" with afirstKeptEntryIdthat retains the whole window.tokensBeforeis unchanged andshouldCompactis still true next turn, which is an unbounded loop of summarization calls that free nothing.Suggested fix
Fall back to the newest cut point rather than the oldest. Keeping only the last turn is the correct degenerate result when that turn's tail alone exceeds the budget, and the existing split-turn machinery already summarizes the prefix. With the change the example above returns
{ firstKeptEntryIndex: 3, turnStartIndex: 2, isSplitTurn: true }.I have a fix with a regression test on a branch:
fix/compaction-cutpoint-fallback.npm run checkpasses and the surrounding suites still pass. I opened it as a PR first and the contribution gate closed it, which is what CONTRIBUTING.md says should happen, so I am bringing it here instead. Happy to leave it as is, adjust it, or drop it entirely if you would rather fix this differently.All reactions