Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 16 additions & 6 deletions src/core/assistant-message/NativeToolCallParser.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { parseJSON } from "partial-json"

import { type ToolName, toolNames, type FileEntry } from "@roo-code/types"
import { customToolRegistry } from "@roo-code/core"

import {
type ToolUse,
type McpToolUse,
type ToolParamName,
toolParamNames,
type NativeToolArgs,
toolParamNames,
} from "../../shared/tools"
import { resolveToolAlias } from "../prompts/tools/filter-tools-for-mode"
import { parseJSON } from "partial-json"
import type {
ApiStreamToolCallStartChunk,
ApiStreamToolCallDeltaChunk,
Expand Down Expand Up @@ -558,23 +561,24 @@ export class NativeToolCallParser {
}): ToolUse<TName> | McpToolUse | null {
// Check if this is a dynamic MCP tool (mcp--serverName--toolName)
const mcpPrefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR

if (typeof toolCall.name === "string" && toolCall.name.startsWith(mcpPrefix)) {
return this.parseDynamicMcpTool(toolCall)
}

// 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)) {
// Validate tool name (after alias resolution).
if (!toolNames.includes(resolvedName as ToolName) && !customToolRegistry.has(resolvedName)) {
console.error(`Invalid tool name: ${toolCall.name} (resolved: ${resolvedName})`)
console.error(`Valid tool names:`, toolNames)
return null
}

try {
// Parse the arguments JSON string
const args = JSON.parse(toolCall.arguments)
const args = toolCall.arguments === "" ? {} : JSON.parse(toolCall.arguments)

// Build legacy params object for backward compatibility with XML protocol and UI.
// Native execution path uses nativeArgs instead, which has proper typing.
Expand All @@ -589,7 +593,7 @@ export class NativeToolCallParser {
}

// Validate parameter name
if (!toolParamNames.includes(key as ToolParamName)) {
if (!toolParamNames.includes(key as ToolParamName) && !customToolRegistry.has(resolvedName)) {
console.warn(`Unknown parameter '${key}' for tool '${resolvedName}'`)
console.warn(`Valid param names:`, toolParamNames)
continue
Expand Down Expand Up @@ -786,6 +790,12 @@ export class NativeToolCallParser {
break

default:
if (customToolRegistry.has(resolvedName)) {
nativeArgs = args as NativeArgsFor<TName>
} else {
console.error(`Unhandled tool: ${resolvedName}`)
}

break
}

Expand Down
43 changes: 41 additions & 2 deletions src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Anthropic } from "@anthropic-ai/sdk"

import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { customToolRegistry } from "@roo-code/core"

import { t } from "../../i18n"

Expand Down Expand Up @@ -1045,9 +1046,8 @@ export async function presentAssistantMessage(cline: Task) {
})
break
default: {
// Handle unknown/invalid tool names
// Handle unknown/invalid tool names OR custom tools
// This is critical for native protocol where every tool_use MUST have a tool_result
// Note: This case should rarely be reached since validateToolUse now checks for unknown tools

// CRITICAL: Don't process partial blocks for unknown tools - just let them stream in.
// If we try to show errors for partial blocks, we'd show the error on every streaming chunk,
Expand All @@ -1056,6 +1056,45 @@ export async function presentAssistantMessage(cline: Task) {
break
}

const customTool = customToolRegistry.get(block.name)

if (customTool) {
try {
console.log(`executing customTool -> ${JSON.stringify(customTool, null, 2)}`)
let customToolArgs

if (customTool.parameters) {
try {
customToolArgs = customTool.parameters.parse(block.nativeArgs || block.params || {})
console.log(`customToolArgs -> ${JSON.stringify(customToolArgs, null, 2)}`)
} catch (parseParamsError) {
const message = `Custom tool "${block.name}" argument validation failed: ${parseParamsError.message}`
console.error(message)
cline.consecutiveMistakeCount++
await cline.say("error", message)
pushToolResult(formatResponse.toolError(message, toolProtocol))
break
}
}

console.log(`${customTool.name}.execute() -> ${JSON.stringify(customToolArgs, null, 2)}`)

const result = await customTool.execute(customToolArgs, {
mode: mode ?? defaultModeSlug,
task: cline,
})

pushToolResult(result)
cline.consecutiveMistakeCount = 0
} catch (executionError: any) {
cline.consecutiveMistakeCount++
await handleError(`executing custom tool "${block.name}"`, executionError)
}

break
}

// Not a custom tool - handle as unknown tool error
const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.`
cline.consecutiveMistakeCount++
cline.recordToolError(block.name as ToolName, errorMessage)
Expand Down
28 changes: 23 additions & 5 deletions src/core/prompts/system.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import * as vscode from "vscode"
import * as os from "os"

import type { ModeConfig, PromptComponent, CustomModePrompts, TodoItem } from "@roo-code/types"

import type { SystemPromptSettings } from "./types"
import {
type ModeConfig,
type PromptComponent,
type CustomModePrompts,
type TodoItem,
getEffectiveProtocol,
isNativeProtocol,
} from "@roo-code/types"
import { customToolRegistry, formatXml } from "@roo-code/core"

import { Mode, modes, defaultModeSlug, getModeBySlug, getGroupName, getModeSelection } from "../../shared/modes"
import { DiffStrategy } from "../../shared/tools"
Expand All @@ -15,8 +21,8 @@ import { CodeIndexManager } from "../../services/code-index/manager"

import { PromptVariables, loadSystemPromptFile } from "./sections/custom-system-prompt"

import type { SystemPromptSettings } from "./types"
import { getToolDescriptionsForMode } from "./tools"
import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types"
import {
getRulesSection,
getSystemInfoSection,
Expand Down Expand Up @@ -98,7 +104,7 @@ async function generatePrompt(
])

// Build tools catalog section only for XML protocol
const toolsCatalog = isNativeProtocol(effectiveProtocol)
const builtInToolsCatalog = isNativeProtocol(effectiveProtocol)
? ""
: `\n\n${getToolDescriptionsForMode(
mode,
Expand All @@ -116,6 +122,18 @@ async function generatePrompt(
modelId,
)}`

let customToolsSection = ""

if (!isNativeProtocol(effectiveProtocol)) {
const customTools = customToolRegistry.getAllSerialized()

if (customTools.length > 0) {
customToolsSection = `\n\n${formatXml(customTools)}`
}
}

const toolsCatalog = builtInToolsCatalog + customToolsSection

const basePrompt = `${roleDefinition}

${markdownFormattingSection()}
Expand Down
29 changes: 22 additions & 7 deletions src/core/task/build-tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import path from "path"

import type OpenAI from "openai"

import type { ProviderSettings, ModeConfig, ModelInfo } from "@roo-code/types"
import { customToolRegistry, formatNative } from "@roo-code/core"

import type { ClineProvider } from "../webview/ClineProvider"

import { getNativeTools, getMcpServerTools } from "../prompts/tools/native-tools"
import { filterNativeToolsForMode, filterMcpToolsForMode } from "../prompts/tools/filter-tools-for-mode"

Expand Down Expand Up @@ -40,25 +46,25 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise

const mcpHub = provider.getMcpHub()

// Get CodeIndexManager for feature checking
// Get CodeIndexManager for feature checking.
const { CodeIndexManager } = await import("../../services/code-index/manager")
const codeIndexManager = CodeIndexManager.getInstance(provider.context, cwd)

// Build settings object for tool filtering
// Build settings object for tool filtering.
const filterSettings = {
todoListEnabled: apiConfiguration?.todoListEnabled ?? true,
browserToolEnabled: browserToolEnabled ?? true,
modelInfo,
diffEnabled,
}

// Determine if partial reads are enabled based on maxReadFileLine setting
// Determine if partial reads are enabled based on maxReadFileLine setting.
const partialReadsEnabled = maxReadFileLine !== -1

// Build native tools with dynamic read_file tool based on partialReadsEnabled
// Build native tools with dynamic read_file tool based on partialReadsEnabled.
const nativeTools = getNativeTools(partialReadsEnabled)

// Filter native tools based on mode restrictions
// Filter native tools based on mode restrictions.
const filteredNativeTools = filterNativeToolsForMode(
nativeTools,
mode,
Expand All @@ -69,9 +75,18 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise
mcpHub,
)

// Filter MCP tools based on mode restrictions
// Filter MCP tools based on mode restrictions.
const mcpTools = getMcpServerTools(mcpHub)
const filteredMcpTools = filterMcpToolsForMode(mcpTools, mode, customModes, experiments)

return [...filteredNativeTools, ...filteredMcpTools]
// Add custom tools if they are available.
await customToolRegistry.loadFromDirectoryIfStale(path.join(cwd, ".roo", "tools"))
const customTools = customToolRegistry.getAllSerialized()
let nativeCustomTools: OpenAI.Chat.ChatCompletionFunctionTool[] = []

if (customTools.length > 0) {
nativeCustomTools = customTools.map(formatNative)
}

return [...filteredNativeTools, ...filteredMcpTools, ...nativeCustomTools]
}
11 changes: 11 additions & 0 deletions src/core/tools/validateToolUse.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ToolName, ModeConfig, ExperimentId, GroupOptions, GroupEntry } from "@roo-code/types"
import { toolNames as validToolNames } from "@roo-code/types"
import { customToolRegistry } from "@roo-code/core"

import { type Mode, FileRestrictionError, getModeBySlug, getGroupName } from "../../shared/modes"
import { EXPERIMENT_IDS } from "../../shared/experiments"
Expand All @@ -16,6 +17,10 @@ export function isValidToolName(toolName: string): toolName is ToolName {
return true
}

if (customToolRegistry.has(toolName)) {
return true
}

// Check if it's a dynamic MCP tool (mcp_serverName_toolName format).
if (toolName.startsWith("mcp_")) {
return true
Expand Down Expand Up @@ -87,6 +92,12 @@ export function isToolAllowedForMode(
return true
}

// For now, allow all custom tools in any mode.
// As a follow-up we should expand the custom tool definition to include mode restrictions.
if (customToolRegistry.has(tool)) {
return true
}

// Check if this is a dynamic MCP tool (mcp_serverName_toolName)
// These should be allowed if the mcp group is allowed for the mode
const isDynamicMcpTool = tool.startsWith("mcp_")
Expand Down
4 changes: 2 additions & 2 deletions src/esbuild.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ async function main() {
const production = process.argv.includes("--production")
const watch = process.argv.includes("--watch")
const minify = production
const sourcemap = true // Always generate source maps for error handling
const sourcemap = true // Always generate source maps for error handling.

/**
* @type {import('esbuild').BuildOptions}
Expand Down Expand Up @@ -100,7 +100,7 @@ async function main() {
plugins,
entryPoints: ["extension.ts"],
outfile: "dist/extension.js",
external: ["vscode"],
external: ["vscode", "esbuild"],
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@
"@roo-code/telemetry": "workspace:^",
"@roo-code/types": "workspace:^",
"@vscode/codicons": "^0.0.36",
"esbuild": "^0.25.0",
"async-mutex": "^0.5.0",
"axios": "^1.12.0",
"cheerio": "^1.0.0",
Expand Down Expand Up @@ -535,7 +536,6 @@
"@types/vscode": "^1.84.0",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "3.3.2",
"esbuild": "^0.25.0",
"execa": "^9.5.2",
"glob": "^11.1.0",
"mkdirp": "^3.0.1",
Expand Down
34 changes: 26 additions & 8 deletions src/utils/__tests__/autoImportSettings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,33 @@ vi.mock("fs/promises", () => ({
readFile: vi.fn(),
}))

vi.mock("path", () => ({
join: vi.fn((...args: string[]) => args.join("/")),
isAbsolute: vi.fn((p: string) => p.startsWith("/")),
basename: vi.fn((p: string) => p.split("/").pop() || ""),
}))
vi.mock("path", async (importOriginal) => {
const actual = await importOriginal<typeof import("path")>()
return {
...actual,
default: {
...actual,
join: vi.fn((...args: string[]) => args.join("/")),
isAbsolute: vi.fn((p: string) => p.startsWith("/")),
basename: vi.fn((p: string) => p.split("/").pop() || ""),
},
join: vi.fn((...args: string[]) => args.join("/")),
isAbsolute: vi.fn((p: string) => p.startsWith("/")),
basename: vi.fn((p: string) => p.split("/").pop() || ""),
}
})

vi.mock("os", () => ({
homedir: vi.fn(() => "/home/user"),
}))
vi.mock("os", async (importOriginal) => {
const actual = await importOriginal<typeof import("os")>()
return {
...actual,
default: {
...actual,
homedir: vi.fn(() => "/home/user"),
},
homedir: vi.fn(() => "/home/user"),
}
})

vi.mock("../fs", () => ({
fileExistsAtPath: vi.fn(),
Expand Down