pi-condense 2.4.3 → 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 +14 -0
- package/PRUNING.md +96 -54
- package/README.md +6 -1
- package/index.ts +28 -42
- 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 -35
- package/src/config.test.ts +27 -1
- package/src/diagnostics.test.ts +114 -0
- package/src/diagnostics.ts +46 -0
- package/src/frontier.test.ts +138 -16
- package/src/frontier.ts +0 -1
- 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 +471 -64
- package/src/pruner.ts +84 -54
- package/src/query-tool.test.ts +117 -0
- package/src/query-tool.ts +47 -31
- package/src/range-compression.integration.test.ts +7 -44
- 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 -49
- package/src/thinking-strip.test.ts +0 -257
- package/src/thinking-strip.ts +0 -83
package/src/pruner.ts
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import type { ToolCallIndexer } from "./indexer.js";
|
|
2
|
-
import type { ChainCompressionConfig, ErrorPurgeConfig
|
|
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
|
-
import { stripOldThinking } from "./thinking-strip.js";
|
|
7
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";
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* Estimate of a message array's context weight. Serializing the whole array
|
|
11
14
|
* (not just visible text) is deliberate: it counts tool-call argument bodies
|
|
12
|
-
* (error-purge)
|
|
13
|
-
*
|
|
15
|
+
* (error-purge) and tool-result arrays (stub-replace / chain-range) so all
|
|
16
|
+
* reclaim mechanisms register.
|
|
14
17
|
*/
|
|
15
18
|
export function sizeMessages(messages: any[]): number {
|
|
16
19
|
return JSON.stringify(messages).length;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
|
-
* Transforms the `context` event message array in
|
|
23
|
+
* Transforms the `context` event message array in four phases:
|
|
21
24
|
*
|
|
22
25
|
* Phase 1 — stub-replace: ToolResultMessages for summarized tool calls are
|
|
23
26
|
* replaced with short stubs pointing the model at `context_tree_query`.
|
|
@@ -43,11 +46,11 @@ export function sizeMessages(messages: any[]): number {
|
|
|
43
46
|
* synthetic user message wrapping the existing per-batch summary text.
|
|
44
47
|
* Only runs when `chainCompression.enabled` and chain entries exist.
|
|
45
48
|
*
|
|
46
|
-
* Phase 4 —
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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.
|
|
51
54
|
*
|
|
52
55
|
* Return shape:
|
|
53
56
|
* - `pruned: true` — at least one change happened; the returned
|
|
@@ -71,50 +74,63 @@ export function pruneMessages(
|
|
|
71
74
|
indexer: ToolCallIndexer,
|
|
72
75
|
chainCompression?: ChainCompressionConfig,
|
|
73
76
|
errorPurge?: ErrorPurgeConfig,
|
|
74
|
-
thinkingStrip?: ThinkingStripConfig,
|
|
75
77
|
protection?: ProtectionConfig,
|
|
76
78
|
recoveryGraceTurns: number = 0,
|
|
77
|
-
|
|
79
|
+
diagnostics?: DiagnosticSink,
|
|
78
80
|
): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } {
|
|
79
81
|
// Phase 1: stub-replace summarized tool results
|
|
80
82
|
let pruned = false;
|
|
81
83
|
const inGrace = inGraceRecoveryToolCallIds(messages, recoveryGraceTurns);
|
|
82
84
|
const next = messages.map((msg) => {
|
|
83
|
-
if (msg.role
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
toolName: msg.toolName,
|
|
112
|
-
content: [{ type: "text", text }],
|
|
113
|
-
isError: false,
|
|
114
|
-
timestamp: msg.timestamp,
|
|
115
|
-
};
|
|
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;
|
|
116
113
|
}
|
|
117
|
-
|
|
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
|
+
};
|
|
118
134
|
});
|
|
119
135
|
|
|
120
136
|
let current: any[] = pruned ? next : messages;
|
|
@@ -135,7 +151,8 @@ export function pruneMessages(
|
|
|
135
151
|
// Prefer the cohesive LLM range summary (B) when present; fall back to the
|
|
136
152
|
// per-batch concatenation for spans compressed before fusion / on failure.
|
|
137
153
|
const chainSummaryText = (entry: typeof chainEntries[number]): string =>
|
|
138
|
-
entry.rangeSummaryText ??
|
|
154
|
+
entry.rangeSummaryText ??
|
|
155
|
+
indexer.getPerBatchSummaryTextForToolCallIds(entry.droppedOccurrenceKeys ?? entry.droppedToolCallIds);
|
|
139
156
|
const blockSummaryLookup = (blockId: string): string | undefined => {
|
|
140
157
|
const entry = indexer.findChainEntryByBlockId(blockId);
|
|
141
158
|
if (!entry) return undefined;
|
|
@@ -147,6 +164,7 @@ export function pruneMessages(
|
|
|
147
164
|
chainSummaryText,
|
|
148
165
|
chainCompression.stripFinalAssistantThinking,
|
|
149
166
|
blockSummaryLookup,
|
|
167
|
+
diagnostics,
|
|
150
168
|
);
|
|
151
169
|
if (compressed !== current) {
|
|
152
170
|
current = compressed;
|
|
@@ -155,13 +173,25 @@ export function pruneMessages(
|
|
|
155
173
|
}
|
|
156
174
|
}
|
|
157
175
|
|
|
158
|
-
// Phase 4:
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
+
);
|
|
165
195
|
}
|
|
166
196
|
|
|
167
197
|
return pruned
|
|
@@ -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,7 +5,8 @@ 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
|
|
8
|
+
import { bareToolCallId } from "./occurrence-key.js";
|
|
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
|
|
11
12
|
// the shared runSummarization already exercised live): a span's per-batch
|
|
@@ -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,
|
|
@@ -249,46 +254,4 @@ describe("range compression integration", () => {
|
|
|
249
254
|
expect(synthetic.content[0].text).toContain("batch one body");
|
|
250
255
|
expect(synthetic.content[0].text).toContain("batch two body");
|
|
251
256
|
});
|
|
252
|
-
|
|
253
|
-
test("boundary strips thinking on survivors after a real phase-3 chain drop", async () => {
|
|
254
|
-
const indexer = new ToolCallIndexer();
|
|
255
|
-
const blockRefs = new BlockRefIssuer();
|
|
256
|
-
indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "tc1" }]);
|
|
257
|
-
indexer.registerSummaryBody(["tc1"], "summary of batch 1");
|
|
258
|
-
|
|
259
|
-
const chain: ChainRange = {
|
|
260
|
-
startUserTimestamp: 100,
|
|
261
|
-
middleToolCallIds: ["tc1"],
|
|
262
|
-
finalAssistantTimestamp: 400,
|
|
263
|
-
};
|
|
264
|
-
const { compressedEntries } = await compressEligible([chain], 0, {
|
|
265
|
-
indexer,
|
|
266
|
-
blockRefs,
|
|
267
|
-
appendEntry: () => {},
|
|
268
|
-
now: () => 999,
|
|
269
|
-
});
|
|
270
|
-
expect(compressedEntries).toHaveLength(1);
|
|
271
|
-
|
|
272
|
-
// A later assistant turn (ts 500) that carries thinking and sits OLDER than the boundary.
|
|
273
|
-
const messages: any[] = [
|
|
274
|
-
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 },
|
|
275
|
-
{ role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash", arguments: {} }], timestamp: 200, usage: {}, stopReason: "tool_use" },
|
|
276
|
-
{ role: "toolResult", toolCallId: "tc1", toolName: "bash", content: [{ type: "text", text: "o1" }], isError: false, timestamp: 210 },
|
|
277
|
-
{ role: "assistant", content: [{ type: "text", text: "mid" }], timestamp: 400, usage: {}, stopReason: "end_turn" },
|
|
278
|
-
{ role: "assistant", content: [{ type: "thinking", thinking: "old-think", thinkingSignature: "s" }, { type: "text", text: "after" }], timestamp: 500, usage: {}, stopReason: "stop" },
|
|
279
|
-
];
|
|
280
|
-
|
|
281
|
-
const cc: ChainCompressionConfig = { enabled: true, rollingWindow: 0, stripFinalAssistantThinking: true, fuseRangeSummary: false };
|
|
282
|
-
const strip: ThinkingStripConfig = { enabled: true, keepLastTurns: 16 };
|
|
283
|
-
// Boundary 600: the ts=500 assistant is older -> its thinking must be stripped,
|
|
284
|
-
// even though phase 3 has dropped the tc1 chain from the array first.
|
|
285
|
-
const { messages: out, pruned } = pruneMessages(messages, indexer, cc, undefined, strip, undefined, 0, 600);
|
|
286
|
-
expect(pruned).toBe(true);
|
|
287
|
-
// Chain middle dropped:
|
|
288
|
-
expect(out.filter((m: any) => m.role === "toolResult")).toHaveLength(0);
|
|
289
|
-
// Surviving ts=500 assistant older than boundary 600 -> thinking stripped:
|
|
290
|
-
const late = out.find((m: any) => m.role === "assistant" && m.timestamp === 500);
|
|
291
|
-
expect(late).toBeDefined();
|
|
292
|
-
expect(late.content.some((c: any) => c.type === "thinking")).toBe(false);
|
|
293
|
-
});
|
|
294
257
|
});
|
|
@@ -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;
|