pi-condense 2.5.0 → 2.6.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 +10 -0
- package/PRUNING.md +111 -23
- package/README.md +6 -1
- package/index.ts +23 -13
- package/package.json +1 -1
- package/src/batch-capture.test.ts +75 -1
- package/src/batch-capture.ts +22 -13
- package/src/chain-compressor.test.ts +114 -0
- package/src/chain-compressor.ts +29 -4
- package/src/chain-detector.test.ts +49 -0
- package/src/chain-detector.ts +7 -0
- package/src/chain-range-prune.test.ts +342 -7
- package/src/chain-range-prune.ts +161 -48
- package/src/commands.test.ts +31 -2
- package/src/commands.ts +25 -10
- package/src/diagnostics.test.ts +114 -0
- package/src/diagnostics.ts +46 -0
- package/src/frontier.test.ts +1 -0
- package/src/id-collision.integration.test.ts +251 -0
- package/src/indexer.test.ts +336 -0
- package/src/indexer.ts +168 -55
- package/src/occurrence-key.test.ts +57 -0
- package/src/occurrence-key.ts +36 -0
- package/src/orphan-sweep.test.ts +67 -0
- package/src/orphan-sweep.ts +40 -0
- package/src/oversized-spill.integration.test.ts +7 -2
- package/src/pruner.test.ts +456 -25
- package/src/pruner.ts +84 -36
- package/src/query-tool.test.ts +117 -0
- package/src/query-tool.ts +47 -31
- package/src/range-compression.integration.test.ts +6 -1
- package/src/recovery-grace.test.ts +13 -0
- package/src/recovery-grace.ts +12 -3
- package/src/spill.test.ts +108 -1
- package/src/spill.ts +5 -3
- package/src/summary-refs.test.ts +51 -1
- package/src/summary-refs.ts +15 -4
- package/src/test-support.ts +54 -0
- package/src/tree-browser.ts +2 -1
- package/src/types.ts +56 -10
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ 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.6.0] - 2026-08-12
|
|
11
|
+
|
|
12
|
+
- **Fix: reused provider tool-call ids could delete a live turn and produce a rejected request ([#8](https://github.com/jjuraszek/pi-condense/issues/8)).** Provider `toolCallId`s (e.g. `bash_23`) are unique only within one response - some providers restart a `${tool}_${n}` counter, so the same bare id recurs across a session denoting different tool calls. `applyChainCompressions` treated the id as session-durable identity: it unioned every persisted chain entry's `droppedToolCallIds` into one session-wide set and dropped any message matching it anywhere in the array, deleting a live assistant turn that happened to reuse a compressed chain's id and orphaning its tool result - rejected outright by Anthropic (`unexpected tool_use_id found in tool_result blocks`) and Kimi K3 (unresolvable tool name), unrecoverable without hand-editing the session JSONL. The same bare ids also mis-keyed the indexer, dedup, `isSummarized`, and batch capture.
|
|
13
|
+
- **Positional chain ranges.** `applyChainCompressions` now resolves each persisted `ChainCompressionEntry` to an index range via `resolveRange` (`src/chain-range-prune.ts`): exactly one `user`-role message at `startUserTimestamp`, exactly one `assistant`-role message at `finalAssistantTimestamp`, `start < end` - any other outcome (including `finalAssistantTimestamp === null`) drops nothing and inserts no synthetic, fail-closed. Drops are role-restricted to `assistant` / `toolResult` / per-batch-summary messages inside the range, so `user`-role messages - including the user-role `<compressed-chain>` synthetic, which keeps re-application idempotent - and third-party `custom_message` entries survive. Per-batch summary suppression stays coverage-based (`toolCallRefs` overlap), not index-membership, because under `batchingMode: "agent-message"` the summary lands after the range. `droppedToolCallIds` is retained only as a diagnostic cross-check against the range's actual contents; the range always wins.
|
|
14
|
+
- **Occurrence-keyed identity.** Records are now keyed `id@resultTimestamp` (`src/occurrence-key.ts`), the discriminant being the `ToolResultMessage`'s own timestamp (not `ToolCallRecord.timestamp`, which is a batch-level timestamp computed inconsistently between the live-capture and session-rescan paths). Persisted shapes gained optional `resultTimestamp` (index records, summary refs), `newResultTimestamp` / `originalResultTimestamp` (dedup aliases), and `droppedOccurrenceKeys` (chain entries) - all optional, so pre-upgrade entries keep bare-id keying and a session spanning the upgrade retains today's behavior for its pre-upgrade half. `isSummarized` is now a strict occurrence-key lookup; the sole sanctioned bare-id fallback is `hasLegacyBareRecord`, true only for a bare id with no occurrence-keyed siblings (a mixed id fails closed on both). Spill sidecars are named from the occurrence key going forward; recovery still reads the persisted `spillPath`, so pre-upgrade blobs keep resolving.
|
|
15
|
+
- **Orphan sweep.** `pruneMessages` now ends with a structural post-condition, `sweepOrphanToolResults` (`src/orphan-sweep.ts`): one forward pass with **per-turn** open-call tracking (each assistant message replaces the open set rather than accumulating into it, so an id used validly early cannot license a later genuine orphan under the same reused id) removes any `toolResult` no surviving assistant opened. Provider-agnostic and structural rather than keyed to any provider's error string; reference-preserving when nothing is swept, so the no-op / prompt-cache-prefix invariant (`doc/specs/2026-08-04-pruner-noop-serialization.md`) holds.
|
|
16
|
+
- **Diagnostics.** New `context-prune-diagnostic` session entry (`{ kind, detail }`, kinds `unresolved-range` / `range-id-mismatch` / `orphan-sweep`) surfaces these degradations without ever entering LLM context - zero tokens, zero cache-prefix change. Deduped per `(kind, dedupKey)`, reset on `session_start` / `session_tree`. The `/pruner` status line grows a self-hiding ` · diag u<N>/m<N>/o<N>` segment (u/m/o map to the three kinds above in that order); each zero counter is omitted and the whole segment disappears when all three are zero.
|
|
17
|
+
- **`context_tree_query` multi-occurrence recovery.** A raw provider id that was reused now returns every matching occurrence (not just one), each labelled `id@timestamp`, chronologically; a legacy record with no `resultTimestamp` is labelled with the bare id plus an explicit `Occurrence: legacy (no resultTimestamp)` line. Short refs (`tN`) are unaffected - still 1:1 and the primary recovery path.
|
|
18
|
+
- **Accepted limitation:** a session that spans the upgrade keeps bare-id keying for everything captured before it landed; only the post-upgrade half of such a session gets occurrence-keyed identity. No migration runs on load. Concretely, within such a session a live tool result whose provider id collides with a pre-upgrade summarized record can still be stub-replaced with that record's stale content (`hasLegacyBareRecord`'s bare-id fallback in `src/pruner.ts` cannot distinguish the two once both denote the same bare id with no occurrence-keyed siblings); new records are unaffected, and the exposure disappears once a session contains no legacy records.
|
|
19
|
+
|
|
10
20
|
## [2.5.0] - 2026-08-05
|
|
11
21
|
|
|
12
22
|
- **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`.
|
package/PRUNING.md
CHANGED
|
@@ -27,12 +27,15 @@
|
|
|
27
27
|
- [Budget-delta flush](#budget-delta-flush)
|
|
28
28
|
10. [Chain Compression](#chain-compression)
|
|
29
29
|
- [Protected-output relocation](#protected-output-relocation)
|
|
30
|
-
11. [
|
|
31
|
-
12. [
|
|
30
|
+
11. [Occurrence Identity](#occurrence-identity)
|
|
31
|
+
12. [Error Purge](#error-purge)
|
|
32
|
+
13. [Orphan Sweep](#orphan-sweep)
|
|
33
|
+
14. [Diagnostics](#diagnostics)
|
|
34
|
+
15. [Why Summarization Works: Research Evidence](#why-summarization-works-research-evidence)
|
|
32
35
|
- [SUPO — Summarization augmented Policy Optimization](#supo--summarization-augmented-policy-optimization)
|
|
33
36
|
- [ReSum — Recursive Summarization for Long-Horizon Agents](#resum--recursive-summarization-for-long-horizon-agents)
|
|
34
37
|
- [ACON — Agent Context Optimization](#acon--agent-context-optimization)
|
|
35
|
-
|
|
38
|
+
16. [Summary](#summary)
|
|
36
39
|
|
|
37
40
|
---
|
|
38
41
|
|
|
@@ -197,7 +200,7 @@ Pruning does **not** delete data. It moves raw tool results out of the hot path
|
|
|
197
200
|
There are two separate things happening during pruning:
|
|
198
201
|
|
|
199
202
|
1. **Context filtering:** future requests stop including the old `toolResult` messages.
|
|
200
|
-
2. **Index preservation:** the extension stores each summarized tool call in the pruner index, keyed by `
|
|
203
|
+
2. **Index preservation:** the extension stores each summarized tool call in the pruner index, keyed by its occurrence key (`id@resultTimestamp`, or the bare id for legacy records - see [Occurrence Identity](#occurrence-identity)).
|
|
201
204
|
|
|
202
205
|
That distinction is the core idea:
|
|
203
206
|
|
|
@@ -310,7 +313,7 @@ batch gets summarized
|
|
|
310
313
|
├─► summary message added to context
|
|
311
314
|
│ └─► includes short refs (`t1`, `t2`, …)
|
|
312
315
|
│
|
|
313
|
-
├─► tool results indexed by
|
|
316
|
+
├─► tool results indexed by occurrence key (id@resultTimestamp)
|
|
314
317
|
│ └─► full raw resultText stored in index/session
|
|
315
318
|
│
|
|
316
319
|
└─► old toolResult messages removed from future context
|
|
@@ -605,7 +608,7 @@ Properties:
|
|
|
605
608
|
- `role: "toolResult"` and the original `toolCallId` / `toolName` / `timestamp` are preserved — role alternation is intact; no synthetic-result injection.
|
|
606
609
|
- `isError: false`, so the model does not interpret the stub as a tool failure.
|
|
607
610
|
- The stub references the **short ref** (`t1`, `t2`, …) the indexer assigned at summary time. Legacy entries from before short-refs landed fall back to the raw `toolCallId`.
|
|
608
|
-
- Deterministic per
|
|
611
|
+
- Deterministic per occurrence key — the stub text never changes across renders of the same `toolResult`, so the prefix cache continues to hit on the pruned range. A reused `toolCallId` is a *different* occurrence (different `resultTimestamp`) and gets its own stub and its own short ref.
|
|
609
612
|
|
|
610
613
|
Implementation: `src/pruner.ts` `pruneMessages(messages, indexer)` returns `{ messages, pruned }`. When `pruned === false`, the original array reference is returned and the `context` handler skips reconstruction entirely.
|
|
611
614
|
|
|
@@ -659,9 +662,10 @@ Mechanism:
|
|
|
659
662
|
|
|
660
663
|
1. When a batch enters `flushPending`, each tool call is hashed by `SHA-1(toolName + "\0" + normalize(resultText))`.
|
|
661
664
|
2. The indexer's `contentHashToOriginal` map (populated by every earlier `addBatch` / `reconstructFromSession`) is consulted.
|
|
662
|
-
3. A hit means an earlier prune already covered identical content. The duplicate is registered as an alias of the original via `indexer.registerDuplicate(
|
|
663
|
-
- `dedupAliasToOriginal[
|
|
664
|
-
- `toolCallIdToAlias[
|
|
665
|
+
3. A hit means an earlier prune already covered identical content. The duplicate is registered as an alias of the original via `indexer.registerDuplicate(newKey, originalKey, appendEntry)`, where both are occurrence keys (or legacy bare ids):
|
|
666
|
+
- `dedupAliasToOriginal[newKey] = originalKey` (so `isSummarized(newKey) === true` and `resolveToolCallId(newKey) === originalKey`).
|
|
667
|
+
- `toolCallIdToAlias[newKey] = toolCallIdToAlias[originalKey]` (so `getShortRefForToolCallId(newKey)` returns the **same** `tN` as the original).
|
|
668
|
+
- Keying by occurrence key, not bare id, means a duplicate is only ever aliased to the specific earlier *occurrence* it matches byte-for-byte - a reused id whose later occurrence has different content is not conflated with the stale one.
|
|
665
669
|
- A `context-prune-dedup-alias` custom entry is persisted so `reconstructFromSession` rebuilds the maps after a restart.
|
|
666
670
|
4. The duplicate is removed from the batch — no summarizer call, no new index entry.
|
|
667
671
|
5. Later, `pruneMessages` stub-replaces the duplicate's `ToolResultMessage` using the original's short ref, and `context_tree_query` returns the original's record whether the model passes the duplicate's id or the original's.
|
|
@@ -687,7 +691,7 @@ The last attempted prune boundary is persisted as `context-prune-frontier` so `f
|
|
|
687
691
|
- **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.
|
|
688
692
|
- **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.
|
|
689
693
|
- **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).
|
|
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
|
|
694
|
+
- **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 four phases in a single point (stub-replace, error-purge, chain-range-prune, orphan-sweep); appears on the status line as `│ prune: ON · 92.0k->14.0k (-85%)` once at least one prune has occurred (the leading `│` keeps the segment visually isolated in the shared footer, load-order independent - there is no trailing divider, since the footer's own space-join between segments already provides one).
|
|
691
695
|
- **Live progress for `/pruner now`:** an `aboveEditor` widget shows one row per pending batch with braille spinner, streamed summary-char count, and ✓ / ⚠ status.
|
|
692
696
|
|
|
693
697
|
### Summarizer outage fallback
|
|
@@ -871,23 +875,31 @@ raw messages from session
|
|
|
871
875
|
│
|
|
872
876
|
├─ [1] tool-result stub-replace (per-batch; existing)
|
|
873
877
|
├─ [2] error-purge (phase 2)
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
strip thinking from final assistant
|
|
878
|
+
├─ [3] chain-range-prune (runs AFTER stubs)
|
|
879
|
+
│ resolve each entry to a positional index range
|
|
880
|
+
│ drop assistant / toolResult / per-batch-summary inside the range
|
|
881
|
+
│ inject <compressed-chain> after start user
|
|
882
|
+
│ strip thinking from final assistant
|
|
883
|
+
└─ [4] orphan sweep (structural post-condition, see below)
|
|
881
884
|
```
|
|
882
885
|
|
|
883
|
-
### Identification model
|
|
886
|
+
### Identification model: positional ranges, not ids
|
|
884
887
|
|
|
885
|
-
Pi-ai's `Message` union (`UserMessage | AssistantMessage | ToolResultMessage`) has no `.id` field. Chain compression
|
|
888
|
+
Pi-ai's `Message` union (`UserMessage | AssistantMessage | ToolResultMessage`) has no `.id` field, and provider `toolCallId`s are not session-durable (see [Occurrence Identity](#occurrence-identity) below). Chain compression therefore decides **what to drop** by message position, not by id membership.
|
|
886
889
|
|
|
887
|
-
|
|
888
|
-
- `toolCallId` sets to identify middle assistant turns and their tool results
|
|
890
|
+
`resolveRange` (`src/chain-range-prune.ts`) maps a persisted `ChainCompressionEntry` to an index range in the current message array:
|
|
889
891
|
|
|
890
|
-
|
|
892
|
+
- exactly one `user`-role message at `entry.startUserTimestamp`
|
|
893
|
+
- exactly one `assistant`-role message at `entry.finalAssistantTimestamp`
|
|
894
|
+
- `startIndex < endIndex`
|
|
895
|
+
|
|
896
|
+
Any other outcome - zero or multiple matches on either boundary, `finalAssistantTimestamp === null`, or a non-ordered pair - resolves to `null` and the entry drops **nothing** and inserts **no synthetic** for that range. This is fail-closed on purpose: an id-set or timestamp-window fallback (accepting a match even when it's ambiguous) risks deleting a live assistant turn that reuses a compressed chain's provider id, orphaning its tool result and producing a rejected request. A resolution failure is invisible in context - it is recorded as an `unresolved-range` diagnostic (see [Diagnostics](#diagnostics) below) and the affected range simply stays raw in context instead of being silently mis-compressed.
|
|
897
|
+
|
|
898
|
+
**Drops are role-restricted**, not index-membership-restricted: inside an accepted range, only `assistant`, `toolResult`, and per-batch-summary (`custom`, `context-prune-summary`) messages are removed. `user`-role messages inside the range - including the already-inserted `<compressed-chain>` synthetic, which is itself `role: "user"` - and any third-party `custom_message` entries survive untouched. Preserving the synthetic is what makes re-application idempotent: calling `applyChainCompressions` a second time with the same entries sees its own synthetic already present (matched by `blockId`) and skips re-inserting it.
|
|
899
|
+
|
|
900
|
+
**Per-batch summary suppression is coverage-based, not index-membership-based.** Under `batchingMode: "agent-message"` a per-batch summary is appended *after* `finalAssistantTimestamp`, i.e. outside the resolved range. Suppressing it by index membership would miss it entirely, so suppression instead checks whether the summary's own `toolCallRefs` overlap the set of ids actually dropped in this pass (`perBatchSummaryOverlapsDropped`).
|
|
901
|
+
|
|
902
|
+
**Diagnostic cross-check.** Each accepted entry also carries `droppedToolCallIds` from detection time. At render time the ids actually inside the resolved range are compared against that recorded set purely as a health check - a mismatch never changes what gets dropped (the range always wins) but emits a `range-id-mismatch` diagnostic, surfacing drift between detection-time and render-time state without ever acting on it.
|
|
891
903
|
|
|
892
904
|
### Rolling window
|
|
893
905
|
|
|
@@ -955,6 +967,48 @@ The `context-prune-chain` session entry carries the matching `protectedToolCallI
|
|
|
955
967
|
|
|
956
968
|
---
|
|
957
969
|
|
|
970
|
+
## Occurrence Identity
|
|
971
|
+
|
|
972
|
+
Every lookup keyed on a tool call - the indexer's record map, dedup aliases, `isSummarized`, batch capture - needs a string that uniquely names one *occurrence* of a tool call for the lifetime of a session.
|
|
973
|
+
|
|
974
|
+
### Why the provider `toolCallId` cannot be that string
|
|
975
|
+
|
|
976
|
+
A provider's `toolCallId` (e.g. `bash_23`) is unique only within the single response that produced it. Some providers restart a `${tool}_${n}` counter, so the same bare id recurs across turns and across a session, denoting a different tool call each time. The indexer cannot treat it as session-durable identity by itself - see [Identification model](#identification-model-positional-ranges-not-ids) for how chain compression sidesteps the same problem by keying on position instead of id.
|
|
977
|
+
|
|
978
|
+
### Why `ToolCallRecord.timestamp` cannot serve as the discriminant either
|
|
979
|
+
|
|
980
|
+
`ToolCallRecord.timestamp` is the *batch's* timestamp, computed once per captured batch (`src/batch-capture.ts`) and shared by every tool call inside that batch - it cannot discriminate between two calls captured together, let alone across a reused id. It is also not computed the way this discriminant would need: the live `turn_end` path stamps it unconditionally with `Date.now()` (`index.ts`'s `turn_end` handler), not from the assistant message's own timestamp. This is exactly why a separate per-tool-call field was needed - `resultTimestamp`, sourced from the `ToolResultMessage`'s own `timestamp` instead of the batch. On reconstruction, `reconstructFromSession` replays whatever value was persisted on the record verbatim; it does not recompute it.
|
|
981
|
+
|
|
982
|
+
### The key: `id@resultTimestamp`
|
|
983
|
+
|
|
984
|
+
The one value read identically at capture time and at every later render or rescan is the `ToolResultMessage`'s own `timestamp` field, stamped once by the message itself. `src/occurrence-key.ts` combines it with the bare id:
|
|
985
|
+
|
|
986
|
+
```
|
|
987
|
+
occKey(toolCallId, resultTimestamp) = `${toolCallId}@${resultTimestamp}`
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
A record with no `resultTimestamp` is keyed by its bare id - the shape for any record that predates this field, not a separate code path.
|
|
991
|
+
|
|
992
|
+
**Where the key lives:** `ToolCallRecord.resultTimestamp` and `SummaryToolCallRef.resultTimestamp` (both optional), the dedup-alias pair `newResultTimestamp` / `originalResultTimestamp`, and `ChainCompressionEntry.droppedOccurrenceKeys`. Bare-id and occurrence keys coexist in the same maps: `getRecord` / `isSummarized` compare keys as opaque strings, with no casting between the two shapes.
|
|
993
|
+
|
|
994
|
+
**`isSummarized` is strict.** It does an exact lookup on the occurrence key (or dedup-alias map) and never falls back to the bare id on a miss. The sole sanctioned bare-id path is `hasLegacyBareRecord(toolCallId)`, true only when the bare id is indexed **and** has no occurrence-keyed siblings (`bareIdToKeys`). A bare id with mixed legacy and occurrence-keyed entries fails closed on both checks - a permissive fallback would stub or recover the wrong occurrence whenever an id was reused.
|
|
995
|
+
|
|
996
|
+
**Spill sidecars** are named from the occurrence key, so a reused id spills to distinct files per occurrence. Recovery reads the `spillPath` persisted on the record rather than re-deriving it from the id, so bare-id-named sidecars still resolve.
|
|
997
|
+
|
|
998
|
+
**Render-time (`src/pruner.ts` stub-replace) gates the bare-id fallback on `hasLegacyBareRecord`, not on "message has no timestamp".** The first lookup key is `occKey(msg.toolCallId, msg.timestamp)` when the message carries a `timestamp` (a live `ToolResultMessage` almost always does) or the bare id otherwise. If that first key misses `isSummarized`, the bare id is tried as a second rung - but **only** when `indexer.hasLegacyBareRecord(msg.toolCallId)` is true (bare id indexed, no occurrence-keyed siblings), regardless of whether the message itself carried a timestamp. Gating that second rung on "the message has no timestamp" instead would mean a pure-legacy session (every record captured before `resultTimestamp` existed, so every occurrence-key lookup misses) never stubs at all, since a timestamped message would never even attempt the bare-id rung. A mixed bare+occurrence id still fails closed on both lookups.
|
|
999
|
+
|
|
1000
|
+
### No migration, and the limitation that leaves
|
|
1001
|
+
|
|
1002
|
+
A record persisted before `resultTimestamp` existed is bare-keyed and stays that way - `reconstructFromSession` does not scan the branch to retroactively assign it one. No migration runs on load; `hasLegacyBareRecord` is the only accommodation, and it is a derivation (single-key check on `bareIdToKeys`), not a rewrite.
|
|
1003
|
+
|
|
1004
|
+
This leaves one accepted, documented exposure: within a session that spans the upgrade, a live tool result whose provider id collides with a pre-upgrade summarized record can be stub-replaced with that record's stale content - the render-time bare-id fallback in `src/pruner.ts` cannot tell the two apart once both denote the same bare `toolCallId` with no occurrence-keyed siblings. New records (both sides occurrence-keyed) are unaffected; the exposure is confined to the pre-upgrade half of a spanning session and disappears entirely once a session contains no legacy records.
|
|
1005
|
+
|
|
1006
|
+
### Multi-occurrence recovery via `context_tree_query`
|
|
1007
|
+
|
|
1008
|
+
Short refs (`tN`) always resolve 1:1 - one ref, one record - and are the primary, recommended recovery path. A raw provider `toolCallId` passed to `context_tree_query` is looked up against every record that shares it via `getRecordsForId` (`src/indexer.ts`): if the id was reused, the tool returns **every** matching occurrence instead of silently picking one, one block each, chronologically, each labelled `id@timestamp`. This includes occurrences that were content-deduplicated to an earlier record: `getRecordsForId` also scans the dedup-alias map for aliases sharing the queried bare id and returns each aliased occurrence too, labelled with **its own** occurrence timestamp rather than the original's, so a reader can tell the two apart. A record with no `resultTimestamp` caught up in such a set is labelled with the bare id plus an explicit `Occurrence: legacy (no resultTimestamp)` line.
|
|
1009
|
+
|
|
1010
|
+
---
|
|
1011
|
+
|
|
958
1012
|
## Error Purge
|
|
959
1013
|
|
|
960
1014
|
Failed tool calls often embed large argument bodies in the assistant message — a `write` call with a 30 KB file body, an `edit` call with a multiline diff. The error result is small (e.g. `"Error: file not found"`), but the original `arguments` stay in the assistant turn indefinitely.
|
|
@@ -981,7 +1035,7 @@ Error purge replaces those arg bodies with compact stubs after the error has coo
|
|
|
981
1035
|
**Transform position:** Error purge runs in Phase 2, after stub-replace and before chain range prune.
|
|
982
1036
|
|
|
983
1037
|
```
|
|
984
|
-
[stub-replace] → [error-purge] → [chain-range-prune]
|
|
1038
|
+
[stub-replace] → [error-purge] → [chain-range-prune] → [orphan-sweep]
|
|
985
1039
|
```
|
|
986
1040
|
|
|
987
1041
|
**Config keys:**
|
|
@@ -994,6 +1048,40 @@ Error purge replaces those arg bodies with compact stubs after the error has coo
|
|
|
994
1048
|
|
|
995
1049
|
---
|
|
996
1050
|
|
|
1051
|
+
## Orphan Sweep
|
|
1052
|
+
|
|
1053
|
+
`pruneMessages` ends with an unconditional structural pass, `sweepOrphanToolResults` (`src/orphan-sweep.ts`): a `toolResult` message whose id was not opened by the immediately preceding assistant turn is removed. It runs after every other phase, over whatever the previous three produced, as a final post-condition rather than a targeted fix for one code path.
|
|
1054
|
+
|
|
1055
|
+
**Why it exists.** pi-ai's auto-repair (`insertSyntheticToolResults`) only fills in a missing `toolResult` for an orphaned `toolCall` - it has no equivalent repair for the opposite shape, an orphaned `toolResult` with no matching `toolCall`. Providers reject that shape outright (Anthropic: `unexpected tool_use_id found in tool_result blocks`; Kimi K3: a tool message needs a resolvable preceding tool_call). An orphaned `toolResult` can appear from any combination of id reuse, an unresolved chain range, or a bug in an upstream phase; the sweep is the last line of defense regardless of cause, and it is intentionally not keyed to any provider's specific error string - the fix is structural ("does this id have an open call"), not reactive to how one provider happens to phrase rejection.
|
|
1056
|
+
|
|
1057
|
+
**Per-turn, not cumulative, open-call tracking.** The sweep does one forward pass over the message array. Each `assistant` message **replaces** the current "open" id set with its own `toolCall` ids; each `toolResult` message either consumes (removes) a matching id from that set or, if its id is not open, is swept. A cumulative seen-set across the whole array would be wrong here: it would let an id used validly by an early turn license a *later* genuine orphan under the same reused id - exactly the collision scenario this exists to catch. Per-turn tracking means only the immediately preceding assistant turn can vouch for a `toolResult`'s id.
|
|
1058
|
+
|
|
1059
|
+
**Provider-agnostic and reference-preserving.** The sweep has no knowledge of which provider is in play; it operates purely on message shape. When nothing is swept it returns the **same array reference** it was given, so a clean render is a true no-op and the no-op / prompt-cache-prefix invariant of `doc/specs/2026-08-04-pruner-noop-serialization.md` holds - the sweep never forces a cache bust on a session where nothing is actually orphaned.
|
|
1060
|
+
|
|
1061
|
+
Every swept id is reported once (hashed batch, not per-id) as an `orphan-sweep` diagnostic.
|
|
1062
|
+
|
|
1063
|
+
---
|
|
1064
|
+
|
|
1065
|
+
## Diagnostics
|
|
1066
|
+
|
|
1067
|
+
Prune-time degradations - an unresolvable chain range, a detection/render id mismatch, a swept orphan - are recorded on an out-of-band channel instead of surfacing in the model's context. `DiagnosticSink` (`src/diagnostics.ts`) writes a `context-prune-diagnostic` session entry (`{ kind, detail }`) for each of three kinds:
|
|
1068
|
+
|
|
1069
|
+
| Kind | Emitted from | Meaning |
|
|
1070
|
+
|---|---|---|
|
|
1071
|
+
| `unresolved-range` | `applyChainCompressions` | A persisted chain entry's boundaries didn't resolve to a unique range (or the range was rejected as nested/duplicate) - the entry compressed nothing |
|
|
1072
|
+
| `range-id-mismatch` | `applyChainCompressions` | The ids actually inside a resolved range don't match the entry's recorded `droppedToolCallIds` - informational only, the range still wins |
|
|
1073
|
+
| `orphan-sweep` | `pruneMessages` (Phase 4) | One or more `toolResult` messages were removed for having no open matching `toolCall` |
|
|
1074
|
+
|
|
1075
|
+
**Never in LLM context.** These are session entries only - zero tokens added, zero cache-prefix change, never read back into the message array the model sees.
|
|
1076
|
+
|
|
1077
|
+
**Deduped per `(kind, dedupKey)`**, so a permanently degraded condition (e.g. the same chain entry failing to resolve on every render) writes one entry, not one per render. The dedup key is entry-specific: a chain `blockId` for `range-id-mismatch` and for a genuinely unresolvable `unresolved-range`; `overlap:<blockId>` for the same `unresolved-range` kind when the entry was instead skipped as nested inside or duplicating another range's start (a distinct dedup-key prefix keeps the two cases greppable under one kind); and a short hash of the sorted swept-id list for `orphan-sweep` (hashing rather than joining the raw list avoids `DiagnosticSink`'s dedup set retaining an ever-longer key per additional orphan over a session's lifetime). A write is only marked seen after the `appendEntry` call succeeds, so a failed write is retried on the next render instead of being silently dropped.
|
|
1078
|
+
|
|
1079
|
+
**Reset on `session_start` and `session_tree`**, matching every other in-memory, non-persisted piece of prune state.
|
|
1080
|
+
|
|
1081
|
+
**Surfaced on the status line.** The footer status widget (`setPruneStatusWidget`, gated by `showPruneStatusLine`) appends a self-hiding ` · diag u<N>/m<N>/o<N>` segment (u = `unresolved-range`, m = `range-id-mismatch`, o = `orphan-sweep`) built from the sink's live counters via `pruneStatusText`. Each letter is omitted when its counter is zero, and the whole segment is absent when all three are zero. `/pruner status` (the slash command) prints a separate settings/stats block and does not include this segment.
|
|
1082
|
+
|
|
1083
|
+
---
|
|
1084
|
+
|
|
997
1085
|
## Summary
|
|
998
1086
|
|
|
999
1087
|
| Concern | How Pruning Addresses It |
|
package/README.md
CHANGED
|
@@ -80,12 +80,17 @@ Every summarizer cost update is emitted on the shared `pi.events` channel `cost:
|
|
|
80
80
|
| Term | Meaning |
|
|
81
81
|
|---|---|
|
|
82
82
|
| Stub | The short breadcrumb (`[Summarized in pruner summary, ref \`t1\`...]`) that replaces a pruned tool result in context |
|
|
83
|
-
| `context_tree_query` | The tool the model calls to recover a stubbed original by ref |
|
|
83
|
+
| `context_tree_query` | The tool the model calls to recover a stubbed original by ref (`tN`) or `toolCallId`. A reused id returns every matching occurrence, not just one, including any that were content-deduplicated to an earlier record - see [PRUNING.md § Occurrence Identity](PRUNING.md#occurrence-identity) |
|
|
84
84
|
| Batch vs chain | A batch is one flush's worth of tool calls; a chain is a longer closed sequence eligible for range compression |
|
|
85
85
|
| Prune frontier | The last attempted prune boundary - advances even on a skip, so nothing is reconsidered twice |
|
|
86
|
+
| Diagnostics (`diag u/m/o`) | A self-hiding status-line segment surfacing prune-time degradations: `u` = unresolved chain range, `m` = detection/render id mismatch (informational, does not change what's dropped), `o` = orphan tool-result sweep. Each letter's count is omitted when zero; the whole segment disappears when all three are zero. Backing session entries are `context-prune-diagnostic` - see below |
|
|
86
87
|
| Prompt-cache interaction | Why batching (not per-turn pruning) is the default - see [PRUNING.md](PRUNING.md#how-prefix-caching-works) |
|
|
87
88
|
| `cost:external` | The shared cost-reporting channel pi-condense emits on (see above) |
|
|
88
89
|
|
|
90
|
+
### Diagnostic entries (`context-prune-diagnostic`)
|
|
91
|
+
|
|
92
|
+
The status-line `diag u<N>/m<N>/o<N>` segment above is backed by `context-prune-diagnostic` session entries - session-log-only, never added to what the model sees. Full mechanics: [PRUNING.md § Diagnostics](PRUNING.md#diagnostics).
|
|
93
|
+
|
|
89
94
|
## When to use / when NOT to use
|
|
90
95
|
|
|
91
96
|
**Use it for:** long coding or research sessions where tool output dominates the prompt; setups deliberately running a smaller/cheaper driver model; pi-cohort fan-outs or pi-gauntlet runs where cost compounds across many turns or many children.
|
package/index.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* config — load/save <agent-dir>/settings.json `contextPrune` namespace (honors PI_CODING_AGENT_DIR)
|
|
6
6
|
* batch-capture — serialize turn_end event into CapturedBatch
|
|
7
7
|
* summarizer — call LLM to summarize a CapturedBatch
|
|
8
|
-
* indexer — maintain Map<
|
|
8
|
+
* indexer — maintain Map<occurrenceKey, ToolCallRecord> + session persistence
|
|
9
9
|
* pruner — filter context event messages
|
|
10
10
|
* query-tool — register context_tree_query tool
|
|
11
11
|
* commands — register /pruner command + message renderer
|
|
@@ -39,6 +39,8 @@ import { detectChains, withClosingMessage } from "./src/chain-detector.js";
|
|
|
39
39
|
import { inGraceRecoveryToolCallIds } from "./src/recovery-grace.js";
|
|
40
40
|
import { shouldBudgetFlush, shouldDeltaFlush, usageFraction } from "./src/budget.js";
|
|
41
41
|
import { spillOversizedBatch } from "./src/spill.js";
|
|
42
|
+
import { occKey } from "./src/occurrence-key.js";
|
|
43
|
+
import { DiagnosticSink } from "./src/diagnostics.js";
|
|
42
44
|
|
|
43
45
|
export default function (pi: ExtensionAPI) {
|
|
44
46
|
// Shared mutable config reference — updated by /pruner commands
|
|
@@ -64,6 +66,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
64
66
|
// rebuilt from session on session_start / session_tree
|
|
65
67
|
const blockRefs = new BlockRefIssuer();
|
|
66
68
|
|
|
69
|
+
// Session-scoped diagnostic sink — tracks recovery-path anomaly counters
|
|
70
|
+
// (dedup'd across the session's lifetime, not per-render).
|
|
71
|
+
const diagnostics = new DiagnosticSink((type, data) => pi.appendEntry(type, data));
|
|
72
|
+
|
|
67
73
|
// Pending batches — accumulated until the prune trigger fires
|
|
68
74
|
const pendingBatches: CapturedBatch[] = [];
|
|
69
75
|
let isFlushing = false;
|
|
@@ -103,7 +109,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
103
109
|
let toolCalls = batch.toolCalls;
|
|
104
110
|
|
|
105
111
|
// The indexer tells us what was successfully summarized earlier.
|
|
106
|
-
toolCalls = toolCalls.filter((tc) => !indexer.isSummarized(tc.toolCallId));
|
|
112
|
+
toolCalls = toolCalls.filter((tc) => !indexer.isSummarized(occKey(tc.toolCallId, tc.resultTimestamp)));
|
|
107
113
|
if (toolCalls.length === 0) return null;
|
|
108
114
|
|
|
109
115
|
// The frontier tells us the last attempted boundary even when the attempt did
|
|
@@ -233,8 +239,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
233
239
|
const remaining: typeof batch.toolCalls = [];
|
|
234
240
|
for (const tc of batch.toolCalls) {
|
|
235
241
|
const originalId = indexer.lookupByContent(tc.toolName, tc.resultText);
|
|
236
|
-
|
|
237
|
-
|
|
242
|
+
const key = occKey(tc.toolCallId, tc.resultTimestamp);
|
|
243
|
+
if (originalId && originalId !== key) {
|
|
244
|
+
indexer.registerDuplicate(key, originalId, persistAlias);
|
|
238
245
|
dedupedPerBatch[i].toolCalls.push(tc);
|
|
239
246
|
dedupedPerBatch[i].rawChars += tc.resultText.length;
|
|
240
247
|
} else {
|
|
@@ -413,7 +420,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
413
420
|
// `display: false` keeps the summary in future LLM context (convertToLlm
|
|
414
421
|
// ignores `display`) while suppressing the full markdown block from Pi's
|
|
415
422
|
// main window; rebuild keys on customType, not display.
|
|
416
|
-
const
|
|
423
|
+
const batchOccurrenceKeys = batch.toolCalls.map((tc) => occKey(tc.toolCallId, tc.resultTimestamp));
|
|
417
424
|
if (delivery === "runtime") {
|
|
418
425
|
pi.sendMessage(
|
|
419
426
|
{ customType: CUSTOM_TYPE_SUMMARY, content: summaryText, display: false, details: batchDetails },
|
|
@@ -428,7 +435,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
428
435
|
}
|
|
429
436
|
// Keep the in-memory summary-body registry current so chain compression
|
|
430
437
|
// can build synthetic chain messages without rescanning session entries.
|
|
431
|
-
indexer.registerSummaryBody(
|
|
438
|
+
indexer.registerSummaryBody(batchOccurrenceKeys, summaryText);
|
|
432
439
|
} else {
|
|
433
440
|
oversizedBatches.push(batch);
|
|
434
441
|
}
|
|
@@ -452,7 +459,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
452
459
|
|
|
453
460
|
if (processedBatches.length === 0) {
|
|
454
461
|
// Nothing was persisted (all calls failed or first call failed)
|
|
455
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim());
|
|
462
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
456
463
|
return { ok: false, reason: "summarizer-failed" };
|
|
457
464
|
}
|
|
458
465
|
|
|
@@ -525,7 +532,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
525
532
|
return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) };
|
|
526
533
|
}
|
|
527
534
|
|
|
528
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim());
|
|
535
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
529
536
|
emitExternalCost(pi, statsAccum);
|
|
530
537
|
|
|
531
538
|
// Chain compression — compress closed chains beyond the rolling window.
|
|
@@ -636,7 +643,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
636
643
|
// When the abort signal fired, summarizeBatch rethrows rather than
|
|
637
644
|
// swallowing the error. Don't show a UI error — the user intended this.
|
|
638
645
|
if (options.signal?.aborted) {
|
|
639
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim());
|
|
646
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
640
647
|
return { ok: false, reason: "aborted" };
|
|
641
648
|
}
|
|
642
649
|
if (isStaleContextError(err)) {
|
|
@@ -663,6 +670,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
663
670
|
// Rebuild stats accumulator from persisted session entries
|
|
664
671
|
statsAccum.reconstructFromSession(ctx);
|
|
665
672
|
fallbackController.reset();
|
|
673
|
+
diagnostics.reset();
|
|
666
674
|
|
|
667
675
|
// Rebuild prune frontier from persisted session entries
|
|
668
676
|
frontier.reconstructFromSession(ctx);
|
|
@@ -672,7 +680,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
672
680
|
previousFraction = null;
|
|
673
681
|
|
|
674
682
|
// Update footer status
|
|
675
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim());
|
|
683
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
676
684
|
|
|
677
685
|
ctx.ui.setWidget(
|
|
678
686
|
"pruner-boot",
|
|
@@ -695,6 +703,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
695
703
|
indexer.reconstructFromSession(ctx);
|
|
696
704
|
blockRefs.rebuildFrom(indexer.getChainEntries().map((e) => e.blockId));
|
|
697
705
|
statsAccum.reconstructFromSession(ctx);
|
|
706
|
+
diagnostics.reset();
|
|
698
707
|
frontier.reconstructFromSession(ctx);
|
|
699
708
|
// Pending batches belong to the old branch — discard them
|
|
700
709
|
pendingBatches.length = 0;
|
|
@@ -825,7 +834,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
825
834
|
|
|
826
835
|
// pruneMessages is the single source of truth for "is there work to do".
|
|
827
836
|
// It returns the original array reference (pruned: false) only when none of
|
|
828
|
-
// the
|
|
837
|
+
// the four phases changed anything; index/registry emptiness alone does not
|
|
829
838
|
// imply a no-op, since error-purge (phase 2) prunes independently of them.
|
|
830
839
|
// Calling it unconditionally is safe and avoids a split gate here.
|
|
831
840
|
const result = pruneMessages(
|
|
@@ -835,13 +844,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
835
844
|
currentConfig.value.purgeErrors,
|
|
836
845
|
currentConfig.value,
|
|
837
846
|
currentConfig.value.recoveryGraceTurns,
|
|
847
|
+
diagnostics,
|
|
838
848
|
);
|
|
839
849
|
if (result.pruned) {
|
|
840
850
|
messages = result.messages;
|
|
841
851
|
changed = true;
|
|
842
852
|
statsAccum.setLiveReclaim(result.beforeChars, result.afterChars);
|
|
843
853
|
}
|
|
844
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim());
|
|
854
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
845
855
|
|
|
846
856
|
if (!changed) return undefined;
|
|
847
857
|
return { messages };
|
|
@@ -878,5 +888,5 @@ export default function (pi: ExtensionAPI) {
|
|
|
878
888
|
return { compressedEntries: result.compressedEntries, skipped: result.skipped.filter((s) => s.reason === "no-summary").length };
|
|
879
889
|
};
|
|
880
890
|
|
|
881
|
-
registerCommands(pi, currentConfig, flushPending, capturePendingBatches, () => statsAccum.getStats(), () => statsAccum.getLiveReclaim(), indexer, compactChains);
|
|
891
|
+
registerCommands(pi, currentConfig, flushPending, capturePendingBatches, () => statsAccum.getStats(), () => statsAccum.getLiveReclaim(), indexer, compactChains, () => diagnostics.counts());
|
|
882
892
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.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",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { serializeBatchForSummarizer } from "./batch-capture.js";
|
|
2
|
+
import { captureBatch, captureUnindexedBatchesFromSession, serializeBatchForSummarizer } from "./batch-capture.js";
|
|
3
3
|
import type { CapturedBatch, CapturedToolCall } from "./types.js";
|
|
4
4
|
|
|
5
5
|
function toolCall(overrides: Partial<CapturedToolCall> = {}): CapturedToolCall {
|
|
@@ -49,3 +49,77 @@ describe("serializeBatchForSummarizer", () => {
|
|
|
49
49
|
expect(result).toContain("[[3:write]] Tool:");
|
|
50
50
|
});
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
describe("occurrence capture", () => {
|
|
54
|
+
test("captureBatch records the matched result's timestamp", () => {
|
|
55
|
+
const message = {
|
|
56
|
+
role: "assistant",
|
|
57
|
+
content: [{ type: "toolCall", id: "bash_23", name: "bash", input: { cmd: "ls" } }],
|
|
58
|
+
timestamp: 2100,
|
|
59
|
+
};
|
|
60
|
+
const results = [
|
|
61
|
+
{ role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "ok" }], isError: false, timestamp: 2150 },
|
|
62
|
+
];
|
|
63
|
+
const batch = captureBatch(message, results, 0, 9999);
|
|
64
|
+
expect(batch.toolCalls[0].resultTimestamp).toBe(2150);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("captureBatch omits resultTimestamp when no result matched", () => {
|
|
68
|
+
const message = { role: "assistant", content: [{ type: "toolCall", id: "x", name: "bash", input: {} }], timestamp: 1 };
|
|
69
|
+
const batch = captureBatch(message, [], 0, 9999);
|
|
70
|
+
expect(batch.toolCalls[0].resultTimestamp).toBeUndefined();
|
|
71
|
+
expect("resultTimestamp" in batch.toolCalls[0]).toBe(false);
|
|
72
|
+
expect(batch.toolCalls[0].resultText).toBe("(no result)");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("rescan pairs each assistant with the results of its OWN turn when ids repeat", () => {
|
|
76
|
+
const entry = (message: any) => ({ type: "message", message, timestamp: undefined });
|
|
77
|
+
const branch = [
|
|
78
|
+
entry({ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 }),
|
|
79
|
+
entry({ role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 1100 }),
|
|
80
|
+
entry({ role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "FIRST" }], isError: false, timestamp: 1150 }),
|
|
81
|
+
entry({ role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 2100 }),
|
|
82
|
+
entry({ role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "SECOND" }], isError: false, timestamp: 2150 }),
|
|
83
|
+
];
|
|
84
|
+
const batches = captureUnindexedBatchesFromSession(branch, { isSummarized: () => false });
|
|
85
|
+
expect(batches).toHaveLength(2);
|
|
86
|
+
expect(batches[0].toolCalls[0].resultText).toBe("FIRST");
|
|
87
|
+
expect(batches[0].toolCalls[0].resultTimestamp).toBe(1150);
|
|
88
|
+
expect(batches[1].toolCalls[0].resultText).toBe("SECOND");
|
|
89
|
+
expect(batches[1].toolCalls[0].resultTimestamp).toBe(2150);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("rescan asks isSummarized with the occurrence key, not the bare id", () => {
|
|
93
|
+
const asked: string[] = [];
|
|
94
|
+
const entry = (message: any) => ({ type: "message", message });
|
|
95
|
+
const branch = [
|
|
96
|
+
entry({ role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 1100 }),
|
|
97
|
+
entry({ role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "x" }], isError: false, timestamp: 1150 }),
|
|
98
|
+
];
|
|
99
|
+
captureUnindexedBatchesFromSession(branch, { isSummarized: (id: string) => (asked.push(id), false) });
|
|
100
|
+
expect(asked).toContain("bash_23@1150");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("rescan still skips a call whose result has not arrived", () => {
|
|
104
|
+
const entry = (message: any) => ({ type: "message", message });
|
|
105
|
+
const branch = [
|
|
106
|
+
entry({ role: "assistant", content: [{ type: "toolCall", id: "pending", name: "bash", input: {} }], timestamp: 1 }),
|
|
107
|
+
];
|
|
108
|
+
expect(captureUnindexedBatchesFromSession(branch, { isSummarized: () => false })).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("rescan does not pair a result that falls outside its own assistant's turn window", () => {
|
|
112
|
+
// bash_23's result lands AFTER the next assistant message, i.e. in the
|
|
113
|
+
// second assistant's window, not the first's. Per-turn scanning does not
|
|
114
|
+
// fabricate a pair for the first assistant (no in-window result), and the
|
|
115
|
+
// second assistant has no bash_23 call to attach the result to either, so
|
|
116
|
+
// no batch is emitted at all.
|
|
117
|
+
const entry = (message: any) => ({ type: "message", message });
|
|
118
|
+
const branch = [
|
|
119
|
+
entry({ role: "assistant", content: [{ type: "toolCall", id: "bash_23", name: "bash", input: {} }], timestamp: 1000 }),
|
|
120
|
+
entry({ role: "assistant", content: [{ type: "toolCall", id: "other", name: "bash", input: {} }], timestamp: 1100 }),
|
|
121
|
+
entry({ role: "toolResult", toolCallId: "bash_23", toolName: "bash", content: [{ type: "text", text: "late" }], isError: false, timestamp: 1150 }),
|
|
122
|
+
];
|
|
123
|
+
expect(captureUnindexedBatchesFromSession(branch, { isSummarized: () => false })).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
});
|
package/src/batch-capture.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CapturedBatch, CapturedToolCall, BatchingMode } from "./types.js";
|
|
2
|
+
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
2
3
|
|
|
3
4
|
/** Joins the text blocks of a ToolResultMessage into a single string. */
|
|
4
5
|
export function extractToolResultText(msg: any): string {
|
|
@@ -37,6 +38,7 @@ export function captureBatch(
|
|
|
37
38
|
|
|
38
39
|
let resultText = "(no result)";
|
|
39
40
|
let isError = false;
|
|
41
|
+
const resultTimestamp = match ? resultTimestampOf(match.timestamp) : undefined;
|
|
40
42
|
|
|
41
43
|
if (match) {
|
|
42
44
|
resultText = extractToolResultText(match);
|
|
@@ -49,6 +51,7 @@ export function captureBatch(
|
|
|
49
51
|
args: block.input ?? block.args ?? block.arguments ?? {},
|
|
50
52
|
resultText,
|
|
51
53
|
isError,
|
|
54
|
+
...(resultTimestamp !== undefined ? { resultTimestamp } : {}),
|
|
52
55
|
} satisfies CapturedToolCall;
|
|
53
56
|
});
|
|
54
57
|
|
|
@@ -70,14 +73,7 @@ export function captureUnindexedBatchesFromSession(
|
|
|
70
73
|
): CapturedBatch[] {
|
|
71
74
|
// branch is SessionEntry[]. Each message entry has { type: "message", message: AgentMessage }.
|
|
72
75
|
// We must unwrap the SessionEntry wrapper before accessing role/toolCallId.
|
|
73
|
-
const
|
|
74
|
-
for (const entry of branch) {
|
|
75
|
-
if (entry.type !== "message") continue;
|
|
76
|
-
const m = entry.message;
|
|
77
|
-
if (m.role === "toolResult" && m.toolCallId) {
|
|
78
|
-
resultMap.set(m.toolCallId, m);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
76
|
+
const entries = branch.filter((entry: any) => entry.type === "message");
|
|
81
77
|
|
|
82
78
|
const batches: CapturedBatch[] = [];
|
|
83
79
|
// turnCounter increments for EVERY assistant message (not just prunable ones).
|
|
@@ -93,8 +89,8 @@ export function captureUnindexedBatchesFromSession(
|
|
|
93
89
|
// a single user → final-agent-message span when batchingMode === "agent-message".
|
|
94
90
|
let userTurnGroup = 0;
|
|
95
91
|
|
|
96
|
-
for (
|
|
97
|
-
|
|
92
|
+
for (let i = 0; i < entries.length; i++) {
|
|
93
|
+
const entry = entries[i];
|
|
98
94
|
const msg = entry.message;
|
|
99
95
|
|
|
100
96
|
// Advance userTurnGroup on every user message so all subsequent assistant
|
|
@@ -109,6 +105,17 @@ export function captureUnindexedBatchesFromSession(
|
|
|
109
105
|
// Stable turn index: count every assistant message regardless of pruning state
|
|
110
106
|
const currentTurnIndex = turnCounter++;
|
|
111
107
|
|
|
108
|
+
// Per-turn result map: only the results between this assistant message and
|
|
109
|
+
// the next one. A branch-wide map is last-wins and mis-pairs repeated ids.
|
|
110
|
+
const turnResults = new Map<string, any>();
|
|
111
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
112
|
+
const m = entries[j].message;
|
|
113
|
+
if (m.role === "assistant") break;
|
|
114
|
+
if (m.role === "toolResult" && m.toolCallId && !turnResults.has(m.toolCallId)) {
|
|
115
|
+
turnResults.set(m.toolCallId, m);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
112
119
|
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
113
120
|
const toolCallBlocks = content.filter((c: any) => c.type === "toolCall");
|
|
114
121
|
|
|
@@ -116,13 +123,15 @@ export function captureUnindexedBatchesFromSession(
|
|
|
116
123
|
const readyToPrune = toolCallBlocks.filter((tc: any) => {
|
|
117
124
|
const id = tc.id;
|
|
118
125
|
if (!id) return false;
|
|
119
|
-
|
|
126
|
+
const result = turnResults.get(id);
|
|
127
|
+
if (!result) return false;
|
|
128
|
+
if (indexer.isSummarized(occKey(id, resultTimestampOf(result.timestamp)))) return false;
|
|
120
129
|
if (exclude(tc.name, tc.input ?? tc.arguments)) return false;
|
|
121
|
-
return
|
|
130
|
+
return true;
|
|
122
131
|
});
|
|
123
132
|
|
|
124
133
|
if (readyToPrune.length > 0) {
|
|
125
|
-
const results = readyToPrune.map((tc: any) =>
|
|
134
|
+
const results = readyToPrune.map((tc: any) => turnResults.get(tc.id));
|
|
126
135
|
const readyIds = new Set(readyToPrune.map((tc: any) => tc.id));
|
|
127
136
|
// We pass the full message but then trim back down to only the tool calls
|
|
128
137
|
// whose results already exist in the session. This lets a flush prune
|