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 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 | Models |
199
- | ------------ | ---------------- |
200
- | `/kimi-k2/i` | Kimi K2 variants |
201
- | `/minimax/i` | MiniMax variants |
202
- | `/glm/i` | GLM variants |
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.2.5",
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",
@@ -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
  ],