Skip to content

Commit 60be51d

Browse files
committed
feat: add retainRatio and retainTokens settings for compaction budget management
1 parent 204dd20 commit 60be51d

9 files changed

Lines changed: 260 additions & 37 deletions

File tree

‎docs/configuration.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
2929
| `env` | object | 环境变量分组(见下方子字段表) |
3030
| `contextWindow` | number/string | 上下文窗口上限,可使用精确 token 数或 `128K`、`1M` 等格式 |
3131
| `autoCompactWindow` | number/string | 自动压缩阈值,默认取上下文窗口的 80% |
32+
| `retainRatio` | number | 上下文压缩后,按模型上下文窗口的百分比保留最近对话历史的比例(默认 `0.16`,即 16%) |
33+
| `retainTokens` | number | 上下文压缩后,按绝对 token 数量保留最近对话历史的预算,与 `retainRatio` 互斥 |
3234
| `model` | string | 模型名称。优先级高于 `env.MODEL` |
3335
| `thinkingEnabled` | boolean | 是否启用思考模式(DeepSeek V4 系列默认启用) |
3436
| `reasoningEffort` | string | 推理强度,可选 `"low"`、`"high"` 或 `"max"`(默认 `"max"`) |
@@ -75,7 +77,24 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
7577
}
7678
```
7779

78-
普通模型的默认上下文窗口为 `256K`,DeepSeek V4 系列为 `1M`。未设置自动压缩阈值时取最终上下文窗口的 50%;无效值会被忽略,自动压缩阈值超过上下文窗口时会限制为上下文窗口。
80+
普通模型的默认上下文窗口为 `256K`,DeepSeek V4 系列为 `1M`。未设置自动压缩阈值时取最终上下文窗口的 80%;无效值会被忽略,自动压缩阈值超过上下文窗口时会限制为上下文窗口。
81+
82+
#### 压缩后的尾部保留预算
83+
84+
`retainRatio` 和 `retainTokens` 是 `settings.json` 的顶层字段,同一配置文件只能设置其中一项。项目级设置任一项时,整体覆盖用户级的保留策略;项目未设置时继承用户配置,两处均未设置时默认采用 `retainRatio: 0.16`。
85+
86+
`retainRatio` 必须是 `(0, 1]` 范围内的数字,预算按「上下文窗口减去输出预留」乘以该比例并向下取整。`retainTokens` 必须是非负整数,`0` 表示不额外预留尾部;不支持 `"32K"` 等字符串。
87+
88+
最终保留预算必须严格小于实际生效的自动压缩阈值,否则报配置错误,不会自动缩小预算。使用较低阈值时,可能需要同时设置较小的保留预算:
89+
90+
```json
91+
{
92+
"autoCompactWindow": "180K",
93+
"retainTokens": 32768
94+
}
95+
```
96+
97+
这两项配置用于自动压缩。工具调用与结果会保持完整,因此实际保留量可能超过预算。手动压缩和上下文超限恢复继续采用不额外预留尾部的策略。
7998

8099
#### `thinkingEnabled` — 思考模式
81100

‎docs/configuration_en.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ The following are all the top-level fields supported in `settings.json`, along w
2929
| `env` | object | Group of environment variables (see sub-field table below) |
3030
| `contextWindow` | number/string | Context-window limit as an exact token count or `128K`/`1M` value |
3131
| `autoCompactWindow` | number/string | Auto-compaction threshold; defaults to 80% of the context window |
32+
| `retainRatio` | number | Budget for retaining recent conversation history after compaction, expressed as a fraction of the model's context window (default `0.16`, or 16%) |
33+
| `retainTokens` | number | Budget for retaining recent conversation history after compaction, expressed as an absolute token count; mutually exclusive with `retainRatio` |
3234
| `model` | string | Model name. Takes precedence over `env.MODEL` |
3335
| `thinkingEnabled` | boolean | Whether to enable thinking mode (enabled by default for DeepSeek V4 series)|
3436
| `reasoningEffort` | string | Reasoning intensity: `"low"`, `"high"`, or `"max"` (default `"max"`) |
@@ -75,7 +77,24 @@ The following are all the top-level fields supported in `settings.json`, along w
7577
}
7678
```
7779

78-
The default context window is `256K` for regular models and `1M` for DeepSeek V4 models. If the auto-compaction threshold is omitted, it is 50% of the final context window. Invalid values are ignored, and an auto-compaction threshold larger than the context window is capped at the context window.
80+
The default context window is `256K` for regular models and `1M` for DeepSeek V4 models. If the auto-compaction threshold is omitted, it is 80% of the final context window. Invalid values are ignored, and an auto-compaction threshold larger than the context window is capped at the context window.
81+
82+
#### Compaction retention
83+
84+
`retainRatio` and `retainTokens` are top-level `settings.json` fields. Set at most one per configuration file. A project-level setting replaces the user-level retention choice; otherwise, the user setting is inherited. With neither set, `retainRatio` defaults to `0.16`.
85+
86+
`retainRatio` must be a number in `(0, 1]`. Its budget is the context window minus the output reserve, multiplied by the ratio and rounded down. `retainTokens` must be a non-negative integer; `0` requests no extra tail retention. Strings such as `"32K"` are not supported.
87+
88+
The resulting budget must be strictly below the effective auto-compaction threshold. Invalid configurations produce an error rather than silently reducing the retention budget. A low threshold may require an explicit smaller retention budget:
89+
90+
```json
91+
{
92+
"autoCompactWindow": "180K",
93+
"retainTokens": 32768
94+
}
95+
```
96+
97+
These settings apply to automatic compaction. Tool calls and their results stay together, so the retained tail may exceed the budget. Manual compaction and context-overflow recovery continue to use no extra tail retention.
7998

8099
#### `thinkingEnabled` — Thinking Mode
81100

‎packages/core/src/common/compaction.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,33 @@ export type CompactBudget = {
88
retain: number;
99
};
1010

11-
export function compactBudget(window: number, configuredThreshold = Math.floor(window * 0.8)): CompactBudget {
11+
export type CompactRetention = { retainRatio?: number; retainTokens?: number };
12+
13+
export function validateCompactRetention({ retainRatio, retainTokens }: CompactRetention): void {
14+
if (retainRatio !== undefined && retainTokens !== undefined)
15+
throw new Error("retainRatio and retainTokens are mutually exclusive.");
16+
if (retainRatio !== undefined && (!Number.isFinite(retainRatio) || retainRatio <= 0 || retainRatio > 1))
17+
throw new Error("retainRatio must be a number in (0, 1].");
18+
if (retainTokens !== undefined && (!Number.isInteger(retainTokens) || retainTokens < 0))
19+
throw new Error("retainTokens must be a non-negative integer.");
20+
}
21+
22+
export function compactBudget(
23+
window: number,
24+
configuredThreshold = Math.floor(window * 0.8),
25+
retention: CompactRetention = {}
26+
): CompactBudget {
27+
validateCompactRetention(retention);
1228
const output = Math.max(1, Math.min(65536, Math.floor(window / 8)));
1329
const input = window - output;
1430
const threshold = Math.min(configuredThreshold, input - output);
1531
if (threshold <= 0) throw new Error("The context window leaves no room for conversation input.");
16-
return { output, input, threshold, retain: Math.min(Math.floor(input * 0.16), Math.floor(threshold * 0.8)) };
32+
const retain = retention.retainTokens ?? Math.floor(input * (retention.retainRatio ?? 0.16));
33+
if (retain >= threshold)
34+
throw new Error(
35+
`The retention budget (${retain}) must be less than the compaction threshold (${threshold}). Adjust retainRatio or retainTokens.`
36+
);
37+
return { output, input, threshold, retain };
1738
}
1839

1940
/** An estimate, not a tokenizer. Never charge binary image encodings as text. */

‎packages/core/src/session.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,8 @@ export type SessionManagerOptions = {
387387
maxRequestFilesBytes?: number;
388388
contextWindow?: number;
389389
autoCompactWindow?: number;
390+
retainRatio?: number;
391+
retainTokens?: number;
390392
webSearchTool?: string;
391393
mcpServers?: Record<string, McpServerConfig>;
392394
permissions?: Required<PermissionSettings>;
@@ -436,6 +438,8 @@ export class SessionManager {
436438
maxRequestFilesBytes?: number;
437439
contextWindow?: number;
438440
autoCompactWindow?: number;
441+
retainRatio?: number;
442+
retainTokens?: number;
439443
webSearchTool?: string;
440444
mcpServers?: Record<string, McpServerConfig>;
441445
permissions?: Required<PermissionSettings>;
@@ -1914,7 +1918,11 @@ ${agentInstructions}
19141918

19151919
private getCompactionBudget(model: string) {
19161920
const settings = this.getResolvedSettings();
1917-
return compactBudget(settings.contextWindow ?? getDefaultContextWindow(model), settings.autoCompactWindow);
1921+
return compactBudget(
1922+
settings.contextWindow ?? getDefaultContextWindow(model),
1923+
settings.autoCompactWindow,
1924+
settings
1925+
);
19181926
}
19191927

19201928
/** Derive exactly the content used by both normal and summary requests, before uploads. */
@@ -1956,7 +1964,7 @@ ${agentInstructions}
19561964
anchor?.route === route &&
19571965
anchor.hashes.length <= hashes.length &&
19581966
anchor.hashes.every((hash, index) => hash === hashes[index]);
1959-
const tokens = anchored ? Math.max(estimate, anchor.tokens + estimate - anchor.estimate) : estimate;
1967+
const tokens = anchored ? anchor.tokens + estimate - anchor.estimate : estimate;
19601968
return { route, hashes, estimate, tokens };
19611969
}
19621970

@@ -2048,10 +2056,10 @@ ${agentInstructions}
20482056
const promptTokens = response.usage?.prompt_tokens;
20492057
this.requestUsageAnchors.set(sessionId, {
20502058
...measurement,
2051-
tokens: Math.max(
2052-
measurement.estimate,
2053-
typeof promptTokens === "number" ? promptTokens : measurement.estimate
2054-
),
2059+
tokens:
2060+
typeof promptTokens === "number" && Number.isFinite(promptTokens) && promptTokens >= 0
2061+
? promptTokens
2062+
: measurement.estimate,
20552063
});
20562064
return response;
20572065
} catch (error) {

‎packages/core/src/settings.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { DEEPSEEK_V4_MODELS, defaultsToThinkingMode, type MultimodalMode } from
22
import * as fs from "fs";
33
import * as os from "os";
44
import * as path from "path";
5+
import { compactBudget, validateCompactRetention } from "./common/compaction";
56

67
export type DeepcodingEnv = Record<string, string | undefined> & {
78
MODEL?: string;
@@ -88,6 +89,8 @@ export type DeepcodingSettings = {
8889
env?: DeepcodingEnv;
8990
contextWindow?: number | string;
9091
autoCompactWindow?: number | string;
92+
retainRatio?: number;
93+
retainTokens?: number;
9194
model?: string;
9295
temperature?: number;
9396
thinkingEnabled?: boolean;
@@ -116,6 +119,8 @@ export type ResolvedDeepcodingSettings = {
116119
model: string;
117120
contextWindow: number;
118121
autoCompactWindow: number;
122+
retainRatio?: number;
123+
retainTokens?: number;
119124
temperature?: number;
120125
thinkingEnabled: boolean;
121126
reasoningEffort: ReasoningEffort;
@@ -607,6 +612,15 @@ export function resolveSettingsSources(
607612
);
608613
const defaultAutoCompactWindow = Math.max(1, Math.floor(contextWindow * 0.8));
609614
const autoCompactWindow = Math.min(configuredAutoCompactWindow ?? defaultAutoCompactWindow, contextWindow);
615+
validateCompactRetention(userSettings ?? {});
616+
validateCompactRetention(projectSettings ?? {});
617+
const retention =
618+
projectSettings?.retainRatio !== undefined || projectSettings?.retainTokens !== undefined
619+
? projectSettings
620+
: userSettings;
621+
const retainTokens = retention?.retainTokens;
622+
const retainRatio = retainTokens === undefined ? (retention?.retainRatio ?? 0.16) : undefined;
623+
compactBudget(contextWindow, autoCompactWindow, { retainRatio, retainTokens });
610624

611625
const thinkingEnabled =
612626
parseBoolean(systemEnv.THINKING_ENABLED) ??
@@ -705,6 +719,8 @@ export function resolveSettingsSources(
705719
model,
706720
contextWindow,
707721
autoCompactWindow,
722+
retainRatio,
723+
retainTokens,
708724
temperature,
709725
thinkingEnabled,
710726
reasoningEffort,

‎packages/core/src/tests/compaction.test.ts‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,12 @@ function fixture(history: SessionMessage[], respond: (request: any, call: number
5555
process.env.HOME = root;
5656
process.env.USERPROFILE = root;
5757
const requests: any[] = [];
58-
const settings = { model: "test-vision", contextWindow: 256 * 1024, autoCompactWindow: 190000 };
58+
const settings = {
59+
model: "test-vision",
60+
contextWindow: 256 * 1024,
61+
autoCompactWindow: 190000,
62+
retainTokens: undefined as number | undefined,
63+
};
5964
const manager = new SessionManager({
6065
projectRoot: root,
6166
createOpenAIClient: () => ({
@@ -539,3 +544,66 @@ test("summary requests share Files API upload and expired-reference recovery", a
539544
assert.match(JSON.stringify(f.requests[1].messages), /file-2/);
540545
assert.doesNotMatch(JSON.stringify(f.requests[1].messages), /base64/);
541546
});
547+
548+
test("retention budgets use the input window and reject budgets at the effective threshold", () => {
549+
assert.equal(compactBudget(1048576, 184320).retain, 157286);
550+
assert.equal(compactBudget(100000, 50000, { retainRatio: 0.2 }).retain, 17500);
551+
assert.equal(compactBudget(100000, 50000, { retainTokens: 1234 }).retain, 1234);
552+
assert.equal(compactBudget(100000, 50000, { retainTokens: 0 }).retain, 0);
553+
for (const retainTokens of [50000, 50001])
554+
assert.throws(() => compactBudget(100000, 50000, { retainTokens }), /must be less/);
555+
assert.throws(() => compactBudget(100000, 10000), /must be less/);
556+
assert.throws(() => compactBudget(100000, 100000, { retainTokens: 75000 }), /must be less/);
557+
});
558+
559+
test("actual usage calibrates image-heavy history downward while new images and text still count", async () => {
560+
const images = Array.from({ length: 26 }, (_, i) => ({
561+
type: "image_url",
562+
image_url: { url: `data:image/png;base64,${i}` },
563+
}));
564+
const f = fixture([{ ...message("images"), contentParams: images }], () => ({
565+
...answer(),
566+
usage: { prompt_tokens: 70106 },
567+
}));
568+
await f.run();
569+
const context = await f.internal.prepareContext(f.read(), f.settings.model, true);
570+
const measured = f.internal.measureRequest("compact-test", context.messages, f.settings.model);
571+
assert.ok(measured.estimate > 100000);
572+
assert.equal(measured.tokens, 70106);
573+
const added = { ...message("new", "user", "new observations ".repeat(1000)), contentParams: images.slice(0, 1) };
574+
f.internal.saveSessionMessages("compact-test", [...f.read(), added]);
575+
const next = await f.internal.prepareContext(f.read(), f.settings.model, true);
576+
const nextMeasurement = f.internal.measureRequest("compact-test", next.messages, f.settings.model);
577+
assert.equal(nextMeasurement.tokens, 70106 + nextMeasurement.estimate - measured.estimate);
578+
assert.ok(nextMeasurement.tokens > 70106 + 4096);
579+
f.settings.autoCompactWindow = 100000;
580+
await f.run();
581+
assert.equal(f.requests.length, 2);
582+
assert.ok(f.requests.every((request) => request.tool_choice !== "none"));
583+
});
584+
585+
test("missing or invalid actual usage falls back to estimates, while zero is valid", async () => {
586+
for (const prompt_tokens of [undefined, NaN, Infinity, -1, "4000", 0]) {
587+
const f = fixture([message("old")], () => ({ ...answer(), usage: { prompt_tokens } }));
588+
await f.run();
589+
const context = await f.internal.prepareContext(f.read(), f.settings.model, true);
590+
const measured = f.internal.measureRequest("compact-test", context.messages, f.settings.model);
591+
assert.equal(measured.tokens, prompt_tokens === 0 ? 0 : measured.estimate);
592+
}
593+
});
594+
595+
test("automatic compaction honors the configured tail budget", async () => {
596+
const retained: number[] = [];
597+
for (const retainTokens of [7000, 20000]) {
598+
const history = Array.from({ length: 10 }, (_, i) => message(`history-${i}`, "user", "x".repeat(20000)));
599+
const f = fixture(history);
600+
f.settings.contextWindow = 100000;
601+
f.settings.autoCompactWindow = 50000;
602+
f.settings.retainTokens = retainTokens;
603+
await f.run();
604+
assert.equal(f.requests.length, 2);
605+
assert.equal(f.requests[0].tool_choice, "none");
606+
retained.push(f.read().filter((m) => !m.compacted && !m.meta?.isSummary).length);
607+
}
608+
assert.deepEqual(retained, [2, 4]);
609+
});

‎packages/core/src/tests/settings-and-notify.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ test("resolveSettings parses numeric and K/M context window settings", () => {
256256
{
257257
contextWindow: " 2m ",
258258
autoCompactWindow: 300_000,
259+
retainTokens: 32000,
259260
},
260261
{ model: "default-model", baseURL: "https://default.example.com" },
261262
TEST_PROCESS_ENV
@@ -936,3 +937,36 @@ test("PLUS settings normalize subscriptionPlan and keep it separate from env", (
936937
fs.rmSync(tempDir, { recursive: true, force: true });
937938
}
938939
});
940+
941+
test("retention settings default and override as a pair across configuration sources", () => {
942+
const resolve = (user: any, project: any = {}) =>
943+
resolveSettingsSources(user, project, { model: "test", baseURL: "https://example.com" }, {});
944+
assert.equal(resolve({}).retainRatio, 0.16);
945+
assert.equal(resolve({}).retainTokens, undefined);
946+
const absolute = resolve({ retainRatio: 0.2 }, { retainTokens: 0 });
947+
assert.equal(absolute.retainTokens, 0);
948+
assert.equal(absolute.retainRatio, undefined);
949+
const ratio = resolve({ retainTokens: 1000 }, { retainRatio: 0.1 });
950+
assert.equal(ratio.retainRatio, 0.1);
951+
assert.equal(ratio.retainTokens, undefined);
952+
assert.equal(resolve({ retainTokens: 1000 }).retainTokens, 1000);
953+
assert.equal(resolve({ retainRatio: 0.2 }).retainRatio, 0.2);
954+
});
955+
956+
test("retention settings reject conflicting, invalid and oversized budgets", () => {
957+
const resolve = (user: any, project: any = {}) =>
958+
resolveSettingsSources(user, project, { model: "test", baseURL: "https://example.com" }, {});
959+
const conflict = { retainRatio: 0.16, retainTokens: 1000 };
960+
assert.throws(() => resolve(conflict, { retainTokens: 2000 }), /mutually exclusive/);
961+
assert.throws(() => resolve({}, conflict), /mutually exclusive/);
962+
for (const retainRatio of [0, -1, 1.1, Infinity, NaN, "0.16", null])
963+
assert.throws(() => resolve({ retainRatio }), /retainRatio/);
964+
for (const retainTokens of [-1, 1.5, Infinity, NaN, "32K", null])
965+
assert.throws(() => resolve({}, { retainTokens }), /retainTokens/);
966+
assert.throws(() => resolve({ contextWindow: 100000, autoCompactWindow: 10000 }), /must be less/);
967+
assert.throws(
968+
() => resolve({ contextWindow: 100000, autoCompactWindow: 50000, retainTokens: 50000 }),
969+
/must be less/
970+
);
971+
assert.throws(() => resolve({ retainRatio: 1 }), /must be less/);
972+
});

0 commit comments

Comments
 (0)