diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 6c7d0a4b4b6..dbfbc4b486a 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -120,6 +120,10 @@ export const modelInfoSchema = z.object({ // These tools will be added if they belong to an allowed group in the current mode // Cannot force-add tools from groups the mode doesn't allow includedTools: z.array(z.string()).optional(), + // Define aliases for specific tools in this model (only applies to native protocol) + // Format: ["originalName:aliasName", ...] e.g., ["apply_diff:edit"] + // The tool will be presented to the model with the alias name, but executed as the original + toolAliases: z.array(z.string()).optional(), /** * Service tiers with pricing information. * Each tier can have a name (for OpenAI service tiers) and pricing overrides. diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 6bdefb107c3..6691956846c 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -68,6 +68,48 @@ export class NativeToolCallParser { } >() + // Reverse alias map for mapping aliased tools back to original names + // Key: alias tool name (e.g., "edit"), Value: original tool name (e.g., "apply_diff") + private static toolAliasReverseMap = new Map() + + /** + * Set the reverse alias map for the current request. + * This maps alias tool names back to their original names. + * Should be called before processing tool calls for each new request. + * + * @param reverseMap - Map from alias tool name to original tool name + */ + public static setToolAliasReverseMap(reverseMap: Map): void { + this.toolAliasReverseMap = reverseMap + } + + /** + * Clear the tool alias reverse map. + * Should be called when starting a new request to prevent stale mappings. + */ + public static clearToolAliasReverseMap(): void { + this.toolAliasReverseMap.clear() + } + + /** + * Map a tool name back to its original name if it was aliased. + * Returns the original name if found, otherwise returns the input name. + * + * @param name - The tool name to resolve (possibly aliased) + * @returns The original tool name + */ + public static resolveOriginalToolName(name: string): string { + return this.toolAliasReverseMap.get(name) || name + } + + /** + * Resolve and return both presented (aliased) and original tool names. + */ + public static resolveToolNames(name: string): { original: string; presented: string } { + const original = this.toolAliasReverseMap.get(name) || name + return { original, presented: name } + } + /** * Process a raw tool call chunk from the API stream. * Handles tracking, buffering, and emits start/delta/end events. @@ -247,12 +289,17 @@ export class NativeToolCallParser { try { const partialArgs = parseJSON(toolCall.argumentsAccumulator) + // Resolve the tool name in case it was renamed and retain presented name + const { original, presented } = this.resolveToolNames(toolCall.name) + const resolvedName = original as ToolName + // Create partial ToolUse with extracted values return this.createPartialToolUse( toolCall.id, - toolCall.name as ToolName, + resolvedName, partialArgs || {}, true, // partial + presented, ) } catch { // Even partial-json-parser can fail on severely malformed JSON @@ -334,6 +381,7 @@ export class NativeToolCallParser { name: ToolName, partialArgs: Record, partial: boolean, + presentedName?: string, ): ToolUse | null { // Build legacy params for display // NOTE: For streaming partial updates, we MUST populate params even for complex types @@ -525,11 +573,13 @@ export class NativeToolCallParser { params, partial, nativeArgs, + ...(presentedName ? { presentedName } : {}), } } /** * Convert a native tool call chunk to a ToolUse object. + * Handles renamed tools by resolving to their original names for execution. * * @param toolCall - The native tool call from the API stream * @returns A properly typed ToolUse object @@ -544,9 +594,16 @@ export class NativeToolCallParser { return this.parseDynamicMcpTool(toolCall) } - // Validate tool name - if (!toolNames.includes(toolCall.name as ToolName)) { + // Resolve renamed tools back to their original names + // This handles cases where a tool was renamed via modelInfo.renamedTools + // e.g., if "apply_diff" was renamed to "edit_file", we resolve "edit_file" back to "apply_diff" + const { original, presented } = this.resolveToolNames(toolCall.name as string) + const resolvedName = original as TName + + // Validate tool name (using resolved name) + if (!toolNames.includes(resolvedName as ToolName)) { console.error(`Invalid tool name: ${toolCall.name}`) + console.error(`Resolved name: ${resolvedName}`) console.error(`Valid tool names:`, toolNames) return null } @@ -563,13 +620,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 } @@ -589,7 +646,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 @@ -778,10 +835,11 @@ 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, + ...(presented !== resolvedName ? { presentedName: presented } : {}), } return result diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.tool-aliasing.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.tool-aliasing.spec.ts new file mode 100644 index 00000000000..7ff143152a4 --- /dev/null +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.tool-aliasing.spec.ts @@ -0,0 +1,259 @@ +// npx vitest run core/assistant-message/__tests__/NativeToolCallParser.tool-aliasing.spec.ts + +import { NativeToolCallParser } from "../NativeToolCallParser" + +describe("NativeToolCallParser Tool Aliasing", () => { + beforeEach(() => { + // Clear any existing reverse map before each test + NativeToolCallParser.clearToolAliasReverseMap() + }) + + afterEach(() => { + // Clean up after each test + NativeToolCallParser.clearToolAliasReverseMap() + }) + + describe("setToolAliasReverseMap", () => { + it("should store the reverse map", () => { + const reverseMap = new Map([ + ["edit_file", "apply_diff"], + ["create_file", "write_to_file"], + ]) + + NativeToolCallParser.setToolAliasReverseMap(reverseMap) + + // Verify by resolving names + expect(NativeToolCallParser.resolveOriginalToolName("edit_file")).toBe("apply_diff") + expect(NativeToolCallParser.resolveOriginalToolName("create_file")).toBe("write_to_file") + }) + + it("should overwrite existing map", () => { + const firstMap = new Map([["edit_file", "apply_diff"]]) + const secondMap = new Map([["new_alias", "original_name"]]) + + NativeToolCallParser.setToolAliasReverseMap(firstMap) + NativeToolCallParser.setToolAliasReverseMap(secondMap) + + // First map should no longer be effective + expect(NativeToolCallParser.resolveOriginalToolName("edit_file")).toBe("edit_file") + // Second map should be effective + expect(NativeToolCallParser.resolveOriginalToolName("new_alias")).toBe("original_name") + }) + }) + + describe("clearToolAliasReverseMap", () => { + it("should clear the reverse map", () => { + const reverseMap = new Map([["edit_file", "apply_diff"]]) + NativeToolCallParser.setToolAliasReverseMap(reverseMap) + + // Verify it works + expect(NativeToolCallParser.resolveOriginalToolName("edit_file")).toBe("apply_diff") + + // Clear and verify it's gone + NativeToolCallParser.clearToolAliasReverseMap() + expect(NativeToolCallParser.resolveOriginalToolName("edit_file")).toBe("edit_file") + }) + }) + + describe("resolveOriginalToolName", () => { + it("should return original name for aliased tool", () => { + const reverseMap = new Map([["edit_file", "apply_diff"]]) + NativeToolCallParser.setToolAliasReverseMap(reverseMap) + + expect(NativeToolCallParser.resolveOriginalToolName("edit_file")).toBe("apply_diff") + }) + + it("should resolve both presented and original names", () => { + const reverseMap = new Map([["edit", "apply_diff"]]) + NativeToolCallParser.setToolAliasReverseMap(reverseMap) + + const result = NativeToolCallParser.resolveToolNames("edit") + expect(result.original).toBe("apply_diff") + expect(result.presented).toBe("edit") + }) + + it("should return same name if not in reverse map", () => { + const reverseMap = new Map([["edit_file", "apply_diff"]]) + NativeToolCallParser.setToolAliasReverseMap(reverseMap) + + expect(NativeToolCallParser.resolveOriginalToolName("read_file")).toBe("read_file") + }) + + it("should return same name when reverse map is empty", () => { + expect(NativeToolCallParser.resolveOriginalToolName("apply_diff")).toBe("apply_diff") + }) + + it("should handle multiple aliased tools", () => { + const reverseMap = new Map([ + ["edit_file", "apply_diff"], + ["create_file", "write_to_file"], + ["view_file", "read_file"], + ]) + NativeToolCallParser.setToolAliasReverseMap(reverseMap) + + expect(NativeToolCallParser.resolveOriginalToolName("edit_file")).toBe("apply_diff") + expect(NativeToolCallParser.resolveOriginalToolName("create_file")).toBe("write_to_file") + expect(NativeToolCallParser.resolveOriginalToolName("view_file")).toBe("read_file") + expect(NativeToolCallParser.resolveOriginalToolName("list_files")).toBe("list_files") // Not aliased + }) + }) + + describe("parseToolCall with aliased tools", () => { + beforeEach(() => { + // Set up a reverse map for testing + const reverseMap = new Map([ + ["edit_file", "apply_diff"], + ["create_file", "write_to_file"], + ]) + NativeToolCallParser.setToolAliasReverseMap(reverseMap) + }) + + it("should resolve aliased tool back to original name", () => { + const toolCall = { + id: "test-id-1", + name: "edit_file" as any, // Model calls it "edit_file" + arguments: JSON.stringify({ path: "test.ts", diff: "some diff content" }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + expect(result!.type).toBe("tool_use") + // The resolved name should be the original "apply_diff" + if (result!.type === "tool_use") { + expect(result!.name).toBe("apply_diff") + } + }) + + it("should parse non-aliased tools normally", () => { + const toolCall = { + id: "test-id-2", + name: "read_file" as any, + arguments: JSON.stringify({ + files: [{ path: "test.ts" }], + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + expect(result!.type).toBe("tool_use") + if (result!.type === "tool_use") { + expect(result!.name).toBe("read_file") // Still "read_file", not aliased + } + }) + + it("should preserve tool arguments when resolving aliased tool", () => { + const toolCall = { + id: "test-id-3", + name: "edit_file" as any, + arguments: JSON.stringify({ + path: "src/test.ts", + diff: "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE", + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + if (result!.type === "tool_use") { + expect(result!.name).toBe("apply_diff") + expect(result!.nativeArgs).toEqual({ + path: "src/test.ts", + diff: "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE", + }) + } + }) + }) + + describe("processStreamingChunk with aliased tools", () => { + beforeEach(() => { + // Set up a reverse map for testing + const reverseMap = new Map([ + ["edit_file", "apply_diff"], + ["edit", "apply_diff"], + ["create_file", "write_to_file"], + ]) + NativeToolCallParser.setToolAliasReverseMap(reverseMap) + NativeToolCallParser.clearAllStreamingToolCalls() + }) + + afterEach(() => { + NativeToolCallParser.clearAllStreamingToolCalls() + }) + + it("should resolve aliased tool in streaming chunks", () => { + // Start streaming with aliased tool name + NativeToolCallParser.startStreamingToolCall("stream-1", "edit_file") + + // Process a chunk with partial arguments + const partialResult = NativeToolCallParser.processStreamingChunk( + "stream-1", + '{"path": "test.ts", "diff": "some diff"', + ) + + // The result should have the original tool name, not the aliased one + expect(partialResult).not.toBeNull() + if (partialResult) { + expect(partialResult.name).toBe("apply_diff") // Resolved to original name + expect(partialResult.partial).toBe(true) + } + }) + + it("should resolve short aliased tool names in streaming chunks", () => { + // Start streaming with a very short aliased tool name like "edit" + NativeToolCallParser.startStreamingToolCall("stream-2", "edit") + + // Process a chunk with partial arguments + const partialResult = NativeToolCallParser.processStreamingChunk( + "stream-2", + '{"path": "file.ts", "diff": "x"}', + ) + + // The result should have the original tool name + expect(partialResult).not.toBeNull() + if (partialResult) { + expect(partialResult.name).toBe("apply_diff") // Resolved to original name + expect(partialResult.nativeArgs).toEqual({ + path: "file.ts", + diff: "x", + }) + } + }) + + it("should handle non-aliased tools in streaming", () => { + // Start streaming with a non-aliased tool + NativeToolCallParser.startStreamingToolCall("stream-3", "read_file") + + // Process a chunk + const partialResult = NativeToolCallParser.processStreamingChunk( + "stream-3", + '{"files": [{"path": "test.ts"}]}', + ) + + // The result should keep the original name + expect(partialResult).not.toBeNull() + if (partialResult) { + expect(partialResult.name).toBe("read_file") + } + }) + + it("should finalize streaming tool call with resolved name", () => { + // Start streaming with aliased tool name + NativeToolCallParser.startStreamingToolCall("stream-4", "edit_file") + + // Process some chunks + NativeToolCallParser.processStreamingChunk("stream-4", '{"path": "test.ts", ') + NativeToolCallParser.processStreamingChunk("stream-4", '"diff": "complete diff"}') + + // Finalize + const finalResult = NativeToolCallParser.finalizeStreamingToolCall("stream-4") + + expect(finalResult).not.toBeNull() + if (finalResult && finalResult.type === "tool_use") { + expect(finalResult.name).toBe("apply_diff") // Resolved to original name + expect(finalResult.partial).toBe(false) + } + }) + }) +}) diff --git a/src/core/prompts/tools/__tests__/tool-aliasing.spec.ts b/src/core/prompts/tools/__tests__/tool-aliasing.spec.ts new file mode 100644 index 00000000000..99af127ccb9 --- /dev/null +++ b/src/core/prompts/tools/__tests__/tool-aliasing.spec.ts @@ -0,0 +1,292 @@ +// npx vitest run core/prompts/tools/__tests__/tool-aliasing.spec.ts + +import type OpenAI from "openai" +import { parseToolAliases, createReverseAliasMap, applyToolAliases } from "../filter-tools-for-mode" + +// End-to-end test for order-independent behavior +describe("Tool Aliasing Order Independence", () => { + const createMockTool = (name: string): OpenAI.Chat.ChatCompletionTool => ({ + type: "function", + function: { + name, + description: `Mock ${name} tool`, + parameters: { type: "object", properties: {}, required: [] }, + }, + }) + + const mockTools: OpenAI.Chat.ChatCompletionTool[] = [ + createMockTool("apply_diff"), + createMockTool("write_to_file"), + createMockTool("read_file"), + ] + + it("should alias to short names (e.g., edit)", () => { + const aliasMap = parseToolAliases(["apply_diff:edit"]) + const reverseMap = createReverseAliasMap(aliasMap) + const aliasedTools = applyToolAliases(mockTools, aliasMap) + + expect(aliasMap.get("apply_diff")).toBe("edit") + expect(reverseMap.get("edit")).toBe("apply_diff") + + const toolNames = aliasedTools.map((t: OpenAI.Chat.ChatCompletionTool) => + t.type === "function" ? t.function.name : "", + ) + expect(toolNames).toContain("edit") + expect(toolNames).not.toContain("apply_diff") + }) + + it("should alias both tools regardless of order in config (order 1)", () => { + const toolAliasesConfig = ["write_to_file:write_file", "apply_diff:replace"] + const aliasMap = parseToolAliases(toolAliasesConfig) + const reverseMap = createReverseAliasMap(aliasMap) + const aliasedTools = applyToolAliases(mockTools, aliasMap) + + // Check alias map has both + expect(aliasMap.size).toBe(2) + expect(aliasMap.get("write_to_file")).toBe("write_file") + expect(aliasMap.get("apply_diff")).toBe("replace") + + // Check reverse map has both + expect(reverseMap.size).toBe(2) + expect(reverseMap.get("write_file")).toBe("write_to_file") + expect(reverseMap.get("replace")).toBe("apply_diff") + + // Check tools are aliased + const toolNames = aliasedTools.map((t: OpenAI.Chat.ChatCompletionTool) => + t.type === "function" ? t.function.name : "", + ) + expect(toolNames).toContain("write_file") + expect(toolNames).toContain("replace") + expect(toolNames).toContain("read_file") // Not aliased + expect(toolNames).not.toContain("write_to_file") + expect(toolNames).not.toContain("apply_diff") + }) + + it("should alias both tools regardless of order in config (order 2)", () => { + const toolAliasesConfig = ["apply_diff:replace", "write_to_file:write_file"] + const aliasMap = parseToolAliases(toolAliasesConfig) + const reverseMap = createReverseAliasMap(aliasMap) + const aliasedTools = applyToolAliases(mockTools, aliasMap) + + // Check alias map has both + expect(aliasMap.size).toBe(2) + expect(aliasMap.get("apply_diff")).toBe("replace") + expect(aliasMap.get("write_to_file")).toBe("write_file") + + // Check reverse map has both + expect(reverseMap.size).toBe(2) + expect(reverseMap.get("replace")).toBe("apply_diff") + expect(reverseMap.get("write_file")).toBe("write_to_file") + + // Check tools are aliased + const toolNames = aliasedTools.map((t: OpenAI.Chat.ChatCompletionTool) => + t.type === "function" ? t.function.name : "", + ) + expect(toolNames).toContain("replace") + expect(toolNames).toContain("write_file") + expect(toolNames).toContain("read_file") // Not aliased + expect(toolNames).not.toContain("apply_diff") + expect(toolNames).not.toContain("write_to_file") + }) +}) + +/** + * Helper to get function name from a ChatCompletionTool + */ +function getFunctionName(tool: OpenAI.Chat.ChatCompletionTool): string { + if ("function" in tool && tool.function) { + return tool.function.name + } + throw new Error("Tool does not have function property") +} + +/** + * Helper to get function description from a ChatCompletionTool + */ +function getFunctionDescription(tool: OpenAI.Chat.ChatCompletionTool): string | undefined { + if ("function" in tool && tool.function) { + return tool.function.description + } + throw new Error("Tool does not have function property") +} + +/** + * Helper to get function parameters from a ChatCompletionTool + */ +function getFunctionParameters(tool: OpenAI.Chat.ChatCompletionTool): OpenAI.FunctionParameters | undefined { + if ("function" in tool && tool.function) { + return tool.function.parameters + } + throw new Error("Tool does not have function property") +} + +describe("Tool Aliasing", () => { + describe("parseToolAliases", () => { + it("should return empty map for undefined input", () => { + const result = parseToolAliases(undefined) + expect(result.size).toBe(0) + }) + + it("should return empty map for empty array", () => { + const result = parseToolAliases([]) + expect(result.size).toBe(0) + }) + + it("should parse single alias specification", () => { + const result = parseToolAliases(["apply_diff:edit_file"]) + expect(result.size).toBe(1) + expect(result.get("apply_diff")).toBe("edit_file") + }) + + it("should parse multiple alias specifications", () => { + const result = parseToolAliases(["apply_diff:edit_file", "write_to_file:create_file"]) + expect(result.size).toBe(2) + expect(result.get("apply_diff")).toBe("edit_file") + expect(result.get("write_to_file")).toBe("create_file") + }) + + it("should ignore invalid specs without colon", () => { + const result = parseToolAliases(["apply_diff", "valid:alias"]) + expect(result.size).toBe(1) + expect(result.get("valid")).toBe("alias") + }) + + it("should ignore specs with colon at start", () => { + const result = parseToolAliases([":alias_name"]) + expect(result.size).toBe(0) + }) + + it("should ignore specs with colon at end", () => { + const result = parseToolAliases(["original_name:"]) + expect(result.size).toBe(0) + }) + + it("should handle tool names with underscores", () => { + const result = parseToolAliases(["my_long_tool_name:short_name"]) + expect(result.size).toBe(1) + expect(result.get("my_long_tool_name")).toBe("short_name") + }) + + it("should handle alias names with multiple colons (only first colon is delimiter)", () => { + const result = parseToolAliases(["original:name:with:colons"]) + expect(result.size).toBe(1) + expect(result.get("original")).toBe("name:with:colons") + }) + }) + + describe("createReverseAliasMap", () => { + it("should create empty map from empty input", () => { + const aliasMap = new Map() + const result = createReverseAliasMap(aliasMap) + expect(result.size).toBe(0) + }) + + it("should reverse single mapping", () => { + const aliasMap = new Map([["apply_diff", "edit_file"]]) + const result = createReverseAliasMap(aliasMap) + expect(result.size).toBe(1) + expect(result.get("edit_file")).toBe("apply_diff") + }) + + it("should reverse multiple mappings", () => { + const aliasMap = new Map([ + ["apply_diff", "edit_file"], + ["write_to_file", "create_file"], + ]) + const result = createReverseAliasMap(aliasMap) + expect(result.size).toBe(2) + expect(result.get("edit_file")).toBe("apply_diff") + expect(result.get("create_file")).toBe("write_to_file") + }) + }) + + describe("applyToolAliases", () => { + const createMockTool = (name: string): OpenAI.Chat.ChatCompletionTool => ({ + type: "function", + function: { + name, + description: `Mock ${name} tool`, + parameters: { + type: "object", + properties: {}, + }, + }, + }) + + it("should return original tools when alias map is empty", () => { + const tools = [createMockTool("apply_diff"), createMockTool("read_file")] + const aliasMap = new Map() + const result = applyToolAliases(tools, aliasMap) + + expect(result).toEqual(tools) + expect(getFunctionName(result[0])).toBe("apply_diff") + expect(getFunctionName(result[1])).toBe("read_file") + }) + + it("should alias a single tool", () => { + const tools = [createMockTool("apply_diff"), createMockTool("read_file")] + const aliasMap = new Map([["apply_diff", "edit_file"]]) + const result = applyToolAliases(tools, aliasMap) + + expect(getFunctionName(result[0])).toBe("edit_file") + expect(getFunctionName(result[1])).toBe("read_file") + }) + + it("should alias multiple tools", () => { + const tools = [createMockTool("apply_diff"), createMockTool("write_to_file"), createMockTool("read_file")] + const aliasMap = new Map([ + ["apply_diff", "edit_file"], + ["write_to_file", "create_file"], + ]) + const result = applyToolAliases(tools, aliasMap) + + expect(getFunctionName(result[0])).toBe("edit_file") + expect(getFunctionName(result[1])).toBe("create_file") + expect(getFunctionName(result[2])).toBe("read_file") + }) + + it("should not modify tools that are not in the alias map", () => { + const tools = [createMockTool("read_file"), createMockTool("list_files")] + const aliasMap = new Map([["apply_diff", "edit_file"]]) + const result = applyToolAliases(tools, aliasMap) + + expect(getFunctionName(result[0])).toBe("read_file") + expect(getFunctionName(result[1])).toBe("list_files") + }) + + it("should preserve tool description and parameters after aliasing", () => { + const tool: OpenAI.Chat.ChatCompletionTool = { + type: "function", + function: { + name: "apply_diff", + description: "Apply a diff to a file", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + diff: { type: "string" }, + }, + required: ["path", "diff"], + }, + }, + } + const aliasMap = new Map([["apply_diff", "edit_file"]]) + const result = applyToolAliases([tool], aliasMap) + + expect(getFunctionName(result[0])).toBe("edit_file") + expect(getFunctionDescription(result[0])).toBe("Apply a diff to a file") + expect(getFunctionParameters(result[0])).toEqual(tool.function.parameters) + }) + + it("should return new array without mutating original tools", () => { + const tools = [createMockTool("apply_diff")] + const aliasMap = new Map([["apply_diff", "edit_file"]]) + const result = applyToolAliases(tools, aliasMap) + + // Original should be unchanged + expect(getFunctionName(tools[0])).toBe("apply_diff") + // Result should be aliased + expect(getFunctionName(result[0])).toBe("edit_file") + }) + }) +}) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index eb87c9bbeca..5463a1252d7 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -6,6 +6,86 @@ import { defaultModeSlug } from "../../../shared/modes" import type { CodeIndexManager } from "../../../services/code-index/manager" import type { McpHub } from "../../../services/mcp/McpHub" +/** + * Parses a toolAliases configuration array and returns a map from original name to alias name. + * + * @param toolAliases - Array of alias strings in format "originalName:aliasName" + * @returns Map from original tool name to alias tool name + */ +export function parseToolAliases(toolAliases: string[] | undefined): Map { + const aliasMap = new Map() + + if (!toolAliases || toolAliases.length === 0) { + return aliasMap + } + + for (const aliasSpec of toolAliases) { + const colonIndex = aliasSpec.indexOf(":") + if (colonIndex > 0 && colonIndex < aliasSpec.length - 1) { + const originalName = aliasSpec.substring(0, colonIndex) + const aliasName = aliasSpec.substring(colonIndex + 1) + aliasMap.set(originalName, aliasName) + } else { + // Log invalid alias specs to help users troubleshoot misconfigured aliases + console.warn( + `[toolAliases] Ignoring invalid alias spec "${aliasSpec}". Expected format: "originalName:aliasName"`, + ) + } + } + + return aliasMap +} + +/** + * Creates a reverse mapping from alias tool names back to original names. + * + * @param aliasMap - Map from original name to alias name + * @returns Map from alias tool name to original tool name + */ +export function createReverseAliasMap(aliasMap: Map): Map { + const reverseMap = new Map() + for (const [original, alias] of aliasMap) { + reverseMap.set(alias, original) + } + return reverseMap +} + +/** + * Applies tool aliasing to a set of native tools. + * + * @param tools - Array of native tools to alias + * @param aliasMap - Map from original tool name to alias name + * @returns Array of tools with names replaced according to the alias map + */ +export function applyToolAliases( + tools: OpenAI.Chat.ChatCompletionTool[], + aliasMap: Map, +): OpenAI.Chat.ChatCompletionTool[] { + if (aliasMap.size === 0) { + return tools + } + + return tools.map((tool) => { + if (tool.type !== "function") { + return tool + } + + const aliasName = aliasMap.get(tool.function.name) + if (!aliasName) { + return tool + } + + // Create a new tool object with the aliased function name + return { + ...tool, + function: { + ...tool.function, + name: aliasName, + }, + } + }) +} + /** * Apply model-specific tool customization to a set of allowed tools. * diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 35f9011a86d..ec7c8bb5642 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2389,6 +2389,8 @@ export class Task extends EventEmitter implements TaskLike { // Clear any leftover streaming tool call state from previous interrupted streams NativeToolCallParser.clearAllStreamingToolCalls() NativeToolCallParser.clearRawChunkState() + // Clear previous tool alias mappings (will be re-set when building tools for this request) + NativeToolCallParser.clearToolAliasReverseMap() await this.diffViewProvider.reset() @@ -3109,7 +3111,9 @@ export class Task extends EventEmitter implements TaskLike { assistantContent.push({ type: "tool_use" as const, id: toolCallId, - name: toolUse.name, + // Use presentedName for API history so the model sees consistent tool names + // (the name it originally called, which may be a renamed version) + name: toolUse.presentedName || toolUse.name, input, }) } @@ -3703,7 +3707,7 @@ export class Task extends EventEmitter implements TaskLike { throw new Error("Provider reference lost during tool building") } - allTools = await buildNativeToolsArray({ + const toolsResult = await buildNativeToolsArray({ provider, cwd: this.cwd, mode, @@ -3715,6 +3719,9 @@ export class Task extends EventEmitter implements TaskLike { modelInfo, diffEnabled: this.diffEnabled, }) + allTools = toolsResult.tools + // Set the reverse alias map for parsing tool calls back to original names + NativeToolCallParser.setToolAliasReverseMap(toolsResult.toolAliasReverseMap) } // Parallel tool calls are disabled - feature is on hold diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index 575b31580e6..c4774a14aa4 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -2,7 +2,13 @@ import type OpenAI from "openai" import type { ProviderSettings, ModeConfig, ModelInfo } from "@roo-code/types" import type { ClineProvider } from "../webview/ClineProvider" import { getNativeTools, getMcpServerTools } from "../prompts/tools/native-tools" -import { filterNativeToolsForMode, filterMcpToolsForMode } from "../prompts/tools/filter-tools-for-mode" +import { + filterNativeToolsForMode, + filterMcpToolsForMode, + parseToolAliases, + applyToolAliases, + createReverseAliasMap, +} from "../prompts/tools/filter-tools-for-mode" interface BuildToolsOptions { provider: ClineProvider @@ -17,14 +23,25 @@ interface BuildToolsOptions { diffEnabled: boolean } +/** + * Result of building native tools array. + */ +export interface BuildToolsResult { + /** Array of filtered and optionally aliased native and MCP tools */ + tools: OpenAI.Chat.ChatCompletionTool[] + /** Map from alias tool name back to original name (for reverse lookup when parsing tool calls) */ + toolAliasReverseMap: Map +} + /** * Builds the complete tools array for native protocol requests. * Combines native tools and MCP tools, filtered by mode restrictions. + * Applies any tool aliases specified in modelInfo.toolAliases. * * @param options - Configuration options for building the tools - * @returns Array of filtered native and MCP tools + * @returns Object containing filtered tools and reverse alias map */ -export async function buildNativeToolsArray(options: BuildToolsOptions): Promise { +export async function buildNativeToolsArray(options: BuildToolsOptions): Promise { const { provider, cwd, @@ -73,5 +90,20 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise const mcpTools = getMcpServerTools(mcpHub) const filteredMcpTools = filterMcpToolsForMode(mcpTools, mode, customModes, experiments) - return [...filteredNativeTools, ...filteredMcpTools] + // Combine filtered tools + let allTools = [...filteredNativeTools, ...filteredMcpTools] + + // Apply tool aliases if specified in modelInfo + const aliasMap = parseToolAliases(modelInfo?.toolAliases) + if (aliasMap.size > 0) { + allTools = applyToolAliases(allTools, aliasMap) + } + + // Create reverse map for parsing tool calls back to original names + const toolAliasReverseMap = createReverseAliasMap(aliasMap) + + return { + tools: allTools, + toolAliasReverseMap, + } } diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 6b82861cb08..0360e5ccd6c 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -121,6 +121,8 @@ export interface ToolUse { type: "tool_use" id?: string // Optional ID to track tool calls name: TName + /** The name as presented by the model (may be renamed). Falls back to `name` when not renamed. */ + presentedName?: string // params is a partial record, allowing only some or none of the possible parameters to be used params: Partial> partial: boolean