feat: add tool alias support for model-specific tool customization (#9989)

Co-authored-by: Hannes Rudolph <hrudolph@gmail.com>
This commit is contained in:
Daniel 2025-12-12 14:05:21 -05:00 committed by GitHub
parent 23a214c519
commit ba7c5535ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 317 additions and 63 deletions

View file

@ -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,12 +247,18 @@ export class NativeToolCallParser {
try {
const partialArgs = parseJSON(toolCall.argumentsAccumulator)
// 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(
toolCall.id,
toolCall.name as ToolName,
resolvedName,
partialArgs || {},
true, // partial
originalName,
)
} catch {
// Even partial-json-parser can fail on severely malformed JSON
@ -327,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<string, any>,
partial: boolean,
originalName?: string,
): ToolUse | null {
// Build legacy params for display
// NOTE: For streaming partial updates, we MUST populate params even for complex types
@ -505,18 +514,33 @@ 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
}
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.originalName = originalName
}
return result
}
/**
@ -535,9 +559,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" -> "apply_diff", "temp_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 +581,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 +607,7 @@ export class NativeToolCallParser {
// will fall back to legacy parameter parsing if supported.
let nativeArgs: NativeArgsFor<TName> | 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<TName>
@ -761,12 +788,17 @@ export class NativeToolCallParser {
const result: ToolUse<TName> = {
type: "tool_use" as const,
name: toolCall.name,
name: resolvedName,
params,
partial: false, // Native tool calls are always complete when yielded
nativeArgs,
}
// Preserve original name for API history when an alias was used
if (toolCall.name !== resolvedName) {
result.originalName = toolCall.name
}
return result
} catch (error) {
console.error(

View file

@ -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 "apply_diff"
const rawIncludedTools = modelInfo?.info?.includedTools
const { resolveToolAlias } = await import("../prompts/tools/filter-tools-for-mode")
const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool))
try {
validateToolUse(

View file

@ -487,7 +487,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 +498,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 +511,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 +525,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 +539,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 +553,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 +568,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 +579,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 +594,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 +606,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 +619,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 +647,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 +660,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 +674,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
})
})
})
@ -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")
})
})
})

View file

@ -1,11 +1,131 @@
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"
/**
* Reverse lookup map - maps alias name to canonical tool name.
* Built once at module load from the central TOOL_ALIASES constant.
*/
const ALIAS_TO_CANONICAL: Map<string, string> = new Map(
Object.entries(TOOL_ALIASES).map(([alias, canonical]) => [alias, canonical]),
)
/**
* 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 CANONICAL_TO_ALIASES: Map<string, string[]> = new Map()
// 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)
}
/**
* 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<string, readonly string[]> = 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)
}
}
/**
* 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<string, OpenAI.Chat.ChatCompletionTool> = 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.
* 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<string>): Set<string> {
const result = new Set<string>()
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).
* 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): readonly string[] {
return ALIAS_GROUPS.get(toolName) ?? [toolName]
}
/**
* Apply model-specific tool customization to a set of allowed tools.
*
@ -18,21 +138,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<string>
/** Maps canonical tool name to alias name for tools that should be renamed */
aliasRenames: Map<string, string>
}
export function applyModelToolCustomization(
allowedTools: Set<string>,
modeConfig: ModeConfig,
modelInfo?: ModelInfo,
): Set<string> {
): ModelToolCustomizationResult {
if (!modelInfo) {
return allowedTools
return { allowedTools, aliasRenames: new Map() }
}
const result = new Set(allowedTools)
const aliasRenames = new Map<string, string>()
// 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 +191,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 +260,12 @@ 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
// Conditionally exclude codebase_search if feature is disabled or not configured
if (
@ -163,14 +305,27 @@ 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) {
// Use cached renamed tool definition to avoid per-message object allocation
filteredTools.push(getOrCreateRenamedTool(tool, aliasName))
} else {
filteredTools.push(tool)
}
}
}
return false
})
}
return filteredTools
}
/**
@ -232,7 +387,16 @@ export function isToolAllowedInMode(
}
// Check if the tool is allowed by the mode's groups
return isToolAllowedForMode(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 ?? {},
)
}
/**

View file

@ -3122,10 +3122,16 @@ export class Task extends EventEmitter<TaskEvents> 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.originalName ?? toolUse.name
assistantContent.push({
type: "tool_use" as const,
id: toolCallId,
name: toolUse.name,
name: toolNameForHistory,
input,
})
}

View file

@ -120,6 +120,12 @@ export interface ToolUse<TName extends ToolName = ToolName> {
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<Record<ToolParamName, string>>
partial: boolean
@ -293,6 +299,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<string, ToolName> = {
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[] }
| ({