pi-condense 2.11.2 → 2.11.4
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 +12 -0
- package/PRUNING.md +14 -4
- package/package.json +1 -1
- package/src/batch-capture.test.ts +70 -2
- package/src/batch-capture.ts +9 -6
- package/src/commands.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 +4 -3
- package/src/pruner.ts +1 -1
- package/src/summarizer.test.ts +23 -0
- package/src/summarizer.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ 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.4] - 2026-10-08
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- The summarizer now sees the first 4,000 and last 4,000 characters of each tool result instead of only the first 2,000, so an error at the end of a test run or the newest rows of a `git log` reach the summary instead of being reported as missing; the prompt tells the model an elided middle is omitted, not empty.
|
|
15
|
+
|
|
16
|
+
## [2.11.3] - 2026-10-04
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- 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)
|
|
21
|
+
|
|
10
22
|
## [2.11.2] - 2026-09-29
|
|
11
23
|
|
|
12
24
|
### Fixed
|
package/PRUNING.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
- [Stub-replace instead of delete](#stub-replace-instead-of-delete)
|
|
20
20
|
- [Protected tools](#protected-tools)
|
|
21
21
|
- [Eager single-result spill](#eager-single-result-spill)
|
|
22
|
+
- [Summarizer input window](#summarizer-input-window)
|
|
22
23
|
- [Trivial-batch skip (minBatchChars)](#trivial-batch-skip-minbatchchars)
|
|
23
24
|
- [Content-hash dedup](#content-hash-dedup)
|
|
24
25
|
- [Oversized summary skip](#oversized-summary-skip)
|
|
@@ -668,6 +669,12 @@ Full output: <sidecar path>. Use context_tree_query(<shortRef>) to retrieve.]
|
|
|
668
669
|
|
|
669
670
|
**Hybrid storage:** bodies below `spillThreshold` stay inline in the `context-prune-index` session entry (portable, as before); only oversized bodies are spilled. Moving the session `.jsonl` without its `-blobs/` directory loses only the giant-blob recovery path; the stub and head preview remain in the index entry.
|
|
670
671
|
|
|
672
|
+
### Summarizer input window
|
|
673
|
+
|
|
674
|
+
Each tool result reaches the summarizer LLM through a fixed head + tail window: results up to 8,000 chars are sent whole; longer ones are sent as their first 4,000 chars, a ` ...[N chars elided]... ` marker, and their last 4,000 chars. The prompt tells the model the elided region was removed, never that it was empty. Results at or above `spillThreshold` are spilled before any summarizer call and never windowed, unless the sidecar write fails; the result then flushes inline and is windowed like any other.
|
|
675
|
+
|
|
676
|
+
Why a window and not the whole result: summarizer input is paid on every flush, and budget-triggered sessions flush every turn, so an uncapped 64k-char result would cost about 16k tokens per summary and raise the chance of a `length`-stopped, unusable summary. Why head + tail and not head only: the facts a summary most often needs - the error at the end of a test run, the newest rows of a log - sit at the tail. The constants are internal, not settings; the fixed total is the per-result cost ceiling. The indexer keeps the full result; `context_tree_query` is unaffected.
|
|
677
|
+
|
|
671
678
|
### Trivial-batch skip (minBatchChars)
|
|
672
679
|
|
|
673
680
|
`minBatchChars: number` (default `1000`) is a pre-flush guard against "summary would be roughly the same size as the input" cases. If the total raw `resultText` across a batch is below the threshold, the batch is skipped: no summarizer LLM call, no `context-prune-index` entry, no `context-prune-summary` injection. The frontier still advances, so the same tool calls are not reconsidered next flush.
|
|
@@ -1096,23 +1103,26 @@ Short refs (`tN`) always resolve 1:1 - one ref, one record - and are the primary
|
|
|
1096
1103
|
|
|
1097
1104
|
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.
|
|
1098
1105
|
|
|
1099
|
-
Error purge
|
|
1106
|
+
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:
|
|
1100
1107
|
|
|
1101
1108
|
```
|
|
1102
|
-
{ "
|
|
1109
|
+
{ "path": "src/foo.ts", "content": "<purged-errored-args size=\"30000\"/>" }
|
|
1103
1110
|
```
|
|
1104
1111
|
|
|
1112
|
+
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.
|
|
1113
|
+
|
|
1105
1114
|
**What triggers a purge:**
|
|
1106
1115
|
|
|
1107
1116
|
- The matching `ToolResultMessage` has `isError: true`.
|
|
1108
1117
|
- 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.
|
|
1109
|
-
- The JSON-stringified argument body is at least `purgeErrors.minArgChars` characters long (default 500). Small args are not worth the substitution.
|
|
1118
|
+
- 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.
|
|
1110
1119
|
|
|
1111
1120
|
**What error purge does NOT touch:**
|
|
1112
1121
|
|
|
1113
1122
|
- The `ToolResultMessage` content — the error message stays visible so the model can see what went wrong.
|
|
1114
1123
|
- Non-errored `toolCall` argument bodies.
|
|
1115
1124
|
- Argument bodies below `minArgChars`.
|
|
1125
|
+
- Keys, non-string values, array lengths, and strings of 200 characters or fewer inside a purged body.
|
|
1116
1126
|
- Anything when `purgeErrors.enabled` is `false`.
|
|
1117
1127
|
|
|
1118
1128
|
**Transform position:** Error purge runs in Phase 2, after stub-replace and before chain range prune.
|
|
@@ -1127,7 +1137,7 @@ Error purge replaces those arg bodies with compact stubs after the error has coo
|
|
|
1127
1137
|
|---|---|---|
|
|
1128
1138
|
| `purgeErrors.enabled` | `true` | Master toggle |
|
|
1129
1139
|
| `purgeErrors.cooldownTurns` | `2` | Turns to wait after error before purging |
|
|
1130
|
-
| `purgeErrors.minArgChars` | `500` | Minimum
|
|
1140
|
+
| `purgeErrors.minArgChars` | `500` | Minimum JSON body size for a failed call to be scanned; only strings over 200 chars inside it are replaced |
|
|
1131
1141
|
|
|
1132
1142
|
---
|
|
1133
1143
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.4",
|
|
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",
|
|
@@ -58,6 +58,74 @@ describe("serializeBatchForSummarizer", () => {
|
|
|
58
58
|
expect(result).toContain("[[2:read]] Tool:");
|
|
59
59
|
expect(result).toContain("[[3:write]] Tool:");
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
const MARKER = /^ \.\.\.\[(\d+) chars elided\]\.\.\. $/;
|
|
63
|
+
|
|
64
|
+
function resultBody(serialized: string): string {
|
|
65
|
+
const prefix = "Result (OK): ";
|
|
66
|
+
return serialized.slice(serialized.indexOf(prefix) + prefix.length);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test("a result of 8,000 chars serializes whole with no marker", () => {
|
|
70
|
+
const raw = "a".repeat(8000);
|
|
71
|
+
const body = resultBody(serializeBatchForSummarizer(batch([toolCall({ resultText: raw })])));
|
|
72
|
+
expect(body).toBe(raw);
|
|
73
|
+
expect(body).not.toContain("chars elided");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a result of 8,001 chars becomes head 4,000 + marker + tail 4,000", () => {
|
|
77
|
+
const raw = "h".repeat(4000) + "m" + "t".repeat(4000);
|
|
78
|
+
const body = resultBody(serializeBatchForSummarizer(batch([toolCall({ resultText: raw })])));
|
|
79
|
+
expect(body).toBe("h".repeat(4000) + " ...[1 chars elided]... " + "t".repeat(4000));
|
|
80
|
+
expect(body.length).toBe(8024);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a result of 20,000 chars keeps raw.slice(0, 4000) and raw.slice(16000) around a 12000 marker", () => {
|
|
84
|
+
const raw = Array.from({ length: 20000 }, (_, i) => String.fromCharCode(97 + (i % 26))).join("");
|
|
85
|
+
const body = resultBody(serializeBatchForSummarizer(batch([toolCall({ resultText: raw })])));
|
|
86
|
+
expect(body.startsWith(raw.slice(0, 4000))).toBe(true);
|
|
87
|
+
expect(body.endsWith(raw.slice(16000))).toBe(true);
|
|
88
|
+
expect(body).toContain(" ...[12000 chars elided]... ");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a sentinel in the last 100 chars of a 5,175-char result survives; one at char 10,000 of 20,000 does not", () => {
|
|
92
|
+
const tailRaw = "x".repeat(5075) + "ERROR_AT_TAIL" + "x".repeat(5175 - 5075 - "ERROR_AT_TAIL".length);
|
|
93
|
+
expect(tailRaw.length).toBe(5175);
|
|
94
|
+
expect(serializeBatchForSummarizer(batch([toolCall({ resultText: tailRaw })]))).toContain("ERROR_AT_TAIL");
|
|
95
|
+
|
|
96
|
+
const midRaw = "x".repeat(10000) + "MID_SENTINEL" + "x".repeat(20000 - 10000 - "MID_SENTINEL".length);
|
|
97
|
+
expect(midRaw.length).toBe(20000);
|
|
98
|
+
expect(serializeBatchForSummarizer(batch([toolCall({ resultText: midRaw })]))).not.toContain("MID_SENTINEL");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("result body equals raw up to 8,000 and head + marker + tail above, for boundary lengths", () => {
|
|
102
|
+
for (const length of [0, 1, 7999, 8000, 8001, 65535, 1008000]) {
|
|
103
|
+
const raw = Array.from({ length }, (_, i) => String.fromCharCode(97 + (i % 26))).join("");
|
|
104
|
+
const body = resultBody(serializeBatchForSummarizer(batch([toolCall({ resultText: raw })])));
|
|
105
|
+
if (length <= 8000) {
|
|
106
|
+
expect(body).toBe(raw);
|
|
107
|
+
} else {
|
|
108
|
+
const marker = ` ...[${length - 8000} chars elided]... `;
|
|
109
|
+
expect(marker).toMatch(MARKER);
|
|
110
|
+
expect(body).toBe(raw.slice(0, 4000) + marker + raw.slice(-4000));
|
|
111
|
+
expect(body.length - raw.length).toBeLessThanOrEqual(marker.length);
|
|
112
|
+
expect(body.length).toBe(8000 + 23 + String(length - 8000).length);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("labels and Tool: lines are unchanged for windowed results", () => {
|
|
118
|
+
const b = batch([
|
|
119
|
+
toolCall({ toolCallId: "a", toolName: "read", resultText: "r".repeat(9000) }),
|
|
120
|
+
toolCall({ toolCallId: "b", toolName: "bash", resultText: "s".repeat(9000) }),
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
const result = serializeBatchForSummarizer(b);
|
|
124
|
+
|
|
125
|
+
expect(result).toContain("[[1:read]] Tool: read(");
|
|
126
|
+
expect(result).toContain("[[2:bash]] Tool: bash(");
|
|
127
|
+
expect(result.match(/chars elided/g)?.length).toBe(2);
|
|
128
|
+
});
|
|
61
129
|
});
|
|
62
130
|
|
|
63
131
|
describe("occurrence capture", () => {
|
|
@@ -316,10 +384,10 @@ describe("image markers", () => {
|
|
|
316
384
|
expect(imageMarkerDigests("no markers here")).toEqual([]);
|
|
317
385
|
});
|
|
318
386
|
|
|
319
|
-
test("a result with >
|
|
387
|
+
test("a result with >8,000 text chars plus an image keeps the marker in the summarizer input", () => {
|
|
320
388
|
const message = { role: "assistant", content: [{ type: "toolCall", id: "r1", name: "read", input: { path: "a.png" } }], timestamp: 1 };
|
|
321
389
|
const results = [
|
|
322
|
-
{ role: "toolResult", toolCallId: "r1", toolName: "read", content: [{ type: "text", text: "x".repeat(
|
|
390
|
+
{ role: "toolResult", toolCallId: "r1", toolName: "read", content: [{ type: "text", text: "x".repeat(9000) }, img("AAAA")], isError: false, timestamp: 2 },
|
|
323
391
|
];
|
|
324
392
|
const out = serializeBatchForSummarizer(captureBatch(message, results, 0, 9999));
|
|
325
393
|
expect(out).toContain(`[image returned: image/png sha256:${imageDigest("AAAA")}]`);
|
package/src/batch-capture.ts
CHANGED
|
@@ -57,8 +57,8 @@ export function imageMarkerDigests(text: string): string[] {
|
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Joins a ToolResultMessage's content into one string: one marker line per
|
|
60
|
-
* image block, then the text blocks. Markers lead
|
|
61
|
-
*
|
|
60
|
+
* image block, then the text blocks. Markers lead so they survive the
|
|
61
|
+
* summarizer's head + tail window.
|
|
62
62
|
*/
|
|
63
63
|
export function extractToolResultText(msg: any): string {
|
|
64
64
|
const content: any[] = Array.isArray(msg?.content) ? msg.content : [];
|
|
@@ -218,6 +218,10 @@ export function captureUnindexedBatchesFromSession(
|
|
|
218
218
|
return batches;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
// Fixed, not a setting: the 8,000-char total is the per-result ceiling on summarizer input.
|
|
222
|
+
const HEAD_CHARS = 4000;
|
|
223
|
+
const TAIL_CHARS = 4000;
|
|
224
|
+
|
|
221
225
|
/** Serializes a single CapturedBatch into readable text for the summarizer LLM. */
|
|
222
226
|
export function serializeBatchForSummarizer(batch: CapturedBatch): string {
|
|
223
227
|
const parts: string[] = [];
|
|
@@ -231,10 +235,9 @@ export function serializeBatchForSummarizer(batch: CapturedBatch): string {
|
|
|
231
235
|
const argsJson = JSON.stringify(tc.args, null, 2);
|
|
232
236
|
|
|
233
237
|
let resultText = tc.resultText;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
resultText = resultText.slice(0, MAX_CHARS) + ` ...[${remaining} chars truncated]`;
|
|
238
|
+
if (resultText.length > HEAD_CHARS + TAIL_CHARS) {
|
|
239
|
+
const elided = resultText.length - HEAD_CHARS - TAIL_CHARS;
|
|
240
|
+
resultText = `${resultText.slice(0, HEAD_CHARS)} ...[${elided} chars elided]... ${resultText.slice(-TAIL_CHARS)}`;
|
|
238
241
|
}
|
|
239
242
|
|
|
240
243
|
return `[[${index + 1}:${tc.toolName}]] Tool: ${tc.toolName}(${argsJson})\nResult (${status}): ${resultText}`;
|
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/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
|
@@ -278,7 +278,7 @@ describe("pruneMessages", () => {
|
|
|
278
278
|
|
|
279
279
|
it("purges errored toolCall args through errorPurge wiring", () => {
|
|
280
280
|
const indexer = makeMockIndexer();
|
|
281
|
-
const largeArgs = { content: "x".repeat(
|
|
281
|
+
const largeArgs = { path: "src/foo.ts", content: "x".repeat(300) };
|
|
282
282
|
const messages: any[] = [
|
|
283
283
|
{
|
|
284
284
|
role: "assistant",
|
|
@@ -344,7 +344,8 @@ describe("pruneMessages", () => {
|
|
|
344
344
|
expect(pruned).toBe(true);
|
|
345
345
|
const errAsst = out.find((m: any) => m.role === "assistant" && m.timestamp === 100) as any;
|
|
346
346
|
expect(errAsst).toBeDefined();
|
|
347
|
-
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=/);
|
|
348
349
|
});
|
|
349
350
|
|
|
350
351
|
it("spill stub tolerates absent spillBytes/resultPreview", () => {
|
|
@@ -1009,7 +1010,7 @@ describe("G4/C3: orphan-sweep zero-fire proof across pruner fixtures", () => {
|
|
|
1009
1010
|
}],
|
|
1010
1011
|
["purges errored toolCall args through errorPurge wiring", () => {
|
|
1011
1012
|
const indexer = makeMockIndexer();
|
|
1012
|
-
const largeArgs = { content: "x".repeat(
|
|
1013
|
+
const largeArgs = { path: "src/foo.ts", content: "x".repeat(300) };
|
|
1013
1014
|
const messages: any[] = [
|
|
1014
1015
|
{ role: "assistant", content: [{ type: "toolCall", id: "tc-err", name: "write", arguments: largeArgs }], timestamp: 100, usage: {}, stopReason: "tool_use" },
|
|
1015
1016
|
{ role: "toolResult", toolCallId: "tc-err", toolName: "write", content: [{ type: "text", text: "Error: permission denied" }], isError: true, timestamp: 110 },
|
package/src/pruner.ts
CHANGED
|
@@ -155,7 +155,7 @@ export function pruneMessages(
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
// Phase 2: error purge
|
|
158
|
+
// Phase 2: error purge - shrink long strings in failed toolCall args after cooldown
|
|
159
159
|
if (errorPurge?.enabled) {
|
|
160
160
|
const afterPurge = purgeErroredArgs(current, errorPurge);
|
|
161
161
|
if (afterPurge !== current) {
|
package/src/summarizer.test.ts
CHANGED
|
@@ -58,4 +58,27 @@ describe("summarizer prompt", () => {
|
|
|
58
58
|
await summarizeBatch(batch, DEFAULT_CONFIG, ctx);
|
|
59
59
|
expect(JSON.stringify(seenInput)).toContain("means the tool returned an image you cannot see");
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
it("tells the model that an elided middle is omitted, not empty", async () => {
|
|
63
|
+
const model = { id: "m", provider: "p", name: "M" };
|
|
64
|
+
const ctx = {
|
|
65
|
+
model,
|
|
66
|
+
modelRegistry: {
|
|
67
|
+
find: () => model,
|
|
68
|
+
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "k", headers: {} }),
|
|
69
|
+
getProviderAuth: async () => undefined,
|
|
70
|
+
},
|
|
71
|
+
ui: { notify() {} },
|
|
72
|
+
} as any;
|
|
73
|
+
const batch = {
|
|
74
|
+
turnIndex: 0,
|
|
75
|
+
timestamp: 0,
|
|
76
|
+
assistantText: "",
|
|
77
|
+
toolCalls: [{ toolCallId: "a", toolName: "bash", args: {}, resultText: "x".repeat(9000), isError: false }],
|
|
78
|
+
} as any;
|
|
79
|
+
await summarizeBatch(batch, DEFAULT_CONFIG, ctx);
|
|
80
|
+
const sent = JSON.stringify(seenInput);
|
|
81
|
+
expect(sent).toContain("chars elided");
|
|
82
|
+
expect(sent).toContain("never report the elided region as empty or absent");
|
|
83
|
+
});
|
|
61
84
|
});
|
package/src/summarizer.ts
CHANGED
|
@@ -22,6 +22,8 @@ Keep each tool call to 1-3 bullet points. Skip calls that succeeded with nothing
|
|
|
22
22
|
|
|
23
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
24
|
|
|
25
|
+
A result containing " ...[N chars elided]... " had its middle removed before you saw it: summarize the head and tail you can see, and never report the elided region as empty or absent.
|
|
26
|
+
|
|
25
27
|
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.`;
|
|
26
28
|
|
|
27
29
|
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.
|