Skip to content

Commit 89e427e

Browse files
fix: protect compress tool calls from being compressed (#185)
* fix: protect compress tool calls from being compressed Sequential compressions eat previous summaries: each compress tool call (carrying the summary) falls inside the next compress range and gets pruned, destroying the accumulated summary chain. Root cause: COMPRESS_DEFAULT_PROTECTED_TOOLS was ["skill"] — compress was not in the list, so filterProtectedToolMessages never hard-excluded compress calls from compression ranges (Bug 39 mechanism). Inconsistency: DEFAULT_PROTECTED_TOOLS (commands level) included compress, but COMPRESS_DEFAULT_PROTECTED_TOOLS (compress level) did not. Fix: add "compress" to COMPRESS_DEFAULT_PROTECTED_TOOLS. One-line change. Users can opt out with compress.protectedTools: ["skill"]. Files: lib/config.ts. Tests: tests/protect-compress-calls.test.ts (6 tests). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * docs: sync stale protectedTools defaults in schema and READMEs After adding "compress" to COMPRESS_DEFAULT_PROTECTED_TOOLS, three documentation files still showed ["skill"] as the default. Updated to ["skill", "compress"] with rationale. Historical changelog entries were not modified. Files: dcp.schema.json, README.md, README.zh-CN.md Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> --------- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent be0f116 commit 89e427e

7 files changed

Lines changed: 256 additions & 10 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,9 +309,9 @@ Each level overrides the previous, so project settings take priority over global
309309
// Controls how likely compression is after user messages
310310
// ("strong" = more likely, "soft" = less likely)
311311
"nudgeForce": "soft",
312-
// Hard-excluded tool names. The root default is ["skill"]; an explicit
312+
// Hard-excluded tool names. The root default is ["skill", "compress"]; an explicit
313313
// array replaces the inherited policy. Use [] to compress all tool outputs.
314-
"protectedTools": ["skill"],
314+
"protectedTools": ["skill", "compress"],
315315
// Preserve text wrapped in <protect>...</protect> when compressed
316316
"protectTags": false,
317317
// Preserve your messages during compression.
@@ -400,7 +400,7 @@ By default, these tools are always protected from pruning:
400400

401401
The `protectedTools` arrays in `commands` and `strategies` add to this default list.
402402

403-
For the `compress` tool, `compress.protectedTools` ensures specific tool outputs are **hard-excluded** from compression ranges (v1.10.0+). When the model compresses a range that includes a protected tool message, that message survives intact in visible context — only the surrounding non-protected messages are compressed. The root default is `["skill"]`; an explicit array replaces the inherited policy. Use `[]` to allow all completed tool outputs to compress.
403+
For the `compress` tool, `compress.protectedTools` ensures specific tool outputs are **hard-excluded** from compression ranges (v1.10.0+). When the model compresses a range that includes a protected tool message, that message survives intact in visible context — only the surrounding non-protected messages are compressed. The root default is `["skill", "compress"]` (the `compress` entry protects compress tool calls — which carry summaries — from being eaten by subsequent sequential compressions); an explicit array replaces the inherited policy. Use `[]` to allow all completed tool outputs to compress.
404404

405405
---
406406

‎README.zh-CN.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,9 @@ ACP 使用自己的配置文件,按以下顺序搜索:
278278
// Controls how likely compression is after user messages
279279
// ("strong" = more likely, "soft" = less likely)
280280
"nudgeForce": "soft",
281-
// Hard-excluded tool names. The root default is ["skill"]; an explicit
281+
// Hard-excluded tool names. The root default is ["skill", "compress"]; an explicit
282282
// array replaces the inherited policy. Use [] to compress all tool outputs.
283-
"protectedTools": ["skill"],
283+
"protectedTools": ["skill", "compress"],
284284
// Preserve text wrapped in <protect>...</protect> when compressed
285285
"protectTags": false,
286286
// Preserve your messages during compression.
@@ -368,7 +368,7 @@ ACP 暴露六个可编辑的 prompt:
368368

369369
`commands` 和 `strategies` 中的 `protectedTools` 数组会添加到此默认列表。
370370

371-
对于 `compress` 工具,`compress.protectedTools` 确保特定工具的输出被**硬排除**在压缩范围之外(v1.10.0+)。当模型压缩包含受保护工具消息的范围时,该消息完整保留在可见上下文中 — 只有周围的非受保护消息被压缩。根默认值为 `["skill"]`;显式数组会替换继承的策略。使用 `[]` 可允许所有已完成工具的输出被压缩。
371+
对于 `compress` 工具,`compress.protectedTools` 确保特定工具的输出被**硬排除**在压缩范围之外(v1.10.0+)。当模型压缩包含受保护工具消息的范围时,该消息完整保留在可见上下文中 — 只有周围的非受保护消息被压缩。根默认值为 `["skill", "compress"]`(`compress` 条目保护携带 summary 的 compress 工具调用,防止被后续顺序压缩吞噬);显式数组会替换继承的策略。使用 `[]` 可允许所有已完成工具的输出被压缩。
372372

373373
---
374374

‎dcp.schema.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,8 +251,8 @@
251251
"items": {
252252
"type": "string"
253253
},
254-
"default": ["skill"],
255-
"description": "Tool names or wildcard patterns to hard-exclude from compression ranges. The root default is [\"skill\"]; an explicit array replaces the inherited policy. Use [] to allow all tool outputs to compress. Supports glob wildcards: * matches any characters, ? matches a single character (e.g., \"mcp_*\", \"my_tool_?\")"
254+
"default": ["skill", "compress"],
255+
"description": "Tool names or wildcard patterns to hard-exclude from compression ranges. The root default is [\"skill\", \"compress\"]; an explicit array replaces the inherited policy. Use [] to allow all tool outputs to compress. Supports glob wildcards: * matches any characters, ? matches a single character (e.g., \"mcp_*\", \"my_tool_?\")"
256256
},
257257
"protectTags": {
258258
"type": "boolean",
@@ -325,7 +325,7 @@
325325
"nudgeFrequency": 5,
326326
"iterationNudgeThreshold": 15,
327327
"nudgeForce": "soft",
328-
"protectedTools": ["skill"],
328+
"protectedTools": ["skill", "compress"],
329329
"protectTags": false,
330330
"protectUserMessages": false,
331331
"minNudgeContextPercent": 15,
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# REQ: Protect compress tool calls from being compressed
2+
3+
## Problem
4+
5+
When the model issues sequential compressions, each compress tool call (which
6+
carries the summary) lives a few messages after the range it compressed. The
7+
next sequential compress's range typically starts right after the previous one's
8+
end, so the previous compress call falls INSIDE the new range and gets pruned.
9+
10+
This creates a "summary-eating chain": each new compression destroys the
11+
previous compression's summary. Over multiple sequential compressions, all
12+
accumulated summaries vanish, causing catastrophic context loss.
13+
14+
Evidence from `ses_07562b88`: 113 messages compressed to 6 in one call because
15+
all previous compress call anchors (b5–b10) were inside the new range and got
16+
pruned along with their summaries.
17+
18+
## Root Cause
19+
20+
Design gap (not a bug in existing logic). `COMPRESS_DEFAULT_PROTECTED_TOOLS`
21+
was `["skill"]` — the `compress` tool was NOT in the list. The hard-exclusion
22+
mechanism (Bug 39, `filterProtectedToolMessages` in `protected-content.ts`)
23+
checks `part.tool` against the `protectedTools` list, so compress tool calls
24+
were never excluded from compression ranges.
25+
26+
Inconsistency: `DEFAULT_PROTECTED_TOOLS` (commands level, for dedup/purgeErrors)
27+
DID include `"compress"`, but `COMPRESS_DEFAULT_PROTECTED_TOOLS` (compress
28+
level, for range/message compression) did NOT.
29+
30+
## Fix
31+
32+
Add `"compress"` to `COMPRESS_DEFAULT_PROTECTED_TOOLS` in `lib/config.ts`.
33+
34+
```typescript
35+
// Before
36+
const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["skill"]
37+
38+
// After
39+
const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["skill", "compress"]
40+
```
41+
42+
This makes `filterProtectedToolMessages` hard-exclude compress tool call
43+
messages from compression ranges (Bug 39 mechanism). The compress call
44+
survives intact in visible context; only the surrounding non-protected
45+
messages are compressed.
46+
47+
## Scope
48+
49+
Emergency fix — one-line config default change. Long-term solution (e.g.,
50+
capping visible compress call count, or GC-level message summary truncation)
51+
deferred to a follow-up.
52+
53+
## Files
54+
55+
- `lib/config.ts` — one-line default change
56+
- `tests/protect-compress-calls.test.ts` — 6 tests covering the hard-exclusion
57+
58+
## Backward Compatibility
59+
60+
Users who set an explicit `compress.protectedTools` array are unaffected (the
61+
default only applies when no override is set). Users who relied on compress
62+
calls being compressible can opt out with `compress.protectedTools: ["skill"]`.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# WORKLOG: Protect compress tool calls from being compressed
2+
3+
## Investigation (Gitea #20)
4+
5+
Investigated `ses_07562b88` compression deviation. Found the root cause:
6+
sequential compressions eat previous summaries because compress tool calls
7+
fall inside the next compress range and get pruned.
8+
9+
Verified in code: `COMPRESS_DEFAULT_PROTECTED_TOOLS = ["skill"]` — `compress`
10+
not in the list. No compress-call-specific protection logic exists anywhere
11+
in `lib/compress/` or `lib/messages/`. The inconsistency between
12+
`DEFAULT_PROTECTED_TOOLS` (has `compress`) and `COMPRESS_DEFAULT_PROTECTED_TOOLS`
13+
(lacks `compress`) is the gap.
14+
15+
GC truncation was already gated behind `majorGcThresholdPercent: "100%"`
16+
(PR #161) and does not fire in normal operation — so relying on GC to trim
17+
accumulated compress calls is not viable.
18+
19+
## Implementation
20+
21+
### 1. Config fix (`lib/config.ts:132`)
22+
23+
```diff
24+
- const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["skill"]
25+
+ const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["skill", "compress"]
26+
```
27+
28+
### 2. Tests (`tests/protect-compress-calls.test.ts`)
29+
30+
6 tests covering:
31+
- `messageContainsProtectedTool` detects compress tool calls as protected
32+
- `messageContainsProtectedTool` does NOT protect when `compress` is absent (opt-out)
33+
- `messageContainsProtectedTool` does NOT protect plain text messages
34+
- `filterProtectedToolMessages` removes compress-call messages, keeps surrounding
35+
- `filterProtectedToolMessages` is a no-op when `compress` is not protected (old behavior)
36+
- `filterProtectedToolMessages` handles all-compress-call selections (empty result)
37+
38+
### 3. Documentation sync (schema + READMEs)
39+
40+
Updated stale `["skill"]` defaults to `["skill", "compress"]` in:
41+
- `dcp.schema.json` — property default (line 254), description text, and default object (line 328)
42+
- `README.md` — Default Configuration section (line 312-314) and Protected Tools explanation (line 403)
43+
- `README.zh-CN.md` — same two sections
44+
45+
Historical changelog entries were NOT modified (they record what was true at that time).
46+
47+
## Verification
48+
49+
- `npm run typecheck` — pass
50+
- `npm run test` — all tests pass including 6 new tests
51+
- `npm run build` — pass

‎lib/config.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ const DEFAULT_PROTECTED_TOOLS = [
129129
"edit",
130130
]
131131

132-
const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["skill"]
132+
const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["skill", "compress"]
133133

134134
export { VALID_CONFIG_KEYS, getInvalidConfigKeys, validateConfigTypes, type ValidationError } from "./config-validation"
135135

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Tests for protecting compress tool calls (which carry summaries) from being
3+
* included in subsequent compression ranges.
4+
*
5+
* Background: compress tool calls live inside assistant messages a few positions
6+
* after the range they compressed. When the model issues a new sequential
7+
* compress whose range starts right after the previous one's end, the previous
8+
* compress call falls inside the new range and gets pruned — destroying the
9+
* accumulated summary chain. Adding "compress" to the default protectedTools
10+
* (COMPRESS_DEFAULT_PROTECTED_TOOLS) makes filterProtectedToolMessages
11+
* hard-exclude those messages (Bug 39 mechanism), so summaries survive.
12+
*/
13+
import assert from "node:assert/strict"
14+
import test from "node:test"
15+
import { messageContainsProtectedTool, filterProtectedToolMessages } from "../lib/compress/protected-content"
16+
import type { SelectionResolution, SearchContext } from "../lib/compress/types"
17+
import type { WithParts } from "../lib/state"
18+
19+
const DEFAULT_PROTECTED = ["skill", "compress"]
20+
21+
function makeCompressCallPart(callID: string, summary: string) {
22+
return {
23+
type: "tool" as const,
24+
callID,
25+
tool: "compress",
26+
state: {
27+
status: "completed" as const,
28+
input: { content: [{ startId: "m00001", endId: "m00010", summary }] },
29+
output: "compressed",
30+
},
31+
}
32+
}
33+
34+
function makeTextPart(id: string, text: string) {
35+
return { type: "text" as const, id, text }
36+
}
37+
38+
function makeMessage(id: string, role: "user" | "assistant", parts: any[]): WithParts {
39+
return {
40+
info: { id, role, sessionID: "ses-test", time: { created: 1 } } as any,
41+
parts,
42+
}
43+
}
44+
45+
function makeSearchContext(messages: WithParts[]): SearchContext {
46+
const rawMessagesById = new Map<string, WithParts>()
47+
const rawIndexById = new Map<string, number>()
48+
messages.forEach((m, i) => {
49+
rawMessagesById.set(m.info.id, m)
50+
rawIndexById.set(m.info.id, i)
51+
})
52+
return {
53+
rawMessages: messages,
54+
rawMessagesById,
55+
rawIndexById,
56+
summaryByBlockId: new Map(),
57+
}
58+
}
59+
60+
function makeSelection(messageIds: string[]): SelectionResolution {
61+
return {
62+
startReference: { kind: "message", rawIndex: 0, messageId: messageIds[0] },
63+
endReference: { kind: "message", rawIndex: messageIds.length - 1, messageId: messageIds[messageIds.length - 1] },
64+
messageIds,
65+
messageTokenById: new Map(messageIds.map((id) => [id, 100])),
66+
toolIds: [],
67+
requiredBlockIds: [],
68+
}
69+
}
70+
71+
test("messageContainsProtectedTool: compress tool call is protected when 'compress' is in the list", () => {
72+
const msg = makeMessage("msg-compress-call", "assistant", [
73+
makeTextPart("p1", "Let me compress the earlier findings."),
74+
makeCompressCallPart("call-1", "Summary of earlier work..."),
75+
])
76+
assert.equal(messageContainsProtectedTool(msg, DEFAULT_PROTECTED, []), true)
77+
})
78+
79+
test("messageContainsProtectedTool: compress tool call is NOT protected when 'compress' is absent (opt-out)", () => {
80+
const msg = makeMessage("msg-compress-call", "assistant", [
81+
makeCompressCallPart("call-1", "Summary of earlier work..."),
82+
])
83+
assert.equal(messageContainsProtectedTool(msg, ["skill"], []), false)
84+
assert.equal(messageContainsProtectedTool(msg, [], []), false)
85+
})
86+
87+
test("messageContainsProtectedTool: plain text message is never protected", () => {
88+
const msg = makeMessage("msg-text", "user", [makeTextPart("p1", "Hello world")])
89+
assert.equal(messageContainsProtectedTool(msg, DEFAULT_PROTECTED, []), false)
90+
})
91+
92+
test("filterProtectedToolMessages: removes compress-call message from selection, keeps surrounding messages", () => {
93+
const compressMsg = makeMessage("msg-compress", "assistant", [
94+
makeTextPart("p1", "Compressing now."),
95+
makeCompressCallPart("call-compress", "Previous summary content..."),
96+
])
97+
const plainMsg1 = makeMessage("msg-plain-1", "user", [makeTextPart("p2", "User question")])
98+
const plainMsg2 = makeMessage("msg-plain-2", "assistant", [makeTextPart("p3", "Assistant answer")])
99+
100+
const ctx = makeSearchContext([plainMsg1, compressMsg, plainMsg2])
101+
const selection = makeSelection(["msg-plain-1", "msg-compress", "msg-plain-2"])
102+
103+
const result = filterProtectedToolMessages(selection, ctx, DEFAULT_PROTECTED, [])
104+
105+
assert.deepEqual(result.messageIds, ["msg-plain-1", "msg-plain-2"])
106+
assert.equal(result.messageTokenById.size, 2)
107+
assert.ok(result.messageTokenById.has("msg-plain-1"))
108+
assert.ok(result.messageTokenById.has("msg-plain-2"))
109+
assert.ok(!result.messageTokenById.has("msg-compress"))
110+
})
111+
112+
test("filterProtectedToolMessages: no-op when 'compress' is not in protectedTools (old behavior)", () => {
113+
const compressMsg = makeMessage("msg-compress", "assistant", [
114+
makeCompressCallPart("call-1", "Summary..."),
115+
])
116+
const ctx = makeSearchContext([compressMsg])
117+
const selection = makeSelection(["msg-compress"])
118+
119+
const result = filterProtectedToolMessages(selection, ctx, ["skill"], [])
120+
assert.deepEqual(result.messageIds, ["msg-compress"])
121+
})
122+
123+
test("filterProtectedToolMessages: all-compress-call selection becomes empty (all excluded)", () => {
124+
const msg1 = makeMessage("msg-c1", "assistant", [makeCompressCallPart("c1", "Summary A")])
125+
const msg2 = makeMessage("msg-c2", "assistant", [makeCompressCallPart("c2", "Summary B")])
126+
127+
const ctx = makeSearchContext([msg1, msg2])
128+
const selection = makeSelection(["msg-c1", "msg-c2"])
129+
130+
const result = filterProtectedToolMessages(selection, ctx, DEFAULT_PROTECTED, [])
131+
assert.equal(result.messageIds.length, 0)
132+
assert.equal(result.messageTokenById.size, 0)
133+
})

0 commit comments

Comments
 (0)