* feat(core): strip inline media before chat compaction summary
Compaction's side-query previously shipped historyToCompress verbatim.
Two related issues degraded summary quality and accuracy:
- Inline image / document bytes (from MCP tool results) leaked into the
summary model's prompt where they could not be interpreted and merely
inflated payload.
- findCompressSplitPoint apportioned chars via JSON.stringify(content),
so a single 1 MB base64 image looked like ~350K tokens and biased
the split point. Real Qwen-VL token cost is at most a few thousand.
This change adds a new compactionInputSlimming module that replaces
inlineData / fileData parts with short [image: <mime>] / [document:
<mime>] placeholders before the side-query, leaving live history
unchanged. The same constant feeds estimateContentChars so the
split-point algorithm sees the budget the summary model actually
consumes downstream. Microcompact is also extended to clear stale
inline images alongside old tool results.
A previous draft of the design also externalized large pastes to a
content-addressable on-disk cache, but it was withdrawn after surveying
claude-code's 2026-03 to 2026-05 releases - upstream consensus is to
keep user input visible to the model and amortize cost via prompt
caching rather than externalize. See the Out-of-scope section of the
design doc for the full rationale.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core): recurse into functionResponse.parts when stripping media
E2E exposed that `read_file` (and any tool that surfaces an image)
wraps the result in `functionResponse.parts` via
`coreToolScheduler.createFunctionResponsePart`. The slimming module
only walked top-level `part.inlineData` / `part.fileData`, so the
nested base64 bytes leaked into the compaction side-query payload.
The previous design doc incorrectly claimed that no recursive walk
was needed.
Three changes:
- `slimCompactionInput.transformPart` recurses into the nested
`functionResponse.parts` array and replaces each entry via the
same image/document placeholder logic.
- `estimatePartChars` walks the nested array too, so the split-point
algorithm doesn't fall back to `JSON.stringify` and over-count the
base64 bytes.
- `microcompactHistory` drops `functionResponse.parts` when clearing
an old tool result; the previous spread of `...part.functionResponse`
silently carried the original media through.
New unit tests cover (a) nested image / document stripping, (b) the
estimator no longer being skewed by nested base64. The previously
failing E2E now PASSES: side-query payload contains zero `data:image/`
occurrences, zero long base64 runs, and exactly one
`[image: image/png]` placeholder.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core): address review findings on compaction image stripping
Addresses 8 valid findings from PR review:
- [Critical] estimatePartTokens now handles `fileData` parts (both
top-level and nested under functionResponse.parts). Without this,
microcompact's `tokensSaved === 0` short-circuit silently discarded
every fileData clear.
- estimatePartTokens for binary parts now uses a fixed
MEDIA_PART_TOKEN_ESTIMATE constant (1,600) instead of base64-length
divided by 4. The old formula billed a 1 MB image as ~250K tokens
rather than its actual ~1,280 visual tokens on Qwen-VL, inflating
the saved-token metric by orders of magnitude.
- mimeType values from MCP tool servers are now run through
sanitizeMimeForPlaceholder before being embedded in `[image: …]` /
`[document: …]` placeholders. An adversarial server could otherwise
craft `image/png]\n\n[SYSTEM: …` and inject instructions into the
summary side-query.
- collectCompactablePartRefs now recognizes a third 'nested-media'
kind: functionResponse parts from non-compactable tools (e.g. MCP
screenshots whose names aren't in COMPACTABLE_TOOLS) that carry
images on functionResponse.parts. The nested media is dropped while
the tool's text output is preserved. Previously such media
accumulated forever in live history.
- keepRecent budgets are now per-kind (tool / media / nested-media).
Setting `toolResultsNumToKeep: 1` keeps 1 of each kind rather than 1
entry total across the merged list — matches the natural reading of
the setting name.
- findCompressSplitPoint's `precomputedCharCounts` fallback path is
now documented as test-only; production callers MUST pass the
precomputed array.
- The text-based branch of isAlreadyCleared is gone: with the new
nested-media handling (drops `parts`) and existing media handling
(replaces with `{ text: … }` that is no longer collected) it was
unreachable.
- OpenAI converter (createToolMessage) now passes text parts inside
functionResponse.parts through as text content. The slimmer writes
`{ text: '[image: image/png]' }` placeholders into the nested array;
without this fix the converter dropped them when serializing to the
OpenAI wire format, leaving the summary model with empty tool
responses instead of the placeholder.
Two findings deferred with rationale (see design doc Open Questions):
MIN_COMPRESSION_FRACTION still uses pre-slim counts (acceptable —
"user shared an image" is itself worth summarizing); SlimResult is not
re-exported (round-3 simplify decided to keep core's public surface
minimal).
E2E re-verified end-to-end: side-query payload contains 0 data:image/
occurrences, 0 long base64 runs, and 1 `[image: image/png]` placeholder
in the expected position. 185/185 collocated unit tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(core): tidy compaction slimming after self-review
Three small polishes from a follow-up code review pass:
- `stripNestedMedia` no longer re-casts its return value: after
destructuring `parts` out of the widened input type, TypeScript
infers the original `FunctionResponse` shape without help.
- `isAlreadyCleared` shed a 10-line comment block — the body is now
one line, so one descriptive line above it is enough.
- OpenAI converter's nested-part text check switched from
`(part as { text?: unknown }).text` to
`'text' in part && typeof part.text === 'string'`, dropping the
cast and letting `in` narrow the type.
No behavior change. 185/185 unit tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(core): wire slim stats to debug log; split MicrocompactMeta tools vs media
Addresses two follow-up review suggestions:
- `slimCompactionInput` returned `stats.imagesStripped` and
`stats.documentsStripped` but the orchestrator never consumed them.
Now logged at debug level whenever non-zero so operators can confirm
the slimming pipeline actually fires on image-heavy compactions.
- `MicrocompactMeta.toolsCleared` lost meaning after the recent
refactor: it had grown to count both tool-result clears AND
inline-media / nested-media clears. Renamed:
- `toolsCleared` → only `tool`-kind clears (compactable tool output)
- `mediaCleared` → `media` + `nested-media` clears (new)
- `toolsKept` / `mediaKept` mirror the split, replacing the prior
`toolsKept` that was actually a combined count.
The single non-test consumer (`client.ts` debug log) updated to use
both fields.
185/185 unit tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 KiB
Compaction Image Stripping + Token Estimation Fix
Problem Statement
When ChatCompressionService triggers (auto or manual), it ships
historyToCompress to the summary model verbatim. Two related issues
degrade quality, accuracy, and cost:
-
Inline image / document bytes leak into the summary prompt. MCP tools that surface attachments (screenshots, design mockups, PDFs) place
inlineDataparts directly into the conversation. The compression pipeline does not strip them, so the summary model receives raw base64 it usually cannot interpret, and the side-query payload is needlessly inflated. -
findCompressSplitPointtoken estimation is wrong for binary parts. The split-point algorithm usesJSON.stringify(content).lengthto apportion chars across the history. A single 1 MB base64 image (~1.4 M chars) makes one entry look like ~350 K tokens, dwarfing actual text and biasing the cut toward the wrong place. The real token cost for a Qwen-VL image is at most a few thousand tokens. The estimator should treat binary parts as a small constant.
claude-code addresses (1) with stripImagesFromMessages. qwen-code has
neither this strip nor the corresponding char-counting fix.
This change adds both, scoped to the compaction side-query input
only. The live conversation history, persistence
(chats/<sessionId>.jsonl), and the prompt sent to the main model on
the next turn are untouched. Slimming applies only to the side-query
payload built inside chatCompressionService.
Out of scope (deferred or rejected)
- Large-paste externalization to a paste cache. An earlier draft
of this design proposed hashing oversize text into
~/.qwen/paste-cache/<sha>.txtand substituting a placeholder. We rejected it after surveying claude-code's 2026-03 to 2026-05 releases: the upstream direction is to keep user input visible to the model and amortize cost via prompt caching (1h TTL knobs, image downscaling) rather than externalize it. Putting verbatim user input behind a hash placeholder risks "intent drift" once compaction has collapsed the original text away. If we revisit this later, the right pattern isread_paste(hash)as a real tool the model can reach for, not silent rewriting.
Current State vs Target
| Concern | qwen-code today | claude-code reference | Target after this change |
|---|---|---|---|
| Image/document in compact prompt | Sent verbatim | stripImagesFromMessages replaces with [image] / [document] |
Sent as [image: mime] / [document: mime] placeholder |
| Binary part token estimation | JSON.stringify().length (wildly off) |
Treated as fixed budget | Configurable constant (default 1,600 tokens / ~6,400 chars) |
| Microcompact image cleanup | Not touched (only text tool results cleared on idle) | Time-based MC clears all | Microcompact also clears stale inline images alongside tool results |
Proposed Changes
Layer 1: compaction input slimming (services/compactionInputSlimming.ts)
A new pure module that takes Content[] and returns a slimmed
Content[]. One transform: inline-media stripping. Walk every Part.
If the part has inlineData or fileData replace it with a text
part of form [image: image/png] (or [document: application/pdf]).
qwen-code attaches tool-returned media on functionResponse.parts
(an extension over the standard @google/genai FunctionResponse
schema; see coreToolScheduler.createFunctionResponsePart). The
slimmer recurses into that nested array so a base64 image returned by
read_file or any MCP attachment-emitting tool is also replaced.
The transform returns a fresh Content[] array; the original is never
mutated. If the transform produces zero changes the original array
reference is returned (identity-equal). The orchestrator calls
slimCompactionInput as the last step before runSideQuery in
chatCompressionService.ts.
Layer 2: token estimation fix (chatCompressionService.ts)
findCompressSplitPoint currently uses JSON.stringify(content).length
for char-count apportionment. Replace this with an
estimateContentChars helper that:
- For
textparts:text.length - For
inlineData/fileDataparts:imageTokenEstimate * 4(default 1,600 × 4 = 6,400 chars). - For
functionCall/functionResponseparts:JSON.stringify(part).length(unchanged behavior).
This is the same constant the slimming module uses, so the budget the
split-point algorithm sees matches what the slimmed prompt actually
consumes downstream. To avoid duplicate walks, compress() precomputes
charCounts once and passes them to findCompressSplitPoint (new
optional 4th argument); the same array is reused for the
MIN_COMPRESSION_FRACTION guard.
Layer 3: microcompact image cleanup (microcompaction/microcompact.ts)
collectCompactablePartRefs now returns three groups:
tool—functionResponseparts from compactable built-in tools. Cleared as a unit: response output replaced with the sentinel,functionResponse.partsdropped along with it.media— top-levelinlineData/fileDataparts under user-role messages (e.g. images pasted via@reference). Replaced with[Old inline media cleared: <mime>].nested-media—functionResponseparts from non-compactable tools (e.g. MCP screenshot tools whose names are not inCOMPACTABLE_TOOLS) that carry images / documents on thefunctionResponse.partsextension field. Only the nested media is dropped; the tool's text output is preserved.
Each kind has its own keepRecent budget. Setting
toolResultsNumToKeep: 1 keeps the most recent of each category
(1 tool + 1 media + 1 nested-media), not 1 entry total across the
combined list.
mimeType values surfaced from MCP tool servers are passed through
sanitizeMimeForPlaceholder before being embedded in any placeholder
string. The slimmer and microcompact share this helper.
Layer 4: configuration (config/config.ts)
One new field under chatCompression settings:
{
"chatCompression": {
"contextPercentageThreshold": 0.7,
"imageTokenEstimate": 1600
}
}
Plus an env override for ops/debug: QWEN_IMAGE_TOKEN_ESTIMATE.
Key Design Decisions
Decision 1: imageTokenEstimate = 1600.
Qwen-VL family caps at 1,280 visual tokens per image without
vl_high_resolution_images; with that flag, up to 16,384. 1,600 is a
conservative middle ground biased slightly high — overestimating leads
to earlier compaction (safe), underestimating leads to late compaction
(unsafe). For non-VL models (Qwen3-Coder, the qwen-code default) the
constant only matters for token-estimation correctness, since images
do not reach the model anyway.
Decision 2: Strip the slimmed copy, not the live history.
slimCompactionInput returns a fresh array; the chat history stored
in GeminiChat is untouched. Local persistence
(.chats/<sessionId>.jsonl) keeps the full conversation as the user
experienced it, so --resume works without loss.
Decision 3: Microcompact treats images uniformly with old tool results. The time-based idle trigger already clears stale tool output; extending it to inline images keeps the policy consistent and reuses the existing keepRecent window.
Decision 4: No paste-store / no text externalization. See Out-of-scope section. Upstream consensus (claude-code 2026-03 → 2026-05) is to keep verbatim user input visible and amortize via prompt caching, not externalize.
Files Affected
New files
packages/core/src/services/compactionInputSlimming.tspackages/core/src/services/compactionInputSlimming.test.ts
Modified files
packages/core/src/config/config.ts— extendChatCompressionSettingspackages/core/src/services/chatCompressionService.ts— call slimming beforerunSideQuery; replace char-count helper; precompute charCounts once for splitter + guardpackages/core/src/services/chatCompressionService.test.ts— add a wire-up test asserting base64 never reaches the summary modelpackages/core/src/services/microcompaction/microcompact.ts— extend collection to inline imagespackages/core/src/services/microcompaction/microcompact.test.ts— test image clearing
Scope Boundaries
In scope
- Strip inline media from compaction input
- Fix
findCompressSplitPointchar estimation - Microcompact image part cleanup on the idle trigger
- One setting + env override
Deferred
- Large-paste externalization (see Out-of-scope above)
- Reinflation tool (
read_paste(hash)etc.) - Persistence-layer dedup
/contextpaste breakdown- Telemetry events for slim stats
Open Questions
- Should the placeholder text include a hash to allow future
reinflation? Today we emit just
[image: image/png]. If/when aread_paste-style tool lands, we may want an ID. For now the placeholder is informational; the original image still exists in the live history and persistence. imageTokenEstimate = 1600correct for non-Qwen-VL models served via Anthropic / OpenAI proxies? Likely a slight under-estimate for Claude (where images can be up to ~5K tokens) but harmless: it only affects the split-point heuristic, never the actual prompt the user-facing model sees.MIN_COMPRESSION_FRACTIONgate is computed on pre-slim char counts. An image-heavy slice can pass the 5% threshold (because images count as ~6,400 chars each in the estimator) and then shrink to[image: …]placeholders post-slim. The summary model then receives almost no textual context. This is intentional for now: the summary's job is to record "user shared an image of X" even when most of the slice was visual, and the gate's purpose is "is there enough to be worth summarizing" — which images reasonably satisfy. If quality regresses we can revisit by either re-checking post-slim or biasing the gate onimagesStrippedproportion.