From 2362d912fe324635932a4c0f228b4e29532ef4bf Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 10 Dec 2025 12:01:49 -0500 Subject: [PATCH 01/16] feat: add tool alias support for model-specific tool customization This PR adds a tool alias system that allows models to expose tools under different names while mapping to existing tool implementations. This is particularly useful for: 1. Model-specific tool naming preferences (e.g., 'edit_file' -> 'search_and_replace') 2. Backward compatibility when renaming tools 3. Semantic tool naming that better matches model training ## Changes ### Core Alias Infrastructure - Added `aliases` property to BaseTool for defining alternative tool names - Implemented alias registration and validation in filter-tools-for-mode.ts - Added `resolveToolAlias()`, `applyToolAliases()`, and `getToolAliasGroup()` helpers ### Model Tool Customization - Enhanced `applyModelToolCustomization()` to track alias renames - Updated `filterNativeToolsForMode()` to rename tools based on aliases in modelInfo.includedTools - Added alias resolution in presentAssistantMessage.ts for tool validation ### Parser Support - Added alias resolution in NativeToolCallParser for both streaming and finalized tool calls - Added `search_and_replace` case in `createPartialToolUse()` for streaming updates ### Example Usage In modelInfo: ```typescript { includedTools: ['edit_file'], // Alias for search_and_replace excludedTools: ['apply_diff'] } ``` The model will see and use 'edit_file', but internally it maps to 'search_and_replace'. ## Test Coverage - Added comprehensive tests for alias resolution functions - Added tests for model tool customization with aliases - Added tests for native tool filtering with alias renames --- .../assistant-message/NativeToolCallParser.ts | 33 ++- .../__tests__/NativeToolCallParser.spec.ts | 80 ++++++ .../presentAssistantMessage.ts | 6 +- .../__tests__/filter-tools-for-mode.spec.ts | 253 +++++++++++++++--- .../prompts/tools/filter-tools-for-mode.ts | 195 +++++++++++++- src/core/tools/BaseTool.ts | 7 + src/core/tools/SearchAndReplaceTool.ts | 1 + 7 files changed, 514 insertions(+), 61 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index e88c26a913f..580a46804fc 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -6,6 +6,7 @@ import { toolParamNames, type NativeToolArgs, } from "../../shared/tools" +import { resolveToolAlias } from "../prompts/tools/filter-tools-for-mode" import { parseJSON } from "partial-json" import type { ApiStreamToolCallStartChunk, @@ -246,10 +247,13 @@ export class NativeToolCallParser { try { const partialArgs = parseJSON(toolCall.argumentsAccumulator) + // Resolve tool alias to canonical name + const resolvedName = resolveToolAlias(toolCall.name) as ToolName + // Create partial ToolUse with extracted values return this.createPartialToolUse( toolCall.id, - toolCall.name as ToolName, + resolvedName, partialArgs || {}, true, // partial ) @@ -505,7 +509,15 @@ export class NativeToolCallParser { } break - // Add other tools as needed + case "search_and_replace": + if (partialArgs.path !== undefined || partialArgs.operations !== undefined) { + nativeArgs = { + path: partialArgs.path, + operations: partialArgs.operations, + } + } + break + default: break } @@ -535,9 +547,12 @@ export class NativeToolCallParser { return this.parseDynamicMcpTool(toolCall) } - // Validate tool name - if (!toolNames.includes(toolCall.name as ToolName)) { - console.error(`Invalid tool name: ${toolCall.name}`) + // Resolve tool alias to canonical name (e.g., "edit_file" -> "search_and_replace") + const resolvedName = resolveToolAlias(toolCall.name as string) as TName + + // Validate tool name (after alias resolution) + if (!toolNames.includes(resolvedName as ToolName)) { + console.error(`Invalid tool name: ${toolCall.name} (resolved: ${resolvedName})`) console.error(`Valid tool names:`, toolNames) return null } @@ -554,13 +569,13 @@ export class NativeToolCallParser { // Skip complex parameters that have been migrated to nativeArgs. // For read_file, the 'files' parameter is a FileEntry[] array that can't be // meaningfully stringified. The properly typed data is in nativeArgs instead. - if (toolCall.name === "read_file" && key === "files") { + if (resolvedName === "read_file" && key === "files") { continue } // Validate parameter name if (!toolParamNames.includes(key as ToolParamName)) { - console.warn(`Unknown parameter '${key}' for tool '${toolCall.name}'`) + console.warn(`Unknown parameter '${key}' for tool '${resolvedName}'`) console.warn(`Valid param names:`, toolParamNames) continue } @@ -580,7 +595,7 @@ export class NativeToolCallParser { // will fall back to legacy parameter parsing if supported. let nativeArgs: NativeArgsFor | undefined = undefined - switch (toolCall.name) { + switch (resolvedName) { case "read_file": if (args.files && Array.isArray(args.files)) { nativeArgs = { files: this.convertFileEntries(args.files) } as NativeArgsFor @@ -761,7 +776,7 @@ export class NativeToolCallParser { const result: ToolUse = { type: "tool_use" as const, - name: toolCall.name, + name: resolvedName, params, partial: false, // Native tool calls are always complete when yielded nativeArgs, diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts index 0e81671cc15..1fc52c51db4 100644 --- a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts @@ -238,4 +238,84 @@ describe("NativeToolCallParser", () => { }) }) }) + + describe("Tool Alias Resolution", () => { + it("should resolve edit_file alias to search_and_replace", () => { + const toolCall = { + id: "toolu_alias_123", + name: "edit_file" as any, // Alias that model might call + arguments: JSON.stringify({ + path: "test.ts", + operations: [ + { + search: "old text", + replace: "new text", + }, + ], + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + // The resolved name should be the canonical tool name + expect(result.name).toBe("search_and_replace") + expect(result.nativeArgs).toBeDefined() + const nativeArgs = result.nativeArgs as { + path: string + operations: Array<{ search: string; replace: string }> + } + expect(nativeArgs.path).toBe("test.ts") + expect(nativeArgs.operations).toHaveLength(1) + } + }) + + it("should still work with canonical search_and_replace name", () => { + const toolCall = { + id: "toolu_canonical_123", + name: "search_and_replace" as const, + arguments: JSON.stringify({ + path: "test.ts", + operations: [ + { + search: "old", + replace: "new", + }, + ], + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + expect(result.name).toBe("search_and_replace") + } + }) + + it("should resolve alias during streaming finalization", () => { + const id = "toolu_alias_streaming" + NativeToolCallParser.startStreamingToolCall(id, "edit_file") + + // Add arguments + NativeToolCallParser.processStreamingChunk( + id, + JSON.stringify({ + path: "streamed.ts", + operations: [{ search: "a", replace: "b" }], + }), + ) + + const result = NativeToolCallParser.finalizeStreamingToolCall(id) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + expect(result.name).toBe("search_and_replace") + } + }) + }) }) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 54e01927261..3a34b574a75 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -695,7 +695,11 @@ export async function presentAssistantMessage(cline: Task) { // potentially causing the stream to appear frozen. if (!block.partial) { const modelInfo = cline.api.getModel() - const includedTools = modelInfo?.info?.includedTools + // Resolve aliases in includedTools before validation + // e.g., "edit_file" should resolve to "search_and_replace" + const rawIncludedTools = modelInfo?.info?.includedTools + const { resolveToolAlias } = await import("../prompts/tools/filter-tools-for-mode") + const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool)) try { validateToolUse( diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts index d2ccaa84fb0..eeafe77a4f8 100644 --- a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -1,7 +1,14 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest" import type OpenAI from "openai" import type { ModeConfig, ModelInfo } from "@roo-code/types" -import { filterNativeToolsForMode, filterMcpToolsForMode, applyModelToolCustomization } from "../filter-tools-for-mode" +import { + filterNativeToolsForMode, + filterMcpToolsForMode, + applyModelToolCustomization, + applyToolAliases, + getToolAliasGroup, + resolveToolAlias, +} from "../filter-tools-for-mode" import * as toolsModule from "../../../../shared/tools" describe("filterNativeToolsForMode", () => { @@ -487,7 +494,7 @@ describe("filterMcpToolsForMode", () => { it("should return original tools when modelInfo is undefined", () => { const tools = new Set(["read_file", "write_to_file", "apply_diff"]) const result = applyModelToolCustomization(tools, codeMode, undefined) - expect(result).toEqual(tools) + expect(result.allowedTools).toEqual(tools) }) it("should exclude tools specified in excludedTools", () => { @@ -498,9 +505,9 @@ describe("filterMcpToolsForMode", () => { excludedTools: ["apply_diff"], } const result = applyModelToolCustomization(tools, codeMode, modelInfo) - expect(result.has("read_file")).toBe(true) - expect(result.has("write_to_file")).toBe(true) - expect(result.has("apply_diff")).toBe(false) + expect(result.allowedTools.has("read_file")).toBe(true) + expect(result.allowedTools.has("write_to_file")).toBe(true) + expect(result.allowedTools.has("apply_diff")).toBe(false) }) it("should exclude multiple tools", () => { @@ -511,10 +518,10 @@ describe("filterMcpToolsForMode", () => { excludedTools: ["apply_diff", "write_to_file"], } const result = applyModelToolCustomization(tools, codeMode, modelInfo) - expect(result.has("read_file")).toBe(true) - expect(result.has("execute_command")).toBe(true) - expect(result.has("write_to_file")).toBe(false) - expect(result.has("apply_diff")).toBe(false) + expect(result.allowedTools.has("read_file")).toBe(true) + expect(result.allowedTools.has("execute_command")).toBe(true) + expect(result.allowedTools.has("write_to_file")).toBe(false) + expect(result.allowedTools.has("apply_diff")).toBe(false) }) it("should include tools only if they belong to allowed groups", () => { @@ -525,9 +532,9 @@ describe("filterMcpToolsForMode", () => { includedTools: ["write_to_file", "apply_diff"], // Both in edit group } const result = applyModelToolCustomization(tools, codeMode, modelInfo) - expect(result.has("read_file")).toBe(true) - expect(result.has("write_to_file")).toBe(true) - expect(result.has("apply_diff")).toBe(true) + expect(result.allowedTools.has("read_file")).toBe(true) + expect(result.allowedTools.has("write_to_file")).toBe(true) + expect(result.allowedTools.has("apply_diff")).toBe(true) }) it("should NOT include tools from groups not allowed by mode", () => { @@ -539,9 +546,9 @@ describe("filterMcpToolsForMode", () => { } // Architect mode doesn't have edit group const result = applyModelToolCustomization(tools, architectMode, modelInfo) - expect(result.has("read_file")).toBe(true) - expect(result.has("write_to_file")).toBe(false) // Not in allowed groups - expect(result.has("apply_diff")).toBe(false) // Not in allowed groups + expect(result.allowedTools.has("read_file")).toBe(true) + expect(result.allowedTools.has("write_to_file")).toBe(false) // Not in allowed groups + expect(result.allowedTools.has("apply_diff")).toBe(false) // Not in allowed groups }) it("should apply both exclude and include operations", () => { @@ -553,10 +560,10 @@ describe("filterMcpToolsForMode", () => { includedTools: ["search_and_replace"], // Another edit tool (customTool) } const result = applyModelToolCustomization(tools, codeMode, modelInfo) - expect(result.has("read_file")).toBe(true) - expect(result.has("write_to_file")).toBe(true) - expect(result.has("apply_diff")).toBe(false) // Excluded - expect(result.has("search_and_replace")).toBe(true) // Included + expect(result.allowedTools.has("read_file")).toBe(true) + expect(result.allowedTools.has("write_to_file")).toBe(true) + expect(result.allowedTools.has("apply_diff")).toBe(false) // Excluded + expect(result.allowedTools.has("search_and_replace")).toBe(true) // Included }) it("should handle empty excludedTools and includedTools arrays", () => { @@ -568,7 +575,7 @@ describe("filterMcpToolsForMode", () => { includedTools: [], } const result = applyModelToolCustomization(tools, codeMode, modelInfo) - expect(result).toEqual(tools) + expect(result.allowedTools).toEqual(tools) }) it("should ignore excluded tools that are not in the original set", () => { @@ -579,9 +586,9 @@ describe("filterMcpToolsForMode", () => { excludedTools: ["apply_diff", "nonexistent_tool"], } const result = applyModelToolCustomization(tools, codeMode, modelInfo) - expect(result.has("read_file")).toBe(true) - expect(result.has("write_to_file")).toBe(true) - expect(result.size).toBe(2) + expect(result.allowedTools.has("read_file")).toBe(true) + expect(result.allowedTools.has("write_to_file")).toBe(true) + expect(result.allowedTools.size).toBe(2) }) it("should NOT include customTools by default", () => { @@ -594,8 +601,8 @@ describe("filterMcpToolsForMode", () => { } const result = applyModelToolCustomization(tools, codeMode, modelInfo) // customTools should not be in the result unless explicitly included - expect(result.has("read_file")).toBe(true) - expect(result.has("write_to_file")).toBe(true) + expect(result.allowedTools.has("read_file")).toBe(true) + expect(result.allowedTools.has("write_to_file")).toBe(true) }) it("should NOT include tools that are not in any TOOL_GROUPS", () => { @@ -606,8 +613,8 @@ describe("filterMcpToolsForMode", () => { includedTools: ["my_custom_tool"], // Not in any tool group } const result = applyModelToolCustomization(tools, codeMode, modelInfo) - expect(result.has("read_file")).toBe(true) - expect(result.has("my_custom_tool")).toBe(false) + expect(result.allowedTools.has("read_file")).toBe(true) + expect(result.allowedTools.has("my_custom_tool")).toBe(false) }) it("should NOT include undefined tools even with allowed groups", () => { @@ -619,8 +626,8 @@ describe("filterMcpToolsForMode", () => { } // Even though architect mode has read group, undefined tools are not added const result = applyModelToolCustomization(tools, architectMode, modelInfo) - expect(result.has("read_file")).toBe(true) - expect(result.has("custom_edit_tool")).toBe(false) + expect(result.allowedTools.has("read_file")).toBe(true) + expect(result.allowedTools.has("custom_edit_tool")).toBe(false) }) describe("with customTools defined in TOOL_GROUPS", () => { @@ -647,9 +654,9 @@ describe("filterMcpToolsForMode", () => { includedTools: ["special_edit_tool"], // customTool from edit group } const result = applyModelToolCustomization(tools, codeMode, modelInfo) - expect(result.has("read_file")).toBe(true) - expect(result.has("write_to_file")).toBe(true) - expect(result.has("special_edit_tool")).toBe(true) // customTool should be included + expect(result.allowedTools.has("read_file")).toBe(true) + expect(result.allowedTools.has("write_to_file")).toBe(true) + expect(result.allowedTools.has("special_edit_tool")).toBe(true) // customTool should be included }) it("should NOT include customTools when not specified in includedTools", () => { @@ -660,9 +667,9 @@ describe("filterMcpToolsForMode", () => { // No includedTools specified } const result = applyModelToolCustomization(tools, codeMode, modelInfo) - expect(result.has("read_file")).toBe(true) - expect(result.has("write_to_file")).toBe(true) - expect(result.has("special_edit_tool")).toBe(false) // customTool should NOT be included by default + expect(result.allowedTools.has("read_file")).toBe(true) + expect(result.allowedTools.has("write_to_file")).toBe(true) + expect(result.allowedTools.has("special_edit_tool")).toBe(false) // customTool should NOT be included by default }) it("should NOT include customTools from groups not allowed by mode", () => { @@ -674,8 +681,8 @@ describe("filterMcpToolsForMode", () => { } // Architect mode doesn't have edit group const result = applyModelToolCustomization(tools, architectMode, modelInfo) - expect(result.has("read_file")).toBe(true) - expect(result.has("special_edit_tool")).toBe(false) // customTool should NOT be included + expect(result.allowedTools.has("read_file")).toBe(true) + expect(result.allowedTools.has("special_edit_tool")).toBe(false) // customTool should NOT be included }) }) }) @@ -824,3 +831,175 @@ describe("filterMcpToolsForMode", () => { }) }) }) + +describe("Tool Aliases", () => { + describe("resolveToolAlias", () => { + it("should resolve alias to canonical name", () => { + // edit_file is an alias for search_and_replace + expect(resolveToolAlias("edit_file")).toBe("search_and_replace") + }) + + it("should return canonical name as-is", () => { + expect(resolveToolAlias("search_and_replace")).toBe("search_and_replace") + }) + + it("should return unknown tool name as-is", () => { + expect(resolveToolAlias("read_file")).toBe("read_file") + }) + }) + + describe("applyToolAliases", () => { + it("should keep canonical tool unchanged", () => { + const allowedTools = new Set(["search_and_replace"]) + const result = applyToolAliases(allowedTools) + expect(result.has("search_and_replace")).toBe(true) + expect(result.size).toBe(1) + }) + + it("should resolve alias to canonical tool", () => { + const allowedTools = new Set(["edit_file"]) + const result = applyToolAliases(allowedTools) + expect(result.has("search_and_replace")).toBe(true) + expect(result.has("edit_file")).toBe(false) // Alias resolved to canonical + expect(result.size).toBe(1) + }) + + it("should not modify tools without aliases", () => { + const allowedTools = new Set(["read_file", "write_to_file"]) + const result = applyToolAliases(allowedTools) + expect(result.size).toBe(2) + expect(result.has("read_file")).toBe(true) + expect(result.has("write_to_file")).toBe(true) + }) + + it("should handle empty set", () => { + const allowedTools = new Set() + const result = applyToolAliases(allowedTools) + expect(result.size).toBe(0) + }) + + it("should resolve aliases in mixed set", () => { + const allowedTools = new Set(["read_file", "edit_file", "write_to_file"]) + const result = applyToolAliases(allowedTools) + expect(result.size).toBe(3) + expect(result.has("read_file")).toBe(true) + expect(result.has("write_to_file")).toBe(true) + expect(result.has("search_and_replace")).toBe(true) // Resolved from alias + }) + }) + + describe("getToolAliasGroup", () => { + it("should return alias group for canonical tool", () => { + const group = getToolAliasGroup("search_and_replace") + expect(group).toContain("search_and_replace") + expect(group).toContain("edit_file") + }) + + it("should return alias group for alias tool", () => { + const group = getToolAliasGroup("edit_file") + expect(group).toContain("search_and_replace") + expect(group).toContain("edit_file") + }) + + it("should return single-item array for non-aliased tool", () => { + const group = getToolAliasGroup("read_file") + expect(group).toEqual(["read_file"]) + }) + }) + + describe("applyModelToolCustomization with aliases", () => { + const codeMode: ModeConfig = { + slug: "code", + name: "Code", + roleDefinition: "Test", + groups: ["read", "edit", "browser", "command", "mcp"] as const, + } + + it("should resolve alias in includedTools to canonical name and track rename", () => { + const tools = new Set(["read_file"]) + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + includedTools: ["edit_file"], // Using alias + } + const result = applyModelToolCustomization(tools, codeMode, modelInfo) + expect(result.allowedTools.has("read_file")).toBe(true) + expect(result.allowedTools.has("search_and_replace")).toBe(true) // Canonical added + expect(result.allowedTools.has("edit_file")).toBe(false) // Not the alias + // Should track the rename + expect(result.aliasRenames.get("search_and_replace")).toBe("edit_file") + }) + + it("should resolve alias in excludedTools to canonical name", () => { + const tools = new Set(["read_file", "search_and_replace"]) + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + excludedTools: ["edit_file"], // Using alias + } + const result = applyModelToolCustomization(tools, codeMode, modelInfo) + expect(result.allowedTools.has("read_file")).toBe(true) + expect(result.allowedTools.has("search_and_replace")).toBe(false) // Excluded via alias + }) + }) + + describe("filterNativeToolsForMode with aliases", () => { + it("should rename tool to alias when alias is specified in includedTools", () => { + const codeMode: ModeConfig = { + slug: "code", + name: "Code", + roleDefinition: "Test", + groups: ["read", "edit", "browser", "command", "mcp"] as const, + } + + const mockToolsWithAliases: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "search_and_replace", + description: "Search and replace", + parameters: {}, + }, + }, + ] + + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + includedTools: ["edit_file"], // Include via alias + } + + const filtered = filterNativeToolsForMode(mockToolsWithAliases, "code", [codeMode], {}, undefined, { + modelInfo, + }) + + const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) + + // The tool should be renamed to the alias name + expect(toolNames).toContain("edit_file") + expect(toolNames).not.toContain("search_and_replace") // Renamed to alias + }) + + it("should exclude canonical tool when alias is specified in excludedTools", () => { + const codeMode: ModeConfig = { + slug: "code", + name: "Code", + roleDefinition: "Test", + groups: ["read", "edit", "browser", "command", "mcp"] as const, + } + + // First add the canonical tool to the allowed set + const tools = new Set(["read_file", "search_and_replace"]) + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + excludedTools: ["edit_file"], // Exclude via alias + } + + const result = applyModelToolCustomization(tools, codeMode, modelInfo) + + expect(result.allowedTools.has("read_file")).toBe(true) + expect(result.allowedTools.has("search_and_replace")).toBe(false) // Excluded via alias + }) + }) +}) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index eb87c9bbeca..5c0d68df0de 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -5,6 +5,118 @@ import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS } from "../../../shared/tools" import { defaultModeSlug } from "../../../shared/modes" import type { CodeIndexManager } from "../../../services/code-index/manager" import type { McpHub } from "../../../services/mcp/McpHub" +import { searchAndReplaceTool } from "../../tools/SearchAndReplaceTool" + +/** + * Tool aliases registry - built from tools that define aliases. + * Maps canonical tool name to array of alias names. + */ +const TOOL_ALIASES: Map = new Map() + +/** + * Reverse lookup - maps alias name to canonical tool name. + */ +const ALIAS_TO_CANONICAL: Map = new Map() + +/** + * Get all tool names from TOOL_GROUPS (including regular tools and customTools). + */ +function getAllToolNames(): Set { + const toolNames = new Set() + for (const groupConfig of Object.values(TOOL_GROUPS)) { + groupConfig.tools.forEach((tool) => toolNames.add(tool)) + if (groupConfig.customTools) { + groupConfig.customTools.forEach((tool) => toolNames.add(tool)) + } + } + return toolNames +} + +/** + * Register a tool's aliases. Validates for duplicate aliases and conflicts with tool names. + * @param toolName - The canonical tool name + * @param aliases - Array of alias names + * @throws Error if an alias is already registered or conflicts with a tool name + */ +function registerToolAliases(toolName: string, aliases: string[]): void { + if (aliases.length === 0) return + + const allToolNames = getAllToolNames() + + // Check for duplicate aliases and conflicts with tool names + for (const alias of aliases) { + if (ALIAS_TO_CANONICAL.has(alias)) { + throw new Error( + `Duplicate tool alias "${alias}" - already registered for tool "${ALIAS_TO_CANONICAL.get(alias)}"`, + ) + } + if (TOOL_ALIASES.has(alias)) { + throw new Error(`Alias "${alias}" conflicts with canonical tool name in alias registry`) + } + if (allToolNames.has(alias)) { + throw new Error(`Alias "${alias}" conflicts with existing tool name`) + } + } + + // Register the aliases + TOOL_ALIASES.set(toolName, aliases) + for (const alias of aliases) { + ALIAS_TO_CANONICAL.set(alias, toolName) + } +} + +// Register all tool aliases from tool instances +registerToolAliases(searchAndReplaceTool.name, searchAndReplaceTool.aliases) + +/** + * Resolves a tool name to its canonical name. + * If the tool name is an alias, returns the canonical tool name. + * If it's already a canonical name or unknown, returns as-is. + * + * @param toolName - The tool name to resolve (may be an alias) + * @returns The canonical tool name + */ +export function resolveToolAlias(toolName: string): string { + const canonical = ALIAS_TO_CANONICAL.get(toolName) + return canonical ?? toolName +} + +/** + * Applies tool alias resolution to a set of allowed tools. + * Resolves any aliases to their canonical tool names. + * + * @param allowedTools - Set of tools that may contain aliases + * @returns Set with aliases resolved to canonical names + */ +export function applyToolAliases(allowedTools: Set): Set { + const result = new Set() + + for (const tool of allowedTools) { + // Resolve alias to canonical name + result.add(resolveToolAlias(tool)) + } + + return result +} + +/** + * Gets all tools in an alias group (including the canonical tool). + * + * @param toolName - Any tool name in the alias group + * @returns Array of all tool names in the alias group, or just the tool if not aliased + */ +export function getToolAliasGroup(toolName: string): string[] { + // Check if it's a canonical tool with aliases + if (TOOL_ALIASES.has(toolName)) { + return [toolName, ...TOOL_ALIASES.get(toolName)!] + } + // Check if it's an alias + const canonical = ALIAS_TO_CANONICAL.get(toolName) + if (canonical) { + return [canonical, ...TOOL_ALIASES.get(canonical)!] + } + return [toolName] +} /** * Apply model-specific tool customization to a set of allowed tools. @@ -18,21 +130,33 @@ import type { McpHub } from "../../../services/mcp/McpHub" * @param modelInfo - Model configuration with tool customization * @returns Modified set of tools after applying model customization */ +/** + * Result of applying model tool customization. + * Contains the set of allowed tools and any alias renames to apply. + */ +interface ModelToolCustomizationResult { + allowedTools: Set + /** Maps canonical tool name to alias name for tools that should be renamed */ + aliasRenames: Map +} + export function applyModelToolCustomization( allowedTools: Set, modeConfig: ModeConfig, modelInfo?: ModelInfo, -): Set { +): ModelToolCustomizationResult { if (!modelInfo) { - return allowedTools + return { allowedTools, aliasRenames: new Map() } } const result = new Set(allowedTools) + const aliasRenames = new Map() // Apply excluded tools (remove from allowed set) if (modelInfo.excludedTools && modelInfo.excludedTools.length > 0) { modelInfo.excludedTools.forEach((tool) => { - result.delete(tool) + const resolvedTool = resolveToolAlias(tool) + result.delete(resolvedTool) }) } @@ -59,16 +183,21 @@ export function applyModelToolCustomization( ) // Add included tools only if they belong to an allowed group - // This includes both regular tools and customTools + // If the tool was specified as an alias, track the rename modelInfo.includedTools.forEach((tool) => { - const toolGroup = toolToGroup.get(tool) + const resolvedTool = resolveToolAlias(tool) + const toolGroup = toolToGroup.get(resolvedTool) if (toolGroup && allowedGroups.has(toolGroup)) { - result.add(tool) + result.add(resolvedTool) + // If the tool was specified as an alias, rename it in the API + if (tool !== resolvedTool) { + aliasRenames.set(resolvedTool, tool) + } } }) } - return result + return { allowedTools: result, aliasRenames } } /** @@ -123,7 +252,15 @@ export function filterNativeToolsForMode( // Apply model-specific tool customization const modelInfo = settings?.modelInfo as ModelInfo | undefined - allowedToolNames = applyModelToolCustomization(allowedToolNames, modeConfig, modelInfo) + const { allowedTools: customizedTools, aliasRenames } = applyModelToolCustomization( + allowedToolNames, + modeConfig, + modelInfo, + ) + allowedToolNames = customizedTools + + // Apply tool aliases - if one tool in an alias group is allowed, all aliases are allowed + allowedToolNames = applyToolAliases(allowedToolNames) // Conditionally exclude codebase_search if feature is disabled or not configured if ( @@ -163,14 +300,33 @@ export function filterNativeToolsForMode( allowedToolNames.delete("access_mcp_resource") } - // Filter native tools based on allowed tool names - return nativeTools.filter((tool) => { + // Filter native tools based on allowed tool names and apply alias renames + const filteredTools: OpenAI.Chat.ChatCompletionTool[] = [] + + for (const tool of nativeTools) { // Handle both ChatCompletionTool and ChatCompletionCustomTool if ("function" in tool && tool.function) { - return allowedToolNames.has(tool.function.name) + const toolName = tool.function.name + if (allowedToolNames.has(toolName)) { + // Check if this tool should be renamed to an alias + const aliasName = aliasRenames.get(toolName) + if (aliasName) { + // Clone the tool with the alias name + filteredTools.push({ + ...tool, + function: { + ...tool.function, + name: aliasName, + }, + }) + } else { + filteredTools.push(tool) + } + } } - return false - }) + } + + return filteredTools } /** @@ -232,7 +388,18 @@ export function isToolAllowedInMode( } // Check if the tool is allowed by the mode's groups - return isToolAllowedForMode(toolName, modeSlug, customModes ?? [], undefined, undefined, experiments ?? {}) + // Also check if any tool in the alias group is allowed + const aliasGroup = getToolAliasGroup(toolName) + return aliasGroup.some((aliasedTool) => + isToolAllowedForMode( + aliasedTool as ToolName, + modeSlug, + customModes ?? [], + undefined, + undefined, + experiments ?? {}, + ), + ) } /** diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index 5d4ec633d1f..c96586674f5 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -47,6 +47,13 @@ export abstract class BaseTool { */ abstract readonly name: TName + /** + * Alias names for this tool. + * When any tool in an alias group is allowed, all aliases are allowed. + * Aliases must be unique across all tools. + */ + readonly aliases: string[] = [] + /** * Parse XML/legacy string-based parameters into typed parameters. * diff --git a/src/core/tools/SearchAndReplaceTool.ts b/src/core/tools/SearchAndReplaceTool.ts index 49d159f4557..fc08bf30d12 100644 --- a/src/core/tools/SearchAndReplaceTool.ts +++ b/src/core/tools/SearchAndReplaceTool.ts @@ -26,6 +26,7 @@ interface SearchAndReplaceParams { export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { readonly name = "search_and_replace" as const + override readonly aliases = ["edit_file"] parseLegacy(params: Partial>): SearchAndReplaceParams { // Parse operations from JSON string if provided From e438424e80db771522aa7c3be95d1c9154964b69 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 10 Dec 2025 11:48:28 -0700 Subject: [PATCH 02/16] fix: preserve tool alias name in API history for consistent conversation context When tool aliases are used (e.g., 'edit_file' -> 'search_and_replace'), the alias name is now preserved and written to API history instead of the canonical name. This prevents confusion in multi-turn conversations where the model sees a different tool name in history than what it was told the tool was named. Changes: - NativeToolCallParser.ts already stores originalName when alias differs from resolved - Task.ts now uses originalName (alias) when available for API history --- .../assistant-message/NativeToolCallParser.ts | 19 ++++++++++++++++++- src/core/task/Task.ts | 8 +++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 580a46804fc..ad607da47fb 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -249,6 +249,8 @@ export class NativeToolCallParser { // Resolve tool alias to canonical name const resolvedName = resolveToolAlias(toolCall.name) as ToolName + // Preserve original name if it differs from resolved (i.e., it was an alias) + const originalName = toolCall.name !== resolvedName ? toolCall.name : undefined // Create partial ToolUse with extracted values return this.createPartialToolUse( @@ -256,6 +258,7 @@ export class NativeToolCallParser { resolvedName, partialArgs || {}, true, // partial + originalName, ) } catch { // Even partial-json-parser can fail on severely malformed JSON @@ -331,12 +334,14 @@ export class NativeToolCallParser { /** * Create a partial ToolUse from currently parsed arguments. * Used during streaming to show progress. + * @param originalName - The original tool name as called by the model (if different from canonical name) */ private static createPartialToolUse( id: string, name: ToolName, partialArgs: Record, partial: boolean, + originalName?: string, ): ToolUse | null { // Build legacy params for display // NOTE: For streaming partial updates, we MUST populate params even for complex types @@ -522,13 +527,20 @@ export class NativeToolCallParser { break } - return { + const result: ToolUse = { type: "tool_use" as const, name, params, partial, nativeArgs, } + + // Preserve original name for API history when an alias was used + if (originalName) { + ;(result as any).originalName = originalName + } + + return result } /** @@ -782,6 +794,11 @@ export class NativeToolCallParser { nativeArgs, } + // Preserve original name for API history when an alias was used + if (toolCall.name !== resolvedName) { + ;(result as any).originalName = toolCall.name + } + return result } catch (error) { console.error( diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index f7e4946f53c..1719523d1ba 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3106,10 +3106,16 @@ export class Task extends EventEmitter implements TaskLike { // nativeArgs is already in the correct API format for all tools const input = toolUse.nativeArgs || toolUse.params + // Use originalName (alias) if present for API history consistency. + // When tool aliases are used (e.g., "edit_file" -> "search_and_replace"), + // we want the alias name in the conversation history to match what the model + // was told the tool was named, preventing confusion in multi-turn conversations. + const toolNameForHistory = (toolUse as any).originalName || toolUse.name + assistantContent.push({ type: "tool_use" as const, id: toolCallId, - name: toolUse.name, + name: toolNameForHistory, input, }) } From 7eb4ee1cb65d01aa5550c2952d2cd27dda704c67 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 10 Dec 2025 14:30:33 -0700 Subject: [PATCH 03/16] feat: define aliases for ApplyDiffTool and WriteToFileTool for improved customization --- src/core/tools/ApplyDiffTool.ts | 1 + src/core/tools/WriteToFileTool.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 7161c7c08ef..93a3aad17ab 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -23,6 +23,7 @@ interface ApplyDiffParams { export class ApplyDiffTool extends BaseTool<"apply_diff"> { readonly name = "apply_diff" as const + override readonly aliases = ["replace"] parseLegacy(params: Partial>): ApplyDiffParams { return { diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 7caaeb6d55d..79c6e1383b7 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -25,6 +25,7 @@ interface WriteToFileParams { export class WriteToFileTool extends BaseTool<"write_to_file"> { readonly name = "write_to_file" as const + override readonly aliases = ["write_file"] parseLegacy(params: Partial>): WriteToFileParams { return { From 5fc941910743a08a0c27197fca8289da3d6579cc Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 10 Dec 2025 16:07:12 -0700 Subject: [PATCH 04/16] feat: register WriteToFileTool and ApplyDiffTool aliases for enhanced tool customization --- src/core/prompts/tools/filter-tools-for-mode.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 5c0d68df0de..8890f42f20e 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -6,6 +6,8 @@ import { defaultModeSlug } from "../../../shared/modes" import type { CodeIndexManager } from "../../../services/code-index/manager" import type { McpHub } from "../../../services/mcp/McpHub" import { searchAndReplaceTool } from "../../tools/SearchAndReplaceTool" +import { writeToFileTool } from "../../tools/WriteToFileTool" +import { applyDiffTool } from "../../tools/ApplyDiffTool" /** * Tool aliases registry - built from tools that define aliases. @@ -67,6 +69,8 @@ function registerToolAliases(toolName: string, aliases: string[]): void { // Register all tool aliases from tool instances registerToolAliases(searchAndReplaceTool.name, searchAndReplaceTool.aliases) +registerToolAliases(writeToFileTool.name, writeToFileTool.aliases) +registerToolAliases(applyDiffTool.name, applyDiffTool.aliases) /** * Resolves a tool name to its canonical name. From 35fe773dc30b53dafa5db818368bce9b140154a1 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 11 Dec 2025 10:58:43 -0700 Subject: [PATCH 05/16] feat: update aliases for ApplyDiffTool and SearchAndReplaceTool for better clarity --- src/core/tools/ApplyDiffTool.ts | 2 +- src/core/tools/SearchAndReplaceTool.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 93a3aad17ab..5ddac26758c 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -23,7 +23,7 @@ interface ApplyDiffParams { export class ApplyDiffTool extends BaseTool<"apply_diff"> { readonly name = "apply_diff" as const - override readonly aliases = ["replace"] + override readonly aliases = ["edit_file"] parseLegacy(params: Partial>): ApplyDiffParams { return { diff --git a/src/core/tools/SearchAndReplaceTool.ts b/src/core/tools/SearchAndReplaceTool.ts index fc08bf30d12..3d96207b9b7 100644 --- a/src/core/tools/SearchAndReplaceTool.ts +++ b/src/core/tools/SearchAndReplaceTool.ts @@ -26,7 +26,7 @@ interface SearchAndReplaceParams { export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { readonly name = "search_and_replace" as const - override readonly aliases = ["edit_file"] + override readonly aliases = ["temp_edit_file"] parseLegacy(params: Partial>): SearchAndReplaceParams { // Parse operations from JSON string if provided From 08e3b8eb7ce018ea4dfb03d62b5bc111a43664b2 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 11 Dec 2025 11:03:39 -0700 Subject: [PATCH 06/16] test: remove tests that dictate specific tool alias mappings --- .../__tests__/NativeToolCallParser.spec.ts | 80 -------- .../__tests__/filter-tools-for-mode.spec.ts | 181 +----------------- 2 files changed, 1 insertion(+), 260 deletions(-) diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts index 1fc52c51db4..0e81671cc15 100644 --- a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts @@ -238,84 +238,4 @@ describe("NativeToolCallParser", () => { }) }) }) - - describe("Tool Alias Resolution", () => { - it("should resolve edit_file alias to search_and_replace", () => { - const toolCall = { - id: "toolu_alias_123", - name: "edit_file" as any, // Alias that model might call - arguments: JSON.stringify({ - path: "test.ts", - operations: [ - { - search: "old text", - replace: "new text", - }, - ], - }), - } - - const result = NativeToolCallParser.parseToolCall(toolCall) - - expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { - // The resolved name should be the canonical tool name - expect(result.name).toBe("search_and_replace") - expect(result.nativeArgs).toBeDefined() - const nativeArgs = result.nativeArgs as { - path: string - operations: Array<{ search: string; replace: string }> - } - expect(nativeArgs.path).toBe("test.ts") - expect(nativeArgs.operations).toHaveLength(1) - } - }) - - it("should still work with canonical search_and_replace name", () => { - const toolCall = { - id: "toolu_canonical_123", - name: "search_and_replace" as const, - arguments: JSON.stringify({ - path: "test.ts", - operations: [ - { - search: "old", - replace: "new", - }, - ], - }), - } - - const result = NativeToolCallParser.parseToolCall(toolCall) - - expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { - expect(result.name).toBe("search_and_replace") - } - }) - - it("should resolve alias during streaming finalization", () => { - const id = "toolu_alias_streaming" - NativeToolCallParser.startStreamingToolCall(id, "edit_file") - - // Add arguments - NativeToolCallParser.processStreamingChunk( - id, - JSON.stringify({ - path: "streamed.ts", - operations: [{ search: "a", replace: "b" }], - }), - ) - - const result = NativeToolCallParser.finalizeStreamingToolCall(id) - - expect(result).not.toBeNull() - expect(result?.type).toBe("tool_use") - if (result?.type === "tool_use") { - expect(result.name).toBe("search_and_replace") - } - }) - }) }) diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts index eeafe77a4f8..0eaa4953d7e 100644 --- a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -1,14 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest" import type OpenAI from "openai" import type { ModeConfig, ModelInfo } from "@roo-code/types" -import { - filterNativeToolsForMode, - filterMcpToolsForMode, - applyModelToolCustomization, - applyToolAliases, - getToolAliasGroup, - resolveToolAlias, -} from "../filter-tools-for-mode" +import { filterNativeToolsForMode, filterMcpToolsForMode, applyModelToolCustomization } from "../filter-tools-for-mode" import * as toolsModule from "../../../../shared/tools" describe("filterNativeToolsForMode", () => { @@ -831,175 +824,3 @@ describe("filterMcpToolsForMode", () => { }) }) }) - -describe("Tool Aliases", () => { - describe("resolveToolAlias", () => { - it("should resolve alias to canonical name", () => { - // edit_file is an alias for search_and_replace - expect(resolveToolAlias("edit_file")).toBe("search_and_replace") - }) - - it("should return canonical name as-is", () => { - expect(resolveToolAlias("search_and_replace")).toBe("search_and_replace") - }) - - it("should return unknown tool name as-is", () => { - expect(resolveToolAlias("read_file")).toBe("read_file") - }) - }) - - describe("applyToolAliases", () => { - it("should keep canonical tool unchanged", () => { - const allowedTools = new Set(["search_and_replace"]) - const result = applyToolAliases(allowedTools) - expect(result.has("search_and_replace")).toBe(true) - expect(result.size).toBe(1) - }) - - it("should resolve alias to canonical tool", () => { - const allowedTools = new Set(["edit_file"]) - const result = applyToolAliases(allowedTools) - expect(result.has("search_and_replace")).toBe(true) - expect(result.has("edit_file")).toBe(false) // Alias resolved to canonical - expect(result.size).toBe(1) - }) - - it("should not modify tools without aliases", () => { - const allowedTools = new Set(["read_file", "write_to_file"]) - const result = applyToolAliases(allowedTools) - expect(result.size).toBe(2) - expect(result.has("read_file")).toBe(true) - expect(result.has("write_to_file")).toBe(true) - }) - - it("should handle empty set", () => { - const allowedTools = new Set() - const result = applyToolAliases(allowedTools) - expect(result.size).toBe(0) - }) - - it("should resolve aliases in mixed set", () => { - const allowedTools = new Set(["read_file", "edit_file", "write_to_file"]) - const result = applyToolAliases(allowedTools) - expect(result.size).toBe(3) - expect(result.has("read_file")).toBe(true) - expect(result.has("write_to_file")).toBe(true) - expect(result.has("search_and_replace")).toBe(true) // Resolved from alias - }) - }) - - describe("getToolAliasGroup", () => { - it("should return alias group for canonical tool", () => { - const group = getToolAliasGroup("search_and_replace") - expect(group).toContain("search_and_replace") - expect(group).toContain("edit_file") - }) - - it("should return alias group for alias tool", () => { - const group = getToolAliasGroup("edit_file") - expect(group).toContain("search_and_replace") - expect(group).toContain("edit_file") - }) - - it("should return single-item array for non-aliased tool", () => { - const group = getToolAliasGroup("read_file") - expect(group).toEqual(["read_file"]) - }) - }) - - describe("applyModelToolCustomization with aliases", () => { - const codeMode: ModeConfig = { - slug: "code", - name: "Code", - roleDefinition: "Test", - groups: ["read", "edit", "browser", "command", "mcp"] as const, - } - - it("should resolve alias in includedTools to canonical name and track rename", () => { - const tools = new Set(["read_file"]) - const modelInfo: ModelInfo = { - contextWindow: 100000, - supportsPromptCache: false, - includedTools: ["edit_file"], // Using alias - } - const result = applyModelToolCustomization(tools, codeMode, modelInfo) - expect(result.allowedTools.has("read_file")).toBe(true) - expect(result.allowedTools.has("search_and_replace")).toBe(true) // Canonical added - expect(result.allowedTools.has("edit_file")).toBe(false) // Not the alias - // Should track the rename - expect(result.aliasRenames.get("search_and_replace")).toBe("edit_file") - }) - - it("should resolve alias in excludedTools to canonical name", () => { - const tools = new Set(["read_file", "search_and_replace"]) - const modelInfo: ModelInfo = { - contextWindow: 100000, - supportsPromptCache: false, - excludedTools: ["edit_file"], // Using alias - } - const result = applyModelToolCustomization(tools, codeMode, modelInfo) - expect(result.allowedTools.has("read_file")).toBe(true) - expect(result.allowedTools.has("search_and_replace")).toBe(false) // Excluded via alias - }) - }) - - describe("filterNativeToolsForMode with aliases", () => { - it("should rename tool to alias when alias is specified in includedTools", () => { - const codeMode: ModeConfig = { - slug: "code", - name: "Code", - roleDefinition: "Test", - groups: ["read", "edit", "browser", "command", "mcp"] as const, - } - - const mockToolsWithAliases: OpenAI.Chat.ChatCompletionTool[] = [ - { - type: "function", - function: { - name: "search_and_replace", - description: "Search and replace", - parameters: {}, - }, - }, - ] - - const modelInfo: ModelInfo = { - contextWindow: 100000, - supportsPromptCache: false, - includedTools: ["edit_file"], // Include via alias - } - - const filtered = filterNativeToolsForMode(mockToolsWithAliases, "code", [codeMode], {}, undefined, { - modelInfo, - }) - - const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) - - // The tool should be renamed to the alias name - expect(toolNames).toContain("edit_file") - expect(toolNames).not.toContain("search_and_replace") // Renamed to alias - }) - - it("should exclude canonical tool when alias is specified in excludedTools", () => { - const codeMode: ModeConfig = { - slug: "code", - name: "Code", - roleDefinition: "Test", - groups: ["read", "edit", "browser", "command", "mcp"] as const, - } - - // First add the canonical tool to the allowed set - const tools = new Set(["read_file", "search_and_replace"]) - const modelInfo: ModelInfo = { - contextWindow: 100000, - supportsPromptCache: false, - excludedTools: ["edit_file"], // Exclude via alias - } - - const result = applyModelToolCustomization(tools, codeMode, modelInfo) - - expect(result.allowedTools.has("read_file")).toBe(true) - expect(result.allowedTools.has("search_and_replace")).toBe(false) // Excluded via alias - }) - }) -}) From 9509b6c3c7513e421e784cc612e1cbbb52cbbc18 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 11 Dec 2025 16:16:51 -0700 Subject: [PATCH 07/16] refactor: centralize tool aliases in TOOL_ALIASES constant - Add TOOL_ALIASES constant to src/shared/tools.ts as single source of truth - Update filter-tools-for-mode.ts to use central constant instead of tool instances - Remove aliases property from BaseTool and individual tool classes - Simplifies adding new aliases to a single-line change in shared/tools.ts --- .../prompts/tools/filter-tools-for-mode.ts | 79 ++++--------------- src/core/tools/ApplyDiffTool.ts | 1 - src/core/tools/BaseTool.ts | 7 -- src/core/tools/SearchAndReplaceTool.ts | 1 - src/core/tools/WriteToFileTool.ts | 1 - src/shared/tools.ts | 16 ++++ 6 files changed, 33 insertions(+), 72 deletions(-) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 8890f42f20e..318cca1b5d6 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -1,77 +1,32 @@ import type OpenAI from "openai" import type { ModeConfig, ToolName, ToolGroup, ModelInfo } from "@roo-code/types" import { getModeBySlug, getToolsForMode, isToolAllowedForMode } from "../../../shared/modes" -import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS } from "../../../shared/tools" +import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES } from "../../../shared/tools" import { defaultModeSlug } from "../../../shared/modes" import type { CodeIndexManager } from "../../../services/code-index/manager" import type { McpHub } from "../../../services/mcp/McpHub" -import { searchAndReplaceTool } from "../../tools/SearchAndReplaceTool" -import { writeToFileTool } from "../../tools/WriteToFileTool" -import { applyDiffTool } from "../../tools/ApplyDiffTool" /** - * Tool aliases registry - built from tools that define aliases. - * Maps canonical tool name to array of alias names. + * Reverse lookup map - maps alias name to canonical tool name. + * Built once at module load from the central TOOL_ALIASES constant. */ -const TOOL_ALIASES: Map = new Map() +const ALIAS_TO_CANONICAL: Map = new Map( + Object.entries(TOOL_ALIASES).map(([alias, canonical]) => [alias, canonical]), +) /** - * Reverse lookup - maps alias name to canonical tool name. + * Canonical to aliases map - maps canonical tool name to array of alias names. + * Built once at module load from the central TOOL_ALIASES constant. */ -const ALIAS_TO_CANONICAL: Map = new Map() +const CANONICAL_TO_ALIASES: Map = new Map() -/** - * Get all tool names from TOOL_GROUPS (including regular tools and customTools). - */ -function getAllToolNames(): Set { - const toolNames = new Set() - for (const groupConfig of Object.values(TOOL_GROUPS)) { - groupConfig.tools.forEach((tool) => toolNames.add(tool)) - if (groupConfig.customTools) { - groupConfig.customTools.forEach((tool) => toolNames.add(tool)) - } - } - return toolNames -} - -/** - * Register a tool's aliases. Validates for duplicate aliases and conflicts with tool names. - * @param toolName - The canonical tool name - * @param aliases - Array of alias names - * @throws Error if an alias is already registered or conflicts with a tool name - */ -function registerToolAliases(toolName: string, aliases: string[]): void { - if (aliases.length === 0) return - - const allToolNames = getAllToolNames() - - // Check for duplicate aliases and conflicts with tool names - for (const alias of aliases) { - if (ALIAS_TO_CANONICAL.has(alias)) { - throw new Error( - `Duplicate tool alias "${alias}" - already registered for tool "${ALIAS_TO_CANONICAL.get(alias)}"`, - ) - } - if (TOOL_ALIASES.has(alias)) { - throw new Error(`Alias "${alias}" conflicts with canonical tool name in alias registry`) - } - if (allToolNames.has(alias)) { - throw new Error(`Alias "${alias}" conflicts with existing tool name`) - } - } - - // Register the aliases - TOOL_ALIASES.set(toolName, aliases) - for (const alias of aliases) { - ALIAS_TO_CANONICAL.set(alias, toolName) - } +// Build the reverse mapping (canonical -> aliases) +for (const [alias, canonical] of Object.entries(TOOL_ALIASES)) { + const existing = CANONICAL_TO_ALIASES.get(canonical) ?? [] + existing.push(alias) + CANONICAL_TO_ALIASES.set(canonical, existing) } -// Register all tool aliases from tool instances -registerToolAliases(searchAndReplaceTool.name, searchAndReplaceTool.aliases) -registerToolAliases(writeToFileTool.name, writeToFileTool.aliases) -registerToolAliases(applyDiffTool.name, applyDiffTool.aliases) - /** * Resolves a tool name to its canonical name. * If the tool name is an alias, returns the canonical tool name. @@ -111,13 +66,13 @@ export function applyToolAliases(allowedTools: Set): Set { */ export function getToolAliasGroup(toolName: string): string[] { // Check if it's a canonical tool with aliases - if (TOOL_ALIASES.has(toolName)) { - return [toolName, ...TOOL_ALIASES.get(toolName)!] + if (CANONICAL_TO_ALIASES.has(toolName)) { + return [toolName, ...CANONICAL_TO_ALIASES.get(toolName)!] } // Check if it's an alias const canonical = ALIAS_TO_CANONICAL.get(toolName) if (canonical) { - return [canonical, ...TOOL_ALIASES.get(canonical)!] + return [canonical, ...CANONICAL_TO_ALIASES.get(canonical)!] } return [toolName] } diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 5ddac26758c..7161c7c08ef 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -23,7 +23,6 @@ interface ApplyDiffParams { export class ApplyDiffTool extends BaseTool<"apply_diff"> { readonly name = "apply_diff" as const - override readonly aliases = ["edit_file"] parseLegacy(params: Partial>): ApplyDiffParams { return { diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index c96586674f5..5d4ec633d1f 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -47,13 +47,6 @@ export abstract class BaseTool { */ abstract readonly name: TName - /** - * Alias names for this tool. - * When any tool in an alias group is allowed, all aliases are allowed. - * Aliases must be unique across all tools. - */ - readonly aliases: string[] = [] - /** * Parse XML/legacy string-based parameters into typed parameters. * diff --git a/src/core/tools/SearchAndReplaceTool.ts b/src/core/tools/SearchAndReplaceTool.ts index 3d96207b9b7..49d159f4557 100644 --- a/src/core/tools/SearchAndReplaceTool.ts +++ b/src/core/tools/SearchAndReplaceTool.ts @@ -26,7 +26,6 @@ interface SearchAndReplaceParams { export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { readonly name = "search_and_replace" as const - override readonly aliases = ["temp_edit_file"] parseLegacy(params: Partial>): SearchAndReplaceParams { // Parse operations from JSON string if provided diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 79c6e1383b7..7caaeb6d55d 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -25,7 +25,6 @@ interface WriteToFileParams { export class WriteToFileTool extends BaseTool<"write_to_file"> { readonly name = "write_to_file" as const - override readonly aliases = ["write_file"] parseLegacy(params: Partial>): WriteToFileParams { return { diff --git a/src/shared/tools.ts b/src/shared/tools.ts index f1f7d3ed80e..dfc8f5d65ef 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -293,6 +293,22 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ "run_slash_command", ] as const +/** + * Central registry of tool aliases. + * Maps alias name -> canonical tool name. + * + * This allows models to use alternative names for tools (e.g., "edit_file" instead of "apply_diff"). + * When a model calls a tool by its alias, the system resolves it to the canonical name for execution, + * but preserves the alias in API conversation history for consistency. + * + * To add a new alias, simply add an entry here. No other files need to be modified. + */ +export const TOOL_ALIASES: Record = { + edit_file: "apply_diff", + write_file: "write_to_file", + temp_edit_file: "search_and_replace", +} as const + export type DiffResult = | { success: true; content: string; failParts?: DiffResult[] } | ({ From 9a1a527ebc6f7fae13ed52eed2304ad3f7ca2ef2 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 11 Dec 2025 16:52:16 -0700 Subject: [PATCH 08/16] fix: type tool alias originalName and correct alias comments --- src/core/assistant-message/NativeToolCallParser.ts | 6 +++--- src/core/assistant-message/presentAssistantMessage.ts | 2 +- src/core/prompts/tools/filter-tools-for-mode.ts | 2 +- src/core/task/Task.ts | 2 +- src/shared/tools.ts | 6 ++++++ 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index ad607da47fb..58d07239628 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -537,7 +537,7 @@ export class NativeToolCallParser { // Preserve original name for API history when an alias was used if (originalName) { - ;(result as any).originalName = originalName + result.originalName = originalName } return result @@ -559,7 +559,7 @@ export class NativeToolCallParser { return this.parseDynamicMcpTool(toolCall) } - // Resolve tool alias to canonical name (e.g., "edit_file" -> "search_and_replace") + // Resolve tool alias to canonical name (e.g., "edit_file" -> "apply_diff", "temp_edit_file" -> "search_and_replace") const resolvedName = resolveToolAlias(toolCall.name as string) as TName // Validate tool name (after alias resolution) @@ -796,7 +796,7 @@ export class NativeToolCallParser { // Preserve original name for API history when an alias was used if (toolCall.name !== resolvedName) { - ;(result as any).originalName = toolCall.name + result.originalName = toolCall.name } return result diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 3a34b574a75..dd1337b6d52 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -696,7 +696,7 @@ export async function presentAssistantMessage(cline: Task) { if (!block.partial) { const modelInfo = cline.api.getModel() // Resolve aliases in includedTools before validation - // e.g., "edit_file" should resolve to "search_and_replace" + // e.g., "edit_file" should resolve to "apply_diff" const rawIncludedTools = modelInfo?.info?.includedTools const { resolveToolAlias } = await import("../prompts/tools/filter-tools-for-mode") const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool)) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 318cca1b5d6..ade84b30cec 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -218,7 +218,7 @@ export function filterNativeToolsForMode( ) allowedToolNames = customizedTools - // Apply tool aliases - if one tool in an alias group is allowed, all aliases are allowed + // Resolve any aliases to canonical tool names for execution-time filtering allowedToolNames = applyToolAliases(allowedToolNames) // Conditionally exclude codebase_search if feature is disabled or not configured diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1719523d1ba..6ae97b4004a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3110,7 +3110,7 @@ export class Task extends EventEmitter implements TaskLike { // When tool aliases are used (e.g., "edit_file" -> "search_and_replace"), // we want the alias name in the conversation history to match what the model // was told the tool was named, preventing confusion in multi-turn conversations. - const toolNameForHistory = (toolUse as any).originalName || toolUse.name + const toolNameForHistory = toolUse.originalName ?? toolUse.name assistantContent.push({ type: "tool_use" as const, diff --git a/src/shared/tools.ts b/src/shared/tools.ts index dfc8f5d65ef..de7a65bfb79 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -120,6 +120,12 @@ export interface ToolUse { type: "tool_use" id?: string // Optional ID to track tool calls name: TName + /** + * The original tool name as called by the model (e.g. an alias like "edit_file"), + * if it differs from the canonical tool name used for execution. + * Used to preserve tool names in API conversation history. + */ + originalName?: string // params is a partial record, allowing only some or none of the possible parameters to be used params: Partial> partial: boolean From 28725cee06e698d9cb42e7144e365444d4e3b301 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 11 Dec 2025 16:54:11 -0700 Subject: [PATCH 09/16] chore: add changeset for tool alias support --- .changeset/tool-alias-support.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tool-alias-support.md diff --git a/.changeset/tool-alias-support.md b/.changeset/tool-alias-support.md new file mode 100644 index 00000000000..af47423699e --- /dev/null +++ b/.changeset/tool-alias-support.md @@ -0,0 +1,5 @@ +--- +"roo-cline": minor +--- + +feat: add tool alias support for model-specific tool customization From 36c1042a4397df79cde547129a5a7f4a02e6c9b6 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 11 Dec 2025 19:08:26 -0500 Subject: [PATCH 10/16] Delete .changeset/tool-alias-support.md --- .changeset/tool-alias-support.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/tool-alias-support.md diff --git a/.changeset/tool-alias-support.md b/.changeset/tool-alias-support.md deleted file mode 100644 index af47423699e..00000000000 --- a/.changeset/tool-alias-support.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": minor ---- - -feat: add tool alias support for model-specific tool customization From 34310612fa8903a982ffa2b886fe2a0bd1b056f5 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 11 Dec 2025 20:30:27 -0700 Subject: [PATCH 11/16] fix: honor model tool aliases when filtering --- .../__tests__/filter-tools-for-mode.spec.ts | 26 +++++++++++++++++++ .../prompts/tools/filter-tools-for-mode.ts | 10 +++++++ 2 files changed, 36 insertions(+) diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts index 0eaa4953d7e..d189b999150 100644 --- a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -822,5 +822,31 @@ describe("filterMcpToolsForMode", () => { expect(toolNames).toContain("search_and_replace") // Included expect(toolNames).not.toContain("apply_diff") // Excluded }) + + it("should rename tools to alias names when model includes aliases", () => { + const codeMode: ModeConfig = { + slug: "code", + name: "Code", + roleDefinition: "Test", + groups: ["read", "edit", "browser", "command", "mcp"] as const, + } + + const modelInfo: ModelInfo = { + contextWindow: 100000, + supportsPromptCache: false, + includedTools: ["edit_file", "write_file"], + } + + const filtered = filterNativeToolsForMode(mockNativeTools, "code", [codeMode], {}, undefined, { + modelInfo, + }) + + const toolNames = filtered.map((t) => ("function" in t ? t.function.name : "")) + + expect(toolNames).toContain("edit_file") + expect(toolNames).toContain("write_file") + expect(toolNames).not.toContain("apply_diff") + expect(toolNames).not.toContain("write_to_file") + }) }) }) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index ade84b30cec..7aa07632af9 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -221,6 +221,16 @@ export function filterNativeToolsForMode( // Resolve any aliases to canonical tool names for execution-time filtering allowedToolNames = applyToolAliases(allowedToolNames) + // Register alias renames for tools that are allowed and explicitly requested as aliases + if (modelInfo?.includedTools && modelInfo.includedTools.length > 0) { + for (const includedTool of modelInfo.includedTools) { + const canonical = resolveToolAlias(includedTool) + if (canonical !== includedTool && allowedToolNames.has(canonical)) { + aliasRenames.set(canonical, includedTool) + } + } + } + // Conditionally exclude codebase_search if feature is disabled or not configured if ( !codeIndexManager || From f061e921a85a31fb6ad30388583b5b9259862d76 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 11 Dec 2025 20:34:46 -0700 Subject: [PATCH 12/16] chore: add changeset for tool alias feature --- .changeset/clever-tools-alias.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clever-tools-alias.md diff --git a/.changeset/clever-tools-alias.md b/.changeset/clever-tools-alias.md new file mode 100644 index 00000000000..462927d7b5e --- /dev/null +++ b/.changeset/clever-tools-alias.md @@ -0,0 +1,5 @@ +--- +"roo-cline": minor +--- + +Add tool alias support for model-specific tool customization, allowing models to expose tools under different names while mapping to existing implementations From 10e2defee1998fbd12f4c21f7b50210f129eab25 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 11 Dec 2025 20:49:22 -0700 Subject: [PATCH 13/16] revert: remove unnecessary changeset --- .changeset/clever-tools-alias.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/clever-tools-alias.md diff --git a/.changeset/clever-tools-alias.md b/.changeset/clever-tools-alias.md deleted file mode 100644 index 462927d7b5e..00000000000 --- a/.changeset/clever-tools-alias.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": minor ---- - -Add tool alias support for model-specific tool customization, allowing models to expose tools under different names while mapping to existing implementations From 7745b1ab956a6fd48e286c682ed61497bb87f1b9 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 11 Dec 2025 20:55:03 -0700 Subject: [PATCH 14/16] refactor: optimize tool alias resolution per review feedback - Pre-compute alias groups at module load time (ALIAS_GROUPS map) - Simplify getToolAliasGroup to O(1) lookup using pre-computed map - Remove redundant applyToolAliases call (already resolved in applyModelToolCustomization) - Simplify isToolAllowedInMode to resolve alias once instead of iterating group --- .../prompts/tools/filter-tools-for-mode.ts | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 7aa07632af9..856831d6982 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -27,6 +27,23 @@ for (const [alias, canonical] of Object.entries(TOOL_ALIASES)) { CANONICAL_TO_ALIASES.set(canonical, existing) } +/** + * Pre-computed alias groups map - maps any tool name (canonical or alias) to its full group. + * Built once at module load for O(1) lookup. + */ +const ALIAS_GROUPS: Map = new Map() + +// Build alias groups for all tools +for (const [canonical, aliases] of CANONICAL_TO_ALIASES.entries()) { + const group = Object.freeze([canonical, ...aliases]) + // Map canonical to group + ALIAS_GROUPS.set(canonical, group) + // Map each alias to the same group + for (const alias of aliases) { + ALIAS_GROUPS.set(alias, group) + } +} + /** * Resolves a tool name to its canonical name. * If the tool name is an alias, returns the canonical tool name. @@ -60,21 +77,13 @@ export function applyToolAliases(allowedTools: Set): Set { /** * Gets all tools in an alias group (including the canonical tool). + * Uses pre-computed ALIAS_GROUPS map for O(1) lookup. * * @param toolName - Any tool name in the alias group * @returns Array of all tool names in the alias group, or just the tool if not aliased */ -export function getToolAliasGroup(toolName: string): string[] { - // Check if it's a canonical tool with aliases - if (CANONICAL_TO_ALIASES.has(toolName)) { - return [toolName, ...CANONICAL_TO_ALIASES.get(toolName)!] - } - // Check if it's an alias - const canonical = ALIAS_TO_CANONICAL.get(toolName) - if (canonical) { - return [canonical, ...CANONICAL_TO_ALIASES.get(canonical)!] - } - return [toolName] +export function getToolAliasGroup(toolName: string): readonly string[] { + return ALIAS_GROUPS.get(toolName) ?? [toolName] } /** @@ -218,9 +227,6 @@ export function filterNativeToolsForMode( ) allowedToolNames = customizedTools - // Resolve any aliases to canonical tool names for execution-time filtering - allowedToolNames = applyToolAliases(allowedToolNames) - // Register alias renames for tools that are allowed and explicitly requested as aliases if (modelInfo?.includedTools && modelInfo.includedTools.length > 0) { for (const includedTool of modelInfo.includedTools) { @@ -357,17 +363,15 @@ export function isToolAllowedInMode( } // Check if the tool is allowed by the mode's groups - // Also check if any tool in the alias group is allowed - const aliasGroup = getToolAliasGroup(toolName) - return aliasGroup.some((aliasedTool) => - isToolAllowedForMode( - aliasedTool as ToolName, - modeSlug, - customModes ?? [], - undefined, - undefined, - experiments ?? {}, - ), + // Resolve to canonical name and check that single value + const canonicalTool = resolveToolAlias(toolName) + return isToolAllowedForMode( + canonicalTool as ToolName, + modeSlug, + customModes ?? [], + undefined, + undefined, + experiments ?? {}, ) } From 275378368840bcea4904e16e881beeca76f09967 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 12 Dec 2025 08:05:14 -0700 Subject: [PATCH 15/16] fix: cache renamed tool definitions to avoid per-message allocation Addresses review feedback: TOOL_ALIASES is static and tool definitions do not change at runtime, so renamed tool definitions are now cached in RENAMED_TOOL_CACHE to avoid creating 2 new objects via spread operators on every assistant message. --- .../prompts/tools/filter-tools-for-mode.ts | 50 ++++++++++++++++--- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 856831d6982..58dbc9a259e 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -44,6 +44,46 @@ for (const [canonical, aliases] of CANONICAL_TO_ALIASES.entries()) { } } +/** + * Cache for renamed tool definitions. + * Maps "canonicalName:aliasName" to the pre-built tool definition. + * This avoids creating new objects via spread operators on every assistant message. + */ +const RENAMED_TOOL_CACHE: Map = new Map() + +/** + * Gets or creates a renamed tool definition with the alias name. + * Uses RENAMED_TOOL_CACHE to avoid repeated object allocation. + * + * @param tool - The original tool definition + * @param aliasName - The alias name to use + * @returns Cached or newly created renamed tool definition + */ +function getOrCreateRenamedTool( + tool: OpenAI.Chat.ChatCompletionTool, + aliasName: string, +): OpenAI.Chat.ChatCompletionTool { + if (!("function" in tool) || !tool.function) { + return tool + } + + const cacheKey = `${tool.function.name}:${aliasName}` + let renamedTool = RENAMED_TOOL_CACHE.get(cacheKey) + + if (!renamedTool) { + renamedTool = { + ...tool, + function: { + ...tool.function, + name: aliasName, + }, + } + RENAMED_TOOL_CACHE.set(cacheKey, renamedTool) + } + + return renamedTool +} + /** * Resolves a tool name to its canonical name. * If the tool name is an alias, returns the canonical tool name. @@ -286,14 +326,8 @@ export function filterNativeToolsForMode( // Check if this tool should be renamed to an alias const aliasName = aliasRenames.get(toolName) if (aliasName) { - // Clone the tool with the alias name - filteredTools.push({ - ...tool, - function: { - ...tool.function, - name: aliasName, - }, - }) + // Use cached renamed tool definition to avoid per-message object allocation + filteredTools.push(getOrCreateRenamedTool(tool, aliasName)) } else { filteredTools.push(tool) } From 5964457597f15302f0782794ee06c754e6ca4cf0 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Fri, 12 Dec 2025 12:08:07 -0500 Subject: [PATCH 16/16] refactor: remove redundant alias registration loop The loop in filterNativeToolsForMode that registers alias renames for tools that are allowed and explicitly requested as aliases is redundant. This logic is already handled in applyModelToolCustomization at lines 195-205, which adds alias renames when processing includedTools. The removed code was iterating over modelInfo.includedTools a second time to add the same entries to aliasRenames that applyModelToolCustomization had already added. --- src/core/prompts/tools/filter-tools-for-mode.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 58dbc9a259e..3c1b2e3676d 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -267,16 +267,6 @@ export function filterNativeToolsForMode( ) allowedToolNames = customizedTools - // Register alias renames for tools that are allowed and explicitly requested as aliases - if (modelInfo?.includedTools && modelInfo.includedTools.length > 0) { - for (const includedTool of modelInfo.includedTools) { - const canonical = resolveToolAlias(includedTool) - if (canonical !== includedTool && allowedToolNames.has(canonical)) { - aliasRenames.set(canonical, includedTool) - } - } - } - // Conditionally exclude codebase_search if feature is disabled or not configured if ( !codeIndexManager ||