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/README.md +237 -0
- package/docs/tool-call-grammar-leakage-survey.md +289 -0
- package/package.json +56 -0
- package/src/grammar-repair.ts +980 -0
- package/src/index.ts +509 -0
- package/tool-repair.ts +192 -0
- package/vitest.config.ts +15 -0
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
|
+
}
|