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:
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user