Skip to content

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

Description

@LightX001

Summary

After repeated compression of a long session, 146 messages (~1.1 MB) remained that no compression block had ever covered. The largest component is reasoning parts (50.9%), and 83.5% of that reasoning is co-resident with compress tool parts. The proximate cause is that filterProtectedToolMessages excludes messages from compression selection at message granularity while compress is force-protected: the thinking emitted by each compression operation is thereby excluded from all future compression and re-sent with every request. The non-reclaimable context floor consequently grows monotonically with the number of compressions performed.

Environment

  • opencode-acp 1.14.27. All code-level statements below were verified against both the shipped dist/index.js and the TypeScript recovered from its embedded sourcemap (75/75 lib/*.ts files byte-identical).
  • Observed session: ~1M-token context limit, 4,846 messages, 227 blocks created across the session's lifetime.

Method

Read-only forensics over opencode.db (SQLite, mode=ro) joined against the plugin registry (storage/plugin/acp/ses_*.json). A message is classified as uncovered if it appears in no block's effectiveMessageIds and has no covering entry in prune.messages.byMessageId. Part size is length(data) in bytes.

Measurements

Residual after the final compression (146 uncovered messages, 1,105,625 B):

component bytes share
reasoning parts 563,171 50.9%
compress tool parts (summaries in state.input.content, force-protected) 153,939 13.9%
skill parts 121,542 11.0%
remaining (other tools, text, step parts) ~266,000 ~24%

Attribution of the 563,171 B of reasoning by co-resident tool part:

  • 95.0% (534,833 B) resides in messages containing at least one protected tool part
  • 83.5% (446,479 B) specifically in messages containing a compress part; session-wide, 120 compress-carrying messages hold 1,130,202 B of reasoning (mean 9,418 B, max 28,067 B per message)
  • 0% attributable to the recent-tail window (no uncovered post-last-compression messages without protected parts) — the residual is structural, not ordinary recent context

Mechanism

  1. filterProtectedToolMessages removes whole messages from every compression selection (lib/compress/protected-content.ts:188-234; removedMessageIds.add(messageId) — no part-level handling). A message's reasoning is therefore exempted together with its protected tool part.
  2. compress and skill are the defaults for compress.protectedTools (config.ts:158/245), and compress is force-appended regardless of user configuration (config.ts:167/476).
  3. Exempted messages never enter byMessageId, so the pruning survival rule (lib/messages/prune.ts:60-89) never removes them, and they are included in every outgoing request while the message is in the array.
  4. Retention of the compress message itself is defensible: the live summary reaches the model only through that message (range.ts:378; wrapCompressedSummary, compress/state.ts:55). The reasoning part in the same message is not covered by that constraint — it never re-enters any selection (excluded prior to selection), and neither tier distillation nor decompress reads it (those operate on the registry and DB).

Scope limitation

The figures above are DB-level totals accumulated over the session's entire lifetime, and bound the per-request residue from above; they do not represent the size of any single outgoing request, which was substantially smaller once consumed blocks were pruned and spliced. When a block is consumed by a higher tier, hideConsumedCompressCalls drops the compress part, and messages left with only structural parts are spliced (compress/parts.ts:1-5 classifies reasoning as structural). Two subclasses have no such reclaim path: (a) the live-block window — every compression round adds an exempt message that persists until T2/T3 consumption; (b) skill-carrying messages — hideConsumedCompressCalls handles only compress parts. Exact per-request decomposition would require simulating the full pipeline and was not performed.

Related findings

  1. The displayed residual is estimated over text+tool only (estimateContextComposition, inject/utils.ts:623-666, total at :703; length/4 heuristic) and excludes reasoning; lastPerMessageNudgeTokens is a lagging snapshot. The reported percentage therefore omits the largest measured residual component.
  2. byMessageId entries whose activeBlockIds drain to empty remain permanently visible: prune.ts:62 treats "entry present, no active blocks" as survive. Observed on 8 messages / 28,338 B.
  3. rewriteCompressInput bails out conservatively when all batch siblings are consumed (hide-consumed.ts:42, kept.length === 0 → return null), leaving unmatched batch summaries in the request (~3.7 KB/block in this session).

Possible mitigation

Strip reasoning parts at request time for messages that survive solely via the protected-message exemption, excluding the last unclosed assistant turn. Providers mandate replaying thinking blocks only for the current unresolved tool round (Anthropic thinking-signature validation; Gemini thought_signature on tool calls), so gating on turn-closure state rather than provider identity covers all providers. No persistent writes required.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions