pi-condense 2.4.3 → 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 +4 -0
- package/PRUNING.md +13 -59
- package/index.ts +7 -31
- package/package.json +1 -1
- package/src/commands.ts +0 -25
- package/src/config.test.ts +27 -1
- package/src/frontier.test.ts +137 -16
- package/src/frontier.ts +0 -1
- package/src/pruner.test.ts +17 -41
- package/src/pruner.ts +4 -22
- package/src/range-compression.integration.test.ts +1 -43
- package/src/types.ts +0 -39
- package/src/thinking-strip.test.ts +0 -257
- package/src/thinking-strip.ts +0 -83
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,10 @@ 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
|
+
|
|
10
14
|
## [2.4.3] - 2026-08-04
|
|
11
15
|
|
|
12
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.
|
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, at render time:
|
|
1019
|
-
|
|
1020
|
-
```
|
|
1021
|
-
[stub-replace] → [error-purge] → [chain-range-prune] → [thinking-strip]
|
|
1022
|
-
```
|
|
1023
|
-
|
|
1024
|
-
The keep-window is a flush-computed assistant-message timestamp (the `keepLastTurns`-back boundary over the **raw** session branch — see the **Cache impact** note below), not a render-time recount over the post-phase-3 survivors. So when chain compression drops closed middle turns inside the window, fewer than `keepLastTurns` *surviving* turns may retain thinking — deliberate, and it only ever strips more, never re-adds. 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 the boundary.
|
|
1025
|
-
|
|
1026
|
-
### Cache impact
|
|
1027
|
-
|
|
1028
|
-
pi-ai serializes prompt-cache breakpoints only at `tools`, `system`, and the last conversation message (verified in `@earendil-works/pi-ai` `api/anthropic-messages.js` `convertMessages`) - there is **no in-history breakpoint**. So the strip boundary is flush-gated, not per-render: it is a persisted assistant-message timestamp on the `context-prune-frontier` entry that stays fixed between flushes and advances only on a non-empty flush. Between flushes every render is byte-stable in its historical prefix, so the cache holds through a whole tool loop; the boundary moves at most once per flush (~once per request), turning the old k-busts-per-render into ~1-per-request. That residual bust is not always free: for a request shorter than `keepLastTurns` turns the boundary (tail-K) sits deeper than the current request's just-summarized tool results, so thinking-strip is the dominant invalidator, ~1 deep reprocess per request; for a request longer than `keepLastTurns` turns summarization's stub-replace reaches deeper and subsumes it (~0 marginal). Retained thinking is bounded to `keepLastTurns` raw-session turns, drifting up to `keepLastTurns + turns-since-flush` between flushes (deliberate: the frozen boundary is what buys cache stability). Note `error-purge` still mutates old history off a live per-render count, an independent cache-bust source not addressed here.
|
|
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
|
@@ -36,7 +36,6 @@ import { PruneFrontierTracker } from "./src/frontier.js";
|
|
|
36
36
|
import { BlockRefIssuer } from "./src/block-refs.js";
|
|
37
37
|
import { compressEligible } from "./src/chain-compressor.js";
|
|
38
38
|
import { detectChains, withClosingMessage } from "./src/chain-detector.js";
|
|
39
|
-
import { computeThinkingBoundary } from "./src/thinking-strip.js";
|
|
40
39
|
import { inGraceRecoveryToolCallIds } from "./src/recovery-grace.js";
|
|
41
40
|
import { shouldBudgetFlush, shouldDeltaFlush, usageFraction } from "./src/budget.js";
|
|
42
41
|
import { spillOversizedBatch } from "./src/spill.js";
|
|
@@ -487,34 +486,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
487
486
|
? "skipped-deduped"
|
|
488
487
|
: "skipped-trivial";
|
|
489
488
|
|
|
490
|
-
// Raw session branch, unwrapped once
|
|
491
|
-
//
|
|
492
|
-
// second O(session-size) pass on every flush. Only materialized when at least
|
|
493
|
-
// one consumer is enabled.
|
|
489
|
+
// Raw session branch, unwrapped once for the chain-compression block below.
|
|
490
|
+
// Only materialized when chain compression is enabled.
|
|
494
491
|
let branchMessages: any[] | undefined;
|
|
495
|
-
if (currentConfig.value.
|
|
492
|
+
if (currentConfig.value.chainCompression.enabled) {
|
|
496
493
|
branchMessages = ctx.sessionManager.getBranch()
|
|
497
494
|
.filter((e: any) => e.type === "message" && e.message)
|
|
498
495
|
.map((e: any) => e.message);
|
|
499
496
|
}
|
|
500
497
|
|
|
501
|
-
// Flush-gated thinking-strip boundary: recompute the (count - keepLastTurns)-th
|
|
502
|
-
// assistant timestamp over the RAW branch (+ the not-yet-persisted closing
|
|
503
|
-
// assistant), monotonically clamped. Stays on the frontier snapshot so renders
|
|
504
|
-
// between flushes read a fixed value and keep the cache prefix. Carries prev
|
|
505
|
-
// through when disabled. Must run regardless of chainCompression.enabled.
|
|
506
|
-
let thinkingBoundary = frontier.get()?.thinkingStripBoundaryTimestamp;
|
|
507
|
-
if (currentConfig.value.thinkingStrip.enabled) {
|
|
508
|
-
const assistantTimestamps = withClosingMessage(branchMessages!, options.closingMessage)
|
|
509
|
-
.filter((m: any) => m?.role === "assistant" && typeof m.timestamp === "number")
|
|
510
|
-
.map((m: any) => m.timestamp);
|
|
511
|
-
thinkingBoundary = computeThinkingBoundary(
|
|
512
|
-
assistantTimestamps,
|
|
513
|
-
currentConfig.value.thinkingStrip.keepLastTurns,
|
|
514
|
-
thinkingBoundary,
|
|
515
|
-
);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
498
|
const frontierSnapshot: PruneFrontier = {
|
|
519
499
|
lastAttemptedToolCallId: lastTC.toolCallId,
|
|
520
500
|
lastAttemptedToolName: lastTC.toolName,
|
|
@@ -525,7 +505,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
525
505
|
rawCharCount: totalRawCharCount,
|
|
526
506
|
summaryCharCount: totalSummaryCharCount,
|
|
527
507
|
outcome: flushOutcome,
|
|
528
|
-
thinkingStripBoundaryTimestamp: thinkingBoundary,
|
|
529
508
|
};
|
|
530
509
|
|
|
531
510
|
try {
|
|
@@ -556,7 +535,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
556
535
|
try {
|
|
557
536
|
// message_end fires before pi persists the closing assistant, so thread it
|
|
558
537
|
// in here; otherwise the newest chain reads as open and K over-retains by 1.
|
|
559
|
-
// branchMessages was unwrapped once above
|
|
538
|
+
// branchMessages was unwrapped once above, gated on chainCompression.enabled.
|
|
560
539
|
const chains = detectChains(withClosingMessage(branchMessages!, options.closingMessage), protectionPredicate);
|
|
561
540
|
const inGrace = inGraceRecoveryToolCallIds(branchMessages!, currentConfig.value.recoveryGraceTurns);
|
|
562
541
|
const { compressedEntries } = await compressEligible(
|
|
@@ -846,19 +825,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
846
825
|
|
|
847
826
|
// pruneMessages is the single source of truth for "is there work to do".
|
|
848
827
|
// It returns the original array reference (pruned: false) only when none of
|
|
849
|
-
// the
|
|
850
|
-
// imply a no-op, since error-purge (phase 2)
|
|
851
|
-
//
|
|
852
|
-
// 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.
|
|
853
831
|
const result = pruneMessages(
|
|
854
832
|
messages,
|
|
855
833
|
indexer,
|
|
856
834
|
currentConfig.value.chainCompression,
|
|
857
835
|
currentConfig.value.purgeErrors,
|
|
858
|
-
currentConfig.value.thinkingStrip,
|
|
859
836
|
currentConfig.value,
|
|
860
837
|
currentConfig.value.recoveryGraceTurns,
|
|
861
|
-
frontier.get()?.thinkingStripBoundaryTimestamp,
|
|
862
838
|
);
|
|
863
839
|
if (result.pruned) {
|
|
864
840
|
messages = result.messages;
|
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
|
+
});
|
package/src/frontier.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { PruneFrontierTracker } from "./frontier.js";
|
|
3
|
+
import { pruneMessages } from "./pruner.js";
|
|
3
4
|
import type { PruneFrontier } from "./types.js";
|
|
4
5
|
|
|
5
6
|
const base: PruneFrontier = {
|
|
@@ -14,39 +15,159 @@ const base: PruneFrontier = {
|
|
|
14
15
|
outcome: "summarized",
|
|
15
16
|
};
|
|
16
17
|
|
|
17
|
-
describe("PruneFrontierTracker.fromJSON
|
|
18
|
-
test("round-trips
|
|
18
|
+
describe("PruneFrontierTracker.fromJSON", () => {
|
|
19
|
+
test("round-trips a full frontier", () => {
|
|
19
20
|
const t = new PruneFrontierTracker();
|
|
20
|
-
t.fromJSON({ ...base
|
|
21
|
-
expect(t.get()?.
|
|
21
|
+
t.fromJSON({ ...base });
|
|
22
|
+
expect(t.get()?.lastAttemptedToolCallId).toBe("tc1");
|
|
23
|
+
expect(t.get()?.outcome).toBe("summarized");
|
|
22
24
|
});
|
|
23
25
|
|
|
24
|
-
test("
|
|
26
|
+
test("ignores an entry with no lastAttemptedToolCallId", () => {
|
|
25
27
|
const t = new PruneFrontierTracker();
|
|
26
|
-
t.fromJSON({
|
|
27
|
-
expect(t.get()
|
|
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();
|
|
28
37
|
});
|
|
29
38
|
});
|
|
30
39
|
|
|
31
|
-
describe("PruneFrontierTracker.reconstructFromSession
|
|
32
|
-
test("reconstructs
|
|
40
|
+
describe("PruneFrontierTracker.reconstructFromSession", () => {
|
|
41
|
+
test("reconstructs from a persisted frontier entry", () => {
|
|
33
42
|
const t = new PruneFrontierTracker();
|
|
34
43
|
const entries = [
|
|
35
|
-
{ type: "custom", customType: "context-prune-frontier", data: { ...base,
|
|
44
|
+
{ type: "custom", customType: "context-prune-frontier", data: { ...base, lastAttemptedTimestamp: 2000 } },
|
|
36
45
|
];
|
|
37
46
|
const fakeCtx = { sessionManager: { getBranch: () => entries } } as any;
|
|
38
47
|
t.reconstructFromSession(fakeCtx);
|
|
39
|
-
expect(t.get()?.
|
|
48
|
+
expect(t.get()?.lastAttemptedTimestamp).toBe(2000);
|
|
40
49
|
});
|
|
41
50
|
|
|
42
|
-
|
|
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;
|
|
43
61
|
const t = new PruneFrontierTracker();
|
|
44
62
|
const entries = [
|
|
45
|
-
{
|
|
63
|
+
{
|
|
64
|
+
type: "custom",
|
|
65
|
+
customType: "context-prune-frontier",
|
|
66
|
+
data: { ...base, lastAttemptedTimestamp: 2000, thinkingStripBoundaryTimestamp: legacyBoundary },
|
|
67
|
+
},
|
|
46
68
|
];
|
|
47
69
|
const fakeCtx = { sessionManager: { getBranch: () => entries } } as any;
|
|
48
|
-
|
|
49
|
-
expect(t.
|
|
50
|
-
|
|
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);
|
|
51
172
|
});
|
|
52
173
|
});
|
package/src/frontier.ts
CHANGED
package/src/pruner.test.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { pruneMessages, sizeMessages } from "./pruner.js";
|
|
3
|
-
import type { ChainCompressionConfig, ChainCompressionEntry
|
|
4
|
-
import { ToolCallIndexer } from "./indexer.js";
|
|
3
|
+
import type { ChainCompressionConfig, ChainCompressionEntry } from "./types.js";
|
|
5
4
|
|
|
6
5
|
// Minimal mock exposing only the ToolCallIndexer surface that pruneMessages calls.
|
|
7
6
|
function makeMockIndexer({
|
|
@@ -375,7 +374,7 @@ describe("pruneMessages", () => {
|
|
|
375
374
|
expect(pruned).toBe(false);
|
|
376
375
|
});
|
|
377
376
|
|
|
378
|
-
it("
|
|
377
|
+
it("leaves thinking blocks on every assistant turn (no thinking-strip phase)", () => {
|
|
379
378
|
const indexer = makeMockIndexer({ summarized: new Set(["c10"]), shortRefs: new Map([["c10", "t1"]]) });
|
|
380
379
|
const mkAsst = (ts: number) => ({
|
|
381
380
|
role: "assistant",
|
|
@@ -394,40 +393,17 @@ describe("pruneMessages", () => {
|
|
|
394
393
|
messages.push(mkAsst(10 + i));
|
|
395
394
|
messages.push({ role: "toolResult", toolCallId: id, toolName: "bash", content: [{ type: "text", text: "o" }], isError: false, timestamp: 100 + i });
|
|
396
395
|
}
|
|
397
|
-
const { messages: out, pruned } = pruneMessages(messages, indexer,
|
|
398
|
-
enabled: true,
|
|
399
|
-
keepLastTurns: 2,
|
|
396
|
+
const { messages: out, pruned } = pruneMessages(messages, indexer, {
|
|
397
|
+
enabled: true, rollingWindow: 0, stripFinalAssistantThinking: false, fuseRangeSummary: false,
|
|
400
398
|
});
|
|
399
|
+
// Phase 1 still fires: c10's toolResult is stub-replaced.
|
|
401
400
|
expect(pruned).toBe(true);
|
|
402
|
-
|
|
403
|
-
// Phase 1: c10 toolResult stub-replaced
|
|
404
401
|
const tr = out.find((m: any) => m.role === "toolResult" && m.toolCallId === "c10") as any;
|
|
405
402
|
expect(tr.content[0].text).toContain("`t1`");
|
|
406
|
-
|
|
407
|
-
// Phase 4: oldest 3 assistant turns stripped, last 2 keep thinking
|
|
403
|
+
// No phase strips thinking any more — all five assistants keep theirs.
|
|
408
404
|
const assistants = out.filter((m: any) => m.role === "assistant");
|
|
409
|
-
|
|
410
|
-
expect(assistants.
|
|
411
|
-
expect(assistants.slice(-2).every((a: any) => hasThinking(a))).toBe(true);
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
it("threads thinkingBoundaryTimestamp into phase 4", () => {
|
|
415
|
-
const indexer = new ToolCallIndexer();
|
|
416
|
-
const strip: ThinkingStripConfig = { enabled: true, keepLastTurns: 16 };
|
|
417
|
-
const messages: any[] = [
|
|
418
|
-
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 },
|
|
419
|
-
{ role: "assistant", content: [{ type: "thinking", thinking: "old", thinkingSignature: "s" }, { type: "text", text: "a" }], timestamp: 10, usage: {}, stopReason: "stop" },
|
|
420
|
-
{ role: "assistant", content: [{ type: "thinking", thinking: "new", thinkingSignature: "s" }, { type: "text", text: "b" }], timestamp: 30, usage: {}, stopReason: "stop" },
|
|
421
|
-
];
|
|
422
|
-
// Boundary 20: ts=10 assistant older -> stripped; ts=30 kept.
|
|
423
|
-
// Live-count would strip nothing (2 assistants < keepLastTurns=16), so a pass
|
|
424
|
-
// proves the boundary arg reached phase 4.
|
|
425
|
-
const { messages: out, pruned } = pruneMessages(messages, indexer, undefined, undefined, strip, undefined, 0, 20);
|
|
426
|
-
expect(pruned).toBe(true);
|
|
427
|
-
const older = out.find((m: any) => m.timestamp === 10) as any;
|
|
428
|
-
const newer = out.find((m: any) => m.timestamp === 30) as any;
|
|
429
|
-
expect(older.content.some((c: any) => c.type === "thinking")).toBe(false);
|
|
430
|
-
expect(newer.content.some((c: any) => c.type === "thinking")).toBe(true);
|
|
405
|
+
expect(assistants.length).toBe(5);
|
|
406
|
+
expect(assistants.every((a: any) => a.content.some((c: any) => c.type === "thinking"))).toBe(true);
|
|
431
407
|
});
|
|
432
408
|
});
|
|
433
409
|
|
|
@@ -457,7 +433,7 @@ describe("render-time protection re-check", () => {
|
|
|
457
433
|
|
|
458
434
|
it("leaves a summarized record verbatim once its path matches protectedPaths", () => {
|
|
459
435
|
const { messages, pruned } = pruneMessages(
|
|
460
|
-
[skillMsg], indexer as any, undefined, undefined,
|
|
436
|
+
[skillMsg], indexer as any, undefined, undefined,
|
|
461
437
|
{ protectedTools: [], protectedPaths: ["**/skills/**/*.md"] },
|
|
462
438
|
);
|
|
463
439
|
expect(pruned).toBe(false);
|
|
@@ -488,7 +464,7 @@ describe("pruneMessages recovery grace", () => {
|
|
|
488
464
|
shortRefs: new Map([["tc-recover", "t1"]]),
|
|
489
465
|
});
|
|
490
466
|
const messages = [mkQueryResult("tc-recover", 1)];
|
|
491
|
-
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined,
|
|
467
|
+
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
|
|
492
468
|
expect(out[0].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
|
|
493
469
|
});
|
|
494
470
|
|
|
@@ -498,7 +474,7 @@ describe("pruneMessages recovery grace", () => {
|
|
|
498
474
|
shortRefs: new Map([["tc-recover", "t1"]]),
|
|
499
475
|
});
|
|
500
476
|
const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
|
|
501
|
-
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined,
|
|
477
|
+
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
|
|
502
478
|
const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any;
|
|
503
479
|
expect(tr.content[0].text).toContain("context_tree_query");
|
|
504
480
|
expect(tr.content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
|
|
@@ -510,7 +486,7 @@ describe("pruneMessages recovery grace", () => {
|
|
|
510
486
|
shortRefs: new Map([["tc-recover", "t1"]]),
|
|
511
487
|
});
|
|
512
488
|
const messages = [mkQueryResult("tc-recover", 1)];
|
|
513
|
-
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined,
|
|
489
|
+
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 0);
|
|
514
490
|
expect(out[0].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
|
|
515
491
|
expect(out[0].content[0].text).toContain("context_tree_query");
|
|
516
492
|
});
|
|
@@ -530,7 +506,7 @@ describe("pruneMessages recovery grace", () => {
|
|
|
530
506
|
timestamp: 1,
|
|
531
507
|
},
|
|
532
508
|
];
|
|
533
|
-
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined,
|
|
509
|
+
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
|
|
534
510
|
expect(out[0].content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
|
|
535
511
|
expect(out[0].content[0].text).toContain("context_tree_query");
|
|
536
512
|
});
|
|
@@ -546,7 +522,7 @@ describe("pruneMessages recovery grace", () => {
|
|
|
546
522
|
});
|
|
547
523
|
const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
|
|
548
524
|
const { messages: out } = pruneMessages(
|
|
549
|
-
messages, indexer, undefined, undefined,
|
|
525
|
+
messages, indexer, undefined, undefined,
|
|
550
526
|
{ protectedTools: [], protectedPaths: ["**/skills/**/*.md"] },
|
|
551
527
|
0,
|
|
552
528
|
);
|
|
@@ -565,7 +541,7 @@ describe("pruneMessages recovery grace", () => {
|
|
|
565
541
|
}]]),
|
|
566
542
|
});
|
|
567
543
|
const messages = [mkQueryResult("tc-recover", 1)];
|
|
568
|
-
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined,
|
|
544
|
+
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
|
|
569
545
|
expect(out[0].content[0].text).toBe("VERBATIM RECOVERY OUTPUT");
|
|
570
546
|
});
|
|
571
547
|
|
|
@@ -580,7 +556,7 @@ describe("pruneMessages recovery grace", () => {
|
|
|
580
556
|
}]]),
|
|
581
557
|
});
|
|
582
558
|
const messages: any[] = [mkQueryResult("tc-recover", 1), mkUser(2), mkUser(3), mkUser(4), mkUser(5)];
|
|
583
|
-
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined,
|
|
559
|
+
const { messages: out } = pruneMessages(messages, indexer, undefined, undefined, undefined, 3);
|
|
584
560
|
const tr = out.find((m: any) => m.toolCallId === "tc-recover") as any;
|
|
585
561
|
expect(tr.content[0].text).not.toBe("VERBATIM RECOVERY OUTPUT");
|
|
586
562
|
expect(tr.content[0].text).toContain("/blobs/tc-recover.txt");
|
|
@@ -592,7 +568,7 @@ describe("sizeMessages", () => {
|
|
|
592
568
|
it("counts hidden fields (thinking blocks), not just visible text", () => {
|
|
593
569
|
// Two messages with identical visible .text but different hidden content.
|
|
594
570
|
// sizeMessages must count the full serialized weight so all reclaim
|
|
595
|
-
// mechanisms (
|
|
571
|
+
// mechanisms (stub-replace, error-purge, chain-range-prune) register correctly.
|
|
596
572
|
const withThinking = [{
|
|
597
573
|
role: "assistant",
|
|
598
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,10 +64,8 @@ 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
|
-
thinkingBoundaryTimestamp?: number,
|
|
78
69
|
): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } {
|
|
79
70
|
// Phase 1: stub-replace summarized tool results
|
|
80
71
|
let pruned = false;
|
|
@@ -155,15 +146,6 @@ export function pruneMessages(
|
|
|
155
146
|
}
|
|
156
147
|
}
|
|
157
148
|
|
|
158
|
-
// Phase 4: thinking strip — keep thinking only on the last K assistant turns
|
|
159
|
-
if (thinkingStrip?.enabled) {
|
|
160
|
-
const afterStrip = stripOldThinking(current, thinkingStrip, thinkingBoundaryTimestamp);
|
|
161
|
-
if (afterStrip !== current) {
|
|
162
|
-
current = afterStrip;
|
|
163
|
-
pruned = true;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
149
|
return pruned
|
|
168
150
|
? { messages: current, pruned, beforeChars: sizeMessages(messages), afterChars: sizeMessages(current) }
|
|
169
151
|
: { messages, pruned, beforeChars: 0, afterChars: 0 };
|
|
@@ -5,7 +5,7 @@ import { compressEligible } from "./chain-compressor.js";
|
|
|
5
5
|
import { pruneMessages } from "./pruner.js";
|
|
6
6
|
import { detectChains } from "./chain-detector.js";
|
|
7
7
|
import { isProtected } from "./protected.js";
|
|
8
|
-
import type { ChainRange, ChainCompressionConfig
|
|
8
|
+
import type { ChainRange, ChainCompressionConfig } from "./types.js";
|
|
9
9
|
|
|
10
10
|
// End-to-end of the in-memory B path (everything except the LLM call, which is
|
|
11
11
|
// the shared runSummarization already exercised live): a span's per-batch
|
|
@@ -249,46 +249,4 @@ describe("range compression integration", () => {
|
|
|
249
249
|
expect(synthetic.content[0].text).toContain("batch one body");
|
|
250
250
|
expect(synthetic.content[0].text).toContain("batch two body");
|
|
251
251
|
});
|
|
252
|
-
|
|
253
|
-
test("boundary strips thinking on survivors after a real phase-3 chain drop", async () => {
|
|
254
|
-
const indexer = new ToolCallIndexer();
|
|
255
|
-
const blockRefs = new BlockRefIssuer();
|
|
256
|
-
indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tc1" }]);
|
|
257
|
-
indexer.registerSummaryBody(["tc1"], "summary of batch 1");
|
|
258
|
-
|
|
259
|
-
const chain: ChainRange = {
|
|
260
|
-
startUserTimestamp: 100,
|
|
261
|
-
middleToolCallIds: ["tc1"],
|
|
262
|
-
finalAssistantTimestamp: 400,
|
|
263
|
-
};
|
|
264
|
-
const { compressedEntries } = await compressEligible([chain], 0, {
|
|
265
|
-
indexer,
|
|
266
|
-
blockRefs,
|
|
267
|
-
appendEntry: () => {},
|
|
268
|
-
now: () => 999,
|
|
269
|
-
});
|
|
270
|
-
expect(compressedEntries).toHaveLength(1);
|
|
271
|
-
|
|
272
|
-
// A later assistant turn (ts 500) that carries thinking and sits OLDER than the boundary.
|
|
273
|
-
const messages: any[] = [
|
|
274
|
-
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 },
|
|
275
|
-
{ role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" },
|
|
276
|
-
{ role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "o1" }], isError: false, timestamp: 210 },
|
|
277
|
-
{ role: "assistant", content: [{ type: "text", text: "mid" }], timestamp: 400, usage: {}, stopReason: "end_turn" },
|
|
278
|
-
{ role: "assistant", content: [{ type: "thinking", thinking: "old-think", thinkingSignature: "s" }, { type: "text", text: "after" }], timestamp: 500, usage: {}, stopReason: "stop" },
|
|
279
|
-
];
|
|
280
|
-
|
|
281
|
-
const cc: ChainCompressionConfig = { enabled: true, rollingWindow: 0, stripFinalAssistantThinking: true, fuseRangeSummary: false };
|
|
282
|
-
const strip: ThinkingStripConfig = { enabled: true, keepLastTurns: 16 };
|
|
283
|
-
// Boundary 600: the ts=500 assistant is older -> its thinking must be stripped,
|
|
284
|
-
// even though phase 3 has dropped the tc1 chain from the array first.
|
|
285
|
-
const { messages: out, pruned } = pruneMessages(messages, indexer, cc, undefined, strip, undefined, 0, 600);
|
|
286
|
-
expect(pruned).toBe(true);
|
|
287
|
-
// Chain middle dropped:
|
|
288
|
-
expect(out.filter((m: any) => m.role === "toolResult")).toHaveLength(0);
|
|
289
|
-
// Surviving ts=500 assistant older than boundary 600 -> thinking stripped:
|
|
290
|
-
const late = out.find((m: any) => m.role === "assistant" && m.timestamp === 500);
|
|
291
|
-
expect(late).toBeDefined();
|
|
292
|
-
expect(late.content.some((c: any) => c.type === "thinking")).toBe(false);
|
|
293
|
-
});
|
|
294
252
|
});
|
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,
|
|
@@ -730,14 +699,6 @@ export interface PruneFrontier {
|
|
|
730
699
|
summaryCharCount: number;
|
|
731
700
|
/** Whether the attempt actually pruned or was skipped for being oversized */
|
|
732
701
|
outcome: PruneFrontierOutcome;
|
|
733
|
-
/**
|
|
734
|
-
* Assistant-message timestamp marking the flush-gated thinking-strip boundary:
|
|
735
|
-
* thinking is stripped from every assistant message older than this. Advances
|
|
736
|
-
* only at flushes (stays fixed between them so renders are prefix-stable and the
|
|
737
|
-
* prompt cache survives a tool loop). Absent on pre-feature entries - the render
|
|
738
|
-
* path then falls back to the live-count window. See src/thinking-strip.ts.
|
|
739
|
-
*/
|
|
740
|
-
thinkingStripBoundaryTimestamp?: number;
|
|
741
702
|
}
|
|
742
703
|
|
|
743
704
|
/**
|
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { stripOldThinking, computeThinkingBoundary } 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
|
-
});
|
|
176
|
-
|
|
177
|
-
describe("stripOldThinking (boundaryTimestamp path)", () => {
|
|
178
|
-
test("strips assistants older than boundary, keeps boundary and newer", () => {
|
|
179
|
-
const msgs = convo(20);
|
|
180
|
-
const assistantTs = msgs.filter((m) => m.role === "assistant").map((m) => m.timestamp);
|
|
181
|
-
const boundary = assistantTs[4];
|
|
182
|
-
const out = stripOldThinking(msgs, cfg(true, 16), boundary);
|
|
183
|
-
expect(out).not.toBe(msgs);
|
|
184
|
-
const assistants = out.filter((m) => m.role === "assistant");
|
|
185
|
-
for (const a of assistants) {
|
|
186
|
-
if (a.timestamp < boundary) expect(hasThinking(a)).toBe(false);
|
|
187
|
-
else expect(hasThinking(a)).toBe(true);
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test("prefix is byte-stable across a growing tail at a fixed boundary (the AC)", () => {
|
|
192
|
-
const msgs = convo(20);
|
|
193
|
-
const boundary = msgs.filter((m) => m.role === "assistant").map((m) => m.timestamp)[4];
|
|
194
|
-
const first = stripOldThinking(msgs, cfg(true, 16), boundary);
|
|
195
|
-
const prefixLen = first.length;
|
|
196
|
-
const grown = [...msgs, assistantToolsThinking(100, ["tcNew"]), toolResult(101, "tcNew")];
|
|
197
|
-
const second = stripOldThinking(grown, cfg(true, 16), boundary);
|
|
198
|
-
expect(JSON.stringify(second.slice(0, prefixLen))).toBe(JSON.stringify(first));
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test("undefined boundary falls back to live-count (same as 2-arg)", () => {
|
|
202
|
-
const msgs = convo(20);
|
|
203
|
-
const viaUndefined = stripOldThinking(msgs, cfg(true, 16), undefined);
|
|
204
|
-
const viaTwoArg = stripOldThinking(msgs, cfg(true, 16));
|
|
205
|
-
expect(JSON.stringify(viaUndefined)).toBe(JSON.stringify(viaTwoArg));
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
test("assistant without a timestamp is kept, never stripped", () => {
|
|
209
|
-
const noTs: any = { role: "assistant", content: [{ type: "thinking", thinking: "x", thinkingSignature: "s" }, { type: "text", text: "y" }], usage: {}, stopReason: "stop" };
|
|
210
|
-
const msgs = [userMsg(1), noTs, ...convo(20).slice(1)];
|
|
211
|
-
const out = stripOldThinking(msgs, cfg(true, 16), 9999);
|
|
212
|
-
const kept = out.find((m) => m.role === "assistant" && m.timestamp === undefined);
|
|
213
|
-
expect(hasThinking(kept)).toBe(true);
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
test("post-chain-drop survivor array: surviving older turns stripped, boundary honored", () => {
|
|
217
|
-
const full = convo(20);
|
|
218
|
-
const assistantTs = full.filter((m) => m.role === "assistant").map((m) => m.timestamp);
|
|
219
|
-
const boundary = assistantTs[10];
|
|
220
|
-
const survivor = [...full.slice(0, 6), ...full.slice(8)];
|
|
221
|
-
const out = stripOldThinking(survivor, cfg(true, 16), boundary);
|
|
222
|
-
for (const a of out.filter((m) => m.role === "assistant")) {
|
|
223
|
-
if (a.timestamp < boundary) expect(hasThinking(a)).toBe(false);
|
|
224
|
-
}
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
describe("computeThinkingBoundary", () => {
|
|
229
|
-
const ts = Array.from({ length: 40 }, (_, i) => (i + 1) * 10);
|
|
230
|
-
|
|
231
|
-
test("count <= keep returns prev unchanged", () => {
|
|
232
|
-
expect(computeThinkingBoundary(ts.slice(0, 16), 16, undefined)).toBeUndefined();
|
|
233
|
-
expect(computeThinkingBoundary(ts.slice(0, 10), 16, 123)).toBe(123);
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
test("count > keep returns the (count-keep)-th timestamp", () => {
|
|
237
|
-
expect(computeThinkingBoundary(ts.slice(0, 20), 16, undefined)).toBe(ts[4]);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
test("keepLastTurns=0 is clamped to 1 (no out-of-bounds)", () => {
|
|
241
|
-
expect(computeThinkingBoundary(ts.slice(0, 20), 0, undefined)).toBe(ts[19]);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
test("monotonic clamp: never regresses when keepLastTurns increases", () => {
|
|
245
|
-
const first = computeThinkingBoundary(ts.slice(0, 40), 16, undefined);
|
|
246
|
-
expect(first).toBe(ts[24]);
|
|
247
|
-
const second = computeThinkingBoundary(ts.slice(0, 40), 32, first);
|
|
248
|
-
expect(second).toBe(first);
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
test("an added trailing turn (e.g. closingMessage) advances the boundary by one", () => {
|
|
252
|
-
const before = computeThinkingBoundary(ts.slice(0, 20), 16, undefined);
|
|
253
|
-
const after = computeThinkingBoundary(ts.slice(0, 21), 16, before);
|
|
254
|
-
expect(after).toBe(ts[5]);
|
|
255
|
-
expect(after).toBeGreaterThan(before as number);
|
|
256
|
-
});
|
|
257
|
-
});
|
package/src/thinking-strip.ts
DELETED
|
@@ -1,83 +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(
|
|
24
|
-
messages: any[],
|
|
25
|
-
config: ThinkingStripConfig,
|
|
26
|
-
boundaryTimestamp?: number,
|
|
27
|
-
): any[] {
|
|
28
|
-
if (!config.enabled) return messages;
|
|
29
|
-
|
|
30
|
-
// Flush-gated path: strip by the persisted timestamp boundary. Fixed between
|
|
31
|
-
// flushes, so consecutive renders produce a byte-identical historical prefix.
|
|
32
|
-
// `!(ts < boundary)` keeps a timestamp-less assistant (undefined < n === false),
|
|
33
|
-
// which is the provider-safe default (never over-strip an unknown-age turn).
|
|
34
|
-
if (boundaryTimestamp !== undefined && boundaryTimestamp !== null) {
|
|
35
|
-
let changed = false;
|
|
36
|
-
const out = messages.map((msg) => {
|
|
37
|
-
if (msg?.role !== "assistant" || !(msg.timestamp < boundaryTimestamp)) return msg;
|
|
38
|
-
if (!Array.isArray(msg.content) || !msg.content.some((c: any) => c.type === "thinking")) return msg;
|
|
39
|
-
changed = true;
|
|
40
|
-
return withoutThinkingBlocks(msg);
|
|
41
|
-
});
|
|
42
|
-
return changed ? out : messages;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Fallback: live-count window (pre-first-flush / pre-feature sessions).
|
|
46
|
-
const keep = Math.max(1, config.keepLastTurns);
|
|
47
|
-
|
|
48
|
-
const assistantIdx: number[] = [];
|
|
49
|
-
for (let i = 0; i < messages.length; i++) {
|
|
50
|
-
if (messages[i]?.role === "assistant") assistantIdx.push(i);
|
|
51
|
-
}
|
|
52
|
-
if (assistantIdx.length <= keep) return messages;
|
|
53
|
-
|
|
54
|
-
const firstKeptAssistant = assistantIdx[assistantIdx.length - keep];
|
|
55
|
-
let changed = false;
|
|
56
|
-
const out = messages.map((msg, i) => {
|
|
57
|
-
if (i >= firstKeptAssistant || msg?.role !== "assistant") return msg;
|
|
58
|
-
if (!Array.isArray(msg.content) || !msg.content.some((c: any) => c.type === "thinking")) return msg;
|
|
59
|
-
changed = true;
|
|
60
|
-
return withoutThinkingBlocks(msg);
|
|
61
|
-
});
|
|
62
|
-
return changed ? out : messages;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Flush-time computation of the thinking-strip boundary: the timestamp of the
|
|
67
|
-
* (count - keepLastTurns)-th assistant message, monotonically clamped so the
|
|
68
|
-
* boundary never moves backward (a mid-session `keepLastTurns` increase must not
|
|
69
|
-
* re-add thinking to an already-stripped message). Stateless recompute - no
|
|
70
|
-
* running counter. `keepLastTurns` is clamped to >= 1 to match `stripOldThinking`
|
|
71
|
-
* and avoid an out-of-bounds index.
|
|
72
|
-
*/
|
|
73
|
-
export function computeThinkingBoundary(
|
|
74
|
-
assistantTimestamps: number[],
|
|
75
|
-
keepLastTurns: number,
|
|
76
|
-
prev?: number,
|
|
77
|
-
): number | undefined {
|
|
78
|
-
const keep = Math.max(1, keepLastTurns);
|
|
79
|
-
const count = assistantTimestamps.length;
|
|
80
|
-
if (count <= keep) return prev;
|
|
81
|
-
const candidate = assistantTimestamps[count - keep];
|
|
82
|
-
return Math.max(prev ?? candidate, candidate);
|
|
83
|
-
}
|