fix(pi-claude-cli): rewrite bare custom tool refs in system prompt to MCP names

Triage system prompts read "MUST call fn_review_spec()" but Sonnet 4.6 routed
through pi-claude-cli writes PROMPT.md and silently skips the call — even with
the addendum explaining the deferred-tool protocol. Confirmed by the FN-2564
agent log: model called other MCP tools (fn_memory_search, fn_task_list) fine
but consistently never reached fn_review_spec, leaving triage looping on
"fn_review_spec was never called" and falling back to zai/glm-5.1 every time.

Rewrite bare `fn_*` (and any non-built-in custom tool name) references in the
system prompt to their `mcp__custom-tools__fn_*` form before sending. The
prompt now literally says "call mcp__custom-tools__fn_review_spec()" so the
model has no inference step, and the deferred-tool reminder Claude Code injects
matches verbatim. Word-boundary safe (won't touch fn_review_specifier) and
idempotent (won't double-prefix already-MCP-named occurrences).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-25 16:50:30 -07:00
parent cd4cef38fc
commit 5e0004cca2
2 changed files with 100 additions and 1 deletions

View File

@@ -910,6 +910,67 @@ describe("buildSystemPrompt", () => {
expect(result).toContain("IMPORTANT:");
expect(result).toContain("tool results");
});
it("rewrites bare custom tool references to MCP-prefixed names", async () => {
vi.resetModules();
vi.doMock("node:fs", () => ({
existsSync: () => false,
readFileSync: () => "",
}));
const { buildSystemPrompt: bsp } = await import("../prompt-builder");
const context = {
systemPrompt:
"Write the PROMPT.md, then call `fn_review_spec()` for review. " +
"If REVISE, call fn_review_spec again. Do not call mcp__custom-tools__fn_review_spec twice manually.",
messages: [],
tools: [
{ name: "fn_review_spec", description: "review", parameters: {} },
{ name: "read", description: "builtin", parameters: {} },
],
} as unknown as any;
const result = bsp(context, "/some/project");
// Bare name occurrences are rewritten
expect(result).toContain(
"call `mcp__custom-tools__fn_review_spec()` for review",
);
expect(result).toContain(
"call mcp__custom-tools__fn_review_spec again",
);
// Already-prefixed occurrence is not double-prefixed
expect(result).not.toContain(
"mcp__custom-tools__mcp__custom-tools__fn_review_spec",
);
// Built-in pi tool names are not rewritten
expect(result).not.toContain("mcp__custom-tools__read");
// The addendum still lists the custom tool with its full mapping
expect(result).toContain("mcp__custom-tools__fn_review_spec");
});
it("does not rewrite identifier substrings that happen to overlap a tool name", async () => {
vi.resetModules();
vi.doMock("node:fs", () => ({
existsSync: () => false,
readFileSync: () => "",
}));
const { buildSystemPrompt: bsp } = await import("../prompt-builder");
const context = {
systemPrompt: "fn_review_specifier and fn_reviews are not the tool.",
messages: [],
tools: [
{ name: "fn_review", description: "x", parameters: {} },
{ name: "fn_review_spec", description: "y", parameters: {} },
],
} as unknown as any;
const result = bsp(context, "/some/project");
// Neither substring should be rewritten — they're different identifiers.
expect(result).toContain("fn_review_specifier");
expect(result).toContain("fn_reviews");
expect(result).not.toContain("mcp__custom-tools__fn_review_specifier");
expect(result).not.toContain("mcp__custom-tools__fn_reviews");
});
});
describe("buildResumePrompt", () => {

View File

@@ -385,7 +385,7 @@ export function buildSystemPrompt(
const parts: string[] = [];
if (context.systemPrompt) {
parts.push(context.systemPrompt);
parts.push(rewriteCustomToolReferences(context.systemPrompt, context.tools));
}
// Look for AGENTS.md
@@ -425,8 +425,46 @@ const BUILT_IN_PI_TOOLS = new Set([
"bash",
"grep",
"find",
"ls",
]);
/**
* Rewrite bare references to custom pi tool names (e.g. `fn_review_spec`,
* `fn_review_spec()`) in the system prompt so they appear as their
* MCP-prefixed names (`mcp__custom-tools__fn_review_spec`). Engine prompts are
* written for direct API tool calls; under pi-claude-cli the same tools are
* reachable only through the MCP shim. Without this rewrite, models like
* Sonnet 4.6 inconsistently translate the names — sometimes calling MCP
* variants, sometimes silently skipping the call (observed in triage where
* `fn_review_spec` was never invoked even though the prompt said "MUST call").
*
* Only rewrites whole-word matches anchored to a non-identifier boundary, so
* substrings inside other identifiers stay intact. Skips already-prefixed
* occurrences (`mcp__custom-tools__fn_review_spec`) and pi built-ins.
*/
function rewriteCustomToolReferences(
prompt: string,
tools: ReadonlyArray<PiToolLike> | undefined,
): string {
if (!prompt || !tools || tools.length === 0) return prompt;
let result = prompt;
for (const tool of tools) {
if (BUILT_IN_PI_TOOLS.has(tool.name)) continue;
// \b doesn't treat `_` as a word boundary the way we want here, so anchor
// the match between either start-of-string/non-identifier-char and either
// end-of-string/non-identifier-char. Also negative-lookbehind for
// `mcp__custom-tools__` so we don't double-prefix.
const escaped = tool.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(
`(?<![A-Za-z0-9_])(?<!mcp__custom-tools__)${escaped}(?![A-Za-z0-9_])`,
"g",
);
result = result.replace(pattern, `mcp__custom-tools__${tool.name}`);
}
return result;
}
/**
* Build a system-prompt addendum that maps each custom pi tool to its
* MCP-exposed name (`mcp__custom-tools__<name>`) and explains Claude Code's