pi-tool-repair 0.1.3 → 0.1.5
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 +1 -1
- package/package.json +10 -10
- package/src/grammar-repair.ts +36 -7
- package/src/index.ts +61 -0
- package/tool-repair.ts +7 -0
package/README.md
CHANGED
|
@@ -168,7 +168,7 @@ Safety gates:
|
|
|
168
168
|
|
|
169
169
|
- `requireKnownTool: true` only recovers calls whose name is in pi's active tool registry.
|
|
170
170
|
- Markup inside fenced code blocks is ignored so syntax discussions and examples are preserved.
|
|
171
|
-
- Incomplete or unparseable blocks are
|
|
171
|
+
- Incomplete or unparseable blocks are not recovered as tool calls. For DSML, dangling or truncated marker tokens (e.g. a stream that died at `<|DSML|tool_calls` with no closing `>`) are still stripped from visible text so the raw marker doesn't persist in the transcript.
|
|
172
172
|
- If the provider already emitted native `toolCall` blocks, leaked shadow text is stripped but duplicate calls are not added.
|
|
173
173
|
|
|
174
174
|
Covered grammar families: DeepSeek DSML, MiniMax/Anthropic `<invoke>`, Qwen/Hermes `<tool_call>`, Kimi sentinels, Mistral `[TOOL_CALLS]`, Llama `<|python_tag|>`, GLM `arg_key`/`arg_value`, Granite JSON `<tool_call>`, MiniMax-Text-01 TypeScript calls, and OLMo3 `<function_calls>` pythonic calls. See [`docs/tool-call-grammar-leakage-survey.md`](./docs/tool-call-grammar-leakage-survey.md) for the survey.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tool-repair",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Validate-then-repair extension for pi
|
|
3
|
+
"version": "0.1.5",
|
|
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",
|
|
7
7
|
"license": "MIT",
|
|
@@ -33,13 +33,6 @@
|
|
|
33
33
|
"docs/",
|
|
34
34
|
"README.md"
|
|
35
35
|
],
|
|
36
|
-
"scripts": {
|
|
37
|
-
"test": "vitest run",
|
|
38
|
-
"test:watch": "vitest",
|
|
39
|
-
"test:coverage": "vitest run --coverage",
|
|
40
|
-
"typecheck": "tsc --noEmit",
|
|
41
|
-
"lint:dead": "knip --no-gitignore"
|
|
42
|
-
},
|
|
43
36
|
"devDependencies": {
|
|
44
37
|
"@earendil-works/pi-coding-agent": "0.79.4",
|
|
45
38
|
"@types/node": "25.9.1",
|
|
@@ -52,5 +45,12 @@
|
|
|
52
45
|
"extensions": [
|
|
53
46
|
"./tool-repair.ts"
|
|
54
47
|
]
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"test": "vitest run",
|
|
51
|
+
"test:watch": "vitest",
|
|
52
|
+
"test:coverage": "vitest run --coverage",
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"lint:dead": "knip --no-gitignore"
|
|
55
55
|
}
|
|
56
|
-
}
|
|
56
|
+
}
|
package/src/grammar-repair.ts
CHANGED
|
@@ -38,6 +38,7 @@ export interface RecoveredToolCall {
|
|
|
38
38
|
|
|
39
39
|
interface Candidate extends RecoveredToolCall {
|
|
40
40
|
range: Range;
|
|
41
|
+
stripOnly?: boolean;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
interface Range {
|
|
@@ -147,13 +148,14 @@ export function repairAssistantMessageGrammarLeaks(
|
|
|
147
148
|
if (text === undefined) return part;
|
|
148
149
|
|
|
149
150
|
const candidates = selectCandidates(parseToolGrammarCandidates(text, enabled))
|
|
150
|
-
.filter((candidate) => isAllowedTool(candidate.name, config, knownTools));
|
|
151
|
+
.filter((candidate) => candidate.stripOnly || isAllowedTool(candidate.name, config, knownTools));
|
|
151
152
|
|
|
152
153
|
if (candidates.length === 0) return part;
|
|
153
154
|
|
|
154
155
|
strippedRanges += candidates.length;
|
|
155
156
|
changed = true;
|
|
156
157
|
for (const candidate of candidates) {
|
|
158
|
+
if (candidate.stripOnly) continue;
|
|
157
159
|
recoveredCalls.push({
|
|
158
160
|
name: candidate.name,
|
|
159
161
|
arguments: candidate.arguments,
|
|
@@ -201,16 +203,21 @@ export function repairAssistantMessageGrammarLeaks(
|
|
|
201
203
|
|
|
202
204
|
export function parseToolGrammarLeaks(text: string, grammars: Iterable<GrammarName> = ALL_GRAMMARS): RecoveredToolCall[] {
|
|
203
205
|
const enabled = new Set(grammars);
|
|
204
|
-
return selectCandidates(parseToolGrammarCandidates(text, enabled))
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
206
|
+
return selectCandidates(parseToolGrammarCandidates(text, enabled))
|
|
207
|
+
.filter((candidate) => !candidate.stripOnly)
|
|
208
|
+
.map((candidate) => ({
|
|
209
|
+
name: candidate.name,
|
|
210
|
+
arguments: candidate.arguments,
|
|
211
|
+
grammar: candidate.grammar,
|
|
212
|
+
}));
|
|
209
213
|
}
|
|
210
214
|
|
|
211
215
|
function parseToolGrammarCandidates(text: string, enabled: Set<GrammarName>): Candidate[] {
|
|
212
216
|
const candidates: Candidate[] = [];
|
|
213
|
-
if (enabled.has("dsml"))
|
|
217
|
+
if (enabled.has("dsml")) {
|
|
218
|
+
candidates.push(...parseDsml(text));
|
|
219
|
+
candidates.push(...parseDsmlDanglingMarkers(text));
|
|
220
|
+
}
|
|
214
221
|
if (enabled.has("kimi")) candidates.push(...parseKimi(text));
|
|
215
222
|
if (enabled.has("mistral")) {
|
|
216
223
|
candidates.push(...parseMistral(text));
|
|
@@ -252,6 +259,28 @@ function parseDsml(text: string): Candidate[] {
|
|
|
252
259
|
return candidates;
|
|
253
260
|
}
|
|
254
261
|
|
|
262
|
+
function parseDsmlDanglingMarkers(text: string): Candidate[] {
|
|
263
|
+
if (!text.includes("DSML")) return [];
|
|
264
|
+
const prefix = "(?:|{1,2}DSML|{1,2}|DSML||\\s*\\|\\s*DSML\\s*\\|\\s*)";
|
|
265
|
+
const markerRe = new RegExp(
|
|
266
|
+
`</?${prefix}(?:tool_calls|function_calls|invoke|parameter)(?:\\s+[^>\\n]*)?>?`,
|
|
267
|
+
"giu",
|
|
268
|
+
);
|
|
269
|
+
const candidates: Candidate[] = [];
|
|
270
|
+
for (const match of text.matchAll(markerRe)) {
|
|
271
|
+
if (match.index === undefined) continue;
|
|
272
|
+
if (isInsideCodeFence(text, match.index)) continue;
|
|
273
|
+
candidates.push({
|
|
274
|
+
name: "",
|
|
275
|
+
arguments: {},
|
|
276
|
+
grammar: "dsml",
|
|
277
|
+
range: { start: match.index, end: match.index + match[0].length },
|
|
278
|
+
stripOnly: true,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return candidates;
|
|
282
|
+
}
|
|
283
|
+
|
|
255
284
|
function findDsmlClose(text: string, from: number, outerName: string): Range | undefined {
|
|
256
285
|
const prefix = "(?:|{1,2}DSML|{1,2}|DSML||\\s*\\|\\s*DSML\\s*\\|\\s*)";
|
|
257
286
|
const closeRe = new RegExp(`</${prefix}${outerName}>`, "giu");
|
package/src/index.ts
CHANGED
|
@@ -131,6 +131,67 @@ export function stripAnchorBleedInPlace(obj: Record<string, unknown>): void {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
// Leaked grammar markers from GLM/ChatGLM style tool-call grammars.
|
|
135
|
+
// These can end up as literal prefixes/suffixes on parsed object keys
|
|
136
|
+
// or string values instead of being interpreted as XML tags.
|
|
137
|
+
const GRAMMAR_TOKEN_LEAKS = [
|
|
138
|
+
{ tag: "<arg_key>", at: "start" as const },
|
|
139
|
+
{ tag: "</arg_key>", at: "end" as const },
|
|
140
|
+
{ tag: "<arg_value>", at: "start" as const },
|
|
141
|
+
{ tag: "</arg_value>", at: "end" as const },
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
export function stripGrammarTokenLeaksInPlace(obj: Record<string, unknown>): void {
|
|
145
|
+
for (const key of Object.keys(obj)) {
|
|
146
|
+
const value = obj[key];
|
|
147
|
+
let newKey = key;
|
|
148
|
+
for (const { tag, at } of GRAMMAR_TOKEN_LEAKS) {
|
|
149
|
+
if (at === "start" && newKey.startsWith(tag)) {
|
|
150
|
+
newKey = newKey.slice(tag.length);
|
|
151
|
+
} else if (at === "end" && newKey.endsWith(tag)) {
|
|
152
|
+
newKey = newKey.slice(0, -tag.length);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
newKey = newKey.trim();
|
|
156
|
+
|
|
157
|
+
if (newKey !== key) {
|
|
158
|
+
obj[newKey] = value;
|
|
159
|
+
delete obj[key];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (typeof value === "string") {
|
|
163
|
+
let s = value;
|
|
164
|
+
for (const { tag, at } of GRAMMAR_TOKEN_LEAKS) {
|
|
165
|
+
if (at === "start" && s.startsWith(tag)) {
|
|
166
|
+
s = s.slice(tag.length);
|
|
167
|
+
} else if (at === "end" && s.endsWith(tag)) {
|
|
168
|
+
s = s.slice(0, -tag.length);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
obj[newKey] = s.trim();
|
|
172
|
+
} else if (Array.isArray(value)) {
|
|
173
|
+
for (let i = 0; i < value.length; i++) {
|
|
174
|
+
const item = value[i];
|
|
175
|
+
if (typeof item === "string") {
|
|
176
|
+
let s = item;
|
|
177
|
+
for (const { tag, at } of GRAMMAR_TOKEN_LEAKS) {
|
|
178
|
+
if (at === "start" && s.startsWith(tag)) {
|
|
179
|
+
s = s.slice(tag.length);
|
|
180
|
+
} else if (at === "end" && s.endsWith(tag)) {
|
|
181
|
+
s = s.slice(0, -tag.length);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
value[i] = s.trim();
|
|
185
|
+
} else if (item && typeof item === "object") {
|
|
186
|
+
stripGrammarTokenLeaksInPlace(item as Record<string, unknown>);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} else if (value && typeof value === "object") {
|
|
190
|
+
stripGrammarTokenLeaksInPlace(value as Record<string, unknown>);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
134
195
|
// ─── Phase 2: Repair Rules ────────────────────────────────────────────────────
|
|
135
196
|
|
|
136
197
|
export interface RepairResult {
|
package/tool-repair.ts
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
hasAnchorBleedBug,
|
|
46
46
|
sanitizeSchemaAnchors,
|
|
47
47
|
stripAnchorBleedInPlace,
|
|
48
|
+
stripGrammarTokenLeaksInPlace,
|
|
48
49
|
wrapRootStringAsObject,
|
|
49
50
|
repairToolInput,
|
|
50
51
|
validateAgainstSchema,
|
|
@@ -213,6 +214,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
// Defense-in-depth: strip leaked grammar tokens (e.g. GLM <arg_key>) from
|
|
218
|
+
// parsed tool-call keys/values before the schema sees them.
|
|
219
|
+
if (input && typeof input === "object") {
|
|
220
|
+
stripGrammarTokenLeaksInPlace(input as Record<string, unknown>);
|
|
221
|
+
}
|
|
222
|
+
|
|
216
223
|
// Only repair built-in tools we have schemas for
|
|
217
224
|
const schema = BUILTIN_SCHEMAS[toolName];
|
|
218
225
|
if (!schema) return;
|