From 3ba077429b222cc390e40d2d3cc879bfdbbc090e Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sat, 8 Aug 2026 17:32:17 -0300 Subject: [PATCH 1/4] fix(tools): recover unambiguous truncated tool names --- src/Tool.test.ts | 31 +++++++++++++++++++++ src/Tool.ts | 19 +++++++++++++ src/services/tools/StreamingToolExecutor.ts | 11 ++++++-- src/services/tools/toolExecution.ts | 12 +++++++- 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 src/Tool.test.ts diff --git a/src/Tool.test.ts b/src/Tool.test.ts new file mode 100644 index 0000000000..f772d701e1 --- /dev/null +++ b/src/Tool.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' + +import { findToolByNameOrUniquePrefix, type Tools } from './Tool.js' + +const tools = [ + { name: 'Read' }, + { name: 'Bash' }, + { name: 'Grep' }, +] as Tools + +describe('findToolByNameOrUniquePrefix', () => { + test('recovers an unambiguous provider-truncated built-in tool name', () => { + expect(findToolByNameOrUniquePrefix(tools, 'Rea')?.name).toBe('Read') + }) + + test('does not guess short, ambiguous, or MCP tool names', () => { + expect(findToolByNameOrUniquePrefix(tools, 'R')).toBeUndefined() + expect( + findToolByNameOrUniquePrefix( + [{ name: 'Read' }, { name: 'Ready' }] as Tools, + 'Rea', + ), + ).toBeUndefined() + expect( + findToolByNameOrUniquePrefix( + [{ name: 'mcp__files__read' }] as Tools, + 'mcp__files__rea', + ), + ).toBeUndefined() + }) +}) diff --git a/src/Tool.ts b/src/Tool.ts index e3d36405ea..a94ad6045d 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -374,6 +374,25 @@ export function findToolByName(tools: Tools, name: string): Tool | undefined { return tools.find(t => toolMatchesName(t, name)) } +/** + * Resolves a provider-truncated built-in tool name only when the prefix is + * unambiguous among tools already available to the model. This keeps malformed + * calls such as `Rea` recoverable as `Read` without guessing MCP tool names or + * accepting very short, collision-prone prefixes. + */ +export function findToolByNameOrUniquePrefix( + tools: Tools, + name: string, +): Tool | undefined { + const exactMatch = findToolByName(tools, name) + if (exactMatch) return exactMatch + + if (name.length < 3 || name.startsWith('mcp__')) return undefined + + const prefixMatches = tools.filter(tool => tool.name.startsWith(name)) + return prefixMatches.length === 1 ? prefixMatches[0] : undefined +} + export type Tool< Input extends AnyObject = AnyObject, Output = unknown, diff --git a/src/services/tools/StreamingToolExecutor.ts b/src/services/tools/StreamingToolExecutor.ts index 2f698b43a6..b4eeeba59d 100644 --- a/src/services/tools/StreamingToolExecutor.ts +++ b/src/services/tools/StreamingToolExecutor.ts @@ -5,7 +5,11 @@ import { withMemoryCorrectionHint, } from 'src/utils/messages.js' import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' -import { findToolByName, type Tools, type ToolUseContext } from '../../Tool.js' +import { + findToolByNameOrUniquePrefix, + type Tools, + type ToolUseContext, +} from '../../Tool.js' import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' import type { AssistantMessage, Message } from '../../types/message.js' import { createChildAbortController } from '../../utils/abortController.js' @@ -80,7 +84,10 @@ export class StreamingToolExecutor { * Add a tool to the execution queue. Will start executing immediately if conditions allow. */ addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void { - const toolDefinition = findToolByName(this.toolDefinitions, block.name) + const toolDefinition = findToolByNameOrUniquePrefix( + this.toolDefinitions, + block.name, + ) if (!toolDefinition) { this.tools.push({ id: block.id, diff --git a/src/services/tools/toolExecution.ts b/src/services/tools/toolExecution.ts index 27acd1ec16..a833a59679 100644 --- a/src/services/tools/toolExecution.ts +++ b/src/services/tools/toolExecution.ts @@ -29,6 +29,7 @@ import { import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' import { findToolByName, + findToolByNameOrUniquePrefix, type Tool, type ToolProgress, type ToolProgressData, @@ -388,7 +389,16 @@ export async function* runToolUse( ): AsyncGenerator { const toolName = toolUse.name // First try to find in the available tools (what the model sees) - let tool = findToolByName(toolUseContext.options.tools, toolName) + let tool = findToolByNameOrUniquePrefix( + toolUseContext.options.tools, + toolName, + ) + + if (tool && tool.name !== toolName) { + logForDebugging( + `Recovered truncated tool name ${toolName} as ${tool.name}: ${toolUse.id}`, + ) + } // If not found, check if it's a deprecated tool being called by alias // (e.g., old transcripts calling "KillShell" which is now an alias for "TaskStop") From 8d22e7d65414840230df4591658d1ec234be596d Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sat, 8 Aug 2026 17:39:57 -0300 Subject: [PATCH 2/4] fix(tools): recover truncated names after filtering --- src/services/tools/StreamingToolExecutor.ts | 15 ++++++++++++++- src/services/tools/toolExecution.ts | 20 +++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/services/tools/StreamingToolExecutor.ts b/src/services/tools/StreamingToolExecutor.ts index b4eeeba59d..a570934921 100644 --- a/src/services/tools/StreamingToolExecutor.ts +++ b/src/services/tools/StreamingToolExecutor.ts @@ -10,6 +10,7 @@ import { type Tools, type ToolUseContext, } from '../../Tool.js' +import { getAllBaseTools } from '../../tools.js' import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' import type { AssistantMessage, Message } from '../../types/message.js' import { createChildAbortController } from '../../utils/abortController.js' @@ -84,10 +85,22 @@ export class StreamingToolExecutor { * Add a tool to the execution queue. Will start executing immediately if conditions allow. */ addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void { - const toolDefinition = findToolByNameOrUniquePrefix( + let toolDefinition = findToolByNameOrUniquePrefix( this.toolDefinitions, block.name, ) + if (!toolDefinition) { + const baseTool = findToolByNameOrUniquePrefix( + getAllBaseTools(), + block.name, + ) + if (baseTool && !baseTool.isMcp && baseTool.name !== block.name) { + toolDefinition = baseTool + logForDebugging( + `Recovered truncated base tool name ${block.name} as ${baseTool.name}: ${block.id}`, + ) + } + } if (!toolDefinition) { this.tools.push({ id: block.id, diff --git a/src/services/tools/toolExecution.ts b/src/services/tools/toolExecution.ts index a833a59679..9f96a05236 100644 --- a/src/services/tools/toolExecution.ts +++ b/src/services/tools/toolExecution.ts @@ -404,10 +404,24 @@ export async function* runToolUse( // (e.g., old transcripts calling "KillShell" which is now an alias for "TaskStop") // Only fall back for tools where the name matches an alias, not the primary name if (!tool) { - const fallbackTool = findToolByName(getAllBaseTools(), toolName) - // Only use fallback if the tool was found via alias (deprecated name) - if (fallbackTool && fallbackTool.aliases?.includes(toolName)) { + const fallbackTool = findToolByNameOrUniquePrefix( + getAllBaseTools(), + toolName, + ) + // A model can only call a tool it received in its schema. Recover an exact + // alias or an unambiguous truncated built-in name when filtering removed + // the original definition from this execution path. + if ( + fallbackTool && + (fallbackTool.aliases?.includes(toolName) || + (!fallbackTool.isMcp && fallbackTool.name !== toolName)) + ) { tool = fallbackTool + if (tool.name !== toolName) { + logForDebugging( + `Recovered truncated base tool name ${toolName} as ${tool.name}: ${toolUse.id}`, + ) + } } } const messageId = assistantMessage.message.id From 15aabd606c33cd7e66c91227e59e49a50a950a4a Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sat, 8 Aug 2026 19:12:46 -0300 Subject: [PATCH 3/4] fix(tools): prefer direct completion for truncated names --- src/Tool.test.ts | 13 +++++++++++-- src/Tool.ts | 14 +++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/Tool.test.ts b/src/Tool.test.ts index f772d701e1..d0947eee66 100644 --- a/src/Tool.test.ts +++ b/src/Tool.test.ts @@ -13,11 +13,20 @@ describe('findToolByNameOrUniquePrefix', () => { expect(findToolByNameOrUniquePrefix(tools, 'Rea')?.name).toBe('Read') }) - test('does not guess short, ambiguous, or MCP tool names', () => { + test('prefers a unique one-character completion over longer deferred tools', () => { + expect( + findToolByNameOrUniquePrefix( + [{ name: 'Read' }, { name: 'ReadMcpResourceTool' }] as Tools, + 'Rea', + )?.name, + ).toBe('Read') + }) + + test('does not guess short, genuinely ambiguous, or MCP tool names', () => { expect(findToolByNameOrUniquePrefix(tools, 'R')).toBeUndefined() expect( findToolByNameOrUniquePrefix( - [{ name: 'Read' }, { name: 'Ready' }] as Tools, + [{ name: 'Read' }, { name: 'Real' }] as Tools, 'Rea', ), ).toBeUndefined() diff --git a/src/Tool.ts b/src/Tool.ts index a94ad6045d..b5f7131308 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -376,9 +376,10 @@ export function findToolByName(tools: Tools, name: string): Tool | undefined { /** * Resolves a provider-truncated built-in tool name only when the prefix is - * unambiguous among tools already available to the model. This keeps malformed - * calls such as `Rea` recoverable as `Read` without guessing MCP tool names or - * accepting very short, collision-prone prefixes. + * unambiguous. Prefer a unique one-character completion before considering + * longer names: `Rea` should recover `Read` even when the deferred + * `ReadMcpResourceTool` is also registered. Do not guess MCP names or accept + * very short, collision-prone prefixes. */ export function findToolByNameOrUniquePrefix( tools: Tools, @@ -390,6 +391,13 @@ export function findToolByNameOrUniquePrefix( if (name.length < 3 || name.startsWith('mcp__')) return undefined const prefixMatches = tools.filter(tool => tool.name.startsWith(name)) + const oneCharacterCompletions = prefixMatches.filter( + tool => tool.name.length === name.length + 1, + ) + if (oneCharacterCompletions.length === 1) { + return oneCharacterCompletions[0] + } + return prefixMatches.length === 1 ? prefixMatches[0] : undefined } From b70f98e7193adff252a7edffa8637015a1888cf5 Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sun, 9 Aug 2026 11:34:58 -0300 Subject: [PATCH 4/4] fix(tools): restore findToolByName import for interrupt handling 7523ea3 swapped the tool lookup import to findToolByNameOrUniquePrefix but getToolInterruptBehavior still calls findToolByName, which would throw a ReferenceError on the cancel/interrupt path. Re-add the import. --- src/services/tools/StreamingToolExecutor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/tools/StreamingToolExecutor.ts b/src/services/tools/StreamingToolExecutor.ts index a570934921..20f5eb9be6 100644 --- a/src/services/tools/StreamingToolExecutor.ts +++ b/src/services/tools/StreamingToolExecutor.ts @@ -6,6 +6,7 @@ import { } from 'src/utils/messages.js' import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' import { + findToolByName, findToolByNameOrUniquePrefix, type Tools, type ToolUseContext,