@r3b1s/pi-repair-layer 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/LICENSE +21 -0
- package/README.md +323 -0
- package/index.ts +2 -0
- package/package.json +60 -0
- package/src/grammar-recovery.ts +1269 -0
- package/src/index.ts +621 -0
- package/src/repair-engine.ts +498 -0
- package/src/settings.ts +95 -0
- package/src/tables.ts +184 -0
- package/src/value-strips.ts +218 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate-then-repair engine for LLM tool-call inputs.
|
|
3
|
+
*
|
|
4
|
+
* Design (matching the publicly described behavior of commandcode's repair layer):
|
|
5
|
+
*
|
|
6
|
+
* - Strictly valid inputs are never touched: the fast path is a plain
|
|
7
|
+
* `Value.Check` and returns the original input by reference.
|
|
8
|
+
* - On failure, the validator's own issue list localizes the damage. Repairs are
|
|
9
|
+
* attempted only at the exact paths the schema disagreed with, in a fixed
|
|
10
|
+
* order (JSON-array parsing must run before bare-string wrapping, or
|
|
11
|
+
* `'["a","b"]'` becomes `['["a","b"]']`).
|
|
12
|
+
* - Every mutation produces a model-facing note so the model can learn the real
|
|
13
|
+
* contract on the next turn. Transparency over silent magic.
|
|
14
|
+
*
|
|
15
|
+
* The strict check deliberately runs BEFORE TypeBox's `Value.Convert` (which pi
|
|
16
|
+
* applies during validation), because Convert silently corrupts exactly the
|
|
17
|
+
* inputs this layer exists to fix: `'["a","b"]'` for an array field becomes
|
|
18
|
+
* `['["a","b"]']`, `null` for an optional string becomes the string `"null"`,
|
|
19
|
+
* and `null` for an optional number becomes `0` — all of which then pass
|
|
20
|
+
* validation and execute with garbage. Repairing at the strict-error sites
|
|
21
|
+
* first means those inputs are fixed properly (with a note) instead. Benign
|
|
22
|
+
* coercions ("5" -> 5) are left to Convert: if no repair rule fires and Convert
|
|
23
|
+
* alone makes the input valid, the input is reported as valid and returned
|
|
24
|
+
* untouched, so pi's native behavior is preserved.
|
|
25
|
+
*
|
|
26
|
+
* The one deliberate exception to validate-then-repair is markdown auto-link
|
|
27
|
+
* unwrapping on path fields: `[notes.md](http://notes.md)` is a perfectly valid
|
|
28
|
+
* string, so validation can never flag it. It is unwrapped unconditionally, but
|
|
29
|
+
* only in the degenerate case where the link text equals the url without its
|
|
30
|
+
* protocol — real markdown links pass through untouched.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { TSchema } from "typebox";
|
|
34
|
+
import { Value } from "typebox/value";
|
|
35
|
+
|
|
36
|
+
export interface StructuralRepair {
|
|
37
|
+
/** Rule name recorded in telemetry when the repair fires. */
|
|
38
|
+
name: string;
|
|
39
|
+
/** Mutate `args` in place. Return a model-facing note when a repair was applied, false otherwise. */
|
|
40
|
+
apply(args: Record<string, unknown>, toolName: string): string | false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ToolRepairConfig {
|
|
44
|
+
/** canonical field name -> wrong names models emit for it, matched at any depth by key. */
|
|
45
|
+
fieldAliases?: Record<string, readonly string[]>;
|
|
46
|
+
/** When the whole input is a bare string, wrap it as `{ [field]: value }`. */
|
|
47
|
+
rootString?: { field: string; wrapInArray?: boolean };
|
|
48
|
+
/** Top-level string fields holding filesystem paths (markdown auto-link unwrapping). */
|
|
49
|
+
pathFields?: readonly string[];
|
|
50
|
+
/** Tool-specific shape folds that single-field rules cannot express. */
|
|
51
|
+
structural?: readonly StructuralRepair[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RepairResult {
|
|
55
|
+
outcome: "valid" | "repaired" | "unrepairable";
|
|
56
|
+
/** What prepareArguments should return: the untouched input, the repaired input, or (unrepairable) the untouched input. */
|
|
57
|
+
args: unknown;
|
|
58
|
+
rulesFired: string[];
|
|
59
|
+
notes: string[];
|
|
60
|
+
/** Compact description of the original validation failure, for telemetry. */
|
|
61
|
+
issueSummary: string | undefined;
|
|
62
|
+
/** Stable hash of (tool, failure shape), for spotting per-model regressions. */
|
|
63
|
+
fingerprint: string | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* Model-readable error for unrepairable input. Throwing this from
|
|
66
|
+
* prepareArguments matters: handing the raw input back to pi instead would
|
|
67
|
+
* let Value.Convert corrupt it (null -> "null") and execute the call anyway.
|
|
68
|
+
*/
|
|
69
|
+
retryMessage: string | undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const MAX_ISSUES = 32;
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Markdown auto-link unwrapping
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
const MARKDOWN_LINK = /\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g;
|
|
79
|
+
const PROTOCOL = /^https?:\/\//;
|
|
80
|
+
|
|
81
|
+
export function unwrapMarkdownAutoLinks(value: string): string {
|
|
82
|
+
return value.replace(MARKDOWN_LINK, (match, text: string, url: string) =>
|
|
83
|
+
url.replace(PROTOCOL, "") === text ? text : match,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Issue collection (TypeBox emits AJV-style errors)
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
interface RawIssue {
|
|
92
|
+
keyword: string;
|
|
93
|
+
instancePath: string;
|
|
94
|
+
params: Record<string, unknown> | undefined;
|
|
95
|
+
message: string | undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface IssueSite {
|
|
99
|
+
parent: Record<string, unknown> | unknown[];
|
|
100
|
+
key: string | number;
|
|
101
|
+
keyword: string;
|
|
102
|
+
/** JSON-schema type name the schema expected at this site, when known. */
|
|
103
|
+
expected: string | undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function collectErrors(schema: TSchema, value: unknown): RawIssue[] {
|
|
107
|
+
const out: RawIssue[] = [];
|
|
108
|
+
for (const error of Value.Errors(schema, value)) {
|
|
109
|
+
out.push({
|
|
110
|
+
keyword: String((error as { keyword?: string }).keyword ?? ""),
|
|
111
|
+
instancePath: String(
|
|
112
|
+
(error as { instancePath?: string }).instancePath ?? "",
|
|
113
|
+
),
|
|
114
|
+
params: (error as { params?: Record<string, unknown> }).params,
|
|
115
|
+
message: (error as { message?: string }).message,
|
|
116
|
+
});
|
|
117
|
+
if (out.length >= MAX_ISSUES) break;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parsePointer(pointer: string): (string | number)[] {
|
|
123
|
+
if (pointer === "" || pointer === "/") return [];
|
|
124
|
+
return pointer
|
|
125
|
+
.split("/")
|
|
126
|
+
.slice(1)
|
|
127
|
+
.map((segment) => {
|
|
128
|
+
const decoded = segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
129
|
+
return /^\d+$/.test(decoded) ? Number(decoded) : decoded;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveAt(root: unknown, segments: (string | number)[]): unknown {
|
|
134
|
+
let current: unknown = root;
|
|
135
|
+
for (const segment of segments) {
|
|
136
|
+
if (current === null || typeof current !== "object") return undefined;
|
|
137
|
+
current = (current as Record<string | number, unknown>)[segment];
|
|
138
|
+
}
|
|
139
|
+
return current;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
143
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isContainer(
|
|
147
|
+
value: unknown,
|
|
148
|
+
): value is Record<string, unknown> | unknown[] {
|
|
149
|
+
return value !== null && typeof value === "object";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Value.Check without its type predicate — the predicate narrows `unknown` to `never` on the false branch. */
|
|
153
|
+
function schemaAccepts(schema: TSchema, value: unknown): boolean {
|
|
154
|
+
return Value.Check(schema, value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function collectIssueSites(schema: TSchema, value: unknown): IssueSite[] {
|
|
158
|
+
const sites: IssueSite[] = [];
|
|
159
|
+
const seen = new Set<string>();
|
|
160
|
+
const push = (site: IssueSite, pointerKey: string) => {
|
|
161
|
+
if (seen.has(pointerKey)) return;
|
|
162
|
+
seen.add(pointerKey);
|
|
163
|
+
sites.push(site);
|
|
164
|
+
};
|
|
165
|
+
for (const issue of collectErrors(schema, value)) {
|
|
166
|
+
if (issue.keyword === "required") {
|
|
167
|
+
const container = resolveAt(value, parsePointer(issue.instancePath));
|
|
168
|
+
if (!isPlainObject(container)) continue;
|
|
169
|
+
const missing = issue.params?.requiredProperties;
|
|
170
|
+
if (!Array.isArray(missing)) continue;
|
|
171
|
+
for (const key of missing) {
|
|
172
|
+
if (typeof key !== "string") continue;
|
|
173
|
+
push(
|
|
174
|
+
{
|
|
175
|
+
parent: container,
|
|
176
|
+
key,
|
|
177
|
+
keyword: issue.keyword,
|
|
178
|
+
expected: undefined,
|
|
179
|
+
},
|
|
180
|
+
`${issue.instancePath}::${key}`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const segments = parsePointer(issue.instancePath);
|
|
186
|
+
if (segments.length === 0) continue; // root-level issues are handled by root repairs
|
|
187
|
+
const parent = resolveAt(value, segments.slice(0, -1));
|
|
188
|
+
if (!isContainer(parent)) continue;
|
|
189
|
+
const key = segments[segments.length - 1];
|
|
190
|
+
const expected =
|
|
191
|
+
typeof issue.params?.type === "string" ? issue.params.type : undefined;
|
|
192
|
+
push(
|
|
193
|
+
{ parent, key, keyword: issue.keyword, expected },
|
|
194
|
+
`${issue.instancePath}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return sites;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Per-issue repair rules, in application order
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
type IssueRule = (
|
|
205
|
+
site: IssueSite,
|
|
206
|
+
toolName: string,
|
|
207
|
+
config: ToolRepairConfig,
|
|
208
|
+
) => string | false;
|
|
209
|
+
|
|
210
|
+
const renameAliasedField: IssueRule = (site, toolName, config) => {
|
|
211
|
+
const { parent, key } = site;
|
|
212
|
+
if (typeof key !== "string" || !isPlainObject(parent)) return false;
|
|
213
|
+
// A null target does not block the rename: `{path: null, file_path: "/x"}`
|
|
214
|
+
// should recover from the alias, and dropNullOrUndefinedField runs later.
|
|
215
|
+
if (key in parent && parent[key] !== undefined && parent[key] !== null)
|
|
216
|
+
return false;
|
|
217
|
+
const aliases = config.fieldAliases?.[key];
|
|
218
|
+
if (!aliases) return false;
|
|
219
|
+
for (const alias of aliases) {
|
|
220
|
+
if (!(alias in parent)) continue;
|
|
221
|
+
const value = parent[alias];
|
|
222
|
+
if (value == null) continue;
|
|
223
|
+
if (value === "") continue;
|
|
224
|
+
parent[key] = value;
|
|
225
|
+
delete parent[alias];
|
|
226
|
+
return `Renamed \`${alias}\` to \`${key}\` for tool "${toolName}". Use \`${key}\` next time — \`${alias}\` is not a valid field for this tool.`;
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const dropNullOrUndefinedField: IssueRule = (site, toolName) => {
|
|
232
|
+
const { parent, key } = site;
|
|
233
|
+
if (typeof key !== "string" || !isPlainObject(parent)) return false;
|
|
234
|
+
if (!(key in parent)) return false;
|
|
235
|
+
const value = parent[key];
|
|
236
|
+
if (value !== null && value !== undefined) return false;
|
|
237
|
+
delete parent[key];
|
|
238
|
+
const kind = value === null ? "null" : "undefined";
|
|
239
|
+
return `Dropped ${kind} \`${key}\` from tool "${toolName}". Optional fields can be omitted entirely rather than sent as ${kind}.`;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const dropEmptyObjectPlaceholder: IssueRule = (site, toolName) => {
|
|
243
|
+
const { parent, key, expected } = site;
|
|
244
|
+
if (expected !== "array" || !isPlainObject(parent) || typeof key !== "string")
|
|
245
|
+
return false;
|
|
246
|
+
const value = parent[key];
|
|
247
|
+
if (!isPlainObject(value) || Object.keys(value).length > 0) return false;
|
|
248
|
+
delete parent[key];
|
|
249
|
+
return `Dropped empty \`{}\` placeholder from \`${key}\` for tool "${toolName}". Send an actual array (or omit the field) next time.`;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
function tryParseJson(text: string): unknown {
|
|
253
|
+
try {
|
|
254
|
+
return JSON.parse(text);
|
|
255
|
+
} catch {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const parseJsonStringifiedArray: IssueRule = (site, toolName) => {
|
|
261
|
+
const { parent, key, expected } = site;
|
|
262
|
+
if (expected !== "array") return false;
|
|
263
|
+
const value = (parent as Record<string | number, unknown>)[key];
|
|
264
|
+
if (typeof value !== "string") return false;
|
|
265
|
+
const trimmed = value.trim();
|
|
266
|
+
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return false;
|
|
267
|
+
const parsed = tryParseJson(trimmed);
|
|
268
|
+
if (!Array.isArray(parsed)) return false;
|
|
269
|
+
(parent as Record<string | number, unknown>)[key] = parsed;
|
|
270
|
+
return `Parsed JSON-stringified array for \`${String(key)}\` in tool "${toolName}". Send the array literal directly (e.g. \`["a","b"]\`) next time, not a string.`;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const parseJsonStringifiedObject: IssueRule = (site, toolName) => {
|
|
274
|
+
const { parent, key, expected } = site;
|
|
275
|
+
if (expected !== "object") return false;
|
|
276
|
+
const value = (parent as Record<string | number, unknown>)[key];
|
|
277
|
+
if (typeof value !== "string") return false;
|
|
278
|
+
const trimmed = value.trim();
|
|
279
|
+
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return false;
|
|
280
|
+
const parsed = tryParseJson(trimmed);
|
|
281
|
+
if (!isPlainObject(parsed)) return false;
|
|
282
|
+
(parent as Record<string | number, unknown>)[key] = parsed;
|
|
283
|
+
return `Parsed JSON-stringified object for \`${String(key)}\` in tool "${toolName}". Send the object literal directly next time, not a string.`;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const wrapBareStringAsArray: IssueRule = (site, toolName) => {
|
|
287
|
+
const { parent, key, expected } = site;
|
|
288
|
+
if (expected !== "array") return false;
|
|
289
|
+
const value = (parent as Record<string | number, unknown>)[key];
|
|
290
|
+
if (typeof value !== "string") return false;
|
|
291
|
+
(parent as Record<string | number, unknown>)[key] = [value];
|
|
292
|
+
return `Wrapped your bare string in a single-element array for \`${String(key)}\` in tool "${toolName}". Send an array (e.g. \`["foo"]\`) next time, not a single string.`;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// Order matters: parse before wrap, rename before drop.
|
|
296
|
+
const ISSUE_RULES: readonly [string, IssueRule][] = [
|
|
297
|
+
["renameAliasedField", renameAliasedField],
|
|
298
|
+
["dropNullOrUndefinedField", dropNullOrUndefinedField],
|
|
299
|
+
["dropEmptyObjectPlaceholder", dropEmptyObjectPlaceholder],
|
|
300
|
+
["parseJsonStringifiedArray", parseJsonStringifiedArray],
|
|
301
|
+
["parseJsonStringifiedObject", parseJsonStringifiedObject],
|
|
302
|
+
["wrapBareStringAsArray", wrapBareStringAsArray],
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// Fingerprinting (FNV-1a) for local telemetry
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
function fnv1a(text: string): string {
|
|
310
|
+
let hash = 0x811c9dc5;
|
|
311
|
+
for (let i = 0; i < text.length; i++) {
|
|
312
|
+
hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
|
|
313
|
+
}
|
|
314
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function describeIssue(issue: RawIssue): string {
|
|
318
|
+
const path = issue.instancePath === "" ? "(root)" : issue.instancePath;
|
|
319
|
+
const detail =
|
|
320
|
+
typeof issue.params?.type === "string"
|
|
321
|
+
? issue.params.type
|
|
322
|
+
: Array.isArray(issue.params?.requiredProperties)
|
|
323
|
+
? issue.params.requiredProperties.join(",")
|
|
324
|
+
: "";
|
|
325
|
+
return `${path}|${issue.keyword}|${detail}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function formatRetryMessage(
|
|
329
|
+
toolName: string,
|
|
330
|
+
issues: RawIssue[],
|
|
331
|
+
input: unknown,
|
|
332
|
+
): string {
|
|
333
|
+
const lines = issues
|
|
334
|
+
.slice(0, 8)
|
|
335
|
+
.map(
|
|
336
|
+
(issue) =>
|
|
337
|
+
` • ${issue.instancePath === "" ? "(root)" : issue.instancePath}: ${issue.message ?? issue.keyword}`,
|
|
338
|
+
);
|
|
339
|
+
let received: string;
|
|
340
|
+
try {
|
|
341
|
+
received = JSON.stringify(input) ?? String(input);
|
|
342
|
+
} catch {
|
|
343
|
+
received = String(input);
|
|
344
|
+
}
|
|
345
|
+
if (received.length > 300) received = `${received.slice(0, 300)}…`;
|
|
346
|
+
return `Invalid input for tool "${toolName}". Fix these issues and retry:\n${lines.join("\n")}\nReceived: ${received}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Driver
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
export function repairToolInput(options: {
|
|
354
|
+
toolName: string;
|
|
355
|
+
schema: TSchema;
|
|
356
|
+
input: unknown;
|
|
357
|
+
config?: ToolRepairConfig;
|
|
358
|
+
}): RepairResult {
|
|
359
|
+
const { toolName, schema, input } = options;
|
|
360
|
+
const config = options.config ?? {};
|
|
361
|
+
const rulesFired: string[] = [];
|
|
362
|
+
const notes: string[] = [];
|
|
363
|
+
const fire = (rule: string, note: string) => {
|
|
364
|
+
if (!rulesFired.includes(rule)) rulesFired.push(rule);
|
|
365
|
+
notes.push(note);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const unwrapPathFields = (target: Record<string, unknown>) => {
|
|
369
|
+
for (const field of config.pathFields ?? []) {
|
|
370
|
+
const value = target[field];
|
|
371
|
+
if (typeof value !== "string") continue;
|
|
372
|
+
const unwrapped = unwrapMarkdownAutoLinks(value);
|
|
373
|
+
if (unwrapped === value) continue;
|
|
374
|
+
target[field] = unwrapped;
|
|
375
|
+
fire(
|
|
376
|
+
"unwrapMarkdownAutoLink",
|
|
377
|
+
`Unwrapped a markdown auto-link in \`${field}\` for tool "${toolName}" (\`${value}\` -> \`${unwrapped}\`). Send plain paths, not markdown links.`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// Stage 1: unconditional path-field cleanup on a clone. Auto-linked paths are
|
|
383
|
+
// valid strings, so validation alone can never catch them.
|
|
384
|
+
let current: unknown = structuredClone(input);
|
|
385
|
+
if (isPlainObject(current)) unwrapPathFields(current);
|
|
386
|
+
|
|
387
|
+
// Stage 2: strict fast path. No Convert here — see the header comment.
|
|
388
|
+
if (schemaAccepts(schema, current)) {
|
|
389
|
+
if (rulesFired.length === 0) {
|
|
390
|
+
return {
|
|
391
|
+
outcome: "valid",
|
|
392
|
+
args: input,
|
|
393
|
+
rulesFired,
|
|
394
|
+
notes,
|
|
395
|
+
issueSummary: undefined,
|
|
396
|
+
fingerprint: undefined,
|
|
397
|
+
retryMessage: undefined,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
outcome: "repaired",
|
|
402
|
+
args: current,
|
|
403
|
+
rulesFired,
|
|
404
|
+
notes,
|
|
405
|
+
issueSummary: undefined,
|
|
406
|
+
fingerprint: undefined,
|
|
407
|
+
retryMessage: undefined,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Record the original failure shape before repairs mutate it.
|
|
412
|
+
const originalIssues = collectErrors(schema, current);
|
|
413
|
+
const issueSummary = originalIssues.map(describeIssue).join("; ");
|
|
414
|
+
const fingerprint = fnv1a(
|
|
415
|
+
`${toolName}::${originalIssues.map(describeIssue).sort().join(";")}`,
|
|
416
|
+
);
|
|
417
|
+
const unrepairable = (): RepairResult => ({
|
|
418
|
+
outcome: "unrepairable",
|
|
419
|
+
args: input,
|
|
420
|
+
rulesFired: [],
|
|
421
|
+
notes: [],
|
|
422
|
+
issueSummary,
|
|
423
|
+
fingerprint,
|
|
424
|
+
retryMessage: formatRetryMessage(toolName, originalIssues, input),
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// Stage 3: root repairs — the model sent something other than an object.
|
|
428
|
+
if (typeof current === "string") {
|
|
429
|
+
const trimmed = current.trim();
|
|
430
|
+
const parsed =
|
|
431
|
+
trimmed.startsWith("{") && trimmed.endsWith("}")
|
|
432
|
+
? tryParseJson(trimmed)
|
|
433
|
+
: undefined;
|
|
434
|
+
if (isPlainObject(parsed)) {
|
|
435
|
+
current = parsed;
|
|
436
|
+
fire(
|
|
437
|
+
"parseJsonStringifiedRootObject",
|
|
438
|
+
`Parsed your JSON-stringified arguments for tool "${toolName}". Send the arguments as a JSON object next time, not a string.`,
|
|
439
|
+
);
|
|
440
|
+
} else if (config.rootString) {
|
|
441
|
+
const { field, wrapInArray } = config.rootString;
|
|
442
|
+
current = { [field]: wrapInArray ? [current] : current };
|
|
443
|
+
fire(
|
|
444
|
+
"wrapRootStringAsObject",
|
|
445
|
+
`Wrapped your bare string as \`{${field}: ${wrapInArray ? '["..."]' : '"..."'}}\` for tool "${toolName}". Call this tool with an object, not a bare string, next time.`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (isPlainObject(current)) unwrapPathFields(current);
|
|
449
|
+
}
|
|
450
|
+
if (!isPlainObject(current)) return unrepairable();
|
|
451
|
+
|
|
452
|
+
// Stage 4: tool-specific structural repairs.
|
|
453
|
+
for (const structural of config.structural ?? []) {
|
|
454
|
+
const note = structural.apply(current, toolName);
|
|
455
|
+
if (note !== false) fire(structural.name, note);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Stage 5: per-issue repairs at the strict validator's failure sites.
|
|
459
|
+
if (!schemaAccepts(schema, current)) {
|
|
460
|
+
for (const site of collectIssueSites(schema, current)) {
|
|
461
|
+
for (const [name, rule] of ISSUE_RULES) {
|
|
462
|
+
const note = rule(site, toolName, config);
|
|
463
|
+
if (note !== false) {
|
|
464
|
+
fire(name, note);
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Stage 6: final verdict, now through pi's own pipeline (Convert, then
|
|
472
|
+
// Check) so benign coercions like "5" -> 5 are accounted for.
|
|
473
|
+
const probe = Value.Convert(schema, structuredClone(current));
|
|
474
|
+
if (schemaAccepts(schema, probe)) {
|
|
475
|
+
if (rulesFired.length === 0) {
|
|
476
|
+
// Convert alone fixes it — defer to pi's native coercion, untouched.
|
|
477
|
+
return {
|
|
478
|
+
outcome: "valid",
|
|
479
|
+
args: input,
|
|
480
|
+
rulesFired,
|
|
481
|
+
notes,
|
|
482
|
+
issueSummary: undefined,
|
|
483
|
+
fingerprint: undefined,
|
|
484
|
+
retryMessage: undefined,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
outcome: "repaired",
|
|
489
|
+
args: probe,
|
|
490
|
+
rulesFired,
|
|
491
|
+
notes,
|
|
492
|
+
issueSummary,
|
|
493
|
+
fingerprint,
|
|
494
|
+
retryMessage: undefined,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
return unrepairable();
|
|
498
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display settings for the repair layer, persisted at
|
|
3
|
+
* ~/.pi/agent/tool-repair/settings.json and edited via /repair-settings.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Grammar-leak recovery mode:
|
|
12
|
+
* - "off": never touch assistant text.
|
|
13
|
+
* - "strip": remove leaked tool-call grammar from text (model-gated), but never
|
|
14
|
+
* promote it to an executable call. This is the default.
|
|
15
|
+
* - "recover": additionally promote leaked calls to real toolCalls that execute
|
|
16
|
+
* in the same turn. Opt-in, because it promotes model text into execution.
|
|
17
|
+
*/
|
|
18
|
+
export type GrammarRecoveryMode = "off" | "strip" | "recover";
|
|
19
|
+
|
|
20
|
+
export interface RepairDisplaySettings {
|
|
21
|
+
/** Append a `🔨 ✓ input repaired (...)` line to repaired tool calls in the TUI. */
|
|
22
|
+
showIndicator: boolean;
|
|
23
|
+
/** Also show the repair note text beneath the indicator. */
|
|
24
|
+
showNotes: boolean;
|
|
25
|
+
/** Grammar-leak recovery mode. Default "strip" (still subject to the model gate). */
|
|
26
|
+
grammarRecovery: GrammarRecoveryMode;
|
|
27
|
+
/**
|
|
28
|
+
* Optional allowlist of tool names a leaked call may be promoted to. When
|
|
29
|
+
* empty, the active tool set is used (a leaked call is only promoted when its
|
|
30
|
+
* name is a currently-registered tool).
|
|
31
|
+
*/
|
|
32
|
+
grammarAllowedTools: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_DISPLAY_SETTINGS: RepairDisplaySettings = {
|
|
36
|
+
showIndicator: true,
|
|
37
|
+
showNotes: false,
|
|
38
|
+
grammarRecovery: "strip",
|
|
39
|
+
grammarAllowedTools: [],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const GRAMMAR_MODES: GrammarRecoveryMode[] = ["off", "strip", "recover"];
|
|
43
|
+
|
|
44
|
+
function isGrammarMode(value: unknown): value is GrammarRecoveryMode {
|
|
45
|
+
return (
|
|
46
|
+
typeof value === "string" && (GRAMMAR_MODES as string[]).includes(value)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function displaySettingsPath(): string {
|
|
51
|
+
return (
|
|
52
|
+
process.env.PI_TOOL_REPAIR_SETTINGS ??
|
|
53
|
+
join(getAgentDir(), "tool-repair", "settings.json")
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function loadDisplaySettings(
|
|
58
|
+
path = displaySettingsPath(),
|
|
59
|
+
): RepairDisplaySettings {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
62
|
+
return {
|
|
63
|
+
showIndicator:
|
|
64
|
+
typeof parsed.showIndicator === "boolean"
|
|
65
|
+
? parsed.showIndicator
|
|
66
|
+
: DEFAULT_DISPLAY_SETTINGS.showIndicator,
|
|
67
|
+
showNotes:
|
|
68
|
+
typeof parsed.showNotes === "boolean"
|
|
69
|
+
? parsed.showNotes
|
|
70
|
+
: DEFAULT_DISPLAY_SETTINGS.showNotes,
|
|
71
|
+
grammarRecovery: isGrammarMode(parsed.grammarRecovery)
|
|
72
|
+
? parsed.grammarRecovery
|
|
73
|
+
: DEFAULT_DISPLAY_SETTINGS.grammarRecovery,
|
|
74
|
+
grammarAllowedTools:
|
|
75
|
+
Array.isArray(parsed.grammarAllowedTools) &&
|
|
76
|
+
parsed.grammarAllowedTools.every((t: unknown) => typeof t === "string")
|
|
77
|
+
? parsed.grammarAllowedTools
|
|
78
|
+
: [...DEFAULT_DISPLAY_SETTINGS.grammarAllowedTools],
|
|
79
|
+
};
|
|
80
|
+
} catch {
|
|
81
|
+
return { ...DEFAULT_DISPLAY_SETTINGS };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function saveDisplaySettings(
|
|
86
|
+
settings: RepairDisplaySettings,
|
|
87
|
+
path = displaySettingsPath(),
|
|
88
|
+
): void {
|
|
89
|
+
try {
|
|
90
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
91
|
+
writeFileSync(path, `${JSON.stringify(settings, null, "\t")}\n`);
|
|
92
|
+
} catch {
|
|
93
|
+
// Settings persistence must never break the session.
|
|
94
|
+
}
|
|
95
|
+
}
|