From 32e3630a89e3690fa1f64d2ee6e925fb3e01a097 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 26 Feb 2026 13:11:57 +0800 Subject: [PATCH] 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 --- src/agents/session-transcript-repair.test.ts | 86 ++++++++++++++++++++ src/agents/session-transcript-repair.ts | 25 +++++- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index e1422f7ea40..1ff6b50ff22 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -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" }); + }); }); diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index 31b9624874c..33d7fcc55ef 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -60,7 +60,7 @@ function hasToolCallName(block: ToolCallBlock, allowedToolNames: Set | 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); }