pi-condense 2.11.5 → 2.13.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 +22 -0
- package/PRUNING.md +50 -32
- package/README.md +2 -0
- package/package.json +4 -4
- package/src/commands.test.ts +46 -0
- package/src/commands.ts +4 -4
- package/src/error-purge.test.ts +91 -1
- package/src/error-purge.transport.test.ts +4 -3
- package/src/error-purge.ts +48 -45
- package/src/frontier.test.ts +1 -0
- package/src/indexer.test.ts +28 -0
- package/src/indexer.ts +4 -0
- package/src/pruner.test.ts +112 -0
- package/src/pruner.ts +9 -8
- package/src/query-tool.test.ts +28 -0
- package/src/reload-rearm.integration.test.ts +2 -8
- package/src/summarizer-wiring.test.ts +138 -16
- package/src/summarizer.test.ts +29 -21
- package/src/summarizer.ts +41 -35
- package/src/supersede.test.ts +73 -3
- package/src/supersede.ts +37 -9
package/src/error-purge.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
+
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
1
2
|
import type { ErrorPurgeConfig } from "./types.js";
|
|
2
3
|
|
|
3
|
-
// Keep short paths and commands readable while reclaiming large
|
|
4
|
+
// Keep short paths and commands readable while reclaiming large completed-call bodies.
|
|
4
5
|
const PURGE_STRING_MIN_CHARS = 200;
|
|
5
6
|
|
|
6
|
-
function shrinkStrings(
|
|
7
|
+
function shrinkStrings(
|
|
8
|
+
value: unknown,
|
|
9
|
+
marker: (size: number) => string = (size) => `<purged-errored-args size="${size}"/>`,
|
|
10
|
+
): [unknown, boolean] {
|
|
7
11
|
if (typeof value === "string") {
|
|
8
|
-
if (value.length > PURGE_STRING_MIN_CHARS) return [
|
|
12
|
+
if (value.length > PURGE_STRING_MIN_CHARS) return [marker(value.length), true];
|
|
9
13
|
return [value, false];
|
|
10
14
|
}
|
|
11
15
|
if (Array.isArray(value)) {
|
|
12
16
|
let out: unknown[] | undefined;
|
|
13
17
|
for (let i = 0; i < value.length; i++) {
|
|
14
|
-
const [next, changed] = shrinkStrings(value[i]);
|
|
18
|
+
const [next, changed] = shrinkStrings(value[i], marker);
|
|
15
19
|
if (!changed) continue;
|
|
16
20
|
out ??= value.slice();
|
|
17
21
|
out[i] = next;
|
|
@@ -22,7 +26,7 @@ function shrinkStrings(value: unknown): [unknown, boolean] {
|
|
|
22
26
|
const object = value as Record<string, unknown>;
|
|
23
27
|
let out: Record<string, unknown> | undefined;
|
|
24
28
|
for (const key of Object.keys(object)) {
|
|
25
|
-
const [next, changed] = shrinkStrings(object[key]);
|
|
29
|
+
const [next, changed] = shrinkStrings(object[key], marker);
|
|
26
30
|
if (!changed) continue;
|
|
27
31
|
// Spread creates own data properties, including JSON's possible __proto__ key.
|
|
28
32
|
out ??= { ...object };
|
|
@@ -34,65 +38,64 @@ function shrinkStrings(value: unknown): [unknown, boolean] {
|
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
|
-
* Shrinks
|
|
38
|
-
* Keys and JSON types
|
|
39
|
-
* properties when replaying calls.
|
|
40
|
-
*
|
|
41
|
-
* Why only the arguments, not the whole toolCall or its toolResult:
|
|
42
|
-
* - The toolResult content (e.g. "Error: file not found") is small and carries
|
|
43
|
-
* the failure signal the model needs to understand what went wrong.
|
|
44
|
-
* - The toolCall block itself must remain so the provider can pair it with its
|
|
45
|
-
* result and avoid injecting a synthetic "No result provided" error.
|
|
46
|
-
* - The arguments body is what grows large — failed `write` / `edit` calls
|
|
47
|
-
* embed the full file content that will never be acted on again.
|
|
48
|
-
*
|
|
49
|
-
* Why the cooldown:
|
|
50
|
-
* - Gives the model 1–2 turns to retry before context is mutated. Purging
|
|
51
|
-
* immediately would remove the call detail before the model has had a
|
|
52
|
-
* chance to see the error and adapt.
|
|
53
|
-
*
|
|
54
|
-
* Turn index is computed internally by counting AssistantMessages in the input.
|
|
55
|
-
* This avoids threading a turn counter through index.ts.
|
|
41
|
+
* Shrinks summarized arguments immediately and errored arguments after cooldown.
|
|
42
|
+
* Keys and JSON types survive pi-ai grammar validation during replay.
|
|
56
43
|
*/
|
|
57
|
-
export function purgeErroredArgs(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
44
|
+
export function purgeErroredArgs(
|
|
45
|
+
messages: any[],
|
|
46
|
+
config: ErrorPurgeConfig,
|
|
47
|
+
summarizedRefs: ReadonlyMap<string, string> = new Map(),
|
|
48
|
+
): any[] {
|
|
49
|
+
if (!config.enabled) return messages;
|
|
50
|
+
|
|
51
|
+
const targets = new Map<number, Map<string, { turn: number; ref?: string }>>();
|
|
52
|
+
const open = new Map<string, { assistantIndex: number; turn: number }>();
|
|
62
53
|
let turnCount = 0;
|
|
63
|
-
for (
|
|
54
|
+
for (let i = 0; i < messages.length; i++) {
|
|
55
|
+
const msg = messages[i];
|
|
64
56
|
if (msg.role === "assistant") {
|
|
65
|
-
// Count each assistant turn; toolResults referencing the turn come next.
|
|
66
57
|
turnCount++;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
58
|
+
open.clear();
|
|
59
|
+
for (const block of msg.content) {
|
|
60
|
+
if (block.type === "toolCall") open.set(block.id, { assistantIndex: i, turn: turnCount });
|
|
61
|
+
}
|
|
62
|
+
} else if (msg.role === "toolResult") {
|
|
63
|
+
const paired = open.get(msg.toolCallId);
|
|
64
|
+
if (!paired) continue;
|
|
65
|
+
open.delete(msg.toolCallId);
|
|
66
|
+
const ref = summarizedRefs.get(occKey(msg.toolCallId, resultTimestampOf(msg.timestamp)));
|
|
67
|
+
if (ref === undefined && msg.isError !== true) continue;
|
|
68
|
+
let blocks = targets.get(paired.assistantIndex);
|
|
69
|
+
if (!blocks) targets.set(paired.assistantIndex, blocks = new Map());
|
|
70
|
+
blocks.set(msg.toolCallId, { turn: paired.turn, ref });
|
|
71
|
+
} else {
|
|
72
|
+
open.clear();
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
const currentTurnIndex = turnCount;
|
|
76
|
+
if (targets.size === 0) return messages;
|
|
76
77
|
|
|
77
78
|
// Pass 2: rewrite AssistantMessages whose toolCall args should be purged.
|
|
78
79
|
let anyModified = false;
|
|
79
|
-
const result = messages.map((msg) => {
|
|
80
|
-
|
|
80
|
+
const result = messages.map((msg, assistantIndex) => {
|
|
81
|
+
const targetsForTurn = targets.get(assistantIndex);
|
|
82
|
+
if (!targetsForTurn) return msg;
|
|
81
83
|
|
|
82
84
|
let contentModified = false;
|
|
83
85
|
const newContent = (msg.content as any[]).map((block) => {
|
|
84
86
|
if (block.type !== "toolCall") return block;
|
|
85
87
|
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
const age = currentTurnIndex - errorTurn;
|
|
90
|
-
if (age < config.cooldownTurns) return block;
|
|
88
|
+
const target = targetsForTurn.get(block.id);
|
|
89
|
+
if (!target) return block;
|
|
90
|
+
if (target.ref === undefined && turnCount - target.turn < config.cooldownTurns) return block;
|
|
91
91
|
|
|
92
92
|
const argBody = JSON.stringify(block.arguments);
|
|
93
93
|
if (argBody.length < config.minArgChars) return block;
|
|
94
94
|
|
|
95
|
-
const [shrunk, changed] = shrinkStrings(
|
|
95
|
+
const [shrunk, changed] = shrinkStrings(
|
|
96
|
+
block.arguments,
|
|
97
|
+
target.ref === undefined ? undefined : (size) => `<purged-args size="${size}" ref="${target.ref}"/>`,
|
|
98
|
+
);
|
|
96
99
|
if (!changed) return block;
|
|
97
100
|
|
|
98
101
|
contentModified = true;
|
package/src/frontier.test.ts
CHANGED
|
@@ -85,6 +85,7 @@ describe("PruneFrontierTracker.reconstructFromSession", () => {
|
|
|
85
85
|
|
|
86
86
|
const indexer = {
|
|
87
87
|
isSummarized: (id: string) => id === "tc-old" || id === "tc-stub",
|
|
88
|
+
isDedupAlias: () => false,
|
|
88
89
|
hasLegacyBareRecord: (id: string) => id === "tc-old" || id === "tc-stub",
|
|
89
90
|
getShortRefForToolCallId: (id: string) => (id === "tc-stub" ? "t1" : id === "tc-old" ? "told" : undefined),
|
|
90
91
|
getRecord: () => undefined,
|
package/src/indexer.test.ts
CHANGED
|
@@ -132,6 +132,28 @@ describe("occurrence keying", () => {
|
|
|
132
132
|
expect(records.map((r) => r.resultTimestamp)).toEqual([1150, 9150]);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
test("isDedupAlias checks exact registered keys without resolving refs or sibling occurrences", () => {
|
|
136
|
+
const indexer = new ToolCallIndexer();
|
|
137
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "SAME" }]), () => {});
|
|
138
|
+
indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "OTHER" }]), () => {});
|
|
139
|
+
indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }]);
|
|
140
|
+
indexer.registerDuplicate("bash_23@9150", "bash_23@1150", () => {});
|
|
141
|
+
indexer.registerDuplicate("bash_legacy", "bash_23@1150", () => {});
|
|
142
|
+
|
|
143
|
+
for (const [key, expected] of [
|
|
144
|
+
["bash_23@9150", true],
|
|
145
|
+
["bash_legacy", true],
|
|
146
|
+
["bash_23@1150", false],
|
|
147
|
+
["bash_23@2150", false],
|
|
148
|
+
["bash_23", false],
|
|
149
|
+
["bash_legacy@9150", false],
|
|
150
|
+
["unknown", false],
|
|
151
|
+
["t1", false],
|
|
152
|
+
] as const) {
|
|
153
|
+
expect(indexer.isDedupAlias(key)).toBe(expected);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
135
157
|
test("summary body lookups are occurrence-keyed", () => {
|
|
136
158
|
const indexer = new ToolCallIndexer();
|
|
137
159
|
indexer.registerSummaryBody(["bash_23@1150"], "summary of FIRST");
|
|
@@ -302,6 +324,10 @@ describe("session rebuild", () => {
|
|
|
302
324
|
expect(indexer.getRecord("bash_99@4150")?.resultText).toBe("FIRST");
|
|
303
325
|
expect(indexer.getShortRefForToolCallId("bash_99@4150")).toBe("t1");
|
|
304
326
|
expect(indexer.getPerBatchSummaryTextForToolCallIds(["bash_23@2150"])).toBe("summary text");
|
|
327
|
+
expect(indexer.isDedupAlias("bash_99@4150")).toBe(true);
|
|
328
|
+
expect(indexer.isDedupAlias("bash_99")).toBe(false);
|
|
329
|
+
expect(indexer.isDedupAlias("bash_23@1150")).toBe(false);
|
|
330
|
+
expect(indexer.isDedupAlias("t1")).toBe(false);
|
|
305
331
|
});
|
|
306
332
|
|
|
307
333
|
test("legacy entries without resultTimestamp keep bare-id keys", () => {
|
|
@@ -336,6 +362,8 @@ describe("session rebuild", () => {
|
|
|
336
362
|
expect(indexer.hasLegacyBareRecord("bash_7")).toBe(true);
|
|
337
363
|
expect(indexer.hasLegacyBareRecord("bash_23")).toBe(false);
|
|
338
364
|
expect(indexer.isSummarized("bash_8")).toBe(true);
|
|
365
|
+
expect(indexer.isDedupAlias("bash_8")).toBe(true);
|
|
366
|
+
expect(indexer.isDedupAlias("bash_7")).toBe(false);
|
|
339
367
|
});
|
|
340
368
|
});
|
|
341
369
|
|
package/src/indexer.ts
CHANGED
|
@@ -183,6 +183,10 @@ export class ToolCallIndexer {
|
|
|
183
183
|
return this.index.has(occurrenceKey) || this.dedupAliasToOriginal.has(occurrenceKey);
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
isDedupAlias(key: string): boolean {
|
|
187
|
+
return this.dedupAliasToOriginal.has(key);
|
|
188
|
+
}
|
|
189
|
+
|
|
186
190
|
/**
|
|
187
191
|
* Returns the full runtime index map.
|
|
188
192
|
*/
|
package/src/pruner.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { pruneMessages, sizeMessages } from "./pruner.js";
|
|
3
|
+
import { purgeErroredArgs } from "./error-purge.js";
|
|
3
4
|
import { charsOf } from "./context-metrics.js";
|
|
4
5
|
import { ToolCallIndexer } from "./indexer.js";
|
|
5
6
|
import { CUSTOM_TYPE_INDEX } from "./types.js";
|
|
@@ -33,6 +34,7 @@ function makeMockIndexer({
|
|
|
33
34
|
} = {}) {
|
|
34
35
|
return {
|
|
35
36
|
isSummarized: (id: string) => summarized.has(id),
|
|
37
|
+
isDedupAlias: (_key: string) => false,
|
|
36
38
|
hasLegacyBareRecord: (id: string) => summarized.has(id),
|
|
37
39
|
getShortRefForToolCallId: (id: string) => shortRefs.get(id),
|
|
38
40
|
getRecord: (id: string) => records.get(id),
|
|
@@ -47,6 +49,116 @@ function makeMockIndexer({
|
|
|
47
49
|
} as any;
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
describe("summarized argument handoff", () => {
|
|
53
|
+
const prompt = "p".repeat(600);
|
|
54
|
+
const config = { enabled: true, minArgChars: 300, cooldownTurns: 2 };
|
|
55
|
+
const history = (name = "subagent"): any[] => [
|
|
56
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "call", name, arguments: { prompt } }] },
|
|
57
|
+
{ role: "toolResult", toolCallId: "call", toolName: name, timestamp: 10, content: [{ type: "text", text: "original output" }], isError: false },
|
|
58
|
+
];
|
|
59
|
+
const render = (messages: any[], indexer: any, options = config, protection?: any, grace = 0) => {
|
|
60
|
+
expectNoOrphanToolResults(messages);
|
|
61
|
+
const result = pruneWithZeroSweepAssertion(messages, indexer, undefined, options, protection, grace);
|
|
62
|
+
expectNoOrphanToolResults(result.messages);
|
|
63
|
+
return result.messages;
|
|
64
|
+
};
|
|
65
|
+
const indexed = (key = "call@10", record?: any) => makeMockIndexer({
|
|
66
|
+
summarized: new Set([key]), shortRefs: new Map([[key, "t6"]]),
|
|
67
|
+
records: new Map([[key, record]]),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
for (const row of [
|
|
71
|
+
{ label: "summary", record: undefined },
|
|
72
|
+
{ label: "spill", record: { spillPath: "/tmp/output", toolName: "subagent" } },
|
|
73
|
+
{ label: "legacy fallback", key: "call" },
|
|
74
|
+
{ label: "timestamp-less", key: "call" },
|
|
75
|
+
]) {
|
|
76
|
+
it(`stubs and shrinks together: ${row.label}`, () => {
|
|
77
|
+
const raw = history();
|
|
78
|
+
if (row.label === "timestamp-less") delete raw[1].timestamp;
|
|
79
|
+
const out = render(raw, indexed(row.key, row.record));
|
|
80
|
+
expect(out[1].content[0].text).toContain("`t6`");
|
|
81
|
+
expect(out[0].content[0].arguments.prompt).toBe(`<purged-args size="${prompt.length}" ref="t6"/>`);
|
|
82
|
+
expect(raw[0].content[0].arguments.prompt).toBe(prompt);
|
|
83
|
+
expect(purgeErroredArgs(out, config, new Map([[row.label === "timestamp-less" ? "call" : "call@10", "t6"]]))).toBe(out);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const row of [
|
|
88
|
+
{ label: "disabled", options: { ...config, enabled: false } },
|
|
89
|
+
{ label: "body below gate", options: { ...config, minArgChars: 1000 } },
|
|
90
|
+
{ label: "no long strings", args: { a: "a".repeat(180), b: "b".repeat(180) } },
|
|
91
|
+
{ label: "unindexed", unindexed: true },
|
|
92
|
+
{ label: "protected", record: { toolName: "read", args: { path: "keep.md" } }, protection: { protectedTools: [], protectedPaths: ["keep.md"] } },
|
|
93
|
+
]) {
|
|
94
|
+
it(`retains arguments: ${row.label}`, () => {
|
|
95
|
+
const raw = history();
|
|
96
|
+
if (row.args) raw[0].content[0].arguments = row.args;
|
|
97
|
+
const out = render(raw, row.unindexed ? makeMockIndexer() : indexed(undefined, row.record), row.options, row.protection);
|
|
98
|
+
expect(out[0]).toBe(raw[0]);
|
|
99
|
+
expect(out[1] === raw[1]).toBe(!!row.unindexed || !!row.protection);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
it("pairs timestamp-less handoff positionally without shrinking a reused-id sibling", () => {
|
|
104
|
+
const indexer = indexed("call");
|
|
105
|
+
indexer.hasLegacyBareRecord = () => false;
|
|
106
|
+
const raw = [...history(), { role: "user", content: "next" }, ...history()];
|
|
107
|
+
delete raw[4].timestamp;
|
|
108
|
+
const out = render(raw, indexer);
|
|
109
|
+
expect(out[0]).toBe(raw[0]);
|
|
110
|
+
expect(out[1]).toBe(raw[1]);
|
|
111
|
+
expect(out[3].content[0].arguments.prompt).toBe('<purged-args size="600" ref="t6"/>');
|
|
112
|
+
expect(out[4].content[0].text).toContain("`t6`");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("uses the same raw-id fallback ref in the stub and argument marker", () => {
|
|
116
|
+
const out = render(history(), makeMockIndexer({ summarized: new Set(["call@10"]) }));
|
|
117
|
+
expect(out[1].content[0].text).toContain("`call`");
|
|
118
|
+
expect(out[0].content[0].arguments.prompt).toBe('<purged-args size="600" ref="call"/>');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("advances recovery grace with stub and shrink at the same boundary", () => {
|
|
122
|
+
const raw = history("context_tree_query");
|
|
123
|
+
const indexer = indexed();
|
|
124
|
+
expect(render(raw, indexer, config, undefined, 1)).toBe(raw);
|
|
125
|
+
raw.push({ role: "user", content: "next" }, { role: "assistant", content: [] }, { role: "user", content: "again" });
|
|
126
|
+
const out = render(raw, indexer, config, undefined, 1);
|
|
127
|
+
expect(out[1].content[0].text).toContain("`t6`");
|
|
128
|
+
expect(out[0].content[0].arguments.prompt).toBe(`<purged-args size="600" ref="t6"/>`);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("switches aged error markers on indexing and keeps fresh-render prefixes stable", () => {
|
|
132
|
+
const raw = history();
|
|
133
|
+
raw[1].isError = true;
|
|
134
|
+
raw.push({ role: "assistant", content: [] }, { role: "user", content: "next" }, { role: "assistant", content: [] });
|
|
135
|
+
const before = render(structuredClone(raw), makeMockIndexer());
|
|
136
|
+
expect(before[0].content[0].arguments.prompt).toBe('<purged-errored-args size="600"/>');
|
|
137
|
+
const indexer = indexed();
|
|
138
|
+
const first = render(structuredClone(raw), indexer);
|
|
139
|
+
expect(first[0].content[0].arguments.prompt).toBe('<purged-args size="600" ref="t6"/>');
|
|
140
|
+
expect(first[1].isError).toBe(false);
|
|
141
|
+
expect(JSON.stringify(render(structuredClone(raw), indexer))).toBe(JSON.stringify(first));
|
|
142
|
+
const extended = [...structuredClone(raw), { role: "user", content: "continue" }, { role: "assistant", content: [] }];
|
|
143
|
+
expect(JSON.stringify(render(extended, indexer).slice(0, raw.length))).toBe(JSON.stringify(first));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("keeps dedup alias prompts while shrinking the recoverable canonical prompt", () => {
|
|
147
|
+
const indexer = new ToolCallIndexer();
|
|
148
|
+
const raw = history();
|
|
149
|
+
indexer.addBatch({ turnIndex: 0, timestamp: 0, assistantText: "", toolCalls: [{ toolCallId: "call", toolName: "subagent", args: raw[0].content[0].arguments, resultText: "original output", isError: false, resultTimestamp: 10 }] }, () => {});
|
|
150
|
+
indexer.registerSummaryRefs([{ shortId: "t6", toolCallId: "call", resultTimestamp: 10 }]);
|
|
151
|
+
indexer.registerDuplicate("alias@20", "call@10", () => {});
|
|
152
|
+
raw.push({ role: "assistant", content: [{ type: "toolCall", id: "alias", name: "subagent", arguments: { prompt: "alias".repeat(120) } }] }, { ...raw[1], toolCallId: "alias", timestamp: 20 });
|
|
153
|
+
const out = render(raw, indexer);
|
|
154
|
+
expect(out[0].content[0].arguments.prompt).toBe('<purged-args size="600" ref="t6"/>');
|
|
155
|
+
expect(out[2]).toBe(raw[2]);
|
|
156
|
+
expect(out[1].content[0].text).toContain("`t6`");
|
|
157
|
+
expect(out[3].content[0].text).toContain("`t6`");
|
|
158
|
+
expect(indexer.getRecord("call@10")?.args).toEqual({ prompt });
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
50
162
|
const enabledCC: ChainCompressionConfig = {
|
|
51
163
|
enabled: true,
|
|
52
164
|
rollingWindow: 0,
|
package/src/pruner.ts
CHANGED
|
@@ -46,9 +46,9 @@ export function sizeMessages(messages: any[]): number {
|
|
|
46
46
|
* See src/supersede.ts. Runs before phase 3 so a superseded read inside a
|
|
47
47
|
* compressed chain relocates as the stub, not the verbatim body.
|
|
48
48
|
*
|
|
49
|
-
* Phase 2
|
|
50
|
-
*
|
|
51
|
-
*
|
|
49
|
+
* Phase 2 - argument purge: shrinks summarized toolCall arguments in the
|
|
50
|
+
* stub render and failed toolCall arguments after cooldown. Unsummarized
|
|
51
|
+
* toolResult error messages stay visible.
|
|
52
52
|
*
|
|
53
53
|
* Phase 3 — chain range prune: closed chains older than the rolling window
|
|
54
54
|
* are dropped (middle assistant + toolResult messages) and replaced with a
|
|
@@ -75,9 +75,8 @@ export function sizeMessages(messages: any[]): number {
|
|
|
75
75
|
* the only consumer (`index.ts` live-reclaim) reads them solely under
|
|
76
76
|
* `if (result.pruned)`.
|
|
77
77
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* `context_tree_query`.
|
|
78
|
+
* Tool-call ids, names, and result pairing stay unchanged; eligible argument
|
|
79
|
+
* strings shrink with a ref for recovery through `context_tree_query`.
|
|
81
80
|
*/
|
|
82
81
|
export function pruneMessages(
|
|
83
82
|
messages: any[],
|
|
@@ -91,6 +90,7 @@ export function pruneMessages(
|
|
|
91
90
|
): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } {
|
|
92
91
|
// Phase 1: stub-replace summarized tool results
|
|
93
92
|
let pruned = false;
|
|
93
|
+
const summarizedRefs = new Map<string, string>();
|
|
94
94
|
const inGrace = inGraceRecoveryToolCallIds(messages, recoveryGraceTurns);
|
|
95
95
|
const next = messages.map((msg) => {
|
|
96
96
|
if (msg.role !== "toolResult") return msg;
|
|
@@ -124,6 +124,7 @@ export function pruneMessages(
|
|
|
124
124
|
}
|
|
125
125
|
pruned = true;
|
|
126
126
|
const ref = indexer.getShortRefForToolCallId(lookupKey) ?? msg.toolCallId;
|
|
127
|
+
if (!indexer.isDedupAlias(lookupKey)) summarizedRefs.set(key, ref);
|
|
127
128
|
const text = record?.spillPath
|
|
128
129
|
? [
|
|
129
130
|
`[Oversized output spilled to file — ${record.spillBytes ?? "?"} bytes.]`,
|
|
@@ -155,9 +156,9 @@ export function pruneMessages(
|
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
// Phase 2:
|
|
159
|
+
// Phase 2: argument purge - summarized now, errored after cooldown
|
|
159
160
|
if (errorPurge?.enabled) {
|
|
160
|
-
const afterPurge = purgeErroredArgs(current, errorPurge);
|
|
161
|
+
const afterPurge = purgeErroredArgs(current, errorPurge, summarizedRefs);
|
|
161
162
|
if (afterPurge !== current) {
|
|
162
163
|
current = afterPurge;
|
|
163
164
|
pruned = true;
|
package/src/query-tool.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { pruneWithZeroSweepAssertion, expectNoOrphanToolResults } from "./test-support.js";
|
|
2
3
|
import { registerQueryTool } from "./query-tool.js";
|
|
3
4
|
import { imageDigest } from "./batch-capture.js";
|
|
4
5
|
import { ToolCallIndexer } from "./indexer.js";
|
|
@@ -91,6 +92,33 @@ describe("context_tree_query image recovery", () => {
|
|
|
91
92
|
});
|
|
92
93
|
|
|
93
94
|
describe("context_tree_query occurrence handling", () => {
|
|
95
|
+
test("alias ref recovers unclipped canonical arguments after rendering, not alias arguments", async () => {
|
|
96
|
+
const idx = new ToolCallIndexer();
|
|
97
|
+
const prompt = "canonical".repeat(80);
|
|
98
|
+
const aliasPrompt = "alias".repeat(120);
|
|
99
|
+
idx.addBatch({ turnIndex: 0, timestamp: 0, assistantText: "", toolCalls: [{ toolCallId: "canonical", toolName: "subagent", args: { prompt }, resultText: "shared original output", isError: false, resultTimestamp: 10 }] }, () => {});
|
|
100
|
+
idx.registerSummaryRefs([{ shortId: "t6", toolCallId: "canonical", resultTimestamp: 10 }]);
|
|
101
|
+
idx.registerDuplicate("alias@20", "canonical@10", () => {});
|
|
102
|
+
const raw = [
|
|
103
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "canonical", name: "subagent", arguments: { prompt } }] },
|
|
104
|
+
{ role: "toolResult", toolCallId: "canonical", toolName: "subagent", timestamp: 10, content: [{ type: "text", text: "shared original output" }] },
|
|
105
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "alias", name: "subagent", arguments: { prompt: aliasPrompt } }] },
|
|
106
|
+
{ role: "toolResult", toolCallId: "alias", toolName: "subagent", timestamp: 20, content: [{ type: "text", text: "shared original output" }] },
|
|
107
|
+
];
|
|
108
|
+
expectNoOrphanToolResults(raw);
|
|
109
|
+
const out = pruneWithZeroSweepAssertion(raw, idx, undefined, { enabled: true, minArgChars: 300, cooldownTurns: 2 }).messages;
|
|
110
|
+
expectNoOrphanToolResults(out);
|
|
111
|
+
expect(out[0].content[0].arguments.prompt).toBe(`<purged-args size="${prompt.length}" ref="t6"/>`);
|
|
112
|
+
expect(out[2].content[0].arguments.prompt).toBe(aliasPrompt);
|
|
113
|
+
const aliasRef = idx.getShortRefForToolCallId("alias@20");
|
|
114
|
+
expect(aliasRef).toBe("t6");
|
|
115
|
+
expect(idx.getRecordsForId(aliasRef!)[0].args).toEqual({ prompt });
|
|
116
|
+
const text = await runTool(idx, [aliasRef!]);
|
|
117
|
+
expect(text).toContain(`Args: ${JSON.stringify({ prompt }, null, 2)}`);
|
|
118
|
+
expect(text).toContain("shared original output");
|
|
119
|
+
expect(text).not.toContain(aliasPrompt);
|
|
120
|
+
expect(text).not.toContain("<purged-args");
|
|
121
|
+
});
|
|
94
122
|
test("a bare id with two occurrences returns both blocks, chronologically", async () => {
|
|
95
123
|
const idx = new ToolCallIndexer();
|
|
96
124
|
capture(idx, "bash_23", 2150, "SECOND", 1);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
2
|
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import * as actualCompat from "@earendil-works/pi-ai/compat";
|
|
6
5
|
import { supersededStub } from "./supersede.js";
|
|
7
6
|
|
|
8
7
|
// This must run before any module that transitively reads PI_CODING_AGENT_DIR
|
|
@@ -67,11 +66,6 @@ let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
|
|
|
67
66
|
return okStream();
|
|
68
67
|
};
|
|
69
68
|
|
|
70
|
-
mock.module("@earendil-works/pi-ai/compat", () => ({
|
|
71
|
-
...actualCompat,
|
|
72
|
-
stream: (...args: any[]) => streamImpl(...args),
|
|
73
|
-
}));
|
|
74
|
-
|
|
75
69
|
type AppendedEntry = { type: string; data: unknown };
|
|
76
70
|
|
|
77
71
|
// The captured-but-unflushed batch: a user message, an assistant message with
|
|
@@ -305,7 +299,7 @@ function bootExtension(
|
|
|
305
299
|
modelRegistry: {
|
|
306
300
|
find: () => undefined,
|
|
307
301
|
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test", headers: {} }),
|
|
308
|
-
|
|
302
|
+
streamSimple: (model: any, input: any, opts: any) => streamImpl(model, input, opts),
|
|
309
303
|
},
|
|
310
304
|
ui: {
|
|
311
305
|
setStatus() {},
|
|
@@ -1,22 +1,14 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
2
|
-
import * as actualCompat from "@earendil-works/pi-ai/compat";
|
|
3
|
-
|
|
4
|
-
// Stub pi-ai's `stream` so runSummarization can be exercised without a network
|
|
5
|
-
// call. `streamImpl` is swapped per test to simulate primary/fallback outcomes.
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
6
2
|
let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
|
|
7
3
|
throw new Error("streamImpl not set");
|
|
8
4
|
};
|
|
9
|
-
|
|
10
|
-
...actualCompat,
|
|
11
|
-
stream: (...args: any[]) => streamImpl(...args),
|
|
12
|
-
}));
|
|
13
|
-
|
|
14
|
-
const { summarizeBatch } = await import("./summarizer.js");
|
|
5
|
+
const { summarizeBatch, summarizeRange } = await import("./summarizer.js");
|
|
15
6
|
const { FallbackController } = await import("./summarizer-fallback.js");
|
|
16
7
|
const { DEFAULT_CONFIG } = await import("./types.js");
|
|
17
8
|
|
|
18
|
-
const PRIMARY = { id: "primary-model", provider: "provider-a", name: "Primary" };
|
|
19
|
-
const SESSION = { id: "session-model", provider: "provider-b", name: "Session" };
|
|
9
|
+
const PRIMARY = { id: "primary-model", provider: "provider-a", name: "Primary", api: "openai-responses" };
|
|
10
|
+
const SESSION = { id: "session-model", provider: "provider-b", name: "Session", api: "openai-responses" };
|
|
11
|
+
const VIRTUAL = { id: "auto", provider: "router", name: "Auto", api: "pi-virtual" };
|
|
20
12
|
|
|
21
13
|
const USAGE = {
|
|
22
14
|
input: 1,
|
|
@@ -67,6 +59,19 @@ function hangingStream(opts: any) {
|
|
|
67
59
|
};
|
|
68
60
|
}
|
|
69
61
|
|
|
62
|
+
// Models a router that never settles and ignores cancellation.
|
|
63
|
+
function deafStream() {
|
|
64
|
+
const never = new Promise<never>(() => {});
|
|
65
|
+
return {
|
|
66
|
+
async *[Symbol.asyncIterator]() {
|
|
67
|
+
await never;
|
|
68
|
+
},
|
|
69
|
+
async result() {
|
|
70
|
+
return never;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
70
75
|
// Emits `events` thinking_delta events spaced `gapMs` apart, then completes
|
|
71
76
|
// successfully — UNLESS `opts.signal` aborts mid-drip, in which case the
|
|
72
77
|
// current sleep rejects, exactly like a real provider stream cancelling on
|
|
@@ -107,13 +112,19 @@ interface Note {
|
|
|
107
112
|
level: string;
|
|
108
113
|
}
|
|
109
114
|
|
|
110
|
-
function makeCtx(notes: Note[], sessionModel: any = SESSION, primaryModel: any = PRIMARY) {
|
|
115
|
+
function makeCtx(notes: Note[], sessionModel: any = SESSION, primaryModel: any = PRIMARY, authOk = true) {
|
|
116
|
+
const authCalls: any[] = [];
|
|
111
117
|
return {
|
|
112
118
|
model: sessionModel,
|
|
119
|
+
authCalls,
|
|
113
120
|
modelRegistry: {
|
|
114
121
|
find: () => primaryModel,
|
|
115
|
-
getApiKeyAndHeaders: async () =>
|
|
116
|
-
|
|
122
|
+
getApiKeyAndHeaders: async (model: any) => {
|
|
123
|
+
authCalls.push(model);
|
|
124
|
+
return authOk ? { ok: true, apiKey: "k", headers: {} } : { ok: false, error: "no key for provider-a" };
|
|
125
|
+
},
|
|
126
|
+
getProviderAuth: async () => ({ auth: { baseUrl: "https://seat.example" } }),
|
|
127
|
+
streamSimple: (model: any, input: any, opts: any) => streamImpl(model, input, opts),
|
|
117
128
|
},
|
|
118
129
|
ui: { notify: (msg: string, level: string) => notes.push({ msg, level }) },
|
|
119
130
|
} as any;
|
|
@@ -132,6 +143,90 @@ function makeBatch() {
|
|
|
132
143
|
|
|
133
144
|
const distinctConfig = { ...DEFAULT_CONFIG, summarizerModel: "provider-a/primary-model" };
|
|
134
145
|
|
|
146
|
+
describe("virtual models through the registry", () => {
|
|
147
|
+
for (const operation of ["batch", "range"] as const) {
|
|
148
|
+
const summarize = (config: any, ctx: any, options: any = {}) => operation === "batch"
|
|
149
|
+
? summarizeBatch(makeBatch(), config, ctx, options)
|
|
150
|
+
: summarizeRange("- step one", config, ctx, options);
|
|
151
|
+
|
|
152
|
+
it(`${operation}: virtual session model rescues a transient primary`, async () => {
|
|
153
|
+
const seen: any[] = [];
|
|
154
|
+
streamImpl = (model) => {
|
|
155
|
+
seen.push(model);
|
|
156
|
+
return model === PRIMARY ? errStream("down") : okStream("- routed summary");
|
|
157
|
+
};
|
|
158
|
+
const notes: Note[] = [];
|
|
159
|
+
const controller = new FallbackController();
|
|
160
|
+
const result = await summarize(distinctConfig, makeCtx(notes, VIRTUAL), { controller });
|
|
161
|
+
expect(result?.summaryText).toBe("- routed summary");
|
|
162
|
+
expect(controller.inFallback).toBe(true);
|
|
163
|
+
expect(seen[1]).toBe(VIRTUAL);
|
|
164
|
+
expect(notes.filter(n => n.level === "warning")).toHaveLength(1);
|
|
165
|
+
expect(notes.filter(n => n.level === "error")).toHaveLength(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
for (const selection of ["default", "router/auto"]) {
|
|
169
|
+
it(`${operation}: virtual ${selection} skips auth and sends only summarizer context`, async () => {
|
|
170
|
+
const calls: any[] = [];
|
|
171
|
+
streamImpl = (model, input, opts) => {
|
|
172
|
+
calls.push({ model, input, opts });
|
|
173
|
+
return okStream("- ok");
|
|
174
|
+
};
|
|
175
|
+
const notes: Note[] = [];
|
|
176
|
+
const ctx = makeCtx(notes, VIRTUAL, VIRTUAL);
|
|
177
|
+
const result = await summarize({ ...DEFAULT_CONFIG, summarizerModel: selection }, ctx);
|
|
178
|
+
expect(result?.summaryText).toBe("- ok");
|
|
179
|
+
expect(calls).toHaveLength(1);
|
|
180
|
+
expect(calls[0].model).toBe(VIRTUAL);
|
|
181
|
+
expect(ctx.authCalls).toHaveLength(0);
|
|
182
|
+
expect(Object.keys(calls[0].input)).toEqual(["messages"]);
|
|
183
|
+
expect(calls[0].input.messages).toHaveLength(1);
|
|
184
|
+
expect(calls[0].input.messages[0].role).toBe("user");
|
|
185
|
+
expect(calls[0].opts.apiKey).toBeUndefined();
|
|
186
|
+
expect(calls[0].opts.headers).toBeUndefined();
|
|
187
|
+
expect(notes).toHaveLength(0);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
it("passes the physical model unchanged and pre-checks once", async () => {
|
|
193
|
+
const seen: any[] = [];
|
|
194
|
+
streamImpl = (model) => {
|
|
195
|
+
seen.push(model);
|
|
196
|
+
return okStream("- ok");
|
|
197
|
+
};
|
|
198
|
+
const ctx = makeCtx([]);
|
|
199
|
+
await summarizeBatch(makeBatch(), DEFAULT_CONFIG, ctx);
|
|
200
|
+
expect(seen[0]).toBe(SESSION);
|
|
201
|
+
expect(seen[0].baseUrl).toBeUndefined();
|
|
202
|
+
expect(ctx.authCalls).toEqual([SESSION]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("missing physical credentials never stream or trip the controller", async () => {
|
|
206
|
+
let called = 0;
|
|
207
|
+
streamImpl = () => { called++; return okStream("- never"); };
|
|
208
|
+
const notes: Note[] = [];
|
|
209
|
+
const controller = new FallbackController();
|
|
210
|
+
const result = await summarizeBatch(makeBatch(), distinctConfig, makeCtx(notes, SESSION, PRIMARY, false), { controller });
|
|
211
|
+
expect(result).toBeNull();
|
|
212
|
+
expect(called).toBe(0);
|
|
213
|
+
expect(controller.inFallback).toBe(false);
|
|
214
|
+
expect(notes).toHaveLength(1);
|
|
215
|
+
expect(notes[0].level).toBe("error");
|
|
216
|
+
expect(notes[0].msg).toContain("no key for provider-a");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("virtual stream failure returns null with the existing error notice", async () => {
|
|
220
|
+
streamImpl = () => errStream("router: no credentials for routed model");
|
|
221
|
+
const notes: Note[] = [];
|
|
222
|
+
const result = await summarizeBatch(makeBatch(), DEFAULT_CONFIG, makeCtx(notes, VIRTUAL));
|
|
223
|
+
expect(result).toBeNull();
|
|
224
|
+
expect(notes).toHaveLength(1);
|
|
225
|
+
expect(notes[0].level).toBe("error");
|
|
226
|
+
expect(notes[0].msg).toContain("router: no credentials for routed model");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
135
230
|
describe("runSummarization wiring — same-model no-op (legacy path)", () => {
|
|
136
231
|
it("summarizerModel=default: transient failure notifies error, returns null, controller untouched", async () => {
|
|
137
232
|
streamImpl = () => errStream("provider overloaded");
|
|
@@ -223,6 +318,33 @@ describe("runSummarization wiring — abort", () => {
|
|
|
223
318
|
});
|
|
224
319
|
|
|
225
320
|
describe("runSummarization wiring — timeouts", () => {
|
|
321
|
+
for (const [kind, idleMs, maxMs, message] of [
|
|
322
|
+
["idle", 20, 0, /stalled/],
|
|
323
|
+
["ceiling", 0, 20, /ceiling/],
|
|
324
|
+
] as const) {
|
|
325
|
+
it(`stream that ignores the signal still times out (${kind})`, async () => {
|
|
326
|
+
streamImpl = () => deafStream();
|
|
327
|
+
const notes: Note[] = [];
|
|
328
|
+
const ctx = makeCtx(notes, VIRTUAL);
|
|
329
|
+
const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: idleMs, summarizerMaxTimeoutMs: maxMs };
|
|
330
|
+
expect(await summarizeBatch(makeBatch(), cfg, ctx, {})).toBeNull();
|
|
331
|
+
expect(notes).toHaveLength(1);
|
|
332
|
+
expect(notes[0].level).toBe("warning");
|
|
333
|
+
expect(notes[0].msg).toMatch(message);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
it("caller abort during a stream that ignores the signal throws, no warning", async () => {
|
|
338
|
+
streamImpl = () => deafStream();
|
|
339
|
+
const notes: Note[] = [];
|
|
340
|
+
const ctx = makeCtx(notes, VIRTUAL);
|
|
341
|
+
const ac = new AbortController();
|
|
342
|
+
setTimeout(() => ac.abort(), 20);
|
|
343
|
+
const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: 0, summarizerMaxTimeoutMs: 0 };
|
|
344
|
+
await expect(summarizeBatch(makeBatch(), cfg, ctx, { signal: ac.signal })).rejects.toThrow();
|
|
345
|
+
expect(notes).toHaveLength(0);
|
|
346
|
+
});
|
|
347
|
+
|
|
226
348
|
it("idle timeout (default model): transient warning, returns null", async () => {
|
|
227
349
|
streamImpl = (_m, _i, opts) => hangingStream(opts);
|
|
228
350
|
const notes: Note[] = [];
|