pi-condense 2.9.2 → 2.10.1
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 +11 -3
- package/README.md +1 -0
- package/index.ts +24 -28
- package/package.json +1 -1
- package/src/batch-capture.test.ts +116 -1
- package/src/batch-capture.ts +47 -16
- package/src/budget.test.ts +21 -1
- package/src/budget.ts +10 -0
- package/src/chain-compressor.test.ts +17 -0
- package/src/chain-detector.test.ts +77 -1
- package/src/chain-detector.ts +26 -7
- package/src/chain-range-prune.test.ts +83 -0
- package/src/chain-range-prune.ts +7 -5
- package/src/config.test.ts +22 -0
- package/src/config.ts +6 -0
- package/src/context-metrics.test.ts +35 -5
- package/src/context-metrics.ts +5 -2
- package/src/oversized-spill.integration.test.ts +49 -1
- package/src/reload-rearm.integration.test.ts +356 -18
- package/src/spill.test.ts +71 -1
- package/src/spill.ts +13 -1
- package/src/types.ts +17 -3
|
@@ -49,6 +49,18 @@ function okStream() {
|
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Classified "transient" by runOnce (src/summarizer.ts) — with
|
|
53
|
+
// summarizerModel: "default" (no distinct fallback model) this yields a
|
|
54
|
+
// null SummarizeResult after exactly one stream() call, no retries.
|
|
55
|
+
function errStream(message: string) {
|
|
56
|
+
return {
|
|
57
|
+
async *[Symbol.asyncIterator]() {},
|
|
58
|
+
async result() {
|
|
59
|
+
return { stopReason: "error", errorMessage: message, content: [], usage: USAGE };
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
52
64
|
let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
|
|
53
65
|
summarizerCalls++;
|
|
54
66
|
return okStream();
|
|
@@ -117,6 +129,26 @@ function closedChainBranch(count: number): any[] {
|
|
|
117
129
|
return msgs;
|
|
118
130
|
}
|
|
119
131
|
|
|
132
|
+
// Builds one independent pending batch: user -> assistant toolCall -> toolResult,
|
|
133
|
+
// with no closing text-only assistant (chain stays open, matching defaultBranch's
|
|
134
|
+
// shape). Used by the frontier-gap tests below to grow/shrink the branch's
|
|
135
|
+
// un-pruned tail across turns by direct array mutation (bootExtension returns
|
|
136
|
+
// the live `branch` array reference, so pushing onto it after boot is visible
|
|
137
|
+
// to every later getBranch() call).
|
|
138
|
+
function pendingBatchEntries(toolCallId: string, text: string, timestamp: number): any[] {
|
|
139
|
+
return [
|
|
140
|
+
{ type: "message", message: { role: "user", content: [{ type: "text", text: `do ${toolCallId}` }], timestamp } },
|
|
141
|
+
{
|
|
142
|
+
type: "message",
|
|
143
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: toolCallId, name: "read", arguments: {} }], timestamp: timestamp + 500 },
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
type: "message",
|
|
147
|
+
message: { role: "toolResult", toolCallId, toolName: "read", content: [{ type: "text", text }], timestamp: timestamp + 1000 },
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
|
|
120
152
|
// Boots a fresh index.ts extension instance against an isolated agent dir +
|
|
121
153
|
// session, mirroring the fixtures shared across the three scenarios below.
|
|
122
154
|
//
|
|
@@ -137,35 +169,45 @@ function closedChainBranch(count: number): any[] {
|
|
|
137
169
|
function bootExtension(
|
|
138
170
|
options: {
|
|
139
171
|
chainCompressionEnabled?: boolean;
|
|
172
|
+
rollingWindow?: number;
|
|
140
173
|
separatePiAppended?: boolean;
|
|
141
174
|
piAppendEntry?: (push: (type: string, data?: unknown) => void) => (type: string, data?: unknown) => void;
|
|
142
175
|
sessionAppendCustomEntry?: (push: (type: string, data?: unknown) => void) => (type: string, data?: unknown) => string;
|
|
143
176
|
branch?: any[];
|
|
144
177
|
protectedTools?: string[];
|
|
178
|
+
autoBudgetThreshold?: number | null;
|
|
179
|
+
budgetTurnDelta?: number | null;
|
|
180
|
+
frontierGapThresholdTokens?: number | null;
|
|
145
181
|
} = {},
|
|
146
182
|
) {
|
|
147
183
|
const agentDir = mkdtempSync(join(tmpdir(), "pi-condense-rearm-"));
|
|
148
184
|
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
185
|
+
const contextPruneSettings: any = {
|
|
186
|
+
enabled: true,
|
|
187
|
+
pruneOn: "agent-message",
|
|
188
|
+
batchingMode: "agent-message",
|
|
189
|
+
autoBudgetThreshold: options.autoBudgetThreshold === undefined ? 0.5 : options.autoBudgetThreshold,
|
|
190
|
+
summarizerModel: "default",
|
|
191
|
+
minBatchChars: 1,
|
|
192
|
+
showPruneStatusLine: true,
|
|
193
|
+
protectedTools: options.protectedTools ?? [],
|
|
194
|
+
chainCompression: {
|
|
195
|
+
enabled: options.chainCompressionEnabled ?? false,
|
|
196
|
+
rollingWindow: options.rollingWindow ?? 3,
|
|
197
|
+
stripFinalAssistantThinking: true,
|
|
198
|
+
fuseRangeSummary: true,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
// Omitted unless the test explicitly passes them, so the "default-null
|
|
202
|
+
// inert" scenario can assert behavior with no key present at all (not an
|
|
203
|
+
// explicit null), matching config.ts's own default.
|
|
204
|
+
if (options.budgetTurnDelta !== undefined) contextPruneSettings.budgetTurnDelta = options.budgetTurnDelta;
|
|
205
|
+
if (options.frontierGapThresholdTokens !== undefined) {
|
|
206
|
+
contextPruneSettings.frontierGapThresholdTokens = options.frontierGapThresholdTokens;
|
|
207
|
+
}
|
|
149
208
|
writeFileSync(
|
|
150
209
|
join(agentDir, "settings.json"),
|
|
151
|
-
JSON.stringify({
|
|
152
|
-
contextPrune: {
|
|
153
|
-
enabled: true,
|
|
154
|
-
pruneOn: "agent-message",
|
|
155
|
-
batchingMode: "agent-message",
|
|
156
|
-
autoBudgetThreshold: 0.5,
|
|
157
|
-
summarizerModel: "default",
|
|
158
|
-
minBatchChars: 1,
|
|
159
|
-
showPruneStatusLine: true,
|
|
160
|
-
protectedTools: options.protectedTools ?? [],
|
|
161
|
-
chainCompression: {
|
|
162
|
-
enabled: options.chainCompressionEnabled ?? false,
|
|
163
|
-
rollingWindow: 3,
|
|
164
|
-
stripFinalAssistantThinking: true,
|
|
165
|
-
fuseRangeSummary: true,
|
|
166
|
-
},
|
|
167
|
-
},
|
|
168
|
-
}),
|
|
210
|
+
JSON.stringify({ contextPrune: contextPruneSettings }),
|
|
169
211
|
);
|
|
170
212
|
|
|
171
213
|
const sessionDir = mkdtempSync(join(tmpdir(), "pi-condense-rearm-session-"));
|
|
@@ -577,4 +619,300 @@ describe("reload rearm (issue #6)", () => {
|
|
|
577
619
|
expect(text).toContain(`chain share: ${expectedPct}%`);
|
|
578
620
|
expect(expectedPct).toBeLessThan(inflatedPct);
|
|
579
621
|
});
|
|
622
|
+
|
|
623
|
+
it("feeds persisted custom_message steers into chain detection via the shared projection (#13)", async () => {
|
|
624
|
+
// A production-feed regression: chain detection/compaction/metrics must
|
|
625
|
+
// all see custom_message entries projected as role "custom" (src/batch-
|
|
626
|
+
// capture.ts projectBranchMessages), not just plain "message" entries.
|
|
627
|
+
// A non-pruner customType (isChainAnchorCustom) opens a chain while idle
|
|
628
|
+
// and anchors resolveRange the same way a user message does; a pruner-
|
|
629
|
+
// namespaced customType (context-prune-*) must NOT anchor one.
|
|
630
|
+
const t0 = new Date().toISOString();
|
|
631
|
+
let t = new Date(t0).getTime();
|
|
632
|
+
|
|
633
|
+
const customAnchoredChain: any[] = [
|
|
634
|
+
{
|
|
635
|
+
type: "custom_message",
|
|
636
|
+
customType: "pi-gauntlet-transition-recovery",
|
|
637
|
+
content: [{ type: "text", text: "continue" }],
|
|
638
|
+
timestamp: t0,
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
type: "message",
|
|
642
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-custom", name: "read", arguments: {} }] },
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
type: "message",
|
|
646
|
+
message: {
|
|
647
|
+
role: "toolResult",
|
|
648
|
+
toolCallId: "tc-custom",
|
|
649
|
+
toolName: "read",
|
|
650
|
+
content: [{ type: "text", text: "x".repeat(400) }],
|
|
651
|
+
timestamp: (t += 1000),
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
type: "message",
|
|
656
|
+
message: { role: "assistant", content: [{ type: "text", text: "done custom" }], timestamp: (t += 1000) },
|
|
657
|
+
},
|
|
658
|
+
];
|
|
659
|
+
|
|
660
|
+
// A second closed, user-anchored chain so the custom-anchored chain above
|
|
661
|
+
// is not the newest/frontier chain (rollingWindow: 0 makes every closed
|
|
662
|
+
// chain not already compressed eligible regardless, but this mirrors a
|
|
663
|
+
// realistic multi-turn session and rules out any "only chain" special case).
|
|
664
|
+
const trailingChain: any[] = [
|
|
665
|
+
{ type: "message", message: { role: "user", content: [{ type: "text", text: "do more" }], timestamp: (t += 1000) } },
|
|
666
|
+
{
|
|
667
|
+
type: "message",
|
|
668
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-trail", name: "read", arguments: {} }] },
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
type: "message",
|
|
672
|
+
message: {
|
|
673
|
+
role: "toolResult",
|
|
674
|
+
toolCallId: "tc-trail",
|
|
675
|
+
toolName: "read",
|
|
676
|
+
content: [{ type: "text", text: "y".repeat(400) }],
|
|
677
|
+
timestamp: (t += 1000),
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
type: "message",
|
|
682
|
+
message: { role: "assistant", content: [{ type: "text", text: "done trailing" }], timestamp: (t += 1000) },
|
|
683
|
+
},
|
|
684
|
+
];
|
|
685
|
+
|
|
686
|
+
const branch = [...customAnchoredChain, ...trailingChain];
|
|
687
|
+
|
|
688
|
+
const { handlers, ctx, appended } = await boot({ chainCompressionEnabled: true, rollingWindow: 0, branch });
|
|
689
|
+
|
|
690
|
+
await handlers.get("session_start")!({}, ctx);
|
|
691
|
+
await handlers.get("turn_end")!(
|
|
692
|
+
{ toolResults: [], message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, turnIndex: 2 },
|
|
693
|
+
ctx,
|
|
694
|
+
);
|
|
695
|
+
await handlers.get("message_end")!(
|
|
696
|
+
{ message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
|
|
697
|
+
ctx,
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
const chainEntries = appended.filter((e) => e.type === "context-prune-chain");
|
|
701
|
+
const anchoredAtCustom = chainEntries.find(
|
|
702
|
+
(e) => (e.data as any).startUserTimestamp === new Date(t0).getTime(),
|
|
703
|
+
);
|
|
704
|
+
expect(anchoredAtCustom).toBeDefined();
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it("does not let a pruner-namespaced custom_message (context-prune-*) anchor a chain (#13)", async () => {
|
|
708
|
+
const t0 = new Date().toISOString();
|
|
709
|
+
let t = new Date(t0).getTime();
|
|
710
|
+
|
|
711
|
+
const pruneSummaryEntry: any[] = [
|
|
712
|
+
{
|
|
713
|
+
type: "custom_message",
|
|
714
|
+
customType: "context-prune-summary",
|
|
715
|
+
content: [{ type: "text", text: "prior summary" }],
|
|
716
|
+
timestamp: t0,
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
type: "message",
|
|
720
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-p", name: "read", arguments: {} }] },
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
type: "message",
|
|
724
|
+
message: {
|
|
725
|
+
role: "toolResult",
|
|
726
|
+
toolCallId: "tc-p",
|
|
727
|
+
toolName: "read",
|
|
728
|
+
content: [{ type: "text", text: "x".repeat(400) }],
|
|
729
|
+
timestamp: (t += 1000),
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
type: "message",
|
|
734
|
+
message: { role: "assistant", content: [{ type: "text", text: "done p" }], timestamp: (t += 1000) },
|
|
735
|
+
},
|
|
736
|
+
];
|
|
737
|
+
|
|
738
|
+
const { handlers, ctx, appended } = await boot({ chainCompressionEnabled: true, rollingWindow: 0, branch: pruneSummaryEntry });
|
|
739
|
+
|
|
740
|
+
await handlers.get("session_start")!({}, ctx);
|
|
741
|
+
await handlers.get("turn_end")!(
|
|
742
|
+
{ toolResults: [], message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, turnIndex: 2 },
|
|
743
|
+
ctx,
|
|
744
|
+
);
|
|
745
|
+
await handlers.get("message_end")!(
|
|
746
|
+
{ message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
|
|
747
|
+
ctx,
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
const chainEntries = appended.filter((e) => e.type === "context-prune-chain");
|
|
751
|
+
const anchoredAtSummary = chainEntries.find(
|
|
752
|
+
(e) => (e.data as any).startUserTimestamp === new Date(t0).getTime(),
|
|
753
|
+
);
|
|
754
|
+
expect(anchoredAtSummary).toBeUndefined();
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("frontier-gap trigger fires at turn_end when the un-pruned tail exceeds the threshold (#13)", async () => {
|
|
758
|
+
const { handlers, ctx, notifications, appended } = await boot({
|
|
759
|
+
autoBudgetThreshold: null,
|
|
760
|
+
frontierGapThresholdTokens: 10,
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
764
|
+
|
|
765
|
+
await handlers.get("session_start")!({}, ctx);
|
|
766
|
+
|
|
767
|
+
// defaultBranch() already carries an unsummarized ~400-char toolResult
|
|
768
|
+
// (~100 tokens), well past the threshold of 10.
|
|
769
|
+
await handlers.get("turn_end")!(
|
|
770
|
+
{
|
|
771
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "read", arguments: {} }] },
|
|
772
|
+
toolResults: [
|
|
773
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() },
|
|
774
|
+
],
|
|
775
|
+
turnIndex: 2,
|
|
776
|
+
},
|
|
777
|
+
ctx,
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
|
|
781
|
+
expect(flushMetricsEntries.length).toBe(1);
|
|
782
|
+
const fm = flushMetricsEntries[0].data as any;
|
|
783
|
+
expect(fm.trigger).toBe("frontier-gap");
|
|
784
|
+
expect(fm.metrics.frontierGapTokens).toBeGreaterThanOrEqual(10);
|
|
785
|
+
|
|
786
|
+
expect(notifications.some((n) => n.includes("un-pruned tail exceeded frontier gap threshold"))).toBe(true);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it("budget trigger takes precedence over frontier-gap when both conditions are met at turn_end (#13)", async () => {
|
|
790
|
+
const { handlers, ctx, appended } = await boot({
|
|
791
|
+
autoBudgetThreshold: 0.5,
|
|
792
|
+
frontierGapThresholdTokens: 10,
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
await handlers.get("session_start")!({}, ctx);
|
|
796
|
+
|
|
797
|
+
// Usage fraction 0.9 crosses the 0.5 budget threshold; defaultBranch()'s
|
|
798
|
+
// un-pruned tail also crosses the 10-token gap threshold. Budget must win.
|
|
799
|
+
ctx.getContextUsage = () => ({ tokens: 900000, contextWindow: 1000000 });
|
|
800
|
+
|
|
801
|
+
await handlers.get("turn_end")!(
|
|
802
|
+
{
|
|
803
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "read", arguments: {} }] },
|
|
804
|
+
toolResults: [
|
|
805
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() },
|
|
806
|
+
],
|
|
807
|
+
turnIndex: 2,
|
|
808
|
+
},
|
|
809
|
+
ctx,
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
|
|
813
|
+
expect(flushMetricsEntries.length).toBe(1);
|
|
814
|
+
expect((flushMetricsEntries[0].data as any).trigger).toBe("budget");
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it("frontier-gap trigger stays inert when frontierGapThresholdTokens is unset (default null), even with a huge un-pruned tail (#13)", async () => {
|
|
818
|
+
const { handlers, ctx, appended } = await boot({
|
|
819
|
+
autoBudgetThreshold: null,
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
await handlers.get("session_start")!({}, ctx);
|
|
823
|
+
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
824
|
+
|
|
825
|
+
await handlers.get("turn_end")!(
|
|
826
|
+
{
|
|
827
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "read", arguments: {} }] },
|
|
828
|
+
toolResults: [
|
|
829
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() },
|
|
830
|
+
],
|
|
831
|
+
turnIndex: 2,
|
|
832
|
+
},
|
|
833
|
+
ctx,
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
expect(appended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("frontier-gap cadence: a partial-failure flush persists the surviving prefix and advances the frontier; the next gap-triggered flush advances it further (#13)", async () => {
|
|
840
|
+
const { handlers, ctx, appended } = await boot({
|
|
841
|
+
autoBudgetThreshold: null,
|
|
842
|
+
frontierGapThresholdTokens: 10,
|
|
843
|
+
branch: [],
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
await handlers.get("session_start")!({}, ctx);
|
|
847
|
+
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
848
|
+
|
|
849
|
+
// Turn 1: branch still empty -> frontierGapTokens is 0 -> no flush, even
|
|
850
|
+
// though this turn's own toolResults are pushed into pendingBatches.
|
|
851
|
+
await handlers.get("turn_end")!(
|
|
852
|
+
{
|
|
853
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-warmup", name: "read", arguments: {} }] },
|
|
854
|
+
toolResults: [
|
|
855
|
+
{ role: "toolResult", toolCallId: "tc-warmup", toolName: "read", content: [{ type: "text", text: "w".repeat(400) }], timestamp: Date.now() },
|
|
856
|
+
],
|
|
857
|
+
turnIndex: 1,
|
|
858
|
+
},
|
|
859
|
+
ctx,
|
|
860
|
+
);
|
|
861
|
+
expect(appended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
|
|
862
|
+
|
|
863
|
+
// Grow the branch with two independent unindexed batches (tc-a, tc-b) —
|
|
864
|
+
// capturePendingBatches rescans the branch, not the in-memory queue, so
|
|
865
|
+
// this is what actually makes the upcoming flush see two batches.
|
|
866
|
+
let t = Date.now();
|
|
867
|
+
ctx.sessionManager.getBranch().push(...pendingBatchEntries("tc-a", "a".repeat(400), (t += 1000)));
|
|
868
|
+
ctx.sessionManager.getBranch().push(...pendingBatchEntries("tc-b", "b".repeat(400), (t += 1000)));
|
|
869
|
+
|
|
870
|
+
let callCount = 0;
|
|
871
|
+
streamImpl = () => {
|
|
872
|
+
callCount++;
|
|
873
|
+
summarizerCalls++;
|
|
874
|
+
if (callCount === 2) return errStream("simulated summarizer failure on second batch");
|
|
875
|
+
return okStream();
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
// Turn 2: gap now over threshold (tc-a + tc-b unsummarized) -> flush fires,
|
|
879
|
+
// processes tc-a successfully, tc-b's summarization call fails.
|
|
880
|
+
await handlers.get("turn_end")!(
|
|
881
|
+
{
|
|
882
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-a", name: "read", arguments: {} }] },
|
|
883
|
+
toolResults: [
|
|
884
|
+
{ role: "toolResult", toolCallId: "tc-a", toolName: "read", content: [{ type: "text", text: "a".repeat(400) }], timestamp: Date.now() },
|
|
885
|
+
],
|
|
886
|
+
turnIndex: 2,
|
|
887
|
+
},
|
|
888
|
+
ctx,
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
let frontierEntries = appended.filter((e) => e.type === "context-prune-frontier");
|
|
892
|
+
expect(frontierEntries.length).toBe(1);
|
|
893
|
+
const firstFrontier = frontierEntries[0].data as any;
|
|
894
|
+
expect(firstFrontier.lastAttemptedToolCallId).toBe("tc-a");
|
|
895
|
+
|
|
896
|
+
// Grow the branch again (tc-b is still unsummarized/pending after the
|
|
897
|
+
// restore; add tc-c as this turn's new work) — gap stays over threshold.
|
|
898
|
+
ctx.sessionManager.getBranch().push(...pendingBatchEntries("tc-c", "c".repeat(400), (t += 1000)));
|
|
899
|
+
|
|
900
|
+
// Turn 3: gap still over threshold -> flush fires again, this time both
|
|
901
|
+
// tc-b (restored) and tc-c succeed (streamImpl only fails on call #2).
|
|
902
|
+
await handlers.get("turn_end")!(
|
|
903
|
+
{
|
|
904
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-c", name: "read", arguments: {} }] },
|
|
905
|
+
toolResults: [
|
|
906
|
+
{ role: "toolResult", toolCallId: "tc-c", toolName: "read", content: [{ type: "text", text: "c".repeat(400) }], timestamp: Date.now() },
|
|
907
|
+
],
|
|
908
|
+
turnIndex: 3,
|
|
909
|
+
},
|
|
910
|
+
ctx,
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
frontierEntries = appended.filter((e) => e.type === "context-prune-frontier");
|
|
914
|
+
expect(frontierEntries.length).toBe(2);
|
|
915
|
+
const secondFrontier = frontierEntries[1].data as any;
|
|
916
|
+
expect(secondFrontier.lastAttemptedTimestamp).toBeGreaterThan(firstFrontier.lastAttemptedTimestamp);
|
|
917
|
+
});
|
|
580
918
|
});
|
package/src/spill.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
2
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { join, basename } from "node:path";
|
|
5
5
|
import { sanitizeId, blobDirFor, blobPathFor, headPreview, spillOversizedBatch } from "./spill.js";
|
|
6
6
|
import { ToolCallIndexer } from "./indexer.js";
|
|
7
7
|
import { registerQueryTool } from "./query-tool.js";
|
|
@@ -24,6 +24,41 @@ describe("blobDirFor / blobPathFor", () => {
|
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
+
describe("blobPathFor byte cap (gh-14)", () => {
|
|
28
|
+
const nameBytes = (p: string) => Buffer.byteLength(basename(p), "utf8");
|
|
29
|
+
|
|
30
|
+
it("251-byte sanitized base keeps today's formula (AC5 boundary, just-under)", () => {
|
|
31
|
+
const id = "a".repeat(251);
|
|
32
|
+
const p = blobPathFor("/s", "sid", id);
|
|
33
|
+
expect(p).toBe(join("/s", "sid-blobs", `${id}.txt`));
|
|
34
|
+
expect(nameBytes(p)).toBe(255);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("252-byte sanitized base is capped to exactly 255 bytes (AC5 boundary, just-over)", () => {
|
|
38
|
+
const p = blobPathFor("/s", "sid", "a".repeat(252));
|
|
39
|
+
expect(nameBytes(p)).toBe(255);
|
|
40
|
+
expect(basename(p)).toMatch(/^a{234}\.[0-9a-f]{16}\.txt$/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("is deterministic: same long key -> identical path", () => {
|
|
44
|
+
const key = "x".repeat(500);
|
|
45
|
+
expect(blobPathFor("/s", "sid", key)).toBe(blobPathFor("/s", "sid", key));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("two long ids sharing the first 300 chars map to distinct filenames (AC3)", () => {
|
|
49
|
+
const a = "t".repeat(300) + "A".repeat(200);
|
|
50
|
+
const b = "t".repeat(300) + "B".repeat(200);
|
|
51
|
+
expect(blobPathFor("/s", "sid", a)).not.toBe(blobPathFor("/s", "sid", b));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("hashes the unsanitized key: long ids that sanitize identically stay distinct", () => {
|
|
55
|
+
const a = "p".repeat(300) + "/x";
|
|
56
|
+
const b = "p".repeat(300) + "\\x";
|
|
57
|
+
expect(sanitizeId(a)).toBe(sanitizeId(b));
|
|
58
|
+
expect(blobPathFor("/s", "sid", a)).not.toBe(blobPathFor("/s", "sid", b));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
27
62
|
describe("headPreview", () => {
|
|
28
63
|
it("returns the whole string when under the byte cap", () => {
|
|
29
64
|
expect(headPreview("hello", 1024)).toBe("hello");
|
|
@@ -206,4 +241,39 @@ describe("spillOversizedBatch", () => {
|
|
|
206
241
|
await expect(readFile(blobPathFor(dir, "sid", "tc2"), "utf-8")).rejects.toBeDefined();
|
|
207
242
|
} finally { await rm(dir, { recursive: true, force: true }); }
|
|
208
243
|
});
|
|
244
|
+
|
|
245
|
+
it("spills a 500-char tool-call id: file created, capped basename, record mutated (AC1)", async () => {
|
|
246
|
+
const dir = await mkdtemp(join(tmpdir(), "spill-"));
|
|
247
|
+
try {
|
|
248
|
+
const indexer = new ToolCallIndexer();
|
|
249
|
+
const longId = "toolu_" + "k".repeat(494); // 500 chars
|
|
250
|
+
const body = "LONG-ID BODY ".repeat(10);
|
|
251
|
+
const batch = mkBatch([{ toolCallId: longId, toolName: "fetch", args: {}, resultText: body, isError: false, resultTimestamp: 1150 }]);
|
|
252
|
+
const spilled = await spillOversizedBatch({ batch, indexer, config: cfg, sessionDir: dir, sessionId: "sid", appendEntry: () => {} });
|
|
253
|
+
expect(spilled.has(longId)).toBe(true);
|
|
254
|
+
const rec = indexer.getRecord(occKey(longId, 1150))!;
|
|
255
|
+
expect(rec.resultText).toBe("");
|
|
256
|
+
expect(rec.resultPreview!.length).toBeGreaterThan(0);
|
|
257
|
+
expect(Buffer.byteLength(basename(rec.spillPath!), "utf8")).toBeLessThanOrEqual(255);
|
|
258
|
+
expect(await readFile(rec.spillPath!, "utf-8")).toBe(body);
|
|
259
|
+
} finally { await rm(dir, { recursive: true, force: true }); }
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("same 500-char id at two occurrences spills to two distinct files (AC2)", async () => {
|
|
263
|
+
const dir = await mkdtemp(join(tmpdir(), "spill-"));
|
|
264
|
+
try {
|
|
265
|
+
const indexer = new ToolCallIndexer();
|
|
266
|
+
const longId = "toolu_" + "k".repeat(494);
|
|
267
|
+
const noDedup = { ...cfg, dedupByContentHash: false };
|
|
268
|
+
const b1 = mkBatch([{ toolCallId: longId, toolName: "bash", args: {}, resultText: "FIRST".repeat(20), isError: false, resultTimestamp: 1150 }]);
|
|
269
|
+
const b2 = mkBatch([{ toolCallId: longId, toolName: "bash", args: {}, resultText: "SECOND".repeat(20), isError: false, resultTimestamp: 3150 }]);
|
|
270
|
+
await spillOversizedBatch({ batch: b1, indexer, config: noDedup, sessionDir: dir, sessionId: "sid", appendEntry: () => {} });
|
|
271
|
+
await spillOversizedBatch({ batch: b2, indexer, config: noDedup, sessionDir: dir, sessionId: "sid", appendEntry: () => {} });
|
|
272
|
+
const rec1 = indexer.getRecord(occKey(longId, 1150))!;
|
|
273
|
+
const rec2 = indexer.getRecord(occKey(longId, 3150))!;
|
|
274
|
+
expect(rec1.spillPath).not.toBe(rec2.spillPath);
|
|
275
|
+
expect(await readFile(rec1.spillPath!, "utf-8")).toBe("FIRST".repeat(20));
|
|
276
|
+
expect(await readFile(rec2.spillPath!, "utf-8")).toBe("SECOND".repeat(20));
|
|
277
|
+
} finally { await rm(dir, { recursive: true, force: true }); }
|
|
278
|
+
});
|
|
209
279
|
});
|
package/src/spill.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import type { CapturedBatch, CapturedToolCall } from "./types.js";
|
|
4
5
|
import type { ToolCallIndexer } from "./indexer.js";
|
|
5
6
|
import { hashToolResult } from "./content-hash.js";
|
|
@@ -15,7 +16,18 @@ export function blobDirFor(sessionDir: string, sessionId: string): string {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export function blobPathFor(sessionDir: string, sessionId: string, toolCallId: string): string {
|
|
18
|
-
|
|
19
|
+
const base = sanitizeId(toolCallId);
|
|
20
|
+
// 255-byte basename cap (gh-14). Uncapped budget: 255 - ".txt" = 251.
|
|
21
|
+
// Capped: 234-byte prefix + "." + 16-hex sha1 + ".txt" = 255 exactly.
|
|
22
|
+
// sanitizeId output is ASCII, so slice counts bytes. The "." separator is
|
|
23
|
+
// unreachable by sanitizeId, keeping capped names disjoint from short-key
|
|
24
|
+
// names. The hash covers the UNsanitized key so ids that sanitize
|
|
25
|
+
// identically stay distinct.
|
|
26
|
+
const name =
|
|
27
|
+
Buffer.byteLength(base, "utf8") <= 251
|
|
28
|
+
? `${base}.txt`
|
|
29
|
+
: `${base.slice(0, 234)}.${createHash("sha1").update(toolCallId).digest("hex").slice(0, 16)}.txt`;
|
|
30
|
+
return join(blobDirFor(sessionDir, sessionId), name);
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
/** Head of `text` capped at `maxBytes` (UTF-8 safe), preferring a line boundary. */
|
package/src/types.ts
CHANGED
|
@@ -414,6 +414,12 @@ export interface ContextPruneConfig {
|
|
|
414
414
|
* null (default) = disabled. Out-of-range (<= 0 or > 1) normalizes to null.
|
|
415
415
|
*/
|
|
416
416
|
budgetTurnDelta: number | null;
|
|
417
|
+
/**
|
|
418
|
+
* Opt-in flush trigger: when the un-pruned tail past the frontier
|
|
419
|
+
* (frontierGapTokens) reaches this many tokens, flush at turn_end.
|
|
420
|
+
* null (default) disables. Config-file-only — no settings overlay row.
|
|
421
|
+
*/
|
|
422
|
+
frontierGapThresholdTokens: number | null;
|
|
417
423
|
}
|
|
418
424
|
|
|
419
425
|
/**
|
|
@@ -426,7 +432,10 @@ export interface ContextPruneConfig {
|
|
|
426
432
|
* into a ChainCompressionEntry by adding blockId, toolRefs, and compressedAt.
|
|
427
433
|
*/
|
|
428
434
|
export interface ChainRange {
|
|
429
|
-
/**
|
|
435
|
+
/**
|
|
436
|
+
* Start anchor timestamp — a user message or an eligible (non-pruner)
|
|
437
|
+
* custom message. Field name kept for persisted-entry compatibility.
|
|
438
|
+
*/
|
|
430
439
|
startUserTimestamp: number;
|
|
431
440
|
/**
|
|
432
441
|
* All toolCallIds in the chain's middle (deduplicated). Collected from both
|
|
@@ -463,7 +472,11 @@ export interface ChainRange {
|
|
|
463
472
|
export interface ChainCompressionEntry {
|
|
464
473
|
/** Stable block ID, monotonic per session: "b1", "b2", ... */
|
|
465
474
|
blockId: string;
|
|
466
|
-
/**
|
|
475
|
+
/**
|
|
476
|
+
* Start anchor timestamp — a user message or an eligible (non-pruner)
|
|
477
|
+
* custom message. Field name kept for persisted-entry compatibility.
|
|
478
|
+
* Keep raw; synthetic inserted after.
|
|
479
|
+
*/
|
|
467
480
|
startUserTimestamp: number;
|
|
468
481
|
/**
|
|
469
482
|
* All toolCallIds in the chain's middle. **Diagnostic only** since the
|
|
@@ -566,6 +579,7 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
|
|
|
566
579
|
spillThreshold: 65536,
|
|
567
580
|
spillPreviewBytes: 2048,
|
|
568
581
|
budgetTurnDelta: null,
|
|
582
|
+
frontierGapThresholdTokens: null,
|
|
569
583
|
};
|
|
570
584
|
|
|
571
585
|
// ── Captured batch ─────────────────────────────────────────────────────────
|
|
@@ -708,7 +722,7 @@ export interface ContextMetricsSnapshot {
|
|
|
708
722
|
frontierGapTokens: number;
|
|
709
723
|
}
|
|
710
724
|
|
|
711
|
-
export type FlushTrigger = "budget" | "delta" | "message-end" | "manual" | "rearmed";
|
|
725
|
+
export type FlushTrigger = "budget" | "delta" | "frontier-gap" | "message-end" | "manual" | "rearmed";
|
|
712
726
|
|
|
713
727
|
/** Payload of CUSTOM_TYPE_FLUSH_METRICS. */
|
|
714
728
|
export interface FlushMetricsEntry {
|