mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat: add toolAliases support for model-specific tool renaming
Add the ability to rename tools for a model using toolAliases config. Format: toolAliases: ["apply_diff:edit_file", "write_to_file:write_file"] This allows models to see tools with alternative names while the internal execution uses the original tool names.
This commit is contained in:
parent
03912d8399
commit
700c2531c7
8 changed files with 742 additions and 13 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<string, string>()
|
||||
|
||||
/**
|
||||
* 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<string, string>): 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<string, any>,
|
||||
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<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>
|
||||
|
|
@ -778,10 +835,11 @@ 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,
|
||||
...(presented !== resolvedName ? { presentedName: presented } : {}),
|
||||
}
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
292
src/core/prompts/tools/__tests__/tool-aliasing.spec.ts
Normal file
292
src/core/prompts/tools/__tests__/tool-aliasing.spec.ts
Normal file
|
|
@ -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<string, string>()
|
||||
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<string, string>()
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -6,6 +6,81 @@ 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<string, string> {
|
||||
const aliasMap = new Map<string, string>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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<string, string>): Map<string, string> {
|
||||
const reverseMap = new Map<string, string>()
|
||||
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<string, string>,
|
||||
): 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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -2389,6 +2389,8 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<OpenAI.Chat.ChatCompletionTool[]> {
|
||||
export async function buildNativeToolsArray(options: BuildToolsOptions): Promise<BuildToolsResult> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,6 +121,8 @@ export interface ToolUse<TName extends ToolName = ToolName> {
|
|||
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<Record<ToolParamName, string>>
|
||||
partial: boolean
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue