@linxiraos/pi-agent-core 1.1.26 → 1.2.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 +6 -243
- package/README.md +1 -1
- package/dist/types/agent.d.ts +27 -7
- package/dist/types/compaction/anthropic.d.ts +14 -8
- package/dist/types/compaction/branch-summarization.d.ts +1 -1
- package/dist/types/compaction/compaction-v2-streaming.d.ts +5 -1
- package/dist/types/compaction/compaction.d.ts +24 -2
- package/dist/types/compaction/entries.d.ts +2 -2
- package/dist/types/compaction/messages.d.ts +6 -0
- package/dist/types/compaction/openai.d.ts +14 -2
- package/dist/types/compaction/pruning.d.ts +64 -16
- package/dist/types/compaction/tool-protection.d.ts +2 -1
- package/dist/types/compaction/utils.d.ts +5 -0
- package/dist/types/image-tokens.d.ts +29 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/speculative-execution.d.ts +1 -1
- package/dist/types/telemetry.d.ts +70 -60
- package/dist/types/tokenizer.d.ts +9 -6
- package/dist/types/tool-arguments.d.ts +19 -0
- package/dist/types/types.d.ts +12 -1
- package/package.json +8 -8
- package/src/agent-loop.ts +191 -34
- package/src/agent.ts +126 -21
- package/src/append-only-context.ts +38 -29
- package/src/compaction/anthropic.ts +30 -12
- package/src/compaction/branch-summarization.ts +1 -1
- package/src/compaction/compaction-v2-streaming.ts +23 -16
- package/src/compaction/compaction.ts +163 -73
- package/src/compaction/entries.ts +2 -2
- package/src/compaction/message-cache.ts +1 -1
- package/src/compaction/messages.ts +25 -0
- package/src/compaction/openai.ts +65 -28
- package/src/compaction/pruning.ts +328 -96
- package/src/compaction/tool-protection.ts +8 -2
- package/src/compaction/utils.ts +9 -0
- package/src/image-tokens.ts +77 -0
- package/src/index.ts +2 -0
- package/src/prompts/dsml-tool-call-leak.md +19 -0
- package/src/speculative-execution.ts +5 -9
- package/src/telemetry.ts +100 -81
- package/src/tokenizer.ts +24 -17
- package/src/tool-arguments.ts +38 -0
- package/src/types.ts +12 -1
|
@@ -7,6 +7,7 @@ import type { Tokenizer } from "../tokenizer";
|
|
|
7
7
|
import type { AgentMessage, AgentToolCall } from "../types";
|
|
8
8
|
import type { SessionEntry, SessionMessageEntry } from "./entries";
|
|
9
9
|
import { invalidateMessageCache } from "./message-cache";
|
|
10
|
+
import { type ConvertToLlm, defaultConvertToLlm, getMessageFromEntry } from "./messages";
|
|
10
11
|
import {
|
|
11
12
|
collectToolCallsById,
|
|
12
13
|
getToolResultMessage,
|
|
@@ -14,9 +15,27 @@ import {
|
|
|
14
15
|
isSkillReadToolResult,
|
|
15
16
|
type ProtectedToolMatcher,
|
|
16
17
|
} from "./tool-protection";
|
|
17
|
-
import { splitReadSelector } from "./utils";
|
|
18
|
+
import { isAlternateFormReadSelector, splitReadSelector } from "./utils";
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
/** Bound warm-cache pruning by the provider's prompt-cache lookback window. */
|
|
21
|
+
export interface CacheLookbackConfig {
|
|
22
|
+
/**
|
|
23
|
+
* Prompt-cache lookback of the model the next request goes to, in block
|
|
24
|
+
* positions (catalog `prompt-cache-lookback`; Anthropic: 20). When set,
|
|
25
|
+
* warm-cache pruning also leaves a result whose rewrite would put the next
|
|
26
|
+
* request's cache lookup out of reach, whatever its token suffix (see
|
|
27
|
+
* `cacheLookbackFloor`). Undefined = no lookback bound.
|
|
28
|
+
*/
|
|
29
|
+
cacheLookbackPositions?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Projects context messages to what the provider receives, so lookback
|
|
32
|
+
* positions count app roles (file mentions, command output, custom
|
|
33
|
+
* messages) as sent. Default {@link defaultConvertToLlm}.
|
|
34
|
+
*/
|
|
35
|
+
convertToLlm?: ConvertToLlm;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PruneConfig extends CacheLookbackConfig {
|
|
20
39
|
/** Keep the most recent tool output tokens intact. */
|
|
21
40
|
protectTokens: number;
|
|
22
41
|
/** Only prune if total savings meets this threshold. */
|
|
@@ -30,6 +49,8 @@ export interface PruneConfig {
|
|
|
30
49
|
* unchanged.
|
|
31
50
|
*/
|
|
32
51
|
supersedeKey?: SupersedeKeyFn;
|
|
52
|
+
/** Whether a keyed result shows its whole target (see {@link SupersedePruneConfig.supersedeComplete}). */
|
|
53
|
+
supersedeComplete?: SupersedeCompleteFn;
|
|
33
54
|
/** Useless-flagged results bypass the protect window (see {@link USELESS_NOTICE}). Default true. */
|
|
34
55
|
pruneUseless?: boolean;
|
|
35
56
|
/**
|
|
@@ -46,7 +67,8 @@ export interface PruneConfig {
|
|
|
46
67
|
* already-sent cache prefix: mutating it forces the provider to re-write the
|
|
47
68
|
* whole suffix (cacheWrite premium). Such results — including superseded and
|
|
48
69
|
* useless ones, which otherwise bypass {@link protectTokens} — are left for
|
|
49
|
-
* compaction/shake (which rebuild the cache anyway) to reclaim
|
|
70
|
+
* compaction/shake (which rebuild the cache anyway) to reclaim, as are
|
|
71
|
+
* results beyond {@link CacheLookbackConfig.cacheLookbackPositions}. Undefined =
|
|
50
72
|
* no cache guard (legacy: superseded/useless prune at any depth).
|
|
51
73
|
*/
|
|
52
74
|
cacheWarmSuffixTokens?: number;
|
|
@@ -100,19 +122,44 @@ export const USELESS_NOTICE = "[Uneventful result elided]";
|
|
|
100
122
|
|
|
101
123
|
/**
|
|
102
124
|
* Maps a tool call to a supersede key. Results sharing a key form a group in
|
|
103
|
-
* which
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* `undefined` to exempt a call from
|
|
125
|
+
* which a newer successful result supersedes every older one. A key `K` also
|
|
126
|
+
* covers keys with prefix `K + "\u0000"`: they are superseded only when the
|
|
127
|
+
* newest successful result for `K` is complete (see
|
|
128
|
+
* {@link SupersedeCompleteFn}). Return `undefined` to exempt a call from
|
|
129
|
+
* supersede grouping.
|
|
107
130
|
*/
|
|
108
131
|
export type SupersedeKeyFn = (toolName: string, args: Record<string, unknown>) => string | undefined;
|
|
109
132
|
|
|
110
|
-
|
|
111
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Whether a successful keyed result shows its whole target, so a result for a
|
|
135
|
+
* parent key (e.g. a selector-free read) may supersede child-key results (e.g.
|
|
136
|
+
* range reads). A summary, truncated page, or notice is not complete.
|
|
137
|
+
*/
|
|
138
|
+
export type SupersedeCompleteFn = (message: ToolResultMessage) => boolean;
|
|
139
|
+
|
|
140
|
+
export interface SupersedePruneConfig extends CacheLookbackConfig {
|
|
141
|
+
/** Supersede key function; a newer successful result with the same key supersedes older ones (see {@link SupersedeKeyFn}). */
|
|
112
142
|
supersedeKey?: SupersedeKeyFn;
|
|
143
|
+
/**
|
|
144
|
+
* Whether a successful keyed result is complete. Absent, every successful
|
|
145
|
+
* result is complete. Child-key results are superseded only when the parent
|
|
146
|
+
* key's newest successful result is complete; same-key results follow
|
|
147
|
+
* newest-wins, since a same-key re-read that shows a different view means
|
|
148
|
+
* the target changed. A failed result supersedes only older failed results.
|
|
149
|
+
*
|
|
150
|
+
* Trade-off, by design: after `read foo.ts:50-200` → edit → a bare
|
|
151
|
+
* `read foo.ts` that returns a summary, the pre-edit range stays in context
|
|
152
|
+
* until a complete bare read or a same-range re-read. Keeping possibly stale
|
|
153
|
+
* detail is preferred over losing detail the summary does not show.
|
|
154
|
+
*/
|
|
155
|
+
supersedeComplete?: SupersedeCompleteFn;
|
|
113
156
|
/** Also prune results flagged useless by their tool. Default false. */
|
|
114
157
|
pruneUseless?: boolean;
|
|
115
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* Prune a candidate now when all messages after it total at most this many
|
|
160
|
+
* estimated tokens and it sits within
|
|
161
|
+
* {@link CacheLookbackConfig.cacheLookbackPositions}. Default 8 000.
|
|
162
|
+
*/
|
|
116
163
|
suffixTokenLimit?: number;
|
|
117
164
|
/**
|
|
118
165
|
* Prune all candidates when the last message is at least this old: the
|
|
@@ -137,6 +184,99 @@ export interface SupersedePruneConfig {
|
|
|
137
184
|
const DEFAULT_SUFFIX_TOKEN_LIMIT = 8_000;
|
|
138
185
|
const DEFAULT_IDLE_FLUSH_MS = 30 * 60_000;
|
|
139
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Positions kept free for the next request's own input (prompt, date/cwd
|
|
189
|
+
* reminder, attachments) and for request projections the count below does not
|
|
190
|
+
* model (orphan-result notes, replayed compaction file metadata, tool-change
|
|
191
|
+
* controls): with Anthropic's 20, stored history gets at most 13.
|
|
192
|
+
*/
|
|
193
|
+
const RESERVED_LOOKBACK_POSITIONS = 6;
|
|
194
|
+
|
|
195
|
+
type LookbackBlock = "tool_use" | "tool_result" | undefined;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Index of the oldest tool result that can be rewritten while the next request
|
|
199
|
+
* still reaches a cache entry, given the model's prompt-cache `lookback` in
|
|
200
|
+
* block positions (a breakpoint counts itself; a run of consecutive `tool_use`
|
|
201
|
+
* blocks, or of consecutive `tool_result` blocks, is one position). A
|
|
202
|
+
* conservative estimate, not an exact count: a skipped prune costs its tokens,
|
|
203
|
+
* a missed lookup the whole cache.
|
|
204
|
+
*
|
|
205
|
+
* Each request writes its tail cache entry at its last block, so rewriting a
|
|
206
|
+
* result invalidates every entry from its issuing assistant turn on, and once a
|
|
207
|
+
* request has 15 user turns its other breakpoint sits on an older decimation
|
|
208
|
+
* checkpoint. The next request's tail breakpoint then reaches the newest
|
|
209
|
+
* surviving entry, which ends the message before the issuing turn, only when
|
|
210
|
+
* the issuing turn, its tool-result batch, everything after and the next input
|
|
211
|
+
* fit in the lookback window; otherwise the lookup falls back to that
|
|
212
|
+
* checkpoint and the whole conversation since is re-written, not the small
|
|
213
|
+
* suffix the token limits budget for. Each entry is projected through
|
|
214
|
+
* `convertToLlm` and counted the way the Anthropic request converter emits it.
|
|
215
|
+
*/
|
|
216
|
+
function cacheLookbackFloor(
|
|
217
|
+
entries: readonly SessionEntry[],
|
|
218
|
+
start: number,
|
|
219
|
+
lookback: number | undefined,
|
|
220
|
+
convertToLlm: ConvertToLlm,
|
|
221
|
+
): number {
|
|
222
|
+
if (lookback === undefined) return start;
|
|
223
|
+
let positions = RESERVED_LOOKBACK_POSITIONS;
|
|
224
|
+
let later: LookbackBlock;
|
|
225
|
+
let runHoistsImages = false;
|
|
226
|
+
let newerIsAssistant = false;
|
|
227
|
+
const add = (block: LookbackBlock): void => {
|
|
228
|
+
if (block === undefined || block !== later) positions++;
|
|
229
|
+
later = block;
|
|
230
|
+
};
|
|
231
|
+
let floor = entries.length;
|
|
232
|
+
for (let i = entries.length - 1; i >= start; i--) {
|
|
233
|
+
const message = getMessageFromEntry(entries[i]);
|
|
234
|
+
const sent = message === undefined ? [] : convertToLlm([message]);
|
|
235
|
+
for (let m = sent.length - 1; m >= 0; m--) {
|
|
236
|
+
const llm = sent[m];
|
|
237
|
+
if (llm.role === "toolResult") {
|
|
238
|
+
if (later !== "tool_result") runHoistsImages = false;
|
|
239
|
+
add("tool_result");
|
|
240
|
+
// Anthropic rejects images in error results, so the converter moves
|
|
241
|
+
// them after the result run behind one explanatory text block.
|
|
242
|
+
let images = 0;
|
|
243
|
+
if (llm.isError) {
|
|
244
|
+
for (const block of llm.content) if (block.type === "image") images++;
|
|
245
|
+
}
|
|
246
|
+
if (images > 0) {
|
|
247
|
+
positions += runHoistsImages ? images : images + 1;
|
|
248
|
+
runHoistsImages = true;
|
|
249
|
+
}
|
|
250
|
+
newerIsAssistant = false;
|
|
251
|
+
} else if (llm.role === "assistant") {
|
|
252
|
+
// The converter pads consecutive assistant turns with a user turn and
|
|
253
|
+
// drops assistant images, blank text, and turns left empty.
|
|
254
|
+
let pad = newerIsAssistant;
|
|
255
|
+
for (let b = llm.content.length - 1; b >= 0; b--) {
|
|
256
|
+
const block = llm.content[b];
|
|
257
|
+
if (block.type === "image" || (block.type === "text" && block.text.trim().length === 0)) continue;
|
|
258
|
+
if (pad) {
|
|
259
|
+
add(undefined);
|
|
260
|
+
pad = false;
|
|
261
|
+
}
|
|
262
|
+
add(block.type === "toolCall" ? "tool_use" : undefined);
|
|
263
|
+
newerIsAssistant = true;
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
// The converter drops blank user/developer turns.
|
|
267
|
+
const blocks =
|
|
268
|
+
typeof llm.content === "string" ? (llm.content.trim().length > 0 ? 1 : 0) : llm.content.length;
|
|
269
|
+
if (blocks === 0) continue;
|
|
270
|
+
for (let b = blocks; b > 0; b--) add(undefined);
|
|
271
|
+
newerIsAssistant = false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (positions >= lookback) return floor;
|
|
275
|
+
if (message?.role === "assistant") floor = i;
|
|
276
|
+
}
|
|
277
|
+
return start;
|
|
278
|
+
}
|
|
279
|
+
|
|
140
280
|
function createPrunedNotice(tokens: number): string {
|
|
141
281
|
return `[Output truncated - ${tokens} tokens]`;
|
|
142
282
|
}
|
|
@@ -156,24 +296,6 @@ function estimatePrunedSavings(tokens: number, notice: string): number {
|
|
|
156
296
|
return Math.max(0, tokens - noticeTokens);
|
|
157
297
|
}
|
|
158
298
|
|
|
159
|
-
/**
|
|
160
|
-
* For each entry index, the estimated token total of all *message* entries
|
|
161
|
-
* strictly after it — how much prompt-cache content the provider must re-write
|
|
162
|
-
* (cacheWrite premium) if that entry is mutated in place. Used to keep prune
|
|
163
|
-
* mutations inside the cheap-to-recache tail.
|
|
164
|
-
*/
|
|
165
|
-
function computeMessageSuffixTokens(entries: readonly SessionEntry[], tokenizer: Tokenizer): number[] {
|
|
166
|
-
// [suppressed] length preallocation
|
|
167
|
-
const suffix = new Array<number>(entries.length);
|
|
168
|
-
let accumulated = 0;
|
|
169
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
170
|
-
suffix[i] = accumulated;
|
|
171
|
-
const entry = entries[i];
|
|
172
|
-
if (entry.type === "message") accumulated += tokenizer.countMessage(entry.message as AgentMessage);
|
|
173
|
-
}
|
|
174
|
-
return suffix;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
299
|
/**
|
|
178
300
|
* Resolve the array index of the compaction boundary (`keepBoundaryId`). Entries
|
|
179
301
|
* before this index are summarized away by the latest compaction and never sent,
|
|
@@ -182,8 +304,36 @@ function computeMessageSuffixTokens(entries: readonly SessionEntry[], tokenizer:
|
|
|
182
304
|
*/
|
|
183
305
|
function resolveBoundaryIndex(entries: readonly SessionEntry[], keepBoundaryId: string | undefined): number {
|
|
184
306
|
if (keepBoundaryId === undefined) return 0;
|
|
185
|
-
|
|
186
|
-
|
|
307
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
308
|
+
if (entries[i].id === keepBoundaryId) return i;
|
|
309
|
+
}
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Tool-call lookup for results in the sent region `entries[start, end)`.
|
|
315
|
+
* Calls there are indexed eagerly; the summarized-away prefix is indexed only
|
|
316
|
+
* on the first miss, so lookups match a whole-branch scan while the common
|
|
317
|
+
* case never walks history the boundary already excludes.
|
|
318
|
+
*/
|
|
319
|
+
class SentToolCalls {
|
|
320
|
+
readonly #entries: readonly SessionEntry[];
|
|
321
|
+
readonly #start: number;
|
|
322
|
+
readonly #sent: Map<string, AgentToolCall>;
|
|
323
|
+
#prefix: Map<string, AgentToolCall> | undefined;
|
|
324
|
+
|
|
325
|
+
constructor(entries: readonly SessionEntry[], start: number) {
|
|
326
|
+
this.#entries = entries;
|
|
327
|
+
this.#start = start;
|
|
328
|
+
this.#sent = collectToolCallsById(entries, start);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
get(id: string): AgentToolCall | undefined {
|
|
332
|
+
const call = this.#sent.get(id);
|
|
333
|
+
if (call !== undefined || this.#start === 0) return call;
|
|
334
|
+
this.#prefix ??= collectToolCallsById(this.#entries, 0, this.#start);
|
|
335
|
+
return this.#prefix.get(id);
|
|
336
|
+
}
|
|
187
337
|
}
|
|
188
338
|
|
|
189
339
|
interface SupersedeCandidate {
|
|
@@ -196,33 +346,59 @@ interface SupersedeCandidate {
|
|
|
196
346
|
notice: string;
|
|
197
347
|
}
|
|
198
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Newer results of one key. An entry exists once any newer result (even a
|
|
351
|
+
* failed one) was seen. `complete` describes the newest successful result
|
|
352
|
+
* only: older successes of the key are superseded, so they cannot vouch for
|
|
353
|
+
* child keys.
|
|
354
|
+
*/
|
|
355
|
+
interface NewerResults {
|
|
356
|
+
success: boolean;
|
|
357
|
+
complete: boolean;
|
|
358
|
+
}
|
|
359
|
+
|
|
199
360
|
/**
|
|
200
361
|
* Collect superseded tool results: for every unpruned, unprotected tool result
|
|
201
|
-
* whose paired call resolves a supersede key, a LATER result
|
|
202
|
-
*
|
|
203
|
-
*
|
|
362
|
+
* whose paired call resolves a supersede key, a LATER successful result of the
|
|
363
|
+
* same key supersedes it, as does its `"\u0000"`-prefix parent key when that
|
|
364
|
+
* key's newest successful result is complete. A failed result supersedes only
|
|
365
|
+
* older failed results; an older failed result is superseded by any later
|
|
366
|
+
* result of its key or a later successful result of its parent key.
|
|
204
367
|
*/
|
|
205
368
|
function collectSupersededResults(
|
|
206
369
|
entries: readonly SessionEntry[],
|
|
370
|
+
start: number,
|
|
207
371
|
tokenizer: Tokenizer,
|
|
208
|
-
|
|
372
|
+
toolCalls: SentToolCalls,
|
|
209
373
|
supersedeKey: SupersedeKeyFn,
|
|
374
|
+
supersedeComplete: SupersedeCompleteFn | undefined,
|
|
210
375
|
protectedTools: readonly ProtectedToolMatcher[],
|
|
211
376
|
): SupersedeCandidate[] {
|
|
377
|
+
// Walk newest → oldest: supersession only depends on NEWER results, so
|
|
378
|
+
// stopping at `start` yields exactly the full scan's candidates at/after it.
|
|
212
379
|
const candidates: SupersedeCandidate[] = [];
|
|
213
|
-
const
|
|
214
|
-
for (let i = entries.length - 1; i >=
|
|
380
|
+
const newerByKey = new Map<string, NewerResults>();
|
|
381
|
+
for (let i = entries.length - 1; i >= start; i--) {
|
|
215
382
|
const entry = entries[i];
|
|
216
383
|
const message = getToolResultMessage(entry);
|
|
217
384
|
if (!message || message.prunedAt !== undefined) continue;
|
|
218
|
-
const toolCall =
|
|
385
|
+
const toolCall = toolCalls.get(message.toolCallId);
|
|
219
386
|
if (!toolCall) continue;
|
|
220
387
|
if (isProtectedToolResult(message, toolCall, protectedTools)) continue;
|
|
221
388
|
const key = supersedeKey(toolCall.name, toolCall.arguments as Record<string, unknown>);
|
|
222
389
|
if (key === undefined) continue;
|
|
223
390
|
const separator = key.indexOf("\u0000");
|
|
224
|
-
const
|
|
225
|
-
|
|
391
|
+
const sameKey = newerByKey.get(key);
|
|
392
|
+
const parent = separator >= 0 ? newerByKey.get(key.slice(0, separator)) : undefined;
|
|
393
|
+
const superseded = message.isError
|
|
394
|
+
? sameKey !== undefined || parent?.success === true
|
|
395
|
+
: sameKey?.success === true || parent?.complete === true;
|
|
396
|
+
const newer = sameKey ?? { success: false, complete: false };
|
|
397
|
+
if (!message.isError && !newer.success) {
|
|
398
|
+
newer.success = true;
|
|
399
|
+
newer.complete = supersedeComplete?.(message) ?? true;
|
|
400
|
+
}
|
|
401
|
+
newerByKey.set(key, newer);
|
|
226
402
|
if (!superseded) continue;
|
|
227
403
|
candidates.push({
|
|
228
404
|
entry: entry as SessionMessageEntry,
|
|
@@ -243,18 +419,19 @@ function collectSupersededResults(
|
|
|
243
419
|
*/
|
|
244
420
|
function collectUselessResults(
|
|
245
421
|
entries: readonly SessionEntry[],
|
|
422
|
+
start: number,
|
|
246
423
|
tokenizer: Tokenizer,
|
|
247
|
-
|
|
424
|
+
toolCalls: SentToolCalls,
|
|
248
425
|
protectedTools: readonly ProtectedToolMatcher[],
|
|
249
426
|
exclude: ReadonlySet<ToolResultMessage>,
|
|
250
427
|
): SupersedeCandidate[] {
|
|
251
428
|
const candidates: SupersedeCandidate[] = [];
|
|
252
|
-
for (let i =
|
|
429
|
+
for (let i = start; i < entries.length; i++) {
|
|
253
430
|
const entry = entries[i];
|
|
254
431
|
const message = getToolResultMessage(entry);
|
|
255
432
|
if (message?.useless !== true || message.prunedAt !== undefined || message.isError === true) continue;
|
|
256
433
|
if (exclude.has(message)) continue;
|
|
257
|
-
if (isProtectedToolResult(message,
|
|
434
|
+
if (isProtectedToolResult(message, toolCalls.get(message.toolCallId), protectedTools)) continue;
|
|
258
435
|
const tokens = tokenizer.countMessage(message as AgentMessage);
|
|
259
436
|
if (estimatePrunedSavings(tokens, USELESS_NOTICE) <= 0) continue;
|
|
260
437
|
candidates.push({ entry: entry as SessionMessageEntry, message, index: i, tokens, notice: USELESS_NOTICE });
|
|
@@ -269,20 +446,39 @@ function collectUselessResults(
|
|
|
269
446
|
* candidate is pruned now only when the suffix after it is small (tail case —
|
|
270
447
|
* the read→edit→read loop) or when the context has been idle long enough that
|
|
271
448
|
* the provider cache is cold anyway (then all still-sent candidates flush).
|
|
272
|
-
* Never mutates entries before `keepBoundaryId` (summarized away — not sent)
|
|
449
|
+
* Never mutates entries before `keepBoundaryId` (summarized away — not sent),
|
|
450
|
+
* and never walks them unless a sent result's call lives there.
|
|
273
451
|
*/
|
|
274
452
|
export function pruneSupersededToolResults(
|
|
275
|
-
entries: SessionEntry[],
|
|
453
|
+
entries: readonly SessionEntry[],
|
|
276
454
|
tokenizer: Tokenizer,
|
|
277
455
|
config: SupersedePruneConfig,
|
|
278
456
|
): PruneResult {
|
|
279
|
-
const
|
|
457
|
+
const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
|
|
458
|
+
const toolCalls = new SentToolCalls(entries, boundaryIndex);
|
|
280
459
|
const candidates = config.supersedeKey
|
|
281
|
-
? collectSupersededResults(
|
|
460
|
+
? collectSupersededResults(
|
|
461
|
+
entries,
|
|
462
|
+
boundaryIndex,
|
|
463
|
+
tokenizer,
|
|
464
|
+
toolCalls,
|
|
465
|
+
config.supersedeKey,
|
|
466
|
+
config.supersedeComplete,
|
|
467
|
+
config.protectedTools,
|
|
468
|
+
)
|
|
282
469
|
: [];
|
|
283
470
|
if (config.pruneUseless) {
|
|
284
471
|
const exclude = new Set(candidates.map(candidate => candidate.message));
|
|
285
|
-
|
|
472
|
+
for (const candidate of collectUselessResults(
|
|
473
|
+
entries,
|
|
474
|
+
boundaryIndex,
|
|
475
|
+
tokenizer,
|
|
476
|
+
toolCalls,
|
|
477
|
+
config.protectedTools,
|
|
478
|
+
exclude,
|
|
479
|
+
)) {
|
|
480
|
+
candidates.push(candidate);
|
|
481
|
+
}
|
|
286
482
|
candidates.sort((a, b) => a.index - b.index);
|
|
287
483
|
}
|
|
288
484
|
if (candidates.length === 0) return NOTHING_PRUNED;
|
|
@@ -299,38 +495,47 @@ export function pruneSupersededToolResults(
|
|
|
299
495
|
const idle =
|
|
300
496
|
lastMessageTimestamp !== undefined && now - lastMessageTimestamp >= (config.idleFlushMs ?? DEFAULT_IDLE_FLUSH_MS);
|
|
301
497
|
|
|
302
|
-
const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
|
|
303
|
-
|
|
304
498
|
let toPrune: SupersedeCandidate[];
|
|
305
499
|
if (idle) {
|
|
306
500
|
// Provider cache is cold (idle exceeds the retention TTL), so re-writing
|
|
307
|
-
// the sent region costs nothing.
|
|
308
|
-
//
|
|
309
|
-
toPrune = candidates
|
|
501
|
+
// the sent region costs nothing. Candidates already start at the
|
|
502
|
+
// compaction boundary (summarized-away entries are never sent).
|
|
503
|
+
toPrune = candidates;
|
|
310
504
|
} else {
|
|
505
|
+
// Mutating a candidate re-writes its suffix (tokens of every message
|
|
506
|
+
// strictly after it) in the warm cache, so prune only when that suffix is
|
|
507
|
+
// small and the next request's cache lookup stays in reach. The suffix
|
|
508
|
+
// only grows walking back, so stop at the first index past either limit
|
|
509
|
+
// instead of measuring the whole branch.
|
|
311
510
|
const suffixTokenLimit = config.suffixTokenLimit ?? DEFAULT_SUFFIX_TOKEN_LIMIT;
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
toPrune = candidates.filter(
|
|
318
|
-
candidate => candidate.index >= boundaryIndex && suffixTokens[candidate.index] <= suffixTokenLimit,
|
|
511
|
+
const lookbackFloor = cacheLookbackFloor(
|
|
512
|
+
entries,
|
|
513
|
+
boundaryIndex,
|
|
514
|
+
config.cacheLookbackPositions,
|
|
515
|
+
config.convertToLlm ?? defaultConvertToLlm,
|
|
319
516
|
);
|
|
517
|
+
toPrune = [];
|
|
518
|
+
let suffixTokens = 0;
|
|
519
|
+
let next = candidates.length - 1;
|
|
520
|
+
for (let i = entries.length - 1; next >= 0 && suffixTokens <= suffixTokenLimit && i >= lookbackFloor; i--) {
|
|
521
|
+
while (next >= 0 && candidates[next].index === i) toPrune.push(candidates[next--]);
|
|
522
|
+
const entry = entries[i];
|
|
523
|
+
if (entry.type === "message") suffixTokens += tokenizer.countMessage(entry.message as AgentMessage);
|
|
524
|
+
}
|
|
525
|
+
toPrune.reverse();
|
|
320
526
|
}
|
|
321
527
|
if (toPrune.length === 0) return NOTHING_PRUNED;
|
|
322
528
|
|
|
323
|
-
const prunedAt = Date.now();
|
|
324
529
|
let tokensSaved = 0;
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
530
|
+
for (const candidate of toPrune) tokensSaved += estimatePrunedSavings(candidate.tokens, candidate.notice);
|
|
531
|
+
|
|
532
|
+
const prunedAt = Date.now();
|
|
533
|
+
const steps = toPrune.map(candidate => blankToolResult(candidate.message, candidate.notice, prunedAt));
|
|
329
534
|
return { prunedCount: toPrune.length, tokensSaved, undo: undoAll(steps) };
|
|
330
535
|
}
|
|
331
536
|
|
|
332
537
|
export function pruneToolOutputs(
|
|
333
|
-
entries: SessionEntry[],
|
|
538
|
+
entries: readonly SessionEntry[],
|
|
334
539
|
tokenizer: Tokenizer,
|
|
335
540
|
config: PruneConfig = DEFAULT_PRUNE_CONFIG,
|
|
336
541
|
): PruneResult {
|
|
@@ -338,12 +543,21 @@ export function pruneToolOutputs(
|
|
|
338
543
|
let tokensSaved = 0;
|
|
339
544
|
|
|
340
545
|
const candidates: Array<{ entry: SessionMessageEntry; tokens: number; superseded: boolean; useless: boolean }> = [];
|
|
341
|
-
|
|
546
|
+
// Entries before the compaction boundary are summarized away (never sent)
|
|
547
|
+
// and are never prune candidates, so no pass below walks them.
|
|
548
|
+
const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
|
|
549
|
+
const toolCalls = new SentToolCalls(entries, boundaryIndex);
|
|
342
550
|
const supersededMessages = config.supersedeKey
|
|
343
551
|
? new Set(
|
|
344
|
-
collectSupersededResults(
|
|
345
|
-
|
|
346
|
-
|
|
552
|
+
collectSupersededResults(
|
|
553
|
+
entries,
|
|
554
|
+
boundaryIndex,
|
|
555
|
+
tokenizer,
|
|
556
|
+
toolCalls,
|
|
557
|
+
config.supersedeKey,
|
|
558
|
+
config.supersedeComplete,
|
|
559
|
+
config.protectedTools,
|
|
560
|
+
).map(candidate => candidate.message),
|
|
347
561
|
)
|
|
348
562
|
: undefined;
|
|
349
563
|
const uselessMessages =
|
|
@@ -351,42 +565,52 @@ export function pruneToolOutputs(
|
|
|
351
565
|
? new Set(
|
|
352
566
|
collectUselessResults(
|
|
353
567
|
entries,
|
|
568
|
+
boundaryIndex,
|
|
354
569
|
tokenizer,
|
|
355
|
-
|
|
570
|
+
toolCalls,
|
|
356
571
|
config.protectedTools,
|
|
357
572
|
supersededMessages ?? new Set(),
|
|
358
573
|
).map(candidate => candidate.message),
|
|
359
574
|
)
|
|
360
575
|
: undefined;
|
|
361
576
|
|
|
362
|
-
const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
|
|
363
577
|
const cacheWarmSuffixTokens = config.cacheWarmSuffixTokens;
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
578
|
+
const lookbackFloor =
|
|
579
|
+
cacheWarmSuffixTokens === undefined
|
|
580
|
+
? boundaryIndex
|
|
581
|
+
: cacheLookbackFloor(
|
|
582
|
+
entries,
|
|
583
|
+
boundaryIndex,
|
|
584
|
+
config.cacheLookbackPositions,
|
|
585
|
+
config.convertToLlm ?? defaultConvertToLlm,
|
|
586
|
+
);
|
|
587
|
+
// Tokens of every message strictly after entry `i` (cache guard only).
|
|
588
|
+
let messageSuffix = 0;
|
|
589
|
+
|
|
590
|
+
for (let i = entries.length - 1; i >= boundaryIndex; i--) {
|
|
369
591
|
const entry = entries[i];
|
|
592
|
+
const suffixAfter = messageSuffix;
|
|
370
593
|
const message = getToolResultMessage(entry);
|
|
371
|
-
if (!message)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if (message.prunedAt !== undefined) {
|
|
377
|
-
accumulatedTokens += tokens;
|
|
594
|
+
if (!message) {
|
|
595
|
+
if (cacheWarmSuffixTokens !== undefined && entry.type === "message") {
|
|
596
|
+
messageSuffix += tokenizer.countMessage(entry.message as AgentMessage);
|
|
597
|
+
}
|
|
378
598
|
continue;
|
|
379
599
|
}
|
|
380
600
|
|
|
601
|
+
const tokens = tokenizer.countMessage(message as AgentMessage);
|
|
602
|
+
messageSuffix += tokens;
|
|
603
|
+
|
|
381
604
|
// Prompt-cache guard: a result whose all-message suffix exceeds the
|
|
382
605
|
// warm-cache window sits in the already-sent cached prefix — mutating it
|
|
383
|
-
// re-writes the whole suffix (cacheWrite premium)
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
// still-cached
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
606
|
+
// re-writes the whole suffix (cacheWrite premium), or the whole
|
|
607
|
+
// conversation once the next request's cache lookup is out of reach.
|
|
608
|
+
// Both only worsen walking back, so every older result is in the warm
|
|
609
|
+
// prefix too. Deeper, still-cached superseded/useless copies are left for
|
|
610
|
+
// compaction/shake.
|
|
611
|
+
if (cacheWarmSuffixTokens !== undefined && (suffixAfter > cacheWarmSuffixTokens || i < lookbackFloor)) break;
|
|
612
|
+
|
|
613
|
+
if (message.prunedAt !== undefined) {
|
|
390
614
|
accumulatedTokens += tokens;
|
|
391
615
|
continue;
|
|
392
616
|
}
|
|
@@ -398,7 +622,13 @@ export function pruneToolOutputs(
|
|
|
398
622
|
const superseded = supersededMessages?.has(message) ?? false;
|
|
399
623
|
const useless = uselessMessages?.has(message) ?? false;
|
|
400
624
|
const tooSmall = tokens < MIN_PRUNE_TOKENS;
|
|
401
|
-
if (
|
|
625
|
+
if (
|
|
626
|
+
!superseded &&
|
|
627
|
+
!useless &&
|
|
628
|
+
(accumulatedTokens < config.protectTokens ||
|
|
629
|
+
tooSmall ||
|
|
630
|
+
isProtectedToolResult(message, toolCalls.get(message.toolCallId), config.protectedTools))
|
|
631
|
+
) {
|
|
402
632
|
accumulatedTokens += tokens;
|
|
403
633
|
continue;
|
|
404
634
|
}
|
|
@@ -440,10 +670,11 @@ export function pruneToolOutputs(
|
|
|
440
670
|
* selector stripped (the read tool's own splitter grammar via
|
|
441
671
|
* {@link splitReadSelector}, e.g. `src/foo.ts:50-200`, `:2-4:raw`).
|
|
442
672
|
* Internal/URL-scheme paths (`skill://…`, `https://…`) are exempt.
|
|
443
|
-
* Selector-free reads key on the bare path;
|
|
444
|
-
* `path + "\u0000" + selector`, so
|
|
445
|
-
*
|
|
446
|
-
*
|
|
673
|
+
* Selector-free reads key on the bare path; line-range reads key on
|
|
674
|
+
* `path + "\u0000" + selector`, so a complete bare-path read can supersede them.
|
|
675
|
+
* `raw` and `conflicts` reads show a different form than a plain read (e.g. raw
|
|
676
|
+
* notebook JSON vs converted notebook text), so they key on
|
|
677
|
+
* `path + "\u0001" + selector` and are superseded only by the same selector.
|
|
447
678
|
*/
|
|
448
679
|
export function readToolSupersedeKey(toolName: string, args: Record<string, unknown>): string | undefined {
|
|
449
680
|
if (toolName !== "read") return undefined;
|
|
@@ -451,5 +682,6 @@ export function readToolSupersedeKey(toolName: string, args: Record<string, unkn
|
|
|
451
682
|
if (typeof path !== "string" || path.length === 0) return undefined;
|
|
452
683
|
if (path.includes("://")) return undefined;
|
|
453
684
|
const { path: base, sel } = splitReadSelector(path);
|
|
454
|
-
|
|
685
|
+
if (sel === undefined) return base;
|
|
686
|
+
return `${base}${isAlternateFormReadSelector(sel) ? "\u0001" : "\u0000"}${sel}`;
|
|
455
687
|
}
|
|
@@ -11,9 +11,15 @@ export type ProtectedToolMatcher = string | ((context: ProtectedToolContext) =>
|
|
|
11
11
|
|
|
12
12
|
const SKILL_INTERNAL_URL_PREFIX = "skill://";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/** Map tool-call ids to their calls across `entries[start, end)` (later calls win). */
|
|
15
|
+
export function collectToolCallsById(
|
|
16
|
+
entries: readonly SessionEntry[],
|
|
17
|
+
start = 0,
|
|
18
|
+
end = entries.length,
|
|
19
|
+
): Map<string, AgentToolCall> {
|
|
15
20
|
const toolCalls = new Map<string, AgentToolCall>();
|
|
16
|
-
for (
|
|
21
|
+
for (let i = start; i < end; i++) {
|
|
22
|
+
const entry = entries[i];
|
|
17
23
|
if (entry.type !== "message") continue;
|
|
18
24
|
const message = entry.message;
|
|
19
25
|
if (message.role !== "assistant") continue;
|
package/src/compaction/utils.ts
CHANGED
|
@@ -38,6 +38,7 @@ const RANGE_LIST_SRC = `${RANGE_CHUNK_SRC}(?:,${RANGE_CHUNK_SRC})*`;
|
|
|
38
38
|
const READ_SELECTOR_RE = new RegExp(`^(?:${RANGE_LIST_SRC}|raw|conflicts)$`, "i");
|
|
39
39
|
const READ_RANGE_ONLY_RE = new RegExp(`^${RANGE_LIST_SRC}$`, "i");
|
|
40
40
|
const READ_RAW_ONLY_RE = /^raw$/i;
|
|
41
|
+
const READ_ALTERNATE_FORM_RE = /^(?:raw|conflicts)$/i;
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* Split a read-tool path into its base path and trailing selector, mirroring the
|
|
@@ -77,6 +78,14 @@ export function stripReadSelector(path: string): string {
|
|
|
77
78
|
return splitReadSelector(path).path;
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Whether a selector from {@link splitReadSelector} shows the file in a form other
|
|
83
|
+
* than its plain lines: verbatim `raw` bytes or the merge `conflicts` view.
|
|
84
|
+
*/
|
|
85
|
+
export function isAlternateFormReadSelector(sel: string): boolean {
|
|
86
|
+
return sel.split(":").some(part => READ_ALTERNATE_FORM_RE.test(part));
|
|
87
|
+
}
|
|
88
|
+
|
|
80
89
|
/**
|
|
81
90
|
* A real filesystem path never contains a `scheme://` URL. Tool-call paths that
|
|
82
91
|
* do — `conflict://1`, `artifact://3`, `local://ctx.md`, `history://…`,
|