LLM-1593 preserve user language after compaction#201
Conversation
During long tool-call chains the compactor's protected window could push the user's last message out of view, stripping the LLM of language / tone anchors. The model would then drift into the user's locale (e.g. Danish). - Add to locate the latest message. - Floor the cutoff so it never exceeds that index: we keep everything from the last user message onward, and only compact tool results before it. - Add test verifying the floor works when the protected window would otherwise bury the user message under 30 tool results. Co-Authored-By: Kimchi <noreply@kimchi.dev>
Kimchi Code Review
Summary📊 Review Score: 82/100 (overall code quality — 0 lowest, 100 highest) 🧪 Tests: yes — A new test case verifies that the compaction window is extended backward to preserve the most recent user message, asserting both message retention and the resulting cutoff value. 📝 Found 2 issue(s). See inline comments for details. What to expectKimchi will analyze the changes in this pull request and post:
The review typically completes within a few minutes. This comment will be updated once the review is ready. Interact with Kimchi
ConfigurationReviews are configured by your organization admin. Powered by Kimchi — AI-powered code review by CAST AI |
There was a problem hiding this comment.
📊 Review Score: 82/100 (overall code quality — 0 lowest, 100 highest)
⏱️ Estimated effort to review: 2/5 (1 = trivial, 5 = very complex)
🧪 Tests: yes — A new test case verifies that the compaction window is extended backward to preserve the most recent user message, asserting both message retention and the resulting cutoff value.
📝 Found 2 issue(s). See inline comments for details.
| @@ -90,8 +105,15 @@ export default function contextCompactorExtension(pi: ExtensionAPI) { | |||
| if (lastInputTokens < PRUNE_THRESHOLD) return | |||
|
|
|||
| const { messages } = event | |||
There was a problem hiding this comment.
After flooring cutoff to the last user message index, the code never checks whether the final cutoff is 0. If the most recent user message sits at index 0 and baseCutoff is greater than 0, the final cutoff becomes 0, which means no messages will actually be pruned. The original early return for cutoff === 0 is bypassed, risking unnecessary work and a spurious compaction telemetry entry.
💡 Suggestion: Add an early return immediately after computing the final cutoff: if (cutoff === 0) return.
| * This ensures the LLM always retains the user's language, tone, | ||
| * and task framing even during long tool-call chains. | ||
| */ | ||
| function findLastUserMessageIndex(messages: ContextEvent["messages"]): number { |
There was a problem hiding this comment.
ℹ️🔧 Maintainability
The as { role?: string } cast inside findLastUserMessageIndex suppresses TypeScript type checking. If the message union type is later refactored, this cast will not trigger a compile-time error and could silently fail to identify user messages at runtime.
💡 Suggestion: Replace the type assertion with a runtime type guard, e.g. function isUserMessage(m: unknown): m is { role: 'user' }, and use it to narrow the message type safely.
Problem
During long tool-call chains, the context compactor's protected window (
PROTECT_WINDOW = 30messages /MAX_PROTECTED_CHARS = 100k) could push the user's last message out of view. With 30+ compacted[compacted: ...]tool results filling the window, the LLM loses the language and tone anchor from the user's input. The model then drifts into the user's locale — for example, switching into Danish mid-session.Fix
Surgically extend the protected window backward so the most recent user message is always visible. The model retains whatever language, tone, and framing the user actually chose.
How it works
Why not other approaches?
Changes
src/extensions/context-compactor.ts— floor pruning at the most recent user messagesrc/extensions/context-compactor.test.ts— coverage for the new floor behaviorTesting
All 18 context-compactor tests pass.
Kimchi Summary
What changed
Extends the context compactor's protected window to always retain the most recent user message, preventing it from being pruned during long tool-call chains.
Why
Without this guard, an extended sequence of tool results could push the user's original message past the pruning boundary, causing the LLM to lose their language, tone, and task framing.
Key changes
src/extensions/context-compactor.ts: AddedfindLastUserMessageIndex()helper to locate the latest user message in context.src/extensions/context-compactor.ts: Updated cutoff calculation toMath.min(baseCutoff, lastUserIndex), expanding the protected window backward when necessary.src/extensions/context-compactor.test.ts: Added test verifying that a user message is preserved even when 30 subsequent tool results fill the protected window.Impact
Pruning becomes slightly less aggressive whenever the most recent user message falls outside the standard protected window. No breaking changes or migration steps are required.