Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c7868de
refactor(core): rewrite compression prompt to 9-section claude-code-s…
LaZzyMan May 28, 2026
d526b2b
refactor(core): align compaction trigger string with new 9-section pr…
LaZzyMan May 28, 2026
61a5b9e
feat(core): add postCompactAttachments module with file path extractor
LaZzyMan May 28, 2026
d1a7415
refactor(core): simplify extractRecentFilePaths internals
LaZzyMan May 28, 2026
51cec3d
feat(core): add image extractor with source-tool metadata
LaZzyMan May 28, 2026
95e012c
feat(core): add size-adaptive file reader for post-compact restore
LaZzyMan May 28, 2026
7c127d6
refactor(core): harden readFileSizeAdaptive size accounting
LaZzyMan May 28, 2026
1aee868
feat(core): add file restoration block composer
LaZzyMan May 28, 2026
2b482f1
test(core): make budget test actually exercise the downgrade path
LaZzyMan May 28, 2026
76da36b
feat(core): add image restoration block composer
LaZzyMan May 28, 2026
86e23a6
feat(core): add composePostCompactHistory orchestrator
LaZzyMan May 28, 2026
ebfcba1
feat(core)!: rewrite compress() to claude-code-style full-history model
LaZzyMan May 28, 2026
ff4cb1e
chore(core): remove obsolete split-point compression infrastructure
LaZzyMan May 28, 2026
7579373
test(core): add single-turn computer-use compaction regression
LaZzyMan May 28, 2026
f3b6c69
docs(core): remove stale "split point" references in tokenEstimation …
LaZzyMan May 28, 2026
9d920a9
fix(core): iterate parts reverse so parallel tool calls keep the last N
LaZzyMan May 28, 2026
db3b8d0
fix(core): code-review fixes — fence escape, path sanitize, alias rem…
LaZzyMan May 28, 2026
f8fb8e2
refactor(core): rewrite compression prompt to <state_snapshot> XML wi…
LaZzyMan May 28, 2026
85df6c1
docs(core): code-review polish on XML prompt rewrite
LaZzyMan May 28, 2026
610bbcb
docs(core): drop stale duplicate sentence left in token-math comment
LaZzyMan May 28, 2026
730f2b9
fix(core): address wenshao review on PR #4599 (correctness + security…
LaZzyMan May 29, 2026
a1b876a
fix(core): add getTargetDir to geminiChat auto-compression test mock
LaZzyMan May 29, 2026
15234f7
feat(core): configurable compaction retention + computer-use screensh…
LaZzyMan May 29, 2026
9790d5f
test(core): assert screenshot trigger can't re-fire post-compaction; …
LaZzyMan May 29, 2026
178d0b3
fix(core): guard readFileSizeAdaptive against multi-GB reads; cover c…
LaZzyMan May 29, 2026
cf5da5a
fix(core): resolve symlinks in workspace boundary; guard compose agai…
LaZzyMan May 29, 2026
1b3d12c
fix(core): close 4 compaction Criticals from review round 4
LaZzyMan May 29, 2026
0e8d856
test(core): cover composePostCompactHistory catch-fallback; document …
LaZzyMan May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
57 changes: 1 addition & 56 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
});
47 changes: 28 additions & 19 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
Expand Down
79 changes: 0 additions & 79 deletions packages/core/src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 0 additions & 5 deletions packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -2182,7 +2181,3 @@ export class GeminiClient {
return info;
}
}

export const TEST_ONLY = {
COMPRESSION_PRESERVE_THRESHOLD,
};
1 change: 1 addition & 0 deletions packages/core/src/core/geminiChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading
Loading