Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/Tool.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
27 changes: 27 additions & 0 deletions src/Tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 23 additions & 2 deletions src/services/tools/StreamingToolExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 28 additions & 4 deletions src/services/tools/toolExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
import {
findToolByName,
findToolByNameOrUniquePrefix,
type Tool,
type ToolProgress,
type ToolProgressData,
Expand Down Expand Up @@ -388,16 +389,39 @@ export async function* runToolUse(
): AsyncGenerator<MessageUpdateLazy, void> {
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
Expand Down
Loading