@linxiraos/pi-agent-core 1.1.27 → 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 -264
- package/README.md +1 -1
- package/dist/types/agent.d.ts +15 -2
- package/dist/types/compaction/branch-summarization.d.ts +1 -1
- package/dist/types/compaction/compaction.d.ts +10 -2
- package/dist/types/compaction/messages.d.ts +6 -0
- package/dist/types/compaction/pruning.d.ts +31 -7
- package/dist/types/compaction/tool-protection.d.ts +2 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/speculative-execution.d.ts +1 -1
- package/dist/types/telemetry.d.ts +60 -60
- 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 +15 -15
- package/src/agent.ts +49 -4
- package/src/append-only-context.ts +38 -29
- package/src/compaction/branch-summarization.ts +1 -1
- package/src/compaction/compaction-v2-streaming.ts +10 -1
- package/src/compaction/compaction.ts +36 -42
- package/src/compaction/message-cache.ts +1 -1
- package/src/compaction/messages.ts +25 -0
- package/src/compaction/openai.ts +2 -0
- package/src/compaction/pruning.ts +249 -77
- package/src/compaction/tool-protection.ts +8 -2
- package/src/index.ts +2 -0
- package/src/speculative-execution.ts +5 -9
- package/src/telemetry.ts +81 -80
- 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,
|
|
@@ -16,7 +17,25 @@ import {
|
|
|
16
17
|
} from "./tool-protection";
|
|
17
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. */
|
|
@@ -48,7 +67,8 @@ export interface PruneConfig {
|
|
|
48
67
|
* already-sent cache prefix: mutating it forces the provider to re-write the
|
|
49
68
|
* whole suffix (cacheWrite premium). Such results — including superseded and
|
|
50
69
|
* useless ones, which otherwise bypass {@link protectTokens} — are left for
|
|
51
|
-
* 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 =
|
|
52
72
|
* no cache guard (legacy: superseded/useless prune at any depth).
|
|
53
73
|
*/
|
|
54
74
|
cacheWarmSuffixTokens?: number;
|
|
@@ -117,7 +137,7 @@ export type SupersedeKeyFn = (toolName: string, args: Record<string, unknown>) =
|
|
|
117
137
|
*/
|
|
118
138
|
export type SupersedeCompleteFn = (message: ToolResultMessage) => boolean;
|
|
119
139
|
|
|
120
|
-
export interface SupersedePruneConfig {
|
|
140
|
+
export interface SupersedePruneConfig extends CacheLookbackConfig {
|
|
121
141
|
/** Supersede key function; a newer successful result with the same key supersedes older ones (see {@link SupersedeKeyFn}). */
|
|
122
142
|
supersedeKey?: SupersedeKeyFn;
|
|
123
143
|
/**
|
|
@@ -135,7 +155,11 @@ export interface SupersedePruneConfig {
|
|
|
135
155
|
supersedeComplete?: SupersedeCompleteFn;
|
|
136
156
|
/** Also prune results flagged useless by their tool. Default false. */
|
|
137
157
|
pruneUseless?: boolean;
|
|
138
|
-
/**
|
|
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
|
+
*/
|
|
139
163
|
suffixTokenLimit?: number;
|
|
140
164
|
/**
|
|
141
165
|
* Prune all candidates when the last message is at least this old: the
|
|
@@ -160,6 +184,99 @@ export interface SupersedePruneConfig {
|
|
|
160
184
|
const DEFAULT_SUFFIX_TOKEN_LIMIT = 8_000;
|
|
161
185
|
const DEFAULT_IDLE_FLUSH_MS = 30 * 60_000;
|
|
162
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
|
+
|
|
163
280
|
function createPrunedNotice(tokens: number): string {
|
|
164
281
|
return `[Output truncated - ${tokens} tokens]`;
|
|
165
282
|
}
|
|
@@ -179,24 +296,6 @@ function estimatePrunedSavings(tokens: number, notice: string): number {
|
|
|
179
296
|
return Math.max(0, tokens - noticeTokens);
|
|
180
297
|
}
|
|
181
298
|
|
|
182
|
-
/**
|
|
183
|
-
* For each entry index, the estimated token total of all *message* entries
|
|
184
|
-
* strictly after it — how much prompt-cache content the provider must re-write
|
|
185
|
-
* (cacheWrite premium) if that entry is mutated in place. Used to keep prune
|
|
186
|
-
* mutations inside the cheap-to-recache tail.
|
|
187
|
-
*/
|
|
188
|
-
function computeMessageSuffixTokens(entries: readonly SessionEntry[], tokenizer: Tokenizer): number[] {
|
|
189
|
-
// [suppressed] length preallocation
|
|
190
|
-
const suffix = new Array<number>(entries.length);
|
|
191
|
-
let accumulated = 0;
|
|
192
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
193
|
-
suffix[i] = accumulated;
|
|
194
|
-
const entry = entries[i];
|
|
195
|
-
if (entry.type === "message") accumulated += tokenizer.countMessage(entry.message as AgentMessage);
|
|
196
|
-
}
|
|
197
|
-
return suffix;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
299
|
/**
|
|
201
300
|
* Resolve the array index of the compaction boundary (`keepBoundaryId`). Entries
|
|
202
301
|
* before this index are summarized away by the latest compaction and never sent,
|
|
@@ -205,8 +304,36 @@ function computeMessageSuffixTokens(entries: readonly SessionEntry[], tokenizer:
|
|
|
205
304
|
*/
|
|
206
305
|
function resolveBoundaryIndex(entries: readonly SessionEntry[], keepBoundaryId: string | undefined): number {
|
|
207
306
|
if (keepBoundaryId === undefined) return 0;
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
}
|
|
210
337
|
}
|
|
211
338
|
|
|
212
339
|
interface SupersedeCandidate {
|
|
@@ -240,19 +367,22 @@ interface NewerResults {
|
|
|
240
367
|
*/
|
|
241
368
|
function collectSupersededResults(
|
|
242
369
|
entries: readonly SessionEntry[],
|
|
370
|
+
start: number,
|
|
243
371
|
tokenizer: Tokenizer,
|
|
244
|
-
|
|
372
|
+
toolCalls: SentToolCalls,
|
|
245
373
|
supersedeKey: SupersedeKeyFn,
|
|
246
374
|
supersedeComplete: SupersedeCompleteFn | undefined,
|
|
247
375
|
protectedTools: readonly ProtectedToolMatcher[],
|
|
248
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.
|
|
249
379
|
const candidates: SupersedeCandidate[] = [];
|
|
250
380
|
const newerByKey = new Map<string, NewerResults>();
|
|
251
|
-
for (let i = entries.length - 1; i >=
|
|
381
|
+
for (let i = entries.length - 1; i >= start; i--) {
|
|
252
382
|
const entry = entries[i];
|
|
253
383
|
const message = getToolResultMessage(entry);
|
|
254
384
|
if (!message || message.prunedAt !== undefined) continue;
|
|
255
|
-
const toolCall =
|
|
385
|
+
const toolCall = toolCalls.get(message.toolCallId);
|
|
256
386
|
if (!toolCall) continue;
|
|
257
387
|
if (isProtectedToolResult(message, toolCall, protectedTools)) continue;
|
|
258
388
|
const key = supersedeKey(toolCall.name, toolCall.arguments as Record<string, unknown>);
|
|
@@ -289,18 +419,19 @@ function collectSupersededResults(
|
|
|
289
419
|
*/
|
|
290
420
|
function collectUselessResults(
|
|
291
421
|
entries: readonly SessionEntry[],
|
|
422
|
+
start: number,
|
|
292
423
|
tokenizer: Tokenizer,
|
|
293
|
-
|
|
424
|
+
toolCalls: SentToolCalls,
|
|
294
425
|
protectedTools: readonly ProtectedToolMatcher[],
|
|
295
426
|
exclude: ReadonlySet<ToolResultMessage>,
|
|
296
427
|
): SupersedeCandidate[] {
|
|
297
428
|
const candidates: SupersedeCandidate[] = [];
|
|
298
|
-
for (let i =
|
|
429
|
+
for (let i = start; i < entries.length; i++) {
|
|
299
430
|
const entry = entries[i];
|
|
300
431
|
const message = getToolResultMessage(entry);
|
|
301
432
|
if (message?.useless !== true || message.prunedAt !== undefined || message.isError === true) continue;
|
|
302
433
|
if (exclude.has(message)) continue;
|
|
303
|
-
if (isProtectedToolResult(message,
|
|
434
|
+
if (isProtectedToolResult(message, toolCalls.get(message.toolCallId), protectedTools)) continue;
|
|
304
435
|
const tokens = tokenizer.countMessage(message as AgentMessage);
|
|
305
436
|
if (estimatePrunedSavings(tokens, USELESS_NOTICE) <= 0) continue;
|
|
306
437
|
candidates.push({ entry: entry as SessionMessageEntry, message, index: i, tokens, notice: USELESS_NOTICE });
|
|
@@ -315,19 +446,22 @@ function collectUselessResults(
|
|
|
315
446
|
* candidate is pruned now only when the suffix after it is small (tail case —
|
|
316
447
|
* the read→edit→read loop) or when the context has been idle long enough that
|
|
317
448
|
* the provider cache is cold anyway (then all still-sent candidates flush).
|
|
318
|
-
* 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.
|
|
319
451
|
*/
|
|
320
452
|
export function pruneSupersededToolResults(
|
|
321
|
-
entries: SessionEntry[],
|
|
453
|
+
entries: readonly SessionEntry[],
|
|
322
454
|
tokenizer: Tokenizer,
|
|
323
455
|
config: SupersedePruneConfig,
|
|
324
456
|
): PruneResult {
|
|
325
|
-
const
|
|
457
|
+
const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
|
|
458
|
+
const toolCalls = new SentToolCalls(entries, boundaryIndex);
|
|
326
459
|
const candidates = config.supersedeKey
|
|
327
460
|
? collectSupersededResults(
|
|
328
461
|
entries,
|
|
462
|
+
boundaryIndex,
|
|
329
463
|
tokenizer,
|
|
330
|
-
|
|
464
|
+
toolCalls,
|
|
331
465
|
config.supersedeKey,
|
|
332
466
|
config.supersedeComplete,
|
|
333
467
|
config.protectedTools,
|
|
@@ -335,7 +469,16 @@ export function pruneSupersededToolResults(
|
|
|
335
469
|
: [];
|
|
336
470
|
if (config.pruneUseless) {
|
|
337
471
|
const exclude = new Set(candidates.map(candidate => candidate.message));
|
|
338
|
-
|
|
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
|
+
}
|
|
339
482
|
candidates.sort((a, b) => a.index - b.index);
|
|
340
483
|
}
|
|
341
484
|
if (candidates.length === 0) return NOTHING_PRUNED;
|
|
@@ -352,38 +495,47 @@ export function pruneSupersededToolResults(
|
|
|
352
495
|
const idle =
|
|
353
496
|
lastMessageTimestamp !== undefined && now - lastMessageTimestamp >= (config.idleFlushMs ?? DEFAULT_IDLE_FLUSH_MS);
|
|
354
497
|
|
|
355
|
-
const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
|
|
356
|
-
|
|
357
498
|
let toPrune: SupersedeCandidate[];
|
|
358
499
|
if (idle) {
|
|
359
500
|
// Provider cache is cold (idle exceeds the retention TTL), so re-writing
|
|
360
|
-
// the sent region costs nothing.
|
|
361
|
-
//
|
|
362
|
-
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;
|
|
363
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.
|
|
364
510
|
const suffixTokenLimit = config.suffixTokenLimit ?? DEFAULT_SUFFIX_TOKEN_LIMIT;
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
toPrune = candidates.filter(
|
|
371
|
-
candidate => candidate.index >= boundaryIndex && suffixTokens[candidate.index] <= suffixTokenLimit,
|
|
511
|
+
const lookbackFloor = cacheLookbackFloor(
|
|
512
|
+
entries,
|
|
513
|
+
boundaryIndex,
|
|
514
|
+
config.cacheLookbackPositions,
|
|
515
|
+
config.convertToLlm ?? defaultConvertToLlm,
|
|
372
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();
|
|
373
526
|
}
|
|
374
527
|
if (toPrune.length === 0) return NOTHING_PRUNED;
|
|
375
528
|
|
|
376
|
-
const prunedAt = Date.now();
|
|
377
529
|
let tokensSaved = 0;
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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));
|
|
382
534
|
return { prunedCount: toPrune.length, tokensSaved, undo: undoAll(steps) };
|
|
383
535
|
}
|
|
384
536
|
|
|
385
537
|
export function pruneToolOutputs(
|
|
386
|
-
entries: SessionEntry[],
|
|
538
|
+
entries: readonly SessionEntry[],
|
|
387
539
|
tokenizer: Tokenizer,
|
|
388
540
|
config: PruneConfig = DEFAULT_PRUNE_CONFIG,
|
|
389
541
|
): PruneResult {
|
|
@@ -391,13 +543,17 @@ export function pruneToolOutputs(
|
|
|
391
543
|
let tokensSaved = 0;
|
|
392
544
|
|
|
393
545
|
const candidates: Array<{ entry: SessionMessageEntry; tokens: number; superseded: boolean; useless: boolean }> = [];
|
|
394
|
-
|
|
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);
|
|
395
550
|
const supersededMessages = config.supersedeKey
|
|
396
551
|
? new Set(
|
|
397
552
|
collectSupersededResults(
|
|
398
553
|
entries,
|
|
554
|
+
boundaryIndex,
|
|
399
555
|
tokenizer,
|
|
400
|
-
|
|
556
|
+
toolCalls,
|
|
401
557
|
config.supersedeKey,
|
|
402
558
|
config.supersedeComplete,
|
|
403
559
|
config.protectedTools,
|
|
@@ -409,42 +565,52 @@ export function pruneToolOutputs(
|
|
|
409
565
|
? new Set(
|
|
410
566
|
collectUselessResults(
|
|
411
567
|
entries,
|
|
568
|
+
boundaryIndex,
|
|
412
569
|
tokenizer,
|
|
413
|
-
|
|
570
|
+
toolCalls,
|
|
414
571
|
config.protectedTools,
|
|
415
572
|
supersededMessages ?? new Set(),
|
|
416
573
|
).map(candidate => candidate.message),
|
|
417
574
|
)
|
|
418
575
|
: undefined;
|
|
419
576
|
|
|
420
|
-
const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
|
|
421
577
|
const cacheWarmSuffixTokens = config.cacheWarmSuffixTokens;
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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--) {
|
|
427
591
|
const entry = entries[i];
|
|
592
|
+
const suffixAfter = messageSuffix;
|
|
428
593
|
const message = getToolResultMessage(entry);
|
|
429
|
-
if (!message)
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (message.prunedAt !== undefined) {
|
|
435
|
-
accumulatedTokens += tokens;
|
|
594
|
+
if (!message) {
|
|
595
|
+
if (cacheWarmSuffixTokens !== undefined && entry.type === "message") {
|
|
596
|
+
messageSuffix += tokenizer.countMessage(entry.message as AgentMessage);
|
|
597
|
+
}
|
|
436
598
|
continue;
|
|
437
599
|
}
|
|
438
600
|
|
|
601
|
+
const tokens = tokenizer.countMessage(message as AgentMessage);
|
|
602
|
+
messageSuffix += tokens;
|
|
603
|
+
|
|
439
604
|
// Prompt-cache guard: a result whose all-message suffix exceeds the
|
|
440
605
|
// warm-cache window sits in the already-sent cached prefix — mutating it
|
|
441
|
-
// re-writes the whole suffix (cacheWrite premium)
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
// still-cached
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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) {
|
|
448
614
|
accumulatedTokens += tokens;
|
|
449
615
|
continue;
|
|
450
616
|
}
|
|
@@ -456,7 +622,13 @@ export function pruneToolOutputs(
|
|
|
456
622
|
const superseded = supersededMessages?.has(message) ?? false;
|
|
457
623
|
const useless = uselessMessages?.has(message) ?? false;
|
|
458
624
|
const tooSmall = tokens < MIN_PRUNE_TOKENS;
|
|
459
|
-
if (
|
|
625
|
+
if (
|
|
626
|
+
!superseded &&
|
|
627
|
+
!useless &&
|
|
628
|
+
(accumulatedTokens < config.protectTokens ||
|
|
629
|
+
tooSmall ||
|
|
630
|
+
isProtectedToolResult(message, toolCalls.get(message.toolCallId), config.protectedTools))
|
|
631
|
+
) {
|
|
460
632
|
accumulatedTokens += tokens;
|
|
461
633
|
continue;
|
|
462
634
|
}
|
|
@@ -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/index.ts
CHANGED
|
@@ -28,6 +28,8 @@ export * from "./thinking";
|
|
|
28
28
|
export * from "./tool-context";
|
|
29
29
|
// Tokenizer choice
|
|
30
30
|
export * from "./tokenizer";
|
|
31
|
+
// Lenient-aware tool argument validation
|
|
32
|
+
export * from "./tool-arguments";
|
|
31
33
|
// Types
|
|
32
34
|
export * from "./types";
|
|
33
35
|
// Yield utilities for Bun event-loop busy-wait prevention
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import type { AssistantMessage } from "@linxiraos/pi-ai";
|
|
3
|
+
import { validateAgentToolArguments } from "./tool-arguments";
|
|
3
4
|
import type {
|
|
4
5
|
AgentContext,
|
|
5
6
|
AgentLoopConfig,
|
|
@@ -712,15 +713,10 @@ export class SpeculativeOperationCoordinator {
|
|
|
712
713
|
}
|
|
713
714
|
let validatedArgs: Record<string, unknown>;
|
|
714
715
|
try {
|
|
715
|
-
validatedArgs =
|
|
716
|
+
validatedArgs = validateAgentToolArguments(tool, toolCall);
|
|
716
717
|
} catch {
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
this.ineligible(toolCall, "tool arguments are not valid", source, parentToolCallId);
|
|
720
|
-
return undefined;
|
|
721
|
-
}
|
|
722
|
-
validatedArgs = { ...toolCall.arguments };
|
|
723
|
-
delete validatedArgs.__rawJson;
|
|
718
|
+
this.ineligible(toolCall, "tool arguments are not valid", source, parentToolCallId);
|
|
719
|
+
return undefined;
|
|
724
720
|
}
|
|
725
721
|
let executionArgs: Record<string, unknown>;
|
|
726
722
|
try {
|