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 +6 -0
- package/PRUNING.md +7 -4
- package/package.json +1 -1
- 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/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
|
|
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
|
-
{ "
|
|
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
|
|
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.
|
|
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: `
|
|
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) {
|