@gajae-code/agent-core 0.4.4 → 0.4.5
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 +15 -0
- package/dist/types/compaction/compaction.d.ts +28 -4
- package/dist/types/compaction/pruning.d.ts +20 -1
- package/package.json +4 -4
- package/src/agent.ts +13 -9
- package/src/compaction/compaction.ts +84 -9
- package/src/compaction/pruning.ts +275 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.4.5] - 2026-06-12
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Made tool-output pruning staleness-aware: results superseded by a later same-target result (re-read file, re-run search) or invalidated by a later successful edit/write are pruned before merely-old ones, including inside the recency protect window. New optional `PruneConfig.staleOverridableTools` (default `["read"]`) waives protected-tool immunity for superseded results while the most recent result per target stays protected. Target identity uses collision-proof canonical JSON tuple keys.
|
|
10
|
+
- `PruneResult` now returns `prunedEntries` so callers whose entry source materializes copies (e.g. blob-externalized session entries) can write mutations back into their canonical store.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Preserved Cursor-native tool call rendering and execution through the agent tool-call path, including runtime tool details.
|
|
15
|
+
|
|
16
|
+
## [0.4.4] - 2026-06-10
|
|
17
|
+
|
|
18
|
+
- Version aligned with the 0.4.4 monorepo release; no functional changes in this package.
|
|
19
|
+
|
|
5
20
|
## [0.4.3] - 2026-06-10
|
|
6
21
|
|
|
7
22
|
### Fixed
|
|
@@ -68,11 +68,35 @@ export declare function effectiveReserveTokens(contextWindow: number, settings:
|
|
|
68
68
|
export declare function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings, maxOutputTokens?: number): boolean;
|
|
69
69
|
export declare function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings, maxOutputTokens?: number): number;
|
|
70
70
|
/**
|
|
71
|
-
* Estimate token count for a message using
|
|
72
|
-
*
|
|
73
|
-
* publish
|
|
71
|
+
* Estimate token count for a message using the native o200k tokenizer.
|
|
72
|
+
* Exact for o200k only; an approximation for Anthropic/other model families
|
|
73
|
+
* (Anthropic doesn't publish a tokenizer) within ~5–10% on English/code text.
|
|
74
|
+
*
|
|
75
|
+
* This materializes the native BPE table (~50MB RSS) on first call. Use it
|
|
76
|
+
* only for context-changing decisions (compaction trigger/cut points, pruning
|
|
77
|
+
* budgets, branch summarization, fork-context seeding, context-limit
|
|
78
|
+
* enforcement). For display-only totals use
|
|
79
|
+
* {@link estimateMessageTokensHeuristic}.
|
|
80
|
+
*/
|
|
81
|
+
export declare function countMessageTokensNativeO200k(message: AgentMessage): number;
|
|
82
|
+
/**
|
|
83
|
+
* Backwards-compatible alias for {@link countMessageTokensNativeO200k}.
|
|
84
|
+
* Existing callers treat this as the canonical message-token estimator for
|
|
85
|
+
* context-changing decisions.
|
|
86
|
+
*/
|
|
87
|
+
export declare const estimateTokens: typeof countMessageTokensNativeO200k;
|
|
88
|
+
/**
|
|
89
|
+
* Cheap, native-free token estimate for a message. Suitable ONLY for
|
|
90
|
+
* display/init surfaces (status line, /context report, HUD totals) — never
|
|
91
|
+
* for context-changing decisions, which must use
|
|
92
|
+
* {@link countMessageTokensNativeO200k}.
|
|
93
|
+
*/
|
|
94
|
+
export declare function estimateMessageTokensHeuristic(message: AgentMessage): number;
|
|
95
|
+
/**
|
|
96
|
+
* Cheap, native-free token estimate for plain string fragments. Display-only
|
|
97
|
+
* counterpart of the native `countTokens(fragments)` aggregate.
|
|
74
98
|
*/
|
|
75
|
-
export declare function
|
|
99
|
+
export declare function estimateTextTokensHeuristic(fragments: string | readonly string[]): number;
|
|
76
100
|
/**
|
|
77
101
|
* Find the user message (or bashExecution) that starts the turn containing the given entry index.
|
|
78
102
|
* Returns -1 if no turn start found before the index.
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
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.
|
|
3
9
|
*/
|
|
4
|
-
import type { SessionEntry } from "./entries";
|
|
10
|
+
import type { SessionEntry, SessionMessageEntry } from "./entries";
|
|
5
11
|
export interface PruneConfig {
|
|
6
12
|
/** Keep the most recent tool output tokens intact. */
|
|
7
13
|
protectTokens: number;
|
|
@@ -9,10 +15,23 @@ export interface PruneConfig {
|
|
|
9
15
|
minimumSavings: number;
|
|
10
16
|
/** Tool names that should never be pruned. */
|
|
11
17
|
protectedTools: string[];
|
|
18
|
+
/**
|
|
19
|
+
* Tools in `protectedTools` whose protection is waived once the result is
|
|
20
|
+
* superseded (a later result for the same target, or a later successful
|
|
21
|
+
* edit/write to the covered file). The most recent result per target is
|
|
22
|
+
* never considered superseded. Optional; defaults to none.
|
|
23
|
+
*/
|
|
24
|
+
staleOverridableTools?: string[];
|
|
12
25
|
}
|
|
13
26
|
export declare const DEFAULT_PRUNE_CONFIG: PruneConfig;
|
|
14
27
|
export interface PruneResult {
|
|
15
28
|
prunedCount: number;
|
|
16
29
|
tokensSaved: number;
|
|
30
|
+
/**
|
|
31
|
+
* The mutated message entries. Callers whose entry source returns
|
|
32
|
+
* materialized copies (not live references) must write these back into
|
|
33
|
+
* their canonical store by id.
|
|
34
|
+
*/
|
|
35
|
+
prunedEntries: SessionMessageEntry[];
|
|
17
36
|
}
|
|
18
37
|
export declare function pruneToolOutputs(entries: SessionEntry[], config?: PruneConfig): PruneResult;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/agent-core",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.5",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://gaebal-gajae.dev",
|
|
7
7
|
"author": "Yeachan-Heo",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@gajae-code/ai": "0.4.
|
|
39
|
-
"@gajae-code/natives": "0.4.
|
|
40
|
-
"@gajae-code/utils": "0.4.
|
|
38
|
+
"@gajae-code/ai": "0.4.5",
|
|
39
|
+
"@gajae-code/natives": "0.4.5",
|
|
40
|
+
"@gajae-code/utils": "0.4.5",
|
|
41
41
|
"@opentelemetry/api": "^1.9.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
package/src/agent.ts
CHANGED
|
@@ -680,7 +680,11 @@ export class Agent {
|
|
|
680
680
|
if (!source) return undefined;
|
|
681
681
|
|
|
682
682
|
const guarded: CursorExecHandlers = {};
|
|
683
|
-
|
|
683
|
+
// Bind each handler to `source`: they are methods of a CursorExecHandlers
|
|
684
|
+
// instance that reference private fields via `this`. Extracting them bare
|
|
685
|
+
// (`const read = source.read`) and calling `read(args)` would invoke them with
|
|
686
|
+
// `this === undefined`, throwing "undefined is not an object (this.#optionsForCall)".
|
|
687
|
+
const read = source.read?.bind(source);
|
|
684
688
|
if (read) {
|
|
685
689
|
guarded.read = async args => {
|
|
686
690
|
this.#assertActiveRun(runId);
|
|
@@ -689,7 +693,7 @@ export class Agent {
|
|
|
689
693
|
return result;
|
|
690
694
|
};
|
|
691
695
|
}
|
|
692
|
-
const ls = source.ls;
|
|
696
|
+
const ls = source.ls?.bind(source);
|
|
693
697
|
if (ls) {
|
|
694
698
|
guarded.ls = async args => {
|
|
695
699
|
this.#assertActiveRun(runId);
|
|
@@ -698,7 +702,7 @@ export class Agent {
|
|
|
698
702
|
return result;
|
|
699
703
|
};
|
|
700
704
|
}
|
|
701
|
-
const grep = source.grep;
|
|
705
|
+
const grep = source.grep?.bind(source);
|
|
702
706
|
if (grep) {
|
|
703
707
|
guarded.grep = async args => {
|
|
704
708
|
this.#assertActiveRun(runId);
|
|
@@ -707,7 +711,7 @@ export class Agent {
|
|
|
707
711
|
return result;
|
|
708
712
|
};
|
|
709
713
|
}
|
|
710
|
-
const write = source.write;
|
|
714
|
+
const write = source.write?.bind(source);
|
|
711
715
|
if (write) {
|
|
712
716
|
guarded.write = async args => {
|
|
713
717
|
this.#assertActiveRun(runId);
|
|
@@ -716,7 +720,7 @@ export class Agent {
|
|
|
716
720
|
return result;
|
|
717
721
|
};
|
|
718
722
|
}
|
|
719
|
-
const deleteHandler = source.delete;
|
|
723
|
+
const deleteHandler = source.delete?.bind(source);
|
|
720
724
|
if (deleteHandler) {
|
|
721
725
|
guarded.delete = async args => {
|
|
722
726
|
this.#assertActiveRun(runId);
|
|
@@ -725,7 +729,7 @@ export class Agent {
|
|
|
725
729
|
return result;
|
|
726
730
|
};
|
|
727
731
|
}
|
|
728
|
-
const shell = source.shell;
|
|
732
|
+
const shell = source.shell?.bind(source);
|
|
729
733
|
if (shell) {
|
|
730
734
|
guarded.shell = async args => {
|
|
731
735
|
this.#assertActiveRun(runId);
|
|
@@ -734,7 +738,7 @@ export class Agent {
|
|
|
734
738
|
return result;
|
|
735
739
|
};
|
|
736
740
|
}
|
|
737
|
-
const shellStream = source.shellStream;
|
|
741
|
+
const shellStream = source.shellStream?.bind(source);
|
|
738
742
|
if (shellStream) {
|
|
739
743
|
guarded.shellStream = async (args, callbacks) => {
|
|
740
744
|
this.#assertActiveRun(runId);
|
|
@@ -743,7 +747,7 @@ export class Agent {
|
|
|
743
747
|
return result;
|
|
744
748
|
};
|
|
745
749
|
}
|
|
746
|
-
const diagnostics = source.diagnostics;
|
|
750
|
+
const diagnostics = source.diagnostics?.bind(source);
|
|
747
751
|
if (diagnostics) {
|
|
748
752
|
guarded.diagnostics = async args => {
|
|
749
753
|
this.#assertActiveRun(runId);
|
|
@@ -752,7 +756,7 @@ export class Agent {
|
|
|
752
756
|
return result;
|
|
753
757
|
};
|
|
754
758
|
}
|
|
755
|
-
const mcp = source.mcp;
|
|
759
|
+
const mcp = source.mcp?.bind(source);
|
|
756
760
|
if (mcp) {
|
|
757
761
|
guarded.mcp = async call => {
|
|
758
762
|
this.#assertActiveRun(runId);
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
type Model,
|
|
14
14
|
type Usage,
|
|
15
15
|
} from "@gajae-code/ai";
|
|
16
|
-
import { countTokens } from "@gajae-code/natives";
|
|
17
16
|
import { logger, prompt } from "@gajae-code/utils";
|
|
18
17
|
import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
|
|
19
18
|
import type { AgentMessage, AgentTool } from "../types";
|
|
@@ -268,18 +267,95 @@ export function resolveThresholdTokens(
|
|
|
268
267
|
const IMAGE_TOKEN_ESTIMATE = 1200;
|
|
269
268
|
|
|
270
269
|
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
270
|
+
* Lazily-required native `countTokens`. `@gajae-code/natives` dlopens a ~39MB
|
|
271
|
+
* addon; importing it at module scope would put that cost on every cold path
|
|
272
|
+
* that touches compaction exports (status line, print mode, context report).
|
|
273
|
+
* Deferring the require to the first context-changing call keeps the trivial
|
|
274
|
+
* `-p` / display paths native-free.
|
|
274
275
|
*/
|
|
275
|
-
|
|
276
|
+
let cachedNativeCountTokens: ((input: string | string[], encoding?: unknown) => number) | null = null;
|
|
277
|
+
|
|
278
|
+
function nativeCountTokens(fragments: string[]): number {
|
|
279
|
+
if (!cachedNativeCountTokens) {
|
|
280
|
+
const { createRequire } = require("node:module") as typeof import("node:module");
|
|
281
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
282
|
+
const natives = requireFromHere("@gajae-code/natives") as {
|
|
283
|
+
countTokens: (input: string | string[], encoding?: unknown) => number;
|
|
284
|
+
};
|
|
285
|
+
cachedNativeCountTokens = natives.countTokens;
|
|
286
|
+
}
|
|
287
|
+
return cachedNativeCountTokens(fragments);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Estimate token count for a message using the native o200k tokenizer.
|
|
292
|
+
* Exact for o200k only; an approximation for Anthropic/other model families
|
|
293
|
+
* (Anthropic doesn't publish a tokenizer) within ~5–10% on English/code text.
|
|
294
|
+
*
|
|
295
|
+
* This materializes the native BPE table (~50MB RSS) on first call. Use it
|
|
296
|
+
* only for context-changing decisions (compaction trigger/cut points, pruning
|
|
297
|
+
* budgets, branch summarization, fork-context seeding, context-limit
|
|
298
|
+
* enforcement). For display-only totals use
|
|
299
|
+
* {@link estimateMessageTokensHeuristic}.
|
|
300
|
+
*/
|
|
301
|
+
export function countMessageTokensNativeO200k(message: AgentMessage): number {
|
|
302
|
+
const { fragments, extra } = collectMessageFragments(message);
|
|
303
|
+
if (fragments.length === 0) return extra;
|
|
304
|
+
return extra + nativeCountTokens(fragments);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Backwards-compatible alias for {@link countMessageTokensNativeO200k}.
|
|
309
|
+
* Existing callers treat this as the canonical message-token estimator for
|
|
310
|
+
* context-changing decisions.
|
|
311
|
+
*/
|
|
312
|
+
export const estimateTokens = countMessageTokensNativeO200k;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Average bytes per token for the cheap heuristic. ~4 bytes/token is the
|
|
316
|
+
* conventional approximation for English/code text under modern BPE
|
|
317
|
+
* vocabularies; it intentionally errs slightly low-precision in exchange for
|
|
318
|
+
* never touching the native tokenizer (and its ~50MB BPE table).
|
|
319
|
+
*/
|
|
320
|
+
const HEURISTIC_BYTES_PER_TOKEN = 4;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Cheap, native-free token estimate for a message. Suitable ONLY for
|
|
324
|
+
* display/init surfaces (status line, /context report, HUD totals) — never
|
|
325
|
+
* for context-changing decisions, which must use
|
|
326
|
+
* {@link countMessageTokensNativeO200k}.
|
|
327
|
+
*/
|
|
328
|
+
export function estimateMessageTokensHeuristic(message: AgentMessage): number {
|
|
329
|
+
const { fragments, extra } = collectMessageFragments(message);
|
|
330
|
+
let bytes = 0;
|
|
331
|
+
for (const fragment of fragments) {
|
|
332
|
+
bytes += fragment.length;
|
|
333
|
+
}
|
|
334
|
+
return extra + Math.ceil(bytes / HEURISTIC_BYTES_PER_TOKEN);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Cheap, native-free token estimate for plain string fragments. Display-only
|
|
339
|
+
* counterpart of the native `countTokens(fragments)` aggregate.
|
|
340
|
+
*/
|
|
341
|
+
export function estimateTextTokensHeuristic(fragments: string | readonly string[]): number {
|
|
342
|
+
if (typeof fragments === "string") return Math.ceil(fragments.length / HEURISTIC_BYTES_PER_TOKEN);
|
|
343
|
+
let bytes = 0;
|
|
344
|
+
for (const fragment of fragments) {
|
|
345
|
+
bytes += fragment.length;
|
|
346
|
+
}
|
|
347
|
+
return Math.ceil(bytes / HEURISTIC_BYTES_PER_TOKEN);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Shared content walk for both the native and heuristic estimators. */
|
|
351
|
+
function collectMessageFragments(message: AgentMessage): { fragments: string[]; extra: number } {
|
|
276
352
|
const fragments: string[] = [];
|
|
277
353
|
let extra = 0;
|
|
278
354
|
if ((message as { role?: string }).role === "bashExecution") {
|
|
279
355
|
const bash = message as { command?: unknown; output?: unknown };
|
|
280
356
|
if (typeof bash.command === "string") fragments.push(bash.command);
|
|
281
357
|
if (typeof bash.output === "string") fragments.push(bash.output);
|
|
282
|
-
return fragments
|
|
358
|
+
return { fragments, extra };
|
|
283
359
|
}
|
|
284
360
|
|
|
285
361
|
switch (message.role) {
|
|
@@ -331,11 +407,10 @@ export function estimateTokens(message: AgentMessage): number {
|
|
|
331
407
|
break;
|
|
332
408
|
}
|
|
333
409
|
default:
|
|
334
|
-
|
|
410
|
+
break;
|
|
335
411
|
}
|
|
336
412
|
|
|
337
|
-
|
|
338
|
-
return extra + countTokens(fragments);
|
|
413
|
+
return { fragments, extra };
|
|
339
414
|
}
|
|
340
415
|
|
|
341
416
|
function estimateEntriesTokens(entries: SessionEntry[], startIndex: number, endIndex: number): number {
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
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.
|
|
3
9
|
*/
|
|
4
10
|
|
|
5
|
-
import type { ToolResultMessage } from "@gajae-code/ai";
|
|
11
|
+
import type { ToolCall, ToolResultMessage } from "@gajae-code/ai";
|
|
6
12
|
import type { AgentMessage } from "../types";
|
|
7
13
|
import { estimateTokens } from "./compaction";
|
|
8
14
|
import type { SessionEntry, SessionMessageEntry } from "./entries";
|
|
@@ -14,17 +20,31 @@ export interface PruneConfig {
|
|
|
14
20
|
minimumSavings: number;
|
|
15
21
|
/** Tool names that should never be pruned. */
|
|
16
22
|
protectedTools: string[];
|
|
23
|
+
/**
|
|
24
|
+
* Tools in `protectedTools` whose protection is waived once the result is
|
|
25
|
+
* superseded (a later result for the same target, or a later successful
|
|
26
|
+
* edit/write to the covered file). The most recent result per target is
|
|
27
|
+
* never considered superseded. Optional; defaults to none.
|
|
28
|
+
*/
|
|
29
|
+
staleOverridableTools?: string[];
|
|
17
30
|
}
|
|
18
31
|
|
|
19
32
|
export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
|
|
20
33
|
protectTokens: 40_000,
|
|
21
34
|
minimumSavings: 20_000,
|
|
22
35
|
protectedTools: ["skill", "read"],
|
|
36
|
+
staleOverridableTools: ["read"],
|
|
23
37
|
};
|
|
24
38
|
|
|
25
39
|
export interface PruneResult {
|
|
26
40
|
prunedCount: number;
|
|
27
41
|
tokensSaved: number;
|
|
42
|
+
/**
|
|
43
|
+
* The mutated message entries. Callers whose entry source returns
|
|
44
|
+
* materialized copies (not live references) must write these back into
|
|
45
|
+
* their canonical store by id.
|
|
46
|
+
*/
|
|
47
|
+
prunedEntries: SessionMessageEntry[];
|
|
28
48
|
}
|
|
29
49
|
|
|
30
50
|
function createPrunedNotice(tokens: number): string {
|
|
@@ -43,11 +63,249 @@ function estimatePrunedSavings(tokens: number): number {
|
|
|
43
63
|
return Math.max(0, tokens - noticeTokens);
|
|
44
64
|
}
|
|
45
65
|
|
|
66
|
+
const EDIT_TOOL_NAMES = new Set(["edit", "write", "apply_patch", "ast_edit"]);
|
|
67
|
+
|
|
68
|
+
/** Extract the file-path argument from a tool call, when the tool has one. */
|
|
69
|
+
function toolCallPath(call: ToolCall): string | undefined {
|
|
70
|
+
const args = call.arguments;
|
|
71
|
+
const path = args.path ?? args.file_path ?? args.filePath;
|
|
72
|
+
return typeof path === "string" && path.length > 0 ? path : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `*** Add|Update|Delete File: <path>` headers open a hunk; `*** Move to:
|
|
77
|
+
* <path>` attaches a rename destination to the current hunk. Move
|
|
78
|
+
* destinations count as touched paths: a rename onto a file invalidates
|
|
79
|
+
* earlier reads of that destination.
|
|
80
|
+
*/
|
|
81
|
+
const APPLY_PATCH_HEADER = /^\*\*\* (?:((?:Add|Update|Delete) File)|(Move to)): (.+)$/gm;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Paths touched by an edit-class tool call, grouped per hunk so a failed
|
|
85
|
+
* hunk can be excluded wholesale (its rename destination included). Most
|
|
86
|
+
* edit tools carry a single path argument; apply_patch envelopes carry an
|
|
87
|
+
* `input` string with per-file headers instead. The envelope shape can
|
|
88
|
+
* arrive under the custom `apply_patch` tool OR the regular `edit` tool
|
|
89
|
+
* (providers without custom-tool support fall back to the JSON function), so
|
|
90
|
+
* any edit-class call with a string `input` is parsed for headers.
|
|
91
|
+
*/
|
|
92
|
+
function editToolPathGroups(call: ToolCall): string[][] {
|
|
93
|
+
const path = toolCallPath(call);
|
|
94
|
+
if (path !== undefined) return [[path]];
|
|
95
|
+
const input = call.arguments.input;
|
|
96
|
+
if (typeof input !== "string") return [];
|
|
97
|
+
const groups: string[][] = [];
|
|
98
|
+
for (const match of input.matchAll(APPLY_PATCH_HEADER)) {
|
|
99
|
+
const headerPath = match[3]?.trim();
|
|
100
|
+
if (!headerPath) continue;
|
|
101
|
+
const isMoveTo = match[2] !== undefined;
|
|
102
|
+
if (isMoveTo && groups.length > 0) {
|
|
103
|
+
groups[groups.length - 1].push(headerPath);
|
|
104
|
+
} else {
|
|
105
|
+
groups.push([headerPath]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return groups;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Trailing read selectors (`:50`, `:50-200`, `:50+150`, `:5-16,960-973`,
|
|
113
|
+
* `:raw`, `:conflicts`), possibly stacked (`:2-4:raw`). Stripped to resolve
|
|
114
|
+
* the underlying file for edit invalidation.
|
|
115
|
+
*/
|
|
116
|
+
const READ_SELECTOR_SUFFIX = /:(?:raw|conflicts|\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*)$/;
|
|
117
|
+
|
|
118
|
+
/** Base file path of a read target with any line/mode selectors stripped. */
|
|
119
|
+
function readBasePath(path: string): string {
|
|
120
|
+
let base = path;
|
|
121
|
+
while (READ_SELECTOR_SUFFIX.test(base)) {
|
|
122
|
+
base = base.replace(READ_SELECTOR_SUFFIX, "");
|
|
123
|
+
}
|
|
124
|
+
return base;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Stable identity for "the same logical lookup": same tool re-targeting the
|
|
129
|
+
* same subject. A later result with the same key supersedes earlier ones.
|
|
130
|
+
* Keys are canonical JSON tuples so user-controlled text (patterns, paths)
|
|
131
|
+
* can never collide via delimiter ambiguity. Search keys include pagination
|
|
132
|
+
* (`skip`) and result-shaping flags (`i`, `gitignore`): a later page or a
|
|
133
|
+
* differently-shaped search complements earlier output, it does not replace it.
|
|
134
|
+
*/
|
|
135
|
+
function toolTargetKey(call: ToolCall): string | undefined {
|
|
136
|
+
const path = toolCallPath(call);
|
|
137
|
+
if (path !== undefined) return JSON.stringify([call.name, "path", path]);
|
|
138
|
+
const pattern = call.arguments.pattern;
|
|
139
|
+
if (typeof pattern === "string" && pattern.length > 0) {
|
|
140
|
+
const paths = call.arguments.paths;
|
|
141
|
+
const pathList = Array.isArray(paths) ? paths.filter((p): p is string => typeof p === "string") : [];
|
|
142
|
+
const skip = typeof call.arguments.skip === "number" ? call.arguments.skip : 0;
|
|
143
|
+
const caseInsensitive = call.arguments.i === true;
|
|
144
|
+
const gitignore = call.arguments.gitignore !== false;
|
|
145
|
+
return JSON.stringify([call.name, "pattern", pattern, pathList, skip, caseInsensitive, gitignore]);
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Files actually mutated according to a tool result's details. Used for
|
|
152
|
+
* AST-edit-shaped results (`ast_edit` direct-apply and the hidden `resolve`
|
|
153
|
+
* apply step), which report `{ applied: true, files: [...] }` — the resolve
|
|
154
|
+
* tool nests that payload under `details.sourceResultDetails`. Conservative:
|
|
155
|
+
* returns nothing unless the details explicitly mark the change as applied.
|
|
156
|
+
* Checked even on `isError` results: a stale-preview apply reports an error
|
|
157
|
+
* while still having mutated the listed files.
|
|
158
|
+
*/
|
|
159
|
+
function resultDetailFiles(message: ToolResultMessage): string[] {
|
|
160
|
+
const raw = message.details as { applied?: unknown; files?: unknown; sourceResultDetails?: unknown } | undefined;
|
|
161
|
+
const candidates = [raw, raw?.sourceResultDetails as { applied?: unknown; files?: unknown } | undefined];
|
|
162
|
+
for (const details of candidates) {
|
|
163
|
+
if (details?.applied === true && Array.isArray(details.files)) {
|
|
164
|
+
return details.files.filter((file): file is string => typeof file === "string" && file.length > 0);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Paths that FAILED in a per-file edit result (`details.perFileResults`) and
|
|
172
|
+
* were NOT mutated by any same-path entry. Multi-file apply_patch catches
|
|
173
|
+
* per-file failures and still returns a non-error result; a purely-failed
|
|
174
|
+
* path was not mutated and must not stale reads. But apply_patch can emit
|
|
175
|
+
* multiple entries for the same path (e.g. several hunks): if any same-path
|
|
176
|
+
* entry succeeded the file still mutated, so it must NOT be suppressed.
|
|
177
|
+
* Conservative: only an entry explicitly marked `isError === true` counts as
|
|
178
|
+
* a failure; anything else (including ambiguous/malformed entries) counts as
|
|
179
|
+
* a success and keeps the path out of the suppression set.
|
|
180
|
+
*/
|
|
181
|
+
function failedEditPaths(message: ToolResultMessage): Set<string> {
|
|
182
|
+
const details = message.details as { perFileResults?: unknown } | undefined;
|
|
183
|
+
const perFile = details?.perFileResults;
|
|
184
|
+
if (!Array.isArray(perFile)) return new Set();
|
|
185
|
+
const failed = new Set<string>();
|
|
186
|
+
const succeeded = new Set<string>();
|
|
187
|
+
for (const item of perFile) {
|
|
188
|
+
const entry = item as { path?: unknown; isError?: unknown };
|
|
189
|
+
if (typeof entry?.path !== "string") continue;
|
|
190
|
+
if (entry.isError === true) failed.add(entry.path);
|
|
191
|
+
else succeeded.add(entry.path);
|
|
192
|
+
}
|
|
193
|
+
// A path mutated if any same-path entry succeeded, even when another
|
|
194
|
+
// same-path entry failed; drop those from the suppression set.
|
|
195
|
+
for (const path of succeeded) failed.delete(path);
|
|
196
|
+
return failed;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Concrete file path a `read` result actually came from, when the tool
|
|
201
|
+
* reported one (`details.resolvedPath`). Suffix resolution can map a bare
|
|
202
|
+
* filename argument onto a different concrete path.
|
|
203
|
+
*/
|
|
204
|
+
function readResolvedPath(message: ToolResultMessage): string | undefined {
|
|
205
|
+
const details = message.details as { resolvedPath?: unknown } | undefined;
|
|
206
|
+
const resolved = details?.resolvedPath;
|
|
207
|
+
return typeof resolved === "string" && resolved.length > 0 ? resolved : undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface StalenessIndex {
|
|
211
|
+
/** Entry indices of toolResults superseded by a later same-target result or a later edit. */
|
|
212
|
+
staleResultIndices: Set<number>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Build a staleness index over session entries (oldest -> newest):
|
|
217
|
+
* - a toolResult is stale when a later non-error toolResult shares its target key;
|
|
218
|
+
* - a `read` result is stale when a later non-error edit/write touches its file.
|
|
219
|
+
* The most recent result per target is never stale.
|
|
220
|
+
*/
|
|
221
|
+
function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
|
|
222
|
+
const callsById = new Map<string, ToolCall>();
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
if (entry.type !== "message") continue;
|
|
225
|
+
const message = entry.message as AgentMessage;
|
|
226
|
+
if (message.role !== "assistant") continue;
|
|
227
|
+
for (const content of message.content) {
|
|
228
|
+
if (content.type === "toolCall") callsById.set(content.id, content);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const lastResultIndexByKey = new Map<string, number>();
|
|
233
|
+
const resultMeta = new Map<number, { key?: string; call: ToolCall; message: ToolResultMessage }>();
|
|
234
|
+
const lastEditIndexByPath = new Map<string, number>();
|
|
235
|
+
|
|
236
|
+
for (let i = 0; i < entries.length; i++) {
|
|
237
|
+
const message = getToolResultMessage(entries[i]);
|
|
238
|
+
if (!message) continue;
|
|
239
|
+
const call = callsById.get(message.toolCallId);
|
|
240
|
+
if (!call) continue;
|
|
241
|
+
|
|
242
|
+
// AST edits mutate files when previews are applied via the hidden
|
|
243
|
+
// `resolve` tool; the call args carry globs, not concrete paths. Both
|
|
244
|
+
// tools report actually-touched files in result details. Collected
|
|
245
|
+
// BEFORE the error gate: a stale-preview apply reports an error while
|
|
246
|
+
// still having mutated the listed files.
|
|
247
|
+
if (call.name === "resolve" || call.name === "ast_edit") {
|
|
248
|
+
for (const editPath of resultDetailFiles(message)) {
|
|
249
|
+
lastEditIndexByPath.set(editPath, i);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (message.isError) continue;
|
|
253
|
+
|
|
254
|
+
const key = toolTargetKey(call);
|
|
255
|
+
resultMeta.set(i, { key, call, message });
|
|
256
|
+
if (key !== undefined) lastResultIndexByKey.set(key, i);
|
|
257
|
+
if (EDIT_TOOL_NAMES.has(call.name)) {
|
|
258
|
+
// Per-file edit results record failures in details.perFileResults;
|
|
259
|
+
// a failed hunk mutated nothing, so exclude its whole path group
|
|
260
|
+
// (rename destination included) from touched paths.
|
|
261
|
+
const failed = failedEditPaths(message);
|
|
262
|
+
for (const group of editToolPathGroups(call)) {
|
|
263
|
+
if (group.some(groupPath => failed.has(groupPath))) continue;
|
|
264
|
+
for (const editPath of group) {
|
|
265
|
+
lastEditIndexByPath.set(editPath, i);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const staleResultIndices = new Set<number>();
|
|
272
|
+
for (const [index, meta] of resultMeta) {
|
|
273
|
+
if (meta.key !== undefined) {
|
|
274
|
+
const lastIndex = lastResultIndexByKey.get(meta.key);
|
|
275
|
+
if (lastIndex !== undefined && lastIndex > index) {
|
|
276
|
+
staleResultIndices.add(index);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (meta.call.name === "read") {
|
|
281
|
+
// Check both the call argument (selectors stripped) and the resolved
|
|
282
|
+
// path from result details: suffix resolution can map a bare filename
|
|
283
|
+
// onto a different concrete path, and edits may use either form.
|
|
284
|
+
const lookupPaths = new Set<string>();
|
|
285
|
+
const argPath = toolCallPath(meta.call);
|
|
286
|
+
if (argPath !== undefined) lookupPaths.add(readBasePath(argPath));
|
|
287
|
+
const resolved = readResolvedPath(meta.message);
|
|
288
|
+
if (resolved !== undefined) lookupPaths.add(resolved);
|
|
289
|
+
for (const lookupPath of lookupPaths) {
|
|
290
|
+
const editIndex = lastEditIndexByPath.get(lookupPath);
|
|
291
|
+
if (editIndex !== undefined && editIndex > index) {
|
|
292
|
+
staleResultIndices.add(index);
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { staleResultIndices };
|
|
300
|
+
}
|
|
301
|
+
|
|
46
302
|
export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
|
|
47
303
|
let accumulatedTokens = 0;
|
|
48
304
|
let tokensSaved = 0;
|
|
49
305
|
let prunedCount = 0;
|
|
50
306
|
|
|
307
|
+
const { staleResultIndices } = buildStalenessIndex(entries);
|
|
308
|
+
const staleOverridable = new Set(config.staleOverridableTools ?? []);
|
|
51
309
|
const candidates: Array<{ entry: SessionMessageEntry; tokens: number }> = [];
|
|
52
310
|
|
|
53
311
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
@@ -56,14 +314,24 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
|
|
|
56
314
|
if (!message) continue;
|
|
57
315
|
|
|
58
316
|
const tokens = estimateTokens(message as AgentMessage);
|
|
59
|
-
const
|
|
317
|
+
const isStale = staleResultIndices.has(i);
|
|
318
|
+
// Staleness waives protected-tool immunity for overridable tools
|
|
319
|
+
// (e.g. a superseded `read`); the most recent result per target is
|
|
320
|
+
// never stale, so the latest read of each file stays protected.
|
|
321
|
+
const isProtected =
|
|
322
|
+
config.protectedTools.includes(message.toolName) && !(isStale && staleOverridable.has(message.toolName));
|
|
60
323
|
|
|
61
324
|
if (message.prunedAt !== undefined) {
|
|
62
325
|
accumulatedTokens += tokens;
|
|
63
326
|
continue;
|
|
64
327
|
}
|
|
65
328
|
|
|
66
|
-
|
|
329
|
+
// Stale results are prunable even inside the recency protect window —
|
|
330
|
+
// they are superseded, so recency no longer implies relevance. They
|
|
331
|
+
// still count toward window accounting so non-stale protection is
|
|
332
|
+
// unchanged.
|
|
333
|
+
const insideProtectWindow = accumulatedTokens < config.protectTokens;
|
|
334
|
+
if ((insideProtectWindow && !isStale) || isProtected) {
|
|
67
335
|
accumulatedTokens += tokens;
|
|
68
336
|
continue;
|
|
69
337
|
}
|
|
@@ -77,16 +345,18 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
|
|
|
77
345
|
}
|
|
78
346
|
|
|
79
347
|
if (tokensSaved < config.minimumSavings || candidates.length === 0) {
|
|
80
|
-
return { prunedCount: 0, tokensSaved: 0 };
|
|
348
|
+
return { prunedCount: 0, tokensSaved: 0, prunedEntries: [] };
|
|
81
349
|
}
|
|
82
350
|
|
|
83
351
|
const prunedAt = Date.now();
|
|
352
|
+
const prunedEntries: SessionMessageEntry[] = [];
|
|
84
353
|
for (const candidate of candidates) {
|
|
85
354
|
const message = candidate.entry.message as ToolResultMessage;
|
|
86
355
|
message.content = [{ type: "text", text: createPrunedNotice(candidate.tokens) }];
|
|
87
356
|
message.prunedAt = prunedAt;
|
|
357
|
+
prunedEntries.push(candidate.entry);
|
|
88
358
|
prunedCount++;
|
|
89
359
|
}
|
|
90
360
|
|
|
91
|
-
return { prunedCount, tokensSaved };
|
|
361
|
+
return { prunedCount, tokensSaved, prunedEntries };
|
|
92
362
|
}
|