pi-tool-repair 0.1.0

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/src/index.ts ADDED
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Shared types, constants, and pure functions for pi-tool-repair.
3
+ *
4
+ * All repair logic lives here so it can be unit-tested without
5
+ * pi ExtensionAPI or event hooks.
6
+ */
7
+
8
+ // ─── Configuration ────────────────────────────────────────────────────────────
9
+
10
+ export interface RepairConfig {
11
+ debug: boolean;
12
+ anchorBleedModels: RegExp[];
13
+ fieldAliases: Record<string, Record<string, string[]>>;
14
+ stringArgTools: Record<string, { field: string; shape: "string" | "array" }>;
15
+ }
16
+
17
+ export const DEFAULT_CONFIG: RepairConfig = {
18
+ debug: Boolean(process.env.PI_TOOL_REPAIR_DEBUG),
19
+ anchorBleedModels: [
20
+ /kimi-k2/i,
21
+ /minimax/i,
22
+ /glm/i,
23
+ ],
24
+ fieldAliases: {
25
+ read: {
26
+ path: [
27
+ "absolutePath", "file_path", "filePath", "filepath", "pathname",
28
+ "target_file", "targetFile", "file", "absolute_path", "fileAbsolutePath",
29
+ ],
30
+ },
31
+ grep: {
32
+ pattern: ["query", "regex", "search", "q", "expression", "text"],
33
+ },
34
+ write: {
35
+ path: [
36
+ "absolutePath", "file_path", "filePath", "filepath", "pathname",
37
+ "target_file", "targetFile",
38
+ ],
39
+ content: ["text", "body", "data", "contents", "fileContent"],
40
+ },
41
+ edit: {
42
+ path: [
43
+ "absolutePath", "file_path", "filePath", "filepath", "pathname",
44
+ "target_file", "targetFile",
45
+ ],
46
+ oldText: [
47
+ "old_string", "oldString", "old", "old_str", "oldStr", "from",
48
+ "old_value", "oldText", "old_text", "oldContent", "old_content",
49
+ ],
50
+ newText: [
51
+ "new_string", "newString", "new", "new_str", "newStr", "to",
52
+ "new_value", "newText", "new_text", "newContent", "new_content",
53
+ ],
54
+ },
55
+ ls: {
56
+ path: ["absolutePath", "directory", "dir", "folder", "directoryPath"],
57
+ },
58
+ find: {
59
+ pattern: ["query", "glob", "expression", "search", "include"],
60
+ },
61
+ bash: {
62
+ command: ["cmd", "shell", "script", "commandLine"],
63
+ },
64
+ },
65
+ stringArgTools: {
66
+ grep: { field: "pattern", shape: "string" },
67
+ find: { field: "pattern", shape: "string" },
68
+ bash: { field: "command", shape: "string" },
69
+ read: { field: "path", shape: "string" },
70
+ ls: { field: "path", shape: "string" },
71
+ },
72
+ };
73
+
74
+ // ─── Phase 0: Schema Poisoning Defense ────────────────────────────────────────
75
+
76
+ export function hasAnchorBleedBug(model: { id?: string } | null | undefined): boolean {
77
+ if (!model || !model.id) return false;
78
+ return DEFAULT_CONFIG.anchorBleedModels.some((re) => re.test(model.id!));
79
+ }
80
+
81
+ export function sanitizePattern(pattern: string): string | undefined {
82
+ if (pattern.includes("|") && (pattern.includes("^") || pattern.includes("$"))) {
83
+ return undefined;
84
+ }
85
+ const stripped = pattern.replace(/\^|\$/g, "");
86
+ return stripped.length > 0 ? stripped : undefined;
87
+ }
88
+
89
+ export function sanitizeSchemaAnchors(schema: unknown): unknown {
90
+ if (!schema || typeof schema !== "object") return schema;
91
+ if (Array.isArray(schema)) return schema.map(sanitizeSchemaAnchors);
92
+ const result: Record<string, unknown> = {};
93
+ for (const [key, value] of Object.entries(schema as Record<string, unknown>)) {
94
+ if (key === "pattern" && typeof value === "string") {
95
+ const sanitized = sanitizePattern(value);
96
+ if (sanitized !== undefined) result[key] = sanitized;
97
+ } else if (value && typeof value === "object") {
98
+ result[key] = sanitizeSchemaAnchors(value);
99
+ } else {
100
+ result[key] = value;
101
+ }
102
+ }
103
+ return result;
104
+ }
105
+
106
+ export function stripAnchorBleedInPlace(obj: Record<string, unknown>): void {
107
+ for (const key of Object.keys(obj)) {
108
+ const value = obj[key];
109
+ if (typeof value === "string") {
110
+ let s = value;
111
+ while (s.startsWith("^")) s = s.slice(1);
112
+ while (s.endsWith("$")) s = s.slice(0, -1);
113
+ obj[key] = s;
114
+ } else if (Array.isArray(value)) {
115
+ for (let i = 0; i < value.length; i++) {
116
+ const item = value[i];
117
+ if (typeof item === "string") {
118
+ let s = item;
119
+ while (s.startsWith("^")) s = s.slice(1);
120
+ while (s.endsWith("$")) s = s.slice(0, -1);
121
+ value[i] = s;
122
+ } else if (item && typeof item === "object") {
123
+ stripAnchorBleedInPlace(item as Record<string, unknown>);
124
+ }
125
+ }
126
+ } else if (value && typeof value === "object") {
127
+ stripAnchorBleedInPlace(value as Record<string, unknown>);
128
+ }
129
+ }
130
+ }
131
+
132
+ // ─── Phase 2: Repair Rules ────────────────────────────────────────────────────
133
+
134
+ export interface RepairResult {
135
+ hint: string;
136
+ ruleName: string;
137
+ }
138
+
139
+ export interface RepairContext {
140
+ toolName: string;
141
+ parent: Record<string, unknown>;
142
+ key: string;
143
+ value: unknown;
144
+ issue: ValidationIssue;
145
+ }
146
+
147
+ export interface ValidationIssue {
148
+ code: string;
149
+ expected?: string;
150
+ received?: string;
151
+ path: (string | number)[];
152
+ message: string;
153
+ }
154
+
155
+ // Rule 1: renameAliasedField
156
+ //
157
+ // Two scenarios:
158
+ // A) The issue path points to an alias key (e.g. "file_path") and the canonical
159
+ // is missing → rename alias → canonical.
160
+ // B) The issue path points to a canonical key marked missing (e.g. "path") and
161
+ // the parent contains an alias for it → rename alias → canonical.
162
+ export function renameAliasedField(ctx: RepairContext): RepairResult | false {
163
+ const aliases = DEFAULT_CONFIG.fieldAliases[ctx.toolName];
164
+ if (!aliases) return false;
165
+
166
+ // Scenario A: ctx.key is an alias (appears in an alias list)
167
+ const canonicalFromAlias = Object.entries(aliases).find(
168
+ ([, aliasList]) => aliasList.includes(ctx.key),
169
+ )?.[0];
170
+ if (canonicalFromAlias && !(canonicalFromAlias in ctx.parent)) {
171
+ const existing = ctx.parent[ctx.key];
172
+ if (existing != null && (typeof existing !== "string" || existing !== "")) {
173
+ ctx.parent[canonicalFromAlias] = existing;
174
+ delete ctx.parent[ctx.key];
175
+ return {
176
+ ruleName: "renameAliasedField",
177
+ hint: `Renamed \`${ctx.key}\` to \`${canonicalFromAlias}\` for tool "${ctx.toolName}". ` +
178
+ `Use \`${canonicalFromAlias}\` next time — \`${ctx.key}\` is not a valid field for this tool.`,
179
+ };
180
+ }
181
+ }
182
+
183
+ // Scenario B: ctx.key is a canonical name that's missing, and the parent has an alias for it
184
+ if (ctx.issue.code === "missing_field" && aliases[ctx.key]) {
185
+ const aliasList = aliases[ctx.key];
186
+ const alias = aliasList.find((a) => a in ctx.parent && ctx.parent[a] != null);
187
+ if (alias) {
188
+ const value = ctx.parent[alias];
189
+ if (typeof value !== "string" || value !== "") {
190
+ // Remove the key so the missing_field issue resolves on re-validation
191
+ delete (ctx.parent as Record<string, unknown>)["__rename_placeholder__"];
192
+ ctx.parent[ctx.key] = value;
193
+ delete ctx.parent[alias];
194
+ return {
195
+ ruleName: "renameAliasedField",
196
+ hint: `Renamed \`${alias}\` to \`${ctx.key}\` for tool "${ctx.toolName}". ` +
197
+ `Use \`${ctx.key}\` next time — \`${alias}\` is not a valid field for this tool.`,
198
+ };
199
+ }
200
+ }
201
+ }
202
+
203
+ return false;
204
+ }
205
+
206
+ // Rule 2: dropNullOrUndefined
207
+ export function dropNullOrUndefined(ctx: RepairContext): RepairResult | false {
208
+ if (!(ctx.key in ctx.parent)) return false;
209
+ if (ctx.value != null) return false;
210
+ delete ctx.parent[ctx.key];
211
+ const kind = ctx.value === null ? "null" : "undefined";
212
+ return {
213
+ ruleName: "dropNullOrUndefined",
214
+ hint: `Dropped ${kind} \`${ctx.key}\` from tool "${ctx.toolName}". ` +
215
+ `Optional fields can be omitted entirely rather than sent as ${kind}.`,
216
+ };
217
+ }
218
+
219
+ // Rule 3: dropEmptyObjectPlaceholder
220
+ export function dropEmptyObjectPlaceholder(ctx: RepairContext): RepairResult | false {
221
+ if (!(ctx.key in ctx.parent)) return false;
222
+ if (typeof ctx.value !== "object" || ctx.value === null || Array.isArray(ctx.value)) return false;
223
+ if (Object.keys(ctx.value as object).length !== 0) return false;
224
+ delete ctx.parent[ctx.key];
225
+ return {
226
+ ruleName: "dropEmptyObjectPlaceholder",
227
+ hint: `Dropped empty \`{}\` placeholder from \`${ctx.key}\` for tool "${ctx.toolName}". ` +
228
+ `Send an actual array (or omit the field) next time.`,
229
+ };
230
+ }
231
+
232
+ // Rule 4: parseJsonStringifiedArray
233
+ export function tryParseJsonArray(str: string): unknown[] | null {
234
+ try {
235
+ const parsed = JSON.parse(str);
236
+ return Array.isArray(parsed) ? parsed : null;
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ export function parseJsonStringifiedArray(ctx: RepairContext): RepairResult | false {
243
+ if (typeof ctx.value !== "string") return false;
244
+ const parsed = tryParseJsonArray(ctx.value);
245
+ if (parsed !== null) {
246
+ ctx.parent[ctx.key] = parsed;
247
+ return {
248
+ ruleName: "parseJsonStringifiedArray",
249
+ hint: `Parsed JSON-stringified array for \`${ctx.key}\` in tool "${ctx.toolName}". ` +
250
+ `Send the array literal directly (e.g. ["a","b"]) next time, not a string.`,
251
+ };
252
+ }
253
+ return false;
254
+ }
255
+
256
+ // Rule 5: wrapBareStringAsArray
257
+ export function wrapBareStringAsArray(ctx: RepairContext): RepairResult | false {
258
+ if (ctx.issue.code !== "invalid_type") return false;
259
+ if (ctx.issue.expected !== "array") return false;
260
+ if (typeof ctx.value !== "string") return false;
261
+ ctx.parent[ctx.key] = [ctx.value];
262
+ return {
263
+ ruleName: "wrapBareStringAsArray",
264
+ hint: `Wrapped your bare string in a single-element array for \`${ctx.key}\` ` +
265
+ `in tool "${ctx.toolName}". Send an array (e.g. ["foo"]) next time, not a single string.`,
266
+ };
267
+ }
268
+
269
+ // Ordered repair pipeline for per-issue fixes
270
+ export const REPAIR_RULES: Array<(ctx: RepairContext) => RepairResult | false> = [
271
+ renameAliasedField,
272
+ dropNullOrUndefined,
273
+ dropEmptyObjectPlaceholder,
274
+ parseJsonStringifiedArray,
275
+ wrapBareStringAsArray,
276
+ ];
277
+
278
+ // ─── Root-Level Repair: wrapRootStringAsObject ────────────────────────────────
279
+
280
+ export function wrapRootStringAsObject(
281
+ input: unknown,
282
+ toolName: string,
283
+ ): { wrapped: Record<string, unknown>; hint: string } | undefined {
284
+ if (typeof input !== "string") return undefined;
285
+ const mapping = DEFAULT_CONFIG.stringArgTools[toolName];
286
+ if (!mapping) return undefined;
287
+
288
+ const wrapped = mapping.shape === "string"
289
+ ? { [mapping.field]: input }
290
+ : { [mapping.field]: [input] };
291
+
292
+ return {
293
+ wrapped,
294
+ hint: `Interpreted your bare string as the \`${mapping.field}\` argument ` +
295
+ `for tool "${toolName}". Call this tool with an object, not a bare string, next time.`,
296
+ };
297
+ }
298
+
299
+ // ─── Deep clone ───────────────────────────────────────────────────────────────
300
+
301
+ export function deepClone(value: unknown): unknown {
302
+ if (value === null || typeof value !== "object") return value;
303
+ if (Array.isArray(value)) return value.map(deepClone);
304
+ const result: Record<string, unknown> = {};
305
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
306
+ result[k] = deepClone(v);
307
+ }
308
+ return result;
309
+ }
310
+
311
+ // ─── Walk to parent container ─────────────────────────────────────────────────
312
+
313
+ export function walkToParent(
314
+ root: Record<string, unknown>,
315
+ path: (string | number)[],
316
+ ): Record<string, unknown> | undefined {
317
+ const parentPath = path.slice(0, -1);
318
+ const result = parentPath.reduce(
319
+ (acc: unknown, key) => {
320
+ if (acc !== null && typeof acc === "object") return (acc as Record<string, unknown>)[key];
321
+ return undefined;
322
+ },
323
+ root as unknown,
324
+ );
325
+ if (result !== null && typeof result === "object" && !Array.isArray(result)) {
326
+ return result as Record<string, unknown>;
327
+ }
328
+ return undefined;
329
+ }
330
+
331
+ // ─── Core Repair Logic ────────────────────────────────────────────────────────
332
+
333
+ export interface RepairOutcome {
334
+ input: unknown;
335
+ rulesFired: string[];
336
+ hints: string[];
337
+ }
338
+
339
+ export function repairToolInput(
340
+ rawInput: unknown,
341
+ issues: ValidationIssue[],
342
+ toolName: string,
343
+ ): RepairOutcome {
344
+ const wrapResult = wrapRootStringAsObject(rawInput, toolName);
345
+ if (wrapResult !== undefined) {
346
+ return {
347
+ input: wrapResult.wrapped,
348
+ rulesFired: ["wrapRootStringAsObject"],
349
+ hints: [wrapResult.hint],
350
+ };
351
+ }
352
+
353
+ if (rawInput === null || typeof rawInput !== "object") {
354
+ return { input: rawInput, rulesFired: [], hints: [] };
355
+ }
356
+
357
+ const cloned = deepClone(rawInput) as Record<string, unknown>;
358
+ const rulesFired: string[] = [];
359
+ const hints: string[] = [];
360
+
361
+ for (const issue of issues) {
362
+ const path = issue.path;
363
+ if (path.length === 0) continue;
364
+
365
+ const parent = walkToParent(cloned, path);
366
+ if (parent === undefined) continue;
367
+
368
+ const key = path[path.length - 1];
369
+ if (typeof key !== "string") continue;
370
+
371
+ const value = parent[key as string];
372
+
373
+ const ctx: RepairContext = {
374
+ toolName,
375
+ parent,
376
+ key,
377
+ value,
378
+ issue,
379
+ };
380
+
381
+ for (const rule of REPAIR_RULES) {
382
+ const result = rule(ctx);
383
+ if (result !== false) {
384
+ if (!rulesFired.includes(result.ruleName)) rulesFired.push(result.ruleName);
385
+ hints.push(result.hint);
386
+ break;
387
+ }
388
+ }
389
+ }
390
+
391
+ return rulesFired.length === 0
392
+ ? { input: rawInput, rulesFired: [], hints: [] }
393
+ : { input: cloned, rulesFired, hints };
394
+ }
395
+
396
+ // ─── Schema validation for built-in tools ─────────────────────────────────────
397
+
398
+ export interface SchemaField {
399
+ type: "string" | "number" | "boolean" | "array" | "object";
400
+ required?: boolean;
401
+ items?: { type: string };
402
+ }
403
+
404
+ export type ToolSchema = Record<string, SchemaField>;
405
+
406
+ export const BUILTIN_SCHEMAS: Record<string, ToolSchema> = {
407
+ read: {
408
+ path: { type: "string", required: true },
409
+ offset: { type: "number" },
410
+ limit: { type: "number" },
411
+ },
412
+ write: {
413
+ path: { type: "string", required: true },
414
+ content: { type: "string", required: true },
415
+ },
416
+ edit: {
417
+ path: { type: "string", required: true },
418
+ oldText: { type: "string", required: true },
419
+ newText: { type: "string", required: true },
420
+ replaceAll: { type: "boolean" },
421
+ },
422
+ bash: {
423
+ command: { type: "string", required: true },
424
+ timeout: { type: "number" },
425
+ },
426
+ grep: {
427
+ pattern: { type: "string", required: true },
428
+ include: { type: "array", items: { type: "string" } },
429
+ },
430
+ find: {
431
+ pattern: { type: "string", required: true },
432
+ },
433
+ ls: {
434
+ path: { type: "string" },
435
+ },
436
+ };
437
+
438
+ export function validateAgainstSchema(
439
+ input: Record<string, unknown>,
440
+ schema: ToolSchema,
441
+ ): ValidationIssue[] {
442
+ const issues: ValidationIssue[] = [];
443
+
444
+ for (const [fieldName, fieldDef] of Object.entries(schema)) {
445
+ const value = input[fieldName];
446
+
447
+ if (value === undefined || value === null) {
448
+ if (fieldDef.required) {
449
+ issues.push({
450
+ code: "missing_field",
451
+ path: [fieldName],
452
+ message: `Required field "${fieldName}" is missing`,
453
+ });
454
+ } else if (value === null) {
455
+ issues.push({
456
+ code: "invalid_type",
457
+ expected: fieldDef.type,
458
+ received: "null",
459
+ path: [fieldName],
460
+ message: `Optional field "${fieldName}" is null — omit it instead`,
461
+ });
462
+ }
463
+ continue;
464
+ }
465
+
466
+ const actualType = Array.isArray(value) ? "array" : typeof value;
467
+ if (actualType !== fieldDef.type) {
468
+ issues.push({
469
+ code: "invalid_type",
470
+ expected: fieldDef.type,
471
+ received: actualType,
472
+ path: [fieldName],
473
+ message: `Field "${fieldName}" expected ${fieldDef.type}, got ${actualType}`,
474
+ });
475
+ }
476
+
477
+ if (fieldDef.type === "array" && typeof value === "string") {
478
+ issues.push({
479
+ code: "invalid_type",
480
+ expected: "array",
481
+ received: "string",
482
+ path: [fieldName],
483
+ message: `Field "${fieldName}" expected array, got string`,
484
+ });
485
+ }
486
+ }
487
+
488
+ return issues;
489
+ }
490
+
491
+ // ─── Logging ──────────────────────────────────────────────────────────────────
492
+
493
+ export * from "./grammar-repair.js";
494
+
495
+ export function logRepair(
496
+ toolName: string,
497
+ outcome: "recovered" | "unrepairable",
498
+ details: { rulesFired: string[]; hints: string[]; input: unknown; repaired?: unknown },
499
+ ): void {
500
+ if (!DEFAULT_CONFIG.debug) return;
501
+ const rules = details.rulesFired.length === 0 ? "none" : details.rulesFired.join(",");
502
+ const lines = [`[pi-tool-repair] tool=${toolName} outcome=${outcome} rules=${rules}`];
503
+ lines.push(` input: ${JSON.stringify(details.input)}`);
504
+ if (details.repaired !== undefined && details.repaired !== details.input) {
505
+ lines.push(` repaired: ${JSON.stringify(details.repaired)}`);
506
+ }
507
+ details.hints.forEach((h, i) => lines.push(` hint[${i}]: ${h}`));
508
+ process.stderr.write(lines.join("\n") + "\n");
509
+ }
package/tool-repair.ts ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Tool Input Repair Extension
3
+ *
4
+ * Validates and repairs common LLM tool-call mistakes before tools execute.
5
+ * Handles the finite set of errors that open models make when calling tools,
6
+ * as documented in the tool-parsing-insight.md analysis of Command Code.
7
+ *
8
+ * Repair pipeline:
9
+ * Phase 0: Schema poisoning (pre-request, model-specific)
10
+ * - Strip regex anchors from JSON Schema patterns for models where they
11
+ * leak into generated values (e.g., Kimi K2 anchor bleed)
12
+ *
13
+ * Phase 1: Grammar leak repair (message_end hook, opt-in)
14
+ * - Strip leaked XML/sentinel tool-call grammars from assistant text/thinking
15
+ * - Recover complete, known-tool calls into pi toolCall blocks
16
+ *
17
+ * Phase 2: Validate-then-repair (tool_call hook)
18
+ * - Validate input against the tool's schema
19
+ * - On failure, walk the validator's issue list and apply targeted repairs
20
+ * - Re-validate. Surface repair notes to the model.
21
+ *
22
+ * Repair rules (order matters!):
23
+ * 1. wrapRootStringAsObject — bare string where object expected
24
+ * 2. renameAliasedField — wrong field name → canonical name
25
+ * 3. dropNullOrUndefined — null/undefined for optional fields
26
+ * 4. dropEmptyObjectPlaceholder — {} where array expected
27
+ * 5. parseJsonStringifiedArray — '"[\"a\"]"' → ["a"]
28
+ * 6. wrapBareStringAsArray — "foo" → ["foo"]
29
+ *
30
+ * See tool-parsing-extraction.md for the full reverse-engineered analysis.
31
+ *
32
+ * Usage:
33
+ * pi -e /path/to/pi-tool-repair
34
+ */
35
+
36
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
37
+ import {
38
+ hasAnchorBleedBug,
39
+ sanitizeSchemaAnchors,
40
+ stripAnchorBleedInPlace,
41
+ wrapRootStringAsObject,
42
+ repairToolInput,
43
+ validateAgainstSchema,
44
+ logRepair,
45
+ BUILTIN_SCHEMAS,
46
+ loadGrammarRepairConfig,
47
+ repairAssistantMessageGrammarLeaks,
48
+ type MinimalAssistantMessage,
49
+ } from "./src/index.js";
50
+
51
+ export default function (pi: ExtensionAPI) {
52
+ const grammarRepairConfig = loadGrammarRepairConfig();
53
+
54
+ // Phase 0: Schema poisoning defense (before_provider_request)
55
+ // Strip regex anchors from JSON Schema patterns for models where they leak
56
+ pi.on("before_provider_request", (event, ctx) => {
57
+ if (!hasAnchorBleedBug(ctx.model)) return;
58
+
59
+ const payload = event.payload as Record<string, unknown>;
60
+ if (!payload || typeof payload !== "object") return;
61
+
62
+ let modified = false;
63
+
64
+ const tools = payload.tools;
65
+ if (Array.isArray(tools)) {
66
+ payload.tools = tools.map((tool: any) => {
67
+ if (tool?.function?.parameters) {
68
+ return {
69
+ ...tool,
70
+ function: {
71
+ ...tool.function,
72
+ parameters: sanitizeSchemaAnchors(tool.function.parameters),
73
+ },
74
+ };
75
+ }
76
+ return tool;
77
+ });
78
+ modified = true;
79
+ }
80
+
81
+ const responseFormat = payload.response_format as any;
82
+ if (responseFormat?.json_schema?.schema) {
83
+ responseFormat.json_schema.schema = sanitizeSchemaAnchors(responseFormat.json_schema.schema);
84
+ modified = true;
85
+ }
86
+
87
+ if (modified) {
88
+ return payload;
89
+ }
90
+ });
91
+
92
+ // Phase 1: Grammar leak repair (message_end)
93
+ // Promote leaked XML/sentinel tool-call grammars from assistant text/thinking
94
+ // into pi toolCall blocks. Disabled by default and configured via
95
+ // ~/.pi/agent/extensions/pi-tool-repair.json.
96
+ pi.on("message_end", (event) => {
97
+ if (!grammarRepairConfig.enabled) return;
98
+ if (event.message.role !== "assistant") return;
99
+
100
+ const knownTools = new Set(
101
+ pi.getActiveTools()
102
+ .filter((name): name is string => typeof name === "string" && name.length > 0),
103
+ );
104
+
105
+ const result = repairAssistantMessageGrammarLeaks(
106
+ event.message as unknown as MinimalAssistantMessage,
107
+ grammarRepairConfig,
108
+ knownTools,
109
+ );
110
+
111
+ if (!result.changed) return;
112
+
113
+ if (grammarRepairConfig.debug) {
114
+ const calls = result.recoveredCalls.map((call) => `${call.grammar}:${call.name}`).join(",") || "none";
115
+ process.stderr.write(
116
+ `[pi-tool-repair] grammar-repair mode=${grammarRepairConfig.mode} ` +
117
+ `stripped=${result.strippedRanges} recovered=${calls}\n`,
118
+ );
119
+ }
120
+
121
+ return { message: result.message as any };
122
+ });
123
+
124
+ // Phase 2: Validate-then-repair (tool_call)
125
+ pi.on("tool_call", (event, ctx) => {
126
+ const toolName = event.toolName;
127
+ const input = (event as any).input;
128
+
129
+ // Defense-in-depth: strip anchor-bleed from generated values
130
+ if (hasAnchorBleedBug(ctx.model)) {
131
+ if (input && typeof input === "object") {
132
+ stripAnchorBleedInPlace(input);
133
+ }
134
+ }
135
+
136
+ // Only repair built-in tools we have schemas for
137
+ const schema = BUILTIN_SCHEMAS[toolName];
138
+ if (!schema) return;
139
+
140
+ if (!input || typeof input !== "object") {
141
+ // Root-level: might be a bare string
142
+ if (typeof input === "string") {
143
+ const wrapResult = wrapRootStringAsObject(input, toolName);
144
+ if (wrapResult !== undefined) {
145
+ (event as any).input = wrapResult.wrapped;
146
+ logRepair(toolName, "recovered", {
147
+ rulesFired: ["wrapRootStringAsObject"],
148
+ hints: [wrapResult.hint],
149
+ input,
150
+ repaired: wrapResult.wrapped,
151
+ });
152
+ }
153
+ }
154
+ return;
155
+ }
156
+
157
+ // Validate as-is
158
+ const issues = validateAgainstSchema(input, schema);
159
+ if (issues.length === 0) return;
160
+
161
+ // Try repairs
162
+ const repairResult = repairToolInput(input, issues, toolName);
163
+ if (repairResult.rulesFired.length === 0) {
164
+ logRepair(toolName, "unrepairable", {
165
+ rulesFired: [],
166
+ hints: [],
167
+ input,
168
+ });
169
+ return;
170
+ }
171
+
172
+ // Re-validate repaired input
173
+ const repairedInput = repairResult.input as Record<string, unknown>;
174
+ const postRepairIssues = validateAgainstSchema(repairedInput, schema);
175
+ if (postRepairIssues.length === 0) {
176
+ (event as any).input = repairedInput;
177
+ logRepair(toolName, "recovered", {
178
+ rulesFired: repairResult.rulesFired,
179
+ hints: repairResult.hints,
180
+ input,
181
+ repaired: repairedInput,
182
+ });
183
+ } else {
184
+ logRepair(toolName, "unrepairable", {
185
+ rulesFired: repairResult.rulesFired,
186
+ hints: repairResult.hints,
187
+ input,
188
+ repaired: repairedInput,
189
+ });
190
+ }
191
+ });
192
+ }