qwen-code/packages/core/src/services/postCompactAttachments.ts
顾盼 365409366d
refactor(core)!: replace tail-preservation compaction with summary + restoration attachments (#4599)
* refactor(core): rewrite compression prompt to 9-section claude-code-style format

Replaces the <state_snapshot> XML template with a numbered 9-section
structure that mandates verbatim preservation of user messages, including
the historical chronological list (section 6). The new format is
designed to pair with post-compact file/image restoration (separate work)
so the agent can resume long single-turn tasks without losing intent.

* refactor(core): align compaction trigger string with new 9-section prompt

The user-turn trigger injected after the system prompt still said
'generate the <state_snapshot>' from the old XML prompt era. Updated to
'produce the 9-section summary' to match Task 1's new prompt format.

Also tightens the prompt test to assert the specific user-message
verbatim mandate (not just the word 'verbatim' anywhere) so a future
regression that drops the mandate won't silently pass.

* feat(core): add postCompactAttachments module with file path extractor

extractRecentFilePaths walks history newest-first and returns the top N
unique file paths touched by read_file/write_file/edit/replace tool calls.
Pure function, no side effects, no state cache — readiness for the next
compaction-rewrite tasks.

* refactor(core): simplify extractRecentFilePaths internals

Three small cleanups from code review:
- Map<string, number> -> Set<string> (the index value was never read)
- Guard against maxFiles <= 0 explicitly (avoids returning 1 result
  when caller passes 0 as a 'disable' sentinel)
- Document 'replace' as a legacy alias for 'edit' so a future cleanup
  pass does not delete it as apparent dead code

Adds one test covering the maxFiles=0 path.

* feat(core): add image extractor with source-tool metadata

extractRecentImages walks history newest-first, collects up to N image
inlineData parts, and attributes each one to the model+functionCall that
preceded it (when one exists). Returns chronological order so callers
can render a meaningful 'last visual state ends here' strip.

* feat(core): add size-adaptive file reader for post-compact restore

readFileSizeAdaptive reads a file and returns one of: embed (full content
for files ≤ maxTokens × 4 chars), reference (path-only for large files),
missing (deleted since last touch), or binary (non-text content). The
embed/reference distinction mirrors claude-code's compact_file_reference
vs file attachment behavior, but without introducing new message types.

* refactor(core): harden readFileSizeAdaptive size accounting

Three corrections from code review:
- Import CHARS_PER_TOKEN from tokenEstimation.ts (canonical) instead of
  redeclaring locally, preventing silent drift between modules.
- Compare decoded character length, not raw byte length, against the
  cap. Otherwise a 10k-char Chinese file would be ~30k bytes and would
  be mis-classified as 'reference' despite fitting the budget.
- Rename FileReadResult -> FileEmbedResult to avoid a name collision
  with the unrelated FileReadResult interface in fileUtils.ts.

Adds a CJK-text test that catches the byte/char regression.

* feat(core): add file restoration block composer

buildFileRestorationBlocks reads each candidate file, classifies it as
embed/reference/missing/binary, and emits one consolidated reference
block (path-only list) plus one user message per embedded small file.
Total embed size is capped at POST_COMPACT_TOKEN_BUDGET; over-budget
files downgrade to reference.

* test(core): make budget test actually exercise the downgrade path

The previous version of this test wrote 3 files totalling 9k chars
against a 200k char budget. The assertions trivially passed regardless
of whether the budget check existed in the implementation.

The new version writes 11 files of 20k chars (each at the per-file cap)
so the budget is exhausted by the 10th and the 11th must downgrade
from embed to reference. Asserts both: file 11 appears in the reference
block, and file 11's content does NOT appear in any embed block.

* feat(core): add image restoration block composer

buildImageRestorationBlock emits a single user message whose first part
is a metadata header (turn index + source tool name + args per image),
followed by the inlineData parts themselves. Handles user-paste images
(no source tool) by labeling them as 'user-provided'.

* feat(core): add composePostCompactHistory orchestrator

Assembles the full post-compact history in order:
  summary → model ack → file references → file embeds → image block.

Each section is built by the per-concern extractors and builders added
in previous tasks. This is the single integration point that
chatCompressionService.compress() will call once the wire-up task lands.

* feat(core)!: rewrite compress() to claude-code-style full-history model

Replaces the split-point + tail-preservation model with full-history
compression + composePostCompactHistory. The entire curated history is
sent to the summary side-query, and the post-compact history is
assembled by the new composer (summary + ack + file restores + image
restore).

BREAKING: the previously-exported findCompressSplitPoint,
splitPointRetainingTrailingPairs, COMPRESSION_PRESERVE_THRESHOLD, and
TOOL_ROUND_RETAIN_COUNT will be removed in the next commit. Tests that
exercise them remain failing temporarily.

* chore(core): remove obsolete split-point compression infrastructure

Deletes findCompressSplitPoint, splitPointRetainingTrailingPairs,
COMPRESSION_PRESERVE_THRESHOLD, MIN_COMPRESSION_FRACTION, and
TOOL_ROUND_RETAIN_COUNT, plus the tests that exercised them. The new
behavior is covered by composePostCompactHistory and its unit tests.

Also cleans up:
- Stale orphan-strip comment in compress() that described the deleted
  manual-trigger orphan-funcCall handling.
- TEST_ONLY.COMPRESSION_PRESERVE_THRESHOLD hatch in client.ts.
- Docstring references in config.ts and compactionInputSlimming.ts.

* test(core): add single-turn computer-use compaction regression

Reproduces the scenario the rewrite targets: one user prompt kicks off
many screenshot tool calls. Asserts that (a) the user prompt is carried
into the summary verbatim and (b) the 3 most recent screenshots are
restored as an image block with source-tool metadata. This is the canary
test for the computer-use UX claim made in the design discussion.

* docs(core): remove stale "split point" references in tokenEstimation comments

Aligns the docstrings with the new compose-based compression flow. The
"split point" and "splitter" concepts no longer exist after the rewrite.

* fix(core): iterate parts reverse so parallel tool calls keep the last N

Real-session E2E surfaced a bug: a model that issues N parallel ReadFile
calls puts all N functionCall parts in ONE model+fc content. The
extractor's outer history walk is newest-first, but the inner parts
walk was forward — so for a 6-parallel batch hitting the cap of 5,
the FIRST 5 parts won and the actually-most-recent (last-listed) file
was dropped.

Fix: walk parts in reverse within each content. Applied symmetrically
to extractRecentImages (same shape, even rarer trigger).

Adds a regression test that hits a 6-parallel batch.

* fix(core): code-review fixes — fence escape, path sanitize, alias removal

- CommonMark-safe fence in file embed blocks. The old 3-backtick fence
  closed prematurely when a file's content contained a triple-backtick
  run (Markdown, CLAUDE.md, JSDoc with code examples) — leaking the
  remainder as unfenced text. Now uses a fence one longer than the
  longest backtick run in the content.

- Strip control characters (\r, \n, \t) from file paths before
  rendering into attachment markdown. Paths come from model-controlled
  history; a \n could inject markdown structure. The actual path stays
  intact for tool calls — only the displayed string is sanitized.

- Remove the historyForCompression alias for curatedHistory in
  compress(). The alias was added as a comment anchor during the
  rewrite but didn't carry semantic information.

* refactor(core): rewrite compression prompt to <state_snapshot> XML with 9 claude-aligned sections

Replaces the 9-section numbered-text prompt with qwen-code's original
<state_snapshot> XML envelope, but with the 9 inner section tags
content-aligned to claude-code:
  <primary_request_and_intent>
  <key_technical_concepts>
  <files_and_code_sections>
  <errors_and_fixes>
  <problem_solving>
  <all_user_messages>
  <pending_tasks>
  <current_work>
  <next_step>

Also:
- <scratchpad> -> <analysis>, stripped by postProcessSummary (saves
  ~600-800 tokens of CoT noise per compaction).
- "Resume directly..." trailer moved out of the prompt body and into
  postProcessSummary (no longer re-generated by the model every
  compaction; lives once in code with our own wording).
- Section 6 verbatim-policed mandate relaxed to "chronological, include
  short messages like 'ok' / 'continue'" — matches claude-code intent
  without forcing the model to literally copy long user messages.

E2E (qwen3.6-plus, 6 substantial .ts files + thorough analysis):
  raw history 6508 -> summary 1513 (after strip ~947), 38% history
  compression. Overall context 24642 -> 20647 reported (-16%), with
  another ~664 tokens actually saved by the post-strip but not
  reflected in the conservative token-math heuristic.

* docs(core): code-review polish on XML prompt rewrite

Four small follow-ups from review of 641a0eadd:

- prompts.ts: rewrite getCompressionPrompt's stale JSDoc — it still
  described the deleted 9-section numbered-text format and the
  verbatim mandate that was relaxed.
- chatCompressionService.ts: clarify the token-math comment so it's
  obvious the ~1000 token deduction covers the full compression
  system prompt + kick-off user turn (not any single instruction)
  and that newTokenCount slightly over-counts because <analysis>
  gets stripped by postProcessSummary downstream.
- postCompactAttachments.ts: add a NOTE comment on the <analysis>
  strip regex covering its strict-tag-match assumption and
  multi-block / non-greedy semantics.
- postCompactAttachments.test.ts: replace the four lazy
  `await import('./postCompactAttachments.js')` calls inside the
  postProcessSummary describe block with one top-level static import
  — consistent with how every other describe in the file imports.

* docs(core): drop stale duplicate sentence left in token-math comment

* fix(core): address wenshao review on PR #4599 (correctness + security + ergonomics)

Seven follow-ups from wenshao's review of the compaction rewrite.

Critical:
- newTokenCount now includes restoration-block tokens via
  estimateContentChars over extraHistory[2..]. Previously the formula
  only counted side-query output, so up to 5 × 5K (files) + 3 × image
  tokens were missing — letting the inflation guard miss and the
  cheap-gate under-estimate the next prompt size (Finding 1).
- composePostCompactHistory now merges every file restoration block
  and the image block into a single user Content following the model
  ack. The previous output had consecutive user roles, which
  geminiChat.test.ts:6289 enforces against and Gemini providers
  reject with 400 "consecutive same-role content" (Finding 2).
- Preserve a trailing model+functionCall through compaction so a
  pending functionResponse (sitting in sendMessageStream's
  pendingUserMessage) has a matching call. Without this, hard-rescue
  auto-compaction mid tool-use loop produces a user+functionResponse
  with no preceding model+functionCall → API 400. This restores the
  protection the split-point in-flight fallback used to provide.
  When the funcCall lands without attachments it folds into the
  ack's own model Content to avoid model→model adjacency (Finding 3).
- composePostCompactHistory now takes an optional workspaceRoot and
  silently skips file paths that resolve outside it.
  extractRecentFilePaths picks up paths from model functionCall args
  regardless of whether the tool execution succeeded; without a
  boundary check, an adversarial model that issued
  read_file('/etc/passwd') — denied by the permission system —
  would still have its path extracted and re-read into the next
  prompt. compress() passes config.getTargetDir() as the boundary
  (Finding 4).

Suggestions:
- composePostCompactHistory + buildFileRestorationBlocks +
  readFileSizeAdaptive all take optional AbortSignal and short-
  circuit / pass it to readFile's { signal } option. Cancelled
  compactions stop on the next file read (Finding 5).
- postProcessSummary fallback no longer re-injects the raw
  <analysis> block when the strip leaves nothing. The new
  stripAnalysisBlock helper runs the closed-tag strip AND an
  unclosed-tag strip (handles 'model ran out of output tokens
  before closing'). If both leave nothing, postProcessSummary
  emits '[Summary unavailable]' rather than leaking scratchpad
  (Finding 6).
- firePostCompactEvent now receives stripAnalysisBlock(summary) so
  hook consumers see the same text that lands in history. The
  resume trailer stays out of the hook payload — that's wrapper
  decoration for the next agent turn, not state for consumers
  (Finding 8a).

Docs:
- Update the geminiChat.ts comment around `trigger: 'auto'` to
  describe what the trigger actually does post-refactor (hook event
  categorization) rather than the deleted manual-only orphan-strip
  it used to guard against (Finding 8b).

Regression tests cover all six fixable code-path changes
(role alternation, trailing funcCall preservation, workspace
boundary, abort propagation, closed-tag fallback strip, unclosed-tag
fallback strip).

* fix(core): add getTargetDir to geminiChat auto-compression test mock

The R3.4 end-to-end auto-compression test drives the real
ChatCompressionService, which reads config.getTargetDir() for the
post-compact file-restoration workspace boundary. The geminiChat mock
config lacked getTargetDir, so the test threw "config.getTargetDir is
not a function" on CI. Add the mock to unblock the failing Test jobs.

* feat(core): configurable compaction retention + computer-use screenshot trigger

Add four env-overridable chatCompression settings (priority env >
settings > default):
- maxRecentFilesToRetain    (QWEN_COMPACT_MAX_RECENT_FILES,     default 5)
- maxRecentImagesToRetain   (QWEN_COMPACT_MAX_RECENT_IMAGES,    default 3)
- enableScreenshotTrigger   (QWEN_COMPACT_SCREENSHOT_TRIGGER,   default true)
- screenshotTriggerThreshold(QWEN_COMPACT_SCREENSHOT_THRESHOLD, default 50)

The screenshot trigger fires auto-compaction once tool-returned images
accumulate to the threshold even when token usage is below the auto tier,
so computer-use sessions don't drown the model in stale screenshots. It
counts only images nested in functionResponse.parts (tool results), not
user pastes, and runs only in the would-be-NOOP path when enabled.

Fix a latent bug surfaced while wiring the trigger: extractRecentImages
only inspected top-level inlineData parts, but convertToFunctionResponse
nests tool media under functionResponse.parts — so post-compact
restoration recovered ZERO tool screenshots in real sessions, while unit
tests stayed green against a fabricated top-level shape. It now walks both
shapes; the image counter and tests use the real nested shape.

Remove the now-defunct contextPercentageThreshold deprecation warning (the
field was already dropped from ChatCompressionSettings) and its tests, and
document the four new settings.

* test(core): assert screenshot trigger can't re-fire post-compaction; fix misleading docs

Code-review follow-up. The screenshot trigger counts only images nested in
functionResponse.parts. Compaction replaces those with the summary and
re-embeds survivors as TOP-LEVEL parts in the restoration block, which the
counter ignores — so the tool-image count always resets to ~0 and the
trigger cannot immediately re-fire, independent of maxRecentImages.

The resolveCompactionTuning JSDoc and the settings.md note previously warned
of a non-existent "maxRecentImages near threshold => compact every turn"
loop. Correct both, and add a regression test asserting
countToolResponseImages() is 0 on composePostCompactHistory output.

* fix(core): guard readFileSizeAdaptive against multi-GB reads; cover composer 4-entry branch

wenshao review round 2 on PR #4599.

- readFileSizeAdaptive now stats the file first and short-circuits to a
  reference when its byte size exceeds maxChars*4 (the safe UTF-8 upper
  bound — a file larger than that cannot fit within maxChars chars). This
  stops a multi-GB file the agent previously touched from being slurped
  into a Buffer and exhausting the heap mid-compaction, exactly when we're
  trying to reduce memory. A large binary file now references rather than
  reading to binary-detect.

- Add a test for composePostCompactHistory's 4-entry branch (attachments +
  trailing model+functionCall) producing [user(summary), model(ack),
  user(attachments), model(fc)]. This is the common mid-tool-loop
  compaction case; a model->model adjacency here is a provider 400. Prior
  tests only covered the 2-entry fold (no attachments) and 3-entry (no
  trailing fc) shapes.

* fix(core): resolve symlinks in workspace boundary; guard compose against throws

wenshao review round 3 on PR #4599 (two Criticals).

- isInsideWorkspace now resolves symlinks via realpathSync (safeRealpath,
  with a lexical fallback for non-existent paths). A symlink living inside
  the workspace but pointing outside (e.g. workspace/.env -> ~/.ssh/id_rsa)
  previously passed the lexical boundary check and had its target read and
  embedded into the post-compact history sent to the provider. Added a
  RED-verified security regression test (secret embedded under the old
  lexical check; rejected under realpath).

- Wrap composePostCompactHistory in try/catch inside compress(). The
  summary side-query has already succeeded at that point, so a
  restoration-assembly throw (disk I/O / malformed history) previously
  escaped to sendMessageStream, crashing the active turn AND bypassing the
  COMPRESSION_FAILED breaker. It now degrades to summary + ack.

* fix(core): close 4 compaction Criticals from review round 4

wenshao review round 4 on PR #4599.

- isSummaryEmpty now checks the STRIPPED summary: a response that is only an
  <analysis> block (no <state_snapshot>) strips to empty, so it takes the
  COMPRESSION_FAILED_EMPTY_SUMMARY path instead of "succeeding" with
  `[Summary unavailable]` as the agent's only context (silent amnesia).
- Manual /compress strips a trailing ORPHANED model+functionCall before
  composing — it has no pending functionResponse, so preserving it would
  emit model[fc] then the next user text turn -> API 400. Auto-compaction
  still keeps it (the pending response pairs with it).
- The restoration-failure catch fallback now folds a trailing
  model+functionCall into the ack turn, so a pending functionResponse
  (auto mid-tool-loop) keeps its matching call even on the degraded path.
- extractRecentFilePaths skips file paths whose tool call FAILED (an error
  functionResponse), so a denied read_file is never re-read off disk during
  compaction — closing a permission-bypass side channel.

RED-verified regression tests for the empty-summary, orphan-strip, and
permission-bypass fixes. Corrected the postProcessSummary comment.

* test(core): cover composePostCompactHistory catch-fallback; document fold text drop

wenshao review round 5 on PR #4599.

- Regression test for the restoration-failure catch fallback: mock
  composePostCompactHistory to reject and assert compaction still returns
  COMPRESSED (no escape to sendMessageStream / breaker bypass) with the
  trailing functionCall folded into the ack and the trailing text dropped.
- Document that the fold branch intentionally keeps only functionCall parts
  (the trailing turn's text is already captured in the summary); the
  asymmetry with the with-attachments branch is deliberate.
2026-05-29 16:20:13 +08:00

700 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* postCompactAttachments — pure builders for the message blocks injected
* AFTER the summary in a compacted history. Replaces qwen-code's tail-
* preservation model (split-point + last 30%) with claude-code's
* "summary + restored attachments" model.
*
* Everything in this module is message-history-driven: no separate state
* caches, no new message types. Extractors walk `Content[]`, builders
* produce ordinary user/model `Content` objects with text/inlineData parts.
*/
import type { Content, Part } from '@google/genai';
import { readFile, stat } from 'node:fs/promises';
import { realpathSync } from 'node:fs';
import { resolve as resolvePath, sep as pathSep } from 'node:path';
import { CHARS_PER_TOKEN } from './tokenEstimation.js';
import { getFunctionResponseParts } from './compactionInputSlimming.js';
export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5;
/**
* Find the longest run of consecutive backticks in `s`. Used to choose
* a CommonMark-safe fence: a fence one backtick longer than any run
* inside the fenced content cannot be closed prematurely.
*/
function longestBacktickRun(s: string): number {
let longest = 0;
let current = 0;
for (const ch of s) {
if (ch === '`') {
current += 1;
if (current > longest) longest = current;
} else {
current = 0;
}
}
return longest;
}
/**
* Strip control characters from a path before rendering it into an
* attachment's markdown text. The path itself stays usable for tool
* calls (we just don't print the dangerous characters). A path with a
* literal newline could otherwise inject markdown structure into the
* model's view of the attachment.
*/
function sanitizePathForDisplay(path: string): string {
return path.replace(/[\r\n\t]/g, '');
}
export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000;
export const POST_COMPACT_TOKEN_BUDGET = 50_000;
export const POST_COMPACT_MAX_IMAGES_TO_RESTORE = 3;
/** Tool names that signal "this turn touched a file at args.file_path". */
const FILE_TOUCHING_TOOLS = new Set<string>([
'read_file',
'write_file',
'edit',
'replace', // legacy alias for 'edit' — may appear in old sessions (see ToolNamesMigration)
]);
/**
* Collect the ids of tool calls whose `functionResponse` reported an error
* (`response.error` present) — denied, cancelled, or otherwise failed. A
* successful call carries `response.output` and no `error`, so it is not
* collected. Used to keep denied/failed file reads out of restoration.
*/
function collectFailedCallIds(history: Content[]): Set<string> {
const failed = new Set<string>();
for (const content of history) {
for (const part of content.parts ?? []) {
const fr = part.functionResponse as
| { id?: string; response?: Record<string, unknown> }
| undefined;
if (fr?.id && fr.response && 'error' in fr.response) {
failed.add(fr.id);
}
}
}
return failed;
}
/**
* Walk the history newest-first, collect the most recently touched file
* paths, deduplicated. Older mentions of the same path are dropped in
* favor of the most recent one.
*/
export function extractRecentFilePaths(
history: Content[],
maxFiles: number,
): string[] {
if (maxFiles <= 0) return [];
// A denied / errored tool call still leaves its `functionCall` in history,
// paired with an error `functionResponse` (`response.error`). Restoring
// such a path would read the file straight off disk during compaction —
// bypassing the very permission the call was denied. Collect those failed
// call ids so we can skip them: never re-read a file the agent didn't
// successfully read.
const failedCallIds = collectFailedCallIds(history);
const seen = new Set<string>();
for (let i = history.length - 1; i >= 0; i--) {
const content = history[i];
if (content.role !== 'model') continue;
// Iterate parts in REVERSE within a single content so parallel tool
// calls (multiple functionCall parts in one model turn) are treated
// as "the last call is the most recent". Forward iteration here would
// pick the FIRST 5 of a 6-parallel batch, dropping the actually-most-
// recent call — discovered via real-session E2E.
const parts = content.parts ?? [];
for (let j = parts.length - 1; j >= 0; j--) {
const part = parts[j];
const call = part.functionCall;
if (!call || !FILE_TOUCHING_TOOLS.has(call.name ?? '')) continue;
// Skip paths whose tool call failed (denied / errored).
if (call.id && failedCallIds.has(call.id)) continue;
const args = call.args as { file_path?: unknown } | undefined;
const filePath =
typeof args?.file_path === 'string' ? args.file_path : undefined;
if (!filePath || seen.has(filePath)) continue;
seen.add(filePath);
if (seen.size >= maxFiles) return [...seen];
}
}
return [...seen];
}
export interface ExtractedImage {
/** The original `inlineData` part, ready to embed verbatim. */
part: Part;
/** Turn index in the original history (for metadata header). */
turnIndex: number;
/** Name of the tool whose call immediately preceded this image, if any. */
sourceToolName?: string;
/** Args of that tool call, for the metadata header. */
sourceToolArgs?: Record<string, unknown>;
}
/**
* Walk a single content's parts in REVERSE and return every image part
* it carries — both top-level `inlineData` (user-pasted images) and
* images nested inside `functionResponse.parts` (qwen-code's tool-media
* carrier; see coreToolScheduler.convertToFunctionResponse). Reverse
* order means the last-emitted image is treated as the most recent.
*
* Walking only the top-level shape — as this module originally did —
* silently drops every tool-returned screenshot, because
* `convertToFunctionResponse` ALWAYS nests tool media under
* `functionResponse.parts` and never at the top level. That made the
* whole screenshot-restoration feature a no-op for real computer-use
* sessions while the unit tests (which fabricated a top-level shape)
* stayed green.
*/
function imagePartsInContentReverse(content: Content): Part[] {
const result: Part[] = [];
const parts = content.parts ?? [];
for (let j = parts.length - 1; j >= 0; j--) {
const part = parts[j];
if (part.inlineData?.mimeType?.startsWith('image/')) {
result.push(part);
continue;
}
const nested = getFunctionResponseParts(part);
if (nested) {
for (let k = nested.length - 1; k >= 0; k--) {
const inner = nested[k];
if (inner.inlineData?.mimeType?.startsWith('image/')) {
result.push(inner);
}
}
}
}
return result;
}
/**
* Walk the history newest-first, collect up to `maxImages` image parts
* (top-level user-pasted images AND tool-returned images nested in
* `functionResponse.parts`), and pair each with the preceding
* model+functionCall (if any) as source-tool metadata.
*
* Returns oldest-first so callers can compose a chronological strip
* (last user-visible state ends up at the bottom of the attachment).
*/
export function extractRecentImages(
history: Content[],
maxImages: number,
): ExtractedImage[] {
if (maxImages <= 0) return [];
const collected: ExtractedImage[] = [];
outer: for (let i = history.length - 1; i >= 0; i--) {
const content = history[i];
const imageParts = imagePartsInContentReverse(content);
if (imageParts.length === 0) continue;
// Attribute via the most recent model+functionCall sitting at i-1
// (the typical (model+fc, user+fr) pair shape). Shared across every
// image in this turn — for parallel tool calls this attributes all
// images to the first call, an accepted simplification.
let sourceToolName: string | undefined;
let sourceToolArgs: Record<string, unknown> | undefined;
const prev = history[i - 1];
if (prev?.role === 'model') {
const fc = prev.parts?.find((p) => p.functionCall)?.functionCall;
if (fc) {
sourceToolName = fc.name ?? undefined;
sourceToolArgs =
(fc.args as Record<string, unknown> | undefined) ?? undefined;
}
}
for (const part of imageParts) {
collected.unshift({ part, turnIndex: i, sourceToolName, sourceToolArgs });
if (collected.length >= maxImages) break outer;
}
}
return collected;
}
/**
* Count images RETURNED BY TOOLS across the whole history — inlineData
* image parts nested inside `functionResponse.parts`. User-pasted
* top-level images are intentionally excluded: this drives the
* computer-use screenshot-overflow auto-compact trigger, whose concern
* is screenshot accumulation from tool results, not occasional pastes.
*/
export function countToolResponseImages(history: Content[]): number {
let count = 0;
for (const content of history) {
for (const part of content.parts ?? []) {
const nested = getFunctionResponseParts(part);
if (!nested) continue;
for (const inner of nested) {
if (inner.inlineData?.mimeType?.startsWith('image/')) count++;
}
}
}
return count;
}
export type FileEmbedResult =
| { kind: 'embed'; content: string }
| { kind: 'reference' }
| { kind: 'missing' }
| { kind: 'binary' };
const BINARY_DETECT_SAMPLE = 512;
const BINARY_NONPRINTABLE_THRESHOLD = 0.3;
/**
* Read a file from disk and decide whether to embed its full content
* (small files, ≤ maxTokens × CHARS_PER_TOKEN) or only return a path
* reference (large files; the agent must call read_file to view them).
*
* Returns 'missing' if the file no longer exists (deleted between when
* it was last touched and compaction time), 'binary' if it appears to
* contain non-text data.
*/
export async function readFileSizeAdaptive(
filePath: string,
maxTokens: number,
signal?: AbortSignal,
): Promise<FileEmbedResult> {
// Honor abort BEFORE issuing the I/O so a cancelled compaction does not
// even start the read (per Finding 5).
if (signal?.aborted) return { kind: 'missing' };
const maxChars = maxTokens * CHARS_PER_TOKEN;
// Byte-size pre-check: avoid loading a multi-GB file into a Buffer just to
// discover it's too large — that would exhaust the V8 heap mid-compaction,
// exactly when we're trying to REDUCE memory. UTF-8 is at most 4 bytes per
// char, so any file whose byte size exceeds maxChars*4 cannot fit within
// maxChars chars; short-circuit it to a reference without reading it.
try {
const { size } = await stat(filePath);
if (size > maxChars * 4) return { kind: 'reference' };
} catch {
// ENOENT / permission / etc. — treat as missing, same as the read path.
return { kind: 'missing' };
}
let buffer: Buffer;
try {
buffer = await readFile(filePath, { signal });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return { kind: 'missing' };
}
if ((err as { name?: string }).name === 'AbortError') {
return { kind: 'missing' };
}
// Permission errors, IO errors, etc. — treat as missing for the
// purpose of compaction. The agent can still retry via read_file
// and get a real error there if it's load-bearing.
return { kind: 'missing' };
}
// Binary detection on first BINARY_DETECT_SAMPLE bytes. Counts
// bytes outside printable ASCII + common whitespace as suspicious.
const sample = buffer.subarray(
0,
Math.min(buffer.length, BINARY_DETECT_SAMPLE),
);
let nonPrintable = 0;
for (const byte of sample) {
const printable =
(byte >= 0x20 && byte <= 0x7e) || // ASCII printable
byte === 0x09 || // tab
byte === 0x0a || // LF
byte === 0x0d || // CR
byte >= 0x80; // utf-8 continuation bytes — treat as printable
if (!printable) nonPrintable++;
}
if (
sample.length > 0 &&
nonPrintable / sample.length > BINARY_NONPRINTABLE_THRESHOLD
) {
return { kind: 'binary' };
}
// Decode once and compare against the cap by character length, not
// byte length. A 3-byte UTF-8 character (e.g. Chinese) would otherwise
// be triple-counted against the budget. The decoded value is reused
// for the embed branch so this costs nothing extra.
const decoded = buffer.toString('utf-8');
if (decoded.length > maxChars) {
return { kind: 'reference' };
}
return { kind: 'embed', content: decoded };
}
/**
* Compose the file-restoration section of a post-compact history. Reads
* each file from disk, classifies as embed/reference/missing/binary, and
* produces:
* - One reference block listing all large files (path only), if any.
* - One embed block per small file with full content.
* - Nothing for missing/binary files.
*
* Total embedded chars are capped at POST_COMPACT_TOKEN_BUDGET ×
* CHARS_PER_TOKEN. Files that would push over the budget are downgraded
* to references.
*/
export async function buildFileRestorationBlocks(
filePaths: string[],
signal?: AbortSignal,
): Promise<Content[]> {
const references: string[] = [];
const embeds: Array<{ path: string; content: string }> = [];
let usedChars = 0;
const budgetChars = POST_COMPACT_TOKEN_BUDGET * CHARS_PER_TOKEN;
for (const filePath of filePaths) {
if (signal?.aborted) break;
const result = await readFileSizeAdaptive(
filePath,
POST_COMPACT_MAX_TOKENS_PER_FILE,
signal,
);
if (result.kind === 'missing' || result.kind === 'binary') continue;
if (result.kind === 'reference') {
references.push(filePath);
continue;
}
// embed — check global budget; downgrade to reference if over.
if (usedChars + result.content.length > budgetChars) {
references.push(filePath);
continue;
}
embeds.push({ path: filePath, content: result.content });
usedChars += result.content.length;
}
const blocks: Content[] = [];
if (references.length > 0) {
const lines = [
'The following files were recently accessed before context was compacted. They are listed as reference only because they are large. Use `read_file` to view current content for any file you need:',
'',
...references.map((p) => `- ${sanitizePathForDisplay(p)}`),
];
blocks.push({
role: 'user',
parts: [{ text: lines.join('\n') }],
});
}
for (const { path, content } of embeds) {
// CommonMark-safe fence: use a backtick run that is one longer than
// the longest run already in the content. Markdown/CLAUDE.md/README
// files frequently contain ``` themselves; a fixed 3-backtick fence
// closes prematurely and leaks the remainder as unfenced text.
const fence = '`'.repeat(longestBacktickRun(content) + 1);
const safeFence = fence.length >= 3 ? fence : '```';
blocks.push({
role: 'user',
parts: [
{
text:
`Recently accessed file (full current content embedded):\n\n` +
`## ${sanitizePathForDisplay(path)}\n\n` +
safeFence +
'\n' +
content +
'\n' +
safeFence,
},
],
});
}
return blocks;
}
/**
* Compose the image-restoration block: a single user Content whose first
* part is a text header listing each image's source (turn index + tool
* call + args), followed by the inlineData parts in chronological order.
*
* Returns null if there are no images so callers can skip it cleanly.
*/
export function buildImageRestorationBlock(
images: ExtractedImage[],
): Content | null {
if (images.length === 0) return null;
const lines = [
'Recent visual snapshots preserved from before context was compacted (most recent last). Each image corresponds to a tool result or user-pasted image earlier in the conversation:',
'',
];
for (const img of images) {
if (img.sourceToolName) {
const argsStr = JSON.stringify(img.sourceToolArgs ?? {});
lines.push(
`- turn ${img.turnIndex}: ${img.sourceToolName} args=${argsStr}`,
);
} else {
lines.push(`- turn ${img.turnIndex}: user-provided image`);
}
}
return {
role: 'user',
parts: [{ text: lines.join('\n') }, ...images.map((img) => img.part)],
};
}
/**
* Assemble the complete post-compact history from the pre-compact
* `history` and the summary text the side-query model produced.
*
* Output ordering:
* 1. Summary as a user message (the side-query output)
* 2. Synthetic model ack ("Got it. Thanks for the additional context.")
* 3. File reference block (path-only list of large files), if any
* 4. Per-embedded-file user message with full content
* 5. Image restoration block, if any
*
* The ack message keeps role alternation correct: the next API call will
* naturally append the model's continuation response.
*/
/**
* Trailer appended to the post-compact summary message. Mirrors claude-code's
* "Resume directly" guidance for the resuming agent: it must NOT acknowledge
* the summary, re-greet, or recap — it picks up from where the prior turn
* left off based on the summary.
*
* Lives in the wrapper (not in the compression system prompt) so the summary
* model does not have to re-generate this text every compaction (saves
* output tokens, prevents wording drift).
*/
const RESUME_TRAILER =
'Resume the prior task using the summary above. Continue from the last in-flight step; do not acknowledge the summary, do not re-introduce, do not greet the user again.';
/**
* Strip the model's drafting scratchpad before the summary becomes the new
* post-compact context. The compression prompt instructs the summary model
* to wrap its chain-of-thought reasoning in an `<analysis>...</analysis>`
* block, which is purely for the model's own benefit; keeping it in history
* wastes tokens and degrades signal-to-noise for the resuming agent.
*
* Defensive design: if the strip removes everything (model produced ONLY an
* analysis block with no summary content), fall back to the raw summary so
* the caller sees something rather than an empty string — the inflation
* guard upstream will still NOOP this round, but we don't want to silently
* lose the entire model response.
*/
/**
* Strip `<analysis>...</analysis>` chain-of-thought blocks from raw
* summary text. Exposed separately from `postProcessSummary` so the
* PostCompact hook event can receive the same stripped text that
* enters history — without the resume trailer, which is wrapper
* decoration meant for the next agent turn only (Finding 8a).
*
* NOTE on the regex:
* - `[\s\S]*?` (non-greedy) handles newlines inside the block AND
* stops at the first `</analysis>` — so multiple non-overlapping
* blocks each get stripped via the `/g` flag.
* - It matches the exact tag `<analysis>` only. If the prompt ever
* evolves to use attributes (e.g. `<analysis type="...">`) or
* nested `<analysis>` tags, this pattern will leak content. The
* compression prompt is under our control, so we keep the pattern
* strict rather than over-engineering.
* - The unclosed-tag fallback (`<analysis>[\s\S]*$`) catches the case
* where the model started an `<analysis>` block and ran out of
* output tokens before closing it. Without this, the closed-tag
* regex above misses and the entire scratchpad leaks into history
* via the fallback path in `postProcessSummary`.
*/
export function stripAnalysisBlock(rawSummary: string): string {
// First pass: strip well-formed `<analysis>...</analysis>` blocks
// (handles multiple via `/g`, newlines via `[\s\S]`).
let result = rawSummary.replace(/<analysis>[\s\S]*?<\/analysis>\s*/g, '');
// Second pass: strip any remaining unclosed `<analysis>` tag (the
// model ran out of output tokens before closing). Uses an
// end-of-string anchor since there's no closing tag to stop at.
result = result.replace(/<analysis>[\s\S]*$/g, '');
return result.trim();
}
export function postProcessSummary(rawSummary: string): string {
const stripped = stripAnalysisBlock(rawSummary);
// Defensive sentinel only. Callers gate on `isSummaryEmpty`, which now
// checks the STRIPPED summary — so a response that strips to nothing is
// treated as an empty summary upstream (COMPRESSION_FAILED_EMPTY_SUMMARY)
// and never reaches here. The inflation guard does NOT catch this case
// (a tiny `[Summary unavailable]` is smaller than the original, so the
// guard wouldn't fire) — the upstream emptiness check is what prevents it.
const body = stripped.length > 0 ? stripped : '[Summary unavailable]';
return `${body}\n\n${RESUME_TRAILER}`;
}
export interface ComposePostCompactOptions {
/**
* Workspace root. When set, file paths from history that resolve
* outside this root are silently skipped (Finding 4). Without this,
* an adversarial model that issued `read_file('/etc/passwd')` —
* even one denied by the permission system — would have its path
* extracted and re-read off disk into the next prompt.
*/
workspaceRoot?: string;
/**
* Cancels in-progress file reads (Finding 5). Propagated to
* `buildFileRestorationBlocks` → `readFileSizeAdaptive` →
* `readFile(path, { signal })`.
*/
signal?: AbortSignal;
/**
* Max recent files to restore. Defaults to
* `POST_COMPACT_MAX_FILES_TO_RESTORE`. Configurable via
* `chatCompression.maxRecentFilesToRetain` (env
* `QWEN_COMPACT_MAX_RECENT_FILES`).
*/
maxFiles?: number;
/**
* Max recent images to restore. Defaults to
* `POST_COMPACT_MAX_IMAGES_TO_RESTORE`. Configurable via
* `chatCompression.maxRecentImagesToRetain` (env
* `QWEN_COMPACT_MAX_RECENT_IMAGES`).
*/
maxImages?: number;
}
/**
* Trailing `model+functionCall` content from the pre-compact history,
* to be preserved in the post-compact output so a pending
* `functionResponse` (sitting in `sendMessageStream`'s
* `pendingUserMessage` waiting to be pushed) has a matching call
* (Finding 3). Returns `undefined` if the last history entry is not
* a model turn with a functionCall part.
*/
function trailingFunctionCallContent(history: Content[]): Content | undefined {
const last = history[history.length - 1];
if (!last || last.role !== 'model') return undefined;
if (!last.parts?.some((p) => !!p.functionCall)) return undefined;
return last;
}
/**
* Resolve and validate a file path against an optional workspace
* root. Returns `true` if the file path lies under `workspaceRoot`
* (or if no root was supplied — caller chose not to enforce). Used
* to skip out-of-workspace paths that a model emitted via a denied
* tool call (Finding 4).
*/
function isInsideWorkspace(filePath: string, workspaceRoot?: string): boolean {
if (!workspaceRoot) return true;
// Resolve symlinks (not just lexical normalization) so a symlink that
// lives INSIDE the workspace but points OUTSIDE — e.g.
// `workspace/.env -> ~/.ssh/id_rsa` — cannot smuggle a sensitive file
// past the boundary and into the post-compact history sent to the
// provider. Mirrors WorkspaceContext.isPathWithinWorkspace's realpath
// handling.
const resolvedFile = safeRealpath(filePath);
const resolvedRoot = safeRealpath(workspaceRoot);
// Append a trailing separator so a sibling path that shares a prefix
// (e.g. workspace=/foo/bar, file=/foo/bar2/x.ts) is correctly
// classified as outside.
const rootWithSep = resolvedRoot.endsWith(pathSep)
? resolvedRoot
: resolvedRoot + pathSep;
return resolvedFile === resolvedRoot || resolvedFile.startsWith(rootWithSep);
}
/**
* realpathSync that falls back to lexical resolution when the path does
* not exist (realpathSync throws ENOENT). A non-existent path can't leak
* content anyway — readFileSizeAdaptive returns 'missing' for it — so the
* lexical fallback only affects the boundary classification, not safety.
*/
function safeRealpath(p: string): string {
try {
return realpathSync(p);
} catch {
return resolvePath(p);
}
}
export async function composePostCompactHistory(
history: Content[],
summary: string,
options: ComposePostCompactOptions = {},
): Promise<Content[]> {
const {
workspaceRoot,
signal,
maxFiles = POST_COMPACT_MAX_FILES_TO_RESTORE,
maxImages = POST_COMPACT_MAX_IMAGES_TO_RESTORE,
} = options;
// Workspace-boundary filter on the extracted file paths (Finding 4).
const filePaths = extractRecentFilePaths(history, maxFiles).filter((p) =>
isInsideWorkspace(p, workspaceRoot),
);
const fileBlocks = await buildFileRestorationBlocks(filePaths, signal);
const images = extractRecentImages(history, maxImages);
const imageBlock = buildImageRestorationBlock(images);
// Merge every file restoration block AND the image block into a
// single user Content (Finding 2). Pushing them as separate user
// Contents produces consecutive same-role entries, which
// geminiChat.test.ts:6289 enforces against and which Gemini
// providers reject as 400 "consecutive same-role content".
const postAckParts: Part[] = [];
for (const block of fileBlocks) {
for (const part of block.parts ?? []) postAckParts.push(part);
}
if (imageBlock) {
for (const part of imageBlock.parts ?? []) postAckParts.push(part);
}
// Preserve trailing model+functionCall so a pending functionResponse
// has a matching call (Finding 3). Place it AFTER any merged
// attachments so role alternation holds:
// - with attachments: [user(sum), model(ack), user(attach), model(fc)]
// - without: [user(sum), model(ack + fc)] — fc lands in
// the ack's own model Content to avoid the
// model→model adjacency that would otherwise
// arise from a separate appended entry.
const trailingFc = trailingFunctionCallContent(history);
const ackParts: Part[] = [
{ text: 'Got it. Thanks for the additional context!' },
];
const out: Content[] = [
{ role: 'user', parts: [{ text: postProcessSummary(summary) }] },
];
if (postAckParts.length > 0) {
out.push({ role: 'model', parts: ackParts });
out.push({ role: 'user', parts: postAckParts });
if (trailingFc) out.push(trailingFc);
} else if (trailingFc) {
// Fold the trailing functionCall into the ack's own Content so we don't
// produce model→model adjacency. Intentionally keep ONLY the
// functionCall parts: the trailing turn's text was already captured in
// the summary, and merging it into the ack would muddy both. (The
// with-attachments branch above keeps trailingFc as its own model turn,
// so text survives there — the asymmetry is deliberate, not a bug.)
const fcParts = (trailingFc.parts ?? []).filter((p) => !!p.functionCall);
out.push({ role: 'model', parts: [...ackParts, ...fcParts] });
} else {
out.push({ role: 'model', parts: ackParts });
}
return out;
}