@vib-rato/agent-core 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +852 -0
- package/README.md +493 -0
- package/dist/types/agent-loop.d.ts +229 -0
- package/dist/types/agent.d.ts +533 -0
- package/dist/types/append-only-context.d.ts +141 -0
- package/dist/types/attempt-scope.d.ts +84 -0
- package/dist/types/compaction/adaptive.d.ts +31 -0
- package/dist/types/compaction/branch-summarization.d.ts +103 -0
- package/dist/types/compaction/compaction.d.ts +330 -0
- package/dist/types/compaction/entries.d.ts +124 -0
- package/dist/types/compaction/errors.d.ts +26 -0
- package/dist/types/compaction/index.d.ts +12 -0
- package/dist/types/compaction/messages.d.ts +61 -0
- package/dist/types/compaction/openai.d.ts +65 -0
- package/dist/types/compaction/pruning.d.ts +130 -0
- package/dist/types/compaction/utils.d.ts +32 -0
- package/dist/types/compaction.d.ts +1 -0
- package/dist/types/harmony-leak.d.ts +100 -0
- package/dist/types/heap-eviction-retainers.test.d.ts +1 -0
- package/dist/types/image-placeholder-guard.d.ts +4 -0
- package/dist/types/index.d.ts +13 -0
- package/dist/types/proxy.d.ts +95 -0
- package/dist/types/run-collector.d.ts +223 -0
- package/dist/types/run-resource-ledger.d.ts +2 -0
- package/dist/types/telemetry.d.ts +605 -0
- package/dist/types/thinking.d.ts +18 -0
- package/dist/types/tool-dispatch-identity.d.ts +27 -0
- package/dist/types/types.d.ts +790 -0
- package/package.json +72 -0
- package/src/agent-loop.ts +5632 -0
- package/src/agent.ts +2437 -0
- package/src/append-only-context.ts +496 -0
- package/src/attempt-scope.ts +195 -0
- package/src/compaction/adaptive.ts +92 -0
- package/src/compaction/branch-summarization.ts +358 -0
- package/src/compaction/compaction.ts +1569 -0
- package/src/compaction/entries.ts +158 -0
- package/src/compaction/errors.ts +31 -0
- package/src/compaction/index.ts +13 -0
- package/src/compaction/messages.ts +212 -0
- package/src/compaction/openai.ts +580 -0
- package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
- package/src/compaction/prompts/branch-summary-context.md +5 -0
- package/src/compaction/prompts/branch-summary-preamble.md +2 -0
- package/src/compaction/prompts/branch-summary.md +30 -0
- package/src/compaction/prompts/compaction-short-summary.md +9 -0
- package/src/compaction/prompts/compaction-summary-context.md +5 -0
- package/src/compaction/prompts/compaction-summary.md +38 -0
- package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
- package/src/compaction/prompts/compaction-update-summary.md +45 -0
- package/src/compaction/prompts/file-operations.md +10 -0
- package/src/compaction/prompts/handoff-document.md +56 -0
- package/src/compaction/prompts/summarization-system.md +3 -0
- package/src/compaction/pruning.ts +1026 -0
- package/src/compaction/utils.ts +189 -0
- package/src/compaction.ts +1 -0
- package/src/harmony-leak.ts +457 -0
- package/src/heap-eviction-retainers.test.ts +293 -0
- package/src/image-placeholder-guard.ts +20 -0
- package/src/index.ts +23 -0
- package/src/prompts/escaped-nonascii-recovery.md +3 -0
- package/src/prompts/repeated-tool-failure-recovery.md +1 -0
- package/src/proxy.ts +408 -0
- package/src/run-collector.ts +728 -0
- package/src/run-resource-ledger.ts +345 -0
- package/src/telemetry.ts +2161 -0
- package/src/thinking.ts +20 -0
- package/src/tool-dispatch-identity.ts +87 -0
- package/src/types.ts +882 -0
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool output pruning utilities for compaction.
|
|
3
|
+
*
|
|
4
|
+
* Candidate selection is staleness-aware: tool results that have been
|
|
5
|
+
* superseded by a later result for the same target (same file read again,
|
|
6
|
+
* same search re-run) or invalidated by a later successful edit/write to a
|
|
7
|
+
* covered file are pruned in preference to merely-old results. Protect-window
|
|
8
|
+
* and minimum-savings hysteresis semantics are unchanged.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import type { ToolCall, ToolResultMessage } from "@vib-rato/ai";
|
|
13
|
+
import { sanitizeText } from "@vib-rato/utils";
|
|
14
|
+
import type { AgentMessage } from "../types";
|
|
15
|
+
import { estimateEntryTokens, estimateTextTokensHeuristic } from "./compaction";
|
|
16
|
+
import type { SessionEntry, SessionMessageEntry } from "./entries";
|
|
17
|
+
|
|
18
|
+
export interface PruneConfig {
|
|
19
|
+
/** Keep the most recent tool output tokens intact. */
|
|
20
|
+
protectTokens: number;
|
|
21
|
+
/** Only prune if total savings meets this threshold. */
|
|
22
|
+
minimumSavings: number;
|
|
23
|
+
/** Tool names that should never be pruned. */
|
|
24
|
+
protectedTools: string[];
|
|
25
|
+
/** Number of newest user turns whose tool outputs must remain intact. Defaults to 2. */
|
|
26
|
+
protectRecentTurns?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Tools in `protectedTools` whose protection is waived once the result is
|
|
29
|
+
* superseded (a later result for the same target, or a later successful
|
|
30
|
+
* edit/write to the covered file). The most recent result per target is
|
|
31
|
+
* never considered superseded. Optional; defaults to none.
|
|
32
|
+
*/
|
|
33
|
+
staleOverridableTools?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
|
|
37
|
+
protectTokens: 40_000,
|
|
38
|
+
minimumSavings: 20_000,
|
|
39
|
+
protectedTools: ["skill", "read"],
|
|
40
|
+
protectRecentTurns: 2,
|
|
41
|
+
staleOverridableTools: ["read"],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface ToolOutputPruneDigest {
|
|
45
|
+
entryId: string;
|
|
46
|
+
sha256: string;
|
|
47
|
+
bytes: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ToolOutputPruneReplacement {
|
|
51
|
+
entryId: string;
|
|
52
|
+
replacementText: string;
|
|
53
|
+
/** Text-only results are the only entries safe to evict to an artifact. */
|
|
54
|
+
complete: boolean;
|
|
55
|
+
tokens: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ToolOutputPrunePlan {
|
|
59
|
+
prunedCount: number;
|
|
60
|
+
tokensSaved: number;
|
|
61
|
+
/** Digest-only identity records; no original output text is retained. */
|
|
62
|
+
digests: readonly ToolOutputPruneDigest[];
|
|
63
|
+
/** Immutable replacement proposals keyed by entry id. */
|
|
64
|
+
replacements: readonly ToolOutputPruneReplacement[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ToolOutputPruneEvictionHandle {
|
|
68
|
+
v: 1;
|
|
69
|
+
artifactId: string;
|
|
70
|
+
uri: string;
|
|
71
|
+
encoding: "utf-8";
|
|
72
|
+
bytes: number;
|
|
73
|
+
sha256: string;
|
|
74
|
+
complete: true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ToolOutputPruneCommitReplacement {
|
|
78
|
+
replacementText?: string;
|
|
79
|
+
eviction?: ToolOutputPruneEvictionHandle;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ToolOutputPruneCommitOptions {
|
|
83
|
+
replacements?: ReadonlyMap<string, ToolOutputPruneCommitReplacement>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type ToolOutputCommitOutcome =
|
|
87
|
+
| { entryId: string; outcome: "committed" }
|
|
88
|
+
| { entryId: string; outcome: "mismatch"; diagnostic: string }
|
|
89
|
+
| { entryId: string; outcome: "unavailable"; diagnostic: string };
|
|
90
|
+
|
|
91
|
+
const ERROR_DIGEST_MAX_CHARS = 240;
|
|
92
|
+
const TAIL_DIGEST_MAX_CHARS = 160;
|
|
93
|
+
const PATH_DIGEST_MAX_CHARS = 120;
|
|
94
|
+
/**
|
|
95
|
+
* Absolute budget for the assembled digest (~64 tokens). Fields are ordered
|
|
96
|
+
* error-first, so truncating the assembled digest drops tail/counts before it
|
|
97
|
+
* ever touches the error signal.
|
|
98
|
+
*/
|
|
99
|
+
const DIGEST_TOTAL_MAX_CHARS = 256;
|
|
100
|
+
|
|
101
|
+
function createGenericPrunedNotice(tokens: number): string {
|
|
102
|
+
return `[Output truncated - ${tokens} tokens]`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function extractToolOutputText(message: ToolResultMessage): { text: string; complete: boolean } {
|
|
106
|
+
if (typeof message.content === "string") return { text: message.content, complete: true };
|
|
107
|
+
const textBlocks: string[] = [];
|
|
108
|
+
let complete = true;
|
|
109
|
+
for (const block of message.content) {
|
|
110
|
+
if (block.type === "text") textBlocks.push(block.text);
|
|
111
|
+
else complete = false;
|
|
112
|
+
}
|
|
113
|
+
return { text: textBlocks.join("\n"), complete };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function firstTextContent(message: ToolResultMessage): string {
|
|
117
|
+
return extractToolOutputText(message).text;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function firstErrorLine(text: string): string | undefined {
|
|
121
|
+
return text
|
|
122
|
+
.split(/\r?\n/)
|
|
123
|
+
.find(line => /error|failed|exception|panic/i.test(line))
|
|
124
|
+
?.trim();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function firstNonEmptyLine(text: string): string | undefined {
|
|
128
|
+
return text
|
|
129
|
+
.split(/\r?\n/)
|
|
130
|
+
.find(line => line.trim().length > 0)
|
|
131
|
+
?.trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function lastNonEmptyLine(text: string): string | undefined {
|
|
135
|
+
return text.trim().split(/\r?\n/).filter(Boolean).at(-1)?.trim();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function truncateField(value: string, maxLength: number): string {
|
|
139
|
+
if (value.length <= maxLength) return value;
|
|
140
|
+
if (maxLength <= 1) return "…";
|
|
141
|
+
return `${value.slice(0, maxLength - 1)}…`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resultPathHint(message: ToolResultMessage, call?: ToolCall): string | undefined {
|
|
145
|
+
return (call && toolCallPath(call)) ?? readResolvedPath(message);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function resultDigest(message: ToolResultMessage, call?: ToolCall): string | undefined {
|
|
149
|
+
const toolName = message.toolName.toLowerCase();
|
|
150
|
+
const text = sanitizeText(firstTextContent(message));
|
|
151
|
+
const error = firstErrorLine(text);
|
|
152
|
+
const path = resultPathHint(message, call);
|
|
153
|
+
const pathPart = path ? `path=${truncateField(path, PATH_DIGEST_MAX_CHARS)}` : undefined;
|
|
154
|
+
if (toolName === "bash") {
|
|
155
|
+
const details = message as { details?: { exitCode?: unknown } };
|
|
156
|
+
const exitCode =
|
|
157
|
+
typeof details.details?.exitCode === "number" ? details.details.exitCode : message.isError ? 1 : 0;
|
|
158
|
+
const tail = text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
|
|
159
|
+
return [
|
|
160
|
+
`exit=${exitCode}`,
|
|
161
|
+
error ? `error=${truncateField(error, ERROR_DIGEST_MAX_CHARS)}` : undefined,
|
|
162
|
+
pathPart,
|
|
163
|
+
tail ? `tail=${truncateField(tail, TAIL_DIGEST_MAX_CHARS)}` : undefined,
|
|
164
|
+
]
|
|
165
|
+
.filter((part): part is string => part !== undefined)
|
|
166
|
+
.join("; ");
|
|
167
|
+
}
|
|
168
|
+
if (toolName === "search" || toolName === "grep") {
|
|
169
|
+
const match = text.match(/(\d+)\s+matches?/i) ?? text.match(/totalMatches["']?:\s*(\d+)/i);
|
|
170
|
+
const files = text.match(/(\d+)\s+files?/i) ?? text.match(/filesWithMatches["']?:\s*(\d+)/i);
|
|
171
|
+
return (
|
|
172
|
+
[
|
|
173
|
+
error ? `error=${truncateField(error, ERROR_DIGEST_MAX_CHARS)}` : undefined,
|
|
174
|
+
pathPart,
|
|
175
|
+
match ? `matches=${match[1]}` : undefined,
|
|
176
|
+
files ? `files=${files[1]}` : undefined,
|
|
177
|
+
]
|
|
178
|
+
.filter((part): part is string => part !== undefined)
|
|
179
|
+
.join("; ") || "search digest unavailable"
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (message.isError !== true) return undefined;
|
|
183
|
+
if (text.trim().length === 0) return "error=tool result failed without text";
|
|
184
|
+
if (error) return [`error=${truncateField(error, ERROR_DIGEST_MAX_CHARS)}`, pathPart].filter(Boolean).join("; ");
|
|
185
|
+
const summary = firstNonEmptyLine(text) ?? lastNonEmptyLine(text);
|
|
186
|
+
return summary ? `summary=${truncateField(summary, ERROR_DIGEST_MAX_CHARS)}` : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function createPrunedNotice(
|
|
190
|
+
tokens: number,
|
|
191
|
+
message?: ToolResultMessage,
|
|
192
|
+
call?: ToolCall,
|
|
193
|
+
artifact?: string,
|
|
194
|
+
): string {
|
|
195
|
+
const generic = createGenericPrunedNotice(tokens);
|
|
196
|
+
const digest =
|
|
197
|
+
truncateField(message ? (resultDigest(message, call) ?? "") : "", DIGEST_TOTAL_MAX_CHARS) || undefined;
|
|
198
|
+
if (!digest && !artifact) return generic;
|
|
199
|
+
if (artifact) {
|
|
200
|
+
return `[Output truncated - ${tokens} tokens; full output: ${artifact}]${digest ? ` ${digest}` : ""}`;
|
|
201
|
+
}
|
|
202
|
+
return `[Output truncated - ${tokens} tokens; ${digest}]`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
|
|
206
|
+
if (entry.type !== "message") return undefined;
|
|
207
|
+
const message = entry.message as AgentMessage;
|
|
208
|
+
if (message.role !== "toolResult") return undefined;
|
|
209
|
+
return message as ToolResultMessage;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function estimatePrunedSavings(tokens: number, notice: string): number {
|
|
213
|
+
return tokens - estimateTextTokensHeuristic(notice);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface AssistantArgumentPruneResult {
|
|
217
|
+
argumentPrunedCount: number;
|
|
218
|
+
argumentTokensSaved: number;
|
|
219
|
+
/**
|
|
220
|
+
* The mutated assistant message entries. Callers whose entry source returns
|
|
221
|
+
* materialized copies must write these back into their canonical store by id.
|
|
222
|
+
*/
|
|
223
|
+
prunedEntries: SessionMessageEntry[];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface PrunedToolArgumentsSentinel {
|
|
227
|
+
pruned: true;
|
|
228
|
+
reason: "stale_tool_arguments";
|
|
229
|
+
pathHints: string[];
|
|
230
|
+
originalChars: number;
|
|
231
|
+
prunedAt: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const EDIT_TOOL_NAMES = new Set(["edit", "write", "apply_patch", "ast_edit"]);
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A tool call's arguments, or `undefined` when the persisted payload is not an
|
|
238
|
+
* object.
|
|
239
|
+
*
|
|
240
|
+
* `ToolCall.arguments` is typed non-nullable, but sessions written by an older
|
|
241
|
+
* cold-spill eviction path carry `arguments: null` where the spill sentinel
|
|
242
|
+
* should be. Reading `.path` off that null threw a TypeError that surfaced as
|
|
243
|
+
* `null is not an object (evaluating 'args.path')` and killed the turn, so
|
|
244
|
+
* every reader of persisted arguments must treat them as untrusted.
|
|
245
|
+
*/
|
|
246
|
+
function toolArguments(call: ToolCall): Record<string, unknown> | undefined {
|
|
247
|
+
const args = call.arguments;
|
|
248
|
+
return typeof args === "object" && args !== null ? args : undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Extract the file-path argument from a tool call, when the tool has one. */
|
|
252
|
+
function toolCallPath(call: ToolCall): string | undefined {
|
|
253
|
+
const args = toolArguments(call);
|
|
254
|
+
if (!args) return undefined;
|
|
255
|
+
const path = args.path ?? args.file_path ?? args.filePath;
|
|
256
|
+
return typeof path === "string" && path.length > 0 ? path : undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* `*** Add|Update|Delete File: <path>` headers open a hunk; `*** Move to:
|
|
261
|
+
* <path>` attaches a rename destination to the current hunk. Move
|
|
262
|
+
* destinations count as touched paths: a rename onto a file invalidates
|
|
263
|
+
* earlier reads of that destination.
|
|
264
|
+
*/
|
|
265
|
+
const APPLY_PATCH_HEADER = /^\*\*\* (?:((?:Add|Update|Delete) File)|(Move to)): (.+)$/gm;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Paths touched by an edit-class tool call, grouped per hunk so a failed
|
|
269
|
+
* hunk can be excluded wholesale (its rename destination included). Most
|
|
270
|
+
* edit tools carry a single path argument; apply_patch envelopes carry an
|
|
271
|
+
* `input` string with per-file headers instead. The envelope shape can
|
|
272
|
+
* arrive under the custom `apply_patch` tool OR the regular `edit` tool
|
|
273
|
+
* (providers without custom-tool support fall back to the JSON function), so
|
|
274
|
+
* any edit-class call with a string `input` is parsed for headers.
|
|
275
|
+
*/
|
|
276
|
+
function editToolPathGroups(call: ToolCall): string[][] {
|
|
277
|
+
const path = toolCallPath(call);
|
|
278
|
+
if (path !== undefined) return [[path]];
|
|
279
|
+
const input = toolArguments(call)?.input;
|
|
280
|
+
if (typeof input !== "string") return [];
|
|
281
|
+
const groups: string[][] = [];
|
|
282
|
+
for (const match of input.matchAll(APPLY_PATCH_HEADER)) {
|
|
283
|
+
const headerPath = match[3]?.trim();
|
|
284
|
+
if (!headerPath) continue;
|
|
285
|
+
const isMoveTo = match[2] !== undefined;
|
|
286
|
+
if (isMoveTo && groups.length > 0) {
|
|
287
|
+
groups[groups.length - 1].push(headerPath);
|
|
288
|
+
} else {
|
|
289
|
+
groups.push([headerPath]);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return groups;
|
|
293
|
+
}
|
|
294
|
+
function pathGroupKey(group: string[]): string {
|
|
295
|
+
return JSON.stringify([...group].sort());
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function pathHintsForGroups(groups: string[][]): string[] {
|
|
299
|
+
return [...new Set(groups.flat())].sort();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isPrunedToolArgumentsSentinel(value: unknown): value is PrunedToolArgumentsSentinel {
|
|
303
|
+
return (
|
|
304
|
+
typeof value === "object" &&
|
|
305
|
+
value !== null &&
|
|
306
|
+
(value as { pruned?: unknown; reason?: unknown }).pruned === true &&
|
|
307
|
+
(value as { pruned?: unknown; reason?: unknown }).reason === "stale_tool_arguments"
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isEditToolCall(call: ToolCall): boolean {
|
|
312
|
+
return EDIT_TOOL_NAMES.has(call.name) || call.customWireName === "apply_patch";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
interface AssistantArgumentStalenessIndex {
|
|
316
|
+
latestSuccessfulMutationByPathGroup: Map<string, { index: number; callId: string }>;
|
|
317
|
+
failedCallIds: Set<string>;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function buildAssistantArgumentStalenessIndex(entries: SessionEntry[]): AssistantArgumentStalenessIndex {
|
|
321
|
+
const callsById = new Map<string, ToolCall>();
|
|
322
|
+
for (const entry of entries) {
|
|
323
|
+
if (entry.type !== "message") continue;
|
|
324
|
+
const message = entry.message as AgentMessage;
|
|
325
|
+
if (message.role !== "assistant") continue;
|
|
326
|
+
for (const content of message.content) {
|
|
327
|
+
if (content.type === "toolCall") callsById.set(content.id, content);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const latestSuccessfulMutationByPathGroup = new Map<string, { index: number; callId: string }>();
|
|
332
|
+
const failedCallIds = new Set<string>();
|
|
333
|
+
for (let i = 0; i < entries.length; i++) {
|
|
334
|
+
const message = getToolResultMessage(entries[i]);
|
|
335
|
+
if (!message) continue;
|
|
336
|
+
const call = callsById.get(message.toolCallId);
|
|
337
|
+
if (!call || !isEditToolCall(call)) continue;
|
|
338
|
+
const detailFiles = call.name === "ast_edit" ? resultDetailFiles(message) : [];
|
|
339
|
+
const groups = detailFiles.length > 0 ? detailFiles.map(file => [file]) : editToolPathGroups(call);
|
|
340
|
+
if (groups.length === 0) continue;
|
|
341
|
+
if (message.isError) {
|
|
342
|
+
failedCallIds.add(call.id);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const successfulPaths = successfulEditPaths(message);
|
|
346
|
+
let mutated = false;
|
|
347
|
+
for (const group of groups) {
|
|
348
|
+
if (successfulPaths !== undefined && !group.some(groupPath => successfulPaths.has(groupPath))) continue;
|
|
349
|
+
latestSuccessfulMutationByPathGroup.set(pathGroupKey(group), { index: i, callId: call.id });
|
|
350
|
+
mutated = true;
|
|
351
|
+
}
|
|
352
|
+
if (!mutated) failedCallIds.add(call.id);
|
|
353
|
+
}
|
|
354
|
+
return { latestSuccessfulMutationByPathGroup, failedCallIds };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Exact read selector grammar mirrored from the read tool without importing its package layer. */
|
|
358
|
+
const READ_SELECTOR_RE = /^(?:L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|raw|conflicts)$/i;
|
|
359
|
+
const READ_RANGE_SELECTOR_RE = /^L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*$/i;
|
|
360
|
+
const READ_RAW_SELECTOR_RE = /^raw$/i;
|
|
361
|
+
|
|
362
|
+
type ReadTarget = { basePath: string; selector?: string };
|
|
363
|
+
|
|
364
|
+
function splitReadTarget(path: string): ReadTarget {
|
|
365
|
+
const outerColon = path.lastIndexOf(":");
|
|
366
|
+
if (outerColon <= 0) return { basePath: path };
|
|
367
|
+
const outer = path.slice(outerColon + 1);
|
|
368
|
+
if (!READ_SELECTOR_RE.test(outer)) return { basePath: path };
|
|
369
|
+
|
|
370
|
+
let basePath = path.slice(0, outerColon);
|
|
371
|
+
let selector = outer;
|
|
372
|
+
const innerColon = basePath.lastIndexOf(":");
|
|
373
|
+
if (innerColon > 0) {
|
|
374
|
+
const inner = basePath.slice(innerColon + 1);
|
|
375
|
+
const compoundRawRange =
|
|
376
|
+
(READ_RAW_SELECTOR_RE.test(inner) && READ_RANGE_SELECTOR_RE.test(outer)) ||
|
|
377
|
+
(READ_RANGE_SELECTOR_RE.test(inner) && READ_RAW_SELECTOR_RE.test(outer));
|
|
378
|
+
if (compoundRawRange) {
|
|
379
|
+
selector = `${inner}:${outer}`;
|
|
380
|
+
basePath = basePath.slice(0, innerColon);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return { basePath, selector };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Base file path of a read target with its one valid selector stripped. */
|
|
387
|
+
function readBasePath(path: string): string {
|
|
388
|
+
return splitReadTarget(path).basePath;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
type ReadLineRange = { start: number; end: number };
|
|
392
|
+
|
|
393
|
+
/** Parse only one explicit, provably bounded trailing read range. */
|
|
394
|
+
function readLineRanges(path: string): ReadLineRange[] {
|
|
395
|
+
const selector = splitReadTarget(path).selector;
|
|
396
|
+
if (!selector || /(?:^|:)raw(?:$|:)/i.test(selector) || /^conflicts$/i.test(selector)) return [];
|
|
397
|
+
return selector.split(",").flatMap(part => {
|
|
398
|
+
const range = part.match(/^L?(\d+)([-+])L?(\d+)$/i);
|
|
399
|
+
if (!range) return [];
|
|
400
|
+
const start = Number(range[1]);
|
|
401
|
+
const end = range[2] === "+" ? start + Number(range[3]) - 1 : Number(range[3]);
|
|
402
|
+
return start > 0 && end >= start ? [{ start, end }] : [];
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function strictlyContainsReadRange(container: ReadLineRange, contained: ReadLineRange): boolean {
|
|
407
|
+
return (
|
|
408
|
+
container.start <= contained.start &&
|
|
409
|
+
container.end >= contained.end &&
|
|
410
|
+
(container.start < contained.start || container.end > contained.end)
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function readSupersedesRead(
|
|
415
|
+
later: ToolCall,
|
|
416
|
+
earlier: ToolCall,
|
|
417
|
+
lineRangesByCall: ReadonlyMap<ToolCall, ReadLineRange[]>,
|
|
418
|
+
): boolean {
|
|
419
|
+
const laterRanges = lineRangesByCall.get(later);
|
|
420
|
+
const earlierRanges = lineRangesByCall.get(earlier);
|
|
421
|
+
return (
|
|
422
|
+
laterRanges?.length === 1 &&
|
|
423
|
+
earlierRanges?.length === 1 &&
|
|
424
|
+
strictlyContainsReadRange(laterRanges[0], earlierRanges[0])
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Stable identity for "the same logical lookup": same tool re-targeting the
|
|
430
|
+
* same subject. A later result with the same key supersedes earlier ones.
|
|
431
|
+
* Keys are canonical JSON tuples so user-controlled text (patterns, paths)
|
|
432
|
+
* can never collide via delimiter ambiguity. Search keys include pagination
|
|
433
|
+
* (`skip`) and result-shaping flags (`i`, `gitignore`): a later page or a
|
|
434
|
+
* differently-shaped search complements earlier output, it does not replace it.
|
|
435
|
+
*/
|
|
436
|
+
const IDEMPOTENT_BASH_COMMAND =
|
|
437
|
+
/^(?:(?:bun|npm|pnpm|yarn)\s+(?:run\s+)?(?:test|build)\b|git\s+status\b|cargo\s+build\b|(?:make|just)\s+build\b)/;
|
|
438
|
+
|
|
439
|
+
function normalizedIdempotentBashCommand(call: ToolCall): string | undefined {
|
|
440
|
+
if (call.name !== "bash") return undefined;
|
|
441
|
+
const args = toolArguments(call);
|
|
442
|
+
if (!args) return undefined;
|
|
443
|
+
const command = args.command;
|
|
444
|
+
if (typeof command !== "string") return undefined;
|
|
445
|
+
const normalized = command.trim().replace(/\s+/g, " ");
|
|
446
|
+
if (/[;&|]/.test(normalized) || !IDEMPOTENT_BASH_COMMAND.test(normalized)) return undefined;
|
|
447
|
+
return JSON.stringify([normalized, typeof args.cwd === "string" ? args.cwd : undefined]);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function toolTargetKey(call: ToolCall): string | undefined {
|
|
451
|
+
const path = toolCallPath(call);
|
|
452
|
+
if (path !== undefined) return JSON.stringify([call.name, "path", path]);
|
|
453
|
+
const command = normalizedIdempotentBashCommand(call);
|
|
454
|
+
if (command !== undefined) return JSON.stringify([call.name, "command", command]);
|
|
455
|
+
const args = toolArguments(call);
|
|
456
|
+
if (!args) return undefined;
|
|
457
|
+
const pattern = args.pattern;
|
|
458
|
+
if (typeof pattern === "string" && pattern.length > 0) {
|
|
459
|
+
const paths = args.paths;
|
|
460
|
+
const pathList = Array.isArray(paths) ? paths.filter((p): p is string => typeof p === "string") : [];
|
|
461
|
+
const skip = typeof args.skip === "number" ? args.skip : 0;
|
|
462
|
+
const caseInsensitive = args.i === true;
|
|
463
|
+
const gitignore = args.gitignore !== false;
|
|
464
|
+
return JSON.stringify([call.name, "pattern", pattern, pathList, skip, caseInsensitive, gitignore]);
|
|
465
|
+
}
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Files actually mutated according to a tool result's details. Used for
|
|
471
|
+
* AST-edit-shaped results (`ast_edit` direct-apply and the hidden `resolve`
|
|
472
|
+
* apply step), which report `{ applied: true, files: [...] }` — the resolve
|
|
473
|
+
* tool nests that payload under `details.sourceResultDetails`. Conservative:
|
|
474
|
+
* returns nothing unless the details explicitly mark the change as applied.
|
|
475
|
+
* Checked even on `isError` results: a stale-preview apply reports an error
|
|
476
|
+
* while still having mutated the listed files.
|
|
477
|
+
*/
|
|
478
|
+
function resultDetailFiles(message: ToolResultMessage): string[] {
|
|
479
|
+
const raw = message.details as { applied?: unknown; files?: unknown; sourceResultDetails?: unknown } | undefined;
|
|
480
|
+
const candidates = [raw, raw?.sourceResultDetails as { applied?: unknown; files?: unknown } | undefined];
|
|
481
|
+
for (const details of candidates) {
|
|
482
|
+
if (details?.applied === true && Array.isArray(details.files)) {
|
|
483
|
+
return details.files.filter((file): file is string => typeof file === "string" && file.length > 0);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return [];
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Paths that a per-file edit result proves were mutated. Multi-file
|
|
491
|
+
* `apply_patch` can return a non-error envelope while individual files fail, so
|
|
492
|
+
* only explicit success (`isError === false`) or the normal successful result
|
|
493
|
+
* shape (a string `diff` with no error flag) counts. Ambiguous/malformed rows
|
|
494
|
+
* fail closed. If no per-file result array exists, return undefined so ordinary
|
|
495
|
+
* single-file successful tool results retain their established behavior.
|
|
496
|
+
*/
|
|
497
|
+
function successfulEditPaths(message: ToolResultMessage): Set<string> | undefined {
|
|
498
|
+
const details = message.details as { perFileResults?: unknown } | undefined;
|
|
499
|
+
const perFile = details?.perFileResults;
|
|
500
|
+
if (!Array.isArray(perFile)) return undefined;
|
|
501
|
+
const succeeded = new Set<string>();
|
|
502
|
+
for (const item of perFile) {
|
|
503
|
+
const entry = item as { path?: unknown; isError?: unknown; diff?: unknown };
|
|
504
|
+
if (typeof entry?.path !== "string") continue;
|
|
505
|
+
if (entry.isError === false || (entry.isError === undefined && typeof entry.diff === "string")) {
|
|
506
|
+
succeeded.add(entry.path);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return succeeded;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Concrete file path a `read` result actually came from, when the tool
|
|
514
|
+
* reported one (`details.resolvedPath`). Suffix resolution can map a bare
|
|
515
|
+
* filename argument onto a different concrete path.
|
|
516
|
+
*/
|
|
517
|
+
function readResolvedPath(message: ToolResultMessage): string | undefined {
|
|
518
|
+
const details = message.details as { resolvedPath?: unknown } | undefined;
|
|
519
|
+
const resolved = details?.resolvedPath;
|
|
520
|
+
return typeof resolved === "string" && resolved.length > 0 ? resolved : undefined;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
interface StalenessIndex {
|
|
524
|
+
/** Entry indices of toolResults superseded by a later same-target result or a later edit. */
|
|
525
|
+
staleResultIndices: Set<number>;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Build a staleness index over session entries (oldest -> newest):
|
|
530
|
+
* - a toolResult is stale when a later non-error toolResult shares its target key;
|
|
531
|
+
* - a `read` result is stale when a later non-error edit/write touches its file.
|
|
532
|
+
* The most recent result per target is never stale.
|
|
533
|
+
*/
|
|
534
|
+
function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
|
|
535
|
+
const callsById = new Map<string, ToolCall>();
|
|
536
|
+
for (const entry of entries) {
|
|
537
|
+
if (entry.type !== "message") continue;
|
|
538
|
+
const message = entry.message as AgentMessage;
|
|
539
|
+
if (message.role !== "assistant") continue;
|
|
540
|
+
for (const content of message.content) {
|
|
541
|
+
if (content.type === "toolCall") callsById.set(content.id, content);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
type ResultMeta = { key?: string; call: ToolCall; message: ToolResultMessage };
|
|
546
|
+
const lastResultIndexByKey = new Map<string, number>();
|
|
547
|
+
const resultMeta = new Map<number, ResultMeta>();
|
|
548
|
+
const lastEditIndexByPath = new Map<string, number>();
|
|
549
|
+
|
|
550
|
+
for (let i = 0; i < entries.length; i++) {
|
|
551
|
+
const message = getToolResultMessage(entries[i]);
|
|
552
|
+
if (!message) continue;
|
|
553
|
+
const call = callsById.get(message.toolCallId);
|
|
554
|
+
if (!call) continue;
|
|
555
|
+
|
|
556
|
+
// AST edits mutate files when previews are applied via the hidden
|
|
557
|
+
// `resolve` tool; the call args carry globs, not concrete paths. Both
|
|
558
|
+
// tools report actually-touched files in result details. Collected
|
|
559
|
+
// BEFORE the error gate: a stale-preview apply reports an error while
|
|
560
|
+
// still having mutated the listed files.
|
|
561
|
+
if (call.name === "resolve" || call.name === "ast_edit") {
|
|
562
|
+
for (const editPath of resultDetailFiles(message)) {
|
|
563
|
+
lastEditIndexByPath.set(editPath, i);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (message.isError) continue;
|
|
567
|
+
|
|
568
|
+
const key = toolTargetKey(call);
|
|
569
|
+
resultMeta.set(i, { key, call, message });
|
|
570
|
+
if (key !== undefined) lastResultIndexByKey.set(key, i);
|
|
571
|
+
if (EDIT_TOOL_NAMES.has(call.name)) {
|
|
572
|
+
// Per-file edit results prove which path groups actually mutated. A
|
|
573
|
+
// malformed or ambiguous row cannot invalidate earlier read evidence.
|
|
574
|
+
const successfulPaths = successfulEditPaths(message);
|
|
575
|
+
for (const group of editToolPathGroups(call)) {
|
|
576
|
+
if (successfulPaths !== undefined && !group.some(groupPath => successfulPaths.has(groupPath))) continue;
|
|
577
|
+
for (const editPath of group) {
|
|
578
|
+
lastEditIndexByPath.set(editPath, i);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const staleResultIndices = new Set<number>();
|
|
585
|
+
for (const [index, meta] of resultMeta) {
|
|
586
|
+
if (meta.key !== undefined) {
|
|
587
|
+
const lastIndex = lastResultIndexByKey.get(meta.key);
|
|
588
|
+
if (lastIndex !== undefined && lastIndex > index) {
|
|
589
|
+
staleResultIndices.add(index);
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (meta.call.name === "read") {
|
|
594
|
+
// Check both the call argument (selectors stripped) and the resolved
|
|
595
|
+
// path from result details: suffix resolution can map a bare filename
|
|
596
|
+
// onto a different concrete path, and edits may use either form.
|
|
597
|
+
const lookupPaths = new Set<string>();
|
|
598
|
+
const argPath = toolCallPath(meta.call);
|
|
599
|
+
if (argPath !== undefined) lookupPaths.add(readBasePath(argPath));
|
|
600
|
+
const resolved = readResolvedPath(meta.message);
|
|
601
|
+
if (resolved !== undefined) lookupPaths.add(resolved);
|
|
602
|
+
for (const lookupPath of lookupPaths) {
|
|
603
|
+
const editIndex = lastEditIndexByPath.get(lookupPath);
|
|
604
|
+
if (editIndex !== undefined && editIndex > index) {
|
|
605
|
+
staleResultIndices.add(index);
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const readsByBasePath = new Map<string, Array<[number, ResultMeta]>>();
|
|
613
|
+
const lineRangesByCall = new Map<ToolCall, ReadLineRange[]>();
|
|
614
|
+
for (const [index, meta] of resultMeta) {
|
|
615
|
+
if (meta.call.name !== "read") continue;
|
|
616
|
+
const path = toolCallPath(meta.call);
|
|
617
|
+
if (!path) continue;
|
|
618
|
+
lineRangesByCall.set(meta.call, readLineRanges(path));
|
|
619
|
+
const basePath = readBasePath(path);
|
|
620
|
+
const group = readsByBasePath.get(basePath);
|
|
621
|
+
if (group) group.push([index, meta]);
|
|
622
|
+
else readsByBasePath.set(basePath, [[index, meta]]);
|
|
623
|
+
}
|
|
624
|
+
for (const reads of readsByBasePath.values()) {
|
|
625
|
+
if (reads.length < 2) continue;
|
|
626
|
+
for (let earlier = 0; earlier < reads.length - 1; earlier++) {
|
|
627
|
+
const [index, meta] = reads[earlier];
|
|
628
|
+
for (let later = earlier + 1; later < reads.length; later++) {
|
|
629
|
+
if (readSupersedesRead(reads[later][1].call, meta.call, lineRangesByCall)) {
|
|
630
|
+
staleResultIndices.add(index);
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return { staleResultIndices };
|
|
638
|
+
}
|
|
639
|
+
export function pruneAssistantToolArguments(
|
|
640
|
+
entries: SessionEntry[],
|
|
641
|
+
config: PruneConfig = DEFAULT_PRUNE_CONFIG,
|
|
642
|
+
): AssistantArgumentPruneResult {
|
|
643
|
+
let accumulatedTokens = 0;
|
|
644
|
+
const { latestSuccessfulMutationByPathGroup, failedCallIds } = buildAssistantArgumentStalenessIndex(entries);
|
|
645
|
+
const argumentFenceStart = recentTurnFenceStart(entries, config.protectRecentTurns ?? 2);
|
|
646
|
+
const candidates: Array<{
|
|
647
|
+
entry: SessionMessageEntry;
|
|
648
|
+
call: ToolCall;
|
|
649
|
+
pathHints: string[];
|
|
650
|
+
originalChars: number;
|
|
651
|
+
}> = [];
|
|
652
|
+
|
|
653
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
654
|
+
const entry = entries[i];
|
|
655
|
+
if (entry.type !== "message") continue;
|
|
656
|
+
// Same newest-turn fence as tool-output pruning: edit/apply_patch
|
|
657
|
+
// arguments in the active (or otherwise protected) turn are live
|
|
658
|
+
// context, even when a later call in that turn superseded their path.
|
|
659
|
+
if (argumentFenceStart !== undefined && i >= argumentFenceStart) continue;
|
|
660
|
+
const message = entry.message as AgentMessage;
|
|
661
|
+
if (message.role !== "assistant") continue;
|
|
662
|
+
const entryTokens = estimateEntryTokens(entry);
|
|
663
|
+
const insideProtectWindow = accumulatedTokens < config.protectTokens;
|
|
664
|
+
accumulatedTokens += entryTokens;
|
|
665
|
+
for (const content of message.content) {
|
|
666
|
+
if (content.type !== "toolCall" || !isEditToolCall(content)) continue;
|
|
667
|
+
const argumentJson = JSON.stringify(content.arguments);
|
|
668
|
+
if (argumentJson === undefined) continue;
|
|
669
|
+
const originalChars = argumentJson.length;
|
|
670
|
+
if (isPrunedToolArgumentsSentinel(content.arguments)) continue;
|
|
671
|
+
if (insideProtectWindow || failedCallIds.has(content.id)) continue;
|
|
672
|
+
const groups = editToolPathGroups(content);
|
|
673
|
+
if (groups.length === 0) continue;
|
|
674
|
+
// Arguments are pruned as one indivisible payload, so require EVERY
|
|
675
|
+
// concrete path group to be stale from a later successful mutation.
|
|
676
|
+
// A group with no later success (failed/unknown/ambiguous) protects the
|
|
677
|
+
// whole call rather than dropping non-stale multi-file patch evidence.
|
|
678
|
+
const isStale = groups.every(group => {
|
|
679
|
+
const latest = latestSuccessfulMutationByPathGroup.get(pathGroupKey(group));
|
|
680
|
+
return latest !== undefined && latest.index > i && latest.callId !== content.id;
|
|
681
|
+
});
|
|
682
|
+
if (!isStale) continue;
|
|
683
|
+
candidates.push({
|
|
684
|
+
entry: entry as SessionMessageEntry,
|
|
685
|
+
call: content,
|
|
686
|
+
pathHints: pathHintsForGroups(groups),
|
|
687
|
+
originalChars,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (candidates.length === 0) {
|
|
693
|
+
return { argumentPrunedCount: 0, argumentTokensSaved: 0, prunedEntries: [] };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const prunedAt = Date.now();
|
|
697
|
+
const candidatesByEntry = new Map<SessionMessageEntry, typeof candidates>();
|
|
698
|
+
for (const candidate of candidates) {
|
|
699
|
+
const group = candidatesByEntry.get(candidate.entry);
|
|
700
|
+
if (group) group.push(candidate);
|
|
701
|
+
else candidatesByEntry.set(candidate.entry, [candidate]);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
let argumentTokensSaved = 0;
|
|
705
|
+
const admittedGroups: Array<{ entry: SessionMessageEntry; candidates: typeof candidates }> = [];
|
|
706
|
+
for (const [entry, entryCandidates] of candidatesByEntry) {
|
|
707
|
+
const candidateByCallId = new Map(entryCandidates.map(candidate => [candidate.call.id, candidate]));
|
|
708
|
+
const message = entry.message as AgentMessage;
|
|
709
|
+
if (message.role !== "assistant") continue;
|
|
710
|
+
const stagedEntry = {
|
|
711
|
+
...entry,
|
|
712
|
+
message: {
|
|
713
|
+
...message,
|
|
714
|
+
content: message.content.map(content => {
|
|
715
|
+
if (content.type !== "toolCall") return content;
|
|
716
|
+
const candidate = candidateByCallId.get(content.id);
|
|
717
|
+
if (!candidate) return content;
|
|
718
|
+
return {
|
|
719
|
+
...content,
|
|
720
|
+
arguments: {
|
|
721
|
+
pruned: true,
|
|
722
|
+
reason: "stale_tool_arguments",
|
|
723
|
+
pathHints: candidate.pathHints,
|
|
724
|
+
originalChars: candidate.originalChars,
|
|
725
|
+
prunedAt,
|
|
726
|
+
} satisfies PrunedToolArgumentsSentinel,
|
|
727
|
+
};
|
|
728
|
+
}),
|
|
729
|
+
},
|
|
730
|
+
} as SessionMessageEntry;
|
|
731
|
+
const savings = Math.max(0, estimateEntryTokens(entry) - estimateEntryTokens(stagedEntry));
|
|
732
|
+
if (savings === 0) continue;
|
|
733
|
+
argumentTokensSaved += savings;
|
|
734
|
+
admittedGroups.push({ entry, candidates: entryCandidates });
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (argumentTokensSaved < config.minimumSavings || admittedGroups.length === 0) {
|
|
738
|
+
return { argumentPrunedCount: 0, argumentTokensSaved: 0, prunedEntries: [] };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
let argumentPrunedCount = 0;
|
|
742
|
+
const prunedEntries: SessionMessageEntry[] = [];
|
|
743
|
+
for (const group of admittedGroups) {
|
|
744
|
+
for (const candidate of group.candidates) {
|
|
745
|
+
candidate.call.arguments = {
|
|
746
|
+
pruned: true,
|
|
747
|
+
reason: "stale_tool_arguments",
|
|
748
|
+
pathHints: candidate.pathHints,
|
|
749
|
+
originalChars: candidate.originalChars,
|
|
750
|
+
prunedAt,
|
|
751
|
+
};
|
|
752
|
+
argumentPrunedCount++;
|
|
753
|
+
}
|
|
754
|
+
prunedEntries.push(group.entry);
|
|
755
|
+
}
|
|
756
|
+
return { argumentPrunedCount, argumentTokensSaved, prunedEntries };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
interface ToolOutputPruneCandidate {
|
|
760
|
+
entry: SessionMessageEntry;
|
|
761
|
+
call?: ToolCall;
|
|
762
|
+
tokens: number;
|
|
763
|
+
originalText: string;
|
|
764
|
+
complete: boolean;
|
|
765
|
+
notice: string;
|
|
766
|
+
savings: number;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function recentTurnFenceStart(entries: SessionEntry[], protectRecentTurns: number): number | undefined {
|
|
770
|
+
if (protectRecentTurns <= 0) return undefined;
|
|
771
|
+
const starts: number[] = [];
|
|
772
|
+
for (let i = 0; i < entries.length; i++) {
|
|
773
|
+
const entry = entries[i];
|
|
774
|
+
if (entry.type !== "message") continue;
|
|
775
|
+
const role = entry.message.role as string;
|
|
776
|
+
if (role === "user" || role === "bashExecution") starts.push(i);
|
|
777
|
+
}
|
|
778
|
+
return starts.length === 0 ? undefined : starts[Math.max(0, starts.length - protectRecentTurns)];
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Read-only candidate collection shared by the digest-only plan and the
|
|
783
|
+
* non-mutating {@link estimateToolOutputPruneSavings} gate.
|
|
784
|
+
*/
|
|
785
|
+
function collectToolOutputPruneCandidates(
|
|
786
|
+
entries: SessionEntry[],
|
|
787
|
+
config: PruneConfig,
|
|
788
|
+
): { candidates: ToolOutputPruneCandidate[]; tokensSaved: number } {
|
|
789
|
+
let accumulatedTokens = 0;
|
|
790
|
+
|
|
791
|
+
const { staleResultIndices } = buildStalenessIndex(entries);
|
|
792
|
+
const callsById = new Map<string, ToolCall>();
|
|
793
|
+
for (const entry of entries) {
|
|
794
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
795
|
+
for (const content of entry.message.content) {
|
|
796
|
+
if (content.type === "toolCall") callsById.set(content.id, content);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
const fenceStart = recentTurnFenceStart(entries, config.protectRecentTurns ?? 2);
|
|
800
|
+
const staleOverridable = new Set(config.staleOverridableTools ?? []);
|
|
801
|
+
const candidates: ToolOutputPruneCandidate[] = [];
|
|
802
|
+
|
|
803
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
804
|
+
const entry = entries[i];
|
|
805
|
+
const message = getToolResultMessage(entry);
|
|
806
|
+
if (!message) continue;
|
|
807
|
+
|
|
808
|
+
const tokens = estimateEntryTokens(entry);
|
|
809
|
+
const isStale = staleResultIndices.has(i);
|
|
810
|
+
// Staleness waives protected-tool immunity for overridable tools
|
|
811
|
+
// (e.g. a superseded `read`); the most recent result per target is
|
|
812
|
+
// never stale, so the latest read of each file stays protected.
|
|
813
|
+
const isProtected =
|
|
814
|
+
config.protectedTools.includes(message.toolName) && !(isStale && staleOverridable.has(message.toolName));
|
|
815
|
+
|
|
816
|
+
if (message.prunedAt !== undefined || (fenceStart !== undefined && i >= fenceStart)) {
|
|
817
|
+
accumulatedTokens += tokens;
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Stale results are prunable even inside the recency protect window —
|
|
822
|
+
// they are superseded, so recency no longer implies relevance. They
|
|
823
|
+
// still count toward window accounting so non-stale protection is
|
|
824
|
+
// unchanged.
|
|
825
|
+
const insideProtectWindow = accumulatedTokens < config.protectTokens;
|
|
826
|
+
if ((insideProtectWindow && !isStale) || isProtected) {
|
|
827
|
+
accumulatedTokens += tokens;
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const call = callsById.get(message.toolCallId);
|
|
832
|
+
const captured = extractToolOutputText(message);
|
|
833
|
+
const notice = createPrunedNotice(tokens, message, call);
|
|
834
|
+
const savings = estimatePrunedSavings(tokens, notice);
|
|
835
|
+
const errorNoticeGrows = message.isError === true && notice.length > captured.text.length;
|
|
836
|
+
if (savings <= 0 || errorNoticeGrows) {
|
|
837
|
+
accumulatedTokens += tokens;
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
candidates.push({
|
|
841
|
+
entry: entry as SessionMessageEntry,
|
|
842
|
+
call,
|
|
843
|
+
tokens,
|
|
844
|
+
originalText: captured.text,
|
|
845
|
+
complete: captured.complete,
|
|
846
|
+
notice,
|
|
847
|
+
savings,
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
accumulatedTokens += tokens;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
let tokensSaved = 0;
|
|
854
|
+
for (const candidate of candidates) {
|
|
855
|
+
tokensSaved += candidate.savings;
|
|
856
|
+
}
|
|
857
|
+
return { candidates, tokensSaved };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function minimumSavings(config: PruneConfig, options: PruneToolOutputsOptions = {}): number {
|
|
861
|
+
const relaxedMinimum = options.relaxedMinimum;
|
|
862
|
+
return typeof relaxedMinimum === "number" && Number.isFinite(relaxedMinimum)
|
|
863
|
+
? Math.min(config.minimumSavings, Math.max(0, relaxedMinimum))
|
|
864
|
+
: config.minimumSavings;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Estimate conservative savings for a digest-only prune plan without mutating
|
|
869
|
+
* entries or invoking artifact publication.
|
|
870
|
+
*/
|
|
871
|
+
export function estimateToolOutputPruneSavings(
|
|
872
|
+
entries: SessionEntry[],
|
|
873
|
+
config: PruneConfig = DEFAULT_PRUNE_CONFIG,
|
|
874
|
+
options: PruneToolOutputsOptions = {},
|
|
875
|
+
): { prunableCount: number; tokensSaved: number } {
|
|
876
|
+
const { candidates, tokensSaved: baseTokensSaved } = collectToolOutputPruneCandidates(entries, config);
|
|
877
|
+
const minimum = minimumSavings(config, options);
|
|
878
|
+
if (baseTokensSaved < minimum || candidates.length === 0) return { prunableCount: 0, tokensSaved: 0 };
|
|
879
|
+
const planned = planToolOutputPruneCandidates(candidates, options);
|
|
880
|
+
const tokensSaved = planned.reduce((total, candidate) => total + candidate.savings, 0);
|
|
881
|
+
if (tokensSaved < minimum || planned.length === 0) return { prunableCount: 0, tokensSaved: 0 };
|
|
882
|
+
return { prunableCount: planned.length, tokensSaved };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Evidence gate for below-threshold maintenance pruning (Finding 13). Pruning
|
|
887
|
+
* forces a prompt-cache-epoch reset, so it only runs when opted in AND the
|
|
888
|
+
* estimated stale savings clear a high minimum AND exceed the one-time reset
|
|
889
|
+
* cost (so the reclaim pays the reset back). Default-off/blocked until live
|
|
890
|
+
* evidence justifies enabling.
|
|
891
|
+
*/
|
|
892
|
+
export function shouldRunMaintenancePrune(args: {
|
|
893
|
+
enabled: boolean;
|
|
894
|
+
estimatedSavings: number;
|
|
895
|
+
minSavings: number;
|
|
896
|
+
cacheEpochResetCost: number;
|
|
897
|
+
}): boolean {
|
|
898
|
+
if (!args.enabled) return false;
|
|
899
|
+
if (args.estimatedSavings < args.minSavings) return false;
|
|
900
|
+
return args.estimatedSavings > args.cacheEpochResetCost;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const MAX_ARTIFACT_REF_CHARS = 16_384;
|
|
904
|
+
|
|
905
|
+
export interface PruneToolOutputsOptions {
|
|
906
|
+
/** Lower the usual minimum only when the caller is already over its compaction threshold. */
|
|
907
|
+
relaxedMinimum?: number;
|
|
908
|
+
/** Conservative maximum ASCII length of every planned artifact reference. */
|
|
909
|
+
artifactRefMaxChars?: number;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
interface PlannedToolOutputPruneCandidate extends ToolOutputPruneCandidate {}
|
|
913
|
+
|
|
914
|
+
function artifactRefMaxChars(options: PruneToolOutputsOptions): number {
|
|
915
|
+
const maxChars = options.artifactRefMaxChars;
|
|
916
|
+
if (maxChars === undefined) return 0;
|
|
917
|
+
if (!Number.isSafeInteger(maxChars) || maxChars <= 0 || maxChars > MAX_ARTIFACT_REF_CHARS) {
|
|
918
|
+
throw new RangeError(`artifactRefMaxChars must be an integer between 1 and ${MAX_ARTIFACT_REF_CHARS}`);
|
|
919
|
+
}
|
|
920
|
+
return maxChars;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function planToolOutputPruneCandidates(
|
|
924
|
+
candidates: ToolOutputPruneCandidate[],
|
|
925
|
+
options: PruneToolOutputsOptions,
|
|
926
|
+
): PlannedToolOutputPruneCandidate[] {
|
|
927
|
+
const maxArtifactChars = artifactRefMaxChars(options);
|
|
928
|
+
const artifactBudget = maxArtifactChars > 0 ? "x".repeat(maxArtifactChars) : undefined;
|
|
929
|
+
return candidates.flatMap(candidate => {
|
|
930
|
+
const notice = createPrunedNotice(
|
|
931
|
+
candidate.tokens,
|
|
932
|
+
candidate.entry.message as ToolResultMessage,
|
|
933
|
+
candidate.call,
|
|
934
|
+
candidate.complete ? artifactBudget : undefined,
|
|
935
|
+
);
|
|
936
|
+
const savings = estimatePrunedSavings(candidate.tokens, notice);
|
|
937
|
+
const errorNoticeGrows =
|
|
938
|
+
(candidate.entry.message as ToolResultMessage).isError === true &&
|
|
939
|
+
notice.length > candidate.originalText.length;
|
|
940
|
+
return savings > 0 && !errorNoticeGrows ? [{ ...candidate, notice, savings }] : [];
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function emptyToolOutputPrunePlan(): ToolOutputPrunePlan {
|
|
945
|
+
return { prunedCount: 0, tokensSaved: 0, digests: [], replacements: [] };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Build a digest-only pruning plan. This function is deliberately read-only:
|
|
950
|
+
* candidates are inspected in private locals and the returned plan never keeps
|
|
951
|
+
* references to the source entries or their original output text.
|
|
952
|
+
*/
|
|
953
|
+
export function planToolOutputPrune(
|
|
954
|
+
entries: SessionEntry[],
|
|
955
|
+
config: PruneConfig = DEFAULT_PRUNE_CONFIG,
|
|
956
|
+
options: PruneToolOutputsOptions = {},
|
|
957
|
+
): ToolOutputPrunePlan {
|
|
958
|
+
const { candidates, tokensSaved: baseTokensSaved } = collectToolOutputPruneCandidates(entries, config);
|
|
959
|
+
const minimum = minimumSavings(config, options);
|
|
960
|
+
if (baseTokensSaved < minimum || candidates.length === 0) return emptyToolOutputPrunePlan();
|
|
961
|
+
|
|
962
|
+
const planned = planToolOutputPruneCandidates(candidates, options);
|
|
963
|
+
const tokensSaved = planned.reduce((total, candidate) => total + candidate.savings, 0);
|
|
964
|
+
if (tokensSaved < minimum || planned.length === 0) return emptyToolOutputPrunePlan();
|
|
965
|
+
|
|
966
|
+
const digests = planned.map(candidate => ({
|
|
967
|
+
entryId: candidate.entry.id,
|
|
968
|
+
bytes: Buffer.byteLength(candidate.originalText, "utf8"),
|
|
969
|
+
sha256: createHash("sha256").update(candidate.originalText, "utf8").digest("hex"),
|
|
970
|
+
}));
|
|
971
|
+
const replacements = planned.map(candidate => ({
|
|
972
|
+
entryId: candidate.entry.id,
|
|
973
|
+
replacementText: candidate.notice,
|
|
974
|
+
complete: candidate.complete,
|
|
975
|
+
tokens: candidate.tokens,
|
|
976
|
+
}));
|
|
977
|
+
return Object.freeze({
|
|
978
|
+
prunedCount: planned.length,
|
|
979
|
+
tokensSaved,
|
|
980
|
+
digests: Object.freeze(digests),
|
|
981
|
+
replacements: Object.freeze(replacements),
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Re-check and commit a digest-only plan against the supplied live entries.
|
|
987
|
+
* Each entry is mutated only after its canonical full-text digest matches.
|
|
988
|
+
*/
|
|
989
|
+
export function commitToolOutputPrune(
|
|
990
|
+
entries: SessionEntry[],
|
|
991
|
+
plan: ToolOutputPrunePlan,
|
|
992
|
+
options: ToolOutputPruneCommitOptions = {},
|
|
993
|
+
): ToolOutputCommitOutcome[] {
|
|
994
|
+
const byId = new Map(entries.filter((e): e is SessionMessageEntry => e.type === "message").map(e => [e.id, e]));
|
|
995
|
+
const proposals = new Map(plan.replacements.map(replacement => [replacement.entryId, replacement]));
|
|
996
|
+
return plan.digests.map(digest => {
|
|
997
|
+
const entry = byId.get(digest.entryId);
|
|
998
|
+
if (!entry) return { entryId: digest.entryId, outcome: "unavailable", diagnostic: "entry not found" };
|
|
999
|
+
const message = entry.message as ToolResultMessage;
|
|
1000
|
+
const captured = extractToolOutputText(message);
|
|
1001
|
+
const bytes = Buffer.byteLength(captured.text, "utf8");
|
|
1002
|
+
const sha = createHash("sha256").update(captured.text, "utf8").digest("hex");
|
|
1003
|
+
if (bytes !== digest.bytes || sha !== digest.sha256) {
|
|
1004
|
+
return { entryId: digest.entryId, outcome: "mismatch", diagnostic: "tool output changed before commit" };
|
|
1005
|
+
}
|
|
1006
|
+
const proposal = proposals.get(digest.entryId);
|
|
1007
|
+
if (!proposal)
|
|
1008
|
+
return { entryId: digest.entryId, outcome: "unavailable", diagnostic: "replacement proposal missing" };
|
|
1009
|
+
const override = options.replacements?.get(digest.entryId);
|
|
1010
|
+
const replacementText = override?.replacementText ?? proposal.replacementText;
|
|
1011
|
+
message.content = [{ type: "text", text: replacementText }];
|
|
1012
|
+
message.prunedAt = Date.now();
|
|
1013
|
+
if (override?.eviction) {
|
|
1014
|
+
const details =
|
|
1015
|
+
message.details && typeof message.details === "object" && !Array.isArray(message.details)
|
|
1016
|
+
? (message.details as Record<string, unknown>)
|
|
1017
|
+
: {};
|
|
1018
|
+
const meta =
|
|
1019
|
+
details.meta && typeof details.meta === "object" && !Array.isArray(details.meta)
|
|
1020
|
+
? (details.meta as Record<string, unknown>)
|
|
1021
|
+
: {};
|
|
1022
|
+
message.details = { ...details, meta: { ...meta, eviction: override.eviction } };
|
|
1023
|
+
}
|
|
1024
|
+
return { entryId: digest.entryId, outcome: "committed" };
|
|
1025
|
+
});
|
|
1026
|
+
}
|