pi-tool-repair 0.1.4 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tool-repair",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",
@@ -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/index.ts CHANGED
@@ -131,6 +131,80 @@ 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>): boolean {
145
+ let changed = false;
146
+ for (const key of Object.keys(obj)) {
147
+ const value = obj[key];
148
+ let newKey = key;
149
+ for (const { tag, at } of GRAMMAR_TOKEN_LEAKS) {
150
+ if (at === "start" && newKey.startsWith(tag)) {
151
+ newKey = newKey.slice(tag.length);
152
+ } else if (at === "end" && newKey.endsWith(tag)) {
153
+ newKey = newKey.slice(0, -tag.length);
154
+ }
155
+ }
156
+ newKey = newKey.trim();
157
+
158
+ if (newKey !== key) {
159
+ obj[newKey] = value;
160
+ delete obj[key];
161
+ changed = true;
162
+ }
163
+
164
+ if (typeof value === "string") {
165
+ let s = value;
166
+ for (const { tag, at } of GRAMMAR_TOKEN_LEAKS) {
167
+ if (at === "start" && s.startsWith(tag)) {
168
+ s = s.slice(tag.length);
169
+ } else if (at === "end" && s.endsWith(tag)) {
170
+ s = s.slice(0, -tag.length);
171
+ }
172
+ }
173
+ const trimmed = s.trim();
174
+ if (trimmed !== value) {
175
+ obj[newKey] = trimmed;
176
+ changed = true;
177
+ }
178
+ } else if (Array.isArray(value)) {
179
+ for (let i = 0; i < value.length; i++) {
180
+ const item = value[i];
181
+ if (typeof item === "string") {
182
+ let s = item;
183
+ for (const { tag, at } of GRAMMAR_TOKEN_LEAKS) {
184
+ if (at === "start" && s.startsWith(tag)) {
185
+ s = s.slice(tag.length);
186
+ } else if (at === "end" && s.endsWith(tag)) {
187
+ s = s.slice(0, -tag.length);
188
+ }
189
+ }
190
+ const trimmed = s.trim();
191
+ if (trimmed !== item) {
192
+ value[i] = trimmed;
193
+ changed = true;
194
+ }
195
+ } else if (item && typeof item === "object") {
196
+ const nestedChanged = stripGrammarTokenLeaksInPlace(item as Record<string, unknown>);
197
+ if (nestedChanged) changed = true;
198
+ }
199
+ }
200
+ } else if (value && typeof value === "object") {
201
+ const nestedChanged = stripGrammarTokenLeaksInPlace(value as Record<string, unknown>);
202
+ if (nestedChanged) changed = true;
203
+ }
204
+ }
205
+ return changed;
206
+ }
207
+
134
208
  // ─── Phase 2: Repair Rules ────────────────────────────────────────────────────
135
209
 
136
210
  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,
@@ -133,8 +134,31 @@ export default function (pi: ExtensionAPI) {
133
134
  pi.on("message_end", (event) => {
134
135
  if (event.message.role !== "assistant") return;
135
136
 
136
- // Phase 1.5: Phantom toolUse normalization (always-on)
137
137
  const currentMessage = event.message as unknown as MinimalAssistantMessage;
138
+
139
+ // Phase 1.4: Strip leaked grammar tokens from existing pi toolCall blocks.
140
+ // These can leak from GLM-style grammars straight into the parsed argument
141
+ // keys (e.g. "<arg_key>command" instead of "command"). We have to repair
142
+ // them here, before pi preflights and validates the tool calls.
143
+ let toolCallArgsChanged = false;
144
+ if (Array.isArray(currentMessage.content)) {
145
+ for (const part of currentMessage.content) {
146
+ if (
147
+ part && typeof part === "object" && !Array.isArray(part) &&
148
+ (part as Record<string, unknown>).type === "toolCall"
149
+ ) {
150
+ const args = (part as Record<string, unknown>).arguments;
151
+ if (
152
+ args && typeof args === "object" && !Array.isArray(args) &&
153
+ stripGrammarTokenLeaksInPlace(args as Record<string, unknown>)
154
+ ) {
155
+ toolCallArgsChanged = true;
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ // Phase 1.5: Phantom toolUse normalization (always-on)
138
162
  const phantomResult = normalizePhantomToolUse(currentMessage);
139
163
 
140
164
  if (phantomResult.changed) {
@@ -173,7 +197,10 @@ export default function (pi: ExtensionAPI) {
173
197
  }
174
198
 
175
199
  // Phase 1: Grammar leak repair (opt-in)
176
- if (!grammarRepairConfig.enabled) return;
200
+ if (!grammarRepairConfig.enabled) {
201
+ if (toolCallArgsChanged) return { message: currentMessage as any };
202
+ return;
203
+ }
177
204
 
178
205
  const knownTools = new Set(
179
206
  safeGetActiveTools(pi)
@@ -186,9 +213,9 @@ export default function (pi: ExtensionAPI) {
186
213
  knownTools,
187
214
  );
188
215
 
189
- if (!result.changed) return;
216
+ if (!result.changed && !toolCallArgsChanged) return;
190
217
 
191
- if (grammarRepairConfig.debug) {
218
+ if (grammarRepairConfig.debug && result.changed) {
192
219
  const calls = result.recoveredCalls.map((call) => `${call.grammar}:${call.name}`).join(",") || "none";
193
220
  process.stderr.write(
194
221
  `[pi-tool-repair] grammar-repair mode=${grammarRepairConfig.mode} ` +
@@ -213,6 +240,12 @@ export default function (pi: ExtensionAPI) {
213
240
  }
214
241
  }
215
242
 
243
+ // Defense-in-depth: strip leaked grammar tokens (e.g. GLM <arg_key>) from
244
+ // parsed tool-call keys/values before the schema sees them.
245
+ if (input && typeof input === "object") {
246
+ stripGrammarTokenLeaksInPlace(input as Record<string, unknown>);
247
+ }
248
+
216
249
  // Only repair built-in tools we have schemas for
217
250
  const schema = BUILTIN_SCHEMAS[toolName];
218
251
  if (!schema) return;