qwen-code/docs/design/compaction-image-stripping/compaction-image-stripping-design.md
顾盼 c512427f93
feat(core): strip inline media before chat compaction summary (#4101)
* 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>
2026-05-14 10:20:11 +08:00

10 KiB
Raw Permalink Blame History

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:

  1. Inline image / document bytes leak into the summary prompt. MCP tools that surface attachments (screenshots, design mockups, PDFs) place inlineData parts 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.

  2. findCompressSplitPoint token estimation is wrong for binary parts. The split-point algorithm uses JSON.stringify(content).length to 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>.txt and 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 is read_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 text parts: text.length
  • For inlineData / fileData parts: imageTokenEstimate * 4 (default 1,600 × 4 = 6,400 chars).
  • For functionCall / functionResponse parts: 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:

  • toolfunctionResponse parts from compactable built-in tools. Cleared as a unit: response output replaced with the sentinel, functionResponse.parts dropped along with it.
  • media — top-level inlineData / fileData parts under user-role messages (e.g. images pasted via @reference). Replaced with [Old inline media cleared: <mime>].
  • nested-mediafunctionResponse parts from non-compactable tools (e.g. MCP screenshot tools whose names are not in COMPACTABLE_TOOLS) that carry images / documents on the functionResponse.parts extension 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.ts
  • packages/core/src/services/compactionInputSlimming.test.ts

Modified files

  • packages/core/src/config/config.ts — extend ChatCompressionSettings
  • packages/core/src/services/chatCompressionService.ts — call slimming before runSideQuery; replace char-count helper; precompute charCounts once for splitter + guard
  • packages/core/src/services/chatCompressionService.test.ts — add a wire-up test asserting base64 never reaches the summary model
  • packages/core/src/services/microcompaction/microcompact.ts — extend collection to inline images
  • packages/core/src/services/microcompaction/microcompact.test.ts — test image clearing

Scope Boundaries

In scope

  • Strip inline media from compaction input
  • Fix findCompressSplitPoint char 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
  • /context paste breakdown
  • Telemetry events for slim stats

Open Questions

  1. Should the placeholder text include a hash to allow future reinflation? Today we emit just [image: image/png]. If/when a read_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.
  2. imageTokenEstimate = 1600 correct 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.
  3. MIN_COMPRESSION_FRACTION gate 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 on imagesStripped proportion.