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 +85 -81
- package/package.json +6 -1
- package/src/grammar-repair.ts +40 -0
- package/src/index.ts +384 -53
- package/tool-repair.ts +85 -218
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
|
|
25
|
-
|
|
|
26
|
-
| `null` for optional fields
|
|
27
|
-
| Arrays as JSON strings
|
|
28
|
-
| `{}` where array expected
|
|
29
|
-
| Bare string
|
|
30
|
-
| Wrong field names
|
|
31
|
-
|
|
|
32
|
-
|
|
|
33
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
+
Pi then runs its normal prepare → validate → execute pipeline
|
|
91
|
+
```
|
|
114
92
|
|
|
115
|
-
|
|
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
|
-
###
|
|
95
|
+
### Repair rules
|
|
125
96
|
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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>
|
|
209
|
-
|
|
210
|
-
| Tool
|
|
211
|
-
|
|
|
212
|
-
| `read`
|
|
213
|
-
| `
|
|
214
|
-
| `
|
|
215
|
-
| `
|
|
216
|
-
| `
|
|
217
|
-
| `edit`
|
|
218
|
-
| `edit`
|
|
219
|
-
| `
|
|
220
|
-
| `
|
|
221
|
-
| `
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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.
|
|
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"
|
package/src/grammar-repair.ts
CHANGED
|
@@ -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", "
|
|
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", "
|
|
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: [
|
|
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>):
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
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
|
-
|
|
112
|
+
let message = event.message as unknown as MinimalAssistantMessage;
|
|
113
|
+
let changed = false;
|
|
114
|
+
const model = safeGetModel(ctx);
|
|
139
115
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
}
|