pi-condense 2.9.1 → 2.10.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 +11 -3
- package/README.md +9 -3
- package/index.ts +32 -51
- 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/commands.test.ts +1 -28
- package/src/commands.ts +8 -14
- 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/reload-rearm.integration.test.ts +369 -98
- 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-"));
|
|
@@ -173,6 +215,8 @@ function bootExtension(
|
|
|
173
215
|
const piAppended: AppendedEntry[] = options.separatePiAppended ? [] : appended;
|
|
174
216
|
const sessionAppended: AppendedEntry[] = appended;
|
|
175
217
|
const handlers = new Map<string, (event: any, ctx: any) => any>();
|
|
218
|
+
const commands = new Map<string, (args: string, ctx: any) => Promise<void>>();
|
|
219
|
+
const notifications: string[] = [];
|
|
176
220
|
|
|
177
221
|
const pushPi = (type: string, data?: unknown) => {
|
|
178
222
|
piAppended.push({ type, data });
|
|
@@ -187,7 +231,9 @@ function bootExtension(
|
|
|
187
231
|
},
|
|
188
232
|
appendEntry: options.piAppendEntry ? options.piAppendEntry(pushPi) : pushPi,
|
|
189
233
|
sendMessage() {},
|
|
190
|
-
registerCommand() {
|
|
234
|
+
registerCommand(name: string, spec: { handler: (args: string, ctx: any) => Promise<void> }) {
|
|
235
|
+
commands.set(name, spec.handler);
|
|
236
|
+
},
|
|
191
237
|
registerTool() {},
|
|
192
238
|
registerMessageRenderer() {},
|
|
193
239
|
events: { emit() {} },
|
|
@@ -221,12 +267,14 @@ function bootExtension(
|
|
|
221
267
|
ui: {
|
|
222
268
|
setStatus() {},
|
|
223
269
|
setWidget() {},
|
|
224
|
-
notify() {
|
|
270
|
+
notify(message: string) {
|
|
271
|
+
notifications.push(message);
|
|
272
|
+
},
|
|
225
273
|
select: async () => undefined,
|
|
226
274
|
},
|
|
227
275
|
};
|
|
228
276
|
|
|
229
|
-
return { handlers, ctx, pi, piAppended, sessionAppended, appended, branch };
|
|
277
|
+
return { handlers, commands, notifications, ctx, pi, piAppended, sessionAppended, appended, branch };
|
|
230
278
|
}
|
|
231
279
|
|
|
232
280
|
async function boot(options?: Parameters<typeof bootExtension>[0]) {
|
|
@@ -526,75 +574,6 @@ describe("reload rearm (issue #6)", () => {
|
|
|
526
574
|
expect(piAppended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
|
|
527
575
|
});
|
|
528
576
|
|
|
529
|
-
it("recomputes the cached metrics snapshot on a turn_end whose toolResults produce no pushed batch (G3)", async () => {
|
|
530
|
-
// Component 4 (spec): the snapshot cache recomputes at every enabled
|
|
531
|
-
// turn_end carrying toolResults, unconditional on whether trim yields a
|
|
532
|
-
// batch to push. Observed via the footer widget suffix (commands.ts's
|
|
533
|
-
// pruneStatusText), which is rendered from the cache, not recomputed
|
|
534
|
-
// itself — the honest seam here since the harness's pi.registerCommand
|
|
535
|
-
// is a no-op stub and the registerCommands getCachedMetrics callback is
|
|
536
|
-
// therefore unreachable from a test.
|
|
537
|
-
const { handlers, ctx, branch } = await boot({ protectedTools: ["secret_tool"] });
|
|
538
|
-
|
|
539
|
-
// Neutralize the budget/delta gate so this test only observes the
|
|
540
|
-
// recompute, not a side-effect flush (harness default usage is 0.6,
|
|
541
|
-
// above the fixture's 0.5 autoBudgetThreshold).
|
|
542
|
-
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
543
|
-
|
|
544
|
-
await handlers.get("session_start")!({}, ctx);
|
|
545
|
-
|
|
546
|
-
const statusCalls: unknown[] = [];
|
|
547
|
-
ctx.ui.setStatus = (_id: string, text?: string) => statusCalls.push(text);
|
|
548
|
-
|
|
549
|
-
// Force a render of the current (session_start-computed) cache.
|
|
550
|
-
const rawBefore = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message);
|
|
551
|
-
await handlers.get("context")!({ messages: rawBefore }, ctx);
|
|
552
|
-
const textBefore = statusCalls[statusCalls.length - 1];
|
|
553
|
-
|
|
554
|
-
// Grow the branch as Pi would before firing turn_end: a new assistant
|
|
555
|
-
// turn with a large thinking block and a protected tool call, plus its
|
|
556
|
-
// toolResult. Protected content is excluded from frontierGapTokens by
|
|
557
|
-
// design, but NOT from openCycleThinkingTokens or largestChainSharePct —
|
|
558
|
-
// so this turn still moves the cache if recomputed.
|
|
559
|
-
const newAssistant = {
|
|
560
|
-
type: "message",
|
|
561
|
-
message: {
|
|
562
|
-
role: "assistant",
|
|
563
|
-
content: [
|
|
564
|
-
{ type: "thinking", text: "t".repeat(4000) },
|
|
565
|
-
{ type: "toolCall", id: "tc2", name: "secret_tool", arguments: {} },
|
|
566
|
-
],
|
|
567
|
-
},
|
|
568
|
-
};
|
|
569
|
-
const newToolResult = {
|
|
570
|
-
type: "message",
|
|
571
|
-
message: {
|
|
572
|
-
role: "toolResult",
|
|
573
|
-
toolCallId: "tc2",
|
|
574
|
-
toolName: "secret_tool",
|
|
575
|
-
content: [{ type: "text", text: "s".repeat(400) }],
|
|
576
|
-
timestamp: Date.now(),
|
|
577
|
-
},
|
|
578
|
-
};
|
|
579
|
-
branch.push(newAssistant, newToolResult);
|
|
580
|
-
|
|
581
|
-
// This turn's toolResults are entirely protected, so trimBatchToPendingRange
|
|
582
|
-
// returns null and no batch is pushed — the case this fix targets.
|
|
583
|
-
await handlers.get("turn_end")!(
|
|
584
|
-
{ message: newAssistant.message, toolResults: [newToolResult.message], turnIndex: 3 },
|
|
585
|
-
ctx,
|
|
586
|
-
);
|
|
587
|
-
|
|
588
|
-
const rawAfter = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message);
|
|
589
|
-
await handlers.get("context")!({ messages: rawAfter }, ctx);
|
|
590
|
-
const textAfter = statusCalls[statusCalls.length - 1];
|
|
591
|
-
|
|
592
|
-
// Pre-fix, the cache is stale (computed once at session_start, on the
|
|
593
|
-
// pre-growth branch) — the widget text does not move. Post-fix, the
|
|
594
|
-
// turn_end recompute picks up the larger open segment/thinking.
|
|
595
|
-
expect(textAfter).not.toBe(textBefore);
|
|
596
|
-
});
|
|
597
|
-
|
|
598
577
|
it("includes a persisted summary custom_message entry in the largest-chain-share denominator (G1)", async () => {
|
|
599
578
|
// Component 1 (spec): denominator = per-message chars over the entire
|
|
600
579
|
// branch projection, INCLUDING retained custom_message summary entries.
|
|
@@ -620,17 +599,13 @@ describe("reload rearm (issue #6)", () => {
|
|
|
620
599
|
};
|
|
621
600
|
const branch = [...closedChain, summaryEntry, closer];
|
|
622
601
|
|
|
623
|
-
const { handlers, ctx } = await boot({ branch });
|
|
602
|
+
const { handlers, commands, notifications, ctx } = await boot({ branch });
|
|
624
603
|
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
625
604
|
|
|
626
|
-
const statusCalls: unknown[] = [];
|
|
627
|
-
ctx.ui.setStatus = (_id: string, text?: string) => statusCalls.push(text);
|
|
628
|
-
|
|
629
605
|
await handlers.get("session_start")!({}, ctx);
|
|
630
606
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
const text = statusCalls[statusCalls.length - 1] as string;
|
|
607
|
+
await commands.get("pruner")!("status", ctx);
|
|
608
|
+
const text = notifications[notifications.length - 1] as string;
|
|
634
609
|
|
|
635
610
|
const chainChars = closedChain.map((e: any) => JSON.stringify(e.message).length).reduce((a, b) => a + b, 0);
|
|
636
611
|
const totalWithSummary = [...closedChain.map((e: any) => e.message), summaryEntry, closer.message]
|
|
@@ -641,7 +616,303 @@ describe("reload rearm (issue #6)", () => {
|
|
|
641
616
|
(100 * chainChars) / closedChain.map((e: any) => JSON.stringify(e.message).length).reduce((a, b) => a + b, 0),
|
|
642
617
|
);
|
|
643
618
|
|
|
644
|
-
expect(text).toContain(`chain ${expectedPct}%`);
|
|
619
|
+
expect(text).toContain(`chain share: ${expectedPct}%`);
|
|
645
620
|
expect(expectedPct).toBeLessThan(inflatedPct);
|
|
646
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
|
+
});
|
|
647
918
|
});
|
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 {
|