pi-tool-repair 0.3.6 → 0.3.8

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
@@ -38,6 +38,7 @@ Run `bun run test:host` for the offline real-host load, native codemode/nested-c
38
38
  | Bare string as root input | `"/path/to/file"` | `{"path":"/path/to/file"}` |
39
39
  | `fabric_exec` code arrays | `{"code":["const x=1;","return x;"]}` | newline-joined `code` |
40
40
  | Schema anchor bleed (Kimi K2) | `"^pattern$"` in values | `"pattern"` |
41
+ | Index-suffixed tool names | `bash_1_234456789`, `functions.bash:0` | `bash` |
41
42
  | Leaked tool grammar (opt-in) | `<|DSML|tool_calls>...` | pi `toolCall` block |
42
43
  | Phantom tool use | `stopReason:"toolUse"` with no call | retryable error |
43
44
 
@@ -88,6 +89,7 @@ before_provider_request
88
89
  model response → message_end
89
90
  ├─ strip leaked grammar tokens from native toolCall blocks
90
91
  ├─ recover complete leaked grammar calls when enabled
92
+ ├─ recover index-suffixed tool names that match exactly one active tool
91
93
  ├─ validate raw arguments against each active tool's live schema
92
94
  ├─ apply only known aliases and schema-directed repairs
93
95
  ├─ commit a candidate only when it re-validates
@@ -111,6 +113,7 @@ Pi 0.84 validates tool arguments before emitting `tool_call`. Repair therefore r
111
113
  | `coerceNumericString` | `"20"` → `20` when the live schema expects a number |
112
114
  | `convertTimeoutMilliseconds` | `timeoutMs` → `timeout` seconds for `bash` |
113
115
  | `joinStringArray` | all-string `fabric_exec.code` arrays → one newline-joined string |
116
+ | `recoverMangledToolName` | `bash_1_234456789` / `functions.bash:0` → `bash` when the name is not an active tool, the suffix is only digits and separators, exactly one active tool fits, and the arguments validate against it |
114
117
 
115
118
  ### Why validate-then-repair
116
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tool-repair",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
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",
@@ -42,7 +42,7 @@
42
42
  "lint:dead": "knip --no-gitignore"
43
43
  },
44
44
  "devDependencies": {
45
- "@earendil-works/pi-coding-agent": "1.1.0",
45
+ "@earendil-works/pi-coding-agent": "1.2.0",
46
46
  "@types/node": "25.9.1",
47
47
  "@vitest/coverage-v8": "4.1.7",
48
48
  "knip": "6.14.1",
@@ -170,24 +170,35 @@ export function resolveGrammarRepairForModel(
170
170
  }
171
171
 
172
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 {
173
+ // `bash_1_234456789` for `bash`), or leak Kimi's call-id shape
174
+ // `functions.{name}:{idx}` into the name. Recover the original name only when
175
+ // the trailing part is made up of separators and digits and exactly one known
176
+ // tool fits, so `read_file_1_2` never collapses to `read` and `s3_2_1` is not
177
+ // guessed when both `s3` and `s3_2` exist. Names that are already known, or
178
+ // whose suffix contains real characters (`read_multiple_files`), are returned
179
+ // untouched.
180
+ const MANGLED_TOOL_PREFIX = "functions.";
181
+ const MANGLED_TOOL_SUFFIX = /^[-_:\d]+$/;
182
+
183
+ export function recoverMangledToolName(name: string, knownTools: ReadonlySet<string>): string {
181
184
  if (knownTools.has(name)) return name;
182
185
 
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;
186
+ const bases = name.startsWith(MANGLED_TOOL_PREFIX)
187
+ ? [name, name.slice(MANGLED_TOOL_PREFIX.length)]
188
+ : [name];
189
+ const matches = new Set<string>();
190
+ for (const base of bases) {
191
+ if (knownTools.has(base)) {
192
+ matches.add(base);
193
+ continue;
194
+ }
195
+ for (const tool of knownTools) {
196
+ if (tool.length >= base.length || !base.startsWith(tool)) continue;
197
+ if (MANGLED_TOOL_SUFFIX.test(base.slice(tool.length))) matches.add(tool);
198
+ }
188
199
  }
189
200
 
190
- return best ?? name;
201
+ return matches.size === 1 ? [...matches][0]! : name;
191
202
  }
192
203
 
193
204
  export function repairAssistantMessageGrammarLeaks(
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  // ─── Configuration ────────────────────────────────────────────────────────────
9
9
 
10
- import type { MinimalAssistantMessage } from "./grammar-repair.js";
10
+ import { recoverMangledToolName, type MinimalAssistantMessage } from "./grammar-repair.js";
11
11
  import { Value } from "typebox/value";
12
12
 
13
13
  const isObject = (value: unknown): value is Record<string, unknown> =>
@@ -915,6 +915,52 @@ export function repairInputAgainstLiveSchema(
915
915
  };
916
916
  }
917
917
 
918
+ // A native call whose name is not an active tool fails with "tool not found".
919
+ // When the name is an active tool plus an index suffix (`bash_1_234456789`,
920
+ // `functions.bash:0`), rename it — but only if the arguments validate against
921
+ // that tool's schema, directly or after the usual argument repairs. Otherwise
922
+ // the call is left as-is so Pi reports the original error.
923
+ function recoverMangledToolCallName(
924
+ name: string,
925
+ rawInput: unknown,
926
+ schemas: ReadonlyMap<string, import("typebox").TSchema>,
927
+ knownTools: ReadonlySet<string>,
928
+ ): LiveToolRepairOutcome | undefined {
929
+ const toolName = recoverMangledToolName(name, knownTools);
930
+ const schema = toolName === name ? undefined : schemas.get(toolName);
931
+ if (!schema) return undefined;
932
+
933
+ const hint = `Renamed tool "${name}" to "${toolName}"; the trailing index is not part of the tool name.`;
934
+ const outcome = repairInputAgainstLiveSchema(toolName, rawInput, schema);
935
+ const repaired = outcome.status === "recovered"
936
+ ? outcome.repaired
937
+ : outcome.status === "unchanged" && schemaCheck(schema, rawInput) === true
938
+ ? rawInput
939
+ : undefined;
940
+
941
+ if (repaired === undefined) {
942
+ return {
943
+ toolName: name,
944
+ status: "unrepairable",
945
+ input: rawInput,
946
+ rulesFired: ["recoverMangledToolName", ...outcome.rulesFired],
947
+ hints: [
948
+ `${hint} Not applied: the arguments do not validate against "${toolName}".`,
949
+ ...outcome.hints,
950
+ ],
951
+ };
952
+ }
953
+
954
+ return {
955
+ toolName,
956
+ status: "recovered",
957
+ input: rawInput,
958
+ repaired,
959
+ rulesFired: ["recoverMangledToolName", ...outcome.rulesFired],
960
+ hints: [hint, ...outcome.hints],
961
+ };
962
+ }
963
+
918
964
  export function repairAssistantToolCallInputs(
919
965
  message: MinimalAssistantMessage,
920
966
  tools: readonly LiveToolSchema[],
@@ -924,12 +970,20 @@ export function repairAssistantToolCallInputs(
924
970
  }
925
971
 
926
972
  const schemas = new Map(tools.map((tool) => [tool.name, tool.parameters]));
973
+ const knownTools = new Set(schemas.keys());
927
974
  const repairs: LiveToolRepairOutcome[] = [];
928
975
  let changed = false;
929
976
  const content = message.content.map((part) => {
930
977
  if (!isObject(part) || part.type !== "toolCall" || typeof part.name !== "string") return part;
931
978
  const schema = schemas.get(part.name);
932
- if (!schema) return part;
979
+ if (!schema) {
980
+ const renamed = recoverMangledToolCallName(part.name, part.arguments, schemas, knownTools);
981
+ if (!renamed) return part;
982
+ repairs.push(renamed);
983
+ if (renamed.status !== "recovered") return part;
984
+ changed = true;
985
+ return { ...part, name: renamed.toolName, arguments: renamed.repaired };
986
+ }
933
987
  const outcome = repairInputAgainstLiveSchema(part.name, part.arguments, schema);
934
988
  if (outcome.status !== "unchanged") repairs.push(outcome);
935
989
  if (outcome.status !== "recovered") return part;