pi-tool-repair 0.2.5 → 0.3.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 +7 -5
- package/package.json +1 -1
- package/src/grammar-repair.ts +161 -0
- package/src/index.ts +3 -1
package/README.md
CHANGED
|
@@ -195,11 +195,13 @@ With Pi Fabric full code mode, `pi-tool-repair` sees `fabric_exec` as the active
|
|
|
195
195
|
|
|
196
196
|
Phase 0 schema sanitization activates for models matching these patterns:
|
|
197
197
|
|
|
198
|
-
| Pattern
|
|
199
|
-
|
|
|
200
|
-
| `/kimi-k2/i`
|
|
201
|
-
| `/
|
|
202
|
-
|
|
|
198
|
+
| Pattern | Models |
|
|
199
|
+
| ----------------- | ----------------------------------- |
|
|
200
|
+
| `/kimi-k2/i` | Kimi K2 variants |
|
|
201
|
+
| `/moonshotai\/kimi/i` | Moonshot Kimi (direct API) |
|
|
202
|
+
| `/@cf\/.*kimi/i` | @cf Kimi (e.g. `@cf/moonshotai/kimi-k2.6`) |
|
|
203
|
+
| `/minimax/i` | MiniMax variants |
|
|
204
|
+
| `/glm/i` | GLM variants |
|
|
203
205
|
|
|
204
206
|
To add more models, edit `anchorBleedModels` in [`src/index.ts`](./src/index.ts).
|
|
205
207
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tool-repair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Validate-then-repair extension for pi — fixes common LLM tool-call mistakes (null fields, stringified arrays, wrong field names, anchor bleed) before tools execute",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Tom X Nguyen",
|
package/src/grammar-repair.ts
CHANGED
|
@@ -116,6 +116,7 @@ export function defaultConfigPath(): string {
|
|
|
116
116
|
return join(getAgentDir(), "extensions", "pi-tool-repair.json");
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
|
|
119
120
|
export function normalizeGrammarRepairConfig(raw: Partial<GrammarRepairConfig> = {}): GrammarRepairConfig {
|
|
120
121
|
const grammarSet = new Set<GrammarName>(ALL_GRAMMARS);
|
|
121
122
|
const grammars = Array.isArray(raw.grammars)
|
|
@@ -168,6 +169,27 @@ export function resolveGrammarRepairForModel(
|
|
|
168
169
|
: config;
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
// Open models occasionally append index tokens to tool names (for example
|
|
173
|
+
// `bash_1_234456789` for `bash`). Recover the original name only when the
|
|
174
|
+
// trailing part is made up of separators and digits, and prefer the longest
|
|
175
|
+
// known tool so `read_file_1_2` never collapses to `read`. Names that are
|
|
176
|
+
// already known, or whose suffix contains real characters
|
|
177
|
+
// (`read_multiple_files`), are returned untouched.
|
|
178
|
+
const MANGLED_TOOL_SUFFIX = /^[_-\d]+$/;
|
|
179
|
+
|
|
180
|
+
function recoverMangledToolName(name: string, knownTools: Set<string>): string {
|
|
181
|
+
if (knownTools.has(name)) return name;
|
|
182
|
+
|
|
183
|
+
let best: string | undefined;
|
|
184
|
+
for (const tool of knownTools) {
|
|
185
|
+
if (tool.length >= name.length || !name.startsWith(tool)) continue;
|
|
186
|
+
if (!MANGLED_TOOL_SUFFIX.test(name.slice(tool.length))) continue;
|
|
187
|
+
if (best === undefined || tool.length > best.length) best = tool;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return best ?? name;
|
|
191
|
+
}
|
|
192
|
+
|
|
171
193
|
export function repairAssistantMessageGrammarLeaks(
|
|
172
194
|
message: MinimalAssistantMessage,
|
|
173
195
|
config: GrammarRepairConfig,
|
|
@@ -188,6 +210,10 @@ export function repairAssistantMessageGrammarLeaks(
|
|
|
188
210
|
if (text === undefined) return part;
|
|
189
211
|
|
|
190
212
|
const candidates = selectCandidates(parseToolGrammarCandidates(text, enabled))
|
|
213
|
+
.map((candidate) => {
|
|
214
|
+
const name = recoverMangledToolName(candidate.name, knownTools);
|
|
215
|
+
return name === candidate.name ? candidate : { ...candidate, name };
|
|
216
|
+
})
|
|
191
217
|
.filter((candidate) => candidate.stripOnly || isAllowedTool(candidate.name, config, knownTools));
|
|
192
218
|
|
|
193
219
|
if (candidates.length === 0) return part;
|
|
@@ -274,6 +300,9 @@ function parseToolGrammarCandidates(text: string, enabled: Set<GrammarName>): Ca
|
|
|
274
300
|
candidates.push(...parseDsmlDanglingMarkers(text));
|
|
275
301
|
}
|
|
276
302
|
if (enabled.has("kimi")) candidates.push(...parseKimi(text));
|
|
303
|
+
if (enabled.has("qwen") || enabled.has("granite") || enabled.has("kimi")) {
|
|
304
|
+
candidates.push(...parseOrphanToolCallClosers(text));
|
|
305
|
+
}
|
|
277
306
|
if (enabled.has("mistral")) {
|
|
278
307
|
candidates.push(...parseMistral(text));
|
|
279
308
|
candidates.push(...parseBareJsonToolCalls(text, "mistral"));
|
|
@@ -420,6 +449,138 @@ function parseKimi(text: string): Candidate[] {
|
|
|
420
449
|
}
|
|
421
450
|
}
|
|
422
451
|
|
|
452
|
+
candidates.push(...parseKimiLooseToolCallXml(text));
|
|
453
|
+
candidates.push(...parseKimiDanglingMarkers(text));
|
|
454
|
+
return candidates;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function parseKimiLooseToolCallXml(text: string): Candidate[] {
|
|
458
|
+
const candidates: Candidate[] = [];
|
|
459
|
+
const openRe = /<tool_call>/gi;
|
|
460
|
+
|
|
461
|
+
for (const match of text.matchAll(openRe)) {
|
|
462
|
+
if (match.index === undefined || isInsideCodeFence(text, match.index)) continue;
|
|
463
|
+
|
|
464
|
+
const start = match.index;
|
|
465
|
+
const bodyStart = start + match[0].length;
|
|
466
|
+
if (findPattern(text, /<\/tool_call>/gi, bodyStart)) continue;
|
|
467
|
+
|
|
468
|
+
const looseClose = findPattern(
|
|
469
|
+
text,
|
|
470
|
+
/<\/(?:redacted_thinking|thinking)>/gi,
|
|
471
|
+
bodyStart,
|
|
472
|
+
);
|
|
473
|
+
if (!looseClose) continue;
|
|
474
|
+
|
|
475
|
+
const body = text.slice(bodyStart, looseClose.start);
|
|
476
|
+
for (const call of parseToolCallJsonBody(body, "kimi")) {
|
|
477
|
+
candidates.push({ ...call, range: { start, end: looseClose.end } });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return candidates;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function parseKimiDanglingMarkers(text: string): Candidate[] {
|
|
485
|
+
const candidates: Candidate[] = [];
|
|
486
|
+
const sectionOpenRe = /<\|(?:redacted_)?tool_calls?_section_begin\|>/gi;
|
|
487
|
+
|
|
488
|
+
for (const match of text.matchAll(sectionOpenRe)) {
|
|
489
|
+
if (match.index === undefined || isInsideCodeFence(text, match.index)) continue;
|
|
490
|
+
const sectionStart = match.index;
|
|
491
|
+
if (findPattern(text, /<\|(?:redacted_)?tool_calls?_section_end\|>/gi, sectionStart)) continue;
|
|
492
|
+
|
|
493
|
+
const end = findBestKimiUnclosedEnd(text, sectionStart + match[0].length) ?? sectionStart + match[0].length;
|
|
494
|
+
candidates.push({
|
|
495
|
+
name: "",
|
|
496
|
+
arguments: {},
|
|
497
|
+
grammar: "kimi",
|
|
498
|
+
range: { start: sectionStart, end },
|
|
499
|
+
stripOnly: true,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const openRe = /<tool_call>/gi;
|
|
504
|
+
for (const match of text.matchAll(openRe)) {
|
|
505
|
+
if (match.index === undefined || isInsideCodeFence(text, match.index)) continue;
|
|
506
|
+
const start = match.index;
|
|
507
|
+
const bodyStart = start + match[0].length;
|
|
508
|
+
const strictClose = findPattern(text, /<\/tool_call>/gi, bodyStart);
|
|
509
|
+
const looseClose = findPattern(text, /<\/(?:redacted_thinking|thinking)>/gi, bodyStart);
|
|
510
|
+
const close = strictClose ?? looseClose;
|
|
511
|
+
if (close && parseToolCallJsonBody(text.slice(bodyStart, close.start), "kimi").length > 0) continue;
|
|
512
|
+
|
|
513
|
+
candidates.push({
|
|
514
|
+
name: "",
|
|
515
|
+
arguments: {},
|
|
516
|
+
grammar: "kimi",
|
|
517
|
+
range: { start, end: close?.end ?? bodyStart },
|
|
518
|
+
stripOnly: true,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const markerRe = /<\|(?:redacted_)?tool_call(?:s)?(?:_section)?_(?:begin(?:_kimi)?|argument_begin|end(?:_kimi)?)\|>/gi;
|
|
523
|
+
for (const match of text.matchAll(markerRe)) {
|
|
524
|
+
if (match.index === undefined || isInsideCodeFence(text, match.index)) continue;
|
|
525
|
+
const range = { start: match.index, end: match.index + match[0].length };
|
|
526
|
+
if (candidates.some((candidate) => rangesOverlap(candidate.range, range))) continue;
|
|
527
|
+
candidates.push({
|
|
528
|
+
name: "",
|
|
529
|
+
arguments: {},
|
|
530
|
+
grammar: "kimi",
|
|
531
|
+
range,
|
|
532
|
+
stripOnly: true,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return candidates;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function findBestKimiUnclosedEnd(text: string, from: number): number | undefined {
|
|
540
|
+
const markerRe = /<\|(?:redacted_)?tool_call(?:s)?(?:_section)?_(?:begin(?:_kimi)?|argument_begin|end(?:_kimi)?)\|>/gi;
|
|
541
|
+
markerRe.lastIndex = from;
|
|
542
|
+
let end: number | undefined;
|
|
543
|
+
for (;;) {
|
|
544
|
+
const match = markerRe.exec(text);
|
|
545
|
+
if (!match) break;
|
|
546
|
+
end = match.index + match[0].length;
|
|
547
|
+
}
|
|
548
|
+
if (end === undefined) return undefined;
|
|
549
|
+
|
|
550
|
+
const tail = text.slice(end);
|
|
551
|
+
const idMatch = /^\s*functions\.[A-Za-z_][\w.-]*:\d*/.exec(tail);
|
|
552
|
+
if (idMatch) end += idMatch[0].length;
|
|
553
|
+
|
|
554
|
+
const argMarker = findPattern(text, /<\|(?:redacted_)?tool_call_argument_begin\|>/gi, end);
|
|
555
|
+
if (argMarker) {
|
|
556
|
+
end = argMarker.end;
|
|
557
|
+
const json = extractFirstBalancedJson(text.slice(end));
|
|
558
|
+
if (json) end += json.end;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return end;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function parseOrphanToolCallClosers(text: string): Candidate[] {
|
|
565
|
+
const candidates: Candidate[] = [];
|
|
566
|
+
const closeRe = /<\/tool_call>\s*/gi;
|
|
567
|
+
|
|
568
|
+
for (const match of text.matchAll(closeRe)) {
|
|
569
|
+
if (match.index === undefined || isInsideCodeFence(text, match.index)) continue;
|
|
570
|
+
const before = text.slice(0, match.index);
|
|
571
|
+
const opens = before.match(/<tool_call>/gi)?.length ?? 0;
|
|
572
|
+
const closes = before.match(/<\/tool_call>/gi)?.length ?? 0;
|
|
573
|
+
if (opens > closes) continue;
|
|
574
|
+
|
|
575
|
+
candidates.push({
|
|
576
|
+
name: "",
|
|
577
|
+
arguments: {},
|
|
578
|
+
grammar: "kimi",
|
|
579
|
+
range: { start: match.index, end: match.index + match[0].length },
|
|
580
|
+
stripOnly: true,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
423
584
|
return candidates;
|
|
424
585
|
}
|
|
425
586
|
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,9 @@ export interface RepairConfig {
|
|
|
24
24
|
export const DEFAULT_CONFIG: RepairConfig = {
|
|
25
25
|
debug: Boolean(process.env.PI_TOOL_REPAIR_DEBUG),
|
|
26
26
|
anchorBleedModels: [
|
|
27
|
-
/kimi-k2/i,
|
|
27
|
+
/kimi[-_]?k2/i,
|
|
28
|
+
/moonshotai\/kimi/i,
|
|
29
|
+
/@cf\/.*kimi/i,
|
|
28
30
|
/minimax/i,
|
|
29
31
|
/glm/i,
|
|
30
32
|
],
|