pi-condense 2.5.0 → 2.6.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 +10 -0
- package/PRUNING.md +111 -23
- package/README.md +6 -1
- package/index.ts +23 -13
- package/package.json +1 -1
- package/src/batch-capture.test.ts +75 -1
- package/src/batch-capture.ts +22 -13
- package/src/chain-compressor.test.ts +114 -0
- package/src/chain-compressor.ts +29 -4
- package/src/chain-detector.test.ts +49 -0
- package/src/chain-detector.ts +7 -0
- package/src/chain-range-prune.test.ts +342 -7
- package/src/chain-range-prune.ts +161 -48
- package/src/commands.test.ts +31 -2
- package/src/commands.ts +25 -10
- package/src/diagnostics.test.ts +114 -0
- package/src/diagnostics.ts +46 -0
- package/src/frontier.test.ts +1 -0
- package/src/id-collision.integration.test.ts +251 -0
- package/src/indexer.test.ts +336 -0
- package/src/indexer.ts +168 -55
- package/src/occurrence-key.test.ts +57 -0
- package/src/occurrence-key.ts +36 -0
- package/src/orphan-sweep.test.ts +67 -0
- package/src/orphan-sweep.ts +40 -0
- package/src/oversized-spill.integration.test.ts +7 -2
- package/src/pruner.test.ts +456 -25
- package/src/pruner.ts +84 -36
- package/src/query-tool.test.ts +117 -0
- package/src/query-tool.ts +47 -31
- package/src/range-compression.integration.test.ts +6 -1
- package/src/recovery-grace.test.ts +13 -0
- package/src/recovery-grace.ts +12 -3
- package/src/spill.test.ts +108 -1
- package/src/spill.ts +5 -3
- package/src/summary-refs.test.ts +51 -1
- package/src/summary-refs.ts +15 -4
- package/src/test-support.ts +54 -0
- package/src/tree-browser.ts +2 -1
- package/src/types.ts +56 -10
package/src/pruner.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import type { ToolCallIndexer } from "./indexer.js";
|
|
2
3
|
import type { ChainCompressionConfig, ErrorPurgeConfig } from "./types.js";
|
|
3
4
|
import { isProtected, type ProtectionConfig } from "./protected.js";
|
|
4
5
|
import { applyChainCompressions } from "./chain-range-prune.js";
|
|
5
6
|
import { purgeErroredArgs } from "./error-purge.js";
|
|
6
7
|
import { inGraceRecoveryToolCallIds } from "./recovery-grace.js";
|
|
8
|
+
import { occKey } from "./occurrence-key.js";
|
|
9
|
+
import { sweepOrphanToolResults } from "./orphan-sweep.js";
|
|
10
|
+
import type { DiagnosticSink } from "./diagnostics.js";
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* Estimate of a message array's context weight. Serializing the whole array
|
|
@@ -16,7 +20,7 @@ export function sizeMessages(messages: any[]): number {
|
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
|
-
* Transforms the `context` event message array in
|
|
23
|
+
* Transforms the `context` event message array in four phases:
|
|
20
24
|
*
|
|
21
25
|
* Phase 1 — stub-replace: ToolResultMessages for summarized tool calls are
|
|
22
26
|
* replaced with short stubs pointing the model at `context_tree_query`.
|
|
@@ -42,6 +46,12 @@ export function sizeMessages(messages: any[]): number {
|
|
|
42
46
|
* synthetic user message wrapping the existing per-batch summary text.
|
|
43
47
|
* Only runs when `chainCompression.enabled` and chain entries exist.
|
|
44
48
|
*
|
|
49
|
+
* Phase 4 — orphan sweep: structural post-condition run unconditionally over
|
|
50
|
+
* the final array. Removes any toolResult whose matching toolCall id was not
|
|
51
|
+
* opened by the immediately preceding assistant turn (per-turn open set, not
|
|
52
|
+
* cumulative — see src/orphan-sweep.ts). Reference-preserving when nothing is
|
|
53
|
+
* swept, so a clean render still returns the identical input array.
|
|
54
|
+
*
|
|
45
55
|
* Return shape:
|
|
46
56
|
* - `pruned: true` — at least one change happened; the returned
|
|
47
57
|
* `messages` is a freshly allocated array.
|
|
@@ -66,46 +76,61 @@ export function pruneMessages(
|
|
|
66
76
|
errorPurge?: ErrorPurgeConfig,
|
|
67
77
|
protection?: ProtectionConfig,
|
|
68
78
|
recoveryGraceTurns: number = 0,
|
|
79
|
+
diagnostics?: DiagnosticSink,
|
|
69
80
|
): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } {
|
|
70
81
|
// Phase 1: stub-replace summarized tool results
|
|
71
82
|
let pruned = false;
|
|
72
83
|
const inGrace = inGraceRecoveryToolCallIds(messages, recoveryGraceTurns);
|
|
73
84
|
const next = messages.map((msg) => {
|
|
74
|
-
if (msg.role
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
toolName: msg.toolName,
|
|
103
|
-
content: [{ type: "text", text }],
|
|
104
|
-
isError: false,
|
|
105
|
-
timestamp: msg.timestamp,
|
|
106
|
-
};
|
|
85
|
+
if (msg.role !== "toolResult") return msg;
|
|
86
|
+
|
|
87
|
+
// Fail-closed: when the message carries a timestamp, the occurrence key
|
|
88
|
+
// is tried first. The bare id is consulted only as a fallback, and only
|
|
89
|
+
// when `hasLegacyBareRecord` confirms it is LEGACY-ONLY (no occurrence
|
|
90
|
+
// siblings) - a mixed bare+occurrence id fails closed there too, since a
|
|
91
|
+
// live result under a reused id is not the legacy one. A permissive
|
|
92
|
+
// bare-id fallback would stub a live result because an older occurrence
|
|
93
|
+
// (or the legacy record) of the same provider id was summarized.
|
|
94
|
+
const key = typeof msg.timestamp === "number" ? occKey(msg.toolCallId, msg.timestamp) : msg.toolCallId;
|
|
95
|
+
const lookupKey = indexer.isSummarized(key)
|
|
96
|
+
? key
|
|
97
|
+
: indexer.hasLegacyBareRecord(msg.toolCallId)
|
|
98
|
+
? msg.toolCallId
|
|
99
|
+
: undefined;
|
|
100
|
+
if (lookupKey === undefined) return msg;
|
|
101
|
+
|
|
102
|
+
const record = indexer.getRecord(lookupKey);
|
|
103
|
+
// Render-time re-check: a record summarized before protectedPaths
|
|
104
|
+
// covered it is repaired here — the raw toolResult still lives in the
|
|
105
|
+
// session JSONL, so skipping the stub restores it verbatim.
|
|
106
|
+
// Dedup aliases resolve to the original record, so an alias whose own
|
|
107
|
+
// path is protected but whose original isn't stays stubbed (edge case).
|
|
108
|
+
if (protection && record && isProtected(record.toolName, record.args, protection)) {
|
|
109
|
+
return msg;
|
|
110
|
+
}
|
|
111
|
+
if (inGrace.has(key)) {
|
|
112
|
+
return msg;
|
|
107
113
|
}
|
|
108
|
-
|
|
114
|
+
pruned = true;
|
|
115
|
+
const ref = indexer.getShortRefForToolCallId(lookupKey) ?? msg.toolCallId;
|
|
116
|
+
const text = record?.spillPath
|
|
117
|
+
? [
|
|
118
|
+
`[Oversized output spilled to file — ${record.spillBytes ?? "?"} bytes.]`,
|
|
119
|
+
`Tool: ${record.toolName}`,
|
|
120
|
+
`Preview (head):`,
|
|
121
|
+
record.resultPreview ?? "",
|
|
122
|
+
`Full output — read this file (offset/limit supported): ${record.spillPath}`,
|
|
123
|
+
`Or use context_tree_query with ref \`${ref}\`.`,
|
|
124
|
+
].join("\n")
|
|
125
|
+
: `[Summarized in pruner summary, ref \`${ref}\`. Use context_tree_query to retrieve full output.]`;
|
|
126
|
+
return {
|
|
127
|
+
role: "toolResult",
|
|
128
|
+
toolCallId: msg.toolCallId,
|
|
129
|
+
toolName: msg.toolName,
|
|
130
|
+
content: [{ type: "text", text }],
|
|
131
|
+
isError: false,
|
|
132
|
+
timestamp: msg.timestamp,
|
|
133
|
+
};
|
|
109
134
|
});
|
|
110
135
|
|
|
111
136
|
let current: any[] = pruned ? next : messages;
|
|
@@ -126,7 +151,8 @@ export function pruneMessages(
|
|
|
126
151
|
// Prefer the cohesive LLM range summary (B) when present; fall back to the
|
|
127
152
|
// per-batch concatenation for spans compressed before fusion / on failure.
|
|
128
153
|
const chainSummaryText = (entry: typeof chainEntries[number]): string =>
|
|
129
|
-
entry.rangeSummaryText ??
|
|
154
|
+
entry.rangeSummaryText ??
|
|
155
|
+
indexer.getPerBatchSummaryTextForToolCallIds(entry.droppedOccurrenceKeys ?? entry.droppedToolCallIds);
|
|
130
156
|
const blockSummaryLookup = (blockId: string): string | undefined => {
|
|
131
157
|
const entry = indexer.findChainEntryByBlockId(blockId);
|
|
132
158
|
if (!entry) return undefined;
|
|
@@ -138,6 +164,7 @@ export function pruneMessages(
|
|
|
138
164
|
chainSummaryText,
|
|
139
165
|
chainCompression.stripFinalAssistantThinking,
|
|
140
166
|
blockSummaryLookup,
|
|
167
|
+
diagnostics,
|
|
141
168
|
);
|
|
142
169
|
if (compressed !== current) {
|
|
143
170
|
current = compressed;
|
|
@@ -146,6 +173,27 @@ export function pruneMessages(
|
|
|
146
173
|
}
|
|
147
174
|
}
|
|
148
175
|
|
|
176
|
+
// Phase 4: orphan sweep — structural post-condition. Reference-preserving
|
|
177
|
+
// when clean, so a no-op render leaves the prompt-cache prefix untouched.
|
|
178
|
+
const swept = sweepOrphanToolResults(current);
|
|
179
|
+
if (swept.messages !== current) {
|
|
180
|
+
current = swept.messages;
|
|
181
|
+
pruned = true;
|
|
182
|
+
const sortedIds = [...swept.sweptIds].sort();
|
|
183
|
+
// Hash the id list into a short, stable dedup key instead of the raw
|
|
184
|
+
// sorted join: a growing orphan set would otherwise write ever-longer
|
|
185
|
+
// keys ("a", "a,b", "a,b,c", ...), and DiagnosticSink.seen retains every
|
|
186
|
+
// prefix forever - O(n^2) characters over a session's lifetime.
|
|
187
|
+
const dedupKey = createHash("sha1").update(sortedIds.join(",")).digest("hex").slice(0, 16);
|
|
188
|
+
const shown = sortedIds.slice(0, 5);
|
|
189
|
+
const more = sortedIds.length > shown.length ? ` ... +${sortedIds.length - shown.length} more` : "";
|
|
190
|
+
diagnostics?.report(
|
|
191
|
+
"orphan-sweep",
|
|
192
|
+
dedupKey,
|
|
193
|
+
`swept ${swept.sweptIds.length} orphan toolResult(s): ${shown.join(", ")}${more}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
149
197
|
return pruned
|
|
150
198
|
? { messages: current, pruned, beforeChars: sizeMessages(messages), afterChars: sizeMessages(current) }
|
|
151
199
|
: { messages, pruned, beforeChars: 0, afterChars: 0 };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { registerQueryTool } from "./query-tool.js";
|
|
3
|
+
import { ToolCallIndexer } from "./indexer.js";
|
|
4
|
+
import type { CapturedBatch } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const capture = (idx: ToolCallIndexer, id: string, ts: number, text: string, turnIndex: number) => {
|
|
7
|
+
const batch: CapturedBatch = {
|
|
8
|
+
turnIndex,
|
|
9
|
+
timestamp: ts - 50,
|
|
10
|
+
assistantText: "",
|
|
11
|
+
toolCalls: [{ toolCallId: id, toolName: "bash", args: { cmd: "ls" }, resultText: text, isError: false, resultTimestamp: ts }],
|
|
12
|
+
};
|
|
13
|
+
idx.addBatch(batch, () => {});
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const captureLegacy = (idx: ToolCallIndexer, id: string, timestamp: number, text: string, turnIndex: number) => {
|
|
17
|
+
const batch: CapturedBatch = {
|
|
18
|
+
turnIndex,
|
|
19
|
+
timestamp,
|
|
20
|
+
assistantText: "",
|
|
21
|
+
toolCalls: [{ toolCallId: id, toolName: "bash", args: { cmd: "ls" }, resultText: text, isError: false }],
|
|
22
|
+
};
|
|
23
|
+
idx.addBatch(batch, () => {});
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// execute returns { content: [{ type: "text", text }], details } (src/query-tool.ts:73-76)
|
|
27
|
+
const runTool = async (indexer: ToolCallIndexer, toolCallIds: string[]): Promise<string> => {
|
|
28
|
+
let registered: any;
|
|
29
|
+
registerQueryTool({ registerTool: (def: any) => (registered = def) } as any, indexer);
|
|
30
|
+
const result = await registered.execute("call-1", { toolCallIds }, undefined, undefined, undefined);
|
|
31
|
+
return result.content[0].text as string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("context_tree_query occurrence handling", () => {
|
|
35
|
+
test("a bare id with two occurrences returns both blocks, chronologically", async () => {
|
|
36
|
+
const idx = new ToolCallIndexer();
|
|
37
|
+
capture(idx, "bash_23", 2150, "SECOND", 1);
|
|
38
|
+
capture(idx, "bash_23", 1150, "FIRST", 0);
|
|
39
|
+
const text = await runTool(idx, ["bash_23"]);
|
|
40
|
+
expect(text.indexOf("FIRST")).toBeGreaterThan(-1);
|
|
41
|
+
expect(text.indexOf("SECOND")).toBeGreaterThan(-1);
|
|
42
|
+
expect(text.indexOf("FIRST")).toBeLessThan(text.indexOf("SECOND"));
|
|
43
|
+
expect(text.match(/## toolRef:/g)).toHaveLength(2);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("an occurrence key returns exactly one block", async () => {
|
|
47
|
+
const idx = new ToolCallIndexer();
|
|
48
|
+
capture(idx, "bash_23", 1150, "FIRST", 0);
|
|
49
|
+
capture(idx, "bash_23", 2150, "SECOND", 1);
|
|
50
|
+
const text = await runTool(idx, ["bash_23@2150"]);
|
|
51
|
+
expect(text.match(/## toolRef:/g)).toHaveLength(1);
|
|
52
|
+
expect(text).toContain("SECOND");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("an unknown id still reports not-found once", async () => {
|
|
56
|
+
const idx = new ToolCallIndexer();
|
|
57
|
+
const text = await runTool(idx, ["nope"]);
|
|
58
|
+
expect(text).toContain("not found in index");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a single match still labels with the caller's input and has no @ suffix", async () => {
|
|
62
|
+
const idx = new ToolCallIndexer();
|
|
63
|
+
capture(idx, "bash_23", 1150, "FIRST", 0);
|
|
64
|
+
const text = await runTool(idx, ["bash_23"]);
|
|
65
|
+
expect(text.match(/## toolRef:/g)).toHaveLength(1);
|
|
66
|
+
expect(text).toContain("## toolRef: bash_23\n");
|
|
67
|
+
expect(text).not.toContain("bash_23@");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("G5 conformance: a bare id whose collision was content-deduplicated still returns both occurrences via context_tree_query", async () => {
|
|
71
|
+
const idx = new ToolCallIndexer();
|
|
72
|
+
capture(idx, "bash_23", 1150, "SAME", 0);
|
|
73
|
+
// Same bare id, later occurrence, content-deduplicated against the original.
|
|
74
|
+
idx.registerDuplicate("bash_23@9150", "bash_23@1150", () => {});
|
|
75
|
+
|
|
76
|
+
const records = idx.getRecordsForId("bash_23");
|
|
77
|
+
expect(records).toHaveLength(2);
|
|
78
|
+
|
|
79
|
+
const text = await runTool(idx, ["bash_23"]);
|
|
80
|
+
expect(text.match(/## toolRef:/g)).toHaveLength(2);
|
|
81
|
+
expect(text).toContain("bash_23@1150");
|
|
82
|
+
expect(text).toContain("bash_23@9150");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("G4/C1: colliding short refs each resolve to their OWN batch through the registered tool", async () => {
|
|
86
|
+
const idx = new ToolCallIndexer();
|
|
87
|
+
capture(idx, "bash_23", 1150, "FIRST", 0);
|
|
88
|
+
capture(idx, "bash_23", 2150, "SECOND", 1);
|
|
89
|
+
idx.registerSummaryRefs([
|
|
90
|
+
{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 },
|
|
91
|
+
{ shortId: "t2", toolCallId: "bash_23", resultTimestamp: 2150 },
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
const firstText = await runTool(idx, ["t1"]);
|
|
95
|
+
expect(firstText.match(/## toolRef:/g)).toHaveLength(1);
|
|
96
|
+
expect(firstText).toContain("FIRST");
|
|
97
|
+
expect(firstText).not.toContain("SECOND");
|
|
98
|
+
|
|
99
|
+
const secondText = await runTool(idx, ["t2"]);
|
|
100
|
+
expect(secondText.match(/## toolRef:/g)).toHaveLength(1);
|
|
101
|
+
expect(secondText).toContain("SECOND");
|
|
102
|
+
expect(secondText).not.toContain("FIRST");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("mixed legacy + new occurrence under the same bare id: legacy record labels with the bare id plus an explicit note, not a fake @legacy key", async () => {
|
|
106
|
+
const idx = new ToolCallIndexer();
|
|
107
|
+
captureLegacy(idx, "bash_23", 1000, "OLD", 0);
|
|
108
|
+
capture(idx, "bash_23", 2150, "NEW", 1);
|
|
109
|
+
const text = await runTool(idx, ["bash_23"]);
|
|
110
|
+
expect(text.match(/## toolRef:/g)).toHaveLength(2);
|
|
111
|
+
expect(text).toContain("## toolRef: bash_23\n");
|
|
112
|
+
expect(text).toContain("Occurrence: legacy (no resultTimestamp)");
|
|
113
|
+
expect(text).toContain("bash_23@2150");
|
|
114
|
+
expect(text).not.toContain("undefined");
|
|
115
|
+
expect(text).not.toContain("@legacy");
|
|
116
|
+
});
|
|
117
|
+
});
|
package/src/query-tool.ts
CHANGED
|
@@ -5,19 +5,28 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
5
5
|
import type { ToolCallIndexer } from "./indexer.js";
|
|
6
6
|
import { QUERY_TOOL_NAME } from "./types.js";
|
|
7
7
|
|
|
8
|
+
// Legacy records (persisted before resultTimestamp existed) have no timestamp
|
|
9
|
+
// to disambiguate. Do NOT mint an `id@legacy` string: it is occurrence-key-
|
|
10
|
+
// SHAPED but not a real key — parseOccKey would treat "legacy" as part of a
|
|
11
|
+
// literal id, so echoing it back into context_tree_query would fail lookup.
|
|
12
|
+
// Label with the bare id and call out the ambiguity in the block body instead.
|
|
13
|
+
function occurrenceLabel(id: string, resultTimestamp: number | undefined): string {
|
|
14
|
+
return resultTimestamp === undefined ? id : `${id}@${resultTimestamp}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
export function registerQueryTool(pi: ExtensionAPI, indexer: ToolCallIndexer): void {
|
|
9
18
|
pi.registerTool({
|
|
10
19
|
name: QUERY_TOOL_NAME,
|
|
11
20
|
label: "Query Original Tool History",
|
|
12
21
|
description:
|
|
13
|
-
"Retrieve original tool call results that have been pruned from active context. Pass the short refs listed in a pruner-summary message, e.g. context_tree_query({ toolCallIds: [\"t12\", \"t3\"] }), to get back the full original outputs.",
|
|
22
|
+
"Retrieve original tool call results that have been pruned from active context. Pass the short refs listed in a pruner-summary message, e.g. context_tree_query({ toolCallIds: [\"t12\", \"t3\"] }), to get back the full original outputs. A raw id that was reused returns every occurrence, each labelled id@timestamp.",
|
|
14
23
|
promptSnippet: "Retrieve original pruned tool outputs by short ref",
|
|
15
24
|
promptGuidelines: [
|
|
16
25
|
"When you need the full output of a tool call that was summarized and pruned from context, use context_tree_query with the short refs listed in the relevant pruner-summary message.",
|
|
17
26
|
],
|
|
18
27
|
parameters: Type.Object({
|
|
19
28
|
toolCallIds: Type.Array(Type.String(), {
|
|
20
|
-
description: 'Required. One or more short refs (e.g. "t12") or raw tool call IDs from a pruner-summary message.',
|
|
29
|
+
description: 'Required. One or more short refs (e.g. "t12") or raw tool call IDs from a pruner-summary message. A reused raw id returns every occurrence, each labelled id@timestamp.',
|
|
21
30
|
}),
|
|
22
31
|
}),
|
|
23
32
|
|
|
@@ -26,46 +35,53 @@ export function registerQueryTool(pi: ExtensionAPI, indexer: ToolCallIndexer): v
|
|
|
26
35
|
const blocks: string[] = [];
|
|
27
36
|
|
|
28
37
|
for (const id of params.toolCallIds) {
|
|
29
|
-
const
|
|
38
|
+
const records = indexer.getRecordsForId(id);
|
|
30
39
|
|
|
31
|
-
if (
|
|
40
|
+
if (records.length === 0) {
|
|
32
41
|
blocks.push(`## toolRef: ${id}\n(not found in index — may not have been summarized yet)`);
|
|
33
42
|
continue;
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
|
|
45
|
+
for (const record of records) {
|
|
46
|
+
// A reused provider id denotes several occurrences; return all of
|
|
47
|
+
// them rather than silently picking one. `tN` refs stay 1:1.
|
|
48
|
+
const label = records.length > 1 ? occurrenceLabel(id, record.resultTimestamp) : id;
|
|
37
49
|
|
|
38
|
-
|
|
39
|
-
const header = [
|
|
40
|
-
`## toolRef: ${id}`,
|
|
41
|
-
`Tool: ${record.toolName}`,
|
|
42
|
-
`Args: ${JSON.stringify(record.args, null, 2)}`,
|
|
43
|
-
`Status: ${status}`,
|
|
44
|
-
`Turn: ${record.turnIndex}`,
|
|
45
|
-
"",
|
|
46
|
-
].join("\n");
|
|
50
|
+
foundRecords[label] = record;
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
const status = record.isError ? "ERROR" : "OK";
|
|
53
|
+
const header = [
|
|
54
|
+
`## toolRef: ${label}`,
|
|
55
|
+
...(records.length > 1 && record.resultTimestamp === undefined ? ["Occurrence: legacy (no resultTimestamp)"] : []),
|
|
56
|
+
`Tool: ${record.toolName}`,
|
|
57
|
+
`Args: ${JSON.stringify(record.args, null, 2)}`,
|
|
58
|
+
`Status: ${status}`,
|
|
59
|
+
`Turn: ${record.turnIndex}`,
|
|
60
|
+
"",
|
|
61
|
+
].join("\n");
|
|
62
|
+
|
|
63
|
+
let raw = record.resultText;
|
|
64
|
+
if (record.spillPath) {
|
|
65
|
+
try {
|
|
66
|
+
raw = await readFile(record.spillPath, "utf-8");
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error(`context_tree_query: failed to read spilled output at ${record.spillPath}:`, err);
|
|
69
|
+
raw = record.resultPreview ?? "(spilled output unavailable — sidecar file missing)";
|
|
70
|
+
}
|
|
55
71
|
}
|
|
56
|
-
}
|
|
57
72
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
73
|
+
const t = truncateHead(raw, {
|
|
74
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
75
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
76
|
+
});
|
|
62
77
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
let body = t.content;
|
|
79
|
+
if (t.truncated) {
|
|
80
|
+
body += `\n[Output truncated: ${t.outputLines}/${t.totalLines} lines shown]`;
|
|
81
|
+
}
|
|
67
82
|
|
|
68
|
-
|
|
83
|
+
blocks.push(`${header}\n${body}`);
|
|
84
|
+
}
|
|
69
85
|
}
|
|
70
86
|
|
|
71
87
|
const combined = blocks.join("\n\n---\n\n");
|
|
@@ -5,6 +5,7 @@ import { compressEligible } from "./chain-compressor.js";
|
|
|
5
5
|
import { pruneMessages } from "./pruner.js";
|
|
6
6
|
import { detectChains } from "./chain-detector.js";
|
|
7
7
|
import { isProtected } from "./protected.js";
|
|
8
|
+
import { bareToolCallId } from "./occurrence-key.js";
|
|
8
9
|
import type { ChainRange, ChainCompressionConfig } from "./types.js";
|
|
9
10
|
|
|
10
11
|
// End-to-end of the in-memory B path (everything except the LLM call, which is
|
|
@@ -185,8 +186,12 @@ describe("range compression integration", () => {
|
|
|
185
186
|
expect(chains[0].protectedToolCallIds).toEqual(["tc2"]);
|
|
186
187
|
|
|
187
188
|
// Only unprotected tc1 has a per-batch summary; tc2 is protected, no short ref.
|
|
189
|
+
// Keyed by the occurrence key detectChains actually produced for tc1's result
|
|
190
|
+
// (id + resultTimestamp), not the bare toolCallId, so this can't silently drift.
|
|
191
|
+
const tc1OccKey = chains[0].middleOccurrenceKeys!.find((k) => bareToolCallId(k) === "tc1")!;
|
|
192
|
+
expect(tc1OccKey).toBeDefined();
|
|
188
193
|
indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tc1" }]);
|
|
189
|
-
indexer.registerSummaryBody([
|
|
194
|
+
indexer.registerSummaryBody([tc1OccKey], "read app.ts summary");
|
|
190
195
|
|
|
191
196
|
const { compressedEntries } = await compressEligible(chains, 0, {
|
|
192
197
|
indexer,
|
|
@@ -3,6 +3,7 @@ import { inGraceRecoveryToolCallIds } from "./recovery-grace.js";
|
|
|
3
3
|
|
|
4
4
|
const user = () => ({ role: "user", content: [{ type: "text", text: "u" }] });
|
|
5
5
|
const ctq = (id: string) => ({ role: "toolResult", toolCallId: id, toolName: "context_tree_query", content: [{ type: "text", text: "x" }] });
|
|
6
|
+
const ctqAt = (id: string, timestamp: number) => ({ role: "toolResult", toolCallId: id, toolName: "context_tree_query", content: [{ type: "text", text: "x" }], timestamp });
|
|
6
7
|
const bash = (id: string) => ({ role: "toolResult", toolCallId: id, toolName: "bash", content: [{ type: "text", text: "x" }] });
|
|
7
8
|
|
|
8
9
|
describe("inGraceRecoveryToolCallIds", () => {
|
|
@@ -32,4 +33,16 @@ describe("inGraceRecoveryToolCallIds", () => {
|
|
|
32
33
|
expect(set.has("t1")).toBe(false);
|
|
33
34
|
expect(set.has("t2")).toBe(true);
|
|
34
35
|
});
|
|
36
|
+
it("keys a timestamped recovery output by occurrence (id@timestamp), not the bare id", () => {
|
|
37
|
+
const msgs = [user(), ctqAt("reused", 100)];
|
|
38
|
+
const set = inGraceRecoveryToolCallIds(msgs, 3);
|
|
39
|
+
expect(set.has("reused@100")).toBe(true);
|
|
40
|
+
expect(set.has("reused")).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
it("does not conflate two occurrences of the same reused bare id", () => {
|
|
43
|
+
const msgs = [user(), ctqAt("reused", 100), user(), user(), user(), user(), ctqAt("reused", 200)];
|
|
44
|
+
const set = inGraceRecoveryToolCallIds(msgs, 3);
|
|
45
|
+
expect(set.has("reused@100")).toBe(false);
|
|
46
|
+
expect(set.has("reused@200")).toBe(true);
|
|
47
|
+
});
|
|
35
48
|
});
|
package/src/recovery-grace.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { QUERY_TOOL_NAME } from "./types.js";
|
|
2
|
+
import { occKey } from "./occurrence-key.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Set of `context_tree_query`
|
|
5
|
-
*
|
|
5
|
+
* Set of `context_tree_query` occurrence keys (or bare ids when the message
|
|
6
|
+
* carries no timestamp) still inside the recovery grace window, computed
|
|
7
|
+
* positionally from the message array (no stored metadata).
|
|
8
|
+
*
|
|
9
|
+
* Keyed by occurrence, not bare id: the pruner's fail-closed ladder in
|
|
10
|
+
* `pruneMessages` looks this set up with the SAME `occKey(id, timestamp)` it
|
|
11
|
+
* computed for the message under test, so a graced occurrence never leaks
|
|
12
|
+
* protection onto a different, later occurrence that happens to reuse the
|
|
13
|
+
* same provider id.
|
|
6
14
|
*
|
|
7
15
|
* A recovery output's "user-turn-group" is the count of `role === "user"`
|
|
8
16
|
* messages at or before its position; its age is `nowUTG - that count`, where
|
|
@@ -26,7 +34,8 @@ export function inGraceRecoveryToolCallIds(messages: any[], graceTurns: number):
|
|
|
26
34
|
continue;
|
|
27
35
|
}
|
|
28
36
|
if (m?.role === "toolResult" && m.toolName === QUERY_TOOL_NAME && typeof m.toolCallId === "string") {
|
|
29
|
-
|
|
37
|
+
const key = typeof m.timestamp === "number" ? occKey(m.toolCallId, m.timestamp) : m.toolCallId;
|
|
38
|
+
if (nowUTG - seen <= graceTurns) result.add(key);
|
|
30
39
|
}
|
|
31
40
|
}
|
|
32
41
|
return result;
|
package/src/spill.test.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { sanitizeId, blobDirFor, blobPathFor, headPreview, spillOversizedBatch } from "./spill.js";
|
|
6
6
|
import { ToolCallIndexer } from "./indexer.js";
|
|
7
|
+
import { registerQueryTool } from "./query-tool.js";
|
|
8
|
+
import { occKey } from "./occurrence-key.js";
|
|
9
|
+
import { CUSTOM_TYPE_INDEX } from "./types.js";
|
|
7
10
|
import type { CapturedBatch } from "./types.js";
|
|
8
11
|
|
|
9
12
|
describe("sanitizeId", () => {
|
|
@@ -36,6 +39,94 @@ describe("headPreview", () => {
|
|
|
36
39
|
});
|
|
37
40
|
});
|
|
38
41
|
|
|
42
|
+
describe("occurrence-keyed spill", () => {
|
|
43
|
+
it("blobPathFor distinguishes two occurrences of one id", () => {
|
|
44
|
+
const a = blobPathFor("/tmp/s", "sess", occKey("bash_23", 1150));
|
|
45
|
+
const b = blobPathFor("/tmp/s", "sess", occKey("bash_23", 3150));
|
|
46
|
+
expect(a).not.toBe(b);
|
|
47
|
+
expect(a.endsWith("bash_23_1150.txt")).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("legacy bare-id sidecar path is unchanged", () => {
|
|
51
|
+
expect(blobPathFor("/tmp/s", "sess", "bash_23").endsWith("bash_23.txt")).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("registerDuplicate is called with occurrence keys on both sides", async () => {
|
|
55
|
+
const calls: string[][] = [];
|
|
56
|
+
const indexer = {
|
|
57
|
+
lookupByContent: () => "bash_1@1000",
|
|
58
|
+
registerDuplicate: (a: string, b: string) => calls.push([a, b]),
|
|
59
|
+
} as any;
|
|
60
|
+
const batch = {
|
|
61
|
+
turnIndex: 0,
|
|
62
|
+
timestamp: 2000,
|
|
63
|
+
assistantText: "",
|
|
64
|
+
toolCalls: [
|
|
65
|
+
{ toolCallId: "bash_2", toolName: "bash", args: {}, resultText: "x".repeat(100), isError: false, resultTimestamp: 2150 },
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
await spillOversizedBatch({
|
|
69
|
+
batch: batch as any,
|
|
70
|
+
indexer,
|
|
71
|
+
config: { spillThreshold: 10, spillPreviewBytes: 10, dedupByContentHash: true },
|
|
72
|
+
sessionDir: "/tmp/s",
|
|
73
|
+
sessionId: "sess",
|
|
74
|
+
appendEntry: () => {},
|
|
75
|
+
});
|
|
76
|
+
expect(calls).toEqual([["bash_2@2150", "bash_1@1000"]]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("G4/C4: legacy bare-id sidecar recovery", () => {
|
|
81
|
+
it("a pre-upgrade legacy record whose spillPath points at a bare-id-named sidecar still resolves through context_tree_query", async () => {
|
|
82
|
+
const dir = await mkdtemp(join(tmpdir(), "spill-legacy-"));
|
|
83
|
+
try {
|
|
84
|
+
// A pre-occurrence-key sidecar, written and named exactly the way a
|
|
85
|
+
// pre-upgrade session would have (bare id, no resultTimestamp suffix).
|
|
86
|
+
const sidecarPath = blobPathFor(dir, "sid", "bash_7");
|
|
87
|
+
await mkdir(blobDirFor(dir, "sid"), { recursive: true });
|
|
88
|
+
await writeFile(sidecarPath, "OLD SPILLED BODY".repeat(20));
|
|
89
|
+
|
|
90
|
+
const indexEntry = {
|
|
91
|
+
type: "custom",
|
|
92
|
+
customType: CUSTOM_TYPE_INDEX,
|
|
93
|
+
data: {
|
|
94
|
+
toolCalls: [
|
|
95
|
+
{
|
|
96
|
+
toolCallId: "bash_7",
|
|
97
|
+
toolName: "fetch",
|
|
98
|
+
args: { url: "https://x" },
|
|
99
|
+
resultText: "",
|
|
100
|
+
resultPreview: "OLD SPILLED",
|
|
101
|
+
spillPath: sidecarPath,
|
|
102
|
+
spillBytes: 340,
|
|
103
|
+
isError: false,
|
|
104
|
+
turnIndex: 0,
|
|
105
|
+
timestamp: 500,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
// No matching ToolResultMessage in the branch (a genuinely pre-upgrade,
|
|
111
|
+
// truncated session) - an index entry persisted without resultTimestamp
|
|
112
|
+
// stays bare-keyed (no migration), so its sidecar keeps its bare-id
|
|
113
|
+
// filename and still resolves via the persisted spillPath.
|
|
114
|
+
const indexer = new ToolCallIndexer();
|
|
115
|
+
indexer.reconstructFromSession({ sessionManager: { getBranch: () => [indexEntry] } } as any);
|
|
116
|
+
expect(indexer.hasLegacyBareRecord("bash_7")).toBe(true);
|
|
117
|
+
|
|
118
|
+
let registered: any;
|
|
119
|
+
registerQueryTool({ registerTool: (def: any) => (registered = def) } as any, indexer);
|
|
120
|
+
const result = await registered.execute("call-1", { toolCallIds: ["bash_7"] }, undefined, undefined, undefined);
|
|
121
|
+
const text = result.content[0].text as string;
|
|
122
|
+
|
|
123
|
+
expect(text).toContain("OLD SPILLED BODY");
|
|
124
|
+
} finally {
|
|
125
|
+
await rm(dir, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
39
130
|
describe("spillOversizedBatch", () => {
|
|
40
131
|
const cfg = { spillThreshold: 10, spillPreviewBytes: 8, dedupByContentHash: true };
|
|
41
132
|
const mkBatch = (toolCalls: any[]): CapturedBatch => ({ turnIndex: 0, timestamp: 1, assistantText: "", toolCalls });
|
|
@@ -85,6 +176,22 @@ describe("spillOversizedBatch", () => {
|
|
|
85
176
|
}
|
|
86
177
|
});
|
|
87
178
|
|
|
179
|
+
it("two occurrences of one toolCallId spill to distinct sidecar files", async () => {
|
|
180
|
+
const dir = await mkdtemp(join(tmpdir(), "spill-"));
|
|
181
|
+
try {
|
|
182
|
+
const indexer = new ToolCallIndexer();
|
|
183
|
+
const batch1 = mkBatch([{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "FIRST".repeat(20), isError: false, resultTimestamp: 1150 }]);
|
|
184
|
+
const batch2 = mkBatch([{ toolCallId: "bash_23", toolName: "bash", args: {}, resultText: "SECOND".repeat(20), isError: false, resultTimestamp: 3150 }]);
|
|
185
|
+
await spillOversizedBatch({ batch: batch1, indexer, config: { ...cfg, dedupByContentHash: false }, sessionDir: dir, sessionId: "sid", appendEntry: () => {} });
|
|
186
|
+
await spillOversizedBatch({ batch: batch2, indexer, config: { ...cfg, dedupByContentHash: false }, sessionDir: dir, sessionId: "sid", appendEntry: () => {} });
|
|
187
|
+
const rec1 = indexer.getRecord("bash_23@1150")!;
|
|
188
|
+
const rec2 = indexer.getRecord("bash_23@3150")!;
|
|
189
|
+
expect(rec1.spillPath).not.toBe(rec2.spillPath);
|
|
190
|
+
expect(await readFile(rec1.spillPath!, "utf-8")).toBe("FIRST".repeat(20));
|
|
191
|
+
expect(await readFile(rec2.spillPath!, "utf-8")).toBe("SECOND".repeat(20));
|
|
192
|
+
} finally { await rm(dir, { recursive: true, force: true }); }
|
|
193
|
+
});
|
|
194
|
+
|
|
88
195
|
it("dedups an oversized duplicate to the original without a second file", async () => {
|
|
89
196
|
const dir = await mkdtemp(join(tmpdir(), "spill-"));
|
|
90
197
|
try {
|
package/src/spill.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import type { CapturedBatch, CapturedToolCall } from "./types.js";
|
|
4
4
|
import type { ToolCallIndexer } from "./indexer.js";
|
|
5
5
|
import { hashToolResult } from "./content-hash.js";
|
|
6
|
+
import { occKey } from "./occurrence-key.js";
|
|
6
7
|
|
|
7
8
|
/** Replace anything outside [A-Za-z0-9_-] so the id can't escape the blob dir. */
|
|
8
9
|
export function sanitizeId(toolCallId: string): string {
|
|
@@ -50,18 +51,19 @@ export async function spillOversizedBatch(args: {
|
|
|
50
51
|
for (const tc of batch.toolCalls) {
|
|
51
52
|
if (tc.resultText.length < config.spillThreshold) continue;
|
|
52
53
|
|
|
54
|
+
const key = occKey(tc.toolCallId, tc.resultTimestamp);
|
|
53
55
|
const hash = hashToolResult(tc.toolName, tc.resultText);
|
|
54
56
|
|
|
55
57
|
if (config.dedupByContentHash) {
|
|
56
58
|
const original = indexer.lookupByContent(tc.toolName, tc.resultText);
|
|
57
|
-
if (original && original !==
|
|
58
|
-
indexer.registerDuplicate(
|
|
59
|
+
if (original && original !== key) {
|
|
60
|
+
indexer.registerDuplicate(key, original, appendEntry);
|
|
59
61
|
handled.add(tc.toolCallId);
|
|
60
62
|
continue;
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
const path = blobPathFor(sessionDir, sessionId,
|
|
66
|
+
const path = blobPathFor(sessionDir, sessionId, key);
|
|
65
67
|
try {
|
|
66
68
|
await mkdir(blobDirFor(sessionDir, sessionId), { recursive: true });
|
|
67
69
|
await writeFile(path, tc.resultText, "utf-8");
|