pi-condense 2.11.1 → 2.11.3
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 +15 -0
- package/PRUNING.md +12 -7
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/batch-capture.test.ts +63 -0
- package/src/batch-capture.ts +21 -5
- package/src/commands.ts +1 -1
- package/src/context-metrics.ts +1 -1
- package/src/error-purge.test.ts +103 -16
- package/src/error-purge.transport.test.ts +89 -0
- package/src/error-purge.ts +40 -3
- package/src/pruner.test.ts +18 -3
- package/src/pruner.ts +7 -6
- package/src/query-tool.test.ts +63 -4
- package/src/query-tool.ts +18 -3
- package/src/spill.test.ts +1 -1
- package/src/summarizer.test.ts +46 -2
- package/src/summarizer.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense) (
|
|
|
7
7
|
Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the tests and
|
|
8
8
|
publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
|
|
9
9
|
|
|
10
|
+
## [2.11.3] - 2026-10-04
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Error purge keeps a failed tool call's argument keys, types, and array lengths and shrinks only strings over 200 characters, so grammar-constrained tools such as `codemode` no longer fail every later request with `Grammar tool call "codemode" requires argument "code" to be a string` after a purge. (#19)
|
|
15
|
+
|
|
16
|
+
## [2.11.2] - 2026-09-29
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Summaries of image-bearing tool results now say an image was returned instead of reporting the read as empty; the summarizer is told never to describe an image it cannot see.
|
|
21
|
+
- `context_tree_query` returns the original image blocks of a pruned image result instead of only its text.
|
|
22
|
+
- Pre-flush dedup no longer aliases different screenshots whose tool text is identical; image data is part of the dedup key.
|
|
23
|
+
- The live reclaim figure prices image blocks at the flat 1,600-token estimate instead of counting base64 as text.
|
|
24
|
+
|
|
10
25
|
## [2.11.1] - 2026-09-29
|
|
11
26
|
|
|
12
27
|
### Fixed
|
package/PRUNING.md
CHANGED
|
@@ -312,7 +312,7 @@ The intended recovery flow is:
|
|
|
312
312
|
3. The model decides the summary is not enough and wants exact raw output.
|
|
313
313
|
4. The model calls `context_tree_query({ toolCallIds: ["t1", ...] })`. The tool accepts short refs and full `toolCallId`s interchangeably (`indexer.resolveToolCallId`).
|
|
314
314
|
5. The tool looks up those IDs in the pruner index.
|
|
315
|
-
6. The tool returns the original stored output back into the current turn.
|
|
315
|
+
6. The tool returns the original stored output back into the current turn. For each `[image returned: ...]` marker in a recovered record, it appends the original image block from the current session branch whose SHA-256 prefix matches; a record with no marker, or whose image is off the branch, returns text only.
|
|
316
316
|
7. The model can now inspect that raw result and continue reasoning.
|
|
317
317
|
|
|
318
318
|
### ASCII: end-to-end "prune, then re-read" flow
|
|
@@ -690,10 +690,12 @@ Mechanism:
|
|
|
690
690
|
- Keying by occurrence key, not bare id, means a duplicate is only ever aliased to the specific earlier *occurrence* it matches byte-for-byte - a reused id whose later occurrence has different content is not conflated with the stale one.
|
|
691
691
|
- A `context-prune-dedup-alias` custom entry is persisted so `reconstructFromSession` rebuilds the maps after a restart.
|
|
692
692
|
4. The duplicate is removed from the batch — no summarizer call, no new index entry.
|
|
693
|
-
5. Later, `pruneMessages` stub-replaces the duplicate's `ToolResultMessage` using the original's short ref, and `context_tree_query` returns the original's record whether the model passes the duplicate's id or the original's.
|
|
693
|
+
5. Later, `pruneMessages` stub-replaces the duplicate's `ToolResultMessage` using the original's short ref, and `context_tree_query` returns the original's record whether the model passes the duplicate's id or the original's. An aliased image result recovers the original's image blocks (see [How the Model Re-reads Raw Outputs](#how-the-model-re-reads-raw-outputs)).
|
|
694
694
|
|
|
695
695
|
Normalization is conservative: `\r\n` → `\n`, per-line trailing whitespace stripping, final `trim()`. Internal whitespace, tabs, and capitalization are preserved so two genuinely different outputs do **not** collide.
|
|
696
696
|
|
|
697
|
+
Image blocks enter `resultText` as leading marker lines `[image returned: <mimeType> sha256:<8hex>]` (`extractToolResultText`, `src/batch-capture.ts`), so the marker is part of the dedup key and two image results alias only when their base64 data matches to the 8-hex prefix. Results captured before the marker existed carry none.
|
|
698
|
+
|
|
697
699
|
Typical wins: re-reading an unchanged file, repeated `git status` / `ls`, retries of the same command. v1 only matches against records **already in the indexer** (cross-flush dedup); intra-flush dedup is deferred so canonicals that get skipped as oversized / trivial never produce dangling aliases.
|
|
698
700
|
|
|
699
701
|
Edit with `/pruner dedup on|off|status` or the settings overlay.
|
|
@@ -715,7 +717,7 @@ The frontier's `lastAttemptedTurnIndex` uses a session-wide numbering domain: it
|
|
|
715
717
|
- **Tree browser (`/pruner tree`):** interactive, foldable tree of pruned tool calls grouped under their summaries. `Ctrl-O` on a summary node opens the full markdown summary in a bordered overlay.
|
|
716
718
|
- **Configurable summarizer thinking (`summarizerThinking`):** trade summary cost / latency for quality (`off` / `minimal` / `low` / `medium` / `high` / `xhigh`). `default` omits the option entirely so the provider chooses.
|
|
717
719
|
- **Cumulative stats:** `context-prune-stats` entries track input/output tokens and cost of every summarizer call; full detail surfaces in `/pruner stats`. Cost is also emitted on the `cost:external` pi.events channel for external aggregators (cumulative per session, live only).
|
|
718
|
-
- **Live reclaim ratio:** measured once per `pruneMessages` call via `sizeMessages(messages) = JSON
|
|
720
|
+
- **Live reclaim ratio:** measured once per `pruneMessages` call via `sizeMessages(messages)` = the sum of `charsOf(message)` (`src/context-metrics.ts`): whole-message JSON chars with each image block priced at a flat 1,600 tokens (6,400 chars) instead of its base64 length, the same estimate the frontier-gap trigger uses. This compares the input array before pruning to the result after. Estimated tokens = chars / 4. The measurement covers all five phases in a single point (stub-replace, supersede, error-purge, chain-range-prune, orphan-sweep); appears on the status line as `│ prune: ON · 92.0k->14.0k (-85%)` once at least one prune has occurred (the leading `│` keeps the segment visually isolated in the shared footer, load-order independent - there is no trailing divider, since the footer's own space-join between segments already provides one).
|
|
719
721
|
- **Live progress for `/pruner now`:** an `aboveEditor` widget shows one row per pending batch with braille spinner, streamed summary-char count, and ✓ / ⚠ status.
|
|
720
722
|
|
|
721
723
|
### Summarizer outage fallback
|
|
@@ -1094,23 +1096,26 @@ Short refs (`tN`) always resolve 1:1 - one ref, one record - and are the primary
|
|
|
1094
1096
|
|
|
1095
1097
|
Failed tool calls often embed large argument bodies in the assistant message — a `write` call with a 30 KB file body, an `edit` call with a multiline diff. The error result is small (e.g. `"Error: file not found"`), but the original `arguments` stay in the assistant turn indefinitely.
|
|
1096
1098
|
|
|
1097
|
-
Error purge
|
|
1099
|
+
Error purge shrinks the large string values inside those argument bodies after the error has cooled down. Every key, JSON type, and array length survives; only strings longer than 200 characters (`PURGE_STRING_MIN_CHARS` in `src/error-purge.ts`, not configurable) become a placeholder carrying the original length:
|
|
1098
1100
|
|
|
1099
1101
|
```
|
|
1100
|
-
{ "
|
|
1102
|
+
{ "path": "src/foo.ts", "content": "<purged-errored-args size=\"30000\"/>" }
|
|
1101
1103
|
```
|
|
1102
1104
|
|
|
1105
|
+
Providers validate replayed tool calls against the declared schema - pi-ai's grammar-constrained tools (`codemode`) require the input property to be a string - so the shape must stay intact. The 200-character floor keeps `path`, a short `command`, or a small `newText` readable; it is a constant because nobody has a reason to tune a readability guard.
|
|
1106
|
+
|
|
1103
1107
|
**What triggers a purge:**
|
|
1104
1108
|
|
|
1105
1109
|
- The matching `ToolResultMessage` has `isError: true`.
|
|
1106
1110
|
- The error occurred at least `purgeErrors.cooldownTurns` assistant turns ago (default 2). The cooldown gives the model 1–2 turns to retry before context is mutated.
|
|
1107
|
-
- The JSON-stringified argument body is at least `purgeErrors.minArgChars` characters long (default 500). Small args are not worth the substitution.
|
|
1111
|
+
- The JSON-stringified argument body is at least `purgeErrors.minArgChars` characters long (default 500). Small args are not worth the substitution. A body that qualifies but holds no string over 200 characters is returned unchanged.
|
|
1108
1112
|
|
|
1109
1113
|
**What error purge does NOT touch:**
|
|
1110
1114
|
|
|
1111
1115
|
- The `ToolResultMessage` content — the error message stays visible so the model can see what went wrong.
|
|
1112
1116
|
- Non-errored `toolCall` argument bodies.
|
|
1113
1117
|
- Argument bodies below `minArgChars`.
|
|
1118
|
+
- Keys, non-string values, array lengths, and strings of 200 characters or fewer inside a purged body.
|
|
1114
1119
|
- Anything when `purgeErrors.enabled` is `false`.
|
|
1115
1120
|
|
|
1116
1121
|
**Transform position:** Error purge runs in Phase 2, after stub-replace and before chain range prune.
|
|
@@ -1125,7 +1130,7 @@ Error purge replaces those arg bodies with compact stubs after the error has coo
|
|
|
1125
1130
|
|---|---|---|
|
|
1126
1131
|
| `purgeErrors.enabled` | `true` | Master toggle |
|
|
1127
1132
|
| `purgeErrors.cooldownTurns` | `2` | Turns to wait after error before purging |
|
|
1128
|
-
| `purgeErrors.minArgChars` | `500` | Minimum
|
|
1133
|
+
| `purgeErrors.minArgChars` | `500` | Minimum JSON body size for a failed call to be scanned; only strings over 200 chars inside it are replaced |
|
|
1129
1134
|
|
|
1130
1135
|
---
|
|
1131
1136
|
|
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ Every summarizer cost update is emitted on the shared `pi.events` channel `cost:
|
|
|
82
82
|
| Term | Meaning |
|
|
83
83
|
|---|---|
|
|
84
84
|
| Stub | The short breadcrumb (`[Summarized in pruner summary, ref \`t1\`...]`) that replaces a pruned tool result in context |
|
|
85
|
-
| `context_tree_query` | The tool the model calls to recover a stubbed original by ref (`tN`) or `toolCallId`. A reused id returns every matching occurrence, not just one, including any that were content-deduplicated to an earlier record - see [PRUNING.md § Occurrence Identity](PRUNING.md#occurrence-identity) |
|
|
85
|
+
| `context_tree_query` | The tool the model calls to recover a stubbed original by ref (`tN`) or `toolCallId`. Image results come back with their original image blocks. A reused id returns every matching occurrence, not just one, including any that were content-deduplicated to an earlier record - see [PRUNING.md § Occurrence Identity](PRUNING.md#occurrence-identity) |
|
|
86
86
|
| Batch vs chain | A batch is one flush's worth of tool calls; a chain is a longer closed sequence eligible for range compression |
|
|
87
87
|
| Prune frontier | The last attempted prune boundary - advances even on a skip, so nothing is reconsidered twice |
|
|
88
88
|
| Diagnostics (`diag u/m/o/b`) | A self-hiding status-line segment surfacing prune-time degradations: `u` = unresolved chain range, `m` = detection/render id mismatch (informational, does not change what's dropped), `o` = orphan tool-result sweep, `b` = a zero-coverage chain with nothing left to backfill (genuine span mismatch, see below). Each letter's count is omitted when zero; the whole segment disappears when all four are zero. Backing session entries are `context-prune-diagnostic` - see below |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.3",
|
|
4
4
|
"description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|
|
@@ -3,10 +3,14 @@ import {
|
|
|
3
3
|
captureBatch,
|
|
4
4
|
captureUnindexedBatchesFromSession,
|
|
5
5
|
deriveLiveTurnIndex,
|
|
6
|
+
extractToolResultText,
|
|
7
|
+
imageDigest,
|
|
8
|
+
imageMarkerDigests,
|
|
6
9
|
projectBranchMessages,
|
|
7
10
|
serializeBatchForSummarizer,
|
|
8
11
|
} from "./batch-capture.js";
|
|
9
12
|
import type { CapturedBatch, CapturedToolCall } from "./types.js";
|
|
13
|
+
import { ToolCallIndexer } from "./indexer.js";
|
|
10
14
|
|
|
11
15
|
function toolCall(overrides: Partial<CapturedToolCall> = {}): CapturedToolCall {
|
|
12
16
|
return {
|
|
@@ -278,3 +282,62 @@ describe("custom-anchor group boundary", () => {
|
|
|
278
282
|
expect(batches[0].userTurnGroup).toBe(batches[1].userTurnGroup);
|
|
279
283
|
});
|
|
280
284
|
});
|
|
285
|
+
|
|
286
|
+
const img = (data: string, mimeType = "image/png") => ({ type: "image", data, mimeType });
|
|
287
|
+
|
|
288
|
+
describe("image markers", () => {
|
|
289
|
+
test("markers lead the text, in block order, with mime type and 8-hex hash", () => {
|
|
290
|
+
const text = extractToolResultText({
|
|
291
|
+
content: [{ type: "text", text: "Read image file [image/png]" }, img("AAAA"), img("BBBB", "image/jpeg")],
|
|
292
|
+
});
|
|
293
|
+
expect(text.split("\n")).toEqual([
|
|
294
|
+
`[image returned: image/png sha256:${imageDigest("AAAA")}]`,
|
|
295
|
+
`[image returned: image/jpeg sha256:${imageDigest("BBBB")}]`,
|
|
296
|
+
"Read image file [image/png]",
|
|
297
|
+
]);
|
|
298
|
+
expect(imageDigest("AAAA")).toMatch(/^[0-9a-f]{8}$/);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("same base64 -> same marker, different base64 -> different marker", () => {
|
|
302
|
+
const a1 = extractToolResultText({ content: [img("AAAA")] });
|
|
303
|
+
const a2 = extractToolResultText({ content: [img("AAAA")] });
|
|
304
|
+
const b = extractToolResultText({ content: [img("BBBB")] });
|
|
305
|
+
expect(a1).toBe(a2);
|
|
306
|
+
expect(a1).not.toBe(b);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("text-only content is unchanged", () => {
|
|
310
|
+
expect(extractToolResultText({ content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] })).toBe("a\nb");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("imageMarkerDigests reads the digests back in marker order", () => {
|
|
314
|
+
const text = extractToolResultText({ content: [img("AAAA"), { type: "text", text: "x" }, img("BBBB")] });
|
|
315
|
+
expect(imageMarkerDigests(text)).toEqual([imageDigest("AAAA"), imageDigest("BBBB")]);
|
|
316
|
+
expect(imageMarkerDigests("no markers here")).toEqual([]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("a result with >2,000 text chars plus an image keeps the marker in the summarizer input", () => {
|
|
320
|
+
const message = { role: "assistant", content: [{ type: "toolCall", id: "r1", name: "read", input: { path: "a.png" } }], timestamp: 1 };
|
|
321
|
+
const results = [
|
|
322
|
+
{ role: "toolResult", toolCallId: "r1", toolName: "read", content: [{ type: "text", text: "x".repeat(5000) }, img("AAAA")], isError: false, timestamp: 2 },
|
|
323
|
+
];
|
|
324
|
+
const out = serializeBatchForSummarizer(captureBatch(message, results, 0, 9999));
|
|
325
|
+
expect(out).toContain(`[image returned: image/png sha256:${imageDigest("AAAA")}]`);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("identical text with different data misses lookupByContent; identical data hits", () => {
|
|
329
|
+
const captureRead = (id: string, data: string, ts: number) =>
|
|
330
|
+
captureBatch(
|
|
331
|
+
{ role: "assistant", content: [{ type: "toolCall", id, name: "read", input: { path: "s.png" } }], timestamp: ts },
|
|
332
|
+
[{ role: "toolResult", toolCallId: id, toolName: "read", content: [{ type: "text", text: "Read image file [image/png]" }, img(data)], isError: false, timestamp: ts + 1 }],
|
|
333
|
+
0,
|
|
334
|
+
ts
|
|
335
|
+
);
|
|
336
|
+
const idx = new ToolCallIndexer();
|
|
337
|
+
idx.addBatch(captureRead("r1", "AAAA", 100), () => {});
|
|
338
|
+
const other = captureRead("r2", "BBBB", 200).toolCalls[0];
|
|
339
|
+
expect(idx.lookupByContent(other.toolName, other.resultText)).toBeUndefined();
|
|
340
|
+
const same = captureRead("r3", "AAAA", 300).toolCalls[0];
|
|
341
|
+
expect(idx.lookupByContent(same.toolName, same.resultText)).toBe("r1@101");
|
|
342
|
+
});
|
|
343
|
+
});
|
package/src/batch-capture.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import type { CapturedBatch, CapturedToolCall, BatchingMode } from "./types.js";
|
|
3
4
|
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
@@ -44,13 +45,28 @@ function projectCustomMessageEntry(e: any): any {
|
|
|
44
45
|
return { role: "custom", customType: e.customType, content: e.content, display: e.display, details: e.details, timestamp: new Date(e.timestamp).getTime() };
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
export function imageDigest(data: string): string {
|
|
49
|
+
return createHash("sha256").update(data).digest("hex").slice(0, 8);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const IMAGE_MARKER_DIGEST = /\[image returned: [^\]\n]* sha256:([0-9a-f]{8})\]/g;
|
|
53
|
+
|
|
54
|
+
export function imageMarkerDigests(text: string): string[] {
|
|
55
|
+
return [...text.matchAll(IMAGE_MARKER_DIGEST)].map((m) => m[1]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Joins a ToolResultMessage's content into one string: one marker line per
|
|
60
|
+
* image block, then the text blocks. Markers lead because the summarizer
|
|
61
|
+
* input keeps only the first 2,000 result chars.
|
|
62
|
+
*/
|
|
48
63
|
export function extractToolResultText(msg: any): string {
|
|
49
64
|
const content: any[] = Array.isArray(msg?.content) ? msg.content : [];
|
|
50
|
-
|
|
51
|
-
.filter((c: any) => c.type === "
|
|
52
|
-
.map((c: any) => c.
|
|
53
|
-
|
|
65
|
+
const markers = content
|
|
66
|
+
.filter((c: any) => c.type === "image")
|
|
67
|
+
.map((c: any) => `[image returned: ${c.mimeType} sha256:${imageDigest(c.data)}]`);
|
|
68
|
+
const texts = content.filter((c: any) => c.type === "text").map((c: any) => c.text);
|
|
69
|
+
return [...markers, ...texts].join("\n");
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
/**
|
package/src/commands.ts
CHANGED
|
@@ -675,7 +675,7 @@ export function registerCommands(
|
|
|
675
675
|
label: "Error purge",
|
|
676
676
|
values: ["true", "false"],
|
|
677
677
|
currentValue: String(config.purgeErrors.enabled),
|
|
678
|
-
description: `
|
|
678
|
+
description: `Shrink long strings inside failed toolCall arguments after a cooldown, keeping argument shape. Reclaims context from large write/edit args that will never succeed. Currently ${config.purgeErrors.enabled ? "ON" : "OFF"}.`,
|
|
679
679
|
},
|
|
680
680
|
{
|
|
681
681
|
id: "purgeErrorsCooldown",
|
package/src/context-metrics.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { ContextMetricsSnapshot, PruneFrontier } from "./types.js";
|
|
|
4
4
|
|
|
5
5
|
const IMAGE_TOKEN_ESTIMATE = 1600;
|
|
6
6
|
|
|
7
|
-
function charsOf(msg: any): number {
|
|
7
|
+
export function charsOf(msg: any): number {
|
|
8
8
|
if (!Array.isArray(msg.content)) return JSON.stringify(msg).length;
|
|
9
9
|
let imageCount = 0;
|
|
10
10
|
const content = msg.content.map((block: any) => {
|
package/src/error-purge.test.ts
CHANGED
|
@@ -8,6 +8,9 @@ const defaultConfig: ErrorPurgeConfig = {
|
|
|
8
8
|
minArgChars: 10,
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
const LONG = "x".repeat(300);
|
|
12
|
+
const PLACEHOLDER_RE = /^<purged-errored-args size="\d+"\/>$/;
|
|
13
|
+
|
|
11
14
|
function makeAssistant(toolCallId: string, argsObj: Record<string, any>, turnN?: number) {
|
|
12
15
|
return {
|
|
13
16
|
role: "assistant",
|
|
@@ -47,7 +50,7 @@ describe("purgeErroredArgs", () => {
|
|
|
47
50
|
it("does not purge while still within cooldown", () => {
|
|
48
51
|
// Error at turn 1, current = turn 2, age = 1 < cooldownTurns 2
|
|
49
52
|
const messages = [
|
|
50
|
-
makeAssistant("tc1", { content:
|
|
53
|
+
makeAssistant("tc1", { content: LONG }),
|
|
51
54
|
makeToolResult("tc1", true),
|
|
52
55
|
makeAssistant("tc2", { cmd: "ls" }),
|
|
53
56
|
makeToolResult("tc2", false),
|
|
@@ -55,11 +58,11 @@ describe("purgeErroredArgs", () => {
|
|
|
55
58
|
const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2 });
|
|
56
59
|
expect(result).toBe(messages);
|
|
57
60
|
const asstMsg = result[0] as any;
|
|
58
|
-
expect(asstMsg.content[0].arguments).toEqual({ content:
|
|
61
|
+
expect(asstMsg.content[0].arguments).toEqual({ content: LONG });
|
|
59
62
|
});
|
|
60
63
|
|
|
61
64
|
it("purges args after cooldown when args meet minArgChars", () => {
|
|
62
|
-
const largeArgs = { content:
|
|
65
|
+
const largeArgs = { content: LONG };
|
|
63
66
|
const messages = [
|
|
64
67
|
makeAssistant("tc1", largeArgs),
|
|
65
68
|
makeToolResult("tc1", true),
|
|
@@ -71,9 +74,8 @@ describe("purgeErroredArgs", () => {
|
|
|
71
74
|
const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars: 10 });
|
|
72
75
|
expect(result).not.toBe(messages);
|
|
73
76
|
const purgedAsst = result[0] as any;
|
|
74
|
-
const originalArgLen = JSON.stringify(largeArgs).length;
|
|
75
77
|
expect(purgedAsst.content[0].arguments).toEqual({
|
|
76
|
-
|
|
78
|
+
content: `<purged-errored-args size="${LONG.length}"/>`,
|
|
77
79
|
});
|
|
78
80
|
// toolResult stays unchanged
|
|
79
81
|
expect((result[1] as any).content[0].text).toBe("Error: file not found");
|
|
@@ -81,7 +83,7 @@ describe("purgeErroredArgs", () => {
|
|
|
81
83
|
|
|
82
84
|
it("does not purge when args are below minArgChars", () => {
|
|
83
85
|
const messages = [
|
|
84
|
-
makeAssistant("tc1", { x:
|
|
86
|
+
makeAssistant("tc1", { x: LONG }),
|
|
85
87
|
makeToolResult("tc1", true),
|
|
86
88
|
makeAssistant("tc2", { cmd: "ls" }),
|
|
87
89
|
makeToolResult("tc2", false),
|
|
@@ -94,7 +96,7 @@ describe("purgeErroredArgs", () => {
|
|
|
94
96
|
|
|
95
97
|
it("does not purge when isError is false", () => {
|
|
96
98
|
const messages = [
|
|
97
|
-
makeAssistant("tc1", { content:
|
|
99
|
+
makeAssistant("tc1", { content: LONG }),
|
|
98
100
|
makeToolResult("tc1", false), // NOT an error
|
|
99
101
|
makeAssistant("tc2", { cmd: "ls" }),
|
|
100
102
|
makeToolResult("tc2", false),
|
|
@@ -106,7 +108,7 @@ describe("purgeErroredArgs", () => {
|
|
|
106
108
|
});
|
|
107
109
|
|
|
108
110
|
it("only purges errored toolCalls in a multi-toolCall assistant message", () => {
|
|
109
|
-
const largeArgs = { content:
|
|
111
|
+
const largeArgs = { content: LONG };
|
|
110
112
|
const okArgs = { cmd: "ls" };
|
|
111
113
|
const messages = [
|
|
112
114
|
// One assistant with two toolCalls: tc-err is errored, tc-ok is not
|
|
@@ -129,16 +131,15 @@ describe("purgeErroredArgs", () => {
|
|
|
129
131
|
expect(result).not.toBe(messages);
|
|
130
132
|
const asst = result[0] as any;
|
|
131
133
|
// errored one is purged
|
|
132
|
-
const originalArgLen = JSON.stringify(largeArgs).length;
|
|
133
134
|
expect(asst.content[0].arguments).toEqual({
|
|
134
|
-
|
|
135
|
+
content: `<purged-errored-args size="${LONG.length}"/>`,
|
|
135
136
|
});
|
|
136
137
|
// non-errored one is untouched
|
|
137
138
|
expect(asst.content[1].arguments).toEqual(okArgs);
|
|
138
139
|
});
|
|
139
140
|
|
|
140
141
|
it("does not mutate the input messages array or any message object", () => {
|
|
141
|
-
const largeArgs = { content:
|
|
142
|
+
const largeArgs = { content: LONG };
|
|
142
143
|
const messages = [
|
|
143
144
|
makeAssistant("tc1", largeArgs),
|
|
144
145
|
makeToolResult("tc1", true),
|
|
@@ -156,8 +157,8 @@ describe("purgeErroredArgs", () => {
|
|
|
156
157
|
});
|
|
157
158
|
|
|
158
159
|
it("exactly-at-cooldown boundary: age === cooldownTurns is purged", () => {
|
|
159
|
-
// Error at turn 1, 2 more assistant turns
|
|
160
|
-
const largeArgs = { content:
|
|
160
|
+
// Error at turn 1, 2 more assistant turns -> age = 2 = cooldownTurns (should purge)
|
|
161
|
+
const largeArgs = { content: LONG };
|
|
161
162
|
const messages = [
|
|
162
163
|
makeAssistant("tc1", largeArgs),
|
|
163
164
|
makeToolResult("tc1", true),
|
|
@@ -168,12 +169,12 @@ describe("purgeErroredArgs", () => {
|
|
|
168
169
|
];
|
|
169
170
|
const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars: 5 });
|
|
170
171
|
expect(result).not.toBe(messages);
|
|
171
|
-
expect((result[0] as any).content[0].arguments.
|
|
172
|
+
expect((result[0] as any).content[0].arguments.content).toMatch(PLACEHOLDER_RE);
|
|
172
173
|
});
|
|
173
174
|
|
|
174
175
|
it("one-below-cooldown boundary: age === cooldownTurns - 1 is NOT purged", () => {
|
|
175
|
-
// Error at turn 1, 1 more assistant turn
|
|
176
|
-
const largeArgs = { content:
|
|
176
|
+
// Error at turn 1, 1 more assistant turn -> age = 1 < cooldownTurns 2
|
|
177
|
+
const largeArgs = { content: LONG };
|
|
177
178
|
const messages = [
|
|
178
179
|
makeAssistant("tc1", largeArgs),
|
|
179
180
|
makeToolResult("tc1", true),
|
|
@@ -183,4 +184,90 @@ describe("purgeErroredArgs", () => {
|
|
|
183
184
|
const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars: 5 });
|
|
184
185
|
expect(result).toBe(messages);
|
|
185
186
|
});
|
|
187
|
+
|
|
188
|
+
function purgedAfterCooldown(args: Record<string, any>, name: string, minArgChars: number) {
|
|
189
|
+
const messages = [
|
|
190
|
+
{ ...makeAssistant("tc1", args), content: [{ type: "toolCall", id: "tc1", name, arguments: args }] },
|
|
191
|
+
{ ...makeToolResult("tc1", true), toolName: name },
|
|
192
|
+
makeAssistant("tc2", { cmd: "ls" }),
|
|
193
|
+
makeToolResult("tc2", false),
|
|
194
|
+
makeAssistant("tc3", { cmd: "pwd" }),
|
|
195
|
+
makeToolResult("tc3", false),
|
|
196
|
+
];
|
|
197
|
+
const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 2, minArgChars });
|
|
198
|
+
return { messages, result, args: (result[0] as any).content[0].arguments };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
it("keeps a grammar tool's input property a string (codemode, #19)", () => {
|
|
202
|
+
const { messages, result, args } = purgedAfterCooldown({ code: "x".repeat(1000) }, "codemode", 500);
|
|
203
|
+
expect(result).not.toBe(messages);
|
|
204
|
+
expect(typeof args.code).toBe("string");
|
|
205
|
+
expect(Object.keys(args)).toEqual(["code"]);
|
|
206
|
+
expect(args.code).toBe('<purged-errored-args size="1000"/>');
|
|
207
|
+
expect(JSON.stringify(args).length).toBeLessThan(500);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("write-shaped call keeps path verbatim and shrinks content", () => {
|
|
211
|
+
const { args } = purgedAfterCooldown({ path: "src/foo.ts", content: "x".repeat(30000) }, "write", 500);
|
|
212
|
+
expect(args.path).toBe("src/foo.ts");
|
|
213
|
+
expect(args.content).toBe('<purged-errored-args size="30000"/>');
|
|
214
|
+
expect(JSON.stringify(args).length).toBeLessThan(100);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("edit-shaped call keeps the edits array length and shrinks only long values", () => {
|
|
218
|
+
const original = {
|
|
219
|
+
path: "src/foo.ts",
|
|
220
|
+
edits: [
|
|
221
|
+
{ oldText: "a".repeat(15000), newText: "x" },
|
|
222
|
+
{ oldText: "y", newText: "b".repeat(15000) },
|
|
223
|
+
],
|
|
224
|
+
};
|
|
225
|
+
const { args } = purgedAfterCooldown(original, "edit", 500);
|
|
226
|
+
expect(args.path).toBe("src/foo.ts");
|
|
227
|
+
expect(Array.isArray(args.edits)).toBe(true);
|
|
228
|
+
expect(args.edits).toHaveLength(2);
|
|
229
|
+
expect(args.edits[0]).toEqual({ oldText: '<purged-errored-args size="15000"/>', newText: "x" });
|
|
230
|
+
expect(args.edits[1]).toEqual({ oldText: "y", newText: '<purged-errored-args size="15000"/>' });
|
|
231
|
+
expect(JSON.stringify(args).length).toBeLessThan(500);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("non-string scalars and short strings pass through by type", () => {
|
|
235
|
+
const { args } = purgedAfterCooldown({ line: 42, force: true, z: null, s: "short", big: "x".repeat(201) }, "bash", 10);
|
|
236
|
+
expect(args.line).toBe(42);
|
|
237
|
+
expect(args.force).toBe(true);
|
|
238
|
+
expect(args.z).toBeNull();
|
|
239
|
+
expect(args.s).toBe("short");
|
|
240
|
+
expect(args.big).toBe('<purged-errored-args size="201"/>');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("a string of exactly 200 chars stays verbatim", () => {
|
|
244
|
+
const { messages, result } = purgedAfterCooldown({ s: "x".repeat(200) }, "bash", 10);
|
|
245
|
+
expect(result).toBe(messages);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("eligible body made only of short strings is returned by identity", () => {
|
|
249
|
+
const original: Record<string, string> = {};
|
|
250
|
+
for (let i = 0; i < 600; i++) original[`k${i}`] = "v";
|
|
251
|
+
const { messages, result } = purgedAfterCooldown(original, "bash", 500);
|
|
252
|
+
expect(result).toBe(messages);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("a second pass over purged output returns the same reference", () => {
|
|
256
|
+
const { result } = purgedAfterCooldown({ path: "src/foo.ts", content: "x".repeat(1000) }, "write", 10);
|
|
257
|
+
expect(purgeErroredArgs(result, { ...defaultConfig, cooldownTurns: 2, minArgChars: 10 })).toBe(result);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("preserves own JSON keys even when a key is __proto__", () => {
|
|
261
|
+
const original = JSON.parse(`{"__proto__":"${LONG}","content":"${LONG}"}`);
|
|
262
|
+
const { args } = purgedAfterCooldown(original, "write", 10);
|
|
263
|
+
expect(Object.keys(args)).toEqual(Object.keys(original));
|
|
264
|
+
expect(args.__proto__).toBe('<purged-errored-args size="300"/>');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("never mutates the original nested arguments", () => {
|
|
268
|
+
const original = { path: "src/foo.ts", edits: [{ oldText: "a".repeat(300), newText: "x" }] };
|
|
269
|
+
const snapshot = structuredClone(original);
|
|
270
|
+
purgedAfterCooldown(original, "edit", 10);
|
|
271
|
+
expect(original).toEqual(snapshot);
|
|
272
|
+
});
|
|
186
273
|
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { getGrammarToolInput } from "@earendil-works/pi-ai/api/constrained-sampling";
|
|
3
|
+
import { stream } from "@earendil-works/pi-ai/api/openai-completions";
|
|
4
|
+
import type { Model, Tool } from "@earendil-works/pi-ai";
|
|
5
|
+
import { purgeErroredArgs } from "./error-purge.js";
|
|
6
|
+
|
|
7
|
+
// Chat Completions and Responses share getGrammarToolInput; one transport covers the throw.
|
|
8
|
+
const model: Model<"openai-completions"> = {
|
|
9
|
+
id: "grammar-test",
|
|
10
|
+
name: "grammar-test",
|
|
11
|
+
api: "openai-completions",
|
|
12
|
+
provider: "openai",
|
|
13
|
+
baseUrl: "http://127.0.0.1:9",
|
|
14
|
+
reasoning: false,
|
|
15
|
+
input: ["text"],
|
|
16
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
17
|
+
contextWindow: 128000,
|
|
18
|
+
maxTokens: 4096,
|
|
19
|
+
compat: { supportsOpenAIGrammarTools: true },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const codemode: Tool = {
|
|
23
|
+
name: "codemode",
|
|
24
|
+
description: "run code",
|
|
25
|
+
parameters: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: { code: { type: "string" } },
|
|
28
|
+
required: ["code"],
|
|
29
|
+
} as any,
|
|
30
|
+
constrainedSampling: { type: "grammar", variants: { openai_lark: 'start: /[\\s\\S]*/' } },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
|
|
34
|
+
const meta = { api: "openai-completions", provider: "openai", model: "grammar-test", usage };
|
|
35
|
+
|
|
36
|
+
function history(codeArgs: Record<string, unknown>) {
|
|
37
|
+
return [
|
|
38
|
+
{ role: "user", content: "run it", timestamp: 1 },
|
|
39
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "codemode", arguments: codeArgs }], ...meta, stopReason: "toolUse", timestamp: 2 },
|
|
40
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "codemode", content: [{ type: "text", text: "Error: boom" }], isError: true, timestamp: 3 },
|
|
41
|
+
{ role: "assistant", content: [{ type: "text", text: "retrying" }], ...meta, stopReason: "stop", timestamp: 4 },
|
|
42
|
+
{ role: "user", content: "again", timestamp: 5 },
|
|
43
|
+
{ role: "assistant", content: [{ type: "text", text: "ok" }], ...meta, stopReason: "stop", timestamp: 6 },
|
|
44
|
+
{ role: "user", content: "go", timestamp: 7 },
|
|
45
|
+
] as any[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function request(messages: any[]) {
|
|
49
|
+
let calls = 0;
|
|
50
|
+
const fetchStub = (async () => {
|
|
51
|
+
calls++;
|
|
52
|
+
throw new Error("connection refused by stub");
|
|
53
|
+
}) as unknown as typeof fetch;
|
|
54
|
+
const result = await stream(model, { messages, tools: [codemode] }, { apiKey: "test", fetch: fetchStub, maxRetries: 0 }).result();
|
|
55
|
+
return { result, calls };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("error purge x pi-ai grammar tools (#19)", () => {
|
|
59
|
+
const config = { enabled: true, cooldownTurns: 2, minArgChars: 500 };
|
|
60
|
+
|
|
61
|
+
it("purged codemode args pass getGrammarToolInput (reporter repro)", () => {
|
|
62
|
+
const purged = purgeErroredArgs(history({ code: "x".repeat(1000) }), config);
|
|
63
|
+
const args = purged[1].content[0].arguments;
|
|
64
|
+
expect(typeof args.code).toBe("string");
|
|
65
|
+
expect(Object.keys(args)).toEqual(["code"]);
|
|
66
|
+
expect(JSON.stringify(args).length).toBeLessThan(500);
|
|
67
|
+
expect(() => getGrammarToolInput("codemode", args, "code")).not.toThrow();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("request building succeeds on purged history; failure is the connection, not the grammar", async () => {
|
|
71
|
+
const purged = purgeErroredArgs(history({ code: "x".repeat(1000) }), config);
|
|
72
|
+
expect(purged[1].content[0].arguments.code).toMatch(/^<purged-errored-args size=/);
|
|
73
|
+
const { result, calls } = await request(purged);
|
|
74
|
+
expect(result.stopReason).toBe("error");
|
|
75
|
+
// The OpenAI SDK wraps fetch rejections in APIConnectionError and drops the cause message.
|
|
76
|
+
expect(result.errorMessage).toMatch(/Connection error/);
|
|
77
|
+
expect(result.errorMessage).not.toMatch(/Grammar tool call/);
|
|
78
|
+
expect(calls).toBe(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("negative control: the old { _purged } shape throws the grammar error before any request", async () => {
|
|
82
|
+
const legacy = history({ code: "x".repeat(1000) });
|
|
83
|
+
legacy[1].content[0].arguments = { _purged: '<purged-errored-args size="1012"/>' };
|
|
84
|
+
const { result, calls } = await request(legacy);
|
|
85
|
+
expect(result.stopReason).toBe("error");
|
|
86
|
+
expect(result.errorMessage).toMatch(/Grammar tool call "codemode" requires argument "code" to be a string/);
|
|
87
|
+
expect(calls).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
});
|
package/src/error-purge.ts
CHANGED
|
@@ -1,8 +1,42 @@
|
|
|
1
1
|
import type { ErrorPurgeConfig } from "./types.js";
|
|
2
2
|
|
|
3
|
+
// Keep short paths and commands readable while reclaiming large failed-call bodies.
|
|
4
|
+
const PURGE_STRING_MIN_CHARS = 200;
|
|
5
|
+
|
|
6
|
+
function shrinkStrings(value: unknown): [unknown, boolean] {
|
|
7
|
+
if (typeof value === "string") {
|
|
8
|
+
if (value.length > PURGE_STRING_MIN_CHARS) return [`<purged-errored-args size="${value.length}"/>`, true];
|
|
9
|
+
return [value, false];
|
|
10
|
+
}
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
let out: unknown[] | undefined;
|
|
13
|
+
for (let i = 0; i < value.length; i++) {
|
|
14
|
+
const [next, changed] = shrinkStrings(value[i]);
|
|
15
|
+
if (!changed) continue;
|
|
16
|
+
out ??= value.slice();
|
|
17
|
+
out[i] = next;
|
|
18
|
+
}
|
|
19
|
+
return out ? [out, true] : [value, false];
|
|
20
|
+
}
|
|
21
|
+
if (value !== null && typeof value === "object") {
|
|
22
|
+
const object = value as Record<string, unknown>;
|
|
23
|
+
let out: Record<string, unknown> | undefined;
|
|
24
|
+
for (const key of Object.keys(object)) {
|
|
25
|
+
const [next, changed] = shrinkStrings(object[key]);
|
|
26
|
+
if (!changed) continue;
|
|
27
|
+
// Spread creates own data properties, including JSON's possible __proto__ key.
|
|
28
|
+
out ??= { ...object };
|
|
29
|
+
out[key] = next;
|
|
30
|
+
}
|
|
31
|
+
return out ? [out, true] : [value, false];
|
|
32
|
+
}
|
|
33
|
+
return [value, false];
|
|
34
|
+
}
|
|
35
|
+
|
|
3
36
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
37
|
+
* Shrinks long strings in failed toolCall arguments after the cooldown window.
|
|
38
|
+
* Keys and JSON types must survive because pi-ai validates grammar-tool input
|
|
39
|
+
* properties when replaying calls.
|
|
6
40
|
*
|
|
7
41
|
* Why only the arguments, not the whole toolCall or its toolResult:
|
|
8
42
|
* - The toolResult content (e.g. "Error: file not found") is small and carries
|
|
@@ -58,8 +92,11 @@ export function purgeErroredArgs(messages: any[], config: ErrorPurgeConfig): any
|
|
|
58
92
|
const argBody = JSON.stringify(block.arguments);
|
|
59
93
|
if (argBody.length < config.minArgChars) return block;
|
|
60
94
|
|
|
95
|
+
const [shrunk, changed] = shrinkStrings(block.arguments);
|
|
96
|
+
if (!changed) return block;
|
|
97
|
+
|
|
61
98
|
contentModified = true;
|
|
62
|
-
return { ...block, arguments:
|
|
99
|
+
return { ...block, arguments: shrunk };
|
|
63
100
|
});
|
|
64
101
|
|
|
65
102
|
if (!contentModified) return msg;
|
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 { charsOf } from "./context-metrics.js";
|
|
3
4
|
import { ToolCallIndexer } from "./indexer.js";
|
|
4
5
|
import { CUSTOM_TYPE_INDEX } from "./types.js";
|
|
5
6
|
import type { ChainCompressionConfig, ChainCompressionEntry } from "./types.js";
|
|
@@ -277,7 +278,7 @@ describe("pruneMessages", () => {
|
|
|
277
278
|
|
|
278
279
|
it("purges errored toolCall args through errorPurge wiring", () => {
|
|
279
280
|
const indexer = makeMockIndexer();
|
|
280
|
-
const largeArgs = { content: "x".repeat(
|
|
281
|
+
const largeArgs = { path: "src/foo.ts", content: "x".repeat(300) };
|
|
281
282
|
const messages: any[] = [
|
|
282
283
|
{
|
|
283
284
|
role: "assistant",
|
|
@@ -343,7 +344,8 @@ describe("pruneMessages", () => {
|
|
|
343
344
|
expect(pruned).toBe(true);
|
|
344
345
|
const errAsst = out.find((m: any) => m.role === "assistant" && m.timestamp === 100) as any;
|
|
345
346
|
expect(errAsst).toBeDefined();
|
|
346
|
-
expect(errAsst.content[0].arguments.
|
|
347
|
+
expect(errAsst.content[0].arguments.path).toBe("src/foo.ts");
|
|
348
|
+
expect(errAsst.content[0].arguments.content).toMatch(/^<purged-errored-args size=/);
|
|
347
349
|
});
|
|
348
350
|
|
|
349
351
|
it("spill stub tolerates absent spillBytes/resultPreview", () => {
|
|
@@ -899,6 +901,19 @@ describe("occurrence-keyed coverage via mock indexer (spill / protection / grace
|
|
|
899
901
|
});
|
|
900
902
|
|
|
901
903
|
describe("sizeMessages", () => {
|
|
904
|
+
it("prices an image block with the flat charsOf estimate, not its base64 length", () => {
|
|
905
|
+
const msg = {
|
|
906
|
+
role: "toolResult",
|
|
907
|
+
toolCallId: "r1",
|
|
908
|
+
toolName: "read",
|
|
909
|
+
content: [{ type: "text", text: "Read image file [image/png]" }, { type: "image", data: "A".repeat(137000), mimeType: "image/png" }],
|
|
910
|
+
isError: false,
|
|
911
|
+
timestamp: 1,
|
|
912
|
+
};
|
|
913
|
+
expect(sizeMessages([msg])).toBe(charsOf(msg));
|
|
914
|
+
expect(sizeMessages([msg])).toBeLessThan(JSON.stringify(msg).length / 10);
|
|
915
|
+
});
|
|
916
|
+
|
|
902
917
|
it("counts hidden fields (thinking blocks), not just visible text", () => {
|
|
903
918
|
// Two messages with identical visible .text but different hidden content.
|
|
904
919
|
// sizeMessages must count the full serialized weight so all reclaim
|
|
@@ -995,7 +1010,7 @@ describe("G4/C3: orphan-sweep zero-fire proof across pruner fixtures", () => {
|
|
|
995
1010
|
}],
|
|
996
1011
|
["purges errored toolCall args through errorPurge wiring", () => {
|
|
997
1012
|
const indexer = makeMockIndexer();
|
|
998
|
-
const largeArgs = { content: "x".repeat(
|
|
1013
|
+
const largeArgs = { path: "src/foo.ts", content: "x".repeat(300) };
|
|
999
1014
|
const messages: any[] = [
|
|
1000
1015
|
{ role: "assistant", content: [{ type: "toolCall", id: "tc-err", name: "write", arguments: largeArgs }], timestamp: 100, usage: {}, stopReason: "tool_use" },
|
|
1001
1016
|
{ role: "toolResult", toolCallId: "tc-err", toolName: "write", content: [{ type: "text", text: "Error: permission denied" }], isError: true, timestamp: 110 },
|
package/src/pruner.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { charsOf } from "./context-metrics.js";
|
|
2
3
|
import type { ToolCallIndexer } from "./indexer.js";
|
|
3
4
|
import type { ChainCompressionConfig, ErrorPurgeConfig } from "./types.js";
|
|
4
5
|
import { isProtected, type ProtectionConfig } from "./protected.js";
|
|
@@ -11,13 +12,13 @@ import type { DiagnosticSink } from "./diagnostics.js";
|
|
|
11
12
|
import { applySupersede, type SupersedeState } from "./supersede.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
* Estimate of a message array's context weight
|
|
15
|
-
* (
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* Estimate of a message array's context weight: whole-message JSON chars
|
|
16
|
+
* (tool-call arguments, tool-result arrays, hidden blocks) so every reclaim
|
|
17
|
+
* mechanism registers, with image blocks at the flat estimate `charsOf`
|
|
18
|
+
* shares with the frontier-gap trigger.
|
|
18
19
|
*/
|
|
19
20
|
export function sizeMessages(messages: any[]): number {
|
|
20
|
-
return
|
|
21
|
+
return messages.reduce((sum, message) => sum + charsOf(message), 0);
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -154,7 +155,7 @@ export function pruneMessages(
|
|
|
154
155
|
}
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
// Phase 2: error purge
|
|
158
|
+
// Phase 2: error purge - shrink long strings in failed toolCall args after cooldown
|
|
158
159
|
if (errorPurge?.enabled) {
|
|
159
160
|
const afterPurge = purgeErroredArgs(current, errorPurge);
|
|
160
161
|
if (afterPurge !== current) {
|
package/src/query-tool.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { registerQueryTool } from "./query-tool.js";
|
|
3
|
+
import { imageDigest } from "./batch-capture.js";
|
|
3
4
|
import { ToolCallIndexer } from "./indexer.js";
|
|
4
5
|
import type { CapturedBatch } from "./types.js";
|
|
5
6
|
|
|
@@ -23,14 +24,72 @@ const captureLegacy = (idx: ToolCallIndexer, id: string, timestamp: number, text
|
|
|
23
24
|
idx.addBatch(batch, () => {});
|
|
24
25
|
};
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
const runTool = async (indexer: ToolCallIndexer, toolCallIds: string[]): Promise<string> => {
|
|
27
|
+
const runToolResult = async (indexer: ToolCallIndexer, toolCallIds: string[], branch: any[] = []): Promise<any> => {
|
|
28
28
|
let registered: any;
|
|
29
29
|
registerQueryTool({ registerTool: (def: any) => (registered = def) } as any, indexer);
|
|
30
|
-
|
|
31
|
-
return result.content[0].text as string;
|
|
30
|
+
return registered.execute("call-1", { toolCallIds }, undefined, undefined, { sessionManager: { getBranch: () => branch } });
|
|
32
31
|
};
|
|
33
32
|
|
|
33
|
+
const runTool = async (indexer: ToolCallIndexer, toolCallIds: string[], branch: any[] = []): Promise<string> =>
|
|
34
|
+
(await runToolResult(indexer, toolCallIds, branch)).content[0].text as string;
|
|
35
|
+
|
|
36
|
+
const png = (data: string) => ({ type: "image", data, mimeType: "image/png" });
|
|
37
|
+
const branchWith = (...images: any[]) => [
|
|
38
|
+
{ type: "message", message: { role: "toolResult", toolCallId: "x", content: [{ type: "text", text: "Read image file [image/png]" }, ...images] } },
|
|
39
|
+
];
|
|
40
|
+
const markerText = (data: string) => `[image returned: image/png sha256:${imageDigest(data)}]\nRead image file [image/png]`;
|
|
41
|
+
|
|
42
|
+
describe("context_tree_query image recovery", () => {
|
|
43
|
+
test("short ref returns only the image named by its marker", async () => {
|
|
44
|
+
const idx = new ToolCallIndexer();
|
|
45
|
+
capture(idx, "read_1", 1150, markerText("AAAA"), 0);
|
|
46
|
+
idx.registerSummaryRefs([{ shortId: "t1", toolCallId: "read_1", resultTimestamp: 1150 }]);
|
|
47
|
+
const result = await runToolResult(idx, ["t1"], branchWith(png("AAAA"), png("BBBB")));
|
|
48
|
+
expect(result.content[0].type).toBe("text");
|
|
49
|
+
expect(result.content.slice(1)).toEqual([png("AAAA")]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("explicit occurrence key returns the image", async () => {
|
|
53
|
+
const idx = new ToolCallIndexer();
|
|
54
|
+
capture(idx, "read_1", 1150, markerText("AAAA"), 0);
|
|
55
|
+
const result = await runToolResult(idx, ["read_1@1150"], branchWith(png("AAAA")));
|
|
56
|
+
expect(result.content.slice(1)).toEqual([png("AAAA")]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("bare alias id returns its original's image", async () => {
|
|
60
|
+
const idx = new ToolCallIndexer();
|
|
61
|
+
capture(idx, "read_1", 1150, markerText("AAAA"), 0);
|
|
62
|
+
idx.registerDuplicate("read_2@2150", "read_1@1150", () => {});
|
|
63
|
+
const result = await runToolResult(idx, ["read_2"], branchWith(png("AAAA")));
|
|
64
|
+
expect(result.content.slice(1)).toEqual([png("AAAA")]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("legacy record without marker returns text only", async () => {
|
|
68
|
+
const idx = new ToolCallIndexer();
|
|
69
|
+
captureLegacy(idx, "read_1", 1000, "Read image file [image/png]", 0);
|
|
70
|
+
expect((await runToolResult(idx, ["read_1"], branchWith(png("AAAA")))).content).toHaveLength(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("image off the current branch returns text only", async () => {
|
|
74
|
+
const idx = new ToolCallIndexer();
|
|
75
|
+
capture(idx, "read_1", 1150, markerText("AAAA"), 0);
|
|
76
|
+
expect((await runToolResult(idx, ["read_1"], branchWith(png("BBBB")))).content).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("pre-change alias between different images returns text only", async () => {
|
|
80
|
+
const idx = new ToolCallIndexer();
|
|
81
|
+
capture(idx, "read_1", 1150, "Read image file [image/png]", 0);
|
|
82
|
+
idx.registerDuplicate("read_2@2150", "read_1@1150", () => {});
|
|
83
|
+
expect((await runToolResult(idx, ["read_2"], branchWith(png("AAAA"), png("BBBB")))).content).toHaveLength(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("description mentions image blocks", () => {
|
|
87
|
+
let registered: any;
|
|
88
|
+
registerQueryTool({ registerTool: (def: any) => (registered = def) } as any, new ToolCallIndexer());
|
|
89
|
+
expect(registered.description).toContain("image blocks");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
34
93
|
describe("context_tree_query occurrence handling", () => {
|
|
35
94
|
test("a bare id with two occurrences returns both blocks, chronologically", async () => {
|
|
36
95
|
const idx = new ToolCallIndexer();
|
package/src/query-tool.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import { Type } from "@sinclair/typebox";
|
|
3
3
|
import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
6
|
+
import { imageDigest, imageMarkerDigests } from "./batch-capture.js";
|
|
5
7
|
import type { ToolCallIndexer } from "./indexer.js";
|
|
6
8
|
import { QUERY_TOOL_NAME } from "./types.js";
|
|
7
9
|
|
|
@@ -19,7 +21,7 @@ export function registerQueryTool(pi: ExtensionAPI, indexer: ToolCallIndexer): v
|
|
|
19
21
|
name: QUERY_TOOL_NAME,
|
|
20
22
|
label: "Query Original Tool History",
|
|
21
23
|
description:
|
|
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.",
|
|
24
|
+
"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. Results include the original image blocks when the pruned output carried images.",
|
|
23
25
|
promptSnippet: "Retrieve original pruned tool outputs by short ref",
|
|
24
26
|
promptGuidelines: [
|
|
25
27
|
"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.",
|
|
@@ -30,7 +32,15 @@ export function registerQueryTool(pi: ExtensionAPI, indexer: ToolCallIndexer): v
|
|
|
30
32
|
}),
|
|
31
33
|
}),
|
|
32
34
|
|
|
33
|
-
async execute(_toolCallId, params, _signal, _onUpdate,
|
|
35
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
36
|
+
const branchImages = new Map<string, ImageContent>();
|
|
37
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
38
|
+
if (entry.type !== "message" || entry.message.role !== "toolResult") continue;
|
|
39
|
+
for (const block of entry.message.content) {
|
|
40
|
+
if (block.type === "image") branchImages.set(imageDigest(block.data), block);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const images: ImageContent[] = [];
|
|
34
44
|
const foundRecords: Record<string, any> = {};
|
|
35
45
|
const blocks: string[] = [];
|
|
36
46
|
|
|
@@ -70,6 +80,11 @@ export function registerQueryTool(pi: ExtensionAPI, indexer: ToolCallIndexer): v
|
|
|
70
80
|
}
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
for (const digest of imageMarkerDigests(raw)) {
|
|
84
|
+
const image = branchImages.get(digest);
|
|
85
|
+
if (image) images.push(image);
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
const t = truncateHead(raw, {
|
|
74
89
|
maxLines: DEFAULT_MAX_LINES,
|
|
75
90
|
maxBytes: DEFAULT_MAX_BYTES,
|
|
@@ -87,7 +102,7 @@ export function registerQueryTool(pi: ExtensionAPI, indexer: ToolCallIndexer): v
|
|
|
87
102
|
const combined = blocks.join("\n\n---\n\n");
|
|
88
103
|
|
|
89
104
|
return {
|
|
90
|
-
content: [{ type: "text", text: combined }],
|
|
105
|
+
content: [{ type: "text", text: combined }, ...images],
|
|
91
106
|
details: { results: foundRecords },
|
|
92
107
|
};
|
|
93
108
|
},
|
package/src/spill.test.ts
CHANGED
|
@@ -152,7 +152,7 @@ describe("G4/C4: legacy bare-id sidecar recovery", () => {
|
|
|
152
152
|
|
|
153
153
|
let registered: any;
|
|
154
154
|
registerQueryTool({ registerTool: (def: any) => (registered = def) } as any, indexer);
|
|
155
|
-
const result = await registered.execute("call-1", { toolCallIds: ["bash_7"] }, undefined, undefined,
|
|
155
|
+
const result = await registered.execute("call-1", { toolCallIds: ["bash_7"] }, undefined, undefined, { sessionManager: { getBranch: () => [] } });
|
|
156
156
|
const text = result.content[0].text as string;
|
|
157
157
|
|
|
158
158
|
expect(text).toContain("OLD SPILLED BODY");
|
package/src/summarizer.test.ts
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
|
-
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import * as actualCompat from "@earendil-works/pi-ai/compat";
|
|
3
|
+
|
|
4
|
+
let seenInput: any;
|
|
5
|
+
mock.module("@earendil-works/pi-ai/compat", () => ({
|
|
6
|
+
...actualCompat,
|
|
7
|
+
stream: (_model: any, input: any) => {
|
|
8
|
+
seenInput = input;
|
|
9
|
+
return {
|
|
10
|
+
async *[Symbol.asyncIterator]() {},
|
|
11
|
+
async result() {
|
|
12
|
+
return {
|
|
13
|
+
stopReason: "stop",
|
|
14
|
+
content: [{ type: "text", text: "- summary" }],
|
|
15
|
+
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const { isUsableSummary, summarizeBatch } = await import("./summarizer.js");
|
|
23
|
+
const { DEFAULT_CONFIG } = await import("./types.js");
|
|
3
24
|
|
|
4
25
|
describe("isUsableSummary", () => {
|
|
5
26
|
it("accepts non-empty text that stopped normally", () => {
|
|
@@ -15,3 +36,26 @@ describe("isUsableSummary", () => {
|
|
|
15
36
|
expect(isUsableSummary("- partial", "length")).toBe(false);
|
|
16
37
|
});
|
|
17
38
|
});
|
|
39
|
+
|
|
40
|
+
describe("summarizer prompt", () => {
|
|
41
|
+
it("tells the model that an image marker is an image it cannot see", async () => {
|
|
42
|
+
const model = { id: "m", provider: "p", name: "M" };
|
|
43
|
+
const ctx = {
|
|
44
|
+
model,
|
|
45
|
+
modelRegistry: {
|
|
46
|
+
find: () => model,
|
|
47
|
+
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "k", headers: {} }),
|
|
48
|
+
getProviderAuth: async () => undefined,
|
|
49
|
+
},
|
|
50
|
+
ui: { notify() {} },
|
|
51
|
+
} as any;
|
|
52
|
+
const batch = {
|
|
53
|
+
turnIndex: 0,
|
|
54
|
+
timestamp: 0,
|
|
55
|
+
assistantText: "",
|
|
56
|
+
toolCalls: [{ toolCallId: "a", toolName: "read", args: {}, resultText: "[image returned: image/png sha256:3f9a2c1e]\nRead image file [image/png]", isError: false }],
|
|
57
|
+
} as any;
|
|
58
|
+
await summarizeBatch(batch, DEFAULT_CONFIG, ctx);
|
|
59
|
+
expect(JSON.stringify(seenInput)).toContain("means the tool returned an image you cannot see");
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/summarizer.ts
CHANGED
|
@@ -20,6 +20,8 @@ For each tool call provide:
|
|
|
20
20
|
|
|
21
21
|
Keep each tool call to 1-3 bullet points. Skip calls that succeeded with nothing reusable to record. Be concise.
|
|
22
22
|
|
|
23
|
+
A result line of the form [image returned: <type> sha256:<hash>] means the tool returned an image you cannot see: state that an image was returned, and never describe or guess its content.
|
|
24
|
+
|
|
23
25
|
Begin the first bullet of each tool call with that tool's [[N:toolname]] label, copied verbatim (both the number and the name) from its line in the input, as the plain, first thing on the line - no bold, backticks, or list numbering around it. Do not renumber, rename, or invent labels; if you skip a tool, skip its label too.`;
|
|
24
26
|
|
|
25
27
|
const RANGE_SYSTEM_PROMPT = `You are fusing several per-step summaries of one CLOSED sub-task from an AI coding assistant's history into a SINGLE cohesive summary.
|