fix(tools): trim whitespace from tool call names before lookup

When LLM models emit tool call names with leading/trailing whitespace
(e.g. " read" instead of "read"), the tool name validation in
sanitizeToolCallInputs would reject the call entirely because
trimmed !== block.name. This caused intermittent "Tool not found" errors.

This commit:
- Removes the strict trimmed !== block.name check in hasToolCallName,
  allowing names that are valid after trimming.
- Adds normalization in repairToolCallInputs to trim tool call names
  before they reach downstream lookup (toolsByName map).
- Adds comprehensive test coverage for whitespace trimming scenarios.

Fixes #27045
This commit is contained in:
root
2026-02-26 13:11:57 +08:00
committed by Philipp Spiess
parent 3f20c43308
commit 32e3630a89
2 changed files with 109 additions and 2 deletions

View File

@@ -314,4 +314,90 @@ describe("sanitizeToolCallInputs", () => {
: [];
expect(types).toEqual(["text", "toolUse"]);
});
it("trims leading whitespace from tool names", () => {
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: " read", arguments: {} }],
},
] as unknown as AgentMessage[];
const out = sanitizeToolCallInputs(input);
const toolCalls = getAssistantToolCallBlocks(out);
expect(toolCalls).toHaveLength(1);
expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
});
it("trims trailing whitespace from tool names", () => {
const input = [
{
role: "assistant",
content: [{ type: "toolUse", id: "call_1", name: "exec ", input: { command: "ls" } }],
},
] as unknown as AgentMessage[];
const out = sanitizeToolCallInputs(input);
const toolCalls = getAssistantToolCallBlocks(out);
expect(toolCalls).toHaveLength(1);
expect((toolCalls[0] as { name?: unknown }).name).toBe("exec");
});
it("trims both leading and trailing whitespace from tool names", () => {
const input = [
{
role: "assistant",
content: [
{ type: "toolCall", id: "call_1", name: " read ", arguments: {} },
{ type: "toolUse", id: "call_2", name: " exec ", input: {} },
],
},
] as unknown as AgentMessage[];
const out = sanitizeToolCallInputs(input);
const toolCalls = getAssistantToolCallBlocks(out);
expect(toolCalls).toHaveLength(2);
expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
expect((toolCalls[1] as { name?: unknown }).name).toBe("exec");
});
it("trims tool names and matches against allowlist", () => {
const input = [
{
role: "assistant",
content: [
{ type: "toolCall", id: "call_1", name: " read ", arguments: {} },
{ type: "toolCall", id: "call_2", name: " write ", arguments: {} },
],
},
] as unknown as AgentMessage[];
const out = sanitizeToolCallInputs(input, { allowedToolNames: ["read"] });
const toolCalls = getAssistantToolCallBlocks(out);
expect(toolCalls).toHaveLength(1);
expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
});
it("preserves other block properties when trimming tool names", () => {
const input = [
{
role: "assistant",
content: [
{ type: "toolCall", id: "call_1", name: " read ", arguments: { path: "/tmp/test" } },
],
},
] as unknown as AgentMessage[];
const out = sanitizeToolCallInputs(input);
const toolCalls = getAssistantToolCallBlocks(out);
expect(toolCalls).toHaveLength(1);
expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
expect((toolCalls[0] as { id?: unknown }).id).toBe("call_1");
expect((toolCalls[0] as { arguments?: unknown }).arguments).toEqual({ path: "/tmp/test" });
});
});

View File

@@ -60,7 +60,7 @@ function hasToolCallName(block: ToolCallBlock, allowedToolNames: Set<string> | n
return false;
}
const trimmed = block.name.trim();
if (!trimmed || trimmed !== block.name) {
if (!trimmed) {
return false;
}
if (trimmed.length > TOOL_CALL_NAME_MAX_CHARS || !TOOL_CALL_NAME_RE.test(trimmed)) {
@@ -143,8 +143,9 @@ export function repairToolCallInputs(
continue;
}
const nextContent = [];
const nextContent: typeof msg.content = [];
let droppedInMessage = 0;
let trimmedInMessage = 0;
for (const block of msg.content) {
if (
@@ -158,6 +159,19 @@ export function repairToolCallInputs(
changed = true;
continue;
}
// Normalize tool call names by trimming whitespace so that downstream
// lookup (toolsByName map) matches correctly even when the model emits
// names with leading/trailing spaces (e.g. " read" → "read").
if (isToolCallBlock(block) && typeof (block as ToolCallBlock).name === "string") {
const rawName = (block as ToolCallBlock).name as string;
if (rawName !== rawName.trim()) {
const normalized = { ...block, name: rawName.trim() } as typeof block;
nextContent.push(normalized);
trimmedInMessage += 1;
changed = true;
continue;
}
}
nextContent.push(block);
}
@@ -171,6 +185,13 @@ export function repairToolCallInputs(
continue;
}
// When tool names were trimmed but nothing was dropped,
// we still need to emit the message with the normalized content.
if (trimmedInMessage > 0) {
out.push({ ...msg, content: nextContent });
continue;
}
out.push(msg);
}