Skip to content

Commit ba7c553

Browse files
feat: add tool alias support for model-specific tool customization (#9989)
Co-authored-by: Hannes Rudolph <[email protected]>
1 parent 23a214c commit ba7c553

File tree

6 files changed

+317
-63
lines changed

6 files changed

+317
-63
lines changed

src/core/assistant-message/NativeToolCallParser.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
toolParamNames,
77
type NativeToolArgs,
88
} from "../../shared/tools"
9+
import { resolveToolAlias } from "../prompts/tools/filter-tools-for-mode"
910
import { parseJSON } from "partial-json"
1011
import type {
1112
ApiStreamToolCallStartChunk,
@@ -246,12 +247,18 @@ export class NativeToolCallParser {
246247
try {
247248
const partialArgs = parseJSON(toolCall.argumentsAccumulator)
248249

250+
// Resolve tool alias to canonical name
251+
const resolvedName = resolveToolAlias(toolCall.name) as ToolName
252+
// Preserve original name if it differs from resolved (i.e., it was an alias)
253+
const originalName = toolCall.name !== resolvedName ? toolCall.name : undefined
254+
249255
// Create partial ToolUse with extracted values
250256
return this.createPartialToolUse(
251257
toolCall.id,
252-
toolCall.name as ToolName,
258+
resolvedName,
253259
partialArgs || {},
254260
true, // partial
261+
originalName,
255262
)
256263
} catch {
257264
// Even partial-json-parser can fail on severely malformed JSON
@@ -327,12 +334,14 @@ export class NativeToolCallParser {
327334
/**
328335
* Create a partial ToolUse from currently parsed arguments.
329336
* Used during streaming to show progress.
337+
* @param originalName - The original tool name as called by the model (if different from canonical name)
330338
*/
331339
private static createPartialToolUse(
332340
id: string,
333341
name: ToolName,
334342
partialArgs: Record<string, any>,
335343
partial: boolean,
344+
originalName?: string,
336345
): ToolUse | null {
337346
// Build legacy params for display
338347
// NOTE: For streaming partial updates, we MUST populate params even for complex types
@@ -505,18 +514,33 @@ export class NativeToolCallParser {
505514
}
506515
break
507516

508-
// Add other tools as needed
517+
case "search_and_replace":
518+
if (partialArgs.path !== undefined || partialArgs.operations !== undefined) {
519+
nativeArgs = {
520+
path: partialArgs.path,
521+
operations: partialArgs.operations,
522+
}
523+
}
524+
break
525+
509526
default:
510527
break
511528
}
512529

513-
return {
530+
const result: ToolUse = {
514531
type: "tool_use" as const,
515532
name,
516533
params,
517534
partial,
518535
nativeArgs,
519536
}
537+
538+
// Preserve original name for API history when an alias was used
539+
if (originalName) {
540+
result.originalName = originalName
541+
}
542+
543+
return result
520544
}
521545

522546
/**
@@ -535,9 +559,12 @@ export class NativeToolCallParser {
535559
return this.parseDynamicMcpTool(toolCall)
536560
}
537561

538-
// Validate tool name
539-
if (!toolNames.includes(toolCall.name as ToolName)) {
540-
console.error(`Invalid tool name: ${toolCall.name}`)
562+
// Resolve tool alias to canonical name (e.g., "edit_file" -> "apply_diff", "temp_edit_file" -> "search_and_replace")
563+
const resolvedName = resolveToolAlias(toolCall.name as string) as TName
564+
565+
// Validate tool name (after alias resolution)
566+
if (!toolNames.includes(resolvedName as ToolName)) {
567+
console.error(`Invalid tool name: ${toolCall.name} (resolved: ${resolvedName})`)
541568
console.error(`Valid tool names:`, toolNames)
542569
return null
543570
}
@@ -554,13 +581,13 @@ export class NativeToolCallParser {
554581
// Skip complex parameters that have been migrated to nativeArgs.
555582
// For read_file, the 'files' parameter is a FileEntry[] array that can't be
556583
// meaningfully stringified. The properly typed data is in nativeArgs instead.
557-
if (toolCall.name === "read_file" && key === "files") {
584+
if (resolvedName === "read_file" && key === "files") {
558585
continue
559586
}
560587

561588
// Validate parameter name
562589
if (!toolParamNames.includes(key as ToolParamName)) {
563-
console.warn(`Unknown parameter '${key}' for tool '${toolCall.name}'`)
590+
console.warn(`Unknown parameter '${key}' for tool '${resolvedName}'`)
564591
console.warn(`Valid param names:`, toolParamNames)
565592
continue
566593
}
@@ -580,7 +607,7 @@ export class NativeToolCallParser {
580607
// will fall back to legacy parameter parsing if supported.
581608
let nativeArgs: NativeArgsFor<TName> | undefined = undefined
582609

583-
switch (toolCall.name) {
610+
switch (resolvedName) {
584611
case "read_file":
585612
if (args.files && Array.isArray(args.files)) {
586613
nativeArgs = { files: this.convertFileEntries(args.files) } as NativeArgsFor<TName>
@@ -761,12 +788,17 @@ export class NativeToolCallParser {
761788

762789
const result: ToolUse<TName> = {
763790
type: "tool_use" as const,
764-
name: toolCall.name,
791+
name: resolvedName,
765792
params,
766793
partial: false, // Native tool calls are always complete when yielded
767794
nativeArgs,
768795
}
769796

797+
// Preserve original name for API history when an alias was used
798+
if (toolCall.name !== resolvedName) {
799+
result.originalName = toolCall.name
800+
}
801+
770802
return result
771803
} catch (error) {
772804
console.error(

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,11 @@ export async function presentAssistantMessage(cline: Task) {
695695
// potentially causing the stream to appear frozen.
696696
if (!block.partial) {
697697
const modelInfo = cline.api.getModel()
698-
const includedTools = modelInfo?.info?.includedTools
698+
// Resolve aliases in includedTools before validation
699+
// e.g., "edit_file" should resolve to "apply_diff"
700+
const rawIncludedTools = modelInfo?.info?.includedTools
701+
const { resolveToolAlias } = await import("../prompts/tools/filter-tools-for-mode")
702+
const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool))
699703

700704
try {
701705
validateToolUse(

src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ describe("filterMcpToolsForMode", () => {
487487
it("should return original tools when modelInfo is undefined", () => {
488488
const tools = new Set(["read_file", "write_to_file", "apply_diff"])
489489
const result = applyModelToolCustomization(tools, codeMode, undefined)
490-
expect(result).toEqual(tools)
490+
expect(result.allowedTools).toEqual(tools)
491491
})
492492

493493
it("should exclude tools specified in excludedTools", () => {
@@ -498,9 +498,9 @@ describe("filterMcpToolsForMode", () => {
498498
excludedTools: ["apply_diff"],
499499
}
500500
const result = applyModelToolCustomization(tools, codeMode, modelInfo)
501-
expect(result.has("read_file")).toBe(true)
502-
expect(result.has("write_to_file")).toBe(true)
503-
expect(result.has("apply_diff")).toBe(false)
501+
expect(result.allowedTools.has("read_file")).toBe(true)
502+
expect(result.allowedTools.has("write_to_file")).toBe(true)
503+
expect(result.allowedTools.has("apply_diff")).toBe(false)
504504
})
505505

506506
it("should exclude multiple tools", () => {
@@ -511,10 +511,10 @@ describe("filterMcpToolsForMode", () => {
511511
excludedTools: ["apply_diff", "write_to_file"],
512512
}
513513
const result = applyModelToolCustomization(tools, codeMode, modelInfo)
514-
expect(result.has("read_file")).toBe(true)
515-
expect(result.has("execute_command")).toBe(true)
516-
expect(result.has("write_to_file")).toBe(false)
517-
expect(result.has("apply_diff")).toBe(false)
514+
expect(result.allowedTools.has("read_file")).toBe(true)
515+
expect(result.allowedTools.has("execute_command")).toBe(true)
516+
expect(result.allowedTools.has("write_to_file")).toBe(false)
517+
expect(result.allowedTools.has("apply_diff")).toBe(false)
518518
})
519519

520520
it("should include tools only if they belong to allowed groups", () => {
@@ -525,9 +525,9 @@ describe("filterMcpToolsForMode", () => {
525525
includedTools: ["write_to_file", "apply_diff"], // Both in edit group
526526
}
527527
const result = applyModelToolCustomization(tools, codeMode, modelInfo)
528-
expect(result.has("read_file")).toBe(true)
529-
expect(result.has("write_to_file")).toBe(true)
530-
expect(result.has("apply_diff")).toBe(true)
528+
expect(result.allowedTools.has("read_file")).toBe(true)
529+
expect(result.allowedTools.has("write_to_file")).toBe(true)
530+
expect(result.allowedTools.has("apply_diff")).toBe(true)
531531
})
532532

533533
it("should NOT include tools from groups not allowed by mode", () => {
@@ -539,9 +539,9 @@ describe("filterMcpToolsForMode", () => {
539539
}
540540
// Architect mode doesn't have edit group
541541
const result = applyModelToolCustomization(tools, architectMode, modelInfo)
542-
expect(result.has("read_file")).toBe(true)
543-
expect(result.has("write_to_file")).toBe(false) // Not in allowed groups
544-
expect(result.has("apply_diff")).toBe(false) // Not in allowed groups
542+
expect(result.allowedTools.has("read_file")).toBe(true)
543+
expect(result.allowedTools.has("write_to_file")).toBe(false) // Not in allowed groups
544+
expect(result.allowedTools.has("apply_diff")).toBe(false) // Not in allowed groups
545545
})
546546

547547
it("should apply both exclude and include operations", () => {
@@ -553,10 +553,10 @@ describe("filterMcpToolsForMode", () => {
553553
includedTools: ["search_and_replace"], // Another edit tool (customTool)
554554
}
555555
const result = applyModelToolCustomization(tools, codeMode, modelInfo)
556-
expect(result.has("read_file")).toBe(true)
557-
expect(result.has("write_to_file")).toBe(true)
558-
expect(result.has("apply_diff")).toBe(false) // Excluded
559-
expect(result.has("search_and_replace")).toBe(true) // Included
556+
expect(result.allowedTools.has("read_file")).toBe(true)
557+
expect(result.allowedTools.has("write_to_file")).toBe(true)
558+
expect(result.allowedTools.has("apply_diff")).toBe(false) // Excluded
559+
expect(result.allowedTools.has("search_and_replace")).toBe(true) // Included
560560
})
561561

562562
it("should handle empty excludedTools and includedTools arrays", () => {
@@ -568,7 +568,7 @@ describe("filterMcpToolsForMode", () => {
568568
includedTools: [],
569569
}
570570
const result = applyModelToolCustomization(tools, codeMode, modelInfo)
571-
expect(result).toEqual(tools)
571+
expect(result.allowedTools).toEqual(tools)
572572
})
573573

574574
it("should ignore excluded tools that are not in the original set", () => {
@@ -579,9 +579,9 @@ describe("filterMcpToolsForMode", () => {
579579
excludedTools: ["apply_diff", "nonexistent_tool"],
580580
}
581581
const result = applyModelToolCustomization(tools, codeMode, modelInfo)
582-
expect(result.has("read_file")).toBe(true)
583-
expect(result.has("write_to_file")).toBe(true)
584-
expect(result.size).toBe(2)
582+
expect(result.allowedTools.has("read_file")).toBe(true)
583+
expect(result.allowedTools.has("write_to_file")).toBe(true)
584+
expect(result.allowedTools.size).toBe(2)
585585
})
586586

587587
it("should NOT include customTools by default", () => {
@@ -594,8 +594,8 @@ describe("filterMcpToolsForMode", () => {
594594
}
595595
const result = applyModelToolCustomization(tools, codeMode, modelInfo)
596596
// customTools should not be in the result unless explicitly included
597-
expect(result.has("read_file")).toBe(true)
598-
expect(result.has("write_to_file")).toBe(true)
597+
expect(result.allowedTools.has("read_file")).toBe(true)
598+
expect(result.allowedTools.has("write_to_file")).toBe(true)
599599
})
600600

601601
it("should NOT include tools that are not in any TOOL_GROUPS", () => {
@@ -606,8 +606,8 @@ describe("filterMcpToolsForMode", () => {
606606
includedTools: ["my_custom_tool"], // Not in any tool group
607607
}
608608
const result = applyModelToolCustomization(tools, codeMode, modelInfo)
609-
expect(result.has("read_file")).toBe(true)
610-
expect(result.has("my_custom_tool")).toBe(false)
609+
expect(result.allowedTools.has("read_file")).toBe(true)
610+
expect(result.allowedTools.has("my_custom_tool")).toBe(false)
611611
})
612612

613613
it("should NOT include undefined tools even with allowed groups", () => {
@@ -619,8 +619,8 @@ describe("filterMcpToolsForMode", () => {
619619
}
620620
// Even though architect mode has read group, undefined tools are not added
621621
const result = applyModelToolCustomization(tools, architectMode, modelInfo)
622-
expect(result.has("read_file")).toBe(true)
623-
expect(result.has("custom_edit_tool")).toBe(false)
622+
expect(result.allowedTools.has("read_file")).toBe(true)
623+
expect(result.allowedTools.has("custom_edit_tool")).toBe(false)
624624
})
625625

626626
describe("with customTools defined in TOOL_GROUPS", () => {
@@ -647,9 +647,9 @@ describe("filterMcpToolsForMode", () => {
647647
includedTools: ["special_edit_tool"], // customTool from edit group
648648
}
649649
const result = applyModelToolCustomization(tools, codeMode, modelInfo)
650-
expect(result.has("read_file")).toBe(true)
651-
expect(result.has("write_to_file")).toBe(true)
652-
expect(result.has("special_edit_tool")).toBe(true) // customTool should be included
650+
expect(result.allowedTools.has("read_file")).toBe(true)
651+
expect(result.allowedTools.has("write_to_file")).toBe(true)
652+
expect(result.allowedTools.has("special_edit_tool")).toBe(true) // customTool should be included
653653
})
654654

655655
it("should NOT include customTools when not specified in includedTools", () => {
@@ -660,9 +660,9 @@ describe("filterMcpToolsForMode", () => {
660660
// No includedTools specified
661661
}
662662
const result = applyModelToolCustomization(tools, codeMode, modelInfo)
663-
expect(result.has("read_file")).toBe(true)
664-
expect(result.has("write_to_file")).toBe(true)
665-
expect(result.has("special_edit_tool")).toBe(false) // customTool should NOT be included by default
663+
expect(result.allowedTools.has("read_file")).toBe(true)
664+
expect(result.allowedTools.has("write_to_file")).toBe(true)
665+
expect(result.allowedTools.has("special_edit_tool")).toBe(false) // customTool should NOT be included by default
666666
})
667667

668668
it("should NOT include customTools from groups not allowed by mode", () => {
@@ -674,8 +674,8 @@ describe("filterMcpToolsForMode", () => {
674674
}
675675
// Architect mode doesn't have edit group
676676
const result = applyModelToolCustomization(tools, architectMode, modelInfo)
677-
expect(result.has("read_file")).toBe(true)
678-
expect(result.has("special_edit_tool")).toBe(false) // customTool should NOT be included
677+
expect(result.allowedTools.has("read_file")).toBe(true)
678+
expect(result.allowedTools.has("special_edit_tool")).toBe(false) // customTool should NOT be included
679679
})
680680
})
681681
})
@@ -822,5 +822,31 @@ describe("filterMcpToolsForMode", () => {
822822
expect(toolNames).toContain("search_and_replace") // Included
823823
expect(toolNames).not.toContain("apply_diff") // Excluded
824824
})
825+
826+
it("should rename tools to alias names when model includes aliases", () => {
827+
const codeMode: ModeConfig = {
828+
slug: "code",
829+
name: "Code",
830+
roleDefinition: "Test",
831+
groups: ["read", "edit", "browser", "command", "mcp"] as const,
832+
}
833+
834+
const modelInfo: ModelInfo = {
835+
contextWindow: 100000,
836+
supportsPromptCache: false,
837+
includedTools: ["edit_file", "write_file"],
838+
}
839+
840+
const filtered = filterNativeToolsForMode(mockNativeTools, "code", [codeMode], {}, undefined, {
841+
modelInfo,
842+
})
843+
844+
const toolNames = filtered.map((t) => ("function" in t ? t.function.name : ""))
845+
846+
expect(toolNames).toContain("edit_file")
847+
expect(toolNames).toContain("write_file")
848+
expect(toolNames).not.toContain("apply_diff")
849+
expect(toolNames).not.toContain("write_to_file")
850+
})
825851
})
826852
})

0 commit comments

Comments
 (0)