pi-condense 2.4.2 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/PRUNING.md +13 -59
- package/index.ts +15 -11
- package/package.json +1 -1
- package/src/commands.ts +0 -25
- package/src/config.test.ts +27 -1
- package/src/frontier.test.ts +173 -0
- package/src/pruner.test.ts +16 -20
- package/src/pruner.ts +4 -21
- package/src/types.ts +0 -31
- package/src/thinking-strip.test.ts +0 -175
- package/src/thinking-strip.ts +0 -42
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense) (
|
|
|
7
7
|
Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the tests and
|
|
8
8
|
publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
|
|
9
9
|
|
|
10
|
+
## [2.5.0] - 2026-08-05
|
|
11
|
+
|
|
12
|
+
- **Removed the main-loop thinking strip (breaking: `contextPrune.thinkingStrip.*` no longer read).** The feature assumed thinking blocks we send are thinking blocks we are billed for. Verified against the live Anthropic API (`count_tokens` and real billed usage agree to the token), the actual rule is: thinking in a *closed* cycle bills 0 input tokens, and thinking in an *open* cycle survives only as an unbroken run starting at the cycle's first assistant turn — any gap discards everything after it. `keepLastTurns` kept the **last** K, so firing it punched a gap at the front and the API dropped all of it: the model received no thinking either way, and the no-gap → gap transition cost a full cache invalidation (measured +33% in a controlled 24-tool-call A/B, $0.6430 → $0.8531, with turn 17 rewriting 63113 tokens at 0% reuse). Across 58 sessions / 1233 open cycles the strip fired 253 times and reached its ~130-turn break-even twice. It was also redundant: chain compression's synthetic block is a `role: "user"` text message, which closes the cycle and frees all prior thinking server-side at zero cache cost. `pruneMessages` drops to three phases (`stub-replace → error-purge → chain-range-prune`); `src/thinking-strip.ts`, `ThinkingStripConfig`, `KEEP_LAST_TURNS_PRESETS`, the two `/pruner` settings entries, and `PruneFrontier.thinkingStripBoundaryTimestamp` are gone. A leftover `thinkingStrip` block in `settings.json` is inert and round-trips untouched (`normalize()` never filters unknown keys); a persisted `context-prune-frontier` entry carrying the old boundary field loads and is ignored. Rationale and measurements: `doc/specs/2026-08-05-remove-thinking-strip.md`.
|
|
13
|
+
|
|
14
|
+
## [2.4.3] - 2026-08-04
|
|
15
|
+
|
|
16
|
+
- **Flush-gated, timestamp-keyed thinking strip ([#3](https://github.com/jjuraszek/pi-condense/issues/3)).** Phase 4 (`stripOldThinking`, `src/thinking-strip.ts`) recomputed its keep-window from the *live assistant count on every `context` render*, so each turn the `(count - keepLastTurns)`-th assistant slid forward and its thinking was stripped **deep in history**. pi-ai sets prompt-cache breakpoints only at `tools + system + last message` (verified in `@earendil-works/pi-ai` `api/anthropic-messages.js` `convertMessages` - no in-history breakpoint), so every deep mutation busted the cached suffix - roughly every render inside a tool loop. The strip boundary is now a **flush-computed, persisted assistant-message timestamp** (`thinkingStripBoundaryTimestamp`, added to the `PruneFrontier` snapshot on the existing `context-prune-frontier` entry): fixed between flushes so consecutive renders are byte-stable in their historical prefix (the cache survives a whole tool loop), advancing only on a non-empty flush (piggybacking summarization's own cache bust - zero marginal busts), monotonically clamped (never re-adds thinking to an already-stripped message, even when `keepLastTurns` is increased mid-session), and keyed by timestamp rather than array index so it is robust to phase-3 chain-range middle drops. An absent boundary (pre-feature sessions, pre-first-flush) falls back to the original live-count window verbatim. Additive optional field, fully backward-compatible; **no new config key**, `keepLastTurns` presets unchanged. `PRUNING.md` cache-impact model corrected. Turns the old k-busts-per-render into ~1-per-request.
|
|
17
|
+
|
|
10
18
|
## [2.4.2] - 2026-08-04
|
|
11
19
|
|
|
12
20
|
- **No-op renders skip context serialization.** `pruneMessages` (`src/pruner.ts`) computed `sizeMessages` (a full `JSON.stringify` over the entire message array) unconditionally on every `context` render, including no-ops where the result is never read (`index.ts` consumes `beforeChars`/`afterChars` only under `if (result.pruned)`). It now computes both sizes lazily in the pruned branch and returns a `{ beforeChars: 0, afterChars: 0 }` sentinel on a no-op, so a render that prunes nothing does zero `JSON.stringify` over the array. CPU/GC only - zero token cost, no wire or return-shape change. Also corrects a stale fast-path comment in the `context` handler (`index.ts`): index/registry emptiness alone does not imply a no-op, because error-purge and thinking-strip prune independently.
|
package/PRUNING.md
CHANGED
|
@@ -28,12 +28,11 @@
|
|
|
28
28
|
10. [Chain Compression](#chain-compression)
|
|
29
29
|
- [Protected-output relocation](#protected-output-relocation)
|
|
30
30
|
11. [Error Purge](#error-purge)
|
|
31
|
-
12. [
|
|
32
|
-
13. [Why Summarization Works: Research Evidence](#why-summarization-works-research-evidence)
|
|
31
|
+
12. [Why Summarization Works: Research Evidence](#why-summarization-works-research-evidence)
|
|
33
32
|
- [SUPO — Summarization augmented Policy Optimization](#supo--summarization-augmented-policy-optimization)
|
|
34
33
|
- [ReSum — Recursive Summarization for Long-Horizon Agents](#resum--recursive-summarization-for-long-horizon-agents)
|
|
35
34
|
- [ACON — Agent Context Optimization](#acon--agent-context-optimization)
|
|
36
|
-
|
|
35
|
+
13. [Summary](#summary)
|
|
37
36
|
|
|
38
37
|
---
|
|
39
38
|
|
|
@@ -688,7 +687,7 @@ The last attempted prune boundary is persisted as `context-prune-frontier` so `f
|
|
|
688
687
|
- **Tree browser (`/pruner tree`):** interactive, foldable tree of pruned tool calls grouped under their summaries. `Ctrl-O` on a summary node opens the full markdown summary in a bordered overlay.
|
|
689
688
|
- **Configurable summarizer thinking (`summarizerThinking`):** trade summary cost / latency for quality (`off` / `minimal` / `low` / `medium` / `high` / `xhigh`). `default` omits the option entirely so the provider chooses.
|
|
690
689
|
- **Cumulative stats:** `context-prune-stats` entries track input/output tokens and cost of every summarizer call; full detail surfaces in `/pruner stats`. Cost is also emitted on the `cost:external` pi.events channel for external aggregators (cumulative per session, live only).
|
|
691
|
-
- **Live reclaim ratio:** measured once per `pruneMessages` call via `sizeMessages(messages) = JSON.stringify(messages).length`, comparing the input array before pruning to the result after. Estimated tokens = chars / 4. The measurement covers all
|
|
690
|
+
- **Live reclaim ratio:** measured once per `pruneMessages` call via `sizeMessages(messages) = JSON.stringify(messages).length`, comparing the input array before pruning to the result after. Estimated tokens = chars / 4. The measurement covers all three reclaim mechanisms in a single point (stub-replace, error-purge, chain-range-prune); appears on the status line as `│ prune: ON · 92k->14k (-85%) │` once at least one prune has occurred (the `│ … │` wrapper keeps the segment visually isolated in the shared footer, load-order independent).
|
|
692
691
|
- **Live progress for `/pruner now`:** an `aboveEditor` widget shows one row per pending batch with braille spinner, streamed summary-char count, and ✓ / ⚠ status.
|
|
693
692
|
|
|
694
693
|
### Summarizer outage fallback
|
|
@@ -863,6 +862,8 @@ A **closed chain** is a span of messages from one user message through any numbe
|
|
|
863
862
|
| Final text-only assistant | **Kept**, thinking blocks stripped (safe — no following tool cycle depends on the signature) |
|
|
864
863
|
| Synthetic `<compressed-chain>` user message | **Injected** immediately after the start user message |
|
|
865
864
|
|
|
865
|
+
**Why there is no separate thinking strip.** The synthetic `<compressed-chain>` block is injected as a `role: "user"` text message, which Anthropic reads as a genuine user turn. That closes the assistant cycle, and the API drops every prior thinking block from the context window server-side - verified against the live API: the same 8-turn loop bills 3152 thinking tokens with a full `toolResult` history and 0 once a user text block precedes the final assistant. So chain compression already reclaims thinking mass for free, as a side effect of the range drop that was rewriting that region anyway. Stub replacement does not have this effect: it preserves `role: "toolResult"`, so the cycle stays open. A dedicated strip phase shipped through v2.4.3; removed in v2.5.0 - see `doc/specs/2026-08-05-remove-thinking-strip.md`.
|
|
866
|
+
|
|
866
867
|
### Transform composition order
|
|
867
868
|
|
|
868
869
|
```
|
|
@@ -870,14 +871,13 @@ raw messages from session
|
|
|
870
871
|
│
|
|
871
872
|
├─ [1] tool-result stub-replace (per-batch; existing)
|
|
872
873
|
├─ [2] error-purge (phase 2)
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
└─ [4] thinking-strip (keep thinking on last K assistant turns)
|
|
874
|
+
└─ [3] chain-range-prune (runs AFTER stubs)
|
|
875
|
+
for each compressed chain:
|
|
876
|
+
drop middle assistants (by toolCallId overlap)
|
|
877
|
+
drop middle toolResults (by toolCallId)
|
|
878
|
+
suppress per-batch summaries (by toolCallRefs overlap)
|
|
879
|
+
inject <compressed-chain> after start user
|
|
880
|
+
strip thinking from final assistant
|
|
881
881
|
```
|
|
882
882
|
|
|
883
883
|
### Identification model
|
|
@@ -981,7 +981,7 @@ Error purge replaces those arg bodies with compact stubs after the error has coo
|
|
|
981
981
|
**Transform position:** Error purge runs in Phase 2, after stub-replace and before chain range prune.
|
|
982
982
|
|
|
983
983
|
```
|
|
984
|
-
[stub-replace] → [error-purge] → [chain-range-prune]
|
|
984
|
+
[stub-replace] → [error-purge] → [chain-range-prune]
|
|
985
985
|
```
|
|
986
986
|
|
|
987
987
|
**Config keys:**
|
|
@@ -994,52 +994,6 @@ Error purge replaces those arg bodies with compact stubs after the error has coo
|
|
|
994
994
|
|
|
995
995
|
---
|
|
996
996
|
|
|
997
|
-
## Main-loop Thinking Strip
|
|
998
|
-
|
|
999
|
-
Chain compression and the summarizer target *tool* mass. But in long single-agent sessions the dominant cost is often **assistant `thinking` blocks**: on Opus 4.5+/Sonnet 4.6+ the API retains every prior-turn thinking block by default, and pi-ai replays them all (with signatures) on every request. One autonomous ops session held ~405 K tokens (~80% of a 500 K window) in thinking alone, untouched by every other strategy — chain compression only fires on *closed* spans, and that session was one long open span.
|
|
1000
|
-
|
|
1001
|
-
Thinking strip is a deterministic, zero-LLM transform (Phase 4) that keeps `thinking` blocks only on the last `keepLastTurns` **assistant turns** and strips them from older assistant messages, leaving each message's `text` and `toolCall` blocks intact.
|
|
1002
|
-
|
|
1003
|
-
### Turn unit
|
|
1004
|
-
|
|
1005
|
-
`keepLastTurns` counts **assistant messages**, not user-bounded spans. The target failure mode is a single long open chain (zero subagents, near-zero user turns) where a span-based window would keep everything. Counting assistant turns directly bounds thinking accumulation regardless of whether any chain closes.
|
|
1006
|
-
|
|
1007
|
-
### Provider safety
|
|
1008
|
-
|
|
1009
|
-
Anthropic's extended-thinking contract during tool use:
|
|
1010
|
-
|
|
1011
|
-
- Only the **last assistant turn's** thinking is required; "you can omit thinking blocks from prior assistant role turns" and the API auto-filters them.
|
|
1012
|
-
- A message's thinking blocks must be dropped **all-or-nothing** ("the entire sequence of consecutive thinking blocks must match the outputs … you can't rearrange or modify the sequence"). The strip reuses `withoutThinkingBlocks`, which removes every thinking block (and its signature) from a message.
|
|
1013
|
-
|
|
1014
|
-
`keepLastTurns` is clamped to `>= 1`, so the most-recent assistant turn — the one that may be awaiting tool results — always keeps its thinking. This is the minimum safe window; the default of 16 is far above the floor and preserves recent reasoning continuity.
|
|
1015
|
-
|
|
1016
|
-
### Transform position
|
|
1017
|
-
|
|
1018
|
-
Thinking strip runs **last**, after chain-range-prune, so "last K assistant turns" is measured over the turns that actually survive to the LLM:
|
|
1019
|
-
|
|
1020
|
-
```
|
|
1021
|
-
[stub-replace] → [error-purge] → [chain-range-prune] → [thinking-strip]
|
|
1022
|
-
```
|
|
1023
|
-
|
|
1024
|
-
In a session with no closed chains, Phases 1–3 may be no-ops and thinking strip does all the work. Where chain compression *does* fire, the two cooperate: chain compression drops whole old middle turns (including their thinking); thinking strip mops up thinking in the surviving recent / in-flight turns beyond K.
|
|
1025
|
-
|
|
1026
|
-
### Cache impact
|
|
1027
|
-
|
|
1028
|
-
Each new assistant turn slides the keep-window by one, stripping the turn that falls out and invalidating the prefix cache from that point (~K turns deep). The stable cached prefix (everything older than the window) still grows monotonically; only a K-deep tail churns. The trade vs the status quo: without stripping, thinking accrues without bound and is billed as cached input on every request until the window overflows; with stripping, total context is bounded at the cost of re-processing the last ~K turns' thinking each turn. Net-positive for long sessions; a literal no-op for sessions under `keepLastTurns` turns. Smaller K is cheaper on both savings and churn (worse only for reasoning continuity).
|
|
1029
|
-
|
|
1030
|
-
### Recovery
|
|
1031
|
-
|
|
1032
|
-
Stripped thinking is **not** recoverable via `context_tree_query` — unlike tool outputs, thinking blocks are not indexed. The raw thinking remains in the session JSONL on disk (the `context` hook never mutates storage); reloading the session without the extension, or reading the file directly, shows the original blocks. Thinking is transient model-internal reasoning, so drop-without-recovery is intentional.
|
|
1033
|
-
|
|
1034
|
-
### Config keys
|
|
1035
|
-
|
|
1036
|
-
| Key | Default | Description |
|
|
1037
|
-
|---|---|---|
|
|
1038
|
-
| `thinkingStrip.enabled` | `true` | Master toggle (gated behind the top-level `enabled`) |
|
|
1039
|
-
| `thinkingStrip.keepLastTurns` | `16` | Keep thinking on the last N assistant turns; strip older. Clamped to `>= 1` |
|
|
1040
|
-
|
|
1041
|
-
---
|
|
1042
|
-
|
|
1043
997
|
## Summary
|
|
1044
998
|
|
|
1045
999
|
| Concern | How Pruning Addresses It |
|
package/index.ts
CHANGED
|
@@ -486,6 +486,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
486
486
|
? "skipped-deduped"
|
|
487
487
|
: "skipped-trivial";
|
|
488
488
|
|
|
489
|
+
// Raw session branch, unwrapped once for the chain-compression block below.
|
|
490
|
+
// Only materialized when chain compression is enabled.
|
|
491
|
+
let branchMessages: any[] | undefined;
|
|
492
|
+
if (currentConfig.value.chainCompression.enabled) {
|
|
493
|
+
branchMessages = ctx.sessionManager.getBranch()
|
|
494
|
+
.filter((e: any) => e.type === "message" && e.message)
|
|
495
|
+
.map((e: any) => e.message);
|
|
496
|
+
}
|
|
497
|
+
|
|
489
498
|
const frontierSnapshot: PruneFrontier = {
|
|
490
499
|
lastAttemptedToolCallId: lastTC.toolCallId,
|
|
491
500
|
lastAttemptedToolName: lastTC.toolName,
|
|
@@ -524,14 +533,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
524
533
|
// Non-fatal: a failure here does not roll back the successful summarization.
|
|
525
534
|
if (currentConfig.value.chainCompression.enabled) {
|
|
526
535
|
try {
|
|
527
|
-
const branch = ctx.sessionManager.getBranch();
|
|
528
|
-
const branchMessages = branch
|
|
529
|
-
.filter((e: any) => e.type === "message" && e.message)
|
|
530
|
-
.map((e: any) => e.message);
|
|
531
536
|
// message_end fires before pi persists the closing assistant, so thread it
|
|
532
537
|
// in here; otherwise the newest chain reads as open and K over-retains by 1.
|
|
533
|
-
|
|
534
|
-
const
|
|
538
|
+
// branchMessages was unwrapped once above, gated on chainCompression.enabled.
|
|
539
|
+
const chains = detectChains(withClosingMessage(branchMessages!, options.closingMessage), protectionPredicate);
|
|
540
|
+
const inGrace = inGraceRecoveryToolCallIds(branchMessages!, currentConfig.value.recoveryGraceTurns);
|
|
535
541
|
const { compressedEntries } = await compressEligible(
|
|
536
542
|
chains,
|
|
537
543
|
currentConfig.value.chainCompression.rollingWindow,
|
|
@@ -819,16 +825,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
819
825
|
|
|
820
826
|
// pruneMessages is the single source of truth for "is there work to do".
|
|
821
827
|
// It returns the original array reference (pruned: false) only when none of
|
|
822
|
-
// the
|
|
823
|
-
// imply a no-op, since error-purge (phase 2)
|
|
824
|
-
//
|
|
825
|
-
// a split gate here.
|
|
828
|
+
// the three phases changed anything; index/registry emptiness alone does not
|
|
829
|
+
// imply a no-op, since error-purge (phase 2) prunes independently of them.
|
|
830
|
+
// Calling it unconditionally is safe and avoids a split gate here.
|
|
826
831
|
const result = pruneMessages(
|
|
827
832
|
messages,
|
|
828
833
|
indexer,
|
|
829
834
|
currentConfig.value.chainCompression,
|
|
830
835
|
currentConfig.value.purgeErrors,
|
|
831
|
-
currentConfig.value.thinkingStrip,
|
|
832
836
|
currentConfig.value,
|
|
833
837
|
currentConfig.value.recoveryGraceTurns,
|
|
834
838
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|
package/src/commands.ts
CHANGED
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
SUMMARIZER_MAX_TIMEOUT_PRESETS,
|
|
17
17
|
AUTO_BUDGET_PRESETS,
|
|
18
18
|
ROLLING_WINDOW_PRESETS,
|
|
19
|
-
KEEP_LAST_TURNS_PRESETS,
|
|
20
19
|
PURGE_COOLDOWN_PRESETS,
|
|
21
20
|
PURGE_MIN_ARG_PRESETS,
|
|
22
21
|
DEFAULT_CONFIG,
|
|
@@ -648,22 +647,6 @@ export function registerCommands(
|
|
|
648
647
|
currentValue: String(config.chainCompression.fuseRangeSummary),
|
|
649
648
|
description: `Fuse a compressed chain's per-batch summaries into one cohesive LLM summary (one extra summarizer call per multi-batch span). Off keeps the per-batch concatenation. Currently ${config.chainCompression.fuseRangeSummary ? "ON" : "OFF"}.`,
|
|
650
649
|
},
|
|
651
|
-
{
|
|
652
|
-
id: "thinkingStripEnabled",
|
|
653
|
-
label: "Thinking strip",
|
|
654
|
-
values: ["true", "false"],
|
|
655
|
-
currentValue: String(config.thinkingStrip.enabled),
|
|
656
|
-
description: `Strip thinking blocks from assistant turns older than the last ${config.thinkingStrip.keepLastTurns}. Reclaims main-loop thinking accumulation; no-op under ${config.thinkingStrip.keepLastTurns} turns. Currently ${config.thinkingStrip.enabled ? "ON" : "OFF"}.`,
|
|
657
|
-
},
|
|
658
|
-
{
|
|
659
|
-
id: "thinkingStripKeepLastTurns",
|
|
660
|
-
label: "Thinking keep (last N turns)",
|
|
661
|
-
values: KEEP_LAST_TURNS_PRESETS.map((p) => p.value),
|
|
662
|
-
currentValue: KEEP_LAST_TURNS_PRESETS.some((p) => p.value === String(config.thinkingStrip.keepLastTurns))
|
|
663
|
-
? String(config.thinkingStrip.keepLastTurns)
|
|
664
|
-
: KEEP_LAST_TURNS_PRESETS[2].value,
|
|
665
|
-
description: `Keep thinking on the last N assistant turns; strip older. Counts assistant turns, not chains. Currently ${config.thinkingStrip.keepLastTurns}.`,
|
|
666
|
-
},
|
|
667
650
|
{
|
|
668
651
|
id: "purgeErrorsEnabled",
|
|
669
652
|
label: "Error purge",
|
|
@@ -801,14 +784,6 @@ export function registerCommands(
|
|
|
801
784
|
newConfig.chainCompression = { ...newConfig.chainCompression, stripFinalAssistantThinking: newValue === "true" };
|
|
802
785
|
} else if (id === "chainCompressionFuseRange") {
|
|
803
786
|
newConfig.chainCompression = { ...newConfig.chainCompression, fuseRangeSummary: newValue === "true" };
|
|
804
|
-
} else if (id === "thinkingStripEnabled") {
|
|
805
|
-
newConfig.thinkingStrip = { ...newConfig.thinkingStrip, enabled: newValue === "true" };
|
|
806
|
-
} else if (id === "thinkingStripKeepLastTurns") {
|
|
807
|
-
const parsed = Number.parseInt(newValue, 10);
|
|
808
|
-
newConfig.thinkingStrip = {
|
|
809
|
-
...newConfig.thinkingStrip,
|
|
810
|
-
keepLastTurns: Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_CONFIG.thinkingStrip.keepLastTurns,
|
|
811
|
-
};
|
|
812
787
|
} else if (id === "purgeErrorsEnabled") {
|
|
813
788
|
newConfig.purgeErrors = { ...newConfig.purgeErrors, enabled: newValue === "true" };
|
|
814
789
|
} else if (id === "purgeErrorsCooldown") {
|
package/src/config.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, beforeAll, afterAll } from "bun:test";
|
|
2
|
-
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { DEFAULT_CONFIG } from "./types.js";
|
|
@@ -14,6 +14,7 @@ import { DEFAULT_CONFIG } from "./types.js";
|
|
|
14
14
|
*/
|
|
15
15
|
let tmpDir: string;
|
|
16
16
|
let loadConfig: typeof import("./config.js").loadConfig;
|
|
17
|
+
let saveConfig: typeof import("./config.js").saveConfig;
|
|
17
18
|
let settingsPath: typeof import("./config.js").settingsPath;
|
|
18
19
|
|
|
19
20
|
beforeAll(async () => {
|
|
@@ -21,6 +22,7 @@ beforeAll(async () => {
|
|
|
21
22
|
process.env.PI_CODING_AGENT_DIR = tmpDir;
|
|
22
23
|
const mod = await import("./config.js");
|
|
23
24
|
loadConfig = mod.loadConfig;
|
|
25
|
+
saveConfig = mod.saveConfig;
|
|
24
26
|
settingsPath = mod.settingsPath;
|
|
25
27
|
});
|
|
26
28
|
|
|
@@ -99,3 +101,27 @@ describe("loadConfig summarizer timeout normalization", () => {
|
|
|
99
101
|
expect(config.summarizerIdleTimeoutMs).toBe(1234);
|
|
100
102
|
});
|
|
101
103
|
});
|
|
104
|
+
|
|
105
|
+
describe("loadConfig backward compatibility with removed thinkingStrip key", () => {
|
|
106
|
+
it("loads without error and round-trips a stale contextPrune.thinkingStrip block unchanged", async () => {
|
|
107
|
+
const stale = { enabled: true, keepLastTurns: 16 };
|
|
108
|
+
await writeContextPrune({ thinkingStrip: stale });
|
|
109
|
+
|
|
110
|
+
const config = await loadConfig();
|
|
111
|
+
|
|
112
|
+
// thinkingStrip is no longer a recognized key: DEFAULT_CONFIG carries no
|
|
113
|
+
// such field, so nothing reads or acts on it.
|
|
114
|
+
expect((DEFAULT_CONFIG as unknown as Record<string, unknown>).thinkingStrip).toBeUndefined();
|
|
115
|
+
// normalize() spreads { ...DEFAULT_CONFIG, ...existing } and re-spreads
|
|
116
|
+
// the merge, so the unrecognized key survives verbatim on the loaded value.
|
|
117
|
+
expect((config as unknown as Record<string, unknown>).thinkingStrip).toEqual(stale);
|
|
118
|
+
|
|
119
|
+
// saveConfig() re-serializes the same config object it's given, so the
|
|
120
|
+
// stale block written above must still be present, byte-equivalent, after
|
|
121
|
+
// a full load -> save round trip through the real settingsPath() file.
|
|
122
|
+
await saveConfig(config);
|
|
123
|
+
const raw = await readFile(settingsPath(), "utf-8");
|
|
124
|
+
const written = JSON.parse(raw);
|
|
125
|
+
expect(written.contextPrune.thinkingStrip).toEqual(stale);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { PruneFrontierTracker } from "./frontier.js";
|
|
3
|
+
import { pruneMessages } from "./pruner.js";
|
|
4
|
+
import type { PruneFrontier } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const base: PruneFrontier = {
|
|
7
|
+
lastAttemptedToolCallId: "tc1",
|
|
8
|
+
lastAttemptedToolName: "bash",
|
|
9
|
+
lastAttemptedTurnIndex: 3,
|
|
10
|
+
lastAttemptedTimestamp: 1000,
|
|
11
|
+
attemptedBatchCount: 1,
|
|
12
|
+
attemptedToolCallCount: 2,
|
|
13
|
+
rawCharCount: 500,
|
|
14
|
+
summaryCharCount: 100,
|
|
15
|
+
outcome: "summarized",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("PruneFrontierTracker.fromJSON", () => {
|
|
19
|
+
test("round-trips a full frontier", () => {
|
|
20
|
+
const t = new PruneFrontierTracker();
|
|
21
|
+
t.fromJSON({ ...base });
|
|
22
|
+
expect(t.get()?.lastAttemptedToolCallId).toBe("tc1");
|
|
23
|
+
expect(t.get()?.outcome).toBe("summarized");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("ignores an entry with no lastAttemptedToolCallId", () => {
|
|
27
|
+
const t = new PruneFrontierTracker();
|
|
28
|
+
t.fromJSON({} as PruneFrontier);
|
|
29
|
+
expect(t.get()).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("tolerates a legacy entry carrying the removed thinkingStripBoundaryTimestamp", () => {
|
|
33
|
+
const t = new PruneFrontierTracker();
|
|
34
|
+
t.fromJSON({ ...base, thinkingStripBoundaryTimestamp: 777 } as PruneFrontier);
|
|
35
|
+
expect(t.get()?.lastAttemptedToolCallId).toBe("tc1");
|
|
36
|
+
expect((t.get() as any).thinkingStripBoundaryTimestamp).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("PruneFrontierTracker.reconstructFromSession", () => {
|
|
41
|
+
test("reconstructs from a persisted frontier entry", () => {
|
|
42
|
+
const t = new PruneFrontierTracker();
|
|
43
|
+
const entries = [
|
|
44
|
+
{ type: "custom", customType: "context-prune-frontier", data: { ...base, lastAttemptedTimestamp: 2000 } },
|
|
45
|
+
];
|
|
46
|
+
const fakeCtx = { sessionManager: { getBranch: () => entries } } as any;
|
|
47
|
+
t.reconstructFromSession(fakeCtx);
|
|
48
|
+
expect(t.get()?.lastAttemptedTimestamp).toBe(2000);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Spans frontier.ts + pruner.ts on purpose: proves a resumed legacy frontier carries
|
|
52
|
+
// no boundary into an *actively-pruning* pipeline, not just an inert one. Phase 1
|
|
53
|
+
// (stub-replace) and Phase 3 (chain-range-prune) are both wired live here -- a
|
|
54
|
+
// summarized toolResult gets stubbed and a chain entry produces a synthetic
|
|
55
|
+
// <compressed-chain> message -- and every surviving assistant turn, both older and
|
|
56
|
+
// newer than the legacy boundary, still carries its thinking block. This does not
|
|
57
|
+
// (and cannot) prove the deleted thinking-strip phase stays deleted; it proves the
|
|
58
|
+
// phases that remain do not touch thinking regardless of the legacy field's presence.
|
|
59
|
+
test("a legacy frontier entry with thinkingStripBoundaryTimestamp resumes without error and strips nothing", () => {
|
|
60
|
+
const legacyBoundary = 555;
|
|
61
|
+
const t = new PruneFrontierTracker();
|
|
62
|
+
const entries = [
|
|
63
|
+
{
|
|
64
|
+
type: "custom",
|
|
65
|
+
customType: "context-prune-frontier",
|
|
66
|
+
data: { ...base, lastAttemptedTimestamp: 2000, thinkingStripBoundaryTimestamp: legacyBoundary },
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
const fakeCtx = { sessionManager: { getBranch: () => entries } } as any;
|
|
70
|
+
|
|
71
|
+
expect(() => t.reconstructFromSession(fakeCtx)).not.toThrow();
|
|
72
|
+
const frontier = t.get();
|
|
73
|
+
expect(frontier).not.toBeNull();
|
|
74
|
+
expect(frontier?.lastAttemptedToolCallId).toBe("tc1");
|
|
75
|
+
expect((frontier as any).thinkingStripBoundaryTimestamp).toBeUndefined();
|
|
76
|
+
|
|
77
|
+
const chainEntry = {
|
|
78
|
+
blockId: "b1",
|
|
79
|
+
startUserTimestamp: 560,
|
|
80
|
+
droppedToolCallIds: ["tc-old"],
|
|
81
|
+
finalAssistantTimestamp: 600,
|
|
82
|
+
toolRefs: ["told"],
|
|
83
|
+
compressedAt: 9999,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const indexer = {
|
|
87
|
+
isSummarized: (id: string) => id === "tc-old" || id === "tc-stub",
|
|
88
|
+
getShortRefForToolCallId: (id: string) => (id === "tc-stub" ? "t1" : id === "tc-old" ? "told" : undefined),
|
|
89
|
+
getRecord: () => undefined,
|
|
90
|
+
getChainEntries: () => [chainEntry],
|
|
91
|
+
getPerBatchSummaryTextForToolCallIds: () => "chain summary text",
|
|
92
|
+
findChainEntryByBlockId: () => undefined,
|
|
93
|
+
} as any;
|
|
94
|
+
|
|
95
|
+
const mkAsst = (ts: number) => ({
|
|
96
|
+
role: "assistant",
|
|
97
|
+
content: [
|
|
98
|
+
{ type: "thinking", thinking: "t", thinkingSignature: "s" },
|
|
99
|
+
{ type: "text", text: "x" },
|
|
100
|
+
],
|
|
101
|
+
timestamp: ts,
|
|
102
|
+
usage: {},
|
|
103
|
+
stopReason: "end_turn",
|
|
104
|
+
});
|
|
105
|
+
const mkAsstWithCall = (ts: number, toolCallId: string) => ({
|
|
106
|
+
role: "assistant",
|
|
107
|
+
content: [
|
|
108
|
+
{ type: "thinking", thinking: "t", thinkingSignature: "s" },
|
|
109
|
+
{ type: "toolCall", id: toolCallId, name: "bash", arguments: {} },
|
|
110
|
+
],
|
|
111
|
+
timestamp: ts,
|
|
112
|
+
usage: {},
|
|
113
|
+
stopReason: "tool_use",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Timestamps straddle the legacy boundary: old code would have stripped the ones below it.
|
|
117
|
+
// tc-stub is a plain summarized tool result (phase 1 target, outside the chain).
|
|
118
|
+
// tc-old is dropped by the chain entry (phase 3 target).
|
|
119
|
+
const messages: any[] = [
|
|
120
|
+
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 },
|
|
121
|
+
mkAsst(legacyBoundary - 100),
|
|
122
|
+
mkAsstWithCall(legacyBoundary - 55, "tc-stub"),
|
|
123
|
+
{
|
|
124
|
+
role: "toolResult",
|
|
125
|
+
toolCallId: "tc-stub",
|
|
126
|
+
toolName: "bash",
|
|
127
|
+
content: [{ type: "text", text: "raw stub-target output" }],
|
|
128
|
+
isError: false,
|
|
129
|
+
timestamp: legacyBoundary - 50,
|
|
130
|
+
},
|
|
131
|
+
mkAsst(legacyBoundary - 1),
|
|
132
|
+
{ role: "user", content: [{ type: "text", text: "do it" }], timestamp: 560 },
|
|
133
|
+
mkAsstWithCall(570, "tc-old"),
|
|
134
|
+
{
|
|
135
|
+
role: "toolResult",
|
|
136
|
+
toolCallId: "tc-old",
|
|
137
|
+
toolName: "bash",
|
|
138
|
+
content: [{ type: "text", text: "raw chain output" }],
|
|
139
|
+
isError: false,
|
|
140
|
+
timestamp: 575,
|
|
141
|
+
},
|
|
142
|
+
mkAsst(600),
|
|
143
|
+
mkAsst(legacyBoundary + 100),
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const { messages: out, pruned } = pruneMessages(messages, indexer, {
|
|
147
|
+
enabled: true,
|
|
148
|
+
rollingWindow: 0,
|
|
149
|
+
stripFinalAssistantThinking: false,
|
|
150
|
+
fuseRangeSummary: false,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Non-vacuity: the pipeline actually did something.
|
|
154
|
+
expect(pruned).toBe(true);
|
|
155
|
+
|
|
156
|
+
// Phase 1 fired: the summarized-but-not-chained toolResult was stub-replaced.
|
|
157
|
+
const stubResult = out.find((m: any) => m.role === "toolResult" && m.toolCallId === "tc-stub") as any;
|
|
158
|
+
expect(stubResult).toBeDefined();
|
|
159
|
+
expect(stubResult.content[0].text).toContain("`t1`");
|
|
160
|
+
expect(stubResult.content[0].text).not.toContain("raw stub-target output");
|
|
161
|
+
|
|
162
|
+
// Phase 3 fired: the chain entry produced a synthetic compressed-chain message.
|
|
163
|
+
const synthetic = out.find(
|
|
164
|
+
(m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"),
|
|
165
|
+
);
|
|
166
|
+
expect(synthetic).toBeDefined();
|
|
167
|
+
|
|
168
|
+
// Every surviving assistant turn, older and newer than the legacy boundary, keeps thinking.
|
|
169
|
+
const assistants = out.filter((m: any) => m.role === "assistant");
|
|
170
|
+
expect(assistants.length).toBe(5);
|
|
171
|
+
expect(assistants.every((a: any) => a.content.some((c: any) => c.type === "thinking"))).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
});
|
package/src/pruner.test.ts
CHANGED
|
@@ -374,7 +374,7 @@ describe("pruneMessages", () => {
|
|
|
374
374
|
expect(pruned).toBe(false);
|
|
375
375
|
});
|
|
376
376
|
|
|
377
|
-
it("
|
|
377
|
+
it("leaves thinking blocks on every assistant turn (no thinking-strip phase)", () => {
|
|
378
378
|
const indexer = makeMockIndexer({ summarized: new Set(["c10"]), shortRefs: new Map([["c10", "t1"]]) });
|
|
379
379
|
const mkAsst = (ts: number) => ({
|
|
380
380
|
role: "assistant",
|
|
@@ -393,21 +393,17 @@ describe("pruneMessages", () => {
|
|
|
393
393
|
messages.push(mkAsst(10 + i));
|
|
394
394
|
messages.push({ role: "toolResult", toolCallId: id, toolName: "bash", content: [{ type: "text", text: "o" }], isError: false, timestamp: 100 + i });
|
|
395
395
|
}
|
|
396
|
-
const { messages: out, pruned } = pruneMessages(messages, indexer,
|
|
397
|
-
enabled: true,
|
|
398
|
-
keepLastTurns: 2,
|
|
396
|
+
const { messages: out, pruned } = pruneMessages(messages, indexer, {
|
|
397
|
+
enabled: true, rollingWindow: 0, stripFinalAssistantThinking: false, fuseRangeSummary: false,
|
|
399
398
|
});
|
|
399
|
+
// Phase 1 still fires: c10's toolResult is stub-replaced.
|
|
400
400
|
expect(pruned).toBe(true);
|
|
401
|
-
|
|
402
|
-
// Phase 1: c10 toolResult stub-replaced
|
|
403
401
|
const tr = out.find((m: any) => m.role === "toolResult" && m.toolCallId === "c10") as any;
|
|
404
402
|
expect(tr.content[0].text).toContain("`t1`");
|
|
405
|
-
|
|
406
|
-
// Phase 4: oldest 3 assistant turns stripped, last 2 keep thinking
|
|
403
|
+
// No phase strips thinking any more — all five assistants keep theirs.
|
|
407
404
|
const assistants = out.filter((m: any) => m.role === "assistant");
|
|
408
|
-
|
|
409
|
-
expect(assistants.
|
|
410
|
-
expect(assistants.slice(-2).every((a: any) => hasThinking(a))).toBe(true);
|
|
405
|
+
expect(assistants.length).toBe(5);
|
|
406
|
+
expect(assistants.every((a: any) => a.content.some((c: any) => c.type === "thinking"))).toBe(true);
|
|
411
407
|
});
|
|
412
408
|
});
|
|
413
409
|
|
|
@@ -437,7 +433,7 @@ describe("render-time protection re-check", () => {
|
|
|
437
433
|
|
|
438
434
|
it("leaves a summarized record verbatim once its path matches protectedPaths", () => {
|
|
439
435
|
const { messages, pruned } = pruneMessages(
|
|
440
|
-
[skillMsg], indexer as any, undefined, undefined,
|
|
436
|
+
[skillMsg], indexer as any, undefined, undefined,
|
|
441
437
|
{ protectedTools: [], protectedPaths: ["**/skills/**/*.md"] },
|
|
442
438
|
);
|
|
443
439
|
expect(pruned).toBe(false);
|
|
@@ -468,7 +464,7 @@ describe("pruneMessages recovery grace", () => {
|
|
|
468
464
|
shortRefs: new Map([["tc-recover", "t1"]]),
|
|
469
465
|
});
|
|
470
466
|
const messages = [mkQueryResult("tc-recover", 1)];
|
|
471
|
-
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined,
|
|
467
|
+
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
|
|
472
468
|
expect(out[0].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
|
|
473
469
|
});
|
|
474
470
|
|
|
@@ -478,7 +474,7 @@ describe("pruneMessages recovery grace", () => {
|
|
|
478
474
|
shortRefs: new Map([["tc-recover", "t1"]]),
|
|
479
475
|
});
|
|
480
476
|
const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
|
|
481
|
-
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined,
|
|
477
|
+
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
|
|
482
478
|
const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any;
|
|
483
479
|
expect(tr.content[0].text).toContain("context_tree_query");
|
|
484
480
|
expect(tr.content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
|
|
@@ -490,7 +486,7 @@ describe("pruneMessages recovery grace", () => {
|
|
|
490
486
|
shortRefs: new Map([["tc-recover", "t1"]]),
|
|
491
487
|
});
|
|
492
488
|
const messages = [mkQueryResult("tc-recover", 1)];
|
|
493
|
-
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined,
|
|
489
|
+
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 0);
|
|
494
490
|
expect(out[0].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
|
|
495
491
|
expect(out[0].content[0].text).toContain("context_tree_query");
|
|
496
492
|
});
|
|
@@ -510,7 +506,7 @@ describe("pruneMessages recovery grace", () => {
|
|
|
510
506
|
timestamp: 1,
|
|
511
507
|
},
|
|
512
508
|
];
|
|
513
|
-
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined,
|
|
509
|
+
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
|
|
514
510
|
expect(out[0].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
|
|
515
511
|
expect(out[0].content[0].text).toContain("context_tree_query");
|
|
516
512
|
});
|
|
@@ -526,7 +522,7 @@ describe("pruneMessages recovery grace", () => {
|
|
|
526
522
|
});
|
|
527
523
|
const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
|
|
528
524
|
const { messages: out } = pruneMessages(
|
|
529
|
-
messages, indexer, undefined, undefined,
|
|
525
|
+
messages, indexer, undefined, undefined,
|
|
530
526
|
{ protectedTools: [], protectedPaths: ["**/skills/**/*.md"] },
|
|
531
527
|
0,
|
|
532
528
|
);
|
|
@@ -545,7 +541,7 @@ describe("pruneMessages recovery grace", () => {
|
|
|
545
541
|
}]]),
|
|
546
542
|
});
|
|
547
543
|
const messages = [mkQueryResult("tc-recover", 1)];
|
|
548
|
-
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined,
|
|
544
|
+
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
|
|
549
545
|
expect(out[0].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
|
|
550
546
|
});
|
|
551
547
|
|
|
@@ -560,7 +556,7 @@ describe("pruneMessages recovery grace", () => {
|
|
|
560
556
|
}]]),
|
|
561
557
|
});
|
|
562
558
|
const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
|
|
563
|
-
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined,
|
|
559
|
+
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
|
|
564
560
|
const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any;
|
|
565
561
|
expect(tr.content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
|
|
566
562
|
expect(tr.content[0].text).toContain("/blobs/tc-recover.txt");
|
|
@@ -572,7 +568,7 @@ describe("sizeMessages", () => {
|
|
|
572
568
|
it("counts hidden fields (thinking blocks), not just visible text", () => {
|
|
573
569
|
// Two messages with identical visible .text but different hidden content.
|
|
574
570
|
// sizeMessages must count the full serialized weight so all reclaim
|
|
575
|
-
// mechanisms (
|
|
571
|
+
// mechanisms (stub-replace, error-purge, chain-range-prune) register correctly.
|
|
576
572
|
const withThinking = [{
|
|
577
573
|
role: "assistant",
|
|
578
574
|
content: [
|
package/src/pruner.ts
CHANGED
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
import type { ToolCallIndexer } from "./indexer.js";
|
|
2
|
-
import type { ChainCompressionConfig, ErrorPurgeConfig
|
|
2
|
+
import type { ChainCompressionConfig, ErrorPurgeConfig } from "./types.js";
|
|
3
3
|
import { isProtected, type ProtectionConfig } from "./protected.js";
|
|
4
4
|
import { applyChainCompressions } from "./chain-range-prune.js";
|
|
5
5
|
import { purgeErroredArgs } from "./error-purge.js";
|
|
6
|
-
import { stripOldThinking } from "./thinking-strip.js";
|
|
7
6
|
import { inGraceRecoveryToolCallIds } from "./recovery-grace.js";
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* Estimate of a message array's context weight. Serializing the whole array
|
|
11
10
|
* (not just visible text) is deliberate: it counts tool-call argument bodies
|
|
12
|
-
* (error-purge)
|
|
13
|
-
*
|
|
11
|
+
* (error-purge) and tool-result arrays (stub-replace / chain-range) so all
|
|
12
|
+
* reclaim mechanisms register.
|
|
14
13
|
*/
|
|
15
14
|
export function sizeMessages(messages: any[]): number {
|
|
16
15
|
return JSON.stringify(messages).length;
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
/**
|
|
20
|
-
* Transforms the `context` event message array in
|
|
19
|
+
* Transforms the `context` event message array in three phases:
|
|
21
20
|
*
|
|
22
21
|
* Phase 1 — stub-replace: ToolResultMessages for summarized tool calls are
|
|
23
22
|
* replaced with short stubs pointing the model at `context_tree_query`.
|
|
@@ -43,12 +42,6 @@ export function sizeMessages(messages: any[]): number {
|
|
|
43
42
|
* synthetic user message wrapping the existing per-batch summary text.
|
|
44
43
|
* Only runs when `chainCompression.enabled` and chain entries exist.
|
|
45
44
|
*
|
|
46
|
-
* Phase 4 — thinking strip: keep `thinking` blocks only on the last
|
|
47
|
-
* `keepLastTurns` assistant turns; strip them from older assistant messages
|
|
48
|
-
* (preserving text + toolCall). Runs last so the window counts the assistant
|
|
49
|
-
* turns that actually survive to the LLM. Only runs when
|
|
50
|
-
* `thinkingStrip.enabled`.
|
|
51
|
-
*
|
|
52
45
|
* Return shape:
|
|
53
46
|
* - `pruned: true` — at least one change happened; the returned
|
|
54
47
|
* `messages` is a freshly allocated array.
|
|
@@ -71,7 +64,6 @@ export function pruneMessages(
|
|
|
71
64
|
indexer: ToolCallIndexer,
|
|
72
65
|
chainCompression?: ChainCompressionConfig,
|
|
73
66
|
errorPurge?: ErrorPurgeConfig,
|
|
74
|
-
thinkingStrip?: ThinkingStripConfig,
|
|
75
67
|
protection?: ProtectionConfig,
|
|
76
68
|
recoveryGraceTurns: number = 0,
|
|
77
69
|
): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } {
|
|
@@ -154,15 +146,6 @@ export function pruneMessages(
|
|
|
154
146
|
}
|
|
155
147
|
}
|
|
156
148
|
|
|
157
|
-
// Phase 4: thinking strip — keep thinking only on the last K assistant turns
|
|
158
|
-
if (thinkingStrip?.enabled) {
|
|
159
|
-
const afterStrip = stripOldThinking(current, thinkingStrip);
|
|
160
|
-
if (afterStrip !== current) {
|
|
161
|
-
current = afterStrip;
|
|
162
|
-
pruned = true;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
149
|
return pruned
|
|
167
150
|
? { messages: current, pruned, beforeChars: sizeMessages(messages), afterChars: sizeMessages(current) }
|
|
168
151
|
: { messages, pruned, beforeChars: 0, afterChars: 0 };
|
package/src/types.ts
CHANGED
|
@@ -163,19 +163,6 @@ export const ROLLING_WINDOW_PRESETS: { value: string; label: string }[] = [
|
|
|
163
163
|
{ value: "10", label: "10" },
|
|
164
164
|
];
|
|
165
165
|
|
|
166
|
-
/**
|
|
167
|
-
* Cycling preset values for the `thinkingStrip.keepLastTurns` setting.
|
|
168
|
-
* Stored as strings because SettingsList cycles string values; converted to
|
|
169
|
-
* number when applied. Counts ASSISTANT turns (messages), not closed chains.
|
|
170
|
-
*/
|
|
171
|
-
export const KEEP_LAST_TURNS_PRESETS: { value: string; label: string }[] = [
|
|
172
|
-
{ value: "4", label: "4" },
|
|
173
|
-
{ value: "8", label: "8" },
|
|
174
|
-
{ value: "16", label: "16 (default)" },
|
|
175
|
-
{ value: "32", label: "32" },
|
|
176
|
-
{ value: "64", label: "64" },
|
|
177
|
-
];
|
|
178
|
-
|
|
179
166
|
/**
|
|
180
167
|
* Cycling preset values for the `minBatchChars` setting in the SettingsList.
|
|
181
168
|
* Stored as strings because SettingsList cycles string values; converted to
|
|
@@ -349,8 +336,6 @@ export interface ContextPruneConfig {
|
|
|
349
336
|
chainCompression: ChainCompressionConfig;
|
|
350
337
|
/** Replace failed toolCall argument bodies with compact stubs after a cooldown window. */
|
|
351
338
|
purgeErrors: ErrorPurgeConfig;
|
|
352
|
-
/** Rolling main-loop thinking-block strip: keep thinking only on the last K assistant turns. */
|
|
353
|
-
thinkingStrip: ThinkingStripConfig;
|
|
354
339
|
/**
|
|
355
340
|
* Pre-flush content-hash dedup pass. When `true`, each captured tool call
|
|
356
341
|
* is hashed by `(toolName, normalize(resultText))` and compared against
|
|
@@ -497,18 +482,6 @@ export interface ErrorPurgeConfig {
|
|
|
497
482
|
minArgChars: number;
|
|
498
483
|
}
|
|
499
484
|
|
|
500
|
-
export interface ThinkingStripConfig {
|
|
501
|
-
enabled: boolean;
|
|
502
|
-
/**
|
|
503
|
-
* Keep `thinking` blocks on the last K assistant turns; strip them from
|
|
504
|
-
* older assistant messages (preserving text + toolCall blocks). Counts
|
|
505
|
-
* assistant messages, not closed chains. Clamped to >= 1 so the most-recent
|
|
506
|
-
* assistant turn always keeps its thinking (Anthropic requires the last
|
|
507
|
-
* assistant turn's thinking during tool use). Default 16.
|
|
508
|
-
*/
|
|
509
|
-
keepLastTurns: number;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
485
|
export const DEFAULT_CONFIG: ContextPruneConfig = {
|
|
513
486
|
enabled: false,
|
|
514
487
|
showPruneStatusLine: true,
|
|
@@ -534,10 +507,6 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
|
|
|
534
507
|
cooldownTurns: 2,
|
|
535
508
|
minArgChars: 500,
|
|
536
509
|
},
|
|
537
|
-
thinkingStrip: {
|
|
538
|
-
enabled: true,
|
|
539
|
-
keepLastTurns: 16,
|
|
540
|
-
},
|
|
541
510
|
dedupByContentHash: true,
|
|
542
511
|
autoBudgetThreshold: null,
|
|
543
512
|
spillThreshold: 65536,
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { stripOldThinking } from "./thinking-strip.js";
|
|
3
|
-
import type { ThinkingStripConfig } from "./types.js";
|
|
4
|
-
|
|
5
|
-
const cfg = (enabled: boolean, keepLastTurns: number): ThinkingStripConfig => ({ enabled, keepLastTurns });
|
|
6
|
-
|
|
7
|
-
function userMsg(ts: number): any {
|
|
8
|
-
return { role: "user", content: [{ type: "text", text: "go" }], timestamp: ts };
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function assistantToolsThinking(ts: number, toolCallIds: string[], thinkingBlocks = 1): any {
|
|
12
|
-
const content: any[] = [];
|
|
13
|
-
for (let i = 0; i < thinkingBlocks; i++) {
|
|
14
|
-
content.push({ type: "thinking", thinking: `t${ts}-${i}`, thinkingSignature: `sig${ts}-${i}` });
|
|
15
|
-
}
|
|
16
|
-
content.push({ type: "text", text: "working" });
|
|
17
|
-
for (const id of toolCallIds) content.push({ type: "toolCall", id, name: "bash", arguments: { cmd: "ls" } });
|
|
18
|
-
return { role: "assistant", content, timestamp: ts, usage: {}, stopReason: "toolUse" };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function assistantTextThinking(ts: number): any {
|
|
22
|
-
return {
|
|
23
|
-
role: "assistant",
|
|
24
|
-
content: [
|
|
25
|
-
{ type: "thinking", thinking: "final reasoning", thinkingSignature: "sigf" },
|
|
26
|
-
{ type: "text", text: "done" },
|
|
27
|
-
],
|
|
28
|
-
timestamp: ts,
|
|
29
|
-
usage: {},
|
|
30
|
-
stopReason: "stop",
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function toolResult(ts: number, toolCallId: string): any {
|
|
35
|
-
return {
|
|
36
|
-
role: "toolResult",
|
|
37
|
-
toolCallId,
|
|
38
|
-
toolName: "bash",
|
|
39
|
-
content: [{ type: "text", text: "out" }],
|
|
40
|
-
isError: false,
|
|
41
|
-
timestamp: ts,
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function hasThinking(msg: any): boolean {
|
|
46
|
-
return Array.isArray(msg.content) && msg.content.some((c: any) => c.type === "thinking");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function countThinking(msg: any): number {
|
|
50
|
-
return Array.isArray(msg.content) ? msg.content.filter((c: any) => c.type === "thinking").length : 0;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** user, then (n-1) tool-using assistant turns each followed by a toolResult, then 1 final text assistant. */
|
|
54
|
-
function convo(nAssistantTurns: number): any[] {
|
|
55
|
-
const msgs: any[] = [userMsg(1)];
|
|
56
|
-
let ts = 2;
|
|
57
|
-
for (let i = 0; i < nAssistantTurns - 1; i++) {
|
|
58
|
-
const id = `tc${i}`;
|
|
59
|
-
msgs.push(assistantToolsThinking(ts++, [id]));
|
|
60
|
-
msgs.push(toolResult(ts++, id));
|
|
61
|
-
}
|
|
62
|
-
msgs.push(assistantTextThinking(ts++));
|
|
63
|
-
return msgs;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
describe("stripOldThinking", () => {
|
|
67
|
-
test("disabled → same reference", () => {
|
|
68
|
-
const msgs = convo(20);
|
|
69
|
-
expect(stripOldThinking(msgs, cfg(false, 16))).toBe(msgs);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("fewer assistant turns than keepLastTurns → same reference", () => {
|
|
73
|
-
const msgs = convo(10);
|
|
74
|
-
expect(stripOldThinking(msgs, cfg(true, 16))).toBe(msgs);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("exactly keepLastTurns assistant turns → same reference (nothing older)", () => {
|
|
78
|
-
const msgs = convo(16);
|
|
79
|
-
expect(stripOldThinking(msgs, cfg(true, 16))).toBe(msgs);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("strips thinking from turns older than the last K, keeps the last K", () => {
|
|
83
|
-
const msgs = convo(20);
|
|
84
|
-
const out = stripOldThinking(msgs, cfg(true, 16));
|
|
85
|
-
expect(out).not.toBe(msgs);
|
|
86
|
-
const assistants = out.filter((m) => m.role === "assistant");
|
|
87
|
-
expect(assistants.length).toBe(20);
|
|
88
|
-
for (const a of assistants.slice(-16)) expect(hasThinking(a)).toBe(true);
|
|
89
|
-
for (const a of assistants.slice(0, 4)) expect(hasThinking(a)).toBe(false);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("keepLastTurns=1 keeps only the most-recent assistant turn's thinking", () => {
|
|
93
|
-
const msgs = convo(5);
|
|
94
|
-
const out = stripOldThinking(msgs, cfg(true, 1));
|
|
95
|
-
const assistants = out.filter((m) => m.role === "assistant");
|
|
96
|
-
expect(hasThinking(assistants[assistants.length - 1])).toBe(true);
|
|
97
|
-
for (const a of assistants.slice(0, -1)) expect(hasThinking(a)).toBe(false);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("keepLastTurns=0 is clamped to 1 (never strips the last assistant turn)", () => {
|
|
101
|
-
const msgs = convo(5);
|
|
102
|
-
const out = stripOldThinking(msgs, cfg(true, 0));
|
|
103
|
-
const assistants = out.filter((m) => m.role === "assistant");
|
|
104
|
-
expect(hasThinking(assistants[assistants.length - 1])).toBe(true);
|
|
105
|
-
expect(hasThinking(assistants[0])).toBe(false);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("trailing tool-use assistant awaiting results keeps its thinking", () => {
|
|
109
|
-
const msgs: any[] = [userMsg(1)];
|
|
110
|
-
let ts = 2;
|
|
111
|
-
for (let i = 0; i < 4; i++) {
|
|
112
|
-
const id = `x${i}`;
|
|
113
|
-
msgs.push(assistantToolsThinking(ts++, [id]));
|
|
114
|
-
msgs.push(toolResult(ts++, id));
|
|
115
|
-
}
|
|
116
|
-
const out = stripOldThinking(msgs, cfg(true, 1));
|
|
117
|
-
const assistants = out.filter((m) => m.role === "assistant");
|
|
118
|
-
const last = assistants[assistants.length - 1];
|
|
119
|
-
expect(hasThinking(last)).toBe(true);
|
|
120
|
-
expect(last.content.some((c: any) => c.type === "toolCall")).toBe(true);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test("stripped assistant keeps its text and toolCall blocks", () => {
|
|
124
|
-
const msgs = convo(20);
|
|
125
|
-
const out = stripOldThinking(msgs, cfg(true, 16));
|
|
126
|
-
const firstAssistant = out.find((m) => m.role === "assistant");
|
|
127
|
-
expect(hasThinking(firstAssistant)).toBe(false);
|
|
128
|
-
expect(firstAssistant.content.some((c: any) => c.type === "text")).toBe(true);
|
|
129
|
-
expect(firstAssistant.content.some((c: any) => c.type === "toolCall")).toBe(true);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("strips all thinking blocks from a message (all-or-nothing)", () => {
|
|
133
|
-
const msgs: any[] = [userMsg(1), assistantToolsThinking(2, ["a"], 2), toolResult(3, "a")];
|
|
134
|
-
let ts = 4;
|
|
135
|
-
for (let i = 0; i < 3; i++) {
|
|
136
|
-
const id = `b${i}`;
|
|
137
|
-
msgs.push(assistantToolsThinking(ts++, [id], 2));
|
|
138
|
-
msgs.push(toolResult(ts++, id));
|
|
139
|
-
}
|
|
140
|
-
msgs.push(assistantTextThinking(ts++));
|
|
141
|
-
const out = stripOldThinking(msgs, cfg(true, 2));
|
|
142
|
-
expect(countThinking(out[1])).toBe(0);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test("no thinking anywhere → same reference", () => {
|
|
146
|
-
const msgs: any[] = [userMsg(1)];
|
|
147
|
-
let ts = 2;
|
|
148
|
-
for (let i = 0; i < 20; i++) {
|
|
149
|
-
const id = `n${i}`;
|
|
150
|
-
msgs.push({
|
|
151
|
-
role: "assistant",
|
|
152
|
-
content: [{ type: "text", text: "x" }, { type: "toolCall", id, name: "bash", arguments: {} }],
|
|
153
|
-
timestamp: ts++,
|
|
154
|
-
usage: {},
|
|
155
|
-
stopReason: "toolUse",
|
|
156
|
-
});
|
|
157
|
-
msgs.push(toolResult(ts++, id));
|
|
158
|
-
}
|
|
159
|
-
expect(stripOldThinking(msgs, cfg(true, 4))).toBe(msgs);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("idempotent: second pass returns same reference", () => {
|
|
163
|
-
const msgs = convo(20);
|
|
164
|
-
const once = stripOldThinking(msgs, cfg(true, 16));
|
|
165
|
-
const twice = stripOldThinking(once, cfg(true, 16));
|
|
166
|
-
expect(twice).toBe(once);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test("preserves message order and length", () => {
|
|
170
|
-
const msgs = convo(20);
|
|
171
|
-
const out = stripOldThinking(msgs, cfg(true, 16));
|
|
172
|
-
expect(out.length).toBe(msgs.length);
|
|
173
|
-
out.forEach((m, i) => expect(m.role).toBe(msgs[i].role));
|
|
174
|
-
});
|
|
175
|
-
});
|
package/src/thinking-strip.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { withoutThinkingBlocks } from "./chain-range-prune.js";
|
|
2
|
-
import type { ThinkingStripConfig } from "./types.js";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Rolling main-loop thinking strip.
|
|
6
|
-
*
|
|
7
|
-
* Keeps `thinking` blocks on the last `keepLastTurns` assistant turns and
|
|
8
|
-
* strips them from all older assistant messages, preserving each message's
|
|
9
|
-
* `text` and `toolCall` blocks. "Turn" counts ASSISTANT messages, not
|
|
10
|
-
* user-bounded spans — the target failure mode is a single long open chain
|
|
11
|
-
* (zero subagents, near-zero user turns) where a span-based window keeps
|
|
12
|
-
* everything.
|
|
13
|
-
*
|
|
14
|
-
* Provider safety (Anthropic): during tool use only the LAST assistant turn's
|
|
15
|
-
* thinking is required; prior turns may be omitted, and a message's thinking
|
|
16
|
-
* blocks must be dropped all-or-nothing. `keepLastTurns` is clamped to >= 1 so
|
|
17
|
-
* the most-recent assistant turn always keeps its thinking. Stripping reuses
|
|
18
|
-
* `withoutThinkingBlocks` (drops the whole block incl. signature).
|
|
19
|
-
*
|
|
20
|
-
* Returns the original array reference unchanged when nothing is stripped, so
|
|
21
|
-
* `pruneMessages` can skip reconstruction.
|
|
22
|
-
*/
|
|
23
|
-
export function stripOldThinking(messages: any[], config: ThinkingStripConfig): any[] {
|
|
24
|
-
if (!config.enabled) return messages;
|
|
25
|
-
const keep = Math.max(1, config.keepLastTurns);
|
|
26
|
-
|
|
27
|
-
const assistantIdx: number[] = [];
|
|
28
|
-
for (let i = 0; i < messages.length; i++) {
|
|
29
|
-
if (messages[i]?.role === "assistant") assistantIdx.push(i);
|
|
30
|
-
}
|
|
31
|
-
if (assistantIdx.length <= keep) return messages;
|
|
32
|
-
|
|
33
|
-
const firstKeptAssistant = assistantIdx[assistantIdx.length - keep];
|
|
34
|
-
let changed = false;
|
|
35
|
-
const out = messages.map((msg, i) => {
|
|
36
|
-
if (i >= firstKeptAssistant || msg?.role !== "assistant") return msg;
|
|
37
|
-
if (!Array.isArray(msg.content) || !msg.content.some((c: any) => c.type === "thinking")) return msg;
|
|
38
|
-
changed = true;
|
|
39
|
-
return withoutThinkingBlocks(msg);
|
|
40
|
-
});
|
|
41
|
-
return changed ? out : messages;
|
|
42
|
-
}
|