pi-condense 2.8.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 +9 -0
- package/PRUNING.md +20 -2
- package/README.md +8 -2
- package/index.ts +18 -1
- package/package.json +1 -1
- 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 +1 -0
- 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 +11 -1
|
@@ -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;
|
|
@@ -505,6 +505,12 @@ export interface ChainCompressionEntry {
|
|
|
505
505
|
* Absent on fusion failure / single-batch spans → renderer falls back to concat.
|
|
506
506
|
*/
|
|
507
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";
|
|
508
514
|
}
|
|
509
515
|
|
|
510
516
|
export interface ChainCompressionConfig {
|
|
@@ -640,6 +646,10 @@ export interface ToolCallRecord {
|
|
|
640
646
|
*/
|
|
641
647
|
export interface IndexEntryData {
|
|
642
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[];
|
|
643
653
|
}
|
|
644
654
|
|
|
645
655
|
/**
|