@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.
- package/CHANGELOG.md +10 -0
- package/dist/types/agent.d.ts +8 -1
- package/dist/types/compaction/openai.d.ts +2 -0
- package/dist/types/compaction/pruning.d.ts +29 -3
- package/dist/types/index.d.ts +1 -0
- package/dist/types/run-resource-ledger.d.ts +2 -0
- package/dist/types/types.d.ts +34 -0
- package/package.json +4 -4
- package/src/agent-loop.ts +128 -30
- package/src/agent.ts +40 -3
- package/src/compaction/openai.ts +8 -2
- package/src/compaction/pruning.ts +325 -131
- package/src/index.ts +1 -0
- package/src/run-resource-ledger.ts +209 -0
- package/src/types.ts +30 -0
|
@@ -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
|
|
52
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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 =
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
297
|
+
const successfulPaths = successfulEditPaths(message);
|
|
262
298
|
let mutated = false;
|
|
263
299
|
for (const group of groups) {
|
|
264
|
-
if (group.some(groupPath =>
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
338
|
+
/** Base file path of a read target with its one valid selector stripped. */
|
|
281
339
|
function readBasePath(path: string): string {
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
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
|
|
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
|
|
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 ===
|
|
417
|
-
|
|
453
|
+
if (entry.isError === false || (entry.isError === undefined && typeof entry.diff === "string")) {
|
|
454
|
+
succeeded.add(entry.path);
|
|
455
|
+
}
|
|
418
456
|
}
|
|
419
|
-
|
|
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
|
|
486
|
-
//
|
|
487
|
-
|
|
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 =>
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
647
|
+
const group = candidatesByEntry.get(candidate.entry);
|
|
648
|
+
if (group) group.push(candidate);
|
|
649
|
+
else candidatesByEntry.set(candidate.entry, [candidate]);
|
|
615
650
|
}
|
|
616
|
-
|
|
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
|
-
|
|
689
|
+
let argumentPrunedCount = 0;
|
|
621
690
|
const prunedEntries: SessionMessageEntry[] = [];
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
|
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
|
|
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 >
|
|
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
|
|
723
|
-
*
|
|
724
|
-
*
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
976
|
+
originals.push(candidate.original);
|
|
783
977
|
}
|
|
784
978
|
|
|
785
|
-
return { prunedCount, tokensSaved, prunedEntries };
|
|
979
|
+
return { prunedCount: candidatesWithArtifacts.length, tokensSaved, originals, prunedEntries };
|
|
786
980
|
}
|