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 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.0",
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",
@@ -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(homedir(), ".pi", "agent", "extensions", "pi-tool-repair.json");
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
- if (!hasAnchorBleedBug(ctx.model)) return;
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
- // Promote leaked XML/sentinel tool-call grammars from assistant text/thinking
94
- // into pi toolCall blocks. Disabled by default and configured via
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.getActiveTools()
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(ctx.model)) {
210
+ if (model && hasAnchorBleedBug(model)) {
131
211
  if (input && typeof input === "object") {
132
212
  stripAnchorBleedInPlace(input);
133
213
  }