@gajae-code/agent-core 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agent.ts CHANGED
@@ -308,6 +308,11 @@ interface CursorToolResultEntry {
308
308
  textLengthAtCall: number;
309
309
  }
310
310
 
311
+ export type AgentQueueSnapshot = {
312
+ steering: AgentMessage[];
313
+ followUp: AgentMessage[];
314
+ };
315
+
311
316
  export class Agent {
312
317
  #state: AgentState = {
313
318
  systemPrompt: [],
@@ -1009,6 +1014,20 @@ export class Agent {
1009
1014
  this.#followUpQueue = [...messages, ...this.#followUpQueue];
1010
1015
  }
1011
1016
 
1017
+ /** Snapshot both executable queues as one atomic session-level view. */
1018
+ snapshotQueues(): AgentQueueSnapshot {
1019
+ return {
1020
+ steering: this.#steeringQueue.slice(),
1021
+ followUp: this.#followUpQueue.slice(),
1022
+ };
1023
+ }
1024
+
1025
+ /** Replace both executable queues with a prior snapshot. */
1026
+ restoreQueues(snapshot: AgentQueueSnapshot): void {
1027
+ this.#steeringQueue = snapshot.steering.slice();
1028
+ this.#followUpQueue = snapshot.followUp.slice();
1029
+ }
1030
+
1012
1031
  #dequeueSteeringMessages(): AgentMessage[] {
1013
1032
  if (this.#steeringMode === "one-at-a-time") {
1014
1033
  if (this.#steeringQueue.length > 0) {
@@ -1607,7 +1626,7 @@ export class Agent {
1607
1626
  this.requestRunTerminal(managedLogicalRunOwner ?? runId, managedDecision.terminal);
1608
1627
  } else if (managedOutcome.type === "run_terminal") {
1609
1628
  this.requestRunTerminal(managedLogicalRunOwner ?? runId, { stopReason: managedOutcome.reason });
1610
- } else if (managedDecision?.type !== "retry") {
1629
+ } else if (managedDecision?.type !== "retry" && managedDecision?.type !== "maintenance") {
1611
1630
  this.#finalizeRun(managedLogicalRunOwner ?? runId);
1612
1631
  }
1613
1632
  }
@@ -1660,7 +1679,10 @@ export class Agent {
1660
1679
  });
1661
1680
  } finally {
1662
1681
  let continuation: ManagedAttemptContinuation | undefined;
1663
- if (managedOutcome?.type === "retryable_discarded" && managedDecision?.type === "retry") {
1682
+ if (
1683
+ managedOutcome?.type !== "run_terminal" &&
1684
+ (managedDecision?.type === "retry" || managedDecision?.type === "maintenance")
1685
+ ) {
1664
1686
  continuation = managedDecision.continuation;
1665
1687
  }
1666
1688
  const ownership: ManagedAttemptContinuationOwnership = {
@@ -1691,7 +1713,18 @@ export class Agent {
1691
1713
  if (continuation && ownership.isCurrent()) {
1692
1714
  try {
1693
1715
  await continuation(ownership);
1694
- if (this.#activeRunId === undefined && this.#managedLogicalRunOwner === managedLogicalRunOwner) {
1716
+ if (
1717
+ managedDecision?.type === "maintenance" &&
1718
+ this.#terminalizedLogicalRunIds.has(managedLogicalRunOwner ?? runId) &&
1719
+ this.#managedLogicalRunOwner === managedLogicalRunOwner
1720
+ ) {
1721
+ this.#managedLogicalRunOwner = undefined;
1722
+ }
1723
+ if (
1724
+ managedDecision?.type !== "maintenance" &&
1725
+ this.#activeRunId === undefined &&
1726
+ this.#managedLogicalRunOwner === managedLogicalRunOwner
1727
+ ) {
1695
1728
  this.#managedLogicalRunOwner = undefined;
1696
1729
  }
1697
1730
  } catch (err) {
@@ -29,7 +29,6 @@ import {
29
29
  withOpenAiRemoteCompactionPreserveData,
30
30
  } from "./openai";
31
31
  import autoHandoffThresholdFocusPrompt from "./prompts/auto-handoff-threshold-focus.md" with { type: "text" };
32
- import compactionShortSummaryPrompt from "./prompts/compaction-short-summary.md" with { type: "text" };
33
32
  import compactionSummaryPrompt from "./prompts/compaction-summary.md" with { type: "text" };
34
33
  import compactionTurnPrefixPrompt from "./prompts/compaction-turn-prefix.md" with { type: "text" };
35
34
  import compactionUpdateSummaryPrompt from "./prompts/compaction-update-summary.md" with { type: "text" };
@@ -144,6 +143,18 @@ export interface CompactionSettings {
144
143
  remoteEndpoint?: string;
145
144
  }
146
145
 
146
+ export type RemoteCompactionFallbackHealthEvent =
147
+ | { kind: "success"; model: string; provider: string }
148
+ | { kind: "fallback"; model: string; provider: string; error: string };
149
+
150
+ export interface RemoteCompactionFallbackHealthHooks {
151
+ recordRemoteCompactionFallback(event: RemoteCompactionFallbackHealthEvent): void;
152
+ }
153
+
154
+ function isAbortError(error: unknown): boolean {
155
+ return error instanceof Error && error.name === "AbortError";
156
+ }
157
+
147
158
  export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
148
159
  enabled: true,
149
160
  strategy: "context-full",
@@ -498,7 +509,8 @@ function collectMessageFragments(message: AgentMessage): { fragments: string[];
498
509
  }
499
510
 
500
511
  switch (message.role) {
501
- case "user": {
512
+ case "user":
513
+ case "custom": {
502
514
  const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
503
515
  if (typeof content === "string") {
504
516
  fragments.push(content);
@@ -698,8 +710,6 @@ export function findCutPoint(
698
710
 
699
711
  for (let i = endIndex - 1; i >= startIndex; i--) {
700
712
  const entry = entries[i];
701
- if (entry.type !== "message") continue;
702
-
703
713
  // Estimate this message's size
704
714
  const messageTokens = estimateEntryTokens(entry);
705
715
  accumulatedTokens += messageTokens;
@@ -757,8 +767,6 @@ const SUMMARIZATION_PROMPT = prompt.render(compactionSummaryPrompt);
757
767
 
758
768
  const UPDATE_SUMMARIZATION_PROMPT = prompt.render(compactionUpdateSummaryPrompt);
759
769
 
760
- const SHORT_SUMMARY_PROMPT = prompt.render(compactionShortSummaryPrompt);
761
-
762
770
  const HANDOFF_DOCUMENT_PROMPT = prompt.render(handoffDocumentPrompt);
763
771
 
764
772
  export const AUTO_HANDOFF_THRESHOLD_FOCUS = prompt.render(autoHandoffThresholdFocusPrompt);
@@ -784,8 +792,7 @@ export interface SummaryOptions {
784
792
  /**
785
793
  * Optional telemetry handle. When provided, every LLM call emitted during
786
794
  * compaction is wrapped in an OTEL chat span tagged with
787
- * `pi.gen_ai.oneshot.kind` (`compaction_summary`, `compaction_short_summary`,
788
- * or `compaction_turn_prefix`). `undefined` keeps the call paths zero-cost.
795
+ * `pi.gen_ai.oneshot.kind` (`compaction_summary` or `compaction_turn_prefix`).
789
796
  */
790
797
  telemetry?: AgentTelemetry;
791
798
  authCredentialType?: "api_key" | "oauth";
@@ -799,6 +806,8 @@ export interface SummaryOptions {
799
806
  providerSessionState?: Map<string, ProviderSessionState>;
800
807
  /** Hint that websocket transport should be preferred when supported by the provider implementation. */
801
808
  preferWebsockets?: boolean;
809
+ /** Session-owned health sink for remote-compaction fallback transition logging. */
810
+ remoteCompactionFallbackHealth?: RemoteCompactionFallbackHealthHooks;
802
811
  }
803
812
 
804
813
  /**
@@ -1040,66 +1049,11 @@ export async function generateHandoff(
1040
1049
  .join("\n");
1041
1050
  }
1042
1051
 
1043
- async function generateShortSummary(
1044
- recentMessages: AgentMessage[],
1045
- historySummary: string | undefined,
1046
- model: Model,
1047
- reserveTokens: number,
1048
- apiKey: string,
1049
- signal?: AbortSignal,
1050
- options?: SummaryOptions,
1051
- ): Promise<string> {
1052
- const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens));
1053
- const llmMessages = (options?.convertToLlm ?? convertToLlm)(recentMessages);
1054
- const conversationText = boundConversationTextForSummary(serializeConversation(llmMessages), model, maxTokens);
1055
-
1056
- let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
1057
- if (historySummary) {
1058
- promptText += `<previous-summary>\n${historySummary}\n</previous-summary>\n\n`;
1059
- }
1060
- promptText += formatAdditionalContext(options?.extraContext);
1061
- promptText += SHORT_SUMMARY_PROMPT;
1062
-
1063
- if (options?.remoteEndpoint) {
1064
- const remote = await requestRemoteCompaction(
1065
- options.remoteEndpoint,
1066
- {
1067
- systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
1068
- prompt: promptText,
1069
- },
1070
- signal,
1071
- );
1072
- return remote.summary;
1073
- }
1074
-
1075
- const response = await instrumentedCompleteSimple(
1076
- model,
1077
- {
1078
- systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT],
1079
- messages: [{ role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() }],
1080
- },
1081
- {
1082
- maxTokens,
1083
- signal,
1084
- apiKey,
1085
- reasoning: Effort.High,
1086
- initiatorOverride: options?.initiatorOverride,
1087
- metadata: options?.metadata,
1088
- sessionId: options?.sessionId,
1089
- providerSessionState: options?.providerSessionState,
1090
- preferWebsockets: options?.preferWebsockets,
1091
- },
1092
- { telemetry: options?.telemetry, oneshotKind: "compaction_short_summary" },
1093
- );
1094
-
1095
- if (response.stopReason === "error") {
1096
- throw new Error(`Short summary failed: ${response.errorMessage || "Unknown error"}`);
1097
- }
1098
-
1099
- return response.content
1100
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
1101
- .map(c => c.text)
1102
- .join("\n");
1052
+ /** Derive a display summary locally to avoid a second compaction LLM request. */
1053
+ function deriveShortSummary(summary: string): string {
1054
+ const firstParagraph = summary.trim().split(/\n\s*\n/, 1)[0] ?? "";
1055
+ const maxLength = 2_000;
1056
+ return firstParagraph.length <= maxLength ? firstParagraph : `${firstParagraph.slice(0, maxLength - 1)}…`;
1103
1057
  }
1104
1058
 
1105
1059
  // ============================================================================
@@ -1149,6 +1103,11 @@ export interface PrepareCompactionOptions {
1149
1103
  * (the confounded raw promptTokens/estimatedTokens quotient is never used).
1150
1104
  */
1151
1105
  tokenCorrectionRatio?: number;
1106
+ /**
1107
+ * Model context-window size. Windows below 66k retain the legacy fixed
1108
+ * keepRecentTokens behavior; larger windows scale the keep window to 30%.
1109
+ */
1110
+ contextWindow?: number;
1152
1111
  }
1153
1112
 
1154
1113
  export function prepareCompaction(
@@ -1179,13 +1138,42 @@ export function prepareCompaction(
1179
1138
  // counts system+tools+full history while estimatedTokens counted only the
1180
1139
  // post-boundary slice, so it was confounded and only ever shrank the window.
1181
1140
  // Here the correction is bidirectional and clamped to [0.5, 2].
1182
- const keepRecentTokens = settings.keepRecentTokens;
1141
+ const configuredKeepRecentTokens = settings.keepRecentTokens;
1142
+ const contextWindow = options.contextWindow;
1143
+ const thresholdSafeKeepRecentTokens =
1144
+ contextWindow !== undefined && Number.isFinite(contextWindow) && contextWindow > 1
1145
+ ? Math.max(
1146
+ 1,
1147
+ resolveThresholdTokens(contextWindow, settings) - effectiveReserveTokens(contextWindow, settings, 0),
1148
+ )
1149
+ : configuredKeepRecentTokens;
1150
+ const keepRecentTokens = Math.min(configuredKeepRecentTokens, thresholdSafeKeepRecentTokens);
1151
+ // Preserve the legacy fixed window for smaller models. At 66k and above,
1152
+ // retain up to 30% of the model context, but never enough to leave the
1153
+ // post-compaction prompt immediately above its configured threshold.
1154
+ const scaledKeepRecentTokens =
1155
+ contextWindow !== undefined && Number.isFinite(contextWindow) && contextWindow >= 66_000
1156
+ ? Math.min(thresholdSafeKeepRecentTokens, Math.max(keepRecentTokens, Math.floor(contextWindow * 0.3)))
1157
+ : keepRecentTokens;
1183
1158
  const rawRatio = options.tokenCorrectionRatio;
1184
1159
  const appliedRatio =
1185
1160
  rawRatio !== undefined && Number.isFinite(rawRatio) && rawRatio > 0
1186
1161
  ? Math.min(TOKEN_CORRECTION_MAX_RATIO, Math.max(TOKEN_CORRECTION_MIN_RATIO, rawRatio))
1187
1162
  : 1;
1188
- const keepRecentTokensCorrected = Math.max(1, Math.round(keepRecentTokens / appliedRatio));
1163
+ // Preserve an explicit keep floor that already covers the whole history: manual
1164
+ // and emergency callers rely on prepareCompaction returning undefined rather
1165
+ // than manufacturing a summary with no useful reduction. Otherwise, a scaled
1166
+ // window that exceeds a short history falls back to the threshold-safe floor.
1167
+ const historyTokens = pathEntries
1168
+ .slice(boundaryStart, boundaryEnd)
1169
+ .reduce((tokens, entry) => tokens + estimateEntryTokens(entry), 0);
1170
+ const effectiveKeepRecentTokens =
1171
+ configuredKeepRecentTokens > historyTokens
1172
+ ? configuredKeepRecentTokens
1173
+ : scaledKeepRecentTokens > keepRecentTokens && scaledKeepRecentTokens > historyTokens
1174
+ ? keepRecentTokens
1175
+ : scaledKeepRecentTokens;
1176
+ const keepRecentTokensCorrected = Math.max(1, Math.round(effectiveKeepRecentTokens / appliedRatio));
1189
1177
 
1190
1178
  const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, keepRecentTokensCorrected);
1191
1179
 
@@ -1305,6 +1293,7 @@ export async function compact(
1305
1293
  sessionId: options?.sessionId,
1306
1294
  providerSessionState: options?.providerSessionState,
1307
1295
  preferWebsockets: options?.preferWebsockets,
1296
+ remoteCompactionFallbackHealth: options?.remoteCompactionFallbackHealth,
1308
1297
  };
1309
1298
 
1310
1299
  let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
@@ -1331,12 +1320,28 @@ export async function compact(
1331
1320
  { authCredentialType: options?.authCredentialType },
1332
1321
  );
1333
1322
  preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, remote);
1334
- } catch (err) {
1335
- logger.warn("OpenAI remote compaction failed, falling back to local summarization", {
1336
- error: err instanceof Error ? err.message : String(err),
1323
+ summaryOptions.remoteCompactionFallbackHealth?.recordRemoteCompactionFallback({
1324
+ kind: "success",
1337
1325
  model: model.id,
1338
1326
  provider: model.provider,
1339
1327
  });
1328
+ } catch (err) {
1329
+ if (signal?.aborted || isAbortError(err)) throw err;
1330
+ const error = err instanceof Error ? err.message : String(err);
1331
+ if (summaryOptions.remoteCompactionFallbackHealth) {
1332
+ summaryOptions.remoteCompactionFallbackHealth.recordRemoteCompactionFallback({
1333
+ kind: "fallback",
1334
+ error,
1335
+ model: model.id,
1336
+ provider: model.provider,
1337
+ });
1338
+ } else {
1339
+ logger.warn("OpenAI remote compaction failed, falling back to local summarization", {
1340
+ error,
1341
+ model: model.id,
1342
+ provider: model.provider,
1343
+ });
1344
+ }
1340
1345
  }
1341
1346
  }
1342
1347
  }
@@ -1406,28 +1411,10 @@ export async function compact(
1406
1411
  summary = "No prior history.";
1407
1412
  }
1408
1413
 
1409
- const shortSummary = await generateShortSummary(
1410
- recentMessages,
1411
- summary,
1412
- model,
1413
- settings.reserveTokens,
1414
- apiKey,
1415
- signal,
1416
- {
1417
- extraContext: options?.extraContext,
1418
- remoteEndpoint: summaryOptions.remoteEndpoint,
1419
- initiatorOverride: summaryOptions.initiatorOverride,
1420
- metadata: summaryOptions.metadata,
1421
- telemetry: summaryOptions.telemetry,
1422
- sessionId: summaryOptions.sessionId,
1423
- providerSessionState: summaryOptions.providerSessionState,
1424
- preferWebsockets: summaryOptions.preferWebsockets,
1425
- },
1426
- );
1427
-
1428
1414
  // Compute file lists and append to summary
1429
1415
  const { readFiles, modifiedFiles } = computeFileLists(fileOps);
1430
1416
  summary = upsertFileOperations(summary, readFiles, modifiedFiles);
1417
+ const shortSummary = deriveShortSummary(summary);
1431
1418
 
1432
1419
  if (!firstKeptEntryId) {
1433
1420
  throw new Error("First kept entry has no ID - session may need migration");
@@ -514,18 +514,14 @@ export async function requestOpenAiRemoteCompaction(
514
514
  });
515
515
 
516
516
  if (!response.ok) {
517
- const errorText = await response.text().catch(() => "");
518
- logger.warn("OpenAI remote compaction failed", {
519
- endpoint,
520
- status: response.status,
521
- statusText: response.statusText,
522
- errorText,
523
- });
524
517
  throw new Error(`Remote compaction failed (${response.status} ${response.statusText})`);
525
518
  }
526
519
 
527
- const data = (await response.json()) as { output?: unknown[] } | undefined;
528
- const rawOutput = data?.output ?? [];
520
+ const data = (await response.json()) as { output?: unknown } | undefined;
521
+ if (!Array.isArray(data?.output)) {
522
+ throw new Error(`Remote compaction response malformed output (outputType=${typeof data?.output})`);
523
+ }
524
+ const rawOutput = data.output;
529
525
  const replacementHistory = rawOutput.filter(
530
526
  (item): item is Record<string, unknown> =>
531
527
  !!item && typeof item === "object" && shouldKeepOpenAiCompactOutputItem(item as Record<string, unknown>),
@@ -539,15 +535,9 @@ export async function requestOpenAiRemoteCompaction(
539
535
  const outputTypes = rawOutput.map(item =>
540
536
  typeof item === "object" && item !== null ? (item as Record<string, unknown>).type : typeof item,
541
537
  );
542
- logger.warn("Remote compaction response missing compaction item", {
543
- endpoint,
544
- model: model.id,
545
- provider: model.provider,
546
- rawOutputLength: rawOutput.length,
547
- outputTypes,
548
- replacementHistoryLength: replacementHistory.length,
549
- });
550
- throw new Error("Remote compaction response missing compaction item");
538
+ throw new Error(
539
+ `Remote compaction response missing compaction item (rawOutputLength=${rawOutput.length}, outputTypes=${outputTypes.join(",")}, replacementHistoryLength=${replacementHistory.length})`,
540
+ );
551
541
  }
552
542
  return { provider: model.provider, replacementHistory, compactionItem };
553
543
  }
@@ -572,13 +562,6 @@ export async function requestRemoteCompaction(
572
562
  });
573
563
 
574
564
  if (!response.ok) {
575
- const errorText = await response.text().catch(() => "");
576
- logger.warn("Remote compaction failed", {
577
- endpoint,
578
- status: response.status,
579
- statusText: response.statusText,
580
- errorText,
581
- });
582
565
  throw new Error(`Remote compaction failed (${response.status} ${response.statusText})`);
583
566
  }
584
567
 
@@ -267,6 +267,56 @@ function readBasePath(path: string): string {
267
267
  return base;
268
268
  }
269
269
 
270
+ type ReadLineRange = { start: number; end: number };
271
+
272
+ const DEFAULT_READ_LINE_LIMIT = 500;
273
+
274
+ /** Parse trailing read selectors using the read tool's actual bounded default. */
275
+ function readLineRanges(path: string): ReadLineRange[] {
276
+ let target = path;
277
+ let raw = false;
278
+ while (/:(?:raw|conflicts)$/.test(target)) {
279
+ raw ||= target.endsWith(":raw");
280
+ target = target.replace(/:(?:raw|conflicts)$/, "");
281
+ }
282
+ const match = target.match(/:(\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*)$/);
283
+ if (!match) return raw ? [{ start: 1, end: Number.POSITIVE_INFINITY }] : [];
284
+ return match[1].split(",").flatMap(part => {
285
+ const range = part.match(/^(\d+)(?:([-+])(\d+))?$/);
286
+ if (!range) return [];
287
+ const start = Number(range[1]);
288
+ const end =
289
+ range[2] === "+"
290
+ ? start + Number(range[3]) - 1
291
+ : range[2] === "-"
292
+ ? Number(range[3])
293
+ : start + DEFAULT_READ_LINE_LIMIT - 1;
294
+ return start > 0 && end >= start ? [{ start, end }] : [];
295
+ });
296
+ }
297
+
298
+ function strictlyContainsReadRange(container: ReadLineRange, contained: ReadLineRange): boolean {
299
+ return (
300
+ container.start <= contained.start &&
301
+ container.end >= contained.end &&
302
+ (container.start < contained.start || container.end > contained.end)
303
+ );
304
+ }
305
+
306
+ function readSupersedesRead(
307
+ later: ToolCall,
308
+ earlier: ToolCall,
309
+ lineRangesByCall: ReadonlyMap<ToolCall, ReadLineRange[]>,
310
+ ): boolean {
311
+ const laterRanges = lineRangesByCall.get(later);
312
+ const earlierRanges = lineRangesByCall.get(earlier);
313
+ return (
314
+ laterRanges?.length === 1 &&
315
+ earlierRanges?.length === 1 &&
316
+ strictlyContainsReadRange(laterRanges[0], earlierRanges[0])
317
+ );
318
+ }
319
+
270
320
  /**
271
321
  * Stable identity for "the same logical lookup": same tool re-targeting the
272
322
  * same subject. A later result with the same key supersedes earlier ones.
@@ -275,9 +325,23 @@ function readBasePath(path: string): string {
275
325
  * (`skip`) and result-shaping flags (`i`, `gitignore`): a later page or a
276
326
  * differently-shaped search complements earlier output, it does not replace it.
277
327
  */
328
+ const IDEMPOTENT_BASH_COMMAND =
329
+ /^(?:(?:bun|npm|pnpm|yarn)\s+(?:run\s+)?(?:test|build)\b|git\s+status\b|cargo\s+build\b|(?:make|just)\s+build\b)/;
330
+
331
+ function normalizedIdempotentBashCommand(call: ToolCall): string | undefined {
332
+ if (call.name !== "bash") return undefined;
333
+ const command = call.arguments.command;
334
+ if (typeof command !== "string") return undefined;
335
+ const normalized = command.trim().replace(/\s+/g, " ");
336
+ if (/[;&|]/.test(normalized) || !IDEMPOTENT_BASH_COMMAND.test(normalized)) return undefined;
337
+ return JSON.stringify([normalized, typeof call.arguments.cwd === "string" ? call.arguments.cwd : undefined]);
338
+ }
339
+
278
340
  function toolTargetKey(call: ToolCall): string | undefined {
279
341
  const path = toolCallPath(call);
280
342
  if (path !== undefined) return JSON.stringify([call.name, "path", path]);
343
+ const command = normalizedIdempotentBashCommand(call);
344
+ if (command !== undefined) return JSON.stringify([call.name, "command", command]);
281
345
  const pattern = call.arguments.pattern;
282
346
  if (typeof pattern === "string" && pattern.length > 0) {
283
347
  const paths = call.arguments.paths;
@@ -372,8 +436,9 @@ function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
372
436
  }
373
437
  }
374
438
 
439
+ type ResultMeta = { key?: string; call: ToolCall; message: ToolResultMessage };
375
440
  const lastResultIndexByKey = new Map<string, number>();
376
- const resultMeta = new Map<number, { key?: string; call: ToolCall; message: ToolResultMessage }>();
441
+ const resultMeta = new Map<number, ResultMeta>();
377
442
  const lastEditIndexByPath = new Map<string, number>();
378
443
 
379
444
  for (let i = 0; i < entries.length; i++) {
@@ -439,6 +504,31 @@ function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
439
504
  }
440
505
  }
441
506
 
507
+ const readsByBasePath = new Map<string, Array<[number, ResultMeta]>>();
508
+ const lineRangesByCall = new Map<ToolCall, ReadLineRange[]>();
509
+ for (const [index, meta] of resultMeta) {
510
+ if (meta.call.name !== "read") continue;
511
+ const path = toolCallPath(meta.call);
512
+ if (!path) continue;
513
+ lineRangesByCall.set(meta.call, readLineRanges(path));
514
+ const basePath = readBasePath(path);
515
+ const group = readsByBasePath.get(basePath);
516
+ if (group) group.push([index, meta]);
517
+ else readsByBasePath.set(basePath, [[index, meta]]);
518
+ }
519
+ for (const reads of readsByBasePath.values()) {
520
+ if (reads.length < 2) continue;
521
+ for (let earlier = 0; earlier < reads.length - 1; earlier++) {
522
+ const [index, meta] = reads[earlier];
523
+ for (let later = earlier + 1; later < reads.length; later++) {
524
+ if (readSupersedesRead(reads[later][1].call, meta.call, lineRangesByCall)) {
525
+ staleResultIndices.add(index);
526
+ break;
527
+ }
528
+ }
529
+ }
530
+ }
531
+
442
532
  return { staleResultIndices };
443
533
  }
444
534
  export function pruneAssistantToolArguments(
@@ -596,6 +686,13 @@ function collectToolOutputPruneCandidates(
596
686
  return { candidates, tokensSaved };
597
687
  }
598
688
 
689
+ function minimumSavings(config: PruneConfig, options: PruneToolOutputsOptions = {}): number {
690
+ const relaxedMinimum = options.relaxedMinimum;
691
+ return typeof relaxedMinimum === "number" && Number.isFinite(relaxedMinimum)
692
+ ? Math.min(config.minimumSavings, Math.max(0, relaxedMinimum))
693
+ : config.minimumSavings;
694
+ }
695
+
599
696
  /**
600
697
  * Estimate the token savings {@link pruneToolOutputs} would achieve, without
601
698
  * mutating any entry. Returns 0 savings when below the configured minimum so the
@@ -604,9 +701,10 @@ function collectToolOutputPruneCandidates(
604
701
  export function estimateToolOutputPruneSavings(
605
702
  entries: SessionEntry[],
606
703
  config: PruneConfig = DEFAULT_PRUNE_CONFIG,
704
+ options: PruneToolOutputsOptions = {},
607
705
  ): { prunableCount: number; tokensSaved: number } {
608
706
  const { candidates, tokensSaved } = collectToolOutputPruneCandidates(entries, config);
609
- if (tokensSaved < config.minimumSavings || candidates.length === 0) {
707
+ if (tokensSaved < minimumSavings(config, options) || candidates.length === 0) {
610
708
  return { prunableCount: 0, tokensSaved: 0 };
611
709
  }
612
710
  return { prunableCount: candidates.length, tokensSaved };
@@ -630,10 +728,20 @@ export function shouldRunMaintenancePrune(args: {
630
728
  return args.estimatedSavings > args.cacheEpochResetCost;
631
729
  }
632
730
 
633
- export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
731
+ export interface PruneToolOutputsOptions {
732
+ /** Lower the usual minimum only when the caller is already over its compaction threshold. */
733
+ relaxedMinimum?: number;
734
+ }
735
+
736
+ export function pruneToolOutputs(
737
+ entries: SessionEntry[],
738
+ config: PruneConfig = DEFAULT_PRUNE_CONFIG,
739
+ options: PruneToolOutputsOptions = {},
740
+ ): PruneResult {
634
741
  const { candidates, tokensSaved } = collectToolOutputPruneCandidates(entries, config);
742
+ const minimum = minimumSavings(config, options);
635
743
 
636
- if (tokensSaved < config.minimumSavings || candidates.length === 0) {
744
+ if (tokensSaved < minimum || candidates.length === 0) {
637
745
  return { prunedCount: 0, tokensSaved: 0, prunedEntries: [] };
638
746
  }
639
747