@gajae-code/agent-core 0.11.11 → 0.12.1

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.
@@ -21,6 +21,8 @@ export interface PruneConfig {
21
21
  minimumSavings: number;
22
22
  /** Tool names that should never be pruned. */
23
23
  protectedTools: string[];
24
+ /** Number of newest user turns whose tool outputs must remain intact. Defaults to 2. */
25
+ protectRecentTurns?: number;
24
26
  /**
25
27
  * Tools in `protectedTools` whose protection is waived once the result is
26
28
  * superseded (a later result for the same target, or a later successful
@@ -34,12 +36,23 @@ export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
34
36
  protectTokens: 40_000,
35
37
  minimumSavings: 20_000,
36
38
  protectedTools: ["skill", "read"],
39
+ protectRecentTurns: 2,
37
40
  staleOverridableTools: ["read"],
38
41
  };
39
42
 
43
+ export interface PrunedOriginal {
44
+ entryId: string;
45
+ toolName?: string;
46
+ originalText: string;
47
+ tokens: number;
48
+ /** Whether originalText captures all-text result content without omission. */
49
+ complete?: boolean;
50
+ }
51
+
40
52
  export interface PruneResult {
41
53
  prunedCount: number;
42
54
  tokensSaved: number;
55
+ originals: PrunedOriginal[];
43
56
  /**
44
57
  * The mutated message entries. Callers whose entry source returns
45
58
  * materialized copies (not live references) must write these back into
@@ -48,17 +61,33 @@ export interface PruneResult {
48
61
  prunedEntries: SessionMessageEntry[];
49
62
  }
50
63
 
51
- const DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER = 1.25;
52
- const ERROR_DIGEST_NOTICE_MIN_CHARS = 240;
64
+ const ERROR_DIGEST_MAX_CHARS = 240;
65
+ const TAIL_DIGEST_MAX_CHARS = 160;
66
+ const PATH_DIGEST_MAX_CHARS = 120;
67
+ /**
68
+ * Absolute budget for the assembled digest (~64 tokens). Fields are ordered
69
+ * error-first, so truncating the assembled digest drops tail/counts before it
70
+ * ever touches the error signal.
71
+ */
72
+ const DIGEST_TOTAL_MAX_CHARS = 256;
53
73
 
54
74
  function createGenericPrunedNotice(tokens: number): string {
55
75
  return `[Output truncated - ${tokens} tokens]`;
56
76
  }
57
77
 
78
+ function capturedTextContent(message: ToolResultMessage): { text: string; complete: boolean } {
79
+ if (typeof message.content === "string") return { text: message.content, complete: true };
80
+ const textBlocks: string[] = [];
81
+ let complete = true;
82
+ for (const block of message.content) {
83
+ if (block.type === "text") textBlocks.push(block.text);
84
+ else complete = false;
85
+ }
86
+ return { text: textBlocks.join("\n"), complete };
87
+ }
88
+
58
89
  function firstTextContent(message: ToolResultMessage): string {
59
- if (typeof message.content === "string") return message.content;
60
- const block = message.content.find(part => part.type === "text");
61
- return block?.type === "text" ? block.text : "";
90
+ return capturedTextContent(message).text;
62
91
  }
63
92
 
64
93
  function firstErrorLine(text: string): string | undefined {
@@ -85,28 +114,39 @@ function truncateField(value: string, maxLength: number): string {
85
114
  return `${value.slice(0, maxLength - 1)}…`;
86
115
  }
87
116
 
88
- function resultDigest(message: ToolResultMessage): string | undefined {
117
+ function resultPathHint(message: ToolResultMessage, call?: ToolCall): string | undefined {
118
+ return (call && toolCallPath(call)) ?? readResolvedPath(message);
119
+ }
120
+
121
+ function resultDigest(message: ToolResultMessage, call?: ToolCall): string | undefined {
89
122
  const toolName = message.toolName.toLowerCase();
90
123
  const text = sanitizeText(firstTextContent(message));
124
+ const error = firstErrorLine(text);
125
+ const path = resultPathHint(message, call);
126
+ const pathPart = path ? `path=${truncateField(path, PATH_DIGEST_MAX_CHARS)}` : undefined;
91
127
  if (toolName === "bash") {
92
128
  const details = message as { details?: { exitCode?: unknown } };
93
129
  const exitCode =
94
130
  typeof details.details?.exitCode === "number" ? details.details.exitCode : message.isError ? 1 : 0;
95
131
  const tail = text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
96
- const error = firstErrorLine(text);
97
- return [`exit=${exitCode}`, tail ? `tail=${tail}` : undefined, error ? `error=${error}` : undefined]
132
+ return [
133
+ `exit=${exitCode}`,
134
+ error ? `error=${truncateField(error, ERROR_DIGEST_MAX_CHARS)}` : undefined,
135
+ pathPart,
136
+ tail ? `tail=${truncateField(tail, TAIL_DIGEST_MAX_CHARS)}` : undefined,
137
+ ]
98
138
  .filter((part): part is string => part !== undefined)
99
139
  .join("; ");
100
140
  }
101
141
  if (toolName === "search" || toolName === "grep") {
102
142
  const match = text.match(/(\d+)\s+matches?/i) ?? text.match(/totalMatches["']?:\s*(\d+)/i);
103
143
  const files = text.match(/(\d+)\s+files?/i) ?? text.match(/filesWithMatches["']?:\s*(\d+)/i);
104
- const error = firstErrorLine(text);
105
144
  return (
106
145
  [
146
+ error ? `error=${truncateField(error, ERROR_DIGEST_MAX_CHARS)}` : undefined,
147
+ pathPart,
107
148
  match ? `matches=${match[1]}` : undefined,
108
149
  files ? `files=${files[1]}` : undefined,
109
- error ? `error=${error}` : undefined,
110
150
  ]
111
151
  .filter((part): part is string => part !== undefined)
112
152
  .join("; ") || "search digest unavailable"
@@ -114,24 +154,20 @@ function resultDigest(message: ToolResultMessage): string | undefined {
114
154
  }
115
155
  if (message.isError !== true) return undefined;
116
156
  if (text.trim().length === 0) return "error=tool result failed without text";
117
- const error = firstErrorLine(text);
118
- if (error) return `error=${error}`;
157
+ if (error) return [`error=${truncateField(error, ERROR_DIGEST_MAX_CHARS)}`, pathPart].filter(Boolean).join("; ");
119
158
  const summary = firstNonEmptyLine(text) ?? lastNonEmptyLine(text);
120
- return summary ? `summary=${summary}` : undefined;
159
+ return summary ? `summary=${truncateField(summary, ERROR_DIGEST_MAX_CHARS)}` : undefined;
121
160
  }
122
161
 
123
- function createPrunedNotice(tokens: number, message?: ToolResultMessage): string {
162
+ function createPrunedNotice(tokens: number, message?: ToolResultMessage, call?: ToolCall, artifact?: string): string {
124
163
  const generic = createGenericPrunedNotice(tokens);
125
- const digest = message ? resultDigest(message) : undefined;
126
- if (!digest) return generic;
127
- const genericTokens = Math.ceil(generic.length / 4);
128
- const maxTokens = Math.max(genericTokens, Math.floor(genericTokens * DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER));
129
- const prefix = `[Output truncated - ${tokens} tokens; `;
130
- const suffix = "]";
131
- const digestChars = maxTokens * 4 - prefix.length - suffix.length;
132
- const maxChars =
133
- message?.isError === true ? Math.max(ERROR_DIGEST_NOTICE_MIN_CHARS, digestChars) : Math.max(0, digestChars);
134
- return `${prefix}${truncateField(digest, maxChars)}${suffix}`;
164
+ const digest =
165
+ truncateField(message ? (resultDigest(message, call) ?? "") : "", DIGEST_TOTAL_MAX_CHARS) || undefined;
166
+ if (!digest && !artifact) return generic;
167
+ if (artifact) {
168
+ return `[Output truncated - ${tokens} tokens; full output: ${artifact}]${digest ? ` ${digest}` : ""}`;
169
+ }
170
+ return `[Output truncated - ${tokens} tokens; ${digest}]`;
135
171
  }
136
172
 
137
173
  function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
@@ -258,10 +294,10 @@ function buildAssistantArgumentStalenessIndex(entries: SessionEntry[]): Assistan
258
294
  failedCallIds.add(call.id);
259
295
  continue;
260
296
  }
261
- const failed = failedEditPaths(message);
297
+ const successfulPaths = successfulEditPaths(message);
262
298
  let mutated = false;
263
299
  for (const group of groups) {
264
- if (group.some(groupPath => failed.has(groupPath))) continue;
300
+ if (successfulPaths !== undefined && !group.some(groupPath => successfulPaths.has(groupPath))) continue;
265
301
  latestSuccessfulMutationByPathGroup.set(pathGroupKey(group), { index: i, callId: call.id });
266
302
  mutated = true;
267
303
  }
@@ -270,46 +306,51 @@ function buildAssistantArgumentStalenessIndex(entries: SessionEntry[]): Assistan
270
306
  return { latestSuccessfulMutationByPathGroup, failedCallIds };
271
307
  }
272
308
 
273
- /**
274
- * Trailing read selectors (`:50`, `:50-200`, `:50+150`, `:5-16,960-973`,
275
- * `:raw`, `:conflicts`), possibly stacked (`:2-4:raw`). Stripped to resolve
276
- * the underlying file for edit invalidation.
277
- */
278
- const READ_SELECTOR_SUFFIX = /:(?:raw|conflicts|\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*)$/;
309
+ /** Exact read selector grammar mirrored from the read tool without importing its package layer. */
310
+ const READ_SELECTOR_RE = /^(?:L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|raw|conflicts)$/i;
311
+ const READ_RANGE_SELECTOR_RE = /^L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*$/i;
312
+ const READ_RAW_SELECTOR_RE = /^raw$/i;
313
+
314
+ type ReadTarget = { basePath: string; selector?: string };
315
+
316
+ function splitReadTarget(path: string): ReadTarget {
317
+ const outerColon = path.lastIndexOf(":");
318
+ if (outerColon <= 0) return { basePath: path };
319
+ const outer = path.slice(outerColon + 1);
320
+ if (!READ_SELECTOR_RE.test(outer)) return { basePath: path };
321
+
322
+ let basePath = path.slice(0, outerColon);
323
+ let selector = outer;
324
+ const innerColon = basePath.lastIndexOf(":");
325
+ if (innerColon > 0) {
326
+ const inner = basePath.slice(innerColon + 1);
327
+ const compoundRawRange =
328
+ (READ_RAW_SELECTOR_RE.test(inner) && READ_RANGE_SELECTOR_RE.test(outer)) ||
329
+ (READ_RANGE_SELECTOR_RE.test(inner) && READ_RAW_SELECTOR_RE.test(outer));
330
+ if (compoundRawRange) {
331
+ selector = `${inner}:${outer}`;
332
+ basePath = basePath.slice(0, innerColon);
333
+ }
334
+ }
335
+ return { basePath, selector };
336
+ }
279
337
 
280
- /** Base file path of a read target with any line/mode selectors stripped. */
338
+ /** Base file path of a read target with its one valid selector stripped. */
281
339
  function readBasePath(path: string): string {
282
- let base = path;
283
- while (READ_SELECTOR_SUFFIX.test(base)) {
284
- base = base.replace(READ_SELECTOR_SUFFIX, "");
285
- }
286
- return base;
340
+ return splitReadTarget(path).basePath;
287
341
  }
288
342
 
289
343
  type ReadLineRange = { start: number; end: number };
290
344
 
291
- const DEFAULT_READ_LINE_LIMIT = 500;
292
-
293
- /** Parse trailing read selectors using the read tool's actual bounded default. */
345
+ /** Parse only one explicit, provably bounded trailing read range. */
294
346
  function readLineRanges(path: string): ReadLineRange[] {
295
- let target = path;
296
- let raw = false;
297
- while (/:(?:raw|conflicts)$/.test(target)) {
298
- raw ||= target.endsWith(":raw");
299
- target = target.replace(/:(?:raw|conflicts)$/, "");
300
- }
301
- const match = target.match(/:(\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*)$/);
302
- if (!match) return raw ? [{ start: 1, end: Number.POSITIVE_INFINITY }] : [];
303
- return match[1].split(",").flatMap(part => {
304
- const range = part.match(/^(\d+)(?:([-+])(\d+))?$/);
347
+ const selector = splitReadTarget(path).selector;
348
+ if (!selector || /(?:^|:)raw(?:$|:)/i.test(selector) || /^conflicts$/i.test(selector)) return [];
349
+ return selector.split(",").flatMap(part => {
350
+ const range = part.match(/^L?(\d+)([-+])L?(\d+)$/i);
305
351
  if (!range) return [];
306
352
  const start = Number(range[1]);
307
- const end =
308
- range[2] === "+"
309
- ? start + Number(range[3]) - 1
310
- : range[2] === "-"
311
- ? Number(range[3])
312
- : start + DEFAULT_READ_LINE_LIMIT - 1;
353
+ const end = range[2] === "+" ? start + Number(range[3]) - 1 : Number(range[3]);
313
354
  return start > 0 && end >= start ? [{ start, end }] : [];
314
355
  });
315
356
  }
@@ -394,32 +435,26 @@ function resultDetailFiles(message: ToolResultMessage): string[] {
394
435
  }
395
436
 
396
437
  /**
397
- * Paths that FAILED in a per-file edit result (`details.perFileResults`) and
398
- * were NOT mutated by any same-path entry. Multi-file apply_patch catches
399
- * per-file failures and still returns a non-error result; a purely-failed
400
- * path was not mutated and must not stale reads. But apply_patch can emit
401
- * multiple entries for the same path (e.g. several hunks): if any same-path
402
- * entry succeeded the file still mutated, so it must NOT be suppressed.
403
- * Conservative: only an entry explicitly marked `isError === true` counts as
404
- * a failure; anything else (including ambiguous/malformed entries) counts as
405
- * a success and keeps the path out of the suppression set.
438
+ * Paths that a per-file edit result proves were mutated. Multi-file
439
+ * `apply_patch` can return a non-error envelope while individual files fail, so
440
+ * only explicit success (`isError === false`) or the normal successful result
441
+ * shape (a string `diff` with no error flag) counts. Ambiguous/malformed rows
442
+ * fail closed. If no per-file result array exists, return undefined so ordinary
443
+ * single-file successful tool results retain their established behavior.
406
444
  */
407
- function failedEditPaths(message: ToolResultMessage): Set<string> {
445
+ function successfulEditPaths(message: ToolResultMessage): Set<string> | undefined {
408
446
  const details = message.details as { perFileResults?: unknown } | undefined;
409
447
  const perFile = details?.perFileResults;
410
- if (!Array.isArray(perFile)) return new Set();
411
- const failed = new Set<string>();
448
+ if (!Array.isArray(perFile)) return undefined;
412
449
  const succeeded = new Set<string>();
413
450
  for (const item of perFile) {
414
- const entry = item as { path?: unknown; isError?: unknown };
451
+ const entry = item as { path?: unknown; isError?: unknown; diff?: unknown };
415
452
  if (typeof entry?.path !== "string") continue;
416
- if (entry.isError === true) failed.add(entry.path);
417
- else succeeded.add(entry.path);
453
+ if (entry.isError === false || (entry.isError === undefined && typeof entry.diff === "string")) {
454
+ succeeded.add(entry.path);
455
+ }
418
456
  }
419
- // A path mutated if any same-path entry succeeded, even when another
420
- // same-path entry failed; drop those from the suppression set.
421
- for (const path of succeeded) failed.delete(path);
422
- return failed;
457
+ return succeeded;
423
458
  }
424
459
 
425
460
  /**
@@ -482,12 +517,11 @@ function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
482
517
  resultMeta.set(i, { key, call, message });
483
518
  if (key !== undefined) lastResultIndexByKey.set(key, i);
484
519
  if (EDIT_TOOL_NAMES.has(call.name)) {
485
- // Per-file edit results record failures in details.perFileResults;
486
- // a failed hunk mutated nothing, so exclude its whole path group
487
- // (rename destination included) from touched paths.
488
- const failed = failedEditPaths(message);
520
+ // Per-file edit results prove which path groups actually mutated. A
521
+ // malformed or ambiguous row cannot invalidate earlier read evidence.
522
+ const successfulPaths = successfulEditPaths(message);
489
523
  for (const group of editToolPathGroups(call)) {
490
- if (group.some(groupPath => failed.has(groupPath))) continue;
524
+ if (successfulPaths !== undefined && !group.some(groupPath => successfulPaths.has(groupPath))) continue;
491
525
  for (const editPath of group) {
492
526
  lastEditIndexByPath.set(editPath, i);
493
527
  }
@@ -555,19 +589,22 @@ export function pruneAssistantToolArguments(
555
589
  config: PruneConfig = DEFAULT_PRUNE_CONFIG,
556
590
  ): AssistantArgumentPruneResult {
557
591
  let accumulatedTokens = 0;
558
- let argumentTokensSaved = 0;
559
592
  const { latestSuccessfulMutationByPathGroup, failedCallIds } = buildAssistantArgumentStalenessIndex(entries);
593
+ const argumentFenceStart = recentTurnFenceStart(entries, config.protectRecentTurns ?? 2);
560
594
  const candidates: Array<{
561
595
  entry: SessionMessageEntry;
562
596
  call: ToolCall;
563
597
  pathHints: string[];
564
598
  originalChars: number;
565
- savings: number;
566
599
  }> = [];
567
600
 
568
601
  for (let i = entries.length - 1; i >= 0; i--) {
569
602
  const entry = entries[i];
570
603
  if (entry.type !== "message") continue;
604
+ // Same newest-turn fence as tool-output pruning: edit/apply_patch
605
+ // arguments in the active (or otherwise protected) turn are live
606
+ // context, even when a later call in that turn superseded their path.
607
+ if (argumentFenceStart !== undefined && i >= argumentFenceStart) continue;
571
608
  const message = entry.message as AgentMessage;
572
609
  if (message.role !== "assistant") continue;
573
610
  const entryTokens = estimateEntryTokens(entry);
@@ -586,63 +623,109 @@ export function pruneAssistantToolArguments(
586
623
  // concrete path group to be stale from a later successful mutation.
587
624
  // A group with no later success (failed/unknown/ambiguous) protects the
588
625
  // whole call rather than dropping non-stale multi-file patch evidence.
589
- const isStale =
590
- groups.length > 0 &&
591
- groups.every(group => {
592
- const latest = latestSuccessfulMutationByPathGroup.get(pathGroupKey(group));
593
- return latest !== undefined && latest.index > i && latest.callId !== content.id;
594
- });
626
+ const isStale = groups.every(group => {
627
+ const latest = latestSuccessfulMutationByPathGroup.get(pathGroupKey(group));
628
+ return latest !== undefined && latest.index > i && latest.callId !== content.id;
629
+ });
595
630
  if (!isStale) continue;
596
- const sentinelChars = JSON.stringify({
597
- pruned: true,
598
- reason: "stale_tool_arguments",
599
- pathHints: pathHintsForGroups(groups),
600
- originalChars,
601
- prunedAt: 0,
602
- } satisfies PrunedToolArgumentsSentinel).length;
603
631
  candidates.push({
604
632
  entry: entry as SessionMessageEntry,
605
633
  call: content,
606
634
  pathHints: pathHintsForGroups(groups),
607
635
  originalChars,
608
- savings: Math.max(0, Math.ceil((originalChars - sentinelChars) / 4)),
609
636
  });
610
637
  }
611
638
  }
612
639
 
640
+ if (candidates.length === 0) {
641
+ return { argumentPrunedCount: 0, argumentTokensSaved: 0, prunedEntries: [] };
642
+ }
643
+
644
+ const prunedAt = Date.now();
645
+ const candidatesByEntry = new Map<SessionMessageEntry, typeof candidates>();
613
646
  for (const candidate of candidates) {
614
- argumentTokensSaved += candidate.savings;
647
+ const group = candidatesByEntry.get(candidate.entry);
648
+ if (group) group.push(candidate);
649
+ else candidatesByEntry.set(candidate.entry, [candidate]);
615
650
  }
616
- if (argumentTokensSaved < config.minimumSavings || candidates.length === 0) {
651
+
652
+ let argumentTokensSaved = 0;
653
+ const admittedGroups: Array<{ entry: SessionMessageEntry; candidates: typeof candidates }> = [];
654
+ for (const [entry, entryCandidates] of candidatesByEntry) {
655
+ const candidateByCallId = new Map(entryCandidates.map(candidate => [candidate.call.id, candidate]));
656
+ const message = entry.message as AgentMessage;
657
+ if (message.role !== "assistant") continue;
658
+ const stagedEntry = {
659
+ ...entry,
660
+ message: {
661
+ ...message,
662
+ content: message.content.map(content => {
663
+ if (content.type !== "toolCall") return content;
664
+ const candidate = candidateByCallId.get(content.id);
665
+ if (!candidate) return content;
666
+ return {
667
+ ...content,
668
+ arguments: {
669
+ pruned: true,
670
+ reason: "stale_tool_arguments",
671
+ pathHints: candidate.pathHints,
672
+ originalChars: candidate.originalChars,
673
+ prunedAt,
674
+ } satisfies PrunedToolArgumentsSentinel,
675
+ };
676
+ }),
677
+ },
678
+ } as SessionMessageEntry;
679
+ const savings = Math.max(0, estimateEntryTokens(entry) - estimateEntryTokens(stagedEntry));
680
+ if (savings === 0) continue;
681
+ argumentTokensSaved += savings;
682
+ admittedGroups.push({ entry, candidates: entryCandidates });
683
+ }
684
+
685
+ if (argumentTokensSaved < config.minimumSavings || admittedGroups.length === 0) {
617
686
  return { argumentPrunedCount: 0, argumentTokensSaved: 0, prunedEntries: [] };
618
687
  }
619
688
 
620
- const prunedAt = Date.now();
689
+ let argumentPrunedCount = 0;
621
690
  const prunedEntries: SessionMessageEntry[] = [];
622
- const prunedEntryIds = new Set<string>();
623
- for (const candidate of candidates) {
624
- candidate.call.arguments = {
625
- pruned: true,
626
- reason: "stale_tool_arguments",
627
- pathHints: candidate.pathHints,
628
- originalChars: candidate.originalChars,
629
- prunedAt,
630
- };
631
- if (!prunedEntryIds.has(candidate.entry.id)) {
632
- prunedEntries.push(candidate.entry);
633
- prunedEntryIds.add(candidate.entry.id);
691
+ for (const group of admittedGroups) {
692
+ for (const candidate of group.candidates) {
693
+ candidate.call.arguments = {
694
+ pruned: true,
695
+ reason: "stale_tool_arguments",
696
+ pathHints: candidate.pathHints,
697
+ originalChars: candidate.originalChars,
698
+ prunedAt,
699
+ };
700
+ argumentPrunedCount++;
634
701
  }
702
+ prunedEntries.push(group.entry);
635
703
  }
636
- return { argumentPrunedCount: candidates.length, argumentTokensSaved, prunedEntries };
704
+ return { argumentPrunedCount, argumentTokensSaved, prunedEntries };
637
705
  }
638
706
 
639
707
  interface ToolOutputPruneCandidate {
640
708
  entry: SessionMessageEntry;
709
+ call?: ToolCall;
641
710
  tokens: number;
711
+ originalText: string;
712
+ complete: boolean;
642
713
  notice: string;
643
714
  savings: number;
644
715
  }
645
716
 
717
+ function recentTurnFenceStart(entries: SessionEntry[], protectRecentTurns: number): number | undefined {
718
+ if (protectRecentTurns <= 0) return undefined;
719
+ const starts: number[] = [];
720
+ for (let i = 0; i < entries.length; i++) {
721
+ const entry = entries[i];
722
+ if (entry.type !== "message") continue;
723
+ const role = entry.message.role as string;
724
+ if (role === "user" || role === "bashExecution") starts.push(i);
725
+ }
726
+ return starts.length === 0 ? undefined : starts[Math.max(0, starts.length - protectRecentTurns)];
727
+ }
728
+
646
729
  /**
647
730
  * Read-only pass that collects the tool-result entries that {@link pruneToolOutputs}
648
731
  * would prune, plus the total estimated token savings. Shared by the mutating
@@ -657,6 +740,14 @@ function collectToolOutputPruneCandidates(
657
740
  let accumulatedTokens = 0;
658
741
 
659
742
  const { staleResultIndices } = buildStalenessIndex(entries);
743
+ const callsById = new Map<string, ToolCall>();
744
+ for (const entry of entries) {
745
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
746
+ for (const content of entry.message.content) {
747
+ if (content.type === "toolCall") callsById.set(content.id, content);
748
+ }
749
+ }
750
+ const fenceStart = recentTurnFenceStart(entries, config.protectRecentTurns ?? 2);
660
751
  const staleOverridable = new Set(config.staleOverridableTools ?? []);
661
752
  const candidates: ToolOutputPruneCandidate[] = [];
662
753
 
@@ -673,7 +764,7 @@ function collectToolOutputPruneCandidates(
673
764
  const isProtected =
674
765
  config.protectedTools.includes(message.toolName) && !(isStale && staleOverridable.has(message.toolName));
675
766
 
676
- if (message.prunedAt !== undefined) {
767
+ if (message.prunedAt !== undefined || (fenceStart !== undefined && i >= fenceStart)) {
677
768
  accumulatedTokens += tokens;
678
769
  continue;
679
770
  }
@@ -688,19 +779,25 @@ function collectToolOutputPruneCandidates(
688
779
  continue;
689
780
  }
690
781
 
691
- const notice = createPrunedNotice(tokens, message);
782
+ const call = callsById.get(message.toolCallId);
783
+ const captured = capturedTextContent(message);
784
+ const notice = createPrunedNotice(tokens, message, call);
692
785
  const savings = estimatePrunedSavings(tokens, notice);
693
- const errorNoticeGrows = message.isError === true && notice.length > firstTextContent(message).length;
786
+ const errorNoticeGrows = message.isError === true && notice.length > captured.text.length;
694
787
  if (savings <= 0 || errorNoticeGrows) {
695
788
  accumulatedTokens += tokens;
696
789
  continue;
697
790
  }
698
791
  candidates.push({
699
792
  entry: entry as SessionMessageEntry,
793
+ call,
700
794
  tokens,
795
+ originalText: captured.text,
796
+ complete: captured.complete,
701
797
  notice,
702
798
  savings,
703
799
  });
800
+
704
801
  accumulatedTokens += tokens;
705
802
  }
706
803
 
@@ -719,20 +816,24 @@ function minimumSavings(config: PruneConfig, options: PruneToolOutputsOptions =
719
816
  }
720
817
 
721
818
  /**
722
- * Estimate the token savings {@link pruneToolOutputs} would achieve, without
723
- * mutating any entry. Returns 0 savings when below the configured minimum so the
724
- * caller sees the same gate the real prune enforces.
819
+ * Estimate the conservative final token savings {@link pruneToolOutputs} would
820
+ * achieve, without mutating entries or invoking the artifact-reference planner.
821
+ * When `artifactRefMaxChars` is present, the estimate budgets that full length
822
+ * for every complete candidate so the real artifact-backed prune cannot save
823
+ * less than the estimate.
725
824
  */
726
825
  export function estimateToolOutputPruneSavings(
727
826
  entries: SessionEntry[],
728
827
  config: PruneConfig = DEFAULT_PRUNE_CONFIG,
729
828
  options: PruneToolOutputsOptions = {},
730
829
  ): { prunableCount: number; tokensSaved: number } {
731
- const { candidates, tokensSaved } = collectToolOutputPruneCandidates(entries, config);
732
- if (tokensSaved < minimumSavings(config, options) || candidates.length === 0) {
733
- return { prunableCount: 0, tokensSaved: 0 };
734
- }
735
- return { prunableCount: candidates.length, tokensSaved };
830
+ const { candidates, tokensSaved: baseTokensSaved } = collectToolOutputPruneCandidates(entries, config);
831
+ const minimum = minimumSavings(config, options);
832
+ if (baseTokensSaved < minimum || candidates.length === 0) return { prunableCount: 0, tokensSaved: 0 };
833
+ const planned = planToolOutputPruneCandidates(candidates, options);
834
+ const tokensSaved = planned.reduce((total, candidate) => total + candidate.savings, 0);
835
+ if (tokensSaved < minimum || planned.length === 0) return { prunableCount: 0, tokensSaved: 0 };
836
+ return { prunableCount: planned.length, tokensSaved };
736
837
  }
737
838
 
738
839
  /**
@@ -753,9 +854,73 @@ export function shouldRunMaintenancePrune(args: {
753
854
  return args.estimatedSavings > args.cacheEpochResetCost;
754
855
  }
755
856
 
857
+ const MAX_ARTIFACT_REF_CHARS = 16_384;
858
+ const ARTIFACT_REF_PREFIX_PATTERN = /^artifact:\/\/\d+/;
859
+
860
+ function isValidArtifactRef(value: string): boolean {
861
+ return ARTIFACT_REF_PREFIX_PATTERN.exec(value)?.[0] === value;
862
+ }
863
+
756
864
  export interface PruneToolOutputsOptions {
757
865
  /** Lower the usual minimum only when the caller is already over its compaction threshold. */
758
866
  relaxedMinimum?: number;
867
+ /**
868
+ * Conservative maximum ASCII length of every planned artifact reference.
869
+ * Required when `artifactRef` is provided so estimation and final admission
870
+ * use the same worst-case notice size.
871
+ */
872
+ artifactRefMaxChars?: number;
873
+ /**
874
+ * Plan a numeric `artifact://<id>` reference for a candidate's original
875
+ * text. The callback may reserve an in-memory identifier, but MUST NOT publish
876
+ * files or mutate session entries; publish only the originals returned by a
877
+ * successful {@link pruneToolOutputs} result.
878
+ */
879
+ artifactRef?: (candidate: PrunedOriginal) => string | undefined;
880
+ }
881
+
882
+ interface PlannedToolOutputPruneCandidate extends ToolOutputPruneCandidate {
883
+ original: PrunedOriginal;
884
+ }
885
+
886
+ function artifactRefMaxChars(options: PruneToolOutputsOptions): number {
887
+ const maxChars = options.artifactRefMaxChars;
888
+ if (maxChars === undefined) {
889
+ if (options.artifactRef) throw new Error("artifactRefMaxChars is required when artifactRef is provided");
890
+ return 0;
891
+ }
892
+ if (!Number.isSafeInteger(maxChars) || maxChars <= 0 || maxChars > MAX_ARTIFACT_REF_CHARS) {
893
+ throw new RangeError(`artifactRefMaxChars must be an integer between 1 and ${MAX_ARTIFACT_REF_CHARS}`);
894
+ }
895
+ return maxChars;
896
+ }
897
+
898
+ function planToolOutputPruneCandidates(
899
+ candidates: ToolOutputPruneCandidate[],
900
+ options: PruneToolOutputsOptions,
901
+ ): PlannedToolOutputPruneCandidate[] {
902
+ const maxArtifactChars = artifactRefMaxChars(options);
903
+ const artifactBudget = maxArtifactChars > 0 ? "x".repeat(maxArtifactChars) : undefined;
904
+ return candidates.flatMap(candidate => {
905
+ const original: PrunedOriginal = {
906
+ entryId: candidate.entry.id,
907
+ toolName: (candidate.entry.message as ToolResultMessage).toolName,
908
+ originalText: candidate.originalText,
909
+ tokens: candidate.tokens,
910
+ complete: candidate.complete,
911
+ };
912
+ const notice = createPrunedNotice(
913
+ candidate.tokens,
914
+ candidate.entry.message as ToolResultMessage,
915
+ candidate.call,
916
+ candidate.complete ? artifactBudget : undefined,
917
+ );
918
+ const savings = estimatePrunedSavings(candidate.tokens, notice);
919
+ const errorNoticeGrows =
920
+ (candidate.entry.message as ToolResultMessage).isError === true &&
921
+ notice.length > original.originalText.length;
922
+ return savings > 0 && !errorNoticeGrows ? [{ ...candidate, notice, savings, original }] : [];
923
+ });
759
924
  }
760
925
 
761
926
  export function pruneToolOutputs(
@@ -763,24 +928,53 @@ export function pruneToolOutputs(
763
928
  config: PruneConfig = DEFAULT_PRUNE_CONFIG,
764
929
  options: PruneToolOutputsOptions = {},
765
930
  ): PruneResult {
766
- const { candidates, tokensSaved } = collectToolOutputPruneCandidates(entries, config);
931
+ const { candidates, tokensSaved: baseTokensSaved } = collectToolOutputPruneCandidates(entries, config);
767
932
  const minimum = minimumSavings(config, options);
768
933
 
769
- if (tokensSaved < minimum || candidates.length === 0) {
770
- return { prunedCount: 0, tokensSaved: 0, prunedEntries: [] };
934
+ if (baseTokensSaved < minimum || candidates.length === 0) {
935
+ return { prunedCount: 0, tokensSaved: 0, originals: [], prunedEntries: [] };
771
936
  }
772
937
 
773
- let prunedCount = 0;
938
+ const plannedCandidates = planToolOutputPruneCandidates(candidates, options);
939
+ const plannedTokensSaved = plannedCandidates.reduce((total, candidate) => total + candidate.savings, 0);
940
+ if (plannedTokensSaved < minimum || plannedCandidates.length === 0) {
941
+ return { prunedCount: 0, tokensSaved: 0, originals: [], prunedEntries: [] };
942
+ }
943
+
944
+ const maxArtifactChars = artifactRefMaxChars(options);
945
+ const candidatesWithArtifacts = plannedCandidates.map(candidate => {
946
+ const artifact = candidate.complete ? options.artifactRef?.(candidate.original) : undefined;
947
+ if (artifact !== undefined && !isValidArtifactRef(artifact)) {
948
+ throw new Error(
949
+ `artifactRef must be a numeric artifact://<id> reference for entry ${candidate.original.entryId}`,
950
+ );
951
+ }
952
+ if (artifact !== undefined && artifact.length > maxArtifactChars) {
953
+ throw new Error(`artifactRef exceeded artifactRefMaxChars for entry ${candidate.original.entryId}`);
954
+ }
955
+ const notice = createPrunedNotice(
956
+ candidate.tokens,
957
+ candidate.entry.message as ToolResultMessage,
958
+ candidate.call,
959
+ artifact,
960
+ );
961
+ return { ...candidate, notice, savings: estimatePrunedSavings(candidate.tokens, notice) };
962
+ });
963
+ const tokensSaved = candidatesWithArtifacts.reduce((total, candidate) => total + candidate.savings, 0);
964
+ if (tokensSaved < minimum) {
965
+ throw new Error("artifact-backed prune savings fell below the conservative admission estimate");
966
+ }
774
967
 
775
968
  const prunedAt = Date.now();
776
969
  const prunedEntries: SessionMessageEntry[] = [];
777
- for (const candidate of candidates) {
970
+ const originals: PrunedOriginal[] = [];
971
+ for (const candidate of candidatesWithArtifacts) {
778
972
  const message = candidate.entry.message as ToolResultMessage;
779
973
  message.content = [{ type: "text", text: candidate.notice }];
780
974
  message.prunedAt = prunedAt;
781
975
  prunedEntries.push(candidate.entry);
782
- prunedCount++;
976
+ originals.push(candidate.original);
783
977
  }
784
978
 
785
- return { prunedCount, tokensSaved, prunedEntries };
979
+ return { prunedCount: candidatesWithArtifacts.length, tokensSaved, originals, prunedEntries };
786
980
  }