@linxiraos/pi-agent-core 1.1.27 → 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.
@@ -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,
@@ -16,7 +17,25 @@ import {
16
17
  } from "./tool-protection";
17
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. */
@@ -48,7 +67,8 @@ export interface PruneConfig {
48
67
  * already-sent cache prefix: mutating it forces the provider to re-write the
49
68
  * whole suffix (cacheWrite premium). Such results — including superseded and
50
69
  * useless ones, which otherwise bypass {@link protectTokens} — are left for
51
- * 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 =
52
72
  * no cache guard (legacy: superseded/useless prune at any depth).
53
73
  */
54
74
  cacheWarmSuffixTokens?: number;
@@ -117,7 +137,7 @@ export type SupersedeKeyFn = (toolName: string, args: Record<string, unknown>) =
117
137
  */
118
138
  export type SupersedeCompleteFn = (message: ToolResultMessage) => boolean;
119
139
 
120
- export interface SupersedePruneConfig {
140
+ export interface SupersedePruneConfig extends CacheLookbackConfig {
121
141
  /** Supersede key function; a newer successful result with the same key supersedes older ones (see {@link SupersedeKeyFn}). */
122
142
  supersedeKey?: SupersedeKeyFn;
123
143
  /**
@@ -135,7 +155,11 @@ export interface SupersedePruneConfig {
135
155
  supersedeComplete?: SupersedeCompleteFn;
136
156
  /** Also prune results flagged useless by their tool. Default false. */
137
157
  pruneUseless?: boolean;
138
- /** 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
+ */
139
163
  suffixTokenLimit?: number;
140
164
  /**
141
165
  * Prune all candidates when the last message is at least this old: the
@@ -160,6 +184,99 @@ export interface SupersedePruneConfig {
160
184
  const DEFAULT_SUFFIX_TOKEN_LIMIT = 8_000;
161
185
  const DEFAULT_IDLE_FLUSH_MS = 30 * 60_000;
162
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
+
163
280
  function createPrunedNotice(tokens: number): string {
164
281
  return `[Output truncated - ${tokens} tokens]`;
165
282
  }
@@ -179,24 +296,6 @@ function estimatePrunedSavings(tokens: number, notice: string): number {
179
296
  return Math.max(0, tokens - noticeTokens);
180
297
  }
181
298
 
182
- /**
183
- * For each entry index, the estimated token total of all *message* entries
184
- * strictly after it — how much prompt-cache content the provider must re-write
185
- * (cacheWrite premium) if that entry is mutated in place. Used to keep prune
186
- * mutations inside the cheap-to-recache tail.
187
- */
188
- function computeMessageSuffixTokens(entries: readonly SessionEntry[], tokenizer: Tokenizer): number[] {
189
- // [suppressed] length preallocation
190
- const suffix = new Array<number>(entries.length);
191
- let accumulated = 0;
192
- for (let i = entries.length - 1; i >= 0; i--) {
193
- suffix[i] = accumulated;
194
- const entry = entries[i];
195
- if (entry.type === "message") accumulated += tokenizer.countMessage(entry.message as AgentMessage);
196
- }
197
- return suffix;
198
- }
199
-
200
299
  /**
201
300
  * Resolve the array index of the compaction boundary (`keepBoundaryId`). Entries
202
301
  * before this index are summarized away by the latest compaction and never sent,
@@ -205,8 +304,36 @@ function computeMessageSuffixTokens(entries: readonly SessionEntry[], tokenizer:
205
304
  */
206
305
  function resolveBoundaryIndex(entries: readonly SessionEntry[], keepBoundaryId: string | undefined): number {
207
306
  if (keepBoundaryId === undefined) return 0;
208
- const index = entries.findIndex(entry => entry.id === keepBoundaryId);
209
- 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
+ }
210
337
  }
211
338
 
212
339
  interface SupersedeCandidate {
@@ -240,19 +367,22 @@ interface NewerResults {
240
367
  */
241
368
  function collectSupersededResults(
242
369
  entries: readonly SessionEntry[],
370
+ start: number,
243
371
  tokenizer: Tokenizer,
244
- toolCallsById: ReadonlyMap<string, AgentToolCall>,
372
+ toolCalls: SentToolCalls,
245
373
  supersedeKey: SupersedeKeyFn,
246
374
  supersedeComplete: SupersedeCompleteFn | undefined,
247
375
  protectedTools: readonly ProtectedToolMatcher[],
248
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.
249
379
  const candidates: SupersedeCandidate[] = [];
250
380
  const newerByKey = new Map<string, NewerResults>();
251
- for (let i = entries.length - 1; i >= 0; i--) {
381
+ for (let i = entries.length - 1; i >= start; i--) {
252
382
  const entry = entries[i];
253
383
  const message = getToolResultMessage(entry);
254
384
  if (!message || message.prunedAt !== undefined) continue;
255
- const toolCall = toolCallsById.get(message.toolCallId);
385
+ const toolCall = toolCalls.get(message.toolCallId);
256
386
  if (!toolCall) continue;
257
387
  if (isProtectedToolResult(message, toolCall, protectedTools)) continue;
258
388
  const key = supersedeKey(toolCall.name, toolCall.arguments as Record<string, unknown>);
@@ -289,18 +419,19 @@ function collectSupersededResults(
289
419
  */
290
420
  function collectUselessResults(
291
421
  entries: readonly SessionEntry[],
422
+ start: number,
292
423
  tokenizer: Tokenizer,
293
- toolCallsById: ReadonlyMap<string, AgentToolCall>,
424
+ toolCalls: SentToolCalls,
294
425
  protectedTools: readonly ProtectedToolMatcher[],
295
426
  exclude: ReadonlySet<ToolResultMessage>,
296
427
  ): SupersedeCandidate[] {
297
428
  const candidates: SupersedeCandidate[] = [];
298
- for (let i = 0; i < entries.length; i++) {
429
+ for (let i = start; i < entries.length; i++) {
299
430
  const entry = entries[i];
300
431
  const message = getToolResultMessage(entry);
301
432
  if (message?.useless !== true || message.prunedAt !== undefined || message.isError === true) continue;
302
433
  if (exclude.has(message)) continue;
303
- if (isProtectedToolResult(message, toolCallsById.get(message.toolCallId), protectedTools)) continue;
434
+ if (isProtectedToolResult(message, toolCalls.get(message.toolCallId), protectedTools)) continue;
304
435
  const tokens = tokenizer.countMessage(message as AgentMessage);
305
436
  if (estimatePrunedSavings(tokens, USELESS_NOTICE) <= 0) continue;
306
437
  candidates.push({ entry: entry as SessionMessageEntry, message, index: i, tokens, notice: USELESS_NOTICE });
@@ -315,19 +446,22 @@ function collectUselessResults(
315
446
  * candidate is pruned now only when the suffix after it is small (tail case —
316
447
  * the read→edit→read loop) or when the context has been idle long enough that
317
448
  * the provider cache is cold anyway (then all still-sent candidates flush).
318
- * 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.
319
451
  */
320
452
  export function pruneSupersededToolResults(
321
- entries: SessionEntry[],
453
+ entries: readonly SessionEntry[],
322
454
  tokenizer: Tokenizer,
323
455
  config: SupersedePruneConfig,
324
456
  ): PruneResult {
325
- const toolCallsById = collectToolCallsById(entries);
457
+ const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
458
+ const toolCalls = new SentToolCalls(entries, boundaryIndex);
326
459
  const candidates = config.supersedeKey
327
460
  ? collectSupersededResults(
328
461
  entries,
462
+ boundaryIndex,
329
463
  tokenizer,
330
- toolCallsById,
464
+ toolCalls,
331
465
  config.supersedeKey,
332
466
  config.supersedeComplete,
333
467
  config.protectedTools,
@@ -335,7 +469,16 @@ export function pruneSupersededToolResults(
335
469
  : [];
336
470
  if (config.pruneUseless) {
337
471
  const exclude = new Set(candidates.map(candidate => candidate.message));
338
- 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
+ }
339
482
  candidates.sort((a, b) => a.index - b.index);
340
483
  }
341
484
  if (candidates.length === 0) return NOTHING_PRUNED;
@@ -352,38 +495,47 @@ export function pruneSupersededToolResults(
352
495
  const idle =
353
496
  lastMessageTimestamp !== undefined && now - lastMessageTimestamp >= (config.idleFlushMs ?? DEFAULT_IDLE_FLUSH_MS);
354
497
 
355
- const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
356
-
357
498
  let toPrune: SupersedeCandidate[];
358
499
  if (idle) {
359
500
  // Provider cache is cold (idle exceeds the retention TTL), so re-writing
360
- // the sent region costs nothing. Entries before the compaction boundary
361
- // are summarized away and never sent — skip them to avoid pointless churn.
362
- 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;
363
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.
364
510
  const suffixTokenLimit = config.suffixTokenLimit ?? DEFAULT_SUFFIX_TOKEN_LIMIT;
365
- // suffixTokens[i] = estimated tokens of all messages strictly after entry i.
366
- // Mutating a candidate re-writes its suffix in the warm cache, so prune only
367
- // when that suffix is small (cheap-to-recache tail) and the candidate sits
368
- // at/after the compaction boundary.
369
- const suffixTokens = computeMessageSuffixTokens(entries, tokenizer);
370
- toPrune = candidates.filter(
371
- candidate => candidate.index >= boundaryIndex && suffixTokens[candidate.index] <= suffixTokenLimit,
511
+ const lookbackFloor = cacheLookbackFloor(
512
+ entries,
513
+ boundaryIndex,
514
+ config.cacheLookbackPositions,
515
+ config.convertToLlm ?? defaultConvertToLlm,
372
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();
373
526
  }
374
527
  if (toPrune.length === 0) return NOTHING_PRUNED;
375
528
 
376
- const prunedAt = Date.now();
377
529
  let tokensSaved = 0;
378
- const steps = toPrune.map(candidate => {
379
- tokensSaved += estimatePrunedSavings(candidate.tokens, candidate.notice);
380
- return blankToolResult(candidate.message, candidate.notice, prunedAt);
381
- });
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));
382
534
  return { prunedCount: toPrune.length, tokensSaved, undo: undoAll(steps) };
383
535
  }
384
536
 
385
537
  export function pruneToolOutputs(
386
- entries: SessionEntry[],
538
+ entries: readonly SessionEntry[],
387
539
  tokenizer: Tokenizer,
388
540
  config: PruneConfig = DEFAULT_PRUNE_CONFIG,
389
541
  ): PruneResult {
@@ -391,13 +543,17 @@ export function pruneToolOutputs(
391
543
  let tokensSaved = 0;
392
544
 
393
545
  const candidates: Array<{ entry: SessionMessageEntry; tokens: number; superseded: boolean; useless: boolean }> = [];
394
- 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);
395
550
  const supersededMessages = config.supersedeKey
396
551
  ? new Set(
397
552
  collectSupersededResults(
398
553
  entries,
554
+ boundaryIndex,
399
555
  tokenizer,
400
- toolCallsById,
556
+ toolCalls,
401
557
  config.supersedeKey,
402
558
  config.supersedeComplete,
403
559
  config.protectedTools,
@@ -409,42 +565,52 @@ export function pruneToolOutputs(
409
565
  ? new Set(
410
566
  collectUselessResults(
411
567
  entries,
568
+ boundaryIndex,
412
569
  tokenizer,
413
- toolCallsById,
570
+ toolCalls,
414
571
  config.protectedTools,
415
572
  supersededMessages ?? new Set(),
416
573
  ).map(candidate => candidate.message),
417
574
  )
418
575
  : undefined;
419
576
 
420
- const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
421
577
  const cacheWarmSuffixTokens = config.cacheWarmSuffixTokens;
422
- // All-message suffix per index, only when the cache guard is armed.
423
- const messageSuffix =
424
- cacheWarmSuffixTokens === undefined ? undefined : computeMessageSuffixTokens(entries, tokenizer);
425
-
426
- 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--) {
427
591
  const entry = entries[i];
592
+ const suffixAfter = messageSuffix;
428
593
  const message = getToolResultMessage(entry);
429
- if (!message) continue;
430
-
431
- const tokens = tokenizer.countMessage(message as AgentMessage);
432
- const isProtected = isProtectedToolResult(message, toolCallsById.get(message.toolCallId), config.protectedTools);
433
-
434
- if (message.prunedAt !== undefined) {
435
- accumulatedTokens += tokens;
594
+ if (!message) {
595
+ if (cacheWarmSuffixTokens !== undefined && entry.type === "message") {
596
+ messageSuffix += tokenizer.countMessage(entry.message as AgentMessage);
597
+ }
436
598
  continue;
437
599
  }
438
600
 
601
+ const tokens = tokenizer.countMessage(message as AgentMessage);
602
+ messageSuffix += tokens;
603
+
439
604
  // Prompt-cache guard: a result whose all-message suffix exceeds the
440
605
  // warm-cache window sits in the already-sent cached prefix — mutating it
441
- // re-writes the whole suffix (cacheWrite premium). Entries before the
442
- // compaction boundary are summarized away (never sent). Both are skipped
443
- // before any prune decision, so superseded/useless cannot reach a deep,
444
- // still-cached copy; compaction/shake reclaim those when they rebuild.
445
- const inWarmPrefix =
446
- messageSuffix !== undefined && cacheWarmSuffixTokens !== undefined && messageSuffix[i] > cacheWarmSuffixTokens;
447
- 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) {
448
614
  accumulatedTokens += tokens;
449
615
  continue;
450
616
  }
@@ -456,7 +622,13 @@ export function pruneToolOutputs(
456
622
  const superseded = supersededMessages?.has(message) ?? false;
457
623
  const useless = uselessMessages?.has(message) ?? false;
458
624
  const tooSmall = tokens < MIN_PRUNE_TOKENS;
459
- 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
+ ) {
460
632
  accumulatedTokens += tokens;
461
633
  continue;
462
634
  }
@@ -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;
package/src/index.ts CHANGED
@@ -28,6 +28,8 @@ export * from "./thinking";
28
28
  export * from "./tool-context";
29
29
  // Tokenizer choice
30
30
  export * from "./tokenizer";
31
+ // Lenient-aware tool argument validation
32
+ export * from "./tool-arguments";
31
33
  // Types
32
34
  export * from "./types";
33
35
  // Yield utilities for Bun event-loop busy-wait prevention
@@ -1,5 +1,6 @@
1
1
  import * as path from "node:path";
2
- import { type AssistantMessage, validateToolArguments } from "@linxiraos/pi-ai";
2
+ import type { AssistantMessage } from "@linxiraos/pi-ai";
3
+ import { validateAgentToolArguments } from "./tool-arguments";
3
4
  import type {
4
5
  AgentContext,
5
6
  AgentLoopConfig,
@@ -712,15 +713,10 @@ export class SpeculativeOperationCoordinator {
712
713
  }
713
714
  let validatedArgs: Record<string, unknown>;
714
715
  try {
715
- validatedArgs = validateToolArguments(tool, toolCall);
716
+ validatedArgs = validateAgentToolArguments(tool, toolCall);
716
717
  } catch {
717
- // Lenience covers schema mismatches; a parse failure has no args to hand over.
718
- if (!tool.lenientArgValidation || "__parseError" in toolCall.arguments) {
719
- this.ineligible(toolCall, "tool arguments are not valid", source, parentToolCallId);
720
- return undefined;
721
- }
722
- validatedArgs = { ...toolCall.arguments };
723
- delete validatedArgs.__rawJson;
718
+ this.ineligible(toolCall, "tool arguments are not valid", source, parentToolCallId);
719
+ return undefined;
724
720
  }
725
721
  let executionArgs: Record<string, unknown>;
726
722
  try {