@linxiraos/pi-agent-core 1.1.26 → 1.2.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +6 -243
  2. package/README.md +1 -1
  3. package/dist/types/agent.d.ts +27 -7
  4. package/dist/types/compaction/anthropic.d.ts +14 -8
  5. package/dist/types/compaction/branch-summarization.d.ts +1 -1
  6. package/dist/types/compaction/compaction-v2-streaming.d.ts +5 -1
  7. package/dist/types/compaction/compaction.d.ts +24 -2
  8. package/dist/types/compaction/entries.d.ts +2 -2
  9. package/dist/types/compaction/messages.d.ts +6 -0
  10. package/dist/types/compaction/openai.d.ts +14 -2
  11. package/dist/types/compaction/pruning.d.ts +64 -16
  12. package/dist/types/compaction/tool-protection.d.ts +2 -1
  13. package/dist/types/compaction/utils.d.ts +5 -0
  14. package/dist/types/image-tokens.d.ts +29 -0
  15. package/dist/types/index.d.ts +1 -0
  16. package/dist/types/speculative-execution.d.ts +1 -1
  17. package/dist/types/telemetry.d.ts +70 -60
  18. package/dist/types/tokenizer.d.ts +9 -6
  19. package/dist/types/tool-arguments.d.ts +19 -0
  20. package/dist/types/types.d.ts +12 -1
  21. package/package.json +8 -8
  22. package/src/agent-loop.ts +191 -34
  23. package/src/agent.ts +126 -21
  24. package/src/append-only-context.ts +38 -29
  25. package/src/compaction/anthropic.ts +30 -12
  26. package/src/compaction/branch-summarization.ts +1 -1
  27. package/src/compaction/compaction-v2-streaming.ts +23 -16
  28. package/src/compaction/compaction.ts +163 -73
  29. package/src/compaction/entries.ts +2 -2
  30. package/src/compaction/message-cache.ts +1 -1
  31. package/src/compaction/messages.ts +25 -0
  32. package/src/compaction/openai.ts +65 -28
  33. package/src/compaction/pruning.ts +328 -96
  34. package/src/compaction/tool-protection.ts +8 -2
  35. package/src/compaction/utils.ts +9 -0
  36. package/src/image-tokens.ts +77 -0
  37. package/src/index.ts +2 -0
  38. package/src/prompts/dsml-tool-call-leak.md +19 -0
  39. package/src/speculative-execution.ts +5 -9
  40. package/src/telemetry.ts +100 -81
  41. package/src/tokenizer.ts +24 -17
  42. package/src/tool-arguments.ts +38 -0
  43. package/src/types.ts +12 -1
@@ -7,6 +7,7 @@ import type { Tokenizer } from "../tokenizer";
7
7
  import type { AgentMessage, AgentToolCall } from "../types";
8
8
  import type { SessionEntry, SessionMessageEntry } from "./entries";
9
9
  import { invalidateMessageCache } from "./message-cache";
10
+ import { type ConvertToLlm, defaultConvertToLlm, getMessageFromEntry } from "./messages";
10
11
  import {
11
12
  collectToolCallsById,
12
13
  getToolResultMessage,
@@ -14,9 +15,27 @@ import {
14
15
  isSkillReadToolResult,
15
16
  type ProtectedToolMatcher,
16
17
  } from "./tool-protection";
17
- import { splitReadSelector } from "./utils";
18
+ import { isAlternateFormReadSelector, splitReadSelector } from "./utils";
18
19
 
19
- export interface PruneConfig {
20
+ /** Bound warm-cache pruning by the provider's prompt-cache lookback window. */
21
+ export interface CacheLookbackConfig {
22
+ /**
23
+ * Prompt-cache lookback of the model the next request goes to, in block
24
+ * positions (catalog `prompt-cache-lookback`; Anthropic: 20). When set,
25
+ * warm-cache pruning also leaves a result whose rewrite would put the next
26
+ * request's cache lookup out of reach, whatever its token suffix (see
27
+ * `cacheLookbackFloor`). Undefined = no lookback bound.
28
+ */
29
+ cacheLookbackPositions?: number;
30
+ /**
31
+ * Projects context messages to what the provider receives, so lookback
32
+ * positions count app roles (file mentions, command output, custom
33
+ * messages) as sent. Default {@link defaultConvertToLlm}.
34
+ */
35
+ convertToLlm?: ConvertToLlm;
36
+ }
37
+
38
+ export interface PruneConfig extends CacheLookbackConfig {
20
39
  /** Keep the most recent tool output tokens intact. */
21
40
  protectTokens: number;
22
41
  /** Only prune if total savings meets this threshold. */
@@ -30,6 +49,8 @@ export interface PruneConfig {
30
49
  * unchanged.
31
50
  */
32
51
  supersedeKey?: SupersedeKeyFn;
52
+ /** Whether a keyed result shows its whole target (see {@link SupersedePruneConfig.supersedeComplete}). */
53
+ supersedeComplete?: SupersedeCompleteFn;
33
54
  /** Useless-flagged results bypass the protect window (see {@link USELESS_NOTICE}). Default true. */
34
55
  pruneUseless?: boolean;
35
56
  /**
@@ -46,7 +67,8 @@ export interface PruneConfig {
46
67
  * already-sent cache prefix: mutating it forces the provider to re-write the
47
68
  * whole suffix (cacheWrite premium). Such results — including superseded and
48
69
  * useless ones, which otherwise bypass {@link protectTokens} — are left for
49
- * compaction/shake (which rebuild the cache anyway) to reclaim. Undefined =
70
+ * compaction/shake (which rebuild the cache anyway) to reclaim, as are
71
+ * results beyond {@link CacheLookbackConfig.cacheLookbackPositions}. Undefined =
50
72
  * no cache guard (legacy: superseded/useless prune at any depth).
51
73
  */
52
74
  cacheWarmSuffixTokens?: number;
@@ -100,19 +122,44 @@ export const USELESS_NOTICE = "[Uneventful result elided]";
100
122
 
101
123
  /**
102
124
  * Maps a tool call to a supersede key. Results sharing a key form a group in
103
- * which every result except the newest is a supersede candidate. A key `K`
104
- * additionally supersedes keys with prefix `K + "\u0000"` (selector-free read
105
- * supersedes selector-carrying reads of the same base path). Return
106
- * `undefined` to exempt a call from supersede grouping.
125
+ * which a newer successful result supersedes every older one. A key `K` also
126
+ * covers keys with prefix `K + "\u0000"`: they are superseded only when the
127
+ * newest successful result for `K` is complete (see
128
+ * {@link SupersedeCompleteFn}). Return `undefined` to exempt a call from
129
+ * supersede grouping.
107
130
  */
108
131
  export type SupersedeKeyFn = (toolName: string, args: Record<string, unknown>) => string | undefined;
109
132
 
110
- export interface SupersedePruneConfig {
111
- /** Supersede key function; results sharing a key supersede older ones. */
133
+ /**
134
+ * Whether a successful keyed result shows its whole target, so a result for a
135
+ * parent key (e.g. a selector-free read) may supersede child-key results (e.g.
136
+ * range reads). A summary, truncated page, or notice is not complete.
137
+ */
138
+ export type SupersedeCompleteFn = (message: ToolResultMessage) => boolean;
139
+
140
+ export interface SupersedePruneConfig extends CacheLookbackConfig {
141
+ /** Supersede key function; a newer successful result with the same key supersedes older ones (see {@link SupersedeKeyFn}). */
112
142
  supersedeKey?: SupersedeKeyFn;
143
+ /**
144
+ * Whether a successful keyed result is complete. Absent, every successful
145
+ * result is complete. Child-key results are superseded only when the parent
146
+ * key's newest successful result is complete; same-key results follow
147
+ * newest-wins, since a same-key re-read that shows a different view means
148
+ * the target changed. A failed result supersedes only older failed results.
149
+ *
150
+ * Trade-off, by design: after `read foo.ts:50-200` → edit → a bare
151
+ * `read foo.ts` that returns a summary, the pre-edit range stays in context
152
+ * until a complete bare read or a same-range re-read. Keeping possibly stale
153
+ * detail is preferred over losing detail the summary does not show.
154
+ */
155
+ supersedeComplete?: SupersedeCompleteFn;
113
156
  /** Also prune results flagged useless by their tool. Default false. */
114
157
  pruneUseless?: boolean;
115
- /** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */
158
+ /**
159
+ * Prune a candidate now when all messages after it total at most this many
160
+ * estimated tokens and it sits within
161
+ * {@link CacheLookbackConfig.cacheLookbackPositions}. Default 8 000.
162
+ */
116
163
  suffixTokenLimit?: number;
117
164
  /**
118
165
  * Prune all candidates when the last message is at least this old: the
@@ -137,6 +184,99 @@ export interface SupersedePruneConfig {
137
184
  const DEFAULT_SUFFIX_TOKEN_LIMIT = 8_000;
138
185
  const DEFAULT_IDLE_FLUSH_MS = 30 * 60_000;
139
186
 
187
+ /**
188
+ * Positions kept free for the next request's own input (prompt, date/cwd
189
+ * reminder, attachments) and for request projections the count below does not
190
+ * model (orphan-result notes, replayed compaction file metadata, tool-change
191
+ * controls): with Anthropic's 20, stored history gets at most 13.
192
+ */
193
+ const RESERVED_LOOKBACK_POSITIONS = 6;
194
+
195
+ type LookbackBlock = "tool_use" | "tool_result" | undefined;
196
+
197
+ /**
198
+ * Index of the oldest tool result that can be rewritten while the next request
199
+ * still reaches a cache entry, given the model's prompt-cache `lookback` in
200
+ * block positions (a breakpoint counts itself; a run of consecutive `tool_use`
201
+ * blocks, or of consecutive `tool_result` blocks, is one position). A
202
+ * conservative estimate, not an exact count: a skipped prune costs its tokens,
203
+ * a missed lookup the whole cache.
204
+ *
205
+ * Each request writes its tail cache entry at its last block, so rewriting a
206
+ * result invalidates every entry from its issuing assistant turn on, and once a
207
+ * request has 15 user turns its other breakpoint sits on an older decimation
208
+ * checkpoint. The next request's tail breakpoint then reaches the newest
209
+ * surviving entry, which ends the message before the issuing turn, only when
210
+ * the issuing turn, its tool-result batch, everything after and the next input
211
+ * fit in the lookback window; otherwise the lookup falls back to that
212
+ * checkpoint and the whole conversation since is re-written, not the small
213
+ * suffix the token limits budget for. Each entry is projected through
214
+ * `convertToLlm` and counted the way the Anthropic request converter emits it.
215
+ */
216
+ function cacheLookbackFloor(
217
+ entries: readonly SessionEntry[],
218
+ start: number,
219
+ lookback: number | undefined,
220
+ convertToLlm: ConvertToLlm,
221
+ ): number {
222
+ if (lookback === undefined) return start;
223
+ let positions = RESERVED_LOOKBACK_POSITIONS;
224
+ let later: LookbackBlock;
225
+ let runHoistsImages = false;
226
+ let newerIsAssistant = false;
227
+ const add = (block: LookbackBlock): void => {
228
+ if (block === undefined || block !== later) positions++;
229
+ later = block;
230
+ };
231
+ let floor = entries.length;
232
+ for (let i = entries.length - 1; i >= start; i--) {
233
+ const message = getMessageFromEntry(entries[i]);
234
+ const sent = message === undefined ? [] : convertToLlm([message]);
235
+ for (let m = sent.length - 1; m >= 0; m--) {
236
+ const llm = sent[m];
237
+ if (llm.role === "toolResult") {
238
+ if (later !== "tool_result") runHoistsImages = false;
239
+ add("tool_result");
240
+ // Anthropic rejects images in error results, so the converter moves
241
+ // them after the result run behind one explanatory text block.
242
+ let images = 0;
243
+ if (llm.isError) {
244
+ for (const block of llm.content) if (block.type === "image") images++;
245
+ }
246
+ if (images > 0) {
247
+ positions += runHoistsImages ? images : images + 1;
248
+ runHoistsImages = true;
249
+ }
250
+ newerIsAssistant = false;
251
+ } else if (llm.role === "assistant") {
252
+ // The converter pads consecutive assistant turns with a user turn and
253
+ // drops assistant images, blank text, and turns left empty.
254
+ let pad = newerIsAssistant;
255
+ for (let b = llm.content.length - 1; b >= 0; b--) {
256
+ const block = llm.content[b];
257
+ if (block.type === "image" || (block.type === "text" && block.text.trim().length === 0)) continue;
258
+ if (pad) {
259
+ add(undefined);
260
+ pad = false;
261
+ }
262
+ add(block.type === "toolCall" ? "tool_use" : undefined);
263
+ newerIsAssistant = true;
264
+ }
265
+ } else {
266
+ // The converter drops blank user/developer turns.
267
+ const blocks =
268
+ typeof llm.content === "string" ? (llm.content.trim().length > 0 ? 1 : 0) : llm.content.length;
269
+ if (blocks === 0) continue;
270
+ for (let b = blocks; b > 0; b--) add(undefined);
271
+ newerIsAssistant = false;
272
+ }
273
+ }
274
+ if (positions >= lookback) return floor;
275
+ if (message?.role === "assistant") floor = i;
276
+ }
277
+ return start;
278
+ }
279
+
140
280
  function createPrunedNotice(tokens: number): string {
141
281
  return `[Output truncated - ${tokens} tokens]`;
142
282
  }
@@ -156,24 +296,6 @@ function estimatePrunedSavings(tokens: number, notice: string): number {
156
296
  return Math.max(0, tokens - noticeTokens);
157
297
  }
158
298
 
159
- /**
160
- * For each entry index, the estimated token total of all *message* entries
161
- * strictly after it — how much prompt-cache content the provider must re-write
162
- * (cacheWrite premium) if that entry is mutated in place. Used to keep prune
163
- * mutations inside the cheap-to-recache tail.
164
- */
165
- function computeMessageSuffixTokens(entries: readonly SessionEntry[], tokenizer: Tokenizer): number[] {
166
- // [suppressed] length preallocation
167
- const suffix = new Array<number>(entries.length);
168
- let accumulated = 0;
169
- for (let i = entries.length - 1; i >= 0; i--) {
170
- suffix[i] = accumulated;
171
- const entry = entries[i];
172
- if (entry.type === "message") accumulated += tokenizer.countMessage(entry.message as AgentMessage);
173
- }
174
- return suffix;
175
- }
176
-
177
299
  /**
178
300
  * Resolve the array index of the compaction boundary (`keepBoundaryId`). Entries
179
301
  * before this index are summarized away by the latest compaction and never sent,
@@ -182,8 +304,36 @@ function computeMessageSuffixTokens(entries: readonly SessionEntry[], tokenizer:
182
304
  */
183
305
  function resolveBoundaryIndex(entries: readonly SessionEntry[], keepBoundaryId: string | undefined): number {
184
306
  if (keepBoundaryId === undefined) return 0;
185
- const index = entries.findIndex(entry => entry.id === keepBoundaryId);
186
- return index < 0 ? 0 : index;
307
+ for (let i = entries.length - 1; i >= 0; i--) {
308
+ if (entries[i].id === keepBoundaryId) return i;
309
+ }
310
+ return 0;
311
+ }
312
+
313
+ /**
314
+ * Tool-call lookup for results in the sent region `entries[start, end)`.
315
+ * Calls there are indexed eagerly; the summarized-away prefix is indexed only
316
+ * on the first miss, so lookups match a whole-branch scan while the common
317
+ * case never walks history the boundary already excludes.
318
+ */
319
+ class SentToolCalls {
320
+ readonly #entries: readonly SessionEntry[];
321
+ readonly #start: number;
322
+ readonly #sent: Map<string, AgentToolCall>;
323
+ #prefix: Map<string, AgentToolCall> | undefined;
324
+
325
+ constructor(entries: readonly SessionEntry[], start: number) {
326
+ this.#entries = entries;
327
+ this.#start = start;
328
+ this.#sent = collectToolCallsById(entries, start);
329
+ }
330
+
331
+ get(id: string): AgentToolCall | undefined {
332
+ const call = this.#sent.get(id);
333
+ if (call !== undefined || this.#start === 0) return call;
334
+ this.#prefix ??= collectToolCallsById(this.#entries, 0, this.#start);
335
+ return this.#prefix.get(id);
336
+ }
187
337
  }
188
338
 
189
339
  interface SupersedeCandidate {
@@ -196,33 +346,59 @@ interface SupersedeCandidate {
196
346
  notice: string;
197
347
  }
198
348
 
349
+ /**
350
+ * Newer results of one key. An entry exists once any newer result (even a
351
+ * failed one) was seen. `complete` describes the newest successful result
352
+ * only: older successes of the key are superseded, so they cannot vouch for
353
+ * child keys.
354
+ */
355
+ interface NewerResults {
356
+ success: boolean;
357
+ complete: boolean;
358
+ }
359
+
199
360
  /**
200
361
  * Collect superseded tool results: for every unpruned, unprotected tool result
201
- * whose paired call resolves a supersede key, a LATER result with the same key
202
- * — or with a key that is the `"\u0000"`-prefix parent of this one — marks it
203
- * superseded. Returned in message order.
362
+ * whose paired call resolves a supersede key, a LATER successful result of the
363
+ * same key supersedes it, as does its `"\u0000"`-prefix parent key when that
364
+ * key's newest successful result is complete. A failed result supersedes only
365
+ * older failed results; an older failed result is superseded by any later
366
+ * result of its key or a later successful result of its parent key.
204
367
  */
205
368
  function collectSupersededResults(
206
369
  entries: readonly SessionEntry[],
370
+ start: number,
207
371
  tokenizer: Tokenizer,
208
- toolCallsById: ReadonlyMap<string, AgentToolCall>,
372
+ toolCalls: SentToolCalls,
209
373
  supersedeKey: SupersedeKeyFn,
374
+ supersedeComplete: SupersedeCompleteFn | undefined,
210
375
  protectedTools: readonly ProtectedToolMatcher[],
211
376
  ): SupersedeCandidate[] {
377
+ // Walk newest → oldest: supersession only depends on NEWER results, so
378
+ // stopping at `start` yields exactly the full scan's candidates at/after it.
212
379
  const candidates: SupersedeCandidate[] = [];
213
- const seenKeys = new Set<string>();
214
- for (let i = entries.length - 1; i >= 0; i--) {
380
+ const newerByKey = new Map<string, NewerResults>();
381
+ for (let i = entries.length - 1; i >= start; i--) {
215
382
  const entry = entries[i];
216
383
  const message = getToolResultMessage(entry);
217
384
  if (!message || message.prunedAt !== undefined) continue;
218
- const toolCall = toolCallsById.get(message.toolCallId);
385
+ const toolCall = toolCalls.get(message.toolCallId);
219
386
  if (!toolCall) continue;
220
387
  if (isProtectedToolResult(message, toolCall, protectedTools)) continue;
221
388
  const key = supersedeKey(toolCall.name, toolCall.arguments as Record<string, unknown>);
222
389
  if (key === undefined) continue;
223
390
  const separator = key.indexOf("\u0000");
224
- const superseded = seenKeys.has(key) || (separator >= 0 && seenKeys.has(key.slice(0, separator)));
225
- seenKeys.add(key);
391
+ const sameKey = newerByKey.get(key);
392
+ const parent = separator >= 0 ? newerByKey.get(key.slice(0, separator)) : undefined;
393
+ const superseded = message.isError
394
+ ? sameKey !== undefined || parent?.success === true
395
+ : sameKey?.success === true || parent?.complete === true;
396
+ const newer = sameKey ?? { success: false, complete: false };
397
+ if (!message.isError && !newer.success) {
398
+ newer.success = true;
399
+ newer.complete = supersedeComplete?.(message) ?? true;
400
+ }
401
+ newerByKey.set(key, newer);
226
402
  if (!superseded) continue;
227
403
  candidates.push({
228
404
  entry: entry as SessionMessageEntry,
@@ -243,18 +419,19 @@ function collectSupersededResults(
243
419
  */
244
420
  function collectUselessResults(
245
421
  entries: readonly SessionEntry[],
422
+ start: number,
246
423
  tokenizer: Tokenizer,
247
- toolCallsById: ReadonlyMap<string, AgentToolCall>,
424
+ toolCalls: SentToolCalls,
248
425
  protectedTools: readonly ProtectedToolMatcher[],
249
426
  exclude: ReadonlySet<ToolResultMessage>,
250
427
  ): SupersedeCandidate[] {
251
428
  const candidates: SupersedeCandidate[] = [];
252
- for (let i = 0; i < entries.length; i++) {
429
+ for (let i = start; i < entries.length; i++) {
253
430
  const entry = entries[i];
254
431
  const message = getToolResultMessage(entry);
255
432
  if (message?.useless !== true || message.prunedAt !== undefined || message.isError === true) continue;
256
433
  if (exclude.has(message)) continue;
257
- if (isProtectedToolResult(message, toolCallsById.get(message.toolCallId), protectedTools)) continue;
434
+ if (isProtectedToolResult(message, toolCalls.get(message.toolCallId), protectedTools)) continue;
258
435
  const tokens = tokenizer.countMessage(message as AgentMessage);
259
436
  if (estimatePrunedSavings(tokens, USELESS_NOTICE) <= 0) continue;
260
437
  candidates.push({ entry: entry as SessionMessageEntry, message, index: i, tokens, notice: USELESS_NOTICE });
@@ -269,20 +446,39 @@ function collectUselessResults(
269
446
  * candidate is pruned now only when the suffix after it is small (tail case —
270
447
  * the read→edit→read loop) or when the context has been idle long enough that
271
448
  * the provider cache is cold anyway (then all still-sent candidates flush).
272
- * Never mutates entries before `keepBoundaryId` (summarized away — not sent).
449
+ * Never mutates entries before `keepBoundaryId` (summarized away — not sent),
450
+ * and never walks them unless a sent result's call lives there.
273
451
  */
274
452
  export function pruneSupersededToolResults(
275
- entries: SessionEntry[],
453
+ entries: readonly SessionEntry[],
276
454
  tokenizer: Tokenizer,
277
455
  config: SupersedePruneConfig,
278
456
  ): PruneResult {
279
- const toolCallsById = collectToolCallsById(entries);
457
+ const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
458
+ const toolCalls = new SentToolCalls(entries, boundaryIndex);
280
459
  const candidates = config.supersedeKey
281
- ? collectSupersededResults(entries, tokenizer, toolCallsById, config.supersedeKey, config.protectedTools)
460
+ ? collectSupersededResults(
461
+ entries,
462
+ boundaryIndex,
463
+ tokenizer,
464
+ toolCalls,
465
+ config.supersedeKey,
466
+ config.supersedeComplete,
467
+ config.protectedTools,
468
+ )
282
469
  : [];
283
470
  if (config.pruneUseless) {
284
471
  const exclude = new Set(candidates.map(candidate => candidate.message));
285
- candidates.push(...collectUselessResults(entries, tokenizer, toolCallsById, config.protectedTools, exclude));
472
+ for (const candidate of collectUselessResults(
473
+ entries,
474
+ boundaryIndex,
475
+ tokenizer,
476
+ toolCalls,
477
+ config.protectedTools,
478
+ exclude,
479
+ )) {
480
+ candidates.push(candidate);
481
+ }
286
482
  candidates.sort((a, b) => a.index - b.index);
287
483
  }
288
484
  if (candidates.length === 0) return NOTHING_PRUNED;
@@ -299,38 +495,47 @@ export function pruneSupersededToolResults(
299
495
  const idle =
300
496
  lastMessageTimestamp !== undefined && now - lastMessageTimestamp >= (config.idleFlushMs ?? DEFAULT_IDLE_FLUSH_MS);
301
497
 
302
- const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
303
-
304
498
  let toPrune: SupersedeCandidate[];
305
499
  if (idle) {
306
500
  // Provider cache is cold (idle exceeds the retention TTL), so re-writing
307
- // the sent region costs nothing. Entries before the compaction boundary
308
- // are summarized away and never sent — skip them to avoid pointless churn.
309
- toPrune = candidates.filter(candidate => candidate.index >= boundaryIndex);
501
+ // the sent region costs nothing. Candidates already start at the
502
+ // compaction boundary (summarized-away entries are never sent).
503
+ toPrune = candidates;
310
504
  } else {
505
+ // Mutating a candidate re-writes its suffix (tokens of every message
506
+ // strictly after it) in the warm cache, so prune only when that suffix is
507
+ // small and the next request's cache lookup stays in reach. The suffix
508
+ // only grows walking back, so stop at the first index past either limit
509
+ // instead of measuring the whole branch.
311
510
  const suffixTokenLimit = config.suffixTokenLimit ?? DEFAULT_SUFFIX_TOKEN_LIMIT;
312
- // suffixTokens[i] = estimated tokens of all messages strictly after entry i.
313
- // Mutating a candidate re-writes its suffix in the warm cache, so prune only
314
- // when that suffix is small (cheap-to-recache tail) and the candidate sits
315
- // at/after the compaction boundary.
316
- const suffixTokens = computeMessageSuffixTokens(entries, tokenizer);
317
- toPrune = candidates.filter(
318
- candidate => candidate.index >= boundaryIndex && suffixTokens[candidate.index] <= suffixTokenLimit,
511
+ const lookbackFloor = cacheLookbackFloor(
512
+ entries,
513
+ boundaryIndex,
514
+ config.cacheLookbackPositions,
515
+ config.convertToLlm ?? defaultConvertToLlm,
319
516
  );
517
+ toPrune = [];
518
+ let suffixTokens = 0;
519
+ let next = candidates.length - 1;
520
+ for (let i = entries.length - 1; next >= 0 && suffixTokens <= suffixTokenLimit && i >= lookbackFloor; i--) {
521
+ while (next >= 0 && candidates[next].index === i) toPrune.push(candidates[next--]);
522
+ const entry = entries[i];
523
+ if (entry.type === "message") suffixTokens += tokenizer.countMessage(entry.message as AgentMessage);
524
+ }
525
+ toPrune.reverse();
320
526
  }
321
527
  if (toPrune.length === 0) return NOTHING_PRUNED;
322
528
 
323
- const prunedAt = Date.now();
324
529
  let tokensSaved = 0;
325
- const steps = toPrune.map(candidate => {
326
- tokensSaved += estimatePrunedSavings(candidate.tokens, candidate.notice);
327
- return blankToolResult(candidate.message, candidate.notice, prunedAt);
328
- });
530
+ for (const candidate of toPrune) tokensSaved += estimatePrunedSavings(candidate.tokens, candidate.notice);
531
+
532
+ const prunedAt = Date.now();
533
+ const steps = toPrune.map(candidate => blankToolResult(candidate.message, candidate.notice, prunedAt));
329
534
  return { prunedCount: toPrune.length, tokensSaved, undo: undoAll(steps) };
330
535
  }
331
536
 
332
537
  export function pruneToolOutputs(
333
- entries: SessionEntry[],
538
+ entries: readonly SessionEntry[],
334
539
  tokenizer: Tokenizer,
335
540
  config: PruneConfig = DEFAULT_PRUNE_CONFIG,
336
541
  ): PruneResult {
@@ -338,12 +543,21 @@ export function pruneToolOutputs(
338
543
  let tokensSaved = 0;
339
544
 
340
545
  const candidates: Array<{ entry: SessionMessageEntry; tokens: number; superseded: boolean; useless: boolean }> = [];
341
- const toolCallsById = collectToolCallsById(entries);
546
+ // Entries before the compaction boundary are summarized away (never sent)
547
+ // and are never prune candidates, so no pass below walks them.
548
+ const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
549
+ const toolCalls = new SentToolCalls(entries, boundaryIndex);
342
550
  const supersededMessages = config.supersedeKey
343
551
  ? new Set(
344
- collectSupersededResults(entries, tokenizer, toolCallsById, config.supersedeKey, config.protectedTools).map(
345
- candidate => candidate.message,
346
- ),
552
+ collectSupersededResults(
553
+ entries,
554
+ boundaryIndex,
555
+ tokenizer,
556
+ toolCalls,
557
+ config.supersedeKey,
558
+ config.supersedeComplete,
559
+ config.protectedTools,
560
+ ).map(candidate => candidate.message),
347
561
  )
348
562
  : undefined;
349
563
  const uselessMessages =
@@ -351,42 +565,52 @@ export function pruneToolOutputs(
351
565
  ? new Set(
352
566
  collectUselessResults(
353
567
  entries,
568
+ boundaryIndex,
354
569
  tokenizer,
355
- toolCallsById,
570
+ toolCalls,
356
571
  config.protectedTools,
357
572
  supersededMessages ?? new Set(),
358
573
  ).map(candidate => candidate.message),
359
574
  )
360
575
  : undefined;
361
576
 
362
- const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
363
577
  const cacheWarmSuffixTokens = config.cacheWarmSuffixTokens;
364
- // All-message suffix per index, only when the cache guard is armed.
365
- const messageSuffix =
366
- cacheWarmSuffixTokens === undefined ? undefined : computeMessageSuffixTokens(entries, tokenizer);
367
-
368
- for (let i = entries.length - 1; i >= 0; i--) {
578
+ const lookbackFloor =
579
+ cacheWarmSuffixTokens === undefined
580
+ ? boundaryIndex
581
+ : cacheLookbackFloor(
582
+ entries,
583
+ boundaryIndex,
584
+ config.cacheLookbackPositions,
585
+ config.convertToLlm ?? defaultConvertToLlm,
586
+ );
587
+ // Tokens of every message strictly after entry `i` (cache guard only).
588
+ let messageSuffix = 0;
589
+
590
+ for (let i = entries.length - 1; i >= boundaryIndex; i--) {
369
591
  const entry = entries[i];
592
+ const suffixAfter = messageSuffix;
370
593
  const message = getToolResultMessage(entry);
371
- if (!message) continue;
372
-
373
- const tokens = tokenizer.countMessage(message as AgentMessage);
374
- const isProtected = isProtectedToolResult(message, toolCallsById.get(message.toolCallId), config.protectedTools);
375
-
376
- if (message.prunedAt !== undefined) {
377
- accumulatedTokens += tokens;
594
+ if (!message) {
595
+ if (cacheWarmSuffixTokens !== undefined && entry.type === "message") {
596
+ messageSuffix += tokenizer.countMessage(entry.message as AgentMessage);
597
+ }
378
598
  continue;
379
599
  }
380
600
 
601
+ const tokens = tokenizer.countMessage(message as AgentMessage);
602
+ messageSuffix += tokens;
603
+
381
604
  // Prompt-cache guard: a result whose all-message suffix exceeds the
382
605
  // warm-cache window sits in the already-sent cached prefix — mutating it
383
- // re-writes the whole suffix (cacheWrite premium). Entries before the
384
- // compaction boundary are summarized away (never sent). Both are skipped
385
- // before any prune decision, so superseded/useless cannot reach a deep,
386
- // still-cached copy; compaction/shake reclaim those when they rebuild.
387
- const inWarmPrefix =
388
- messageSuffix !== undefined && cacheWarmSuffixTokens !== undefined && messageSuffix[i] > cacheWarmSuffixTokens;
389
- if (inWarmPrefix || i < boundaryIndex) {
606
+ // re-writes the whole suffix (cacheWrite premium), or the whole
607
+ // conversation once the next request's cache lookup is out of reach.
608
+ // Both only worsen walking back, so every older result is in the warm
609
+ // prefix too. Deeper, still-cached superseded/useless copies are left for
610
+ // compaction/shake.
611
+ if (cacheWarmSuffixTokens !== undefined && (suffixAfter > cacheWarmSuffixTokens || i < lookbackFloor)) break;
612
+
613
+ if (message.prunedAt !== undefined) {
390
614
  accumulatedTokens += tokens;
391
615
  continue;
392
616
  }
@@ -398,7 +622,13 @@ export function pruneToolOutputs(
398
622
  const superseded = supersededMessages?.has(message) ?? false;
399
623
  const useless = uselessMessages?.has(message) ?? false;
400
624
  const tooSmall = tokens < MIN_PRUNE_TOKENS;
401
- if (!superseded && !useless && (accumulatedTokens < config.protectTokens || isProtected || tooSmall)) {
625
+ if (
626
+ !superseded &&
627
+ !useless &&
628
+ (accumulatedTokens < config.protectTokens ||
629
+ tooSmall ||
630
+ isProtectedToolResult(message, toolCalls.get(message.toolCallId), config.protectedTools))
631
+ ) {
402
632
  accumulatedTokens += tokens;
403
633
  continue;
404
634
  }
@@ -440,10 +670,11 @@ export function pruneToolOutputs(
440
670
  * selector stripped (the read tool's own splitter grammar via
441
671
  * {@link splitReadSelector}, e.g. `src/foo.ts:50-200`, `:2-4:raw`).
442
672
  * Internal/URL-scheme paths (`skill://…`, `https://…`) are exempt.
443
- * Selector-free reads key on the bare path; selector-carrying reads key on
444
- * `path + "\u0000" + selector`, so two reads collide only when the newer is
445
- * selector-free or the selectors are identical (the pass's prefix rule lets a
446
- * bare-path read supersede selector-carrying reads of the same file).
673
+ * Selector-free reads key on the bare path; line-range reads key on
674
+ * `path + "\u0000" + selector`, so a complete bare-path read can supersede them.
675
+ * `raw` and `conflicts` reads show a different form than a plain read (e.g. raw
676
+ * notebook JSON vs converted notebook text), so they key on
677
+ * `path + "\u0001" + selector` and are superseded only by the same selector.
447
678
  */
448
679
  export function readToolSupersedeKey(toolName: string, args: Record<string, unknown>): string | undefined {
449
680
  if (toolName !== "read") return undefined;
@@ -451,5 +682,6 @@ export function readToolSupersedeKey(toolName: string, args: Record<string, unkn
451
682
  if (typeof path !== "string" || path.length === 0) return undefined;
452
683
  if (path.includes("://")) return undefined;
453
684
  const { path: base, sel } = splitReadSelector(path);
454
- return sel === undefined ? base : `${base}\u0000${sel}`;
685
+ if (sel === undefined) return base;
686
+ return `${base}${isAlternateFormReadSelector(sel) ? "\u0001" : "\u0000"}${sel}`;
455
687
  }
@@ -11,9 +11,15 @@ export type ProtectedToolMatcher = string | ((context: ProtectedToolContext) =>
11
11
 
12
12
  const SKILL_INTERNAL_URL_PREFIX = "skill://";
13
13
 
14
- export function collectToolCallsById(entries: readonly SessionEntry[]): Map<string, AgentToolCall> {
14
+ /** Map tool-call ids to their calls across `entries[start, end)` (later calls win). */
15
+ export function collectToolCallsById(
16
+ entries: readonly SessionEntry[],
17
+ start = 0,
18
+ end = entries.length,
19
+ ): Map<string, AgentToolCall> {
15
20
  const toolCalls = new Map<string, AgentToolCall>();
16
- for (const entry of entries) {
21
+ for (let i = start; i < end; i++) {
22
+ const entry = entries[i];
17
23
  if (entry.type !== "message") continue;
18
24
  const message = entry.message;
19
25
  if (message.role !== "assistant") continue;
@@ -38,6 +38,7 @@ const RANGE_LIST_SRC = `${RANGE_CHUNK_SRC}(?:,${RANGE_CHUNK_SRC})*`;
38
38
  const READ_SELECTOR_RE = new RegExp(`^(?:${RANGE_LIST_SRC}|raw|conflicts)$`, "i");
39
39
  const READ_RANGE_ONLY_RE = new RegExp(`^${RANGE_LIST_SRC}$`, "i");
40
40
  const READ_RAW_ONLY_RE = /^raw$/i;
41
+ const READ_ALTERNATE_FORM_RE = /^(?:raw|conflicts)$/i;
41
42
 
42
43
  /**
43
44
  * Split a read-tool path into its base path and trailing selector, mirroring the
@@ -77,6 +78,14 @@ export function stripReadSelector(path: string): string {
77
78
  return splitReadSelector(path).path;
78
79
  }
79
80
 
81
+ /**
82
+ * Whether a selector from {@link splitReadSelector} shows the file in a form other
83
+ * than its plain lines: verbatim `raw` bytes or the merge `conflicts` view.
84
+ */
85
+ export function isAlternateFormReadSelector(sel: string): boolean {
86
+ return sel.split(":").some(part => READ_ALTERNATE_FORM_RE.test(part));
87
+ }
88
+
80
89
  /**
81
90
  * A real filesystem path never contains a `scheme://` URL. Tool-call paths that
82
91
  * do — `conflict://1`, `artifact://3`, `local://ctx.md`, `history://…`,