diff --git a/ToolExtensions.md b/ToolExtensions.md new file mode 100644 index 0000000000..f2c0be8302 --- /dev/null +++ b/ToolExtensions.md @@ -0,0 +1,68 @@ +## Extension Tool API + +Roo now provides an API for VSCode extensions to register custom tools that can be used by Roo. This allows other extensions to extend Roo's capabilities without modifying the Roo codebase. + +### Using the Extension Tool API + +To use the Extension Tool API, your extension needs to: + +1. Add `RooVeterinaryInc.roo-cline` as an extension dependency in your `package.json`. +2. Get the Roo extension API and access the `extensionTools` property. +3. Register your tools using the `registerTool` method. + +Here's a simple example: + +```typescript +// Access the Roo extension +const rooExtension = vscode.extensions.getExtension("RooVeterinaryInc.roo-cline") + +if (rooExtension && rooExtension.exports.extensionTools) { + // Register a tool + rooExtension.exports.extensionTools.registerTool(context.extension.id, { + name: "my_tool", + description: "Description of what the tool does", + inputSchema: { + // Optional JSON schema for tool arguments + type: "object", + properties: { + myArg: { + type: "string", + description: "Description of the argument", + }, + }, + }, + execute: async (args) => { + // Implement your tool functionality + return { + content: [ + { + type: "text", + text: "Result of the tool execution", + }, + ], + } + }, + }) +} +``` + +### Tool Response Format + +Tools return responses in the same format as MCP tools: + +```typescript +{ + content: [ + { + type: 'text', + text: 'Text content' + } + // Can also include resources + ], + isError?: boolean // Optional flag to indicate if the tool execution failed +} +``` + +### Example Extension + +See the [Roo-NB](https://github.com/RooVeterinaryInc/Roo-NB) extension for a complete example of a tool provider extension. diff --git a/evals/packages/types/src/roo-code.ts b/evals/packages/types/src/roo-code.ts index b397d37b64..ba28e6f282 100644 --- a/evals/packages/types/src/roo-code.ts +++ b/evals/packages/types/src/roo-code.ts @@ -743,6 +743,7 @@ export const globalSettingsSchema = z.object({ alwaysApproveResubmit: z.boolean().optional(), requestDelaySeconds: z.number().optional(), alwaysAllowMcp: z.boolean().optional(), + alwaysAllowExtTools: z.boolean().optional(), alwaysAllowModeSwitch: z.boolean().optional(), alwaysAllowSubtasks: z.boolean().optional(), alwaysAllowExecute: z.boolean().optional(), @@ -822,6 +823,7 @@ const globalSettingsRecord: GlobalSettingsRecord = { alwaysApproveResubmit: undefined, requestDelaySeconds: undefined, alwaysAllowMcp: undefined, + alwaysAllowExtTools: undefined, alwaysAllowModeSwitch: undefined, alwaysAllowSubtasks: undefined, alwaysAllowExecute: undefined, diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 6d37063457..37bce44043 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -26,6 +26,7 @@ import { askFollowupQuestionTool } from "../tools/askFollowupQuestionTool" import { switchModeTool } from "../tools/switchModeTool" import { attemptCompletionTool } from "../tools/attemptCompletionTool" import { newTaskTool } from "../tools/newTaskTool" +import { useExtToolTool } from "../tools/useExtToolTool" import { checkpointSave } from "../checkpoints" @@ -180,6 +181,8 @@ export async function presentAssistantMessage(cline: Task) { return `[${block.name} for '${block.params.server_name}']` case "access_mcp_resource": return `[${block.name} for '${block.params.server_name}']` + case "use_ext_tool": + return `[${block.name} for '${block.params.extension_id}']` case "ask_followup_question": return `[${block.name} for '${block.params.question}']` case "attempt_completion": @@ -440,6 +443,9 @@ export async function presentAssistantMessage(cline: Task) { removeClosingTag, ) break + case "use_ext_tool": + await useExtToolTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) + break case "ask_followup_question": await askFollowupQuestionTool( cline, diff --git a/src/core/prompts/__tests__/custom-system-prompt.test.ts b/src/core/prompts/__tests__/custom-system-prompt.test.ts index 977ab051a0..6c0d88810c 100644 --- a/src/core/prompts/__tests__/custom-system-prompt.test.ts +++ b/src/core/prompts/__tests__/custom-system-prompt.test.ts @@ -67,6 +67,7 @@ describe("File-Based Custom System Prompt", () => { "test/path", // Using a relative path without leading slash false, // supportsComputerUse undefined, // mcpHub + undefined, // extensionToolManager undefined, // diffStrategy undefined, // browserViewportSize defaultModeSlug, // mode @@ -101,6 +102,7 @@ describe("File-Based Custom System Prompt", () => { "test/path", // Using a relative path without leading slash false, // supportsComputerUse undefined, // mcpHub + undefined, // extensionToolManager undefined, // diffStrategy undefined, // browserViewportSize defaultModeSlug, // mode @@ -144,6 +146,7 @@ describe("File-Based Custom System Prompt", () => { "test/path", // Using a relative path without leading slash false, // supportsComputerUse undefined, // mcpHub + undefined, // extensionToolManager undefined, // diffStrategy undefined, // browserViewportSize defaultModeSlug, // mode diff --git a/src/core/prompts/__tests__/system.test.ts b/src/core/prompts/__tests__/system.test.ts index 3647d2d859..e91b96f20c 100644 --- a/src/core/prompts/__tests__/system.test.ts +++ b/src/core/prompts/__tests__/system.test.ts @@ -202,6 +202,7 @@ describe("SYSTEM_PROMPT", () => { "/test/path", false, // supportsComputerUse undefined, // mcpHub + undefined, // extensionToolManager undefined, // diffStrategy undefined, // browserViewportSize defaultModeSlug, // mode @@ -222,6 +223,7 @@ describe("SYSTEM_PROMPT", () => { "/test/path", true, // supportsComputerUse undefined, // mcpHub + undefined, // extensionToolManager undefined, // diffStrategy "1280x800", // browserViewportSize defaultModeSlug, // mode @@ -244,6 +246,7 @@ describe("SYSTEM_PROMPT", () => { "/test/path", false, // supportsComputerUse mockMcpHub, // mcpHub + undefined, // extensionToolManager undefined, // diffStrategy undefined, // browserViewportSize defaultModeSlug, // mode @@ -264,6 +267,7 @@ describe("SYSTEM_PROMPT", () => { "/test/path", false, // supportsComputerUse undefined, // explicitly undefined mcpHub + undefined, // extensionToolManager undefined, // diffStrategy undefined, // browserViewportSize defaultModeSlug, // mode @@ -284,6 +288,7 @@ describe("SYSTEM_PROMPT", () => { "/test/path", true, // supportsComputerUse undefined, // mcpHub + undefined, // extensionToolManager undefined, // diffStrategy "900x600", // different viewport size defaultModeSlug, // mode @@ -304,6 +309,7 @@ describe("SYSTEM_PROMPT", () => { "/test/path", false, // supportsComputerUse undefined, // mcpHub + undefined, // extensionToolManager new MultiSearchReplaceDiffStrategy(), // Use actual diff strategy from the codebase undefined, // browserViewportSize defaultModeSlug, // mode @@ -325,6 +331,7 @@ describe("SYSTEM_PROMPT", () => { "/test/path", false, // supportsComputerUse undefined, // mcpHub + undefined, // extensionToolManager new MultiSearchReplaceDiffStrategy(), // Use actual diff strategy from the codebase undefined, // browserViewportSize defaultModeSlug, // mode @@ -346,6 +353,7 @@ describe("SYSTEM_PROMPT", () => { "/test/path", false, // supportsComputerUse undefined, // mcpHub + undefined, // extensionToolManager new MultiSearchReplaceDiffStrategy(), // Use actual diff strategy from the codebase undefined, // browserViewportSize defaultModeSlug, // mode @@ -394,6 +402,7 @@ describe("SYSTEM_PROMPT", () => { "/test/path", false, // supportsComputerUse undefined, // mcpHub + undefined, // extensionToolManager undefined, // diffStrategy undefined, // browserViewportSize defaultModeSlug, // mode @@ -452,6 +461,7 @@ describe("SYSTEM_PROMPT", () => { "/test/path", false, // supportsComputerUse undefined, // mcpHub + undefined, // extensionToolManager undefined, // diffStrategy undefined, // browserViewportSize "custom-mode", // mode @@ -487,6 +497,7 @@ describe("SYSTEM_PROMPT", () => { "/test/path", false, // supportsComputerUse undefined, // mcpHub + undefined, // extensionToolManager undefined, // diffStrategy undefined, // browserViewportSize defaultModeSlug as Mode, // mode @@ -517,6 +528,7 @@ describe("SYSTEM_PROMPT", () => { "/test/path", false, // supportsComputerUse undefined, // mcpHub + undefined, // extensionToolManager undefined, // diffStrategy undefined, // browserViewportSize defaultModeSlug as Mode, // mode @@ -561,6 +573,7 @@ describe("addCustomInstructions", () => { "/test/path", false, // supportsComputerUse undefined, // mcpHub + undefined, // extensionToolManager undefined, // diffStrategy undefined, // browserViewportSize "architect", // mode @@ -581,6 +594,7 @@ describe("addCustomInstructions", () => { "/test/path", false, // supportsComputerUse undefined, // mcpHub + undefined, // extensionToolManager undefined, // diffStrategy undefined, // browserViewportSize "ask", // mode @@ -603,6 +617,7 @@ describe("addCustomInstructions", () => { "/test/path", false, // supportsComputerUse mockMcpHub, // mcpHub + undefined, // extensionToolManager undefined, // diffStrategy undefined, // browserViewportSize defaultModeSlug, // mode @@ -626,6 +641,7 @@ describe("addCustomInstructions", () => { "/test/path", false, // supportsComputerUse mockMcpHub, // mcpHub + undefined, // extensionToolManager undefined, // diffStrategy undefined, // browserViewportSize defaultModeSlug, // mode diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index dc6e08c8a0..f1c566bf1b 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -71,6 +71,9 @@ Otherwise, if you have not completed the task and do not need additional informa invalidMcpToolArgumentError: (serverName: string, toolName: string) => `Invalid JSON argument used with ${serverName} for ${toolName}. Please retry with a properly formatted JSON argument.`, + invalidExtToolArgumentError: (extensionId: string, toolName: string) => + `Invalid JSON argument used with ${extensionId} for ${toolName}. Please retry with a properly formatted JSON argument.`, + toolResult: ( text: string, images?: string[], diff --git a/src/core/prompts/sections/ext-tools.ts b/src/core/prompts/sections/ext-tools.ts new file mode 100644 index 0000000000..c3e53e4914 --- /dev/null +++ b/src/core/prompts/sections/ext-tools.ts @@ -0,0 +1,48 @@ +import { ExtensionToolManager } from "../../../services/extensions/ExtensionToolManager" + +/** + * Generates the section of the system prompt that describes available extension tools + */ +export async function getExtToolsSection(extensionToolManager?: ExtensionToolManager): Promise { + // If no manager is provided, get the singleton instance + if (!extensionToolManager) { + try { + extensionToolManager = await ExtensionToolManager.getInstance() + } catch (error) { + console.error("Failed to get ExtensionToolManager:", error) + return "" + } + } + + // Get all registered tools + const allTools = extensionToolManager.getAllTools() + + if (allTools.length === 0) { + return "" + } + + // Group tools by extension + const extensionTools: Record = {} + + for (const { extensionId, tool } of allTools) { + if (!extensionTools[extensionId]) { + extensionTools[extensionId] = [] + } + + extensionTools[extensionId].push(`${tool.name}: ${tool.description}`) + } + + let result = "# Available Extension Tools\n\n" + + for (const [extensionId, tools] of Object.entries(extensionTools)) { + result += `## Extension: ${extensionId}\n\n` + + for (const toolDesc of tools) { + result += `- ${toolDesc}\n` + } + + result += "\n" + } + + return result +} diff --git a/src/core/prompts/sections/index.ts b/src/core/prompts/sections/index.ts index d06dbbfde1..2c490079f5 100644 --- a/src/core/prompts/sections/index.ts +++ b/src/core/prompts/sections/index.ts @@ -4,6 +4,7 @@ export { getObjectiveSection } from "./objective" export { addCustomInstructions } from "./custom-instructions" export { getSharedToolUseSection } from "./tool-use" export { getMcpServersSection } from "./mcp-servers" +export { getExtToolsSection } from "./ext-tools" export { getToolUseGuidelinesSection } from "./tool-use-guidelines" export { getCapabilitiesSection } from "./capabilities" export { getModesSection } from "./modes" diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 96221ae91f..bd6e1f9afd 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -20,6 +20,7 @@ import { getObjectiveSection, getSharedToolUseSection, getMcpServersSection, + getExtToolsSection, getToolUseGuidelinesSection, getCapabilitiesSection, getModesSection, @@ -28,6 +29,7 @@ import { } from "./sections" import { formatLanguage } from "../../shared/language" import { CodeIndexManager } from "../../services/code-index/manager" +import { ExtensionToolManager } from "../../exports/extensionToolApi" async function generatePrompt( context: vscode.ExtensionContext, @@ -35,6 +37,7 @@ async function generatePrompt( supportsComputerUse: boolean, mode: Mode, mcpHub?: McpHub, + extensionToolManager?: ExtensionToolManager, diffStrategy?: DiffStrategy, browserViewportSize?: string, promptComponent?: PromptComponent, @@ -57,11 +60,14 @@ async function generatePrompt( const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0] const roleDefinition = promptComponent?.roleDefinition || modeConfig.roleDefinition - const [modesSection, mcpServersSection] = await Promise.all([ + const [modesSection, mcpServersSection, extToolsSection] = await Promise.all([ getModesSection(context), modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp") ? getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation) : Promise.resolve(""), + modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "ext") + ? getExtToolsSection(extensionToolManager) + : Promise.resolve(""), ]) const codeIndexManager = CodeIndexManager.getInstance(context) @@ -80,6 +86,7 @@ ${getToolDescriptionsForMode( effectiveDiffStrategy, browserViewportSize, mcpHub, + extensionToolManager, customModeConfigs, experiments, )} @@ -88,6 +95,8 @@ ${getToolUseGuidelinesSection()} ${mcpServersSection} +${extToolsSection} + ${getCapabilitiesSection(cwd, supportsComputerUse, mcpHub, effectiveDiffStrategy, codeIndexManager)} ${modesSection} @@ -108,6 +117,7 @@ export const SYSTEM_PROMPT = async ( cwd: string, supportsComputerUse: boolean, mcpHub?: McpHub, + extensionToolManager?: ExtensionToolManager, diffStrategy?: DiffStrategy, browserViewportSize?: string, mode: Mode = defaultModeSlug, @@ -175,6 +185,7 @@ ${customInstructions}` supportsComputerUse, currentMode.slug, mcpHub, + extensionToolManager, effectiveDiffStrategy, browserViewportSize, promptComponent, diff --git a/src/core/prompts/tools/index.ts b/src/core/prompts/tools/index.ts index 4b3f796919..fb3a446a6c 100644 --- a/src/core/prompts/tools/index.ts +++ b/src/core/prompts/tools/index.ts @@ -1,6 +1,7 @@ import { ToolName } from "../../../schemas" import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, DiffStrategy } from "../../../shared/tools" import { McpHub } from "../../../services/mcp/McpHub" +import { ExtensionToolManager } from "../../../services/extensions/ExtensionToolManager" import { Mode, ModeConfig, getModeConfig, isToolAllowedForMode, getGroupName } from "../../../shared/modes" import { ToolArgs } from "./types" @@ -17,6 +18,7 @@ import { getBrowserActionDescription } from "./browser-action" import { getAskFollowupQuestionDescription } from "./ask-followup-question" import { getAttemptCompletionDescription } from "./attempt-completion" import { getUseMcpToolDescription } from "./use-mcp-tool" +import { getUseExtToolDescription } from "./use-ext-tool" import { getAccessMcpResourceDescription } from "./access-mcp-resource" import { getSwitchModeDescription } from "./switch-mode" import { getNewTaskDescription } from "./new-task" @@ -36,6 +38,7 @@ const toolDescriptionMap: Record string | undefined> ask_followup_question: () => getAskFollowupQuestionDescription(), attempt_completion: () => getAttemptCompletionDescription(), use_mcp_tool: (args) => getUseMcpToolDescription(args), + use_ext_tool: (args) => getUseExtToolDescription(args), access_mcp_resource: (args) => getAccessMcpResourceDescription(args), codebase_search: () => getCodebaseSearchDescription(), switch_mode: () => getSwitchModeDescription(), @@ -54,6 +57,7 @@ export function getToolDescriptionsForMode( diffStrategy?: DiffStrategy, browserViewportSize?: string, mcpHub?: McpHub, + extensionToolManager?: ExtensionToolManager, customModes?: ModeConfig[], experiments?: Record, ): string { @@ -64,6 +68,7 @@ export function getToolDescriptionsForMode( diffStrategy, browserViewportSize, mcpHub, + extensionToolManager, } const tools = new Set() @@ -130,6 +135,7 @@ export { getAskFollowupQuestionDescription, getAttemptCompletionDescription, getUseMcpToolDescription, + getUseExtToolDescription, getAccessMcpResourceDescription, getSwitchModeDescription, getInsertContentDescription, diff --git a/src/core/prompts/tools/types.ts b/src/core/prompts/tools/types.ts index f2b890abdf..edd60a7d0b 100644 --- a/src/core/prompts/tools/types.ts +++ b/src/core/prompts/tools/types.ts @@ -1,5 +1,6 @@ import { DiffStrategy } from "../../../shared/tools" import { McpHub } from "../../../services/mcp/McpHub" +import { ExtensionToolManager } from "../../../services/extensions/ExtensionToolManager" export type ToolArgs = { cwd: string @@ -7,5 +8,6 @@ export type ToolArgs = { diffStrategy?: DiffStrategy browserViewportSize?: string mcpHub?: McpHub + extensionToolManager?: ExtensionToolManager toolOptions?: any } diff --git a/src/core/prompts/tools/use-ext-tool.ts b/src/core/prompts/tools/use-ext-tool.ts new file mode 100644 index 0000000000..4ff5a53d24 --- /dev/null +++ b/src/core/prompts/tools/use-ext-tool.ts @@ -0,0 +1,35 @@ +import { ToolArgs } from "./types" + +export function getUseExtToolDescription(args: ToolArgs): string | undefined { + if (!args.extensionToolManager || args.extensionToolManager.getAllTools().length === 0) { + return undefined + } + + return `## use_ext_tool +Description: Request to use a tool provided by a VSCode extension. Extensions can register tools that provide special capabilities. Each tool has defined input parameters that may be required or optional. +Parameters: +- extension_id: (required) The ID of the extension providing the tool +- tool_name: (required) The name of the tool to execute +- arguments: (required) A JSON object containing the tool's input parameters, following the tool's specifications +Usage: + +extension id here +tool name here + +{ + "param1": "value1", + "param2": "value2" +} + + + +Example: Requesting to use an extension tool + + +RooVeterinaryInc.roo-nb +get_notebook_info + +{} + +` +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 231a6049ad..98baa3b9aa 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1476,6 +1476,7 @@ export class Task extends EventEmitter { this.cwd, (this.api.getModel().info.supportsComputerUse ?? false) && (browserToolEnabled ?? true), mcpHub, + provider.getExtensionToolManager(), this.diffStrategy, browserViewportSize, mode, diff --git a/src/core/tools/useExtToolTool.ts b/src/core/tools/useExtToolTool.ts new file mode 100644 index 0000000000..51562f36dd --- /dev/null +++ b/src/core/tools/useExtToolTool.ts @@ -0,0 +1,119 @@ +import { Task } from "../task/Task" +import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" +import { formatResponse } from "../prompts/responses" +import { ClineAskUseExtTool } from "../../shared/ExtensionMessage" + +export async function useExtToolTool( + cline: Task, + block: ToolUse, + askApproval: AskApproval, + handleError: HandleError, + pushToolResult: PushToolResult, + removeClosingTag: RemoveClosingTag, +) { + const extension_id: string | undefined = block.params.extension_id + const tool_name: string | undefined = block.params.tool_name + const tool_arguments: string | undefined = block.params.arguments + + try { + if (block.partial) { + const partialMessage = JSON.stringify({ + type: "use_ext_tool", + extensionId: removeClosingTag("extension_id", extension_id), + toolName: removeClosingTag("tool_name", tool_name), + arguments: removeClosingTag("arguments", tool_arguments), + } satisfies ClineAskUseExtTool) + + await cline.ask("use_ext_tool", partialMessage, block.partial).catch(() => {}) + return + } else { + if (!extension_id) { + cline.consecutiveMistakeCount++ + cline.recordToolError("use_ext_tool") + pushToolResult(await cline.sayAndCreateMissingParamError("use_ext_tool", "extension_id")) + return + } + + if (!tool_name) { + cline.consecutiveMistakeCount++ + cline.recordToolError("use_ext_tool") + pushToolResult(await cline.sayAndCreateMissingParamError("use_ext_tool", "tool_name")) + return + } + + let parsedArguments: Record | undefined + + if (tool_arguments) { + try { + parsedArguments = JSON.parse(tool_arguments) + } catch (error) { + cline.consecutiveMistakeCount++ + cline.recordToolError("use_ext_tool") + await cline.say("error", `Roo tried to use ${tool_name} with an invalid JSON argument. Retrying...`) + + pushToolResult( + formatResponse.toolError(formatResponse.invalidExtToolArgumentError(extension_id, tool_name)), + ) + + return + } + } + + cline.consecutiveMistakeCount = 0 + + const completeMessage = JSON.stringify({ + type: "use_ext_tool", + extensionId: extension_id, + toolName: tool_name, + arguments: tool_arguments, + } satisfies ClineAskUseExtTool) + + const didApprove = await askApproval("use_ext_tool", completeMessage) + + if (!didApprove) { + return + } + + // Get the extension tool manager from the provider reference + const extensionToolManager = await cline.providerRef.deref()?.getExtensionToolManagerAsync() + + // Check if the tool exists + if (!extensionToolManager || !extensionToolManager.isToolRegistered(extension_id, tool_name)) { + pushToolResult( + formatResponse.toolError(`Extension tool '${tool_name}' not found for extension '${extension_id}'`), + ) + return + } + + // Now execute the tool - important to call say before the actual execution + await cline.say("extension_tool_request_started") + + const toolResult = await extensionToolManager.executeExtensionTool(extension_id, tool_name, parsedArguments) + + // Format the result + const toolResultPretty = + (toolResult?.isError ? "Error:\n" : "") + + toolResult?.content + .map((item) => { + if (item.type === "text") { + return item.text + } + if (item.type === "resource" && "resource" in item) { + const { blob: _, ...rest } = item.resource + return JSON.stringify(rest, null, 2) + } + return "" + }) + .filter(Boolean) + .join("\n\n") || "(No response)" + + await cline.say("extension_tool_response", toolResultPretty) + pushToolResult(formatResponse.toolResult(toolResultPretty)) + + return + } + } catch (error) { + await handleError("executing extension tool", error) + return + } +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 43f56b8fa5..34e3a80bc5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -38,6 +38,7 @@ import { getTheme } from "../../integrations/theme/getTheme" import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker" import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" +import { ExtensionToolManager } from "../../services/extensions/ExtensionToolManager" import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService" import { CodeIndexManager } from "../../services/code-index/manager" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" @@ -82,6 +83,7 @@ export class ClineProvider extends EventEmitter implements return this._workspaceTracker } protected mcpHub?: McpHub // Change from private to protected + protected extensionToolManager?: ExtensionToolManager // Manage extension tools similar to mcpHub public isViewLaunched = false public settingsImportedAt?: number @@ -128,6 +130,15 @@ export class ClineProvider extends EventEmitter implements .catch((error) => { this.log(`Failed to initialize MCP Hub: ${error}`) }) + + // Initialize ExtensionToolManager from the singleton + ExtensionToolManager.getInstance(this.context, this) + .then((manager) => { + this.extensionToolManager = manager + }) + .catch((error) => { + this.log(`Failed to initialize ExtensionToolManager: ${error}`) + }) } // Adds a new Cline instance to clineStack, marking the start of a new task. @@ -1211,6 +1222,7 @@ export class ClineProvider extends EventEmitter implements alwaysAllowExecute, alwaysAllowBrowser, alwaysAllowMcp, + alwaysAllowExtTools, alwaysAllowModeSwitch, alwaysAllowSubtasks, allowedMaxRequests, @@ -1287,6 +1299,7 @@ export class ClineProvider extends EventEmitter implements alwaysAllowExecute: alwaysAllowExecute ?? false, alwaysAllowBrowser: alwaysAllowBrowser ?? false, alwaysAllowMcp: alwaysAllowMcp ?? false, + alwaysAllowExtTools: alwaysAllowExtTools ?? false, alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: alwaysAllowSubtasks ?? false, allowedMaxRequests, @@ -1401,6 +1414,7 @@ export class ClineProvider extends EventEmitter implements alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, alwaysAllowBrowser: stateValues.alwaysAllowBrowser ?? false, alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, + alwaysAllowExtTools: stateValues.alwaysAllowExtTools ?? false, alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false, allowedMaxRequests: stateValues.allowedMaxRequests, @@ -1560,6 +1574,28 @@ export class ClineProvider extends EventEmitter implements return this.mcpHub } + // Get the extension tool manager instance + public getExtensionToolManager(): ExtensionToolManager { + // If we already have an instance, return it + if (this.extensionToolManager) { + return this.extensionToolManager + } + + // This is not ideal, but needed for backwards compatibility until all code is updated to use async + throw new Error("ExtensionToolManager not initialized yet. Wait for initialization to complete.") + } + + // Async version of getExtensionToolManager that waits for initialization if needed + public async getExtensionToolManagerAsync(): Promise { + if (this.extensionToolManager) { + return this.extensionToolManager + } + + // If not initialized yet, initialize it + this.extensionToolManager = await ExtensionToolManager.getInstance(this.context, this) + return this.extensionToolManager + } + /** * Returns properties to be included in every telemetry event * This method is called by the telemetry service to get context information diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index f676fa18f6..df0e754ed7 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -56,6 +56,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web cwd, canUseBrowserTool, mcpEnabled ? provider.getMcpHub() : undefined, + provider.getExtensionToolManager(), diffStrategy, browserViewportSize ?? "900x600", mode, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c8fd3608e4..c26d49cc2e 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -156,6 +156,10 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We await updateGlobalState("alwaysAllowMcp", message.bool) await provider.postStateToWebview() break + case "alwaysAllowExtTools": + await updateGlobalState("alwaysAllowExtTools", message.bool) + await provider.postStateToWebview() + break case "alwaysAllowModeSwitch": await updateGlobalState("alwaysAllowModeSwitch", message.bool) await provider.postStateToWebview() @@ -523,6 +527,24 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We } break } + case "toggleExtToolAlwaysAllow": { + try { + // Toggle the global alwaysAllowExtTools setting + const currentValue = getGlobalState("alwaysAllowExtTools") ?? false + await updateGlobalState("alwaysAllowExtTools", !currentValue) + + // Log the action for debugging + provider.log(`Extension tools auto-approval toggled to ${!currentValue}`) + + // Update the UI state + await provider.postStateToWebview() + } catch (error) { + provider.log( + `Failed to toggle auto-approve for extension tools: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + } + break + } case "toggleMcpServer": { try { await provider diff --git a/src/exports/api.ts b/src/exports/api.ts index 17728f3019..963bf912f5 100644 --- a/src/exports/api.ts +++ b/src/exports/api.ts @@ -24,7 +24,18 @@ import { RooCodeAPI } from "./interface" import { IpcServer } from "./ipc" import { outputChannelLog } from "./log" +import { ExtensionToolManager } from "./extensionToolApi" + export class API extends EventEmitter implements RooCodeAPI { + private _extensionTools?: ExtensionToolManager + + public get extensionTools(): ExtensionToolManager { + if (!this._extensionTools) { + throw new Error("ExtensionToolManager not initialized yet") + } + return this._extensionTools + } + private readonly outputChannel: vscode.OutputChannel private readonly sidebarProvider: ClineProvider private readonly context: vscode.ExtensionContext @@ -41,6 +52,16 @@ export class API extends EventEmitter implements RooCodeAPI { ) { super() + // Get the extension tools from the provider instead of initializing directly + provider + .getExtensionToolManagerAsync() + .then((manager) => { + this._extensionTools = manager + }) + .catch((error) => { + console.error("Failed to get ExtensionToolManager from provider:", error) + }) + this.outputChannel = outputChannel this.sidebarProvider = provider this.context = provider.context diff --git a/src/exports/extensionToolApi.ts b/src/exports/extensionToolApi.ts new file mode 100644 index 0000000000..6d280f3a65 --- /dev/null +++ b/src/exports/extensionToolApi.ts @@ -0,0 +1,2 @@ +// Export ExtensionToolManager from the services directory +export { ExtensionToolManager } from "../services/extensions/ExtensionToolManager" diff --git a/src/exports/extensionTools.ts b/src/exports/extensionTools.ts new file mode 100644 index 0000000000..cca3c19047 --- /dev/null +++ b/src/exports/extensionTools.ts @@ -0,0 +1,108 @@ +import { McpToolCallResponse } from "../shared/mcp" +import { EventEmitter } from "node:events" +import { z } from "zod" + +/** + * Interface for extension-provided tool + */ +export interface ExtensionTool { + /** + * The name of the tool that will be used in prompt and code + */ + name: string + + /** + * Description of what the tool does for LLM and user information + */ + description: string + + /** + * Optional JSON schema for tool arguments + */ + inputSchema?: object + + /** + * Method that will be called when the tool is executed + * @param args Arguments passed to the tool + * @returns Response in the same format as MCP tool responses + */ + execute(args?: Record): Promise +} + +/** + * Response format for extension tools + * Uses the same format as MCP tool responses for consistency + */ +export type ExtensionToolResponse = McpToolCallResponse + +/** + * Events emitted by the Extension Tool API + */ +export enum ExtensionToolEventName { + /** + * Emitted when a tool is registered + */ + ToolRegistered = "toolRegistered", + + /** + * Emitted when a tool is unregistered + */ + ToolUnregistered = "toolUnregistered", +} + +/** + * Event types for Extension Tool API + */ +export type ExtensionToolEvents = { + [ExtensionToolEventName.ToolRegistered]: [extensionId: string, toolName: string] + [ExtensionToolEventName.ToolUnregistered]: [extensionId: string, toolName: string] +} + +/** + * API for extensions to register and manage tools + */ +export interface ExtensionToolAPI extends EventEmitter { + /** + * Register a tool from an extension with type-safe arguments using Zod + * @param extensionId ID of the VSCode extension registering the tool + * @param options Tool configuration with name, description, and optional Zod schema + * @param fn Function that implements the tool with typed arguments + */ + registerTool( + extensionId: string, + options: { + name: string + description: string + inputSchema?: T + }, + fn: (args: z.infer) => Promise, + ): void + + /** + * Unregister a tool + * @param extensionId ID of the VSCode extension that registered the tool + * @param toolName Name of the tool to unregister + */ + unregisterTool(extensionId: string, toolName: string): void + + /** + * Unregister all tools from an extension + * @param extensionId ID of the VSCode extension + */ + unregisterAllTools(extensionId: string): void + + /** + * Get all tools registered by an extension + * @param extensionId ID of the VSCode extension + * @returns Array of tool names registered by the extension + */ + getRegisteredTools(extensionId: string): string[] + + /** + * Check if a tool is registered + * @param extensionId ID of the VSCode extension + * @param toolName Name of the tool + * @returns True if the tool is registered, false otherwise + */ + isToolRegistered(extensionId: string, toolName: string): boolean +} diff --git a/src/exports/index.ts b/src/exports/index.ts new file mode 100644 index 0000000000..54e68149e8 --- /dev/null +++ b/src/exports/index.ts @@ -0,0 +1,3 @@ +// Add export for extension tool API +export * from "./extensionTools" +export { ExtensionToolManager } from "./extensionToolApi" diff --git a/src/exports/roo-code.d.ts b/src/exports/roo-code.d.ts index 904eba8530..36c1bd148f 100644 --- a/src/exports/roo-code.d.ts +++ b/src/exports/roo-code.d.ts @@ -95,6 +95,7 @@ type GlobalSettings = { alwaysApproveResubmit?: boolean | undefined requestDelaySeconds?: number | undefined alwaysAllowMcp?: boolean | undefined + alwaysAllowExtTools?: boolean | undefined alwaysAllowModeSwitch?: boolean | undefined alwaysAllowSubtasks?: boolean | undefined alwaysAllowExecute?: boolean | undefined @@ -173,9 +174,9 @@ type GlobalSettings = { whenToUse?: string | undefined customInstructions?: string | undefined groups: ( - | ("read" | "edit" | "browser" | "command" | "mcp" | "modes") + | ("read" | "edit" | "browser" | "command" | "mcp" | "ext" | "modes") | [ - "read" | "edit" | "browser" | "command" | "mcp" | "modes", + "read" | "edit" | "browser" | "command" | "mcp" | "ext" | "modes", { fileRegex?: string | undefined description?: string | undefined @@ -416,6 +417,7 @@ type ClineMessage = { | "mistake_limit_reached" | "browser_action_launch" | "use_mcp_server" + | "use_ext_tool" | "auto_approval_max_req_reached" ) | undefined @@ -438,6 +440,8 @@ type ClineMessage = { | "browser_action_result" | "mcp_server_request_started" | "mcp_server_response" + | "extension_tool_request_started" + | "extension_tool_response" | "subtask_result" | "checkpoint_saved" | "rooignore_error" @@ -502,6 +506,7 @@ type RooCodeEvents = { | "mistake_limit_reached" | "browser_action_launch" | "use_mcp_server" + | "use_ext_tool" | "auto_approval_max_req_reached" ) | undefined @@ -524,6 +529,8 @@ type RooCodeEvents = { | "browser_action_result" | "mcp_server_request_started" | "mcp_server_response" + | "extension_tool_request_started" + | "extension_tool_response" | "subtask_result" | "checkpoint_saved" | "rooignore_error" @@ -609,6 +616,7 @@ type RooCodeEvents = { | "list_code_definition_names" | "browser_action" | "use_mcp_tool" + | "use_ext_tool" | "access_mcp_resource" | "ask_followup_question" | "attempt_completion" @@ -874,6 +882,7 @@ type IpcMessage = alwaysApproveResubmit?: boolean | undefined requestDelaySeconds?: number | undefined alwaysAllowMcp?: boolean | undefined + alwaysAllowExtTools?: boolean | undefined alwaysAllowModeSwitch?: boolean | undefined alwaysAllowSubtasks?: boolean | undefined alwaysAllowExecute?: boolean | undefined @@ -949,9 +958,17 @@ type IpcMessage = whenToUse?: string | undefined customInstructions?: string | undefined groups: ( - | ("read" | "edit" | "browser" | "command" | "mcp" | "modes") + | ("read" | "edit" | "browser" | "command" | "mcp" | "ext" | "modes") | [ - "read" | "edit" | "browser" | "command" | "mcp" | "modes", + ( + | "read" + | "edit" + | "browser" + | "command" + | "mcp" + | "ext" + | "modes" + ), { fileRegex?: string | undefined description?: string | undefined @@ -1021,6 +1038,7 @@ type IpcMessage = | "mistake_limit_reached" | "browser_action_launch" | "use_mcp_server" + | "use_ext_tool" | "auto_approval_max_req_reached" ) | undefined @@ -1043,6 +1061,8 @@ type IpcMessage = | "browser_action_result" | "mcp_server_request_started" | "mcp_server_response" + | "extension_tool_request_started" + | "extension_tool_response" | "subtask_result" | "checkpoint_saved" | "rooignore_error" @@ -1386,6 +1406,7 @@ type TaskCommand = alwaysApproveResubmit?: boolean | undefined requestDelaySeconds?: number | undefined alwaysAllowMcp?: boolean | undefined + alwaysAllowExtTools?: boolean | undefined alwaysAllowModeSwitch?: boolean | undefined alwaysAllowSubtasks?: boolean | undefined alwaysAllowExecute?: boolean | undefined @@ -1461,9 +1482,9 @@ type TaskCommand = whenToUse?: string | undefined customInstructions?: string | undefined groups: ( - | ("read" | "edit" | "browser" | "command" | "mcp" | "modes") + | ("read" | "edit" | "browser" | "command" | "mcp" | "ext" | "modes") | [ - "read" | "edit" | "browser" | "command" | "mcp" | "modes", + "read" | "edit" | "browser" | "command" | "mcp" | "ext" | "modes", { fileRegex?: string | undefined description?: string | undefined @@ -1529,6 +1550,7 @@ type TaskEvent = | "mistake_limit_reached" | "browser_action_launch" | "use_mcp_server" + | "use_ext_tool" | "auto_approval_max_req_reached" ) | undefined @@ -1551,6 +1573,8 @@ type TaskEvent = | "browser_action_result" | "mcp_server_request_started" | "mcp_server_response" + | "extension_tool_request_started" + | "extension_tool_response" | "subtask_result" | "checkpoint_saved" | "rooignore_error" diff --git a/src/exports/types.ts b/src/exports/types.ts index 6f4989df62..e49b0dc88e 100644 --- a/src/exports/types.ts +++ b/src/exports/types.ts @@ -95,6 +95,7 @@ type GlobalSettings = { alwaysApproveResubmit?: boolean | undefined requestDelaySeconds?: number | undefined alwaysAllowMcp?: boolean | undefined + alwaysAllowExtTools?: boolean | undefined alwaysAllowModeSwitch?: boolean | undefined alwaysAllowSubtasks?: boolean | undefined alwaysAllowExecute?: boolean | undefined @@ -173,9 +174,9 @@ type GlobalSettings = { whenToUse?: string | undefined customInstructions?: string | undefined groups: ( - | ("read" | "edit" | "browser" | "command" | "mcp" | "modes") + | ("read" | "edit" | "browser" | "command" | "mcp" | "ext" | "modes") | [ - "read" | "edit" | "browser" | "command" | "mcp" | "modes", + "read" | "edit" | "browser" | "command" | "mcp" | "ext" | "modes", { fileRegex?: string | undefined description?: string | undefined @@ -424,6 +425,7 @@ type ClineMessage = { | "mistake_limit_reached" | "browser_action_launch" | "use_mcp_server" + | "use_ext_tool" | "auto_approval_max_req_reached" ) | undefined @@ -446,6 +448,8 @@ type ClineMessage = { | "browser_action_result" | "mcp_server_request_started" | "mcp_server_response" + | "extension_tool_request_started" + | "extension_tool_response" | "subtask_result" | "checkpoint_saved" | "rooignore_error" @@ -514,6 +518,7 @@ type RooCodeEvents = { | "mistake_limit_reached" | "browser_action_launch" | "use_mcp_server" + | "use_ext_tool" | "auto_approval_max_req_reached" ) | undefined @@ -536,6 +541,8 @@ type RooCodeEvents = { | "browser_action_result" | "mcp_server_request_started" | "mcp_server_response" + | "extension_tool_request_started" + | "extension_tool_response" | "subtask_result" | "checkpoint_saved" | "rooignore_error" @@ -621,6 +628,7 @@ type RooCodeEvents = { | "list_code_definition_names" | "browser_action" | "use_mcp_tool" + | "use_ext_tool" | "access_mcp_resource" | "ask_followup_question" | "attempt_completion" @@ -888,6 +896,7 @@ type IpcMessage = alwaysApproveResubmit?: boolean | undefined requestDelaySeconds?: number | undefined alwaysAllowMcp?: boolean | undefined + alwaysAllowExtTools?: boolean | undefined alwaysAllowModeSwitch?: boolean | undefined alwaysAllowSubtasks?: boolean | undefined alwaysAllowExecute?: boolean | undefined @@ -963,9 +972,17 @@ type IpcMessage = whenToUse?: string | undefined customInstructions?: string | undefined groups: ( - | ("read" | "edit" | "browser" | "command" | "mcp" | "modes") + | ("read" | "edit" | "browser" | "command" | "mcp" | "ext" | "modes") | [ - "read" | "edit" | "browser" | "command" | "mcp" | "modes", + ( + | "read" + | "edit" + | "browser" + | "command" + | "mcp" + | "ext" + | "modes" + ), { fileRegex?: string | undefined description?: string | undefined @@ -1035,6 +1052,7 @@ type IpcMessage = | "mistake_limit_reached" | "browser_action_launch" | "use_mcp_server" + | "use_ext_tool" | "auto_approval_max_req_reached" ) | undefined @@ -1057,6 +1075,8 @@ type IpcMessage = | "browser_action_result" | "mcp_server_request_started" | "mcp_server_response" + | "extension_tool_request_started" + | "extension_tool_response" | "subtask_result" | "checkpoint_saved" | "rooignore_error" @@ -1402,6 +1422,7 @@ type TaskCommand = alwaysApproveResubmit?: boolean | undefined requestDelaySeconds?: number | undefined alwaysAllowMcp?: boolean | undefined + alwaysAllowExtTools?: boolean | undefined alwaysAllowModeSwitch?: boolean | undefined alwaysAllowSubtasks?: boolean | undefined alwaysAllowExecute?: boolean | undefined @@ -1477,9 +1498,9 @@ type TaskCommand = whenToUse?: string | undefined customInstructions?: string | undefined groups: ( - | ("read" | "edit" | "browser" | "command" | "mcp" | "modes") + | ("read" | "edit" | "browser" | "command" | "mcp" | "ext" | "modes") | [ - "read" | "edit" | "browser" | "command" | "mcp" | "modes", + "read" | "edit" | "browser" | "command" | "mcp" | "ext" | "modes", { fileRegex?: string | undefined description?: string | undefined @@ -1547,6 +1568,7 @@ type TaskEvent = | "mistake_limit_reached" | "browser_action_launch" | "use_mcp_server" + | "use_ext_tool" | "auto_approval_max_req_reached" ) | undefined @@ -1569,6 +1591,8 @@ type TaskEvent = | "browser_action_result" | "mcp_server_request_started" | "mcp_server_response" + | "extension_tool_request_started" + | "extension_tool_response" | "subtask_result" | "checkpoint_saved" | "rooignore_error" diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 4fb893ae1f..261c61b372 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -115,7 +115,7 @@ export type ProviderName = z.infer * ToolGroup */ -export const toolGroups = ["read", "edit", "browser", "command", "mcp", "modes"] as const +export const toolGroups = ["read", "edit", "browser", "command", "mcp", "ext", "modes"] as const export const toolGroupsSchema = z.enum(toolGroups) @@ -796,6 +796,7 @@ export const globalSettingsSchema = z.object({ alwaysApproveResubmit: z.boolean().optional(), requestDelaySeconds: z.number().optional(), alwaysAllowMcp: z.boolean().optional(), + alwaysAllowExtTools: z.boolean().optional(), alwaysAllowModeSwitch: z.boolean().optional(), alwaysAllowSubtasks: z.boolean().optional(), alwaysAllowExecute: z.boolean().optional(), @@ -882,6 +883,7 @@ const globalSettingsRecord: GlobalSettingsRecord = { alwaysApproveResubmit: undefined, requestDelaySeconds: undefined, alwaysAllowMcp: undefined, + alwaysAllowExtTools: undefined, alwaysAllowModeSwitch: undefined, alwaysAllowSubtasks: undefined, alwaysAllowExecute: undefined, @@ -1037,6 +1039,7 @@ export const clineAsks = [ "mistake_limit_reached", "browser_action_launch", "use_mcp_server", + "use_ext_tool", "auto_approval_max_req_reached", ] as const @@ -1064,6 +1067,8 @@ export const clineSays = [ "browser_action_result", "mcp_server_request_started", "mcp_server_response", + "extension_tool_request_started", + "extension_tool_response", "subtask_result", "checkpoint_saved", "rooignore_error", @@ -1152,6 +1157,7 @@ export const toolNames = [ "list_code_definition_names", "browser_action", "use_mcp_tool", + "use_ext_tool", "access_mcp_resource", "ask_followup_question", "attempt_completion", diff --git a/src/services/extensions/ExtensionToolManager.ts b/src/services/extensions/ExtensionToolManager.ts new file mode 100644 index 0000000000..aada04a754 --- /dev/null +++ b/src/services/extensions/ExtensionToolManager.ts @@ -0,0 +1,234 @@ +import * as vscode from "vscode" +import { EventEmitter } from "node:events" +import { + ExtensionTool, + ExtensionToolAPI, + ExtensionToolEventName, + ExtensionToolEvents, + ExtensionToolResponse, +} from "../../exports/extensionTools" +import { ClineProvider } from "../../core/webview/ClineProvider" +import { z } from "zod" + +/** + * Implementation of the Extension Tool API + */ +export class ExtensionToolManager extends EventEmitter implements ExtensionToolAPI { + private tools: Map> = new Map() + private static instance: ExtensionToolManager | null = null + private static initializationPromise: Promise | null = null + private static providers = new Set() + private static readonly GLOBAL_STATE_KEY = "extension-tool-manager-instance-id" + + /** + * Get the singleton ExtensionToolManager instance. + * Creates a new instance if one doesn't exist. + * Thread-safe implementation using a promise-based lock. + */ + public static async getInstance( + context?: vscode.ExtensionContext, + provider?: ClineProvider, + ): Promise { + // Register the provider if provided + if (provider) { + this.providers.add(provider) + } + + // If we already have an instance, return it + if (this.instance) { + return this.instance + } + + // If initialization is in progress, wait for it + if (this.initializationPromise) { + return this.initializationPromise + } + + // Create a new initialization promise + this.initializationPromise = (async () => { + try { + // Double-check instance in case it was created while we were waiting + if (!this.instance) { + this.instance = new ExtensionToolManager() + // Store a unique identifier in global state to track the primary instance + if (context) { + await context.globalState.update(this.GLOBAL_STATE_KEY, Date.now().toString()) + } + } + return this.instance + } finally { + // Clear the initialization promise after completion or error + this.initializationPromise = null + } + })() + + return this.initializationPromise + } + + /** + * Unregister a provider from the ExtensionToolManager + */ + public static unregisterProvider(provider: ClineProvider): void { + this.providers.delete(provider) + } + + /** + * Private constructor to enforce singleton pattern + */ + private constructor() { + super() + } + + /** + * Register a tool with type-safe arguments using Zod + */ + public registerTool( + extensionId: string, + options: { + name: string + description: string + inputSchema?: T + }, + fn: (args: z.infer) => Promise, + ): void { + const { name, description, inputSchema } = options + + if (!this.tools.has(extensionId)) { + this.tools.set(extensionId, new Map()) + } + + const extensionTools = this.tools.get(extensionId)! + + const tool: ExtensionTool = { + name, + description, + inputSchema, + execute: async (args?: Record) => { + try { + const parsedArgs = inputSchema ? await inputSchema.parseAsync(args) : ({} as z.infer) + return await fn(parsedArgs) + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + } + } + }, + } + + extensionTools.set(name, tool) + + // Emit event + this.emit(ExtensionToolEventName.ToolRegistered, extensionId, name) + + console.log(`Registered tool '${name}' from extension '${extensionId}'`) + } + + /** + * Unregister a tool + */ + public unregisterTool(extensionId: string, toolName: string): void { + const extensionTools = this.tools.get(extensionId) + if (extensionTools && extensionTools.has(toolName)) { + extensionTools.delete(toolName) + + // Emit event + this.emit(ExtensionToolEventName.ToolUnregistered, extensionId, toolName) + + console.log(`Unregistered tool '${toolName}' from extension '${extensionId}'`) + } + } + + /** + * Unregister all tools from an extension + */ + public unregisterAllTools(extensionId: string): void { + const extensionTools = this.tools.get(extensionId) + if (extensionTools) { + // Create a copy of the keys to avoid issues during iteration + const toolNames = [...extensionTools.keys()] + + toolNames.forEach((toolName) => { + this.unregisterTool(extensionId, toolName) + }) + + // Clean up the map + this.tools.delete(extensionId) + + console.log(`Unregistered all tools from extension '${extensionId}'`) + } + } + + /** + * Get all tools registered by an extension + */ + public getRegisteredTools(extensionId: string): string[] { + const extensionTools = this.tools.get(extensionId) + if (!extensionTools) { + return [] + } + return [...extensionTools.keys()] + } + + /** + * Check if a tool is registered + */ + public isToolRegistered(extensionId: string, toolName: string): boolean { + const extensionTools = this.tools.get(extensionId) + return !!extensionTools && extensionTools.has(toolName) + } + + /** + * Execute a tool by extension ID and tool name + * @param extensionId ID of the extension that registered the tool + * @param toolName Name of the tool to execute + * @param args Arguments to pass to the tool + * @returns Tool execution result + */ + public async executeExtensionTool(extensionId: string, toolName: string, args?: Record) { + const extensionTools = this.tools.get(extensionId) + if (!extensionTools) { + throw new Error(`No tools registered for extension '${extensionId}'`) + } + + const tool = extensionTools.get(toolName) + if (!tool) { + throw new Error(`Tool '${toolName}' not found for extension '${extensionId}'`) + } + + try { + return await tool.execute(args) + } catch (error) { + console.error(`Error executing tool '${toolName}' from extension '${extensionId}':`, error) + return { + content: [ + { + type: "text", + text: `Error executing tool: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + } + } + } + + /** + * Get all registered tools as a flat array + */ + public getAllTools(): { extensionId: string; tool: ExtensionTool }[] { + const allTools: { extensionId: string; tool: ExtensionTool }[] = [] + + for (const [extensionId, toolMap] of this.tools.entries()) { + for (const [_, tool] of toolMap.entries()) { + allTools.push({ extensionId, tool }) + } + } + + return allTools + } +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index cd1efbe983..ed9d972928 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -132,6 +132,7 @@ export type ExtensionState = Pick< | "alwaysApproveResubmit" // | "requestDelaySeconds" // Optional in GlobalSettings, required here. | "alwaysAllowMcp" + | "alwaysAllowExtTools" | "alwaysAllowModeSwitch" | "alwaysAllowSubtasks" | "alwaysAllowExecute" @@ -290,6 +291,13 @@ export interface ClineAskUseMcpServer { uri?: string } +export type ClineAskUseExtTool = { + type: "use_ext_tool" + extensionId: string + toolName: string + arguments?: string +} + export interface ClineApiReqInfo { request?: string tokensIn?: number diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 85a12aa238..b70b6180f8 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -56,6 +56,7 @@ export interface WebviewMessage { | "vsCodeSetting" | "alwaysAllowBrowser" | "alwaysAllowMcp" + | "alwaysAllowExtTools" | "alwaysAllowModeSwitch" | "allowedMaxRequests" | "alwaysAllowSubtasks" @@ -78,6 +79,7 @@ export interface WebviewMessage { | "openProjectMcpSettings" | "restartMcpServer" | "toggleToolAlwaysAllow" + | "toggleExtToolAlwaysAllow" | "toggleMcpServer" | "updateMcpTimeout" | "fuzzyMatchThreshold" @@ -151,6 +153,7 @@ export interface WebviewMessage { commands?: string[] audioType?: AudioType serverName?: string + extensionId?: string toolName?: string alwaysAllow?: boolean mode?: Mode diff --git a/src/shared/modes.ts b/src/shared/modes.ts index 18028791a9..1671e724ee 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -57,14 +57,20 @@ export const modes: readonly ModeConfig[] = [ name: "💻 Code", roleDefinition: "You are Roo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.", - groups: ["read", "edit", "browser", "command", "mcp"], + groups: ["read", "edit", "browser", "command", "mcp", "ext"], }, { slug: "architect", name: "🏗️ Architect", roleDefinition: "You are Roo, an experienced technical leader who is inquisitive and an excellent planner. Your goal is to gather information and get context to create a detailed plan for accomplishing the user's task, which the user will review and approve before they switch into another mode to implement the solution.", - groups: ["read", ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }], "browser", "mcp"], + groups: [ + "read", + ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }], + "browser", + "mcp", + "ext", + ], customInstructions: "1. Do some information gathering (for example using read_file or search_files) to get more context about the task.\n\n2. You should also ask the user clarifying questions to get a better understanding of the task.\n\n3. Once you've gained more context about the user's request, you should create a detailed plan for how to accomplish the task. Include Mermaid diagrams if they help make your plan clearer.\n\n4. Ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and plan the best way to accomplish it.\n\n5. Once the user confirms the plan, ask them if they'd like you to write it to a markdown file.\n\n6. Use the switch_mode tool to request that the user switch to another mode to implement the solution.", }, @@ -73,7 +79,7 @@ export const modes: readonly ModeConfig[] = [ name: "❓ Ask", roleDefinition: "You are Roo, a knowledgeable technical assistant focused on answering questions and providing information about software development, technology, and related topics.", - groups: ["read", "browser", "mcp"], + groups: ["read", "browser", "mcp", "ext"], customInstructions: "You can analyze code, explain concepts, and access external resources. Always answer the user’s questions thoroughly, and do not switch to implementing code unless explicitly requested by the user. Include Mermaid diagrams when they clarify your response.", }, @@ -82,7 +88,7 @@ export const modes: readonly ModeConfig[] = [ name: "🪲 Debug", roleDefinition: "You are Roo, an expert software debugger specializing in systematic problem diagnosis and resolution.", - groups: ["read", "edit", "browser", "command", "mcp"], + groups: ["read", "edit", "browser", "command", "mcp", "ext"], customInstructions: "Reflect on 5-7 different possible sources of the problem, distill those down to 1-2 most likely sources, and then add logs to validate your assumptions. Explicitly ask the user to confirm the diagnosis before fixing the problem.", }, diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 37ab53516e..3f5333515a 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -42,6 +42,7 @@ export const toolParamNames = [ "tool_name", "arguments", "uri", + "extension_id", "question", "result", "diff", @@ -132,6 +133,11 @@ export interface UseMcpToolToolUse extends ToolUse { params: Partial, "server_name" | "tool_name" | "arguments">> } +export interface UseExtToolToolUse extends ToolUse { + name: "use_ext_tool" + params: Partial, "extension_id" | "tool_name" | "arguments">> +} + export interface AccessMcpResourceToolUse extends ToolUse { name: "access_mcp_resource" params: Partial, "server_name" | "uri">> @@ -180,6 +186,7 @@ export const TOOL_DISPLAY_NAMES: Record = { list_code_definition_names: "list definitions", browser_action: "use a browser", use_mcp_tool: "use mcp tools", + use_ext_tool: "use extension tool", access_mcp_resource: "access mcp resources", ask_followup_question: "ask questions", attempt_completion: "complete tasks", @@ -216,6 +223,9 @@ export const TOOL_GROUPS: Record = { mcp: { tools: ["use_mcp_tool", "access_mcp_resource"], }, + ext: { + tools: ["use_ext_tool"], + }, modes: { tools: ["switch_mode", "new_task"], alwaysAvailable: true, diff --git a/webview-ui/src/components/chat/AutoApproveMenu.tsx b/webview-ui/src/components/chat/AutoApproveMenu.tsx index ac02d6b8c4..c22f927765 100644 --- a/webview-ui/src/components/chat/AutoApproveMenu.tsx +++ b/webview-ui/src/components/chat/AutoApproveMenu.tsx @@ -22,6 +22,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { alwaysAllowExecute, alwaysAllowBrowser, alwaysAllowMcp, + alwaysAllowExtTools, alwaysAllowModeSwitch, alwaysAllowSubtasks, alwaysApproveResubmit, @@ -31,6 +32,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { setAlwaysAllowExecute, setAlwaysAllowBrowser, setAlwaysAllowMcp, + setAlwaysAllowExtTools, setAlwaysAllowModeSwitch, setAlwaysAllowSubtasks, setAlwaysApproveResubmit, @@ -59,6 +61,9 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { case "alwaysAllowMcp": setAlwaysAllowMcp(value) break + case "alwaysAllowExtTools": + setAlwaysAllowExtTools(value) + break case "alwaysAllowModeSwitch": setAlwaysAllowModeSwitch(value) break @@ -76,6 +81,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { setAlwaysAllowExecute, setAlwaysAllowBrowser, setAlwaysAllowMcp, + setAlwaysAllowExtTools, setAlwaysAllowModeSwitch, setAlwaysAllowSubtasks, setAlwaysApproveResubmit, @@ -91,6 +97,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { alwaysAllowExecute: alwaysAllowExecute, alwaysAllowBrowser: alwaysAllowBrowser, alwaysAllowMcp: alwaysAllowMcp, + alwaysAllowExtTools: alwaysAllowExtTools, alwaysAllowModeSwitch: alwaysAllowModeSwitch, alwaysAllowSubtasks: alwaysAllowSubtasks, alwaysApproveResubmit: alwaysApproveResubmit, @@ -101,6 +108,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { alwaysAllowExecute, alwaysAllowBrowser, alwaysAllowMcp, + alwaysAllowExtTools, alwaysAllowModeSwitch, alwaysAllowSubtasks, alwaysApproveResubmit, diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index f9522901b7..76c18b6845 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -4,7 +4,13 @@ import { useTranslation, Trans } from "react-i18next" import deepEqual from "fast-deep-equal" import { VSCodeBadge, VSCodeButton } from "@vscode/webview-ui-toolkit/react" -import { ClineApiReqInfo, ClineAskUseMcpServer, ClineMessage, ClineSayTool } from "@roo/shared/ExtensionMessage" +import { + ClineApiReqInfo, + ClineAskUseMcpServer, + ClineAskUseExtTool, + ClineMessage, + ClineSayTool, +} from "@roo/shared/ExtensionMessage" import { COMMAND_OUTPUT_STRING } from "@roo/shared/combineCommandSequences" import { safeJsonParse } from "@roo/shared/safeJsonParse" @@ -176,6 +182,11 @@ export const ChatRowContent = ({ : t("chat:mcp.wantsToAccessResource", { serverName: mcpServerUse.serverName })} , ] + case "use_ext_tool": + return [ + , + {t("chat:extTools.wantsToUse")}, + ] case "completion_result": return [ ) + case "use_ext_tool": + const useExtTool = safeJsonParse(message.text) + if (!useExtTool) { + return null + } + + return ( + <> +
+ {icon} + {title} +
+ +
+
+ +
+
+ {useExtTool.extensionId} +
+
{useExtTool.toolName}
+
+
+ + {useExtTool.arguments && useExtTool.arguments !== "{}" && ( +
+
+ {t("chat:arguments")} +
+ +
+ )} +
+ + ) case "completion_result": if (message.text) { return ( diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 0b39baf39a..f2a94f61b7 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -80,6 +80,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0)) { vscode.postMessage({ @@ -800,6 +811,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction + required?: string[] + } +} + +type ExtToolRowProps = { + tool: ExtTool + extensionId?: string + alwaysAllowExtTools?: boolean +} + +const ExtToolRow = ({ tool, extensionId, alwaysAllowExtTools }: ExtToolRowProps) => { + const { t } = useAppTranslation() + + const handleAlwaysAllowChange = () => { + if (!extensionId) return + vscode.postMessage({ + type: "toggleExtToolAlwaysAllow", + extensionId, + toolName: tool.name, + alwaysAllow: !tool.alwaysAllow, + }) + } + + return ( +
+
e.stopPropagation()}> +
+ + {tool.name} +
+ {extensionId && alwaysAllowExtTools && ( + + {t("mcp:tool.alwaysAllow")} + + )} +
+ {tool.description && ( +
+ {tool.description} +
+ )} + {tool.inputSchema && + "properties" in tool.inputSchema && + Object.keys(tool.inputSchema.properties as Record).length > 0 && ( +
+
+ {t("mcp:tool.parameters")} +
+ {Object.entries(tool.inputSchema.properties as Record).map( + ([paramName, schema]) => { + const isRequired = + tool.inputSchema && + "required" in tool.inputSchema && + Array.isArray(tool.inputSchema.required) && + tool.inputSchema.required.includes(paramName) + + return ( +
+ + {paramName} + {isRequired && ( + * + )} + + + {schema.description || t("mcp:tool.noDescription")} + +
+ ) + }, + )} +
+ )} +
+ ) +} + +export default ExtToolRow diff --git a/webview-ui/src/components/extensions/ExtToolsView.tsx b/webview-ui/src/components/extensions/ExtToolsView.tsx new file mode 100644 index 0000000000..4685c0497a --- /dev/null +++ b/webview-ui/src/components/extensions/ExtToolsView.tsx @@ -0,0 +1,179 @@ +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui" + +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { Tab, TabContent, TabHeader } from "../common/Tab" +import ExtToolRow from "./ExtToolRow" + +// Sample extension type (should be defined in shared types) +type ExtensionWithTools = { + id: string + name: string + tools?: Array<{ + name: string + description?: string + alwaysAllow?: boolean + inputSchema?: { + properties?: Record + required?: string[] + } + }> + disabled?: boolean +} + +type ExtToolsViewProps = { + onDone: () => void +} + +const ExtToolsView = ({ onDone }: ExtToolsViewProps) => { + // In a real implementation, we would get this from context + // This is just a placeholder for demonstration + const [extensions] = useState([ + { + id: "example.extension", + name: "Example Extension", + tools: [ + { + name: "sampleTool", + description: "This is a sample extension tool", + inputSchema: { + properties: { + param1: { + description: "First parameter", + }, + param2: { + description: "Second parameter", + }, + }, + required: ["param1"], + }, + }, + ], + }, + ]) + + const { alwaysAllowExtTools } = useExtensionState() + + const { t } = useAppTranslation() + + return ( + + +

{t("extTools:title") || "Extension Tools"}

+ +
+ + +
+ {t("extTools:description") || "Extension tools allow Roo to interact with VS Code extensions."} +
+ + {/* Extension Tools List */} + {extensions.length > 0 && ( +
+ {extensions.map((extension) => ( + + ))} +
+ )} +
+
+ ) +} + +const ExtensionRow = ({ + extension, + alwaysAllowExtTools, +}: { + extension: ExtensionWithTools + alwaysAllowExtTools?: boolean +}) => { + const { t } = useAppTranslation() + const [isExpanded, setIsExpanded] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + const handleRowClick = () => { + setIsExpanded(!isExpanded) + } + + return ( +
+
+ + {extension.name} +
+ + {isExpanded && ( +
+
+ {extension.tools && extension.tools.length > 0 ? ( + extension.tools.map((tool) => ( + + )) + ) : ( +
+ {t("extTools:noTools") || "No tools found in this extension"} +
+ )} +
+
+ )} + + {/* Delete Confirmation Dialog */} + + + + {t("extTools:disableDialog.title") || "Disable Extension"} + + {t("extTools:disableDialog.description", { extensionName: extension.name }) || + `Are you sure you want to disable ${extension.name}?`} + + + + + + + +
+ ) +} + +export default ExtToolsView diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index 4f44ada43c..95b9daeb1c 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -21,6 +21,7 @@ type AutoApproveSettingsProps = HTMLAttributes & { alwaysApproveResubmit?: boolean requestDelaySeconds: number alwaysAllowMcp?: boolean + alwaysAllowExtTools?: boolean alwaysAllowModeSwitch?: boolean alwaysAllowSubtasks?: boolean alwaysAllowExecute?: boolean @@ -35,6 +36,7 @@ type AutoApproveSettingsProps = HTMLAttributes & { | "alwaysApproveResubmit" | "requestDelaySeconds" | "alwaysAllowMcp" + | "alwaysAllowExtTools" | "alwaysAllowModeSwitch" | "alwaysAllowSubtasks" | "alwaysAllowExecute" @@ -52,6 +54,7 @@ export const AutoApproveSettings = ({ alwaysApproveResubmit, requestDelaySeconds, alwaysAllowMcp, + alwaysAllowExtTools, alwaysAllowModeSwitch, alwaysAllowSubtasks, alwaysAllowExecute, @@ -89,6 +92,7 @@ export const AutoApproveSettings = ({ alwaysAllowBrowser={alwaysAllowBrowser} alwaysApproveResubmit={alwaysApproveResubmit} alwaysAllowMcp={alwaysAllowMcp} + alwaysAllowExtTools={alwaysAllowExtTools} alwaysAllowModeSwitch={alwaysAllowModeSwitch} alwaysAllowSubtasks={alwaysAllowSubtasks} alwaysAllowExecute={alwaysAllowExecute} diff --git a/webview-ui/src/components/settings/AutoApproveToggle.tsx b/webview-ui/src/components/settings/AutoApproveToggle.tsx index 17307e8367..4ef245f252 100644 --- a/webview-ui/src/components/settings/AutoApproveToggle.tsx +++ b/webview-ui/src/components/settings/AutoApproveToggle.tsx @@ -11,6 +11,7 @@ type AutoApproveToggles = Pick< | "alwaysAllowBrowser" | "alwaysApproveResubmit" | "alwaysAllowMcp" + | "alwaysAllowExtTools" | "alwaysAllowModeSwitch" | "alwaysAllowSubtasks" | "alwaysAllowExecute" @@ -62,6 +63,13 @@ export const autoApproveSettingsConfig: Record {Object.values(autoApproveSettingsConfig).map(({ key, descriptionKey, labelKey, icon, testId }) => (