pi-condense 2.11.2 → 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 CHANGED
@@ -7,6 +7,12 @@ 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
+
10
16
  ## [2.11.2] - 2026-09-29
11
17
 
12
18
  ### Fixed
package/PRUNING.md CHANGED
@@ -1096,23 +1096,26 @@ Short refs (`tN`) always resolve 1:1 - one ref, one record - and are the primary
1096
1096
 
1097
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.
1098
1098
 
1099
- Error purge replaces those arg bodies with compact stubs after the error has cooled down:
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:
1100
1100
 
1101
1101
  ```
1102
- { "_purged": "<purged-errored-args size=\"N\"/>" }
1102
+ { "path": "src/foo.ts", "content": "<purged-errored-args size=\"30000\"/>" }
1103
1103
  ```
1104
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
+
1105
1107
  **What triggers a purge:**
1106
1108
 
1107
1109
  - The matching `ToolResultMessage` has `isError: true`.
1108
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.
1109
- - 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.
1110
1112
 
1111
1113
  **What error purge does NOT touch:**
1112
1114
 
1113
1115
  - The `ToolResultMessage` content — the error message stays visible so the model can see what went wrong.
1114
1116
  - Non-errored `toolCall` argument bodies.
1115
1117
  - Argument bodies below `minArgChars`.
1118
+ - Keys, non-string values, array lengths, and strings of 200 characters or fewer inside a purged body.
1116
1119
  - Anything when `purgeErrors.enabled` is `false`.
1117
1120
 
1118
1121
  **Transform position:** Error purge runs in Phase 2, after stub-replace and before chain range prune.
@@ -1127,7 +1130,7 @@ Error purge replaces those arg bodies with compact stubs after the error has coo
1127
1130
  |---|---|---|
1128
1131
  | `purgeErrors.enabled` | `true` | Master toggle |
1129
1132
  | `purgeErrors.cooldownTurns` | `2` | Turns to wait after error before purging |
1130
- | `purgeErrors.minArgChars` | `500` | Minimum argument body size to purge |
1133
+ | `purgeErrors.minArgChars` | `500` | Minimum JSON body size for a failed call to be scanned; only strings over 200 chars inside it are replaced |
1131
1134
 
1132
1135
  ---
1133
1136
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.11.2",
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",
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) {