mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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.
This commit is contained in:
parent
39cc9b3e6f
commit
365409366d
16 changed files with 2946 additions and 1227 deletions
|
|
@ -146,7 +146,11 @@ Settings are organized into categories. Most settings should be placed within th
|
|||
| `model.maxWallTimeSeconds` | number | Wall-clock budget for headless / unattended runs, in seconds. `-1` means unlimited. Overridable per-invocation via `--max-wall-time`, which requires a positive duration (`90`, `30s`, `5m`, `1h`, `1.5h`); the minimum is 1 second — sub-second values (`500ms`, `0.5`) are rejected as typos. Omit the flag to fall back to this setting. Aborts with exit code 55 when exceeded. | `-1` |
|
||||
| `model.maxToolCalls` | number | Cumulative tool-call budget for a run (counts every executed tool, success or failure; `structured_output` under `--json-schema` is exempt). `-1` means unlimited; `0` means "no tool calls allowed". Capped at 1,000,000 to catch typos. Overridable via `--max-tool-calls`. Aborts with exit code 55 when exceeded. | `-1` |
|
||||
| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `splitToolMedia` (set `true` for strict OpenAI-compatible servers like LM Studio that reject non-text content on `role: "tool"` messages — splits media into a follow-up user message), `contextWindowSize` (override model's context window size), `modalities` (override auto-detected input modalities), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` |
|
||||
| `model.chatCompression.contextPercentageThreshold` | number | **REMOVED.** Auto-compaction now uses a three-tier threshold ladder (warn / auto / hard) computed internally from the model's context window via the `computeThresholds()` function — no longer user-configurable. Setting this field in `settings.json` is silently ignored, and a one-line deprecation warning is emitted to stderr at startup. There is currently no replacement for "disable compression entirely" — reactive overflow recovery remains the safety net at the API layer if compression itself fails. (See PR #4345 / `docs/design/auto-compaction-threshold-redesign.md` for the redesign rationale.) | `N/A` |
|
||||
| `model.chatCompression.contextPercentageThreshold` | number | **REMOVED.** Auto-compaction now uses a three-tier threshold ladder (warn / auto / hard) computed internally from the model's context window via the `computeThresholds()` function — no longer user-configurable. Setting this field in `settings.json` is silently ignored (no startup warning). There is currently no replacement for "disable compression entirely" — reactive overflow recovery remains the safety net at the API layer if compression itself fails. (See PR #4345 / `docs/design/auto-compaction-threshold-redesign.md` for the redesign rationale.) | `N/A` |
|
||||
| `model.chatCompression.maxRecentFilesToRetain` | number | Number of most-recently-touched files whose current content is restored (embedded if small, otherwise referenced by path) into history after auto-compaction. `0` restores none. Env override: `QWEN_COMPACT_MAX_RECENT_FILES`. | `5` |
|
||||
| `model.chatCompression.maxRecentImagesToRetain` | number | Number of most-recent images (tool screenshots / user pastes) restored into history after auto-compaction. `0` restores none. Env override: `QWEN_COMPACT_MAX_RECENT_IMAGES`. | `3` |
|
||||
| `model.chatCompression.enableScreenshotTrigger` | boolean | When `true`, auto-compaction also fires once the number of tool-returned images accumulated in history reaches `screenshotTriggerThreshold`, independent of token usage — aimed at computer-use sessions where frequent screenshots dilute model attention. Counts only images returned inside tool results, not user-pasted images. Env override: `QWEN_COMPACT_SCREENSHOT_TRIGGER` (`1`/`true`/`0`/`false`). | `true` |
|
||||
| `model.chatCompression.screenshotTriggerThreshold` | number | Tool-returned image count at or above which the screenshot trigger fires (only when `enableScreenshotTrigger`). Compaction resets the count — surviving images are re-embedded as top-level parts, which the trigger doesn't count — so it won't immediately re-fire. Env override: `QWEN_COMPACT_SCREENSHOT_THRESHOLD`. | `50` |
|
||||
| `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` |
|
||||
| `model.skipLoopDetection` | boolean | Disables streaming loop detection checks. Defaults to `true` (loop detection is skipped) to avoid false positives interrupting legitimate workflows. Set to `false` to re-enable streaming loop detection — useful as a guardrail in headless / non-interactive runs where stuck repetition can otherwise waste budget. | `true` |
|
||||
| `model.skipStartupContext` | boolean | Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup. | `false` |
|
||||
|
|
|
|||
|
|
@ -6,11 +6,7 @@
|
|||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import type {
|
||||
ChatCompressionSettings,
|
||||
ConfigParameters,
|
||||
SandboxConfig,
|
||||
} from './config.js';
|
||||
import type { ConfigParameters, SandboxConfig } from './config.js';
|
||||
import {
|
||||
Config,
|
||||
ApprovalMode,
|
||||
|
|
@ -3368,55 +3364,4 @@ describe('Model Switching and Config Updates', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatCompression.contextPercentageThreshold deprecation', () => {
|
||||
// The proportional-threshold knob `contextPercentageThreshold` was
|
||||
// removed in the auto-compaction threshold redesign (Task 8) — the
|
||||
// value is now derived from `computeThresholds(...)` in the
|
||||
// ChatCompressionService and is no longer user-tunable. Existing
|
||||
// settings.json files that still set the field should keep working
|
||||
// but get a one-time stderr warning so users know to remove it.
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs a stderr warning when the deprecated field is set', () => {
|
||||
new Config({
|
||||
...baseParams,
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.5,
|
||||
} as ChatCompressionSettings,
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'chatCompression.contextPercentageThreshold has been removed',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not warn when chatCompression is absent', () => {
|
||||
new Config({ ...baseParams });
|
||||
const warnCalls = warnSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(
|
||||
warnCalls.some((m) => m.includes('contextPercentageThreshold')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not warn when chatCompression is set without the deprecated field', () => {
|
||||
new Config({
|
||||
...baseParams,
|
||||
chatCompression: { imageTokenEstimate: 1600 },
|
||||
});
|
||||
const warnCalls = warnSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(
|
||||
warnCalls.some((m) => m.includes('contextPercentageThreshold')),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -270,12 +270,39 @@ export interface BugCommandSettings {
|
|||
export interface ChatCompressionSettings {
|
||||
/**
|
||||
* Estimated tokens for a single inline image / document part when
|
||||
* apportioning chars across history in `findCompressSplitPoint`.
|
||||
* apportioning chars across history during compression size estimation.
|
||||
* Also used as the placeholder budget when stripping inline media
|
||||
* out of the side-query compaction prompt. Default 1600.
|
||||
* Env override: `QWEN_IMAGE_TOKEN_ESTIMATE`.
|
||||
*/
|
||||
imageTokenEstimate?: number;
|
||||
/**
|
||||
* Number of most-recently-touched files whose current content is
|
||||
* restored (embedded or referenced) after auto-compaction. Default 5.
|
||||
* Env override: `QWEN_COMPACT_MAX_RECENT_FILES`.
|
||||
*/
|
||||
maxRecentFilesToRetain?: number;
|
||||
/**
|
||||
* Number of most-recent images (tool screenshots / user pastes)
|
||||
* restored after auto-compaction. Default 3.
|
||||
* Env override: `QWEN_COMPACT_MAX_RECENT_IMAGES`.
|
||||
*/
|
||||
maxRecentImagesToRetain?: number;
|
||||
/**
|
||||
* When true, auto-compaction also fires once the number of
|
||||
* tool-returned images accumulated in history reaches
|
||||
* `screenshotTriggerThreshold`, independent of token usage. Aimed at
|
||||
* computer-use sessions where frequent screenshots dilute model
|
||||
* attention without necessarily exceeding the token budget. Default true.
|
||||
* Env override: `QWEN_COMPACT_SCREENSHOT_TRIGGER` (`1`/`true`/`0`/`false`).
|
||||
*/
|
||||
enableScreenshotTrigger?: boolean;
|
||||
/**
|
||||
* Tool-returned image count at or above which the screenshot trigger
|
||||
* fires (only when `enableScreenshotTrigger`). Default 50.
|
||||
* Env override: `QWEN_COMPACT_SCREENSHOT_THRESHOLD`.
|
||||
*/
|
||||
screenshotTriggerThreshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1171,24 +1198,6 @@ export class Config {
|
|||
this.loadMemoryFromIncludeDirectories =
|
||||
params.loadMemoryFromIncludeDirectories ?? false;
|
||||
this.importFormat = params.importFormat ?? 'tree';
|
||||
// Auto-compaction threshold moved to built-in constants (computeThresholds
|
||||
// in chatCompressionService.ts). The old `contextPercentageThreshold`
|
||||
// field is deprecated; if present in user settings, emit a one-time
|
||||
// warning and ignore the value.
|
||||
if (
|
||||
params.chatCompression &&
|
||||
typeof (params.chatCompression as Record<string, unknown>)[
|
||||
'contextPercentageThreshold'
|
||||
] !== 'undefined'
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[qwen-code] chatCompression.contextPercentageThreshold has been removed ' +
|
||||
'and is now controlled by built-in thresholds. Setting will be ignored. ' +
|
||||
'Remove this key from your settings.json to silence this warning; ' +
|
||||
'see docs/users/configuration/settings.md for current compaction behavior.',
|
||||
);
|
||||
}
|
||||
this.chatCompression = params.chatCompression;
|
||||
this.interactive = params.interactive ?? false;
|
||||
this.trustedFolder = params.trustedFolder;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import type { Content, GenerateContentResponse, Part } from '@google/genai';
|
||||
import { GeminiClient, SendMessageType } from './client.js';
|
||||
import { findCompressSplitPoint } from '../services/chatCompressionService.js';
|
||||
import { getRecentGitStatus } from '../utils/gitUtils.js';
|
||||
import {
|
||||
AuthType,
|
||||
|
|
@ -265,84 +264,6 @@ function getLastTurnRequestText(): string {
|
|||
return JSON.stringify(request ?? '');
|
||||
}
|
||||
|
||||
describe('findCompressSplitPoint', () => {
|
||||
it('should throw an error for non-positive numbers', () => {
|
||||
expect(() => findCompressSplitPoint([], 0)).toThrow(
|
||||
'Fraction must be between 0 and 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error for a fraction greater than or equal to 1', () => {
|
||||
expect(() => findCompressSplitPoint([], 1)).toThrow(
|
||||
'Fraction must be between 0 and 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle an empty history', () => {
|
||||
expect(findCompressSplitPoint([], 0.5)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle a fraction in the middle', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%)
|
||||
{ role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%)
|
||||
{ role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%)
|
||||
{ role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%)
|
||||
{ role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%)
|
||||
];
|
||||
expect(findCompressSplitPoint(history, 0.5)).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle a fraction of last index', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%)
|
||||
{ role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%)
|
||||
{ role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%)
|
||||
{ role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%)
|
||||
{ role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%)
|
||||
];
|
||||
expect(findCompressSplitPoint(history, 0.9)).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle a fraction of after last index', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (24%%)
|
||||
{ role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (50%)
|
||||
{ role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (74%)
|
||||
{ role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (100%)
|
||||
];
|
||||
expect(findCompressSplitPoint(history, 0.8)).toBe(4);
|
||||
});
|
||||
|
||||
it('compresses everything before the trailing in-flight functionCall', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'This is the first message.' }] },
|
||||
{ role: 'model', parts: [{ text: 'This is the second message.' }] },
|
||||
{ role: 'user', parts: [{ text: 'This is the third message.' }] },
|
||||
{ role: 'model', parts: [{ functionCall: {} }] },
|
||||
];
|
||||
// Trailing m+fc is in-flight; the in-flight fallback compresses
|
||||
// everything except the trailing fc (no preceding pair to retain).
|
||||
expect(findCompressSplitPoint(history, 0.99)).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle a history with only one item', () => {
|
||||
const historyWithEmptyParts: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Message 1' }] },
|
||||
];
|
||||
expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle history with weird parts', () => {
|
||||
const historyWithEmptyParts: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Message 1' }] },
|
||||
{ role: 'model', parts: [{ fileData: { fileUri: 'derp' } }] },
|
||||
{ role: 'user', parts: [{ text: 'Message 2' }] },
|
||||
];
|
||||
expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gemini Client (client.ts)', () => {
|
||||
let mockContentGenerator: ContentGenerator;
|
||||
let mockConfig: Config;
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ import {
|
|||
} from './turn.js';
|
||||
|
||||
// Services
|
||||
import { COMPRESSION_PRESERVE_THRESHOLD } from '../services/chatCompressionService.js';
|
||||
import { LoopDetectionService } from '../services/loopDetectionService.js';
|
||||
import { CommitAttributionService } from '../services/commitAttribution.js';
|
||||
|
||||
|
|
@ -2182,7 +2181,3 @@ export class GeminiClient {
|
|||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
export const TEST_ONLY = {
|
||||
COMPRESSION_PRESERVE_THRESHOLD,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ describe('GeminiChat', async () => {
|
|||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
setModel: vi.fn(),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
|
||||
getTargetDir: vi.fn().mockReturnValue('/test/project/root'),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/test/temp'),
|
||||
|
|
|
|||
|
|
@ -1540,12 +1540,15 @@ export class GeminiChat {
|
|||
pendingUserMessage: userContent,
|
||||
precomputedEffectiveTokens: effectiveTokens,
|
||||
// Hard-rescue is force=true to bypass the cheap-gate breaker
|
||||
// but it's an AUTOMATIC trigger. Explicit trigger='auto' tells
|
||||
// the service to skip the manual-only orphan-strip that would
|
||||
// otherwise drop the active funcCall whose matching
|
||||
// funcResponse is sitting in `pendingUserMessage` waiting to
|
||||
// be pushed. Without this, hard-rescue mid tool-use loop
|
||||
// corrupts the next API request's tool-call/response pairing.
|
||||
// but it remains a semantically AUTOMATIC trigger. Tag the
|
||||
// compactTrigger explicitly as 'auto' so the PostCompact
|
||||
// hook event fires with the correct trigger category (the
|
||||
// default `force=true → 'manual'` mapping would otherwise
|
||||
// misclassify it). The compress() service preserves a
|
||||
// trailing model+functionCall via
|
||||
// composePostCompactHistory's `trailingFunctionCallContent`
|
||||
// handling on its own, so the API request's tool-call /
|
||||
// response pairing stays intact regardless of trigger value.
|
||||
trigger: shouldForceFromHard ? 'auto' : undefined,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
getSubagentSystemReminder,
|
||||
getPlanModeSystemReminder,
|
||||
resolvePathFromEnv,
|
||||
getCompressionPrompt,
|
||||
} from './prompts.js';
|
||||
import { isGitRepository } from '../utils/gitUtils.js';
|
||||
import fs from 'node:fs';
|
||||
|
|
@ -761,3 +762,46 @@ describe('New Applications workflow deferred to skill', () => {
|
|||
expect(prompt).toContain('## New Applications');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCompressionPrompt', () => {
|
||||
it('uses the <state_snapshot> XML envelope with all 9 required section tags', () => {
|
||||
const prompt = getCompressionPrompt();
|
||||
expect(prompt).toContain('<state_snapshot>');
|
||||
expect(prompt).toContain('</state_snapshot>');
|
||||
expect(prompt).toContain('<primary_request_and_intent>');
|
||||
expect(prompt).toContain('<key_technical_concepts>');
|
||||
expect(prompt).toContain('<files_and_code_sections>');
|
||||
expect(prompt).toContain('<errors_and_fixes>');
|
||||
expect(prompt).toContain('<problem_solving>');
|
||||
expect(prompt).toContain('<all_user_messages>');
|
||||
expect(prompt).toContain('<pending_tasks>');
|
||||
expect(prompt).toContain('<current_work>');
|
||||
expect(prompt).toContain('<next_step>');
|
||||
});
|
||||
|
||||
it('instructs the model to wrap reasoning in an <analysis> block', () => {
|
||||
const prompt = getCompressionPrompt();
|
||||
expect(prompt).toContain('<analysis>');
|
||||
// Must signal that <analysis> is stripped (so the model knows it is a
|
||||
// drafting scratchpad, not part of the final summary).
|
||||
expect(prompt).toMatch(/<analysis>.*stripped|stripped.*<analysis>/is);
|
||||
});
|
||||
|
||||
it('asks for the <all_user_messages> section to be chronological and inclusive', () => {
|
||||
const prompt = getCompressionPrompt();
|
||||
// The actual mandate text — verbatim-but-not-VERBATIM-policed.
|
||||
expect(prompt).toMatch(/all user messages.*chronological/i);
|
||||
expect(prompt).toContain('"ok"');
|
||||
expect(prompt).toContain('"continue"');
|
||||
});
|
||||
|
||||
it('does NOT include the resume trailer in the prompt body', () => {
|
||||
// The trailer lives in postCompactAttachments.postProcessSummary, not in
|
||||
// the prompt. Keeping it out of the prompt saves output tokens per
|
||||
// compaction and prevents wording drift.
|
||||
const prompt = getCompressionPrompt();
|
||||
expect(prompt).not.toMatch(
|
||||
/resume.*directly|continue the conversation from where it left off/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -437,65 +437,64 @@ When you encounter an obstacle, do not use destructive actions as a shortcut to
|
|||
|
||||
/**
|
||||
* Provides the system prompt for the history compression process.
|
||||
* This prompt instructs the model to act as a specialized state manager,
|
||||
* think in a scratchpad, and produce a structured XML summary.
|
||||
*
|
||||
* Asks the summary model to wrap its chain-of-thought in an `<analysis>`
|
||||
* block (stripped before the result enters history) and then emit a
|
||||
* `<state_snapshot>` XML envelope with 9 sub-sections aligned to
|
||||
* claude-code's compaction format: 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.
|
||||
*
|
||||
* The resume trailer ("do not acknowledge the summary, ..." etc.) is
|
||||
* NOT in this prompt — it is appended once by `postProcessSummary` in
|
||||
* `postCompactAttachments.ts` so the summary model does not re-generate
|
||||
* it every compaction.
|
||||
*/
|
||||
export function getCompressionPrompt(): string {
|
||||
return `
|
||||
You are the component that summarizes internal chat history into a given structure.
|
||||
You are the component that summarizes a conversation when its context window is about to overflow. The summary you produce will become the agent's ONLY memory of everything that happened before this point. The agent will resume its work based solely on this summary plus a small number of restored file / image attachments that follow.
|
||||
|
||||
When the conversation history grows too large, you will be invoked to distill the entire history into a concise, structured XML snapshot. This snapshot is CRITICAL, as it will become the agent's *only* memory of the past. The agent will resume its work based solely on this snapshot. All crucial details, plans, errors, and user directives MUST be preserved.
|
||||
First, wrap your reasoning in an <analysis> block. Inside it, walk through the conversation chronologically and identify, for each section: the user's explicit requests and intent, your approach to those requests, key decisions / technical concepts / code patterns, specific details (file names, code snippets, function signatures, file edits), errors and how they were fixed, and any specific user feedback — especially when the user told you to do something differently. The <analysis> block is stripped before the summary reaches the next agent; it is purely a drafting scratchpad to improve the summary that follows.
|
||||
|
||||
First, you will think through the entire history in a private <scratchpad>. Review the user's overall goal, the agent's actions, tool outputs, file modifications, and any unresolved questions. Identify every piece of information that is essential for future actions.
|
||||
|
||||
After your reasoning is complete, generate the final <state_snapshot> XML object. Be incredibly dense with information. Omit any irrelevant conversational filler.
|
||||
|
||||
The structure MUST be as follows:
|
||||
Then produce the final summary as the EXACT XML structure below. Be dense. Omit conversational filler.
|
||||
|
||||
<state_snapshot>
|
||||
<overall_goal>
|
||||
<!-- A single, concise sentence describing the user's high-level objective. -->
|
||||
<!-- Example: "Refactor the authentication service to use a new JWT library." -->
|
||||
</overall_goal>
|
||||
<primary_request_and_intent>
|
||||
<!-- Capture all of the user's explicit requests and intents in detail. Quote the user's exact phrasing where intent is at stake. -->
|
||||
</primary_request_and_intent>
|
||||
|
||||
<key_knowledge>
|
||||
<!-- Crucial facts, conventions, and constraints the agent must remember based on the conversation history and interaction with the user. Use bullet points. -->
|
||||
<!-- Example:
|
||||
- Build Command: \`npm run build\`
|
||||
- Testing: Tests are run with \`npm test\`. Test files must end in \`.test.ts\`.
|
||||
- API Endpoint: The primary API endpoint is \`https://api.example.com/v2\`.
|
||||
|
||||
-->
|
||||
</key_knowledge>
|
||||
<key_technical_concepts>
|
||||
<!-- List all important technical concepts, technologies, and frameworks discussed. -->
|
||||
</key_technical_concepts>
|
||||
|
||||
<file_system_state>
|
||||
<!-- List files that have been created, read, modified, or deleted. Note their status and critical learnings. -->
|
||||
<!-- Example:
|
||||
- CWD: \`/home/user/project/src\`
|
||||
- READ: \`package.json\` - Confirmed 'axios' is a dependency.
|
||||
- MODIFIED: \`services/auth.ts\` - Replaced 'jsonwebtoken' with 'jose'.
|
||||
- CREATED: \`tests/new-feature.test.ts\` - Initial test structure for the new feature.
|
||||
-->
|
||||
</file_system_state>
|
||||
<files_and_code_sections>
|
||||
<!-- Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages. Include full code snippets where applicable, and a summary of why this file read or edit is important. -->
|
||||
</files_and_code_sections>
|
||||
|
||||
<recent_actions>
|
||||
<!-- A summary of the last few significant agent actions and their outcomes. Focus on facts. -->
|
||||
<!-- Example:
|
||||
- Ran \`grep 'old_function'\` which returned 3 results in 2 files.
|
||||
- Ran \`npm run test\`, which failed due to a snapshot mismatch in \`UserProfile.test.ts\`.
|
||||
- Ran \`ls -F static/\` and discovered image assets are stored as \`.webp\`.
|
||||
-->
|
||||
</recent_actions>
|
||||
<errors_and_fixes>
|
||||
<!-- List every error encountered and how it was fixed. Include the verbatim error message when it was quoted to the agent. Pay special attention to specific user feedback on the error, especially if the user told you to do something differently. -->
|
||||
</errors_and_fixes>
|
||||
|
||||
<current_plan>
|
||||
<!-- The agent's step-by-step plan. Mark completed steps. -->
|
||||
<!-- Example:
|
||||
1. [DONE] Identify all files using the deprecated 'UserAPI'.
|
||||
2. [IN PROGRESS] Refactor \`src/components/UserProfile.tsx\` to use the new 'ProfileAPI'.
|
||||
3. [TODO] Refactor the remaining files.
|
||||
4. [TODO] Update tests to reflect the API change.
|
||||
-->
|
||||
</current_plan>
|
||||
<problem_solving>
|
||||
<!-- Document problems solved and any ongoing troubleshooting efforts. -->
|
||||
</problem_solving>
|
||||
|
||||
<all_user_messages>
|
||||
<!-- List ALL user messages that are not tool results, in chronological order. These are critical for understanding the user's feedback and shifting intent. Include short messages like "ok" or "continue" — they are signal. -->
|
||||
</all_user_messages>
|
||||
|
||||
<pending_tasks>
|
||||
<!-- Outline any pending tasks that the user has explicitly asked the agent to work on but that are not yet complete. -->
|
||||
</pending_tasks>
|
||||
|
||||
<current_work>
|
||||
<!-- Describe in detail precisely what the agent was working on immediately before this summary was requested, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable. -->
|
||||
</current_work>
|
||||
|
||||
<next_step>
|
||||
<!-- List the single next step the agent will take, related to the most recent work. The step MUST be DIRECTLY in line with the user's most recent explicit request and the task the agent was working on immediately before this summary. If the last task was concluded, list a next step only if it is explicitly in line with the user's request — do NOT start tangential or older work without confirming with the user first. If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. -->
|
||||
</next_step>
|
||||
</state_snapshot>
|
||||
`.trim();
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -15,35 +15,18 @@ import { logChatCompression } from '../telemetry/loggers.js';
|
|||
import { makeChatCompressionEvent } from '../telemetry/types.js';
|
||||
import { PreCompactTrigger, PostCompactTrigger } from '../hooks/types.js';
|
||||
import {
|
||||
DEFAULT_IMAGE_TOKEN_ESTIMATE,
|
||||
estimateContentChars,
|
||||
resolveCompactionTuning,
|
||||
resolveSlimmingConfig,
|
||||
slimCompactionInput,
|
||||
} from './compactionInputSlimming.js';
|
||||
import { estimatePromptTokens } from './tokenEstimation.js';
|
||||
|
||||
/**
|
||||
* The fraction of the latest chat history to keep. A value of 0.3
|
||||
* means that only the last 30% of the chat history will be kept after compression.
|
||||
*/
|
||||
export const COMPRESSION_PRESERVE_THRESHOLD = 0.3;
|
||||
|
||||
/**
|
||||
* Minimum fraction of history (by character count) that must be compressible
|
||||
* to proceed with a compression API call. Prevents futile calls where the
|
||||
* model receives almost no context and generates a useless summary.
|
||||
*/
|
||||
export const MIN_COMPRESSION_FRACTION = 0.05;
|
||||
|
||||
/**
|
||||
* When the trailing entry is an in-flight `model+functionCall` and the regular
|
||||
* scan finds no clean split past the target fraction, the splitter falls back
|
||||
* to compressing everything except the last few entries. This constant sets
|
||||
* how many most-recent complete `(model+functionCall, user+functionResponse)`
|
||||
* tool rounds are retained as working context (the trailing in-flight call is
|
||||
* always retained on top of these).
|
||||
*/
|
||||
export const TOOL_ROUND_RETAIN_COUNT = 2;
|
||||
import { CHARS_PER_TOKEN, estimatePromptTokens } from './tokenEstimation.js';
|
||||
import {
|
||||
composePostCompactHistory,
|
||||
countToolResponseImages,
|
||||
postProcessSummary,
|
||||
stripAnalysisBlock,
|
||||
} from './postCompactAttachments.js';
|
||||
|
||||
/**
|
||||
* Hard cap on the compression sideQuery output (summary text only, since
|
||||
|
|
@ -149,118 +132,6 @@ export function computeThresholds(window: number): CompactionThresholds {
|
|||
|
||||
export type CompactTrigger = 'manual' | 'auto';
|
||||
|
||||
const hasFunctionCall = (content: Content | undefined): boolean =>
|
||||
!!content?.parts?.some((part) => !!part.functionCall);
|
||||
|
||||
const hasFunctionResponse = (content: Content | undefined): boolean =>
|
||||
!!content?.parts?.some((part) => !!part.functionResponse);
|
||||
|
||||
/**
|
||||
* Walk backward from the trailing in-flight `model+functionCall` and return
|
||||
* the index after which the most-recent `retainCount` complete tool-round
|
||||
* pairs sit (plus the trailing fc itself). Used by the splitter's in-flight
|
||||
* fallback path. Stops counting at the first non-pair encountered, so the
|
||||
* retain count is best-effort: if there are fewer complete pairs than
|
||||
* requested, all of them are retained.
|
||||
*/
|
||||
function splitPointRetainingTrailingPairs(
|
||||
contents: Content[],
|
||||
retainCount: number,
|
||||
): number {
|
||||
let pairsFound = 0;
|
||||
let i = contents.length - 2;
|
||||
while (i >= 1 && pairsFound < retainCount) {
|
||||
if (hasFunctionCall(contents[i - 1]) && hasFunctionResponse(contents[i])) {
|
||||
pairsFound += 1;
|
||||
i -= 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return contents.length - (2 * pairsFound + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the oldest item to keep when compressing. May return
|
||||
* contents.length which indicates that everything should be compressed.
|
||||
*
|
||||
* The algorithm has two phases:
|
||||
*
|
||||
* 1. **Scan:** walk left-to-right looking for the first non-functionResponse
|
||||
* user message that lands past `fraction` of total chars. That's the
|
||||
* "clean" split — the kept slice starts with a fresh user prompt.
|
||||
*
|
||||
* 2. **Fallbacks** (no clean split found): the gate that gets us here has
|
||||
* already decided we need to compress, so all three fallbacks bias toward
|
||||
* *more* compression rather than less:
|
||||
*
|
||||
* - last entry is `model` without functionCall → compress everything.
|
||||
* - last entry is `user` with functionResponse → compress everything (the
|
||||
* trailing tool round is complete; no orphans).
|
||||
* - last entry is `model` with functionCall (in-flight) → compress
|
||||
* everything except the trailing call plus the last `retainCount`
|
||||
* complete tool rounds. The kept slice may start with `model+fc`;
|
||||
* callers must inject a synthetic continuation user message between
|
||||
* `summary_ack_model` and the kept slice to preserve role alternation.
|
||||
*
|
||||
* The pre-fallback returns of `lastSplitPoint` (compress less) only happen
|
||||
* for malformed histories that don't end in user/model.
|
||||
*
|
||||
* Exported for testing purposes.
|
||||
*/
|
||||
export function findCompressSplitPoint(
|
||||
contents: Content[],
|
||||
fraction: number,
|
||||
retainCount = TOOL_ROUND_RETAIN_COUNT,
|
||||
precomputedCharCounts?: number[],
|
||||
): number {
|
||||
if (fraction <= 0 || fraction >= 1) {
|
||||
throw new Error('Fraction must be between 0 and 1');
|
||||
}
|
||||
|
||||
// Slimming-aware char estimator: base64 payloads in inlineData
|
||||
// would otherwise dominate the split. The caller can pre-compute and
|
||||
// pass `precomputedCharCounts` to avoid a redundant walk when the
|
||||
// surrounding compress() loop also needs the values.
|
||||
//
|
||||
// NOTE on the fallback: when `precomputedCharCounts` is omitted, we
|
||||
// use `DEFAULT_IMAGE_TOKEN_ESTIMATE` rather than the user's resolved
|
||||
// setting / env override. The only production caller is `compress()`,
|
||||
// which always passes precomputed counts, so the fallback is a
|
||||
// test-friendly default — not a behavior path users can influence.
|
||||
// Production callers MUST pass `precomputedCharCounts`.
|
||||
const charCounts =
|
||||
precomputedCharCounts ??
|
||||
contents.map((content) =>
|
||||
estimateContentChars(content, DEFAULT_IMAGE_TOKEN_ESTIMATE),
|
||||
);
|
||||
const totalCharCount = charCounts.reduce((a, b) => a + b, 0);
|
||||
const targetCharCount = totalCharCount * fraction;
|
||||
|
||||
let lastSplitPoint = 0;
|
||||
let cumulativeCharCount = 0;
|
||||
for (let i = 0; i < contents.length; i++) {
|
||||
const content = contents[i];
|
||||
if (content.role === 'user' && !hasFunctionResponse(content)) {
|
||||
if (cumulativeCharCount >= targetCharCount) {
|
||||
return i;
|
||||
}
|
||||
lastSplitPoint = i;
|
||||
}
|
||||
cumulativeCharCount += charCounts[i];
|
||||
}
|
||||
|
||||
const lastContent = contents[contents.length - 1];
|
||||
if (lastContent?.role === 'model') {
|
||||
if (!hasFunctionCall(lastContent)) return contents.length;
|
||||
return splitPointRetainingTrailingPairs(contents, retainCount);
|
||||
}
|
||||
if (lastContent?.role === 'user' && hasFunctionResponse(lastContent)) {
|
||||
return contents.length;
|
||||
}
|
||||
return lastSplitPoint;
|
||||
}
|
||||
|
||||
export interface CompressOptions {
|
||||
promptId: string;
|
||||
force: boolean;
|
||||
|
|
@ -325,6 +196,7 @@ export class ChatCompressionService {
|
|||
const compactTrigger = trigger ?? (force ? 'manual' : 'auto');
|
||||
const chatCompressionSettings = config.getChatCompression();
|
||||
const slimmingConfig = resolveSlimmingConfig(chatCompressionSettings);
|
||||
const tuning = resolveCompactionTuning(chatCompressionSettings);
|
||||
|
||||
// Cheap gates first — these don't need the curated history. Forward
|
||||
// originalTokenCount on NOOP (matching the threshold-gate branch below)
|
||||
|
|
@ -365,14 +237,26 @@ export class ChatCompressionService {
|
|||
)
|
||||
: originalTokenCount;
|
||||
if (effectiveTokens < auto) {
|
||||
return {
|
||||
newHistory: null,
|
||||
info: {
|
||||
originalTokenCount,
|
||||
newTokenCount: originalTokenCount,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
},
|
||||
};
|
||||
// Screenshot-overflow trigger: even below the token threshold,
|
||||
// compact once tool-returned images accumulate past the configured
|
||||
// count, so computer-use sessions don't drown the model in stale
|
||||
// screenshots. Only counted in the would-be-NOOP path and only when
|
||||
// enabled, so the common case pays nothing. Counts NESTED tool media
|
||||
// only (countToolResponseImages), not user-pasted top-level images.
|
||||
const screenshotOverflow =
|
||||
tuning.enableScreenshotTrigger &&
|
||||
countToolResponseImages(chat.getHistoryShallow(true)) >=
|
||||
tuning.screenshotTriggerThreshold;
|
||||
if (!screenshotOverflow) {
|
||||
return {
|
||||
newHistory: null,
|
||||
info: {
|
||||
originalTokenCount,
|
||||
newTokenCount: originalTokenCount,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -407,52 +291,14 @@ export class ChatCompressionService {
|
|||
}
|
||||
}
|
||||
|
||||
// Only manual `/compress` (trigger='manual') performs the orphan-strip:
|
||||
// if the chat was interrupted with a trailing model funcCall whose
|
||||
// funcResponse never arrived, the user-initiated /compress between
|
||||
// turns can safely drop it before computing the split point.
|
||||
//
|
||||
// Both automatic paths (trigger='auto') — cheap-gate (force=false) AND
|
||||
// hard-rescue (force=true) — must NOT strip. They fire inside
|
||||
// sendMessageStream() BEFORE the pending funcResponse is pushed onto
|
||||
// history, so the trailing funcCall is still active, not orphaned.
|
||||
//
|
||||
// Gating on `trigger === 'manual'` instead of `force` disambiguates
|
||||
// "user wants this compressed now, history can be mutated" from
|
||||
// "automatic compression mid-turn, history snapshot is live state and
|
||||
// must be preserved verbatim". Earlier the predicate used `force`,
|
||||
// which is correct for manual /compress (force=true, trigger='manual')
|
||||
// but conflated hard-rescue (force=true, trigger='auto') and silently
|
||||
// stripped active funcCalls there.
|
||||
const lastMessage = curatedHistory[curatedHistory.length - 1];
|
||||
const hasOrphanedFuncCall =
|
||||
compactTrigger === 'manual' &&
|
||||
lastMessage?.role === 'model' &&
|
||||
lastMessage.parts?.some((p) => !!p.functionCall);
|
||||
const historyForSplit = hasOrphanedFuncCall
|
||||
? curatedHistory.slice(0, -1)
|
||||
: curatedHistory;
|
||||
// CLAUDE-CODE-STYLE FULL-HISTORY COMPRESSION: the entire curated
|
||||
// history is sent to the summary side-query (no split, no tail
|
||||
// preservation), and the post-compact history is assembled by
|
||||
// composePostCompactHistory below (summary + model ack + recent
|
||||
// file restores + recent image restore).
|
||||
|
||||
// Precompute charCounts once and share with the splitter + the
|
||||
// MIN_COMPRESSION_FRACTION guard below, avoiding two extra walks.
|
||||
const charCounts = historyForSplit.map((c) =>
|
||||
estimateContentChars(c, slimmingConfig.imageTokenEstimate),
|
||||
);
|
||||
const splitPoint = findCompressSplitPoint(
|
||||
historyForSplit,
|
||||
1 - COMPRESSION_PRESERVE_THRESHOLD,
|
||||
TOOL_ROUND_RETAIN_COUNT,
|
||||
charCounts,
|
||||
);
|
||||
|
||||
const historyToCompress = historyForSplit.slice(0, splitPoint);
|
||||
const historyToKeep = historyForSplit.slice(splitPoint);
|
||||
// The in-flight fallback path may produce a kept slice starting with
|
||||
// model+functionCall; the post-summary history needs a synthetic user
|
||||
// between the summary's model_ack and the kept entries.
|
||||
const keepNeedsContinuationBridge = historyToKeep[0]?.role === 'model';
|
||||
|
||||
if (historyToCompress.length === 0) {
|
||||
// Guard: need at least a user+model pair for a meaningful summary.
|
||||
if (curatedHistory.length < 2) {
|
||||
return {
|
||||
newHistory: null,
|
||||
info: {
|
||||
|
|
@ -463,28 +309,10 @@ export class ChatCompressionService {
|
|||
};
|
||||
}
|
||||
|
||||
// Guard: if historyToCompress is too small relative to the total history,
|
||||
// skip compression. This prevents futile API calls where the model receives
|
||||
// almost no context and generates a useless "summary" that inflates tokens.
|
||||
let compressCharCount = 0;
|
||||
for (let i = 0; i < splitPoint; i++) compressCharCount += charCounts[i]!;
|
||||
const totalCharCount = charCounts.reduce((a, b) => a + b, 0);
|
||||
if (
|
||||
totalCharCount > 0 &&
|
||||
compressCharCount / totalCharCount < MIN_COMPRESSION_FRACTION
|
||||
) {
|
||||
return {
|
||||
newHistory: null,
|
||||
info: {
|
||||
originalTokenCount,
|
||||
newTokenCount: originalTokenCount,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Slim the side-query; live history unchanged.
|
||||
const slim = slimCompactionInput(historyToCompress);
|
||||
// Slim the side-query input: replace inlineData with placeholders.
|
||||
// The original history (with images) is preserved separately for
|
||||
// the post-compact image restoration block.
|
||||
const slim = slimCompactionInput(curatedHistory);
|
||||
if (slim.stats.imagesStripped > 0 || slim.stats.documentsStripped > 0) {
|
||||
config
|
||||
.getDebugLogger()
|
||||
|
|
@ -507,7 +335,7 @@ export class ChatCompressionService {
|
|||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: 'First, reason in your scratchpad. Then, generate the <state_snapshot>.',
|
||||
text: 'First, reason in your <analysis> block. Then, produce the <state_snapshot> XML.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -524,7 +352,15 @@ export class ChatCompressionService {
|
|||
promptId,
|
||||
});
|
||||
const summary = summaryResult.text;
|
||||
const isSummaryEmpty = !summary || summary.trim().length === 0;
|
||||
// Check the PROCESSED summary: postProcessSummary strips <analysis>
|
||||
// blocks, so a response that is ONLY <analysis>...</analysis> (no
|
||||
// <state_snapshot>) has a non-empty RAW body but strips to nothing. If
|
||||
// we gated on the raw body, compaction would "succeed" and the agent
|
||||
// would resume with `[Summary unavailable]` as its only context — total
|
||||
// amnesia with green metrics. Treat strip-to-empty as an empty summary
|
||||
// so it takes the COMPRESSION_FAILED_EMPTY_SUMMARY path (NOOP) instead.
|
||||
const isSummaryEmpty =
|
||||
!summary || stripAnalysisBlock(summary).trim().length === 0;
|
||||
const compressionUsageMetadata = summaryResult.usage;
|
||||
const compressionInputTokenCount =
|
||||
compressionUsageMetadata?.promptTokenCount;
|
||||
|
|
@ -588,41 +424,81 @@ export class ChatCompressionService {
|
|||
let canCalculateNewTokenCount = false;
|
||||
|
||||
if (!isSummaryEmpty) {
|
||||
extraHistory = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: summary }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Got it. Thanks for the additional context!' }],
|
||||
},
|
||||
// When the kept slice starts with model+functionCall (because
|
||||
// tool-round absorption pulled the only fresh user message into
|
||||
// compress), inject a synthetic continuation prompt so the joined
|
||||
// history alternates correctly.
|
||||
...(keepNeedsContinuationBridge
|
||||
? [
|
||||
{
|
||||
role: 'user' as const,
|
||||
parts: [
|
||||
{
|
||||
text: 'Continue with the prior task using the context above.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...historyToKeep,
|
||||
];
|
||||
// Manual /compress has no pending functionResponse, so a trailing
|
||||
// model+functionCall is an ORPHAN (e.g. an interrupted/cancelled tool
|
||||
// call). Preserving it emits model[functionCall] immediately followed
|
||||
// by the next user TEXT turn, which the API rejects (a functionCall
|
||||
// must be followed by its functionResponse). Strip it for manual;
|
||||
// auto-compaction keeps it because the pending functionResponse pairs
|
||||
// with it (trailingFunctionCallContent).
|
||||
const lastCurated = curatedHistory[curatedHistory.length - 1];
|
||||
const historyForCompose =
|
||||
compactTrigger === 'manual' &&
|
||||
lastCurated?.role === 'model' &&
|
||||
lastCurated.parts?.some((p) => !!p.functionCall)
|
||||
? curatedHistory.slice(0, -1)
|
||||
: curatedHistory;
|
||||
|
||||
// Use the new composer — assembles summary + ack + file restores +
|
||||
// image restore. No tail preservation, no continuation bridge.
|
||||
try {
|
||||
extraHistory = await composePostCompactHistory(
|
||||
historyForCompose,
|
||||
summary,
|
||||
{
|
||||
workspaceRoot: config.getTargetDir(),
|
||||
signal,
|
||||
maxFiles: tuning.maxRecentFiles,
|
||||
maxImages: tuning.maxRecentImages,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
// The summary side-query already succeeded; only restoration
|
||||
// assembly (disk I/O, history walking) failed. Degrade to
|
||||
// summary + ack rather than letting the throw escape to
|
||||
// sendMessageStream — an uncaught error there crashes the active
|
||||
// turn AND bypasses the COMPRESSION_FAILED breaker. The summary
|
||||
// still reduces context, so this is a degraded success, not a
|
||||
// compression failure.
|
||||
config
|
||||
.getDebugLogger()
|
||||
.warn(`[chat-compression] composePostCompactHistory failed: ${err}`);
|
||||
// Fold a trailing model+functionCall into the ack so a pending
|
||||
// functionResponse (auto-compaction mid-tool-loop) keeps its matching
|
||||
// call — otherwise the next request has an orphaned functionResponse
|
||||
// → 400. (Manual orphans were already stripped above.) Folding into
|
||||
// the ack avoids a model→model adjacency.
|
||||
const trailingFc = historyForCompose[historyForCompose.length - 1];
|
||||
const fcParts =
|
||||
trailingFc?.role === 'model'
|
||||
? (trailingFc.parts ?? []).filter((p) => !!p.functionCall)
|
||||
: [];
|
||||
extraHistory = [
|
||||
{ role: 'user', parts: [{ text: postProcessSummary(summary) }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Got it. Thanks for the additional context!' },
|
||||
...fcParts,
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Best-effort token math using *only* model-reported token counts.
|
||||
//
|
||||
// Note: compressionInputTokenCount includes the compression prompt and
|
||||
// the extra "reason in your scratchpad" instruction(approx. 1000 tokens), and
|
||||
// compressionOutputTokenCount reflects the summary tokens only since
|
||||
// thinking is disabled.
|
||||
// We accept these inaccuracies to avoid local token estimation.
|
||||
// Note: compressionInputTokenCount includes the entire compression
|
||||
// system prompt (the <state_snapshot> instructions, ~900 tokens) PLUS
|
||||
// the short kick-off user turn ("First, reason in your <analysis>
|
||||
// block. Then, produce the <state_snapshot> XML.", ~20 tokens) — the
|
||||
// "approx. 1000 tokens" subtracted below is for that combined fixed
|
||||
// overhead, not for any single instruction.
|
||||
// compressionOutputTokenCount reflects the raw model response (i.e.
|
||||
// <analysis> + <state_snapshot>); the <analysis> block is stripped
|
||||
// by postProcessSummary before the summary enters history, so the
|
||||
// real cost in newHistory is slightly lower than this count
|
||||
// suggests. We accept that inaccuracy in favor of avoiding local
|
||||
// token estimation.
|
||||
if (
|
||||
typeof compressionInputTokenCount === 'number' &&
|
||||
compressionInputTokenCount > 0 &&
|
||||
|
|
@ -636,6 +512,23 @@ export class ChatCompressionService {
|
|||
(compressionInputTokenCount - 1000) +
|
||||
compressionOutputTokenCount,
|
||||
);
|
||||
// The composer injects file-restoration blocks (up to
|
||||
// maxRecentFiles × 5K tokens) and an image-restoration block (up to
|
||||
// maxRecentImages images) that are NOT in
|
||||
// compressionOutputTokenCount. Estimate their
|
||||
// cost locally so the inflation guard below
|
||||
// (newTokenCount > originalTokenCount) actually fires when
|
||||
// attachments dominate the post-compact size, and so
|
||||
// `lastPromptTokenCount` doesn't under-report the next auto-
|
||||
// compaction cheap-gate input (Finding 1).
|
||||
const restorationChars = extraHistory
|
||||
.slice(2) // skip [summary, model ack]
|
||||
.reduce(
|
||||
(acc, c) =>
|
||||
acc + estimateContentChars(c, slimmingConfig.imageTokenEstimate),
|
||||
0,
|
||||
);
|
||||
newTokenCount += Math.ceil(restorationChars / CHARS_PER_TOKEN);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -685,9 +578,18 @@ export class ChatCompressionService {
|
|||
compactTrigger === 'manual'
|
||||
? PostCompactTrigger.Manual
|
||||
: PostCompactTrigger.Auto;
|
||||
// Pass the stripped summary (Finding 8a) so hook consumers see
|
||||
// the same text that lands in history — not the raw side-query
|
||||
// output with the <analysis> scratchpad still attached. The
|
||||
// resume trailer is NOT included; it is wrapper decoration for
|
||||
// the next agent turn, not state for downstream consumers.
|
||||
await config
|
||||
.getHookSystem()
|
||||
?.firePostCompactEvent(postCompactTrigger, summary, signal);
|
||||
?.firePostCompactEvent(
|
||||
postCompactTrigger,
|
||||
stripAnalysisBlock(summary),
|
||||
signal,
|
||||
);
|
||||
} catch (err) {
|
||||
config.getDebugLogger().warn(`PostCompact hook failed: ${err}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,20 +8,33 @@ import type { Content } from '@google/genai';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_IMAGE_TOKEN_ESTIMATE,
|
||||
DEFAULT_MAX_RECENT_FILES,
|
||||
DEFAULT_MAX_RECENT_IMAGES,
|
||||
DEFAULT_SCREENSHOT_TRIGGER_ENABLED,
|
||||
DEFAULT_SCREENSHOT_TRIGGER_THRESHOLD,
|
||||
estimateContentChars,
|
||||
estimatePartChars,
|
||||
resolveCompactionTuning,
|
||||
resolveSlimmingConfig,
|
||||
sanitizeMimeForPlaceholder,
|
||||
slimCompactionInput,
|
||||
} from './compactionInputSlimming.js';
|
||||
|
||||
const COMPACTION_ENV_KEYS = [
|
||||
'QWEN_IMAGE_TOKEN_ESTIMATE',
|
||||
'QWEN_COMPACT_MAX_RECENT_FILES',
|
||||
'QWEN_COMPACT_MAX_RECENT_IMAGES',
|
||||
'QWEN_COMPACT_SCREENSHOT_TRIGGER',
|
||||
'QWEN_COMPACT_SCREENSHOT_THRESHOLD',
|
||||
];
|
||||
|
||||
describe('compactionInputSlimming', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env['QWEN_IMAGE_TOKEN_ESTIMATE'];
|
||||
for (const k of COMPACTION_ENV_KEYS) delete process.env[k];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['QWEN_IMAGE_TOKEN_ESTIMATE'];
|
||||
for (const k of COMPACTION_ENV_KEYS) delete process.env[k];
|
||||
});
|
||||
|
||||
describe('resolveSlimmingConfig', () => {
|
||||
|
|
@ -58,6 +71,89 @@ describe('compactionInputSlimming', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('resolveCompactionTuning', () => {
|
||||
it('returns defaults when nothing is set', () => {
|
||||
const t = resolveCompactionTuning(undefined);
|
||||
expect(t.maxRecentFiles).toBe(DEFAULT_MAX_RECENT_FILES);
|
||||
expect(t.maxRecentImages).toBe(DEFAULT_MAX_RECENT_IMAGES);
|
||||
expect(t.enableScreenshotTrigger).toBe(
|
||||
DEFAULT_SCREENSHOT_TRIGGER_ENABLED,
|
||||
);
|
||||
expect(t.screenshotTriggerThreshold).toBe(
|
||||
DEFAULT_SCREENSHOT_TRIGGER_THRESHOLD,
|
||||
);
|
||||
});
|
||||
|
||||
it('honors settings when env is unset', () => {
|
||||
const t = resolveCompactionTuning({
|
||||
maxRecentFilesToRetain: 2,
|
||||
maxRecentImagesToRetain: 1,
|
||||
enableScreenshotTrigger: false,
|
||||
screenshotTriggerThreshold: 12,
|
||||
});
|
||||
expect(t.maxRecentFiles).toBe(2);
|
||||
expect(t.maxRecentImages).toBe(1);
|
||||
expect(t.enableScreenshotTrigger).toBe(false);
|
||||
expect(t.screenshotTriggerThreshold).toBe(12);
|
||||
});
|
||||
|
||||
it('accepts 0 for the retention caps (restore none)', () => {
|
||||
const t = resolveCompactionTuning({
|
||||
maxRecentFilesToRetain: 0,
|
||||
maxRecentImagesToRetain: 0,
|
||||
});
|
||||
expect(t.maxRecentFiles).toBe(0);
|
||||
expect(t.maxRecentImages).toBe(0);
|
||||
});
|
||||
|
||||
it('env overrides settings for every knob', () => {
|
||||
process.env['QWEN_COMPACT_MAX_RECENT_FILES'] = '7';
|
||||
process.env['QWEN_COMPACT_MAX_RECENT_IMAGES'] = '9';
|
||||
process.env['QWEN_COMPACT_SCREENSHOT_TRIGGER'] = '0';
|
||||
process.env['QWEN_COMPACT_SCREENSHOT_THRESHOLD'] = '99';
|
||||
const t = resolveCompactionTuning({
|
||||
maxRecentFilesToRetain: 1,
|
||||
maxRecentImagesToRetain: 1,
|
||||
enableScreenshotTrigger: true,
|
||||
screenshotTriggerThreshold: 5,
|
||||
});
|
||||
expect(t.maxRecentFiles).toBe(7);
|
||||
expect(t.maxRecentImages).toBe(9);
|
||||
expect(t.enableScreenshotTrigger).toBe(false);
|
||||
expect(t.screenshotTriggerThreshold).toBe(99);
|
||||
});
|
||||
|
||||
it('parses the boolean env both ways and ignores typos', () => {
|
||||
process.env['QWEN_COMPACT_SCREENSHOT_TRIGGER'] = 'false';
|
||||
expect(resolveCompactionTuning(undefined).enableScreenshotTrigger).toBe(
|
||||
false,
|
||||
);
|
||||
process.env['QWEN_COMPACT_SCREENSHOT_TRIGGER'] = '1';
|
||||
expect(resolveCompactionTuning(undefined).enableScreenshotTrigger).toBe(
|
||||
true,
|
||||
);
|
||||
// Unrecognized env string falls through to the settings value.
|
||||
process.env['QWEN_COMPACT_SCREENSHOT_TRIGGER'] = 'yes-please';
|
||||
expect(
|
||||
resolveCompactionTuning({ enableScreenshotTrigger: false })
|
||||
.enableScreenshotTrigger,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('falls through invalid numeric env to settings, then defaults', () => {
|
||||
process.env['QWEN_COMPACT_SCREENSHOT_THRESHOLD'] = 'not-a-number';
|
||||
expect(
|
||||
resolveCompactionTuning({ screenshotTriggerThreshold: 33 })
|
||||
.screenshotTriggerThreshold,
|
||||
).toBe(33);
|
||||
// Threshold has a min of 1, so 0 is rejected → default.
|
||||
process.env['QWEN_COMPACT_SCREENSHOT_THRESHOLD'] = '0';
|
||||
expect(
|
||||
resolveCompactionTuning(undefined).screenshotTriggerThreshold,
|
||||
).toBe(DEFAULT_SCREENSHOT_TRIGGER_THRESHOLD);
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimatePartChars', () => {
|
||||
it('uses text length for text parts', () => {
|
||||
expect(estimatePartChars({ text: 'hello' }, 1600)).toBe(5);
|
||||
|
|
|
|||
|
|
@ -98,12 +98,88 @@ function resolveNumber(
|
|||
return defaultValue;
|
||||
}
|
||||
|
||||
export const DEFAULT_MAX_RECENT_FILES = 5;
|
||||
export const DEFAULT_MAX_RECENT_IMAGES = 3;
|
||||
export const DEFAULT_SCREENSHOT_TRIGGER_ENABLED = true;
|
||||
export const DEFAULT_SCREENSHOT_TRIGGER_THRESHOLD = 50;
|
||||
|
||||
export interface ResolvedCompactionTuning {
|
||||
/** Recent files restored after compaction (0 = restore none). */
|
||||
maxRecentFiles: number;
|
||||
/** Recent images restored after compaction (0 = restore none). */
|
||||
maxRecentImages: number;
|
||||
/** Whether tool-image accumulation can trigger auto-compaction. */
|
||||
enableScreenshotTrigger: boolean;
|
||||
/** Tool-image count at or above which the trigger fires (≥ 1). */
|
||||
screenshotTriggerThreshold: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the post-compact retention + screenshot-trigger knobs in
|
||||
* priority order env > settings > default, reusing the same validation
|
||||
* rules as `resolveSlimmingConfig`.
|
||||
*
|
||||
* The screenshot trigger counts only images nested in
|
||||
* `functionResponse.parts` (tool results). Compaction replaces those with
|
||||
* the summary, and the surviving images are re-embedded as TOP-LEVEL parts
|
||||
* in the restoration block — which the counter ignores. So compaction
|
||||
* always resets the tool-image count to ~0 and the trigger cannot
|
||||
* immediately re-fire, independent of `maxRecentImages`.
|
||||
*/
|
||||
export function resolveCompactionTuning(
|
||||
settings: ChatCompressionSettings | undefined,
|
||||
): ResolvedCompactionTuning {
|
||||
return {
|
||||
maxRecentFiles: resolveNumber(
|
||||
process.env['QWEN_COMPACT_MAX_RECENT_FILES'],
|
||||
settings?.maxRecentFilesToRetain,
|
||||
DEFAULT_MAX_RECENT_FILES,
|
||||
{ minInclusive: 0 },
|
||||
),
|
||||
maxRecentImages: resolveNumber(
|
||||
process.env['QWEN_COMPACT_MAX_RECENT_IMAGES'],
|
||||
settings?.maxRecentImagesToRetain,
|
||||
DEFAULT_MAX_RECENT_IMAGES,
|
||||
{ minInclusive: 0 },
|
||||
),
|
||||
enableScreenshotTrigger: resolveBoolean(
|
||||
process.env['QWEN_COMPACT_SCREENSHOT_TRIGGER'],
|
||||
settings?.enableScreenshotTrigger,
|
||||
DEFAULT_SCREENSHOT_TRIGGER_ENABLED,
|
||||
),
|
||||
screenshotTriggerThreshold: resolveNumber(
|
||||
process.env['QWEN_COMPACT_SCREENSHOT_THRESHOLD'],
|
||||
settings?.screenshotTriggerThreshold,
|
||||
DEFAULT_SCREENSHOT_TRIGGER_THRESHOLD,
|
||||
{ minInclusive: 1 },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a boolean knob in priority order env > settings > default.
|
||||
* Accepts `1`/`true` and `0`/`false` (case-sensitive, matching the
|
||||
* existing `getEmitToolUseSummaries` convention); any other env string
|
||||
* is ignored so a typo falls through rather than silently flipping the
|
||||
* flag.
|
||||
*/
|
||||
function resolveBoolean(
|
||||
envValue: string | undefined,
|
||||
settingsValue: boolean | undefined,
|
||||
defaultValue: boolean,
|
||||
): boolean {
|
||||
if (envValue === '1' || envValue === 'true') return true;
|
||||
if (envValue === '0' || envValue === 'false') return false;
|
||||
if (typeof settingsValue === 'boolean') return settingsValue;
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate char count for a single `Part`, used by
|
||||
* `findCompressSplitPoint` and by the slimming module's own budget
|
||||
* `estimateContentChars` and by the slimming module's own budget
|
||||
* accounting. Binary parts get a fixed budget (in chars) derived from
|
||||
* the configured token estimate; this keeps base64 payloads from
|
||||
* skewing the split point or token-budget math.
|
||||
* skewing compression size estimation or token-budget math.
|
||||
*/
|
||||
export function estimatePartChars(
|
||||
part: Part,
|
||||
|
|
@ -144,8 +220,12 @@ export function estimatePartChars(
|
|||
* qwen-code attaches media here (see
|
||||
* `coreToolScheduler.createFunctionResponsePart`); the standard
|
||||
* `@google/genai` FunctionResponse type does not declare it.
|
||||
*
|
||||
* Exported so post-compact image extraction/counting walks the SAME
|
||||
* carrier the slimmer strips — otherwise the two disagree on where tool
|
||||
* media lives and screenshots silently vanish from restoration.
|
||||
*/
|
||||
function getFunctionResponseParts(part: Part): Part[] | undefined {
|
||||
export function getFunctionResponseParts(part: Part): Part[] | undefined {
|
||||
const fr = part.functionResponse as { parts?: unknown } | undefined;
|
||||
return Array.isArray(fr?.parts) ? (fr.parts as Part[]) : undefined;
|
||||
}
|
||||
|
|
|
|||
1220
packages/core/src/services/postCompactAttachments.test.ts
Normal file
1220
packages/core/src/services/postCompactAttachments.test.ts
Normal file
File diff suppressed because it is too large
Load diff
700
packages/core/src/services/postCompactAttachments.ts
Normal file
700
packages/core/src/services/postCompactAttachments.ts
Normal file
|
|
@ -0,0 +1,700 @@
|
|||
/**
|
||||
* @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;
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ import {
|
|||
* not byte counts — for CJK / multi-byte text the byte/char ratio differs
|
||||
* from 1, so a "bytes" name would mislead. Programmatically aliased to
|
||||
* compactionInputSlimming.ts's TOKEN_TO_CHAR_RATIO so the auto-compaction
|
||||
* trigger and the compression splitter can never drift on this constant.
|
||||
* trigger and the compression size estimator can never drift on this constant.
|
||||
* Matches claude-code's roughTokenCountEstimation default. (review #4168 R3.1)
|
||||
*/
|
||||
export const CHARS_PER_TOKEN = TOKEN_TO_CHAR_RATIO;
|
||||
|
|
@ -27,8 +27,8 @@ export const CHARS_PER_TOKEN = TOKEN_TO_CHAR_RATIO;
|
|||
*
|
||||
* Reuses `estimateContentChars` so that inlineData / functionCall /
|
||||
* functionResponse get the same treatment they receive when computing
|
||||
* compression split points — keeping the two estimators in sync prevents
|
||||
* the auto-compaction trigger and the splitter from disagreeing on size.
|
||||
* compression size estimates — keeping the two estimators in sync prevents
|
||||
* the auto-compaction trigger and the compressor from disagreeing on size.
|
||||
*
|
||||
* Intended for the pre-send threshold gate only. char/4 is a conservative
|
||||
* lower bound (real tokenizers vary ±30%); using it to TRIGGER compaction
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue