Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,30 @@ In this example, for `anthropic/claude-sonnet-4-6`: the floor is 30%, the over-m
- **Status:** ACTIVE
- **Description:** Always protect the most recent user message from compression, regardless of `preserveRecentMessages` or `preserveRecentTokens`.

#### `compress.stripProtectedReasoning`
- **Type:** `boolean`
- **Default:** `true`
- **Status:** ACTIVE
- **Description:** Kill-switch for closed-turn thinking stripping (#368). Protected-exempt messages (e.g. `compress`/`skill` tool calls) are excluded from compression at message granularity, so their `reasoning` parts are re-sent on every request and form a permanently incompressible floor. When enabled (default), ACP drops the `reasoning` parts from those messages in **closed historical turns** (strictly before the last genuine user message) at request time. The active round is never touched; no persisted state is modified. Set to `false` to disable entirely.

#### `compress.stripProtectedReasoningThreshold`
- **Type:** `number`
- **Default:** `0`
- **Status:** ACTIVE
- **Description:** Minimum total `reasoning` length (chars) on a protected-exempt historical message before its reasoning is stripped. `0` (default) strips regardless of size — per-message size is prefix-cache noise (invalidation propagates from the first stripped message); cache stability is controlled by `stripProtectedReasoningMinMessages` instead.

#### `compress.stripProtectedReasoningProviders`
- **Type:** `string[]`
- **Default:** `["anthropic", "gemini"]`
- **Status:** ACTIVE
- **Description:** Provider allowlist for closed-turn thinking stripping (case-insensitive substring match on the provider id; `"*"` = all providers; entries are trimmed). **Fail-closed**: an unknown, undefined, or non-matching provider strips nothing, as does an explicit `[]` (strip for no provider). Closed-turn thinking stripping is only documented-safe for Anthropic/Gemini; some GPT-family gateways reject requests whose historical thinking blocks are incomplete.

#### `compress.stripProtectedReasoningMinMessages`
- **Type:** `integer` (≥ 0)
- **Default:** `100`
- **Status:** ACTIVE
- **Description:** Activation gate: closed-turn thinking stripping only runs when the request carries at least this many messages. Short sessions keep a byte-stable prefix (no cache churn); the reclaimed floor only matters on long sessions. `0` disables the gate. Fractional values are rejected by validation.

---

### `gc` (Generation & Cleanup)
Expand Down
24 changes: 24 additions & 0 deletions CONFIGURATION.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,30 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的):
- **状态:** ACTIVE
- **说明:** 始终保护最近一条用户消息不被压缩,无论 `preserveRecentMessages` 或 `preserveRecentTokens` 如何设置。

#### `compress.stripProtectedReasoning`
- **类型:** `boolean`
- **默认值:** `true`
- **状态:** ACTIVE
- **说明:** 闭轮思考剥离的总开关(#368)。受保护豁免消息(如 `compress`/`skill` 工具调用)以消息粒度被排除在压缩之外,其 `reasoning` 部分随每轮请求重复发送,形成永久不可压缩的上下文底座。启用时(默认),ACP 在请求时丢弃这些消息在**已关闭历史轮次**中(严格位于最后一条真实用户消息之前)的 `reasoning` 部分。当前活跃轮次永不受影响;不修改任何持久化状态。设为 `false` 可完全禁用。

#### `compress.stripProtectedReasoningThreshold`
- **类型:** `number`
- **默认值:** `0`
- **状态:** ACTIVE
- **说明:** 受保护豁免历史消息上 `reasoning` 总长度(字符)达到该阈值后才剥离。默认 `0` 表示无论大小都剥离 —— 单条消息的大小对前缀缓存只是噪声(失效会从第一条被改写的消息向后传播);缓存稳定性由 `stripProtectedReasoningMinMessages` 控制。

#### `compress.stripProtectedReasoningProviders`
- **类型:** `string[]`
- **默认值:** `["anthropic", "gemini"]`
- **状态:** ACTIVE
- **说明:** 闭轮思考剥离的提供方白名单(对 provider id 大小写不敏感的子串匹配;`"*"` = 所有提供方;条目会做 trim)。**失败关闭(fail-closed)**:未知、未定义或不匹配的提供方一律不剥离;显式 `[]` 同样不剥离任何提供方。闭轮思考剥离仅在 Anthropic/Gemini 上验证过安全性;部分 GPT 系网关会拒绝历史思考块不完整的请求。

#### `compress.stripProtectedReasoningMinMessages`
- **类型:** `integer`(≥ 0)
- **默认值:** `100`
- **状态:** ACTIVE
- **说明:** 激活门:仅当请求携带的消息数达到该值时才运行闭轮思考剥离。短会话保持字节稳定的前缀(无缓存扰动);被回收的底座只在长会话中才有意义。`0` 禁用该门。小数会被校验拒绝。

---

### `gc`(生成与清理)
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,13 @@ Each level overrides the previous, so project settings take priority over global
// Preserve your messages during compression.
// Warning: large copy-pasted prompts will never be compressed away
"protectUserMessages": false,
// Closed-turn thinking stripping on protected-exempt messages (#368).
// "reasoning" parts of compress/skill tool calls in closed historical
// turns are dropped at request time; the active turn is never touched.
"stripProtectedReasoning": true,
"stripProtectedReasoningThreshold": 0,
"stripProtectedReasoningProviders": ["anthropic", "gemini"],
"stripProtectedReasoningMinMessages": 100
},
// Garbage collection — hardcoded 100% fallback only
"gc": {
Expand Down
7 changes: 7 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,13 @@ ACP 使用自己的配置文件,按以下顺序搜索:
// Preserve your messages during compression.
// Warning: large copy-pasted prompts will never be compressed away
"protectUserMessages": false,
// 受保护豁免消息的闭轮思考剥离(#368)。
// 请求时丢弃已关闭历史轮次中 compress/skill 工具调用的
// "reasoning" 部分;当前活跃轮次永不受影响。
"stripProtectedReasoning": true,
"stripProtectedReasoningThreshold": 0,
"stripProtectedReasoningProviders": ["anthropic", "gemini"],
"stripProtectedReasoningMinMessages": 100
},
// 垃圾回收与批量清理
"gc": {
Expand Down
29 changes: 28 additions & 1 deletion dcp.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,29 @@
"type": "boolean",
"default": true,
"description": "Always protect the most recent user message from compression."
},
"stripProtectedReasoning": {
"type": "boolean",
"default": true,
"description": "Strip reasoning parts from protected-exempt (compress/skill) messages in closed historical turns. The current (possibly-open) round is never touched."
},
"stripProtectedReasoningThreshold": {
"type": "number",
"default": 0,
"minimum": 0,
"description": "Minimum total reasoning length (chars) on a protected-exempt historical message before its reasoning is stripped. Default 0 strips regardless of size; cache protection comes from stripProtectedReasoningMinMessages."
},
"stripProtectedReasoningProviders": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"default": ["anthropic", "gemini"],
"description": "Provider allowlist for stripProtectedReasoning (case-insensitive substring match on the provider id; '*' = all providers). Fail-closed: unknown provider or empty list strips nothing. Closed-turn thinking stripping is only documented-safe for Anthropic/Gemini; some GPT-family gateways reject incomplete historical thinking."
},
"stripProtectedReasoningMinMessages": {
"type": "integer",
"default": 100,
"minimum": 0,
"description": "Activation gate: stripProtectedReasoning only runs when the request carries at least this many messages. Short sessions keep a byte-stable prefix; the reclaimed floor only matters on long sessions. 0 disables the gate."
}
},
"default": {
Expand All @@ -592,7 +615,11 @@
"lastSegmentSoftBlock": true,
"preserveRecentMessages": 20,
"preserveRecentTokens": 20000,
"preserveLastUserMessage": true
"preserveLastUserMessage": true,
"stripProtectedReasoning": true,
"stripProtectedReasoningThreshold": 0,
"stripProtectedReasoningProviders": ["anthropic", "gemini"],
"stripProtectedReasoningMinMessages": 100
}
},
"gc": {
Expand Down
113 changes: 113 additions & 0 deletions devlog/2026-09-07_strip-protected-reasoning/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# DESIGN - Strip reasoning from protected-exempt historical messages

- Task ID: `2026-09-07_strip-protected-reasoning`
- Home Repo: `opencode-acp`
- Created: 2026-09-07
- Status: Final (updated 2026-09-08 per review: **provider allowlist gate + activation gate added, threshold default 0** — see §8)

## 1. Problem Statement

- **What problem are we solving?** The `reasoning` parts on `compress`/`skill`-carrying assistant messages form a permanently-incompressible context floor: protection is message-granular, so the whole message (reasoning included) is excluded from compression and re-sent every turn. Each round adds ~9 KB of unreclaimable reasoning.
- **Why now?** Measured ~83.5% of the never-covered residual in a real long session (#368); it is a monotonic feedback loop that degrades long-session usability.

## 2. Goals & Non-Goals

- **Goals**:
- Reclaim the reasoning floor at request time with zero loss of user-visible / compression-critical data.
- Never break providers that require reasoning replay — enforced by the **provider allowlist gate** (fail-closed; review update 2026-09-08) + the **turn-closure gate** (the active round is always preserved).
- Keep the sent prefix cache-stable within a turn.
- **Non-Goals**:
- Part-granular protection rework (separate effort).
- The three secondary findings A/B/C (separate issues).
- Any persistent/DB write.

## 3. Current Architecture

The `experimental.chat.messages.transform` pipeline (`lib/hooks.ts`) runs, in order (relevant span):

```
:256 prune()
:257 truncateLargeToolOutputs()
:258 hideConsumedCompressCalls() ← splices reasoning-only leftovers (reasoning is "structural")
:259 assignMessageRefs()
:262 injectCompressNudges(prePruneTokens)
:290 injectMessageIds()
:291 hideFailedCompressCalls()
:292 stripStaleMetadata()
:293 dropEmptyMessages()
:294 postTokens
```

Key facts (code-verified @ v1.14.27):
- `filterProtectedToolMessages` (`lib/compress/protected-content.ts:188-234`, `:202`) excludes the **whole** message (reasoning included) from every selection.
- `compress`/`skill` are default `compress.protectedTools` (`lib/config.ts:158`); `compress` is in `FORCE_COMPRESS_PROTECTED` (`:167`, force-appended `:476`).
- Excluded messages never enter `byMessageId`, so `prune` (`lib/messages/prune.ts:60-66`) re-sends them every turn.
- `lib/compress/parts.ts:1` `STRUCTURAL_PART_TYPES = ["step-start","step-finish","reasoning"]` → `hasMeaningfulContent()` is false on a reasoning-only leftover, so `hideConsumedCompressCalls` (`lib/compress/hide-consumed.ts:121-124`) **splices** consumed-block messages whose only remaining content is reasoning. **Therefore the only reasoning with no reclaim path is on (a) live-block compress calls and (b) skill-carrying messages** — exactly the floor this pass targets.
- `getLastUserMessage` (`lib/messages/query.ts:10`) returns the last user-role message that is **not** synthetic and **not** all-`ignored` (tool-result user msgs are ignored) — i.e. the last **genuine** user input. This is the turn boundary and is the same mechanism `stripStaleMetadata` already relies on.

## 4. Proposed Architecture

- **Overview**: A single request-time pass inserted **after** `hideConsumedCompressCalls` (operates on the minimal surviving set; never touches about-to-be-spliced messages) and **before** `assignMessageRefs`.

```
# request-level gates (evaluated once per request, before per-message iteration):
# Gate 0: provider allowlist — FAIL-CLOSED. allowedProviders !== undefined, "*" not in it,
# and providerID undefined or not substring-matched → strip nothing.
# Gate 0.5: session activation — minMessages > 0 and messages.length < minMessages → strip nothing.

for each assistant message m at index i:
if i >= lastGenuineUserIndex: # Gate 1: current open round → KEEP
continue
if not hasProtectedToolPart(m): # Gate 2: selector = protected tool call (compress/skill)
continue
if reasoningLength(m) <= threshold: # Gate 3: size threshold (default 0 = strip all sizes)
continue
m.parts = m.parts.filter(p => p.type !== "reasoning") # drop reasoning parts only, keep tool call
```

- **Key components**:
- **Pass function** in `lib/messages/reasoning-strip.ts` (new export, name distinct from `stripStaleMetadata`; e.g. `stripProtectedReasoning`).
- **Gate 1 — turn-closure**: `lastGenuineUserIndex = index of getLastUserMessage(messages)`. All assistant messages at/after it are the current (possibly-open) round → reasoning kept. Only messages strictly before are candidates. If `getLastUserMessage` returns `null` → strip nothing (fail-safe).
- **Gate 2 — selector** (narrow, per operator): `m` contains a **protected tool part** (`compress`/`skill`). Only these are the "floor" — normal historical messages' reasoning is already reclaimed by compression, so we do **not** target arbitrary large-reasoning messages. (Simplified from the earlier "compress part OR all-non-structural-are-protected" predicate; confirm with owner.)
- **Gate 3 — size threshold** (default `0` per review 2026-09-08): only strip when the message's total `reasoning` content length **exceeds the threshold**. Review rationale for 0: prefix-cache invalidation propagates from the **first divergent message** — as soon as one message in a turn is stripped, everything after it re-caches, so sparing small messages saves nothing once any large one is stripped. The measured mean is 9,418 B (max 28,067 B), far above any sensible threshold, so the gate is kept only as an operator knob; the **activation gate (Gate 5) is the cache lever**.
- **Gate 4 — provider allowlist (ADDED per review 2026-09-08).** `compress.stripProtectedReasoningProviders: string[]`, default `["anthropic","gemini"]`; `"*"` = all providers. Matching is case-insensitive substring (`providerID.toLowerCase().includes(entry.toLowerCase())`). **Fail-closed**: `modelProviderID` undefined (older opencode builds / missing model info) or unmatched → the whole pass is a no-op, because GPT-family gateways may reject incomplete historical thinking. The call site passes `state.modelProviderID` (`lib/hooks.ts`). Explicit `[]` = strip for no provider (full opt-out at the strip level, distinct from the kill-switch which disables the pass entirely).
- **Gate 5 — session activation (ADDED per review 2026-09-08).** `compress.stripProtectedReasoningMinMessages: number`, default `100`; `0` = always active. Below the threshold the pass is a byte-stable no-op, so short sessions (where the floor does not matter and any prefix churn is pure cost) never pay cache invalidation. This absorbs the issue-thread "turn-count-gated handling of ancient content" idea, scoped to the strip pass instead of compression itself.
- **Action**: rebuild `m.parts` without `reasoning` parts (keep the tool call + any other non-reasoning parts). Message `info.id`/order unchanged → no effect on `mNNNNN` refs or downstream passes.
- **Data flow**: pure in-memory mutation of the per-request array. **No state/DB writes.** Deterministic → idempotent.
- **API / interface changes**: new config keys under `compress.*` (see §4 of REQ). No change to persisted state format, exported tool APIs, or internal `dcp` tags.

## 5. Design Decisions & Rationale

| Decision | Options Considered | Chosen | Why |
|----------|--------------------|--------|-----|
| Gate axis | (a) provider-only; (b) turn-closure; (c) both | **(b) turn-closure** (+ provider gate §8) | Turn-closure keeps the mitigation active on high-thinking models while never touching the open round (the only case where replay actually matters). Provider-only would forfeit the biggest contributor. |
| Placement | before `hideConsumedCompressCalls`; after it | **after** (`:258`→`:259`) | Operates on the minimal surviving set; never processes messages about to be spliced. |
| What to strip | whole message; reasoning parts only | **reasoning parts only** | Dropping the whole message kills the live summary (it lives only in the compress-call body, `state.ts:55-63`). Reasoning has no value once the summary is finalized. |
| Predicate scope | any protected msg; only protected-exempt msgs | **only protected-exempt** (compress part, or all-non-structural-are-protected) | Narrow; never touches user-visible text or normal messages. |
| Provider policy | allowlist; blocklist; none | **allowlist, fail-closed** (review 2026-09-08) | Owner initially chose none ("provider 先不管 有问题再说"); the PR #370 review showed GPT-family gateways may 400 on incomplete historical thinking and recommended fail-closed `["anthropic","gemini"]` + `"*"` escape hatch; owner approved direct application ("直接修改pr"). |
| Naming | `stripExemptReasoning`; other | **distinct from `stripStaleMetadata`** | Avoids conceptual collision in `reasoning-strip.ts`. |
| Strip trigger | uniform; size-gated; session-activation-gated | **session-activation-gated** (`minMessages: 100`) + size threshold default 0 | Review: per-message size is cache-noise (invalidation propagates from the first stripped message); a request-level activation gate bounds churn to sessions large enough to have a floor. |

## 6. Impact Analysis

- **Backward compatibility**: additive config with safe defaults; no persisted-state or internal-tag changes.
- **Performance**: one O(n) pass per request (n = message count); negligible vs existing pipeline steps.
- **Cache**: prefix is byte-identical within a turn (strip set is fixed by the stable `lastGenuineUserIndex`). Invalidation is bounded to (a) turn-boundary shifts (≈ one prior turn's messages) and (b) a one-time rebuild on first enablement.
- **Security**: none (no new network/credential surface).
- **Dependencies**: none new.

## 7. Migration Plan

- **Steps**:
1) Ship the pass + config behind the kill-switch.
2) Default per owner decision (§8).
- **Feature flags / gradual rollout**: `compress.stripProtectedReasoning` (kill-switch) + provider-policy key. Can ship default-off (opt-in) if the owner prefers a burn-in release (the `qualityGate` precedent).

## 8. Open Questions (RESOLVED — owner decision 2026-09-07)

- [x] **Provider policy** — ~~none~~ → **allowlist, fail-closed** (updated 2026-09-08 review session; owner: "直接修改pr"). `["anthropic","gemini"]` default, `"*"` = all, case-insensitive substring; undefined/unmatched provider → no-op.
- [x] **Size-threshold default + unit** — ~~2048~~ → **0 chars** (updated 2026-09-08 review: cache-noise; activation gate is the lever). Unit = characters (matches `part.text.length`; cheap, no tokenizer).
- [x] **Session activation** — **ADDED 2026-09-08 review**: `stripProtectedReasoningMinMessages: 100` (0 = always). Small sessions keep a byte-stable prefix.
- [x] **Selector** — target = protected tool-call messages (`compress`/`skill`), NOT all large-reasoning messages. Confirmed.
- [x] **Default on/off** — **default-on** with the global kill-switch `stripProtectedReasoning: false`.
- [x] **Provider-ID matching** — case-insensitive substring against allowlist entries; `"*"` short-circuits.
Loading
Loading