mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
* 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.
700 lines
26 KiB
TypeScript
700 lines
26 KiB
TypeScript
/**
|
||
* @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;
|
||
}
|