pi-tool-repair 0.1.0 → 0.1.2
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 +6 -0
- package/package.json +1 -1
- package/src/grammar-repair.ts +2 -2
- package/src/index.ts +36 -0
- package/tool-repair.ts +87 -7
package/README.md
CHANGED
|
@@ -40,6 +40,12 @@ Reverse-engineered from [Command Code](https://commandcode.ai/)'s tool parsing p
|
|
|
40
40
|
pi install https://github.com/monotykamary/pi-tool-repair
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
**With npm**:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm install pi-tool-repair
|
|
47
|
+
```
|
|
48
|
+
|
|
43
49
|
**Manual** — add to `~/.pi/agent/settings.json`:
|
|
44
50
|
|
|
45
51
|
```json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tool-repair",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
package/src/grammar-repair.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
2
|
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
|
|
5
5
|
export const GRAMMAR_NAMES = [
|
|
6
6
|
"dsml",
|
|
@@ -109,7 +109,7 @@ export function loadGrammarRepairConfig(path = defaultConfigPath()): GrammarRepa
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
export function defaultConfigPath(): string {
|
|
112
|
-
return join(
|
|
112
|
+
return join(getAgentDir(), "extensions", "pi-tool-repair.json");
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
export function normalizeGrammarRepairConfig(raw: Partial<GrammarRepairConfig> = {}): GrammarRepairConfig {
|
package/src/index.ts
CHANGED
|
@@ -296,6 +296,42 @@ export function wrapRootStringAsObject(
|
|
|
296
296
|
};
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
// ─── Phase 1.5: Phantom toolUse normalization ───────────────────────────────
|
|
300
|
+
//
|
|
301
|
+
// Some providers (notably vLLM-backed endpoints like z.ai and Lilac) intermittently
|
|
302
|
+
// emit finish_reason: "tool_calls" without any delta.tool_calls chunks. Pi maps this
|
|
303
|
+
// to stopReason: "toolUse" with zero toolCall blocks — a broken state where the
|
|
304
|
+
// agent loop thinks it should execute tools but has nothing to run, causing an
|
|
305
|
+
// "abrupt stop". Detect and normalize to stopReason: "stop" so the agent exits
|
|
306
|
+
// cleanly.
|
|
307
|
+
|
|
308
|
+
export interface PhantomToolUseResult {
|
|
309
|
+
changed: boolean;
|
|
310
|
+
message: MinimalAssistantMessage;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function normalizePhantomToolUse(
|
|
314
|
+
message: MinimalAssistantMessage,
|
|
315
|
+
): PhantomToolUseResult {
|
|
316
|
+
if (message.role !== "assistant") return { changed: false, message };
|
|
317
|
+
if (message.stopReason !== "toolUse") return { changed: false, message };
|
|
318
|
+
|
|
319
|
+
const content = message.content;
|
|
320
|
+
const hasToolCalls = Array.isArray(content) &&
|
|
321
|
+
content.some((block) => typeof block === "object" && block !== null && !Array.isArray(block) && (block as Record<string, unknown>).type === "toolCall");
|
|
322
|
+
|
|
323
|
+
if (hasToolCalls) return { changed: false, message };
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
changed: true,
|
|
327
|
+
message: {
|
|
328
|
+
...message,
|
|
329
|
+
stopReason: "error",
|
|
330
|
+
errorMessage: "stream ended before tool_calls were received (vLLM phantom tool_use)",
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
299
335
|
// ─── Deep clone ───────────────────────────────────────────────────────────────
|
|
300
336
|
|
|
301
337
|
export function deepClone(value: unknown): unknown {
|
package/tool-repair.ts
CHANGED
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* - Strip leaked XML/sentinel tool-call grammars from assistant text/thinking
|
|
15
15
|
* - Recover complete, known-tool calls into pi toolCall blocks
|
|
16
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
|
+
*
|
|
17
23
|
* Phase 2: Validate-then-repair (tool_call hook)
|
|
18
24
|
* - Validate input against the tool's schema
|
|
19
25
|
* - On failure, walk the validator's issue list and apply targeted repairs
|
|
@@ -35,6 +41,7 @@
|
|
|
35
41
|
|
|
36
42
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
37
43
|
import {
|
|
44
|
+
DEFAULT_CONFIG,
|
|
38
45
|
hasAnchorBleedBug,
|
|
39
46
|
sanitizeSchemaAnchors,
|
|
40
47
|
stripAnchorBleedInPlace,
|
|
@@ -45,16 +52,39 @@ import {
|
|
|
45
52
|
BUILTIN_SCHEMAS,
|
|
46
53
|
loadGrammarRepairConfig,
|
|
47
54
|
repairAssistantMessageGrammarLeaks,
|
|
55
|
+
normalizePhantomToolUse,
|
|
48
56
|
type MinimalAssistantMessage,
|
|
49
57
|
} from "./src/index.js";
|
|
50
58
|
|
|
59
|
+
// Safely access ctx.model without throwing on stale contexts.
|
|
60
|
+
// After session replacement (newSession/fork/switchSession/reload), the
|
|
61
|
+
// extension runner invalidates stale contexts and ctx.model throws.
|
|
62
|
+
// When that happens, the request belongs to a dead session — bail out.
|
|
63
|
+
function safeGetModel(ctx: { model?: any }): any | undefined {
|
|
64
|
+
try {
|
|
65
|
+
return ctx.model;
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Safely call pi.getActiveTools() without throwing on stale contexts.
|
|
72
|
+
function safeGetActiveTools(pi: ExtensionAPI): string[] {
|
|
73
|
+
try {
|
|
74
|
+
return pi.getActiveTools();
|
|
75
|
+
} catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
51
80
|
export default function (pi: ExtensionAPI) {
|
|
52
81
|
const grammarRepairConfig = loadGrammarRepairConfig();
|
|
53
82
|
|
|
54
83
|
// Phase 0: Schema poisoning defense (before_provider_request)
|
|
55
84
|
// Strip regex anchors from JSON Schema patterns for models where they leak
|
|
56
85
|
pi.on("before_provider_request", (event, ctx) => {
|
|
57
|
-
|
|
86
|
+
const model = safeGetModel(ctx);
|
|
87
|
+
if (!model || !hasAnchorBleedBug(model)) return;
|
|
58
88
|
|
|
59
89
|
const payload = event.payload as Record<string, unknown>;
|
|
60
90
|
if (!payload || typeof payload !== "object") return;
|
|
@@ -89,16 +119,64 @@ export default function (pi: ExtensionAPI) {
|
|
|
89
119
|
}
|
|
90
120
|
});
|
|
91
121
|
|
|
92
|
-
// Phase 1: Grammar leak repair (message_end)
|
|
93
|
-
//
|
|
94
|
-
//
|
|
122
|
+
// Phase 1 + 1.5: Grammar leak repair + phantom toolUse normalization (message_end)
|
|
123
|
+
//
|
|
124
|
+
// Phase 1.5 (always-on): Detect stopReason: "toolUse" with zero toolCall blocks
|
|
125
|
+
// and convert to a retryable error (stopReason: "error"). This triggers pi's
|
|
126
|
+
// built-in auto-retry mechanism so the agent re-prompts automatically. This
|
|
127
|
+
// guards against vLLM streaming bugs where finish_reason: "tool_calls" is emitted
|
|
128
|
+
// without any delta.tool_calls chunks.
|
|
129
|
+
//
|
|
130
|
+
// Phase 1 (opt-in): Promote leaked XML/sentinel tool-call grammars from
|
|
131
|
+
// assistant text/thinking into pi toolCall blocks. Configured via
|
|
95
132
|
// ~/.pi/agent/extensions/pi-tool-repair.json.
|
|
96
133
|
pi.on("message_end", (event) => {
|
|
97
|
-
if (!grammarRepairConfig.enabled) return;
|
|
98
134
|
if (event.message.role !== "assistant") return;
|
|
99
135
|
|
|
136
|
+
// Phase 1.5: Phantom toolUse normalization (always-on)
|
|
137
|
+
const currentMessage = event.message as unknown as MinimalAssistantMessage;
|
|
138
|
+
const phantomResult = normalizePhantomToolUse(currentMessage);
|
|
139
|
+
|
|
140
|
+
if (phantomResult.changed) {
|
|
141
|
+
if (DEFAULT_CONFIG.debug) {
|
|
142
|
+
process.stderr.write(
|
|
143
|
+
`[pi-tool-repair] phantom-tooluse: converted stopReason from "toolUse" to retryable error (no toolCall blocks)\n`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// If grammar repair is also enabled, run it on the normalized message so
|
|
148
|
+
// it can still recover leaked tool calls from the text content. If grammar
|
|
149
|
+
// repair recovers calls, it will set stopReason back to "toolUse".
|
|
150
|
+
if (grammarRepairConfig.enabled) {
|
|
151
|
+
const knownTools = new Set(
|
|
152
|
+
safeGetActiveTools(pi)
|
|
153
|
+
.filter((name): name is string => typeof name === "string" && name.length > 0),
|
|
154
|
+
);
|
|
155
|
+
const grammarResult = repairAssistantMessageGrammarLeaks(
|
|
156
|
+
phantomResult.message,
|
|
157
|
+
grammarRepairConfig,
|
|
158
|
+
knownTools,
|
|
159
|
+
);
|
|
160
|
+
if (grammarResult.changed) {
|
|
161
|
+
if (grammarRepairConfig.debug) {
|
|
162
|
+
const calls = grammarResult.recoveredCalls.map((call) => `${call.grammar}:${call.name}`).join(",") || "none";
|
|
163
|
+
process.stderr.write(
|
|
164
|
+
`[pi-tool-repair] grammar-repair mode=${grammarRepairConfig.mode} ` +
|
|
165
|
+
`stripped=${grammarResult.strippedRanges} recovered=${calls}\n`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return { message: grammarResult.message as any };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { message: phantomResult.message as any };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Phase 1: Grammar leak repair (opt-in)
|
|
176
|
+
if (!grammarRepairConfig.enabled) return;
|
|
177
|
+
|
|
100
178
|
const knownTools = new Set(
|
|
101
|
-
pi
|
|
179
|
+
safeGetActiveTools(pi)
|
|
102
180
|
.filter((name): name is string => typeof name === "string" && name.length > 0),
|
|
103
181
|
);
|
|
104
182
|
|
|
@@ -126,8 +204,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
126
204
|
const toolName = event.toolName;
|
|
127
205
|
const input = (event as any).input;
|
|
128
206
|
|
|
207
|
+
const model = safeGetModel(ctx);
|
|
208
|
+
|
|
129
209
|
// Defense-in-depth: strip anchor-bleed from generated values
|
|
130
|
-
if (hasAnchorBleedBug(
|
|
210
|
+
if (model && hasAnchorBleedBug(model)) {
|
|
131
211
|
if (input && typeof input === "object") {
|
|
132
212
|
stripAnchorBleedInPlace(input);
|
|
133
213
|
}
|