Files
my-pi/pi-condense/doc/specs/2026-08-05-remove-thinking-strip.md
T

146 lines
14 KiB
Markdown

# Remove the thinking strip
Supersedes: [doc/specs/2026-08-04-thinking-strip-flush-gated.md](./2026-08-04-thinking-strip-flush-gated.md) - fully.
## Context
`thinkingStrip` removes `thinking` blocks from older assistant turns at render time, keeping them on the last `keepLastTurns` (default 16). It has shipped since v0.13.0; v2.4.3 made its boundary flush-gated to stop a per-render cache break.
The feature was designed against an assumed API model: that thinking blocks we send are thinking blocks the model reads and we are billed for, so removing old ones saves tokens. That model is wrong. This spec records the measurements that disprove it and removes the feature.
## Problem
Anthropic's actual handling of `thinking` blocks, verified against the live API (`count_tokens` and real billed usage agree to the token):
| regime | billed input tokens | probe |
|---|---|---|
| thinking in a **closed** cycle (a user text turn has since occurred) | 0 | `withThinking=566 stripped=566 delta=0` |
| thinking in an **open** cycle, contiguous from the cycle's first assistant turn | billed in full | `first 4` bills 4 blocks, `ALL 0-7` bills 8 |
| thinking in an open cycle **after any gap** | 0 | `last 4 (4-7)` bills 0, `middle (3-4)` bills 0 |
Three consequences, each independently fatal to the feature:
**1. It strips what is already free.** Closed-cycle thinking costs zero input tokens and is dropped from the context window before the model attends to it. Stripping it saves nothing and changes nothing the model sees.
**2. It delivers no thinking to the model, and pays for the privilege.** In an open cycle, retention is a *prefix* property: only the unbroken run starting at the cycle's first assistant turn survives. `keepLastTurns` keeps the *last* K, so the moment it fires it punches a gap at the front and Anthropic discards every block after it - including the K it just preserved. The model receives zero thinking either way, and the transition from no-gap to gap is a full cache invalidation.
Measured in a controlled A/B (24 sequential file reads, isolated `PI_CODING_AGENT_DIR`, identical prompts):
```
BASE (bare pi, no extension) cacheWrite= 89449 cost=$0.6495
NOSTRIP (pi-condense, thinkingStrip disabled) cacheWrite= 87781 cost=$0.6430
COND (pi-condense, defaults) cacheWrite=148222 cost=$0.8531 +33%
```
`COND`'s only non-warmup break is turn 17 - the first turn past `keepLastTurns=16` - which rewrote 63113 tokens at 0% cache reuse. `NOSTRIP` has no extra breaks over `BASE`.
Direct API confirmation that gap *creation* is the invalidating event, and that moving an existing gap is free:
```
req2 a1=think (no gap) write=5004 read=0 prime
req3 a1=strip a2=think (GAP) write=3485 read=0 full miss
req3 a1+a2=think (no gap) write=1633 read=5004 hit
```
**3. The economics can never work.** Retained thinking bills at cache-read rates ($0.30/Mtok); removing it costs a cache write ($3.75/Mtok). The 12.5x asymmetry means a strip only pays back if the same cycle continues for roughly another 130 turns. Across 58 gridstrong sessions (1233 open cycles):
| | |
|---|---|
| median cycle length | 3 assistant turns |
| cycles > 16 turns (strip fires) | 253 (20.5%) |
| cycles > ~130 turns (strip breaks even) | **2 (0.2%)** |
| estimated strip cost across the archive | ~$95 of $2564 total spend |
The feature is net-negative in roughly 250 of its 253 firings.
**4. It is redundant with chain compression.** `applyChainCompressions` in `src/chain-range-prune.ts` emits the synthetic chain block as `role: "user"` with a `type: "text"` block (line 34 at time of writing). Anthropic reads that as a genuine user turn, which closes the cycle and discards all prior thinking - at zero cache cost, because the range drop was already rewriting that region. Stub replacement does not do this: `src/pruner.ts:109` preserves `role: "toolResult"`, so the cycle stays open.
```
full toolResult, a1+a2 think billedThinking=3152
STUBBED toolResult (role kept), a1+a2 think billedThinking=3152 no effect
chain-drop user TEXT inserted before a2 billedThinking=0 cycle closed
```
Chain compression is enabled by default and fires several times per session, against the strip's ~4.3. It already performs the strip's intended job, for free, at a semantic boundary rather than an arbitrary turn count.
## Decision
Remove `thinkingStrip` entirely. No replacement mechanism, no replacement config key.
The resulting behavior is the union of two things that are already correct and already free: Anthropic drops closed-cycle thinking server-side, and chain compression closes cycles as a structural side effect of range dropping. What remains resident is open-cycle thinking since the last chain drop, which is the reasoning the model is actively using and which bills at cache-read rates.
### Alternatives considered and rejected
**Lower or raise `keepLastTurns`.** The knob has two reachable states, not a range. Below the cycle length there is a gap and the model sees no thinking; above it there is no gap and the model sees all of it. Every value from 1 to 15 produces identical behavior in a 20-turn cycle. Raising to 32 moves the cliff later and makes more cycles pay full freight before reaching it.
**Keep the first N turns instead of the last N.** Matches the API's prefix semantics, but inverts relevance: the model would retain reasoning about work already completed and lose the reasoning that produced the tool call whose result just arrived. Dominated by keep-all (which includes those same blocks plus the relevant ones) and by keep-none (which is free).
**Keep only the most recent turn (keep-last-1).** Forces a no-gap-to-gap transition at the third request of *every* cycle, raising the invalidation count from ~4.3/session to ~21/session. Roughly 4x worse than the bug it would replace.
**Gate the boundary to the stub frontier** (strip thinking exactly when its tool results are stubbed - the original proposal this worktree was opened for). Genuinely free: the invalidation is already happening at that position. But worth $4.59 across all 58 sessions (15.3M resident tokens at cache-read rates), or $0.08/session against a $44/session average. It also cannot express the intent cleanly - punching the gap drops *all* open-cycle thinking, including reasoning about tool results that are still fully present in context. Rejected on the repo's own bar: the coupling from `thinking-strip` to `frontier` is not worth 0.2%.
### Accepted consequence
Thinking blocks that reason about pruned tool outputs are retained rather than stripped. This is bounded and acceptable:
- Before the last chain drop: thinking is dropped server-side and never reaches the model. No exposure.
- After it: the referenced outputs are mostly unpruned anyway (`recoveryGraceTurns` protects the last 3 turns), and pruned ones are replaced by a stub plus its summary, recoverable via `context_tree_query`.
- The extension already retains assistant *text* and *tool calls* that reference pruned outputs, unconditionally and always live in the window. Singling out thinking would be inconsistent.
Residual risk is summarizer fidelity - a summary that drops a detail the retained thinking cites. That is a summarizer concern, not a thinking-retention one, and is out of scope here.
With the strip gone, `chainCompression.enabled: false` becomes the only configuration that carries open-cycle thinking indefinitely. Measured worst case across 1233 cycles is 30750 tokens (126 turns); median 388, p99 8968; zero cycles above 50k. Documented, not guarded.
## Scope
Delete:
| target | note |
|---|---|
| `src/thinking-strip.ts` | whole module (`stripOldThinking`, `computeThinkingBoundary`) |
| `src/pruner.ts` phase 4 | and the `thinkingStrip` + `thinkingBoundaryTimestamp` params of `pruneMessages` |
| `src/types.ts` | `ThinkingStripConfig`, `ContextPruneConfig.thinkingStrip`, `DEFAULT_CONFIG.thinkingStrip`, `PruneFrontier.thinkingStripBoundaryTimestamp`, and the `KEEP_LAST_TURNS_PRESETS` export (line 171) - it has no other consumer |
| `index.ts` | the `computeThinkingBoundary` import (line 39); boundary computation in `flushPending` (lines 507-511); its assignment to `frontierSnapshot`; the argument at the `pruneMessages` call site. Also narrow the `branchMessages` unwrap conditional (line 495) from `thinkingStrip.enabled \|\| chainCompression.enabled` to `chainCompression.enabled` alone |
| `src/frontier.ts` | `thinkingStripBoundaryTimestamp` in `fromJSON` |
| `src/commands.ts` | the `KEEP_LAST_TURNS_PRESETS` import (line 19), the `thinkingStripEnabled` toggle and `thinkingStripKeepLastTurns` selector (its preset usage at lines 661-664), and both `onChange` handlers |
| tests | delete `src/thinking-strip.test.ts`; strip phase-4 cases from `src/pruner.test.ts` and boundary cases from `src/frontier.test.ts`; update the positional `pruneMessages` call sites in `src/range-compression.integration.test.ts` and `src/oversized-spill.integration.test.ts` for the shortened signature |
Backward compatibility, both directions of an in-flight session:
- A `contextPrune.thinkingStrip` block left in a user's `settings.json` is inert but **persists**. `normalize()` in `src/config.ts` builds `{ ...DEFAULT_CONFIG, ...existing }` and then re-spreads it, so unrecognized keys are neither filtered nor rejected: the block survives `loadConfig()` untouched and is re-serialized verbatim by the next `saveConfig()`. It never throws or warns. No code change and no deprecation notice - the key simply stops being read, and stays in the file until a user removes it by hand.
- A persisted `context-prune-frontier` entry carrying `thinkingStripBoundaryTimestamp` is ignored on `session_start`. The field is optional and read-only at that site.
## Testing
- Delete `src/thinking-strip.test.ts`. Remove the phase-4 assertions from `src/pruner.test.ts` and the boundary-specific cases from `src/frontier.test.ts`. Update the positional `pruneMessages` calls in `src/range-compression.integration.test.ts` and `src/oversized-spill.integration.test.ts` to the shortened signature.
- **New** (additive) test: `pruneMessages` leaves `thinking` blocks untouched on assistant messages. Pin `stripFinalAssistantThinking: false` in this fixture - with the default `true`, `applyChainCompressions` legitimately strips thinking from a compressed chain's kept final assistant, so an unqualified "untouched everywhere" assertion would contradict that mechanism.
- Leave the existing `"strips thinking blocks from final assistant when stripFinalAssistantThinking is true"` case (`src/pruner.test.ts:182`) in place - that mechanism is out of scope and must keep working.
- Assert a session resumed from a frontier entry containing `thinkingStripBoundaryTimestamp` loads without error and strips nothing.
- Assert a `settings.json` containing `contextPrune.thinkingStrip` loads without error and round-trips the key unchanged.
- Full suite green: baseline on this branch is `274 pass, 0 fail, 23 files` (`bun test src/`).
- Typecheck per AGENTS.md before committing.
- Smoke test against an isolated `$PI_CODING_AGENT_DIR`: `DEFAULT_CONFIG.enabled` is `false`, so the settings file must set `contextPrune.enabled: true` and the prompt must force real tool calls and a flush. Assert **at least one** `context-prune-frontier` entry was written before asserting that none of them carries `thinkingStripBoundaryTimestamp` - otherwise the check passes vacuously on a session that never flushed.
## Documentation impact
- Feature / user-facing docs introduced: none
- Materially amended existing docs: `PRUNING.md` (delete the "Main-loop thinking strip" section and its cache-impact discussion; add a short note that chain drops close the assistant cycle server-side and that closed-cycle thinking is free, so no strip is needed); `doc/configuration.md` (delete the `thinkingStrip` block from the settings JSON example at line 39 and both `thinkingStrip.*` rows from the settings table at lines 76-77 - this is the file README designates as the full settings reference); `CHANGELOG.md` (breaking: config key removed). `README.md` needs no edit - it contains no `thinking` references.
- Derived / memory docs invalidated: `AGENTS.md` - the Project Layout block lists `src/thinking-strip.ts`, and the `src/pruner.ts` line describes the phase composition as `stub-replace -> error-purge -> chain-range-prune -> thinking-strip`; both need updating in the same commit
## Out of scope
- **`chainCompression.stripFinalAssistantThinking`** - a separate mechanism, not measured here, plausibly carrying the same defect. Belongs to chain compression; decide separately rather than removing it as a ride-along.
- **Chain compression's own cache behavior.** A 6-prompt A/B showed chain compression costing +4.5% ($0.4784 -> $0.5000) with 2 full invalidations from 3 chain drops, but the fixture peaked at 20k tokens where the archive median is 131k - the regime where compression is supposed to pay. Not a verdict; needs a large-fixture A/B before any conclusion.
- **The unattributed remainder of archive rewrite spend.** The strip explains ~$95 of ~$1997. A prompt-delta proxy suggested far more breaks than the controlled runs reproduce; the proxy is unreliable and the remainder is unexplained.
- **Summarizer fidelity** for details cited by retained thinking.
## Measurement caveats
- The 58-session gridstrong archive predates v2.4.3 for 55 of 58 sessions, so it measures the per-render boundary, not the flush-gated one. The cycle-length and thinking-volume statistics are unaffected by this (they are properties of the transcript, not of the strip); the ~$95 cost figure is specific to the pre-2.4.3 path and is an upper bound for the current one.
- Cost figures use Sonnet 4.5 pricing: $3/Mtok input, $15/Mtok output, $3.75/Mtok cache write (5m), $0.30/Mtok cache read.
- The controlled A/B peaks at 89k tokens; archive median peak is 224k. Conclusions about the strip hold regardless of scale (the mechanism is positional, not size-dependent), but absolute costs do not extrapolate linearly.
## Open questions
- `chainCompression.stripFinalAssistantThinking` defaults to `true` and is untested against the prefix-retention rule. Track separately.