pi-condense 2.7.0 → 2.9.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 +18 -0
- package/PRUNING.md +26 -6
- package/README.md +9 -3
- package/index.ts +18 -1
- package/package.json +1 -1
- package/src/budget.test.ts +49 -2
- package/src/budget.ts +22 -8
- package/src/chain-compressor.test.ts +419 -7
- package/src/chain-compressor.ts +144 -3
- package/src/chain-range-prune.test.ts +9 -0
- package/src/chain-range-prune.ts +1 -1
- package/src/commands.test.ts +10 -0
- package/src/commands.ts +6 -2
- package/src/diagnostics.test.ts +11 -2
- package/src/diagnostics.ts +1 -0
- package/src/id-collision.integration.test.ts +20 -2
- package/src/indexer.test.ts +211 -2
- package/src/indexer.ts +45 -4
- package/src/query-tool.test.ts +29 -0
- package/src/range-compression.integration.test.ts +395 -2
- package/src/spill.ts +19 -6
- package/src/types.ts +26 -7
|
@@ -5,9 +5,14 @@ import { compressEligible } from "./chain-compressor.js";
|
|
|
5
5
|
import { pruneMessages } from "./pruner.js";
|
|
6
6
|
import { detectChains } from "./chain-detector.js";
|
|
7
7
|
import { isProtected } from "./protected.js";
|
|
8
|
-
import { bareToolCallId } from "./occurrence-key.js";
|
|
8
|
+
import { bareToolCallId, occKey } from "./occurrence-key.js";
|
|
9
|
+
import { expectNoOrphanToolResults } from "./test-support.js";
|
|
10
|
+
import { CUSTOM_TYPE_CHAIN, CUSTOM_TYPE_INDEX } from "./types.js";
|
|
9
11
|
import type { ChainRange, ChainCompressionConfig } from "./types.js";
|
|
10
12
|
|
|
13
|
+
const noopDiagnostics = { report: () => {} };
|
|
14
|
+
const testBackfill = { spillThreshold: 1_000_000, spillPreviewBytes: 2048, sessionDir: "/tmp", sessionId: "s1" };
|
|
15
|
+
|
|
11
16
|
// End-to-end of the in-memory B path (everything except the LLM call, which is
|
|
12
17
|
// the shared runSummarization already exercised live): a span's per-batch
|
|
13
18
|
// summaries are fused by compressEligible, the entry lands in the real indexer's
|
|
@@ -41,6 +46,9 @@ describe("range compression integration", () => {
|
|
|
41
46
|
fuseInputs.push(text);
|
|
42
47
|
return "FUSED COHESIVE SUMMARY";
|
|
43
48
|
},
|
|
49
|
+
messages: [],
|
|
50
|
+
diagnostics: noopDiagnostics,
|
|
51
|
+
backfill: testBackfill,
|
|
44
52
|
});
|
|
45
53
|
|
|
46
54
|
// Fusion received the concatenated per-batch summaries and stored its result.
|
|
@@ -101,6 +109,9 @@ describe("range compression integration", () => {
|
|
|
101
109
|
blockRefs,
|
|
102
110
|
appendEntry: () => {},
|
|
103
111
|
now: () => 999,
|
|
112
|
+
messages: [],
|
|
113
|
+
diagnostics: noopDiagnostics,
|
|
114
|
+
backfill: testBackfill,
|
|
104
115
|
});
|
|
105
116
|
|
|
106
117
|
expect(compressedEntries).toHaveLength(1);
|
|
@@ -198,6 +209,9 @@ describe("range compression integration", () => {
|
|
|
198
209
|
blockRefs,
|
|
199
210
|
appendEntry: () => {},
|
|
200
211
|
now: () => 999,
|
|
212
|
+
messages,
|
|
213
|
+
diagnostics: noopDiagnostics,
|
|
214
|
+
backfill: testBackfill,
|
|
201
215
|
});
|
|
202
216
|
|
|
203
217
|
expect(compressedEntries).toHaveLength(1);
|
|
@@ -238,7 +252,15 @@ describe("range compression integration", () => {
|
|
|
238
252
|
indexer.registerSummaryBody(["tc2"], "batch two body");
|
|
239
253
|
|
|
240
254
|
const chain: ChainRange = { startUserTimestamp: 100, middleToolCallIds: ["tc1", "tc2"], finalAssistantTimestamp: 400 };
|
|
241
|
-
await compressEligible([chain], 0, {
|
|
255
|
+
await compressEligible([chain], 0, {
|
|
256
|
+
indexer,
|
|
257
|
+
blockRefs,
|
|
258
|
+
appendEntry: () => {},
|
|
259
|
+
now: () => 1,
|
|
260
|
+
messages: [],
|
|
261
|
+
diagnostics: noopDiagnostics,
|
|
262
|
+
backfill: testBackfill,
|
|
263
|
+
});
|
|
242
264
|
|
|
243
265
|
const messages: any[] = [
|
|
244
266
|
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 },
|
|
@@ -254,4 +276,375 @@ describe("range compression integration", () => {
|
|
|
254
276
|
expect(synthetic.content[0].text).toContain("batch one body");
|
|
255
277
|
expect(synthetic.content[0].text).toContain("batch two body");
|
|
256
278
|
});
|
|
279
|
+
|
|
280
|
+
test("mixed flush: one covered + one uncovered chain compress in a single compressEligible call", async () => {
|
|
281
|
+
const indexer = new ToolCallIndexer();
|
|
282
|
+
const blockRefs = new BlockRefIssuer();
|
|
283
|
+
|
|
284
|
+
// Chain A (covered): per-batch summary exists for tcA.
|
|
285
|
+
indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tcA" }]);
|
|
286
|
+
indexer.registerSummaryBody(["tcA"], "summary of tcA");
|
|
287
|
+
|
|
288
|
+
const messages: any[] = [
|
|
289
|
+
{ role: "user", content: [{ type: "text", text: "go A" }], timestamp: 100 },
|
|
290
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tcA", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" },
|
|
291
|
+
{ role: "toolResult", toolCallId: "tcA", toolName: "bash", content: [{ type: "text", text: "outA" }], isError: false, timestamp: 210 },
|
|
292
|
+
{ role: "assistant", content: [{ type: "text", text: "done A" }], timestamp: 400, usage: {}, stopReason: "end_turn" },
|
|
293
|
+
// Chain B (uncovered): no per-batch summary for tcB1/tcB2.
|
|
294
|
+
{ role: "user", content: [{ type: "text", text: "go B" }], timestamp: 1000 },
|
|
295
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tcB1", name: "bash", arguments: { cmd: "one" } }], timestamp: 1100, usage: {}, stopReason: "tool_use" },
|
|
296
|
+
{ role: "toolResult", toolCallId: "tcB1", toolName: "bash", content: [{ type: "text", text: "outB1" }], isError: false, timestamp: 1110 },
|
|
297
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tcB2", name: "bash", arguments: { cmd: "two" } }], timestamp: 1200, usage: {}, stopReason: "tool_use" },
|
|
298
|
+
{ role: "toolResult", toolCallId: "tcB2", toolName: "bash", content: [{ type: "text", text: "outB2" }], isError: false, timestamp: 1210 },
|
|
299
|
+
{ role: "assistant", content: [{ type: "text", text: "done B" }], timestamp: 1400, usage: {}, stopReason: "end_turn" },
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
const chainA: ChainRange = { startUserTimestamp: 100, middleToolCallIds: ["tcA"], finalAssistantTimestamp: 400 };
|
|
303
|
+
const chainB: ChainRange = {
|
|
304
|
+
startUserTimestamp: 1000,
|
|
305
|
+
middleToolCallIds: ["tcB1", "tcB2"],
|
|
306
|
+
middleOccurrenceKeys: [occKey("tcB1", 1110), occKey("tcB2", 1210)],
|
|
307
|
+
finalAssistantTimestamp: 1400,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const { compressedEntries } = await compressEligible([chainA, chainB], 0, {
|
|
311
|
+
indexer,
|
|
312
|
+
blockRefs,
|
|
313
|
+
appendEntry: () => {},
|
|
314
|
+
now: () => 999,
|
|
315
|
+
messages,
|
|
316
|
+
diagnostics: noopDiagnostics,
|
|
317
|
+
backfill: testBackfill,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(compressedEntries).toHaveLength(2);
|
|
321
|
+
const entryA = compressedEntries.find((e) => e.startUserTimestamp === 100)!;
|
|
322
|
+
const entryB = compressedEntries.find((e) => e.startUserTimestamp === 1000)!;
|
|
323
|
+
expect(entryA.bodySource).toBeUndefined();
|
|
324
|
+
expect(entryB.bodySource).toBe("deterministic");
|
|
325
|
+
|
|
326
|
+
const cc: ChainCompressionConfig = { enabled: true, rollingWindow: 0, stripFinalAssistantThinking: true, fuseRangeSummary: false };
|
|
327
|
+
const { messages: out } = pruneMessages(messages, indexer, cc);
|
|
328
|
+
|
|
329
|
+
const synthetics = out.filter((m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"));
|
|
330
|
+
expect(synthetics).toHaveLength(2);
|
|
331
|
+
const uncoveredSynthetic = synthetics.find((s: any) => s.content[0].text.includes("Deterministic chain compression"));
|
|
332
|
+
expect(uncoveredSynthetic).toBeDefined();
|
|
333
|
+
expectNoOrphanToolResults(out);
|
|
334
|
+
|
|
335
|
+
// Covered-output identity: the presence of chain B's deterministic branch
|
|
336
|
+
// must not perturb chain A's rendering at all. Rebuild chain A alone (chain
|
|
337
|
+
// B never existed) and require byte-identical entry + rendered text.
|
|
338
|
+
const referenceIndexer = new ToolCallIndexer();
|
|
339
|
+
const referenceBlockRefs = new BlockRefIssuer();
|
|
340
|
+
referenceIndexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tcA" }]);
|
|
341
|
+
referenceIndexer.registerSummaryBody(["tcA"], "summary of tcA");
|
|
342
|
+
const referenceMessages = messages.slice(0, 4); // chain A's own span only
|
|
343
|
+
const { compressedEntries: refEntries } = await compressEligible([chainA], 0, {
|
|
344
|
+
indexer: referenceIndexer,
|
|
345
|
+
blockRefs: referenceBlockRefs,
|
|
346
|
+
appendEntry: () => {},
|
|
347
|
+
now: () => 999,
|
|
348
|
+
messages: referenceMessages,
|
|
349
|
+
diagnostics: noopDiagnostics,
|
|
350
|
+
backfill: testBackfill,
|
|
351
|
+
});
|
|
352
|
+
expect(refEntries).toHaveLength(1);
|
|
353
|
+
expect(entryA).toEqual(refEntries[0]);
|
|
354
|
+
|
|
355
|
+
const { messages: refOut } = pruneMessages(referenceMessages, referenceIndexer, cc);
|
|
356
|
+
const refSynthetic = refOut.find(
|
|
357
|
+
(m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"),
|
|
358
|
+
);
|
|
359
|
+
expect(refSynthetic).toBeDefined();
|
|
360
|
+
const coveredSynthetic = synthetics.find((s: any) => !s.content[0].text.includes("Deterministic chain compression"));
|
|
361
|
+
expect(coveredSynthetic).toEqual(refSynthetic);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("recovery through backfill: an uncovered chain's raw output is recoverable via its t<N> ref", async () => {
|
|
365
|
+
const indexer = new ToolCallIndexer();
|
|
366
|
+
const blockRefs = new BlockRefIssuer();
|
|
367
|
+
|
|
368
|
+
const messages: any[] = [
|
|
369
|
+
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 },
|
|
370
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tcB1", name: "bash", arguments: { cmd: "one" } }], timestamp: 1100, usage: {}, stopReason: "tool_use" },
|
|
371
|
+
{ role: "toolResult", toolCallId: "tcB1", toolName: "bash", content: [{ type: "text", text: "outB1 raw" }], isError: false, timestamp: 1110 },
|
|
372
|
+
{ role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1400, usage: {}, stopReason: "end_turn" },
|
|
373
|
+
];
|
|
374
|
+
const chainB: ChainRange = {
|
|
375
|
+
startUserTimestamp: 1000,
|
|
376
|
+
middleToolCallIds: ["tcB1"],
|
|
377
|
+
middleOccurrenceKeys: [occKey("tcB1", 1110)],
|
|
378
|
+
finalAssistantTimestamp: 1400,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const { compressedEntries } = await compressEligible([chainB], 0, {
|
|
382
|
+
indexer,
|
|
383
|
+
blockRefs,
|
|
384
|
+
appendEntry: () => {},
|
|
385
|
+
now: () => 999,
|
|
386
|
+
messages,
|
|
387
|
+
diagnostics: noopDiagnostics,
|
|
388
|
+
backfill: testBackfill,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
expect(compressedEntries).toHaveLength(1);
|
|
392
|
+
const ref = compressedEntries[0].toolRefs[0];
|
|
393
|
+
const resolved = indexer.resolveToolCallId(ref);
|
|
394
|
+
expect(resolved).toBeDefined();
|
|
395
|
+
expect(indexer.getRecord(resolved!)?.resultText).toBe("outB1 raw");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("restart mid-failure-window: index entry persists even when the chain-entry append fails, and converges on retry", async () => {
|
|
399
|
+
const indexer = new ToolCallIndexer();
|
|
400
|
+
const blockRefs = new BlockRefIssuer();
|
|
401
|
+
|
|
402
|
+
const messages: any[] = [
|
|
403
|
+
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 },
|
|
404
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tcB1", name: "bash", arguments: { cmd: "one" } }], timestamp: 1100, usage: {}, stopReason: "tool_use" },
|
|
405
|
+
{ role: "toolResult", toolCallId: "tcB1", toolName: "bash", content: [{ type: "text", text: "outB1 raw" }], isError: false, timestamp: 1110 },
|
|
406
|
+
{ role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1400, usage: {}, stopReason: "end_turn" },
|
|
407
|
+
];
|
|
408
|
+
const chainB: ChainRange = {
|
|
409
|
+
startUserTimestamp: 1000,
|
|
410
|
+
middleToolCallIds: ["tcB1"],
|
|
411
|
+
middleOccurrenceKeys: [occKey("tcB1", 1110)],
|
|
412
|
+
finalAssistantTimestamp: 1400,
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const captured: Array<{ type: string; data: unknown }> = [];
|
|
416
|
+
const flakyAppendEntry = (type: string, data?: unknown) => {
|
|
417
|
+
if (type === CUSTOM_TYPE_CHAIN) throw new Error("session write failed");
|
|
418
|
+
captured.push({ type, data });
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// compressEligible does not catch chain-entry append failures itself
|
|
422
|
+
// (index.ts's caller wraps the whole call in try/catch); the throw
|
|
423
|
+
// propagates, but the index entry (with its refs) is already durable in
|
|
424
|
+
// `captured` by the time it does, per the append-before-commit ordering.
|
|
425
|
+
await expect(
|
|
426
|
+
compressEligible([chainB], 0, {
|
|
427
|
+
indexer,
|
|
428
|
+
blockRefs,
|
|
429
|
+
appendEntry: flakyAppendEntry,
|
|
430
|
+
now: () => 999,
|
|
431
|
+
messages,
|
|
432
|
+
diagnostics: noopDiagnostics,
|
|
433
|
+
backfill: testBackfill,
|
|
434
|
+
}),
|
|
435
|
+
).rejects.toThrow("session write failed");
|
|
436
|
+
|
|
437
|
+
expect(captured.filter((c) => c.type === CUSTOM_TYPE_INDEX)).toHaveLength(1);
|
|
438
|
+
expect(captured.filter((c) => c.type === CUSTOM_TYPE_CHAIN)).toHaveLength(0);
|
|
439
|
+
|
|
440
|
+
// Rebuild a fresh indexer from exactly what got captured (simulating a restart).
|
|
441
|
+
const branch = captured.map(({ type, data }) => ({ type: "custom", customType: type, data }));
|
|
442
|
+
const rebuilt = new ToolCallIndexer();
|
|
443
|
+
rebuilt.reconstructFromSession({ sessionManager: { getBranch: () => branch } } as any);
|
|
444
|
+
|
|
445
|
+
// Re-run with an honest appendEntry: records are already indexed, so this
|
|
446
|
+
// composes-and-persists the chain entry without re-backfilling.
|
|
447
|
+
const captured2: Array<{ type: string; data: unknown }> = [];
|
|
448
|
+
const round2 = await compressEligible([chainB], 0, {
|
|
449
|
+
indexer: rebuilt,
|
|
450
|
+
blockRefs,
|
|
451
|
+
appendEntry: (type, data) => captured2.push({ type, data }),
|
|
452
|
+
now: () => 1000,
|
|
453
|
+
messages,
|
|
454
|
+
diagnostics: noopDiagnostics,
|
|
455
|
+
backfill: testBackfill,
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
expect(round2.compressedEntries).toHaveLength(1);
|
|
459
|
+
expect(captured2.filter((c) => c.type === CUSTOM_TYPE_INDEX)).toHaveLength(0);
|
|
460
|
+
expect(captured2.filter((c) => c.type === CUSTOM_TYPE_CHAIN)).toHaveLength(1);
|
|
461
|
+
|
|
462
|
+
// Refs are identical to round 1's - the index entry (with its refs) never
|
|
463
|
+
// got re-persisted, so round 2 reused the durable ref from round 1.
|
|
464
|
+
const round1IndexEntry = captured.find((c) => c.type === CUSTOM_TYPE_INDEX)!.data as any;
|
|
465
|
+
const round1Refs = round1IndexEntry.refs.map((r: { shortId: string }) => r.shortId);
|
|
466
|
+
expect(round2.compressedEntries[0].toolRefs).toEqual(round1Refs);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("multi-chain compact: three uncovered chains compress in one compressEligible call", async () => {
|
|
470
|
+
const indexer = new ToolCallIndexer();
|
|
471
|
+
const blockRefs = new BlockRefIssuer();
|
|
472
|
+
|
|
473
|
+
const messages: any[] = [];
|
|
474
|
+
const chains: ChainRange[] = [];
|
|
475
|
+
for (let i = 0; i < 3; i++) {
|
|
476
|
+
const base = 1000 + i * 1000;
|
|
477
|
+
const startTs = base;
|
|
478
|
+
const callTs = base + 100;
|
|
479
|
+
const resultTs = base + 110;
|
|
480
|
+
const finalTs = base + 400;
|
|
481
|
+
const id = `tc${i}`;
|
|
482
|
+
messages.push(
|
|
483
|
+
{ role: "user", content: [{ type: "text", text: `go ${i}` }], timestamp: startTs },
|
|
484
|
+
{ role: "assistant", content: [{ type: "toolCall", id, name: "bash", arguments: { cmd: id } }], timestamp: callTs, usage: {}, stopReason: "tool_use" },
|
|
485
|
+
{ role: "toolResult", toolCallId: id, toolName: "bash", content: [{ type: "text", text: `out ${i}` }], isError: false, timestamp: resultTs },
|
|
486
|
+
{ role: "assistant", content: [{ type: "text", text: `done ${i}` }], timestamp: finalTs, usage: {}, stopReason: "end_turn" },
|
|
487
|
+
);
|
|
488
|
+
chains.push({
|
|
489
|
+
startUserTimestamp: startTs,
|
|
490
|
+
middleToolCallIds: [id],
|
|
491
|
+
middleOccurrenceKeys: [occKey(id, resultTs)],
|
|
492
|
+
finalAssistantTimestamp: finalTs,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const { compressedEntries } = await compressEligible(chains, 0, {
|
|
497
|
+
indexer,
|
|
498
|
+
blockRefs,
|
|
499
|
+
appendEntry: () => {},
|
|
500
|
+
now: () => 9999,
|
|
501
|
+
messages,
|
|
502
|
+
diagnostics: noopDiagnostics,
|
|
503
|
+
backfill: testBackfill,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
expect(compressedEntries).toHaveLength(3);
|
|
507
|
+
expect(compressedEntries.every((e) => e.bodySource === "deterministic")).toBe(true);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("fully-deduped chain: backfilled via index-membership filter (not isSummarized), compressed, pre-existing alias entries still resolve", async () => {
|
|
511
|
+
const indexer = new ToolCallIndexer();
|
|
512
|
+
const blockRefs = new BlockRefIssuer();
|
|
513
|
+
|
|
514
|
+
// Originals indexed in an earlier flush (as if summarized then).
|
|
515
|
+
indexer.addBatch(
|
|
516
|
+
{
|
|
517
|
+
turnIndex: 0,
|
|
518
|
+
timestamp: 10,
|
|
519
|
+
assistantText: "",
|
|
520
|
+
toolCalls: [
|
|
521
|
+
{ toolCallId: "origA", toolName: "bash", args: {}, resultText: "SAME_A", isError: false, resultTimestamp: 20 },
|
|
522
|
+
{ toolCallId: "origB", toolName: "bash", args: {}, resultText: "SAME_B", isError: false, resultTimestamp: 21 },
|
|
523
|
+
],
|
|
524
|
+
},
|
|
525
|
+
() => {},
|
|
526
|
+
);
|
|
527
|
+
indexer.registerSummaryBody([occKey("origA", 20), occKey("origB", 21)], "summary of originals");
|
|
528
|
+
indexer.registerSummaryRefs([
|
|
529
|
+
{ shortId: "t1", toolCallId: "origA", resultTimestamp: 20 },
|
|
530
|
+
{ shortId: "t2", toolCallId: "origB", resultTimestamp: 21 },
|
|
531
|
+
]);
|
|
532
|
+
|
|
533
|
+
// Pre-existing, unrelated duplicate of origA (registered before the chain
|
|
534
|
+
// under test even ran) - the control used below to prove the chain's own
|
|
535
|
+
// backfill does not disturb unrelated alias entries.
|
|
536
|
+
indexer.registerDuplicate(occKey("dup3", 30), occKey("origA", 20), () => {});
|
|
537
|
+
|
|
538
|
+
// The chain's own middle calls (tc1, tc2) were FULLY deduped by the
|
|
539
|
+
// pre-flush content-hash pass: both matched already-indexed originals,
|
|
540
|
+
// so neither was ever sent to the summarizer and neither has a direct
|
|
541
|
+
// index entry - only a dedup-alias entry pointing at origA/origB.
|
|
542
|
+
indexer.registerDuplicate(occKey("tc1", 110), occKey("origA", 20), () => {});
|
|
543
|
+
indexer.registerDuplicate(occKey("tc2", 120), occKey("origB", 21), () => {});
|
|
544
|
+
|
|
545
|
+
// isSummarized is true (dedup-alias hit) but the record is NOT in the
|
|
546
|
+
// index map directly - this is exactly the distinction the backfill
|
|
547
|
+
// filter (index membership) must honor instead of isSummarized().
|
|
548
|
+
expect(indexer.isSummarized(occKey("tc1", 110))).toBe(true);
|
|
549
|
+
expect(indexer.getIndex().has(occKey("tc1", 110))).toBe(false);
|
|
550
|
+
expect(indexer.isSummarized(occKey("tc2", 120))).toBe(true);
|
|
551
|
+
expect(indexer.getIndex().has(occKey("tc2", 120))).toBe(false);
|
|
552
|
+
|
|
553
|
+
const messages: any[] = [
|
|
554
|
+
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 },
|
|
555
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 105, usage: {}, stopReason: "tool_use" },
|
|
556
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "SAME_A" }], isError: false, timestamp: 110 },
|
|
557
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tc2", name: "bash", arguments: {} }], timestamp: 115, usage: {}, stopReason: "tool_use" },
|
|
558
|
+
{ role: "toolResult", toolCallId: "tc2", toolName: "bash", content: [{ type: "text", text: "SAME_B" }], isError: false, timestamp: 120 },
|
|
559
|
+
{ role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 200, usage: {}, stopReason: "end_turn" },
|
|
560
|
+
];
|
|
561
|
+
const chain: ChainRange = {
|
|
562
|
+
startUserTimestamp: 100,
|
|
563
|
+
middleToolCallIds: ["tc1", "tc2"],
|
|
564
|
+
middleOccurrenceKeys: [occKey("tc1", 110), occKey("tc2", 120)],
|
|
565
|
+
finalAssistantTimestamp: 200,
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
// Zero coverage: no summary body was ever registered for tc1/tc2's own keys.
|
|
569
|
+
expect(indexer.hasPerBatchSummaryCoveringAny([occKey("tc1", 110), occKey("tc2", 120)])).toBe(false);
|
|
570
|
+
|
|
571
|
+
const { compressedEntries } = await compressEligible([chain], 0, {
|
|
572
|
+
indexer,
|
|
573
|
+
blockRefs,
|
|
574
|
+
appendEntry: () => {},
|
|
575
|
+
now: () => 1,
|
|
576
|
+
messages,
|
|
577
|
+
diagnostics: noopDiagnostics,
|
|
578
|
+
backfill: testBackfill,
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
expect(compressedEntries).toHaveLength(1);
|
|
582
|
+
expect(compressedEntries[0].bodySource).toBe("deterministic");
|
|
583
|
+
|
|
584
|
+
// tc1/tc2 are now directly backfilled (index-membership filter let them through).
|
|
585
|
+
expect(indexer.getRecord(occKey("tc1", 110))?.resultText).toBe("SAME_A");
|
|
586
|
+
expect(indexer.getRecord(occKey("tc2", 120))?.resultText).toBe("SAME_B");
|
|
587
|
+
|
|
588
|
+
// Pre-existing, unrelated alias entry (dup3 -> origA) still resolves unchanged.
|
|
589
|
+
expect(indexer.resolveToolCallId(occKey("dup3", 30))).toBe(occKey("origA", 20));
|
|
590
|
+
expect(indexer.getRecord(occKey("dup3", 30))?.resultText).toBe("SAME_A");
|
|
591
|
+
|
|
592
|
+
// Backfilled records never seed contentHashToOriginal - the canonical for
|
|
593
|
+
// "SAME_A"/"SAME_B" content stays origA/origB, unpoisoned by tc1/tc2.
|
|
594
|
+
expect(indexer.lookupByContent("bash", "SAME_A")).toBe(occKey("origA", 20));
|
|
595
|
+
expect(indexer.lookupByContent("bash", "SAME_B")).toBe(occKey("origB", 21));
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("protected member in an uncovered chain: excluded from backfill index+refs, relocated verbatim at render, unprotected middle dropped", async () => {
|
|
599
|
+
const indexer = new ToolCallIndexer();
|
|
600
|
+
const blockRefs = new BlockRefIssuer();
|
|
601
|
+
|
|
602
|
+
const messages: any[] = [
|
|
603
|
+
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1000 },
|
|
604
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tcB1", name: "bash", arguments: { cmd: "one" } }], timestamp: 1100, usage: {}, stopReason: "tool_use" },
|
|
605
|
+
{ role: "toolResult", toolCallId: "tcB1", toolName: "bash", content: [{ type: "text", text: "outB1" }], isError: false, timestamp: 1110 },
|
|
606
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tcB2", name: "todowrite", arguments: {} }], timestamp: 1200, usage: {}, stopReason: "tool_use" },
|
|
607
|
+
{ role: "toolResult", toolCallId: "tcB2", toolName: "todowrite", content: [{ type: "text", text: "PLAN-STATE-XYZ" }], isError: false, timestamp: 1210 },
|
|
608
|
+
{ role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 1400, usage: {}, stopReason: "end_turn" },
|
|
609
|
+
];
|
|
610
|
+
const chainB: ChainRange = {
|
|
611
|
+
startUserTimestamp: 1000,
|
|
612
|
+
middleToolCallIds: ["tcB1", "tcB2"],
|
|
613
|
+
middleOccurrenceKeys: [occKey("tcB1", 1110), occKey("tcB2", 1210)],
|
|
614
|
+
finalAssistantTimestamp: 1400,
|
|
615
|
+
protectedToolCallIds: ["tcB2"],
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const { compressedEntries } = await compressEligible([chainB], 0, {
|
|
619
|
+
indexer,
|
|
620
|
+
blockRefs,
|
|
621
|
+
appendEntry: () => {},
|
|
622
|
+
now: () => 999,
|
|
623
|
+
messages,
|
|
624
|
+
diagnostics: noopDiagnostics,
|
|
625
|
+
backfill: testBackfill,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
expect(compressedEntries).toHaveLength(1);
|
|
629
|
+
const entry = compressedEntries[0];
|
|
630
|
+
expect(entry.bodySource).toBe("deterministic");
|
|
631
|
+
|
|
632
|
+
// (a) protected id absent from the backfilled index entry's records and from toolRefs.
|
|
633
|
+
expect(indexer.getRecord(occKey("tcB2", 1210))).toBeUndefined();
|
|
634
|
+
expect(indexer.getRecord(occKey("tcB1", 1110))).toBeDefined();
|
|
635
|
+
expect(entry.toolRefs).toHaveLength(1);
|
|
636
|
+
expect(entry.toolRefs.map((r) => indexer.resolveToolCallId(r))).not.toContain(occKey("tcB2", 1210));
|
|
637
|
+
|
|
638
|
+
// (b) + (c) render: protected relocated verbatim inside the compressed-chain block; unprotected middle dropped.
|
|
639
|
+
const cc: ChainCompressionConfig = { enabled: true, rollingWindow: 0, stripFinalAssistantThinking: true, fuseRangeSummary: false };
|
|
640
|
+
const { messages: out } = pruneMessages(messages, indexer, cc);
|
|
641
|
+
const synthetic = out.find(
|
|
642
|
+
(m: any) => m.role === "user" && typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"),
|
|
643
|
+
);
|
|
644
|
+
expect(synthetic).toBeDefined();
|
|
645
|
+
expect(synthetic.content[0].text).toContain('<protected-output tool="todowrite">');
|
|
646
|
+
expect(synthetic.content[0].text).toContain("PLAN-STATE-XYZ");
|
|
647
|
+
expect(out.filter((m: any) => m.role === "toolResult")).toHaveLength(0);
|
|
648
|
+
expectNoOrphanToolResults(out);
|
|
649
|
+
});
|
|
257
650
|
});
|
package/src/spill.ts
CHANGED
|
@@ -36,6 +36,24 @@ interface SpillConfig {
|
|
|
36
36
|
dedupByContentHash: boolean;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
interface SpillableRecord {
|
|
40
|
+
toolName: string;
|
|
41
|
+
resultText: string;
|
|
42
|
+
spillBytes?: number;
|
|
43
|
+
resultPreview?: string;
|
|
44
|
+
spillPath?: string;
|
|
45
|
+
contentHash?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Mutates `record` in place: spillBytes/resultPreview/spillPath/contentHash set, resultText emptied. */
|
|
49
|
+
export function applySpill(record: SpillableRecord, spillPath: string, previewBytes: number): void {
|
|
50
|
+
record.spillBytes = Buffer.byteLength(record.resultText, "utf8");
|
|
51
|
+
record.resultPreview = headPreview(record.resultText, previewBytes);
|
|
52
|
+
record.spillPath = spillPath;
|
|
53
|
+
record.contentHash = hashToolResult(record.toolName, record.resultText);
|
|
54
|
+
record.resultText = "";
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
export async function spillOversizedBatch(args: {
|
|
40
58
|
batch: CapturedBatch;
|
|
41
59
|
indexer: ToolCallIndexer;
|
|
@@ -52,7 +70,6 @@ export async function spillOversizedBatch(args: {
|
|
|
52
70
|
if (tc.resultText.length < config.spillThreshold) continue;
|
|
53
71
|
|
|
54
72
|
const key = occKey(tc.toolCallId, tc.resultTimestamp);
|
|
55
|
-
const hash = hashToolResult(tc.toolName, tc.resultText);
|
|
56
73
|
|
|
57
74
|
if (config.dedupByContentHash) {
|
|
58
75
|
const original = indexer.lookupByContent(tc.toolName, tc.resultText);
|
|
@@ -72,11 +89,7 @@ export async function spillOversizedBatch(args: {
|
|
|
72
89
|
continue;
|
|
73
90
|
}
|
|
74
91
|
|
|
75
|
-
tc
|
|
76
|
-
tc.resultPreview = headPreview(tc.resultText, config.spillPreviewBytes);
|
|
77
|
-
tc.spillPath = path;
|
|
78
|
-
tc.contentHash = hash;
|
|
79
|
-
tc.resultText = "";
|
|
92
|
+
applySpill(tc, path, config.spillPreviewBytes);
|
|
80
93
|
toIndex.push(tc);
|
|
81
94
|
handled.add(tc.toolCallId);
|
|
82
95
|
}
|
package/src/types.ts
CHANGED
|
@@ -94,7 +94,7 @@ export const CUSTOM_TYPE_DIAGNOSTIC = "context-prune-diagnostic";
|
|
|
94
94
|
*/
|
|
95
95
|
export const CUSTOM_TYPE_FLUSH_METRICS = "context-prune-flush-metrics";
|
|
96
96
|
|
|
97
|
-
export type DiagnosticKind = "unresolved-range" | "range-id-mismatch" | "orphan-sweep";
|
|
97
|
+
export type DiagnosticKind = "unresolved-range" | "range-id-mismatch" | "orphan-sweep" | "backfill-empty";
|
|
98
98
|
|
|
99
99
|
export interface DiagnosticEntryData {
|
|
100
100
|
kind: DiagnosticKind;
|
|
@@ -239,7 +239,8 @@ export const SUMMARIZER_MAX_TIMEOUT_PRESETS: { value: string; label: string }[]
|
|
|
239
239
|
/**
|
|
240
240
|
* Cycling presets for the `autoBudgetThreshold` setting (stored as strings;
|
|
241
241
|
* the settings UI cycles string values). "0" is the disabled sentinel → null.
|
|
242
|
-
* Other values are 0–1 fractions of the context window (e.g. "0.8" = flush at
|
|
242
|
+
* Other values are 0–1 fractions of the context window (e.g. "0.8" = flush at
|
|
243
|
+
* 80% of the window, or at MAX_BUDGET_WINDOW tokens, whichever comes first).
|
|
243
244
|
*/
|
|
244
245
|
export const AUTO_BUDGET_PRESETS: { value: string; label: string }[] = [
|
|
245
246
|
{ value: "0", label: "Off (default)" },
|
|
@@ -387,10 +388,14 @@ export interface ContextPruneConfig {
|
|
|
387
388
|
/**
|
|
388
389
|
* Token-budget auto-flush trigger. A fraction in (0, 1] (a 0–1 share of the
|
|
389
390
|
* context window, NOT a 0–100 percentage; e.g. 0.8 = flush at 80% of the
|
|
390
|
-
* window). When set, a flush of all
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
391
|
+
* window, capped at 300k tokens - see below). When set, a flush of all
|
|
392
|
+
* pending batches is forced at the end of
|
|
393
|
+
* any tool-using turn once context usage reaches `threshold * contextWindow`
|
|
394
|
+
* tokens OR 300,000 tokens (MAX_BUDGET_WINDOW in src/budget.ts), whichever
|
|
395
|
+
* comes first — regardless of `pruneOn`. The ceiling keeps the setting
|
|
396
|
+
* reachable on huge-window models, where 0.9 of 1M would mean 900k tokens; it
|
|
397
|
+
* never binds on a model advertising 300k or less. An ADDITIONAL trigger on
|
|
398
|
+
* top of `pruneOn`, not a replacement.
|
|
394
399
|
*
|
|
395
400
|
* null (default) = disabled, preserving pre-feature behavior. Out-of-range
|
|
396
401
|
* values (<= 0 or > 1) normalize to null.
|
|
@@ -402,7 +407,11 @@ export interface ContextPruneConfig {
|
|
|
402
407
|
spillPreviewBytes: number;
|
|
403
408
|
/**
|
|
404
409
|
* Per-turn usage-fraction increase (0–1) that forces a flush, independent of
|
|
405
|
-
* autoBudgetThreshold.
|
|
410
|
+
* autoBudgetThreshold. The fraction is measured against the effective window
|
|
411
|
+
* `min(contextWindow, MAX_BUDGET_WINDOW)` (300_000), so the required growth is
|
|
412
|
+
* `delta * min(contextWindow, MAX_BUDGET_WINDOW)` tokens - e.g. 0.1 means +30k
|
|
413
|
+
* tokens in one turn on any model at or above 300k, and +20k on a 200k model.
|
|
414
|
+
* null (default) = disabled. Out-of-range (<= 0 or > 1) normalizes to null.
|
|
406
415
|
*/
|
|
407
416
|
budgetTurnDelta: number | null;
|
|
408
417
|
}
|
|
@@ -496,6 +505,12 @@ export interface ChainCompressionEntry {
|
|
|
496
505
|
* Absent on fusion failure / single-batch spans → renderer falls back to concat.
|
|
497
506
|
*/
|
|
498
507
|
rangeSummaryText?: string;
|
|
508
|
+
/**
|
|
509
|
+
* "deterministic" = zero-LLM synthetic body built by the uncovered-chain
|
|
510
|
+
* backfill path (rangeSummaryText holds the stub). Absent = LLM-fused or
|
|
511
|
+
* per-batch semantics, unchanged.
|
|
512
|
+
*/
|
|
513
|
+
bodySource?: "deterministic";
|
|
499
514
|
}
|
|
500
515
|
|
|
501
516
|
export interface ChainCompressionConfig {
|
|
@@ -631,6 +646,10 @@ export interface ToolCallRecord {
|
|
|
631
646
|
*/
|
|
632
647
|
export interface IndexEntryData {
|
|
633
648
|
toolCalls: ToolCallRecord[];
|
|
649
|
+
/** Entry written by backfillChainRecords: records must NOT seed contentHashToOriginal. */
|
|
650
|
+
backfilled?: true;
|
|
651
|
+
/** Refs allocated at backfill time; durable carrier for alias reconstruction. */
|
|
652
|
+
refs?: SummaryToolCallRef[];
|
|
634
653
|
}
|
|
635
654
|
|
|
636
655
|
/**
|