pi-tool-repair 0.1.11 → 0.2.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
@@ -21,16 +21,19 @@ Reverse-engineered from [Command Code](https://commandcode.ai/)'s tool parsing p
21
21
 
22
22
  ## What it fixes
23
23
 
24
- | Problem | Model sends | After repair |
25
- | ----------------------------- | ---------------------------------- | --------------------------- |
26
- | `null` for optional fields | `{"path": "/foo", "offset": null}` | `{"path": "/foo"}` |
27
- | Arrays as JSON strings | `"[\"a\",\"b\"]"` | `["a","b"]` |
28
- | `{}` where array expected | `{"include": {}}` | _(dropped)_ |
29
- | Bare string → array | `"foo"` | `["foo"]` |
30
- | Wrong field names | `{"file_path": "/foo"}` | `{"path": "/foo"}` |
31
- | Bare string as root input | `"/path/to/file"` | `{"path": "/path/to/file"}` |
32
- | Schema anchor bleed (Kimi K2) | `"^pattern$"` in values | `"pattern"` |
33
- | Leaked tool grammar (opt-in) | `<|DSML|tool_calls>...` | pi `toolCall` block |
24
+ | Problem | Model sends | After repair |
25
+ | ------- | ----------- | ------------ |
26
+ | `null` for optional fields | `{"path":"/foo","offset":null}` | `{"path":"/foo"}` |
27
+ | Arrays as JSON strings | `{"edits":"[{...}]"}` | `{"edits":[{...}]}` |
28
+ | `{}` where an optional array is expected | `{"include":{}}` | _(dropped)_ |
29
+ | Bare string where an array is expected | `{"include":"foo"}` | `{"include":["foo"]}` |
30
+ | Wrong field names | `{"file_path":"/foo"}` | `{"path":"/foo"}` |
31
+ | Numeric strings | `{"limit":"20"}` | `{"limit":20}` |
32
+ | Bare string as root input | `"/path/to/file"` | `{"path":"/path/to/file"}` |
33
+ | `fabric_exec` code arrays | `{"code":["const x=1;","return x;"]}` | newline-joined `code` |
34
+ | Schema anchor bleed (Kimi K2) | `"^pattern$"` in values | `"pattern"` |
35
+ | Leaked tool grammar (opt-in) | `<|DSML|tool_calls>...` | pi `toolCall` block |
36
+ | Phantom tool use | `stopReason:"toolUse"` with no call | retryable error |
34
37
 
35
38
  ## Install
36
39
 
@@ -72,62 +75,40 @@ Reload with `/reload` after any install method.
72
75
 
73
76
  ## How it works
74
77
 
75
- ```
76
- ┌────────────────────────────────────────────────────────────┐
77
- │ Phase 0: Schema poisoning (before_provider_request) │
78
- │ │
79
- │ Strip regex anchors from JSON Schema patterns for models │
80
- │ where they leak into generated values (Kimi K2, MiniMax) │
81
- │ │
82
- │ Fixes what YOU send the model — not what the model sends │
83
- └──────────────────────────┬─────────────────────────────────┘
84
- │
85
- ▼
86
- Model generates tool call
87
- │
88
- ▼
89
- ┌────────────────────────────────────────────────────────────┐
90
- │ Phase 1: Grammar leak repair (message_end, opt-in) │
91
- │ │
92
- │ Detect raw XML/sentinel tool grammars emitted as text or │
93
- │ thinking, strip them from visible output, and recover them │
94
- │ as pi toolCall blocks when complete and safe. │
95
- └──────────────────────────┬─────────────────────────────────┘
96
- │
97
- ▼
98
- ┌────────────────────────────────────────────────────────────┐
99
- │ Phase 2: Validate-then-repair (tool_call) │
100
- │ │
101
- │ 1. Validate input against schema (if known tool) │
102
- │ ↳ Valid? Ship it untouched. │
103
- │ 2. Walk the validator's issue list │
104
- │ ↳ Apply targeted repairs only at the exact failed paths │
105
- │ 3. Re-validate the repaired input │
106
- │ ↳ Still invalid? Let the tool handle it. │
107
- │ 4. Log outcome (debug mode) │
108
- └────────────────────────────────────────────────────────────┘
109
- ```
78
+ ```text
79
+ before_provider_request
80
+ └─ model-gated schema anchor sanitization
110
81
 
111
- ### Repair rules (in order)
82
+ model response → message_end
83
+ ├─ strip leaked grammar tokens from native toolCall blocks
84
+ ├─ recover complete leaked grammar calls when enabled
85
+ ├─ validate raw arguments against each active tool's live schema
86
+ ├─ apply only known aliases and schema-directed repairs
87
+ ├─ commit a candidate only when it re-validates
88
+ └─ turn phantom toolUse responses into retryable errors
112
89
 
113
- Order matters — `parseJsonStringifiedArray` must run before `wrapBareStringAsArray` or you get double-wrapping.
90
+ Pi then runs its normal prepare → validate → execute pipeline
91
+ ```
114
92
 
115
- | # | Rule | What it catches |
116
- | --- | ---------------------------- | ----------------------------------------------- |
117
- | 1 | `renameAliasedField` | `file_path` → `path`, `query` → `pattern`, etc. |
118
- | 2 | `dropNullOrUndefined` | `null`/`undefined` for optional fields |
119
- | 3 | `dropEmptyObjectPlaceholder` | `{}` where array expected |
120
- | 4 | `parseJsonStringifiedArray` | `"[\"a\",\"b\"]"` → `["a","b"]` |
121
- | 5 | `wrapBareStringAsArray` | `"foo"` → `["foo"]` |
122
- | 6 | `wrapRootStringAsObject` | `"/path"` → `{"path": "/path"}` |
93
+ Pi 0.84 validates tool arguments before emitting `tool_call`. Repair therefore runs on the finalized assistant message, while the provider's raw arguments are still available and before Pi's validation can reject or coerce them.
123
94
 
124
- ### Why validate-then-repair (not preprocess-then-validate)
95
+ ### Repair rules
125
96
 
126
- Preprocessing inputs before validation silently corrupts valid data — rewriting file content that happened to look like JSON, for example. The better design:
97
+ | Rule | What it catches |
98
+ | ---- | --------------- |
99
+ | `renameAliasedField` | `file_path` → `path`, `query` → `pattern`, option aliases, etc. |
100
+ | `dropNullOrUndefined` | `null`/`undefined` for schema-optional fields |
101
+ | `dropEmptyObjectPlaceholder` | `{}` where an optional array is expected |
102
+ | `parseJsonStringifiedArray` | `"[\"a\",\"b\"]"` → `["a","b"]` |
103
+ | `wrapBareStringAsArray` | `"foo"` → `["foo"]` when the schema expects an array |
104
+ | `wrapRootStringAsObject` | `"/path"` → `{"path":"/path"}` for known string-primary tools |
105
+ | `coerceNumericString` | `"20"` → `20` when the live schema expects a number |
106
+ | `convertTimeoutMilliseconds` | `timeoutMs` → `timeout` seconds for `bash` |
107
+ | `joinStringArray` | all-string `fabric_exec.code` arrays → one newline-joined string |
127
108
 
128
- 1. **Parse the input as-is.** If valid, ship it untouched.
129
- 2. **On failure, walk the validator's issue list** and apply repairs only at the exact paths that failed.
130
- 3. **Re-validate.** The schema localizes the bug for you — you only spend repair effort where it's actually needed.
109
+ ### Why validate-then-repair
110
+
111
+ The extension reads the schemas from `pi.getAllTools()` instead of maintaining a parallel copy. Schema-valid input with no known compatibility aliases is returned unchanged. Invalid input is cloned, repaired only at schema-declared fields, and revalidated; an unrepairable candidate is discarded so Pi reports the original error. Canonical fields win when both canonical and alias spellings are present.
131
112
 
132
113
  ## Configuration
133
114
 
@@ -164,6 +145,24 @@ Modes:
164
145
  | `recover` | Strip leaked markup and append recovered pi `toolCall` blocks. |
165
146
  | `strip` | Strip leaked markup only; do not execute recovered calls. |
166
147
 
148
+ #### Per-model enablement
149
+
150
+ If only some of your models leak grammar — common with local servers such as llama.cpp, vLLM, or Ollama — auto-enable recovery per model id with `leakModels`. Entries are case-insensitive regex fragments matched against the active model id:
151
+
152
+ ```json
153
+ {
154
+ "grammarRepair": {
155
+ "leakModels": ["kimi", "qwen3", "gguf"]
156
+ }
157
+ }
158
+ ```
159
+
160
+ Recovery turns on whenever the session's model id matches a pattern. Global `enabled: true` takes precedence over `leakModels`, so models with reliable native tool calling stay untouched. Regex entries that fail to compile are ignored.
161
+
162
+ #### What the model sees on the next request
163
+
164
+ Every repair runs on pi's `message_end` hook, where the repaired message is replaced in place — the corrected call, not the model's original output, is what pi writes to the session file and resends on later requests. With `mode: "recover"`, leaked tool-call text is likewise converted into real `toolCall` blocks before persistence, so subsequent requests show the model a properly formed call plus its tool results: an in-context correction loop instead of a silent execute-time patch. Local models benefit the most since there is no prompt-cache penalty for the rewritten history; providers that cache by prefix may treat the first turn after a repair as a cache miss.
165
+
167
166
  Safety gates:
168
167
 
169
168
  - `requireKnownTool: true` only recovers calls whose name is in pi's active tool registry.
@@ -186,7 +185,11 @@ Set `PI_TOOL_REPAIR_DEBUG=1` or `grammarRepair.debug: true` to log repair diagno
186
185
 
187
186
  ### Covered tools
188
187
 
189
- Repair rules apply to pi's built-in tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`.
188
+ Schema-directed null, array, and numeric repairs apply to every active tool whose live schema Pi exposes. The curated alias table covers Pi's built-in `read`, `write`, `edit`, `bash`, `grep`, `find`, and `ls` tools. `fabric_exec` root strings and all-string `code` arrays are also supported.
189
+
190
+ ### Pi Fabric compatibility
191
+
192
+ With Pi Fabric full code mode, `pi-tool-repair` sees `fabric_exec` as the active model-facing tool. It repairs the outer call, leaked provider grammars, anchor bleed, and phantom tool-use responses before Pi validation. Nested `pi.*` calls are created later by Fabric's TypeScript guest, so Fabric owns their alias and optional-null normalization before its registry validation. No duplicate tool registration or wrapper is required.
190
193
 
191
194
  ### Anchor bleed models
192
195
 
@@ -205,32 +208,33 @@ To add more models, edit `anchorBleedModels` in [`src/index.ts`](./src/index.ts)
205
208
  The extension maps common model mistakes (wrong field names) to the canonical field name. For example, when calling `read`, the model can send `file_path`, `absolutePath`, `filepath`, `target_file`, etc. — all map to `path`.
206
209
 
207
210
  <details>
208
- <summary><strong>Full alias table</strong></summary>
209
-
210
- | Tool | Canonical | Aliases |
211
- | ------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
212
- | `read` | `path` | `absolutePath`, `file_path`, `filePath`, `filepath`, `pathname`, `target_file`, `targetFile`, `file`, `absolute_path`, `fileAbsolutePath` |
213
- | `grep` | `pattern` | `query`, `regex`, `search`, `q`, `expression`, `text` |
214
- | `write` | `path` | `absolutePath`, `file_path`, `filePath`, `filepath`, `pathname`, `target_file`, `targetFile` |
215
- | `write` | `content` | `text`, `body`, `data`, `contents`, `fileContent` |
216
- | `edit` | `path` | `absolutePath`, `file_path`, `filePath`, `filepath`, `pathname`, `target_file`, `targetFile` |
217
- | `edit` | `oldText` | `old_string`, `oldString`, `old`, `old_str`, `oldStr`, `from`, `old_value`, `oldText`, `old_text`, `oldContent`, `old_content` |
218
- | `edit` | `newText` | `new_string`, `newString`, `new`, `new_str`, `newStr`, `to`, `new_value`, `newText`, `new_text`, `newContent`, `new_content` |
219
- | `ls` | `path` | `absolutePath`, `directory`, `dir`, `folder`, `directoryPath` |
220
- | `find` | `pattern` | `query`, `glob`, `expression`, `search`, `include` |
221
- | `bash` | `command` | `cmd`, `shell`, `script`, `commandLine` |
211
+ <summary><strong>Alias summary</strong></summary>
212
+
213
+ | Tool | Canonical | Aliases |
214
+ | ---- | --------- | ------- |
215
+ | `read` | `path` | `absolutePath`, `file_path`, `filePath`, `filepath`, `pathname`, `target_file`, `targetFile`, `file`, `absolute_path`, `fileAbsolutePath` |
216
+ | `read` | `offset`, `limit` | `start`, `max` |
217
+ | `grep` | `pattern` | `query`, `regex`, `search`, `q`, `expression`, `text` |
218
+ | `grep` | `glob`, `ignoreCase`, `context`, `limit` | `globPattern`, `ic`, `caseInsensitive`, `ctx`, `max` |
219
+ | `write` | `path`, `content` | path aliases above; `text`, `body`, `data`, `contents`, `fileContent` |
220
+ | `edit` | `path` | path aliases above |
221
+ | `edit` | `oldText` | `old_string`, `oldString`, `old`, `old_str`, `oldStr`, `from`, `old_value`, `old_text`, `oldContent`, `old_content` |
222
+ | `edit` | `newText` | `new_string`, `newString`, `new`, `replacement`, `new_str`, `newStr`, `to`, `new_value`, `new_text`, `newContent`, `new_content` |
223
+ | `ls` | `path`, `limit` | path aliases plus `directory`, `dir`, `folder`, `directoryPath`; `max` |
224
+ | `find` | `pattern`, `limit` | `query`, `regex`, `glob`, `expression`, `search`, `include`, `name`, `filename`; `max` |
225
+ | `bash` | `command` | `cmd`, `shell`, `cmdline`, `script`, `commandLine` |
222
226
 
223
227
  </details>
224
228
 
225
229
  ## Development
226
230
 
227
231
  ```bash
228
- npm install
229
- npm test # run tests
230
- npm run test:watch # watch mode
231
- npm run test:coverage # coverage report
232
- npm run typecheck # type checking
233
- npm run lint:dead # dead code detection
232
+ pnpm install
233
+ pnpm test # run tests
234
+ pnpm run test:watch # watch mode
235
+ pnpm run test:coverage # coverage report
236
+ pnpm run typecheck # type checking
237
+ pnpm run lint:dead # dead code detection
234
238
  ```
235
239
 
236
240
  ## Related projects
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tool-repair",
3
- "version": "0.1.11",
3
+ "version": "0.2.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",
@@ -38,9 +38,14 @@
38
38
  "@types/node": "25.9.1",
39
39
  "@vitest/coverage-v8": "4.1.7",
40
40
  "knip": "6.14.1",
41
+ "typebox": "1.3.7",
41
42
  "typescript": "6.0.3",
42
43
  "vitest": "4.1.7"
43
44
  },
45
+ "peerDependencies": {
46
+ "@earendil-works/pi-coding-agent": "*",
47
+ "typebox": "*"
48
+ },
44
49
  "pi": {
45
50
  "extensions": [
46
51
  "./tool-repair.ts"
@@ -24,6 +24,9 @@ export interface GrammarRepairConfig {
24
24
  mode: GrammarRepairMode;
25
25
  requireKnownTool: boolean;
26
26
  debug: boolean;
27
+ // Case-insensitive regex fragments that auto-enable grammar repair when the
28
+ // active model id matches one of them. Ignored while `enabled` is true.
29
+ leakModels?: string[];
27
30
  }
28
31
 
29
32
  export interface ExtensionFileConfig {
@@ -119,15 +122,52 @@ export function normalizeGrammarRepairConfig(raw: Partial<GrammarRepairConfig> =
119
122
  ? raw.grammars.filter((name): name is GrammarName => grammarSet.has(name as GrammarName))
120
123
  : ALL_GRAMMARS;
121
124
 
125
+ const leakModels = normalizeLeakModels(raw.leakModels);
126
+
122
127
  return {
123
128
  enabled: raw.enabled ?? DEFAULT_GRAMMAR_REPAIR_CONFIG.enabled,
124
129
  grammars: grammars.length > 0 ? grammars : ALL_GRAMMARS,
125
130
  mode: raw.mode === "strip" ? "strip" : "recover",
126
131
  requireKnownTool: raw.requireKnownTool ?? DEFAULT_GRAMMAR_REPAIR_CONFIG.requireKnownTool,
127
132
  debug: raw.debug ?? DEFAULT_GRAMMAR_REPAIR_CONFIG.debug,
133
+ ...(leakModels ? { leakModels } : {}),
128
134
  };
129
135
  }
130
136
 
137
+ const compileLeakModelPattern = (pattern: string): RegExp | undefined => {
138
+ try {
139
+ return new RegExp(pattern, "i");
140
+ } catch {
141
+ return undefined;
142
+ }
143
+ };
144
+
145
+ function normalizeLeakModels(raw: unknown): string[] | undefined {
146
+ if (!Array.isArray(raw)) return undefined;
147
+ const valid = raw.filter(
148
+ (pattern): pattern is string =>
149
+ typeof pattern === "string" && compileLeakModelPattern(pattern) !== undefined,
150
+ );
151
+ return valid.length > 0 ? valid : undefined;
152
+ }
153
+
154
+ // Enables grammar repair for the current message when the active model id
155
+ // matches a configured leakModels pattern. Global `enabled: true` always wins,
156
+ // so models with native tool calling keep the recovery path off unless listed.
157
+ export function resolveGrammarRepairForModel(
158
+ config: GrammarRepairConfig,
159
+ model: { id?: string } | null | undefined,
160
+ ): GrammarRepairConfig {
161
+ if (config.enabled) return config;
162
+ const patterns = config.leakModels;
163
+ if (!patterns || patterns.length === 0) return config;
164
+ const modelId = model?.id;
165
+ if (typeof modelId !== "string" || modelId.length === 0) return config;
166
+ return patterns.some((pattern) => compileLeakModelPattern(pattern)?.test(modelId))
167
+ ? { ...config, enabled: true }
168
+ : config;
169
+ }
170
+
131
171
  export function repairAssistantMessageGrammarLeaks(
132
172
  message: MinimalAssistantMessage,
133
173
  config: GrammarRepairConfig,
package/src/index.ts CHANGED
@@ -8,6 +8,10 @@
8
8
  // ─── Configuration ────────────────────────────────────────────────────────────
9
9
 
10
10
  import type { MinimalAssistantMessage } from "./grammar-repair.js";
11
+ import { Value } from "typebox/value";
12
+
13
+ const isObject = (value: unknown): value is Record<string, unknown> =>
14
+ typeof value === "object" && value !== null && !Array.isArray(value);
11
15
 
12
16
  export interface RepairConfig {
13
17
  debug: boolean;
@@ -33,39 +37,51 @@ export const DEFAULT_CONFIG: RepairConfig = {
33
37
  "absolutePath", "file_path", "filePath", "filepath", "pathname",
34
38
  "target_file", "targetFile", "file", "absolute_path", "fileAbsolutePath",
35
39
  ],
40
+ offset: ["start"],
41
+ limit: ["max"],
36
42
  },
37
43
  grep: {
38
44
  pattern: ["query", "regex", "search", "q", "expression", "text"],
45
+ glob: ["globPattern"],
46
+ ignoreCase: ["ic", "caseInsensitive"],
47
+ context: ["ctx"],
48
+ limit: ["max"],
39
49
  },
40
50
  write: {
41
51
  path: [
42
52
  "absolutePath", "file_path", "filePath", "filepath", "pathname",
43
- "target_file", "targetFile",
53
+ "target_file", "targetFile", "file", "absolute_path", "fileAbsolutePath",
44
54
  ],
45
55
  content: ["text", "body", "data", "contents", "fileContent"],
46
56
  },
47
57
  edit: {
48
58
  path: [
49
59
  "absolutePath", "file_path", "filePath", "filepath", "pathname",
50
- "target_file", "targetFile",
60
+ "target_file", "targetFile", "file", "absolute_path", "fileAbsolutePath",
51
61
  ],
52
62
  oldText: [
53
63
  "old_string", "oldString", "old", "old_str", "oldStr", "from",
54
- "old_value", "oldText", "old_text", "oldContent", "old_content",
64
+ "old_value", "old_text", "oldContent", "old_content",
55
65
  ],
56
66
  newText: [
57
- "new_string", "newString", "new", "new_str", "newStr", "to",
58
- "new_value", "newText", "new_text", "newContent", "new_content",
67
+ "new_string", "newString", "new", "replacement", "new_str", "newStr", "to",
68
+ "new_value", "new_text", "newContent", "new_content",
59
69
  ],
60
70
  },
61
71
  ls: {
62
- path: ["absolutePath", "directory", "dir", "folder", "directoryPath"],
72
+ path: [
73
+ "absolutePath", "file_path", "filePath", "filepath", "pathname",
74
+ "target_file", "targetFile", "file", "absolute_path", "fileAbsolutePath",
75
+ "directory", "dir", "folder", "directoryPath",
76
+ ],
77
+ limit: ["max"],
63
78
  },
64
79
  find: {
65
- pattern: ["query", "glob", "expression", "search", "include"],
80
+ pattern: ["query", "regex", "glob", "expression", "search", "include", "name", "filename"],
81
+ limit: ["max"],
66
82
  },
67
83
  bash: {
68
- command: ["cmd", "shell", "script", "commandLine"],
84
+ command: ["cmd", "shell", "cmdline", "script", "commandLine"],
69
85
  },
70
86
  },
71
87
  stringArgTools: {
@@ -74,6 +90,7 @@ export const DEFAULT_CONFIG: RepairConfig = {
74
90
  bash: { field: "command", shape: "string" },
75
91
  read: { field: "path", shape: "string" },
76
92
  ls: { field: "path", shape: "string" },
93
+ fabric_exec: { field: "code", shape: "string" },
77
94
  },
78
95
  };
79
96
 
@@ -114,14 +131,18 @@ export function sanitizeSchemaAnchors(schema: unknown): unknown {
114
131
  return result;
115
132
  }
116
133
 
117
- export function stripAnchorBleedInPlace(obj: Record<string, unknown>): void {
134
+ export function stripAnchorBleedInPlace(obj: Record<string, unknown>): boolean {
135
+ let changed = false;
118
136
  for (const key of Object.keys(obj)) {
119
137
  const value = obj[key];
120
138
  if (typeof value === "string") {
121
139
  let s = value;
122
140
  while (s.startsWith("^")) s = s.slice(1);
123
141
  while (s.endsWith("$")) s = s.slice(0, -1);
124
- obj[key] = s;
142
+ if (s !== value) {
143
+ obj[key] = s;
144
+ changed = true;
145
+ }
125
146
  } else if (Array.isArray(value)) {
126
147
  for (let i = 0; i < value.length; i++) {
127
148
  const item = value[i];
@@ -129,15 +150,19 @@ export function stripAnchorBleedInPlace(obj: Record<string, unknown>): void {
129
150
  let s = item;
130
151
  while (s.startsWith("^")) s = s.slice(1);
131
152
  while (s.endsWith("$")) s = s.slice(0, -1);
132
- value[i] = s;
153
+ if (s !== item) {
154
+ value[i] = s;
155
+ changed = true;
156
+ }
133
157
  } else if (item && typeof item === "object") {
134
- stripAnchorBleedInPlace(item as Record<string, unknown>);
158
+ if (stripAnchorBleedInPlace(item as Record<string, unknown>)) changed = true;
135
159
  }
136
160
  }
137
161
  } else if (value && typeof value === "object") {
138
- stripAnchorBleedInPlace(value as Record<string, unknown>);
162
+ if (stripAnchorBleedInPlace(value as Record<string, unknown>)) changed = true;
139
163
  }
140
164
  }
165
+ return changed;
141
166
  }
142
167
 
143
168
  // Leaked grammar markers from GLM/ChatGLM style tool-call grammars.
@@ -150,19 +175,26 @@ const GRAMMAR_TOKEN_LEAKS = [
150
175
  { tag: "</arg_value>", at: "end" as const },
151
176
  ];
152
177
 
178
+ const stripBoundaryGrammarTokens = (value: string): string | undefined => {
179
+ let stripped = value;
180
+ let changed = false;
181
+ for (const { tag, at } of GRAMMAR_TOKEN_LEAKS) {
182
+ if (at === "start" && stripped.startsWith(tag)) {
183
+ stripped = stripped.slice(tag.length);
184
+ changed = true;
185
+ } else if (at === "end" && stripped.endsWith(tag)) {
186
+ stripped = stripped.slice(0, -tag.length);
187
+ changed = true;
188
+ }
189
+ }
190
+ return changed ? stripped.trim() : undefined;
191
+ };
192
+
153
193
  export function stripGrammarTokenLeaksInPlace(obj: Record<string, unknown>): boolean {
154
194
  let changed = false;
155
195
  for (const key of Object.keys(obj)) {
156
196
  const value = obj[key];
157
- let newKey = key;
158
- for (const { tag, at } of GRAMMAR_TOKEN_LEAKS) {
159
- if (at === "start" && newKey.startsWith(tag)) {
160
- newKey = newKey.slice(tag.length);
161
- } else if (at === "end" && newKey.endsWith(tag)) {
162
- newKey = newKey.slice(0, -tag.length);
163
- }
164
- }
165
- newKey = newKey.trim();
197
+ const newKey = stripBoundaryGrammarTokens(key) ?? key;
166
198
 
167
199
  if (newKey !== key) {
168
200
  obj[newKey] = value;
@@ -171,44 +203,26 @@ export function stripGrammarTokenLeaksInPlace(obj: Record<string, unknown>): boo
171
203
  }
172
204
 
173
205
  if (typeof value === "string") {
174
- let s = value;
175
- for (const { tag, at } of GRAMMAR_TOKEN_LEAKS) {
176
- if (at === "start" && s.startsWith(tag)) {
177
- s = s.slice(tag.length);
178
- } else if (at === "end" && s.endsWith(tag)) {
179
- s = s.slice(0, -tag.length);
180
- }
181
- }
182
- const trimmed = s.trim();
183
- if (trimmed !== value) {
184
- obj[newKey] = trimmed;
206
+ const stripped = stripBoundaryGrammarTokens(value);
207
+ if (stripped !== undefined) {
208
+ obj[newKey] = stripped;
185
209
  changed = true;
186
210
  }
187
211
  } else if (Array.isArray(value)) {
188
212
  for (let i = 0; i < value.length; i++) {
189
213
  const item = value[i];
190
214
  if (typeof item === "string") {
191
- let s = item;
192
- for (const { tag, at } of GRAMMAR_TOKEN_LEAKS) {
193
- if (at === "start" && s.startsWith(tag)) {
194
- s = s.slice(tag.length);
195
- } else if (at === "end" && s.endsWith(tag)) {
196
- s = s.slice(0, -tag.length);
197
- }
198
- }
199
- const trimmed = s.trim();
200
- if (trimmed !== item) {
201
- value[i] = trimmed;
215
+ const stripped = stripBoundaryGrammarTokens(item);
216
+ if (stripped !== undefined) {
217
+ value[i] = stripped;
202
218
  changed = true;
203
219
  }
204
220
  } else if (item && typeof item === "object") {
205
- const nestedChanged = stripGrammarTokenLeaksInPlace(item as Record<string, unknown>);
206
- if (nestedChanged) changed = true;
221
+ if (stripGrammarTokenLeaksInPlace(item as Record<string, unknown>)) changed = true;
207
222
  }
208
223
  }
209
224
  } else if (value && typeof value === "object") {
210
- const nestedChanged = stripGrammarTokenLeaksInPlace(value as Record<string, unknown>);
211
- if (nestedChanged) changed = true;
225
+ if (stripGrammarTokenLeaksInPlace(value as Record<string, unknown>)) changed = true;
212
226
  }
213
227
  }
214
228
  return changed;
@@ -536,9 +550,7 @@ export const BUILTIN_SCHEMAS: Record<string, ToolSchema> = {
536
550
  },
537
551
  edit: {
538
552
  path: { type: "string", required: true },
539
- oldText: { type: "string", required: true },
540
- newText: { type: "string", required: true },
541
- replaceAll: { type: "boolean" },
553
+ edits: { type: "array", required: true, items: { type: "object" } },
542
554
  },
543
555
  bash: {
544
556
  command: { type: "string", required: true },
@@ -546,13 +558,21 @@ export const BUILTIN_SCHEMAS: Record<string, ToolSchema> = {
546
558
  },
547
559
  grep: {
548
560
  pattern: { type: "string", required: true },
549
- include: { type: "array", items: { type: "string" } },
561
+ path: { type: "string" },
562
+ glob: { type: "string" },
563
+ ignoreCase: { type: "boolean" },
564
+ literal: { type: "boolean" },
565
+ context: { type: "number" },
566
+ limit: { type: "number" },
550
567
  },
551
568
  find: {
552
569
  pattern: { type: "string", required: true },
570
+ path: { type: "string" },
571
+ limit: { type: "number" },
553
572
  },
554
573
  ls: {
555
574
  path: { type: "string" },
575
+ limit: { type: "number" },
556
576
  },
557
577
  };
558
578
 
@@ -609,7 +629,318 @@ export function validateAgainstSchema(
609
629
  return issues;
610
630
  }
611
631
 
612
- // ─── Logging ──────────────────────────────────────────────────────────────────
632
+
633
+ export type LiveToolSchema = {
634
+ name: string;
635
+ parameters: import("typebox").TSchema;
636
+ };
637
+
638
+ export type LiveRepairStatus = "unchanged" | "recovered" | "unrepairable";
639
+
640
+ export interface LiveToolRepairOutcome {
641
+ toolName: string;
642
+ status: LiveRepairStatus;
643
+ input: unknown;
644
+ repaired?: unknown;
645
+ rulesFired: string[];
646
+ hints: string[];
647
+ }
648
+
649
+ export interface AssistantToolCallRepairResult {
650
+ changed: boolean;
651
+ message: MinimalAssistantMessage;
652
+ repairs: LiveToolRepairOutcome[];
653
+ }
654
+
655
+ const schemaRecord = (schema: unknown): Record<string, unknown> | undefined =>
656
+ isObject(schema) ? schema : undefined;
657
+
658
+ const schemaProperties = (schema: unknown): Record<string, Record<string, unknown>> => {
659
+ const properties = schemaRecord(schema)?.properties;
660
+ if (!isObject(properties)) return {};
661
+ return Object.fromEntries(
662
+ Object.entries(properties)
663
+ .filter((entry): entry is [string, Record<string, unknown>] => isObject(entry[1])),
664
+ );
665
+ };
666
+
667
+ const schemaRequired = (schema: unknown): Set<string> => {
668
+ const required = schemaRecord(schema)?.required;
669
+ return new Set(Array.isArray(required) ? required.filter((key): key is string => typeof key === "string") : []);
670
+ };
671
+
672
+ const schemaCheck = (schema: import("typebox").TSchema, input: unknown): boolean | undefined => {
673
+ try {
674
+ return Value.Check(schema, input);
675
+ } catch {
676
+ return undefined;
677
+ }
678
+ };
679
+
680
+ const addRule = (
681
+ rulesFired: string[],
682
+ hints: string[],
683
+ ruleName: string,
684
+ hint: string,
685
+ ): void => {
686
+ if (!rulesFired.includes(ruleName)) rulesFired.push(ruleName);
687
+ hints.push(hint);
688
+ };
689
+
690
+ const firstUsableField = (
691
+ input: Record<string, unknown>,
692
+ names: string[],
693
+ ): { key: string; value: unknown } | undefined => {
694
+ for (const key of names) {
695
+ if (!Object.hasOwn(input, key)) continue;
696
+ const value = input[key];
697
+ if (value === null || value === undefined || value === "") continue;
698
+ return { key, value };
699
+ }
700
+ return undefined;
701
+ };
702
+
703
+ const deleteFields = (input: Record<string, unknown>, names: string[]): void => {
704
+ for (const name of names) delete input[name];
705
+ };
706
+
707
+ const repairObjectFromSchema = (
708
+ toolName: string,
709
+ schema: Record<string, unknown>,
710
+ input: Record<string, unknown>,
711
+ rulesFired: string[],
712
+ hints: string[],
713
+ path: string[] = [],
714
+ ): void => {
715
+ const properties = schemaProperties(schema);
716
+ const required = schemaRequired(schema);
717
+ const aliases = DEFAULT_CONFIG.fieldAliases[toolName] ?? {};
718
+
719
+ for (const [canonical, aliasNames] of Object.entries(aliases)) {
720
+ if (!Object.hasOwn(properties, canonical) || Object.hasOwn(input, canonical)) continue;
721
+ const alias = firstUsableField(input, aliasNames.filter((name) => name !== canonical));
722
+ if (!alias) continue;
723
+ input[canonical] = alias.value;
724
+ delete input[alias.key];
725
+ addRule(
726
+ rulesFired,
727
+ hints,
728
+ "renameAliasedField",
729
+ `Renamed \`${[...path, alias.key].join(".")}\` to \`${[...path, canonical].join(".")}\` for tool "${toolName}".`,
730
+ );
731
+ }
732
+
733
+ if (toolName === "edit" && path.length === 0 && Object.hasOwn(properties, "edits") && !Object.hasOwn(input, "edits")) {
734
+ const oldNames = ["oldText", ...(aliases.oldText ?? []).filter((name) => name !== "oldText")];
735
+ const newNames = ["newText", ...(aliases.newText ?? []).filter((name) => name !== "newText")];
736
+ const oldField = firstUsableField(input, oldNames);
737
+ const newField = firstUsableField(input, newNames);
738
+ if (typeof oldField?.value === "string" && typeof newField?.value === "string") {
739
+ deleteFields(input, oldNames);
740
+ deleteFields(input, newNames);
741
+ input.edits = [{ oldText: oldField.value, newText: newField.value }];
742
+ addRule(
743
+ rulesFired,
744
+ hints,
745
+ "wrapLegacyEditFields",
746
+ "Moved legacy edit text fields into the current `edits` array.",
747
+ );
748
+ }
749
+ }
750
+
751
+ if (
752
+ toolName === "bash" &&
753
+ path.length === 0 &&
754
+ !Object.hasOwn(input, "timeout") &&
755
+ Object.hasOwn(input, "timeoutMs")
756
+ ) {
757
+ const timeoutMs = input.timeoutMs;
758
+ if (timeoutMs !== null && timeoutMs !== undefined && Number.isFinite(Number(timeoutMs))) {
759
+ input.timeout = Number(timeoutMs) / 1000;
760
+ delete input.timeoutMs;
761
+ addRule(
762
+ rulesFired,
763
+ hints,
764
+ "convertTimeoutMilliseconds",
765
+ "Converted `bash.timeoutMs` from milliseconds to `timeout` seconds.",
766
+ );
767
+ }
768
+ }
769
+
770
+ if (
771
+ toolName === "fabric_exec" &&
772
+ path.length === 0 &&
773
+ schemaRecord(properties.code)?.type === "string" &&
774
+ Array.isArray(input.code) &&
775
+ input.code.every((line) => typeof line === "string")
776
+ ) {
777
+ input.code = input.code.join("\n");
778
+ addRule(
779
+ rulesFired,
780
+ hints,
781
+ "joinStringArray",
782
+ "Joined the `fabric_exec.code` string array with newlines.",
783
+ );
784
+ }
785
+
786
+ for (const [key, propertySchema] of Object.entries(properties)) {
787
+ if (!Object.hasOwn(input, key)) continue;
788
+ const value = input[key];
789
+ const fieldPath = [...path, key].join(".");
790
+
791
+ if ((value === null || value === undefined) && !required.has(key)) {
792
+ delete input[key];
793
+ addRule(
794
+ rulesFired,
795
+ hints,
796
+ "dropNullOrUndefined",
797
+ `Dropped optional ${value === null ? "null" : "undefined"} \`${fieldPath}\` from tool "${toolName}".`,
798
+ );
799
+ continue;
800
+ }
801
+
802
+ if (
803
+ propertySchema.type === "number" &&
804
+ typeof value === "string" &&
805
+ value.trim() !== "" &&
806
+ Number.isFinite(Number(value))
807
+ ) {
808
+ input[key] = Number(value);
809
+ addRule(
810
+ rulesFired,
811
+ hints,
812
+ "coerceNumericString",
813
+ `Converted numeric string in ${fieldPath} for tool "${toolName}".`,
814
+ );
815
+ continue;
816
+ }
817
+
818
+ if (propertySchema.type === "array") {
819
+ if (isObject(value) && Object.keys(value).length === 0) {
820
+ delete input[key];
821
+ addRule(
822
+ rulesFired,
823
+ hints,
824
+ "dropEmptyObjectPlaceholder",
825
+ `Dropped empty object placeholder from array field \`${fieldPath}\` for tool "${toolName}".`,
826
+ );
827
+ continue;
828
+ }
829
+ if (typeof value === "string") {
830
+ const parsed = tryParseJsonArray(value);
831
+ input[key] = parsed ?? [value];
832
+ addRule(
833
+ rulesFired,
834
+ hints,
835
+ parsed ? "parseJsonStringifiedArray" : "wrapBareStringAsArray",
836
+ parsed
837
+ ? `Parsed JSON-stringified array in \`${fieldPath}\` for tool "${toolName}".`
838
+ : `Wrapped bare string in an array for \`${fieldPath}\` in tool "${toolName}".`,
839
+ );
840
+ }
841
+ const arrayValue = input[key];
842
+ const itemSchema = schemaRecord(propertySchema.items);
843
+ if (Array.isArray(arrayValue) && itemSchema) {
844
+ for (let index = 0; index < arrayValue.length; index++) {
845
+ const item = arrayValue[index];
846
+ if (isObject(item)) {
847
+ repairObjectFromSchema(
848
+ toolName,
849
+ itemSchema,
850
+ item,
851
+ rulesFired,
852
+ hints,
853
+ [...path, key, String(index)],
854
+ );
855
+ }
856
+ }
857
+ }
858
+ continue;
859
+ }
860
+
861
+ if (propertySchema.type === "object" && isObject(value)) {
862
+ repairObjectFromSchema(toolName, propertySchema, value, rulesFired, hints, [...path, key]);
863
+ }
864
+ }
865
+ };
866
+
867
+ export function repairInputAgainstLiveSchema(
868
+ toolName: string,
869
+ rawInput: unknown,
870
+ schema: import("typebox").TSchema,
871
+ ): LiveToolRepairOutcome {
872
+ const initial = schemaCheck(schema, rawInput);
873
+ if (initial === undefined) {
874
+ return { toolName, status: "unchanged", input: rawInput, rulesFired: [], hints: [] };
875
+ }
876
+
877
+ const wrapped = initial ? undefined : wrapRootStringAsObject(rawInput, toolName);
878
+ const candidate = wrapped ? wrapped.wrapped : deepClone(rawInput);
879
+ const rulesFired = wrapped ? ["wrapRootStringAsObject"] : [];
880
+ const hints = wrapped ? [wrapped.hint] : [];
881
+
882
+ const objectSchema = schemaRecord(schema);
883
+ if (isObject(candidate) && objectSchema) {
884
+ repairObjectFromSchema(toolName, objectSchema, candidate, rulesFired, hints);
885
+ }
886
+
887
+ if (rulesFired.length === 0) {
888
+ return {
889
+ toolName,
890
+ status: initial ? "unchanged" : "unrepairable",
891
+ input: rawInput,
892
+ rulesFired,
893
+ hints,
894
+ };
895
+ }
896
+
897
+ return schemaCheck(schema, candidate) === true
898
+ ? {
899
+ toolName,
900
+ status: "recovered",
901
+ input: rawInput,
902
+ repaired: candidate,
903
+ rulesFired,
904
+ hints,
905
+ }
906
+ : {
907
+ toolName,
908
+ status: "unrepairable",
909
+ input: rawInput,
910
+ repaired: candidate,
911
+ rulesFired,
912
+ hints,
913
+ };
914
+ }
915
+
916
+ export function repairAssistantToolCallInputs(
917
+ message: MinimalAssistantMessage,
918
+ tools: readonly LiveToolSchema[],
919
+ ): AssistantToolCallRepairResult {
920
+ if (message.role !== "assistant" || !Array.isArray(message.content)) {
921
+ return { changed: false, message, repairs: [] };
922
+ }
923
+
924
+ const schemas = new Map(tools.map((tool) => [tool.name, tool.parameters]));
925
+ const repairs: LiveToolRepairOutcome[] = [];
926
+ let changed = false;
927
+ const content = message.content.map((part) => {
928
+ if (!isObject(part) || part.type !== "toolCall" || typeof part.name !== "string") return part;
929
+ const schema = schemas.get(part.name);
930
+ if (!schema) return part;
931
+ const outcome = repairInputAgainstLiveSchema(part.name, part.arguments, schema);
932
+ if (outcome.status !== "unchanged") repairs.push(outcome);
933
+ if (outcome.status !== "recovered") return part;
934
+ changed = true;
935
+ return { ...part, arguments: outcome.repaired };
936
+ });
937
+
938
+ return changed
939
+ ? { changed: true, message: { ...message, content }, repairs }
940
+ : { changed: false, message, repairs };
941
+ }
942
+
943
+ // Logging
613
944
 
614
945
  export * from "./grammar-repair.js";
615
946
 
package/tool-repair.ts CHANGED
@@ -1,42 +1,9 @@
1
1
  /**
2
2
  * Tool Input Repair Extension
3
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 1.5: Phantom toolUse normalization (message_end hook, always-on)
18
- * - Detect stopReason: "toolUse" with zero toolCall blocks
19
- * - Convert to a retryable error to trigger pi's auto-retry mechanism
20
- * - Guards against vLLM streaming bugs where finish_reason: "tool_calls"
21
- * is emitted without any delta.tool_calls chunks
22
- *
23
- * Phase 2: Validate-then-repair (tool_call hook)
24
- * - Validate input against the tool's schema
25
- * - On failure, walk the validator's issue list and apply targeted repairs
26
- * - Re-validate. Surface repair notes to the model.
27
- *
28
- * Repair rules (order matters!):
29
- * 1. wrapRootStringAsObject — bare string where object expected
30
- * 2. renameAliasedField — wrong field name → canonical name
31
- * 3. dropNullOrUndefined — null/undefined for optional fields
32
- * 4. dropEmptyObjectPlaceholder — {} where array expected
33
- * 5. parseJsonStringifiedArray — '"[\"a\"]"' → ["a"]
34
- * 6. wrapBareStringAsArray — "foo" → ["foo"]
35
- *
36
- * See tool-parsing-extraction.md for the full reverse-engineered analysis.
37
- *
38
- * Usage:
39
- * pi -e /path/to/pi-tool-repair
4
+ * Repairs provider-level schema poisoning and malformed assistant tool calls
5
+ * before Pi validates them. Valid inputs pass through unchanged; attempted
6
+ * argument repairs are committed only when they satisfy the live tool schema.
40
7
  */
41
8
 
42
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -47,21 +14,15 @@ import {
47
14
  sanitizeSchemaAnchors,
48
15
  stripAnchorBleedInPlace,
49
16
  stripGrammarTokenLeaksInPlace,
50
- wrapRootStringAsObject,
51
- repairToolInput,
52
- validateAgainstSchema,
53
17
  logRepair,
54
- BUILTIN_SCHEMAS,
55
18
  loadGrammarRepairConfig,
56
19
  repairAssistantMessageGrammarLeaks,
20
+ resolveGrammarRepairForModel,
21
+ repairAssistantToolCallInputs,
57
22
  normalizePhantomToolUse,
58
23
  type MinimalAssistantMessage,
59
24
  } from "./src/index.js";
60
25
 
61
- // Safely access ctx.model without throwing on stale contexts.
62
- // After session replacement (newSession/fork/switchSession/reload), the
63
- // extension runner invalidates stale contexts and ctx.model throws.
64
- // When that happens, the request belongs to a dead session — bail out.
65
26
  function safeGetModel(ctx: { model?: any }): any | undefined {
66
27
  try {
67
28
  return ctx.model;
@@ -70,7 +31,6 @@ function safeGetModel(ctx: { model?: any }): any | undefined {
70
31
  }
71
32
  }
72
33
 
73
- // Safely call pi.getActiveTools() without throwing on stale contexts.
74
34
  function safeGetActiveTools(pi: ExtensionAPI): string[] {
75
35
  try {
76
36
  return pi.getActiveTools();
@@ -79,11 +39,37 @@ function safeGetActiveTools(pi: ExtensionAPI): string[] {
79
39
  }
80
40
  }
81
41
 
42
+ function safeGetActiveToolSchemas(pi: ExtensionAPI) {
43
+ try {
44
+ const active = new Set(pi.getActiveTools());
45
+ return pi.getAllTools()
46
+ .filter((tool) => active.has(tool.name))
47
+ .map((tool) => ({ name: tool.name, parameters: tool.parameters }));
48
+ } catch {
49
+ return [];
50
+ }
51
+ }
52
+
53
+ const toolCallArguments = (message: MinimalAssistantMessage): Record<string, unknown>[] => {
54
+ if (!Array.isArray(message.content)) return [];
55
+ const argumentsList: Record<string, unknown>[] = [];
56
+ for (const part of message.content) {
57
+ if (
58
+ part && typeof part === "object" && !Array.isArray(part) &&
59
+ (part as Record<string, unknown>).type === "toolCall"
60
+ ) {
61
+ const args = (part as Record<string, unknown>).arguments;
62
+ if (args && typeof args === "object" && !Array.isArray(args)) {
63
+ argumentsList.push(args as Record<string, unknown>);
64
+ }
65
+ }
66
+ }
67
+ return argumentsList;
68
+ };
69
+
82
70
  export default function (pi: ExtensionAPI) {
83
71
  const grammarRepairConfig = loadGrammarRepairConfig();
84
72
 
85
- // Phase 0: Schema poisoning defense (before_provider_request)
86
- // Strip regex anchors from JSON Schema patterns for models where they leak
87
73
  pi.on("before_provider_request", (event, ctx) => {
88
74
  const model = safeGetModel(ctx);
89
75
  if (!model || !hasAnchorBleedBug(model)) return;
@@ -92,7 +78,6 @@ export default function (pi: ExtensionAPI) {
92
78
  if (!payload || typeof payload !== "object") return;
93
79
 
94
80
  let modified = false;
95
-
96
81
  const tools = payload.tools;
97
82
  if (Array.isArray(tools)) {
98
83
  payload.tools = tools.map((tool: any) => {
@@ -116,192 +101,74 @@ export default function (pi: ExtensionAPI) {
116
101
  modified = true;
117
102
  }
118
103
 
119
- if (modified) {
120
- return payload;
121
- }
104
+ if (modified) return payload;
122
105
  });
123
106
 
124
- // Phase 1 + 1.5: Grammar leak repair + phantom toolUse normalization (message_end)
125
- //
126
- // Phase 1.5 (always-on): Detect stopReason: "toolUse" with zero toolCall blocks
127
- // and convert to a retryable error (stopReason: "error"). This triggers pi's
128
- // built-in auto-retry mechanism so the agent re-prompts automatically. This
129
- // guards against vLLM streaming bugs where finish_reason: "tool_calls" is emitted
130
- // without any delta.tool_calls chunks.
131
- //
132
- // Phase 1 (opt-in): Promote leaked XML/sentinel tool-call grammars from
133
- // assistant text/thinking into pi toolCall blocks. Configured via
134
- // ~/.pi/agent/extensions/pi-tool-repair.json.
135
- pi.on("message_end", (event) => {
107
+ // Pi 0.84 prepares and validates tool arguments before `tool_call`, so raw
108
+ // argument recovery must happen on the finalized assistant message instead.
109
+ pi.on("message_end", (event, ctx) => {
136
110
  if (event.message.role !== "assistant") return;
137
111
 
138
- const currentMessage = event.message as unknown as MinimalAssistantMessage;
112
+ let message = event.message as unknown as MinimalAssistantMessage;
113
+ let changed = false;
114
+ const model = safeGetModel(ctx);
139
115
 
140
- // Phase 1.4: Strip leaked grammar tokens from existing pi toolCall blocks.
141
- // These can leak from GLM-style grammars straight into the parsed argument
142
- // keys (e.g. "<arg_key>command" instead of "command"). We have to repair
143
- // them here, before pi preflights and validates the tool calls.
144
- let toolCallArgsChanged = false;
145
- if (Array.isArray(currentMessage.content)) {
146
- for (const part of currentMessage.content) {
147
- if (
148
- part && typeof part === "object" && !Array.isArray(part) &&
149
- (part as Record<string, unknown>).type === "toolCall"
150
- ) {
151
- const args = (part as Record<string, unknown>).arguments;
152
- if (
153
- args && typeof args === "object" && !Array.isArray(args) &&
154
- stripGrammarTokenLeaksInPlace(args as Record<string, unknown>)
155
- ) {
156
- toolCallArgsChanged = true;
157
- }
158
- }
159
- }
116
+ for (const args of toolCallArguments(message)) {
117
+ if (model && hasGrammarLeakBug(model) && stripGrammarTokenLeaksInPlace(args)) changed = true;
118
+ if (model && hasAnchorBleedBug(model) && stripAnchorBleedInPlace(args)) changed = true;
160
119
  }
161
120
 
162
- // Phase 1.5: Phantom toolUse normalization (always-on)
163
- const phantomResult = normalizePhantomToolUse(currentMessage);
164
-
165
- if (phantomResult.changed) {
166
- if (DEFAULT_CONFIG.debug) {
167
- process.stderr.write(
168
- `[pi-tool-repair] phantom-tooluse: converted stopReason from "toolUse" to retryable error (no toolCall blocks)\n`,
169
- );
170
- }
171
-
172
- // If grammar repair is also enabled, run it on the normalized message so
173
- // it can still recover leaked tool calls from the text content. If grammar
174
- // repair recovers calls, it will set stopReason back to "toolUse".
175
- if (grammarRepairConfig.enabled) {
176
- const knownTools = new Set(
177
- safeGetActiveTools(pi)
178
- .filter((name): name is string => typeof name === "string" && name.length > 0),
179
- );
180
- const grammarResult = repairAssistantMessageGrammarLeaks(
181
- phantomResult.message,
182
- grammarRepairConfig,
183
- knownTools,
184
- );
185
- if (grammarResult.changed) {
186
- if (grammarRepairConfig.debug) {
187
- const calls = grammarResult.recoveredCalls.map((call) => `${call.grammar}:${call.name}`).join(",") || "none";
188
- process.stderr.write(
189
- `[pi-tool-repair] grammar-repair mode=${grammarRepairConfig.mode} ` +
190
- `stripped=${grammarResult.strippedRanges} recovered=${calls}\n`,
191
- );
192
- }
193
- return { message: grammarResult.message as any };
121
+ const effectiveGrammarRepairConfig = resolveGrammarRepairForModel(grammarRepairConfig, model);
122
+ if (effectiveGrammarRepairConfig.enabled) {
123
+ const knownTools = new Set(
124
+ safeGetActiveTools(pi)
125
+ .filter((name): name is string => typeof name === "string" && name.length > 0),
126
+ );
127
+ const grammarResult = repairAssistantMessageGrammarLeaks(
128
+ message,
129
+ effectiveGrammarRepairConfig,
130
+ knownTools,
131
+ );
132
+ if (grammarResult.changed) {
133
+ message = grammarResult.message;
134
+ changed = true;
135
+ if (effectiveGrammarRepairConfig.debug) {
136
+ const calls = grammarResult.recoveredCalls
137
+ .map((call) => `${call.grammar}:${call.name}`)
138
+ .join(",") || "none";
139
+ process.stderr.write(
140
+ `[pi-tool-repair] grammar-repair mode=${effectiveGrammarRepairConfig.mode} ` +
141
+ `stripped=${grammarResult.strippedRanges} recovered=${calls}\n`,
142
+ );
194
143
  }
195
144
  }
196
-
197
- return { message: phantomResult.message as any };
198
- }
199
-
200
- // Phase 1: Grammar leak repair (opt-in)
201
- if (!grammarRepairConfig.enabled) {
202
- if (toolCallArgsChanged) return { message: currentMessage as any };
203
- return;
204
- }
205
-
206
- const knownTools = new Set(
207
- safeGetActiveTools(pi)
208
- .filter((name): name is string => typeof name === "string" && name.length > 0),
209
- );
210
-
211
- const result = repairAssistantMessageGrammarLeaks(
212
- event.message as unknown as MinimalAssistantMessage,
213
- grammarRepairConfig,
214
- knownTools,
215
- );
216
-
217
- if (!result.changed && !toolCallArgsChanged) return;
218
-
219
- if (grammarRepairConfig.debug && result.changed) {
220
- const calls = result.recoveredCalls.map((call) => `${call.grammar}:${call.name}`).join(",") || "none";
221
- process.stderr.write(
222
- `[pi-tool-repair] grammar-repair mode=${grammarRepairConfig.mode} ` +
223
- `stripped=${result.strippedRanges} recovered=${calls}\n`,
224
- );
225
145
  }
226
146
 
227
- return { message: result.message as any };
228
- });
229
-
230
- // Phase 2: Validate-then-repair (tool_call)
231
- pi.on("tool_call", (event, ctx) => {
232
- const toolName = event.toolName;
233
- const input = (event as any).input;
234
-
235
- const model = safeGetModel(ctx);
236
-
237
- // Defense-in-depth: strip anchor-bleed from generated values
238
- if (model && hasAnchorBleedBug(model)) {
239
- if (input && typeof input === "object") {
240
- stripAnchorBleedInPlace(input);
241
- }
147
+ const inputResult = repairAssistantToolCallInputs(message, safeGetActiveToolSchemas(pi));
148
+ for (const repair of inputResult.repairs) {
149
+ logRepair(repair.toolName, repair.status === "recovered" ? "recovered" : "unrepairable", {
150
+ rulesFired: repair.rulesFired,
151
+ hints: repair.hints,
152
+ input: repair.input,
153
+ ...(repair.repaired !== undefined ? { repaired: repair.repaired } : {}),
154
+ });
242
155
  }
243
-
244
- // Strip leaked grammar tokens (e.g. GLM <arg_key>) from parsed tool-call
245
- // keys/values before the schema sees them. Only for models known to produce
246
- // grammar token leaks.
247
- if (input && typeof input === "object" && model && hasGrammarLeakBug(model)) {
248
- stripGrammarTokenLeaksInPlace(input as Record<string, unknown>);
156
+ if (inputResult.changed) {
157
+ message = inputResult.message;
158
+ changed = true;
249
159
  }
250
160
 
251
- // Only repair built-in tools we have schemas for
252
- const schema = BUILTIN_SCHEMAS[toolName];
253
- if (!schema) return;
254
-
255
- if (!input || typeof input !== "object") {
256
- // Root-level: might be a bare string
257
- if (typeof input === "string") {
258
- const wrapResult = wrapRootStringAsObject(input, toolName);
259
- if (wrapResult !== undefined) {
260
- (event as any).input = wrapResult.wrapped;
261
- logRepair(toolName, "recovered", {
262
- rulesFired: ["wrapRootStringAsObject"],
263
- hints: [wrapResult.hint],
264
- input,
265
- repaired: wrapResult.wrapped,
266
- });
267
- }
161
+ const phantomResult = normalizePhantomToolUse(message);
162
+ if (phantomResult.changed) {
163
+ message = phantomResult.message;
164
+ changed = true;
165
+ if (DEFAULT_CONFIG.debug) {
166
+ process.stderr.write(
167
+ '[pi-tool-repair] phantom-tooluse: converted stopReason from "toolUse" to retryable error (no toolCall blocks)\n',
168
+ );
268
169
  }
269
- return;
270
170
  }
271
171
 
272
- // Validate as-is
273
- const issues = validateAgainstSchema(input, schema);
274
- if (issues.length === 0) return;
275
-
276
- // Try repairs
277
- const repairResult = repairToolInput(input, issues, toolName);
278
- if (repairResult.rulesFired.length === 0) {
279
- logRepair(toolName, "unrepairable", {
280
- rulesFired: [],
281
- hints: [],
282
- input,
283
- });
284
- return;
285
- }
286
-
287
- // Re-validate repaired input
288
- const repairedInput = repairResult.input as Record<string, unknown>;
289
- const postRepairIssues = validateAgainstSchema(repairedInput, schema);
290
- if (postRepairIssues.length === 0) {
291
- (event as any).input = repairedInput;
292
- logRepair(toolName, "recovered", {
293
- rulesFired: repairResult.rulesFired,
294
- hints: repairResult.hints,
295
- input,
296
- repaired: repairedInput,
297
- });
298
- } else {
299
- logRepair(toolName, "unrepairable", {
300
- rulesFired: repairResult.rulesFired,
301
- hints: repairResult.hints,
302
- input,
303
- repaired: repairedInput,
304
- });
305
- }
172
+ return changed ? { message: message as any } : undefined;
306
173
  });
307
174
  }