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 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 replaces those arg bodies with compact stubs after the error has cooled down:
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
- { "_purged": "<purged-errored-args size=\"N\"/>" }
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 argument body size to purge |
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.2",
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 >2,000 text chars plus an image keeps the marker in the summarizer input", () => {
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(5000) }, img("AAAA")], isError: false, timestamp: 2 },
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")}]`);
@@ -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 because the summarizer
61
- * input keeps only the first 2,000 result chars.
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
- const MAX_CHARS = 2000;
235
- if (resultText.length > MAX_CHARS) {
236
- const remaining = resultText.length - MAX_CHARS;
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: `Replace failed toolCall argument bodies with compact stubs after a cooldown. Reclaims context from large write/edit args that will never succeed. Currently ${config.purgeErrors.enabled ? "ON" : "OFF"}.`,
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",
@@ -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: "a very long argument body here" }),
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: "a very long argument body here" });
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: "a very long argument body here" };
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
- _purged: `<purged-errored-args size="${originalArgLen}"/>`,
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: "hi" }), // JSON is only ~10 chars
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: "a very long argument body here" }),
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: "a very long argument body here that is big" };
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
- _purged: `<purged-errored-args size="${originalArgLen}"/>`,
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: "a very long argument body here" };
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 → age = 2 = cooldownTurns (should purge)
160
- const largeArgs = { content: "argument body that is long enough to purge" };
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._purged).toBeDefined();
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 → age = 1 < cooldownTurns 2
176
- const largeArgs = { content: "argument body that is long enough to purge" };
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
+ });
@@ -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
- * Replaces the `arguments` body of failed toolCall blocks with a compact stub
5
- * once the error is old enough to be beyond the cooldown window.
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: { _purged: `<purged-errored-args size="${argBody.length}"/>` } };
99
+ return { ...block, arguments: shrunk };
63
100
  });
64
101
 
65
102
  if (!contentModified) return msg;
@@ -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(200) };
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._purged).toMatch(/^<purged-errored-args size=/);
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(200) };
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 — replace failed toolCall arg bodies after cooldown
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) {
@@ -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.