diff --git a/src/Tool.test.ts b/src/Tool.test.ts new file mode 100644 index 0000000000..d0947eee66 --- /dev/null +++ b/src/Tool.test.ts @@ -0,0 +1,40 @@ +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('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: 'Real' }] 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..b5f7131308 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -374,6 +374,33 @@ 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. 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, + 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)) + 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 +} + 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..20f5eb9be6 100644 --- a/src/services/tools/StreamingToolExecutor.ts +++ b/src/services/tools/StreamingToolExecutor.ts @@ -5,7 +5,13 @@ import { withMemoryCorrectionHint, } from 'src/utils/messages.js' import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' -import { findToolByName, type Tools, type ToolUseContext } from '../../Tool.js' +import { + findToolByName, + findToolByNameOrUniquePrefix, + 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' @@ -80,7 +86,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 = findToolByName(this.toolDefinitions, block.name) + 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 27acd1ec16..9f96a05236 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,16 +389,39 @@ 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") // 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