Skip to content

feat: strip reasoning from protected-exempt historical messages (gated) - #370

Closed
ranxianglei wants to merge 4 commits into
masterfrom
2026-09-07_strip-protected-reasoning
Closed

feat: strip reasoning from protected-exempt historical messages (gated)#370
ranxianglei wants to merge 4 commits into
masterfrom
2026-09-07_strip-protected-reasoning

Conversation

@ranxianglei

@ranxianglei ranxianglei commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Problem

On long sessions, reasoning parts that ride on compress/skill-carrying assistant messages form a permanently-incompressible context floor. Protection is message-granular (lib/compress/protected-content.ts:202), so the whole message — reasoning included — is excluded from every compression and re-sent on every turn. Each compression round adds ~9 KB of unreclaimable reasoning; 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.

Solution

A single request-time pass, stripProtectedReasoning, that drops the reasoning parts from protected-exempt messages in closed historical turns before the request is sent. The current (possibly-open) round is always preserved. No persisted-state or internal-tag changes; no DB writes.

Five gates (request-level gates 4–5 run first; all must hold to strip)

  1. Provider allowlist (added by review, 2026-09-08)compress.stripProtectedReasoningProviders (default ["anthropic","gemini"], "*" = all, case-insensitive substring). Fail-closed: unknown or undefined provider → nothing is stripped. Rationale: GPT-family gateways may reject requests whose historical thinking blocks are incomplete.
  2. Session activation (added by review)compress.stripProtectedReasoningMinMessages (default 100, 0 = always). Below it the pass is a byte-stable no-op, so short sessions never pay prefix-cache churn.
  3. Turn-closure — message index strictly < lastUserIndex (index of getLastUserMessage). The active round is never touched.
  4. Selector — the message contains a tool part whose part.toolconfig.compress.protectedTools (compress/skill).
  5. Size — total reasoning length > threshold (default 0 — review found per-message size is cache-noise: invalidation propagates from the first stripped message, so the activation gate is the real cache lever).

Action: msg.parts = parts.filter(p => p.type !== "reasoning") — drops reasoning only; the tool call + other parts are preserved (the live summary lives in the compress-call body, not the reasoning).

Placement

Inserted after hideConsumedCompressCalls and before assignMessageRefs in lib/hooks.ts — operates on the minimal surviving set (reasoning-only leftovers are already spliced there, since reasoning is "structural" per lib/compress/parts.ts:1).

Config (additive, safe defaults)

  • compress.stripProtectedReasoning (bool, default true) — kill-switch.
  • compress.stripProtectedReasoningThreshold (number, default 0) — size threshold in chars.
  • compress.stripProtectedReasoningProviders (string[], default ["anthropic","gemini"]; "*" = all; explicit [] = strip for no provider) — added by review.
  • compress.stripProtectedReasoningMinMessages (number, default 100; 0 = always) — added by review.
  • All registered in VALID_CONFIG_KEYS + validateConfigTypes; excluded from CompressOverridableConfig (global-only).

Files

  • lib/messages/reasoning-strip.ts — the pass, now with 5 gates + optional options param (omitted = ungated for pure-function callers).
  • lib/hooks.ts — passes state.modelProviderID + config gates into the call.
  • lib/config.ts — 4 config keys (interface + defaults + merge + CompressOverridableConfig exclusion).
  • lib/config-validation.tsVALID_CONFIG_KEYS + validateConfigTypes (incl. string[] element validation).
  • dcp.schema.json — schema properties + defaults.
  • tests/reasoning-strip.test.ts — 18 pass tests + 5 merge tests + 14 gate tests.
  • tests/e2e-message-transform.test.ts — hook-level kill-switch, provider-gate (fail-closed), and activation-gate tests.
  • devlog/2026-09-07_strip-protected-reasoning/ — REQ / DESIGN / WORKLOG (review updates recorded).

Testing

  • 1124/1124 tests pass (was 1096 at PR open; +28 across the review rounds). Typecheck clean, build clean.
  • Mutation-verified (§5.7.3): disabling the provider gate → 5 tests fail; disabling the activation gate → 2 tests fail; dropping the hook options object → 2 e2e tests fail; dropping the hook ?? fallbacks → 1 e2e test fails (each verified by temporarily mutating the code, then restoring).

Review

  • Original implementation: dual-agent review, both REQUEST-CHANGES; all findings addressed.
  • 2026-09-08 review session (issue Protected-message exemption inflates the incompressible context floor: per-compression reasoning is never reclaimable (~83.5% of measured residual) #368 re-review of this PR): recommended the provider allowlist + activation gate + threshold-0 changes now in this PR; owner approved direct application ("直接修改pr").
  • Second dual-agent review of the additions (source + test focus): both APPROVE, all MINOR/NIT findings addressed — request-level provider resolution (requestModel?.providerID ?? state.modelProviderID, fixes undefined-provider on a fresh session's first request), allowlist entry trimming, 9 new config-validation tests (incl. fractional minMessages now rejected — Number.isInteger), hook-fallback e2e test (mutation-verified), tight boundary + presence assertions. One pre-existing follow-up noted: e2e buildConfig is structurally incomplete (tests are excluded from typecheck).

Secondary findings (filed as separate issues)

Fixes #368

Request-time pass (stripProtectedReasoning) that reclaims the
never-compressible reasoning floor: reasoning parts on compress/skill
assistant messages in CLOSED historical turns are dropped before the
request is sent, while the current (possibly-open) round is always
preserved.

- lib/messages/reasoning-strip.ts: new pass, 3 gates (turn-closure via
  getLastUserMessage, protected-tool selector, size threshold > 2048 chars)
- lib/hooks.ts: wired after hideConsumedCompressCalls, guarded by kill-switch
- lib/config.ts: compress.stripProtectedReasoning (bool, default true) +
  compress.stripProtectedReasoningThreshold (number, default 2048); excluded
  from CompressOverridableConfig (global-only)
- lib/config-validation.ts: registered both keys in VALID_CONFIG_KEYS +
  validateConfigTypes
- dcp.schema.json: schema properties + default
- tests: +19 tests (unit + hook-level kill-switch, mutation-verified)

No provider gate (owner decision: handle reactively). No persisted-state or
internal-tag changes. 1096/1096 tests pass.

Fixes #368
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

📦 Built Plugin Artifact

Branch: 2026-09-07_strip-protected-reasoning (70c21ba)

Option A — Install from npm PR tag (recommended)

opencode plugin opencode-acp@pr-370 --global

Each push to this PR publishes a new version under the pr-370 npm tag.

Option B — Install from GitHub

opencode plugin "github:ranxianglei/opencode-acp#2026-09-07_strip-protected-reasoning" --global

Option C — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf opencode-acp-pr370.tgz
cp -r package/dist ~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/dist
  1. Restart opencode to pick up changes.

This comment is automatically updated on each push.

…ze (review #368)

Per the 2026-09-08 review session (owner-approved), three changes:

- Provider allowlist gate (fail-closed): stripProtectedReasoningProviders
  (default [anthropic, gemini], '*' = all, case-insensitive substring,
  entries trimmed; undefined/unmatched provider strips nothing).
  hooks.ts resolves the CURRENT request's provider via
  requestModel?.providerID ?? state.modelProviderID (requestModel hoisted
  from the last user message's info.model).
- Session activation gate: stripProtectedReasoningMinMessages (default
  100, integer >= 0; 0 = always) — short sessions keep a byte-stable
  prefix.
- Threshold default 2048 -> 0 (size is cache-noise; activation gate is
  the cache lever).

Tests: 1124 pass (was 1112). Mutations verified: provider gate (5 fails),
activation gate (2), hook options dropped (2), hook fallbacks dropped (1).
Review round 2: dual-agent APPROVE; findings addressed (trim, config
validation incl. Number.isInteger, hook-fallback e2e, tight boundaries).
@ranxianglei ranxianglei changed the title feat: strip reasoning from protected-exempt historical messages feat: strip reasoning from protected-exempt historical messages (gated) Sep 9, 2026
Adds the 4 new compress.* keys to CONFIGURATION.md / CONFIGURATION.zh-CN.md
(reference sections) and README.md / README.zh-CN.md (example config blocks):
stripProtectedReasoning, ...Threshold, ...Providers, ...MinMessages.
@ranxianglei

Copy link
Copy Markdown
Owner Author

Superseded: owner redesign — the allowlist/activation-gate approach is replaced by a clean reimplementation: nested compress.reasoning { drop, threshold } config with the existing three-level provider/model cascade (#344), selector narrowed to compress tool calls only, single-thinking-size threshold. New PR incoming (refs #368).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Protected-message exemption inflates the incompressible context floor: per-compression reasoning is never reclaimable (~83.5% of measured residual)

1 participant