diff --git a/.changeset/swift-webs-peel.md b/.changeset/swift-webs-peel.md new file mode 100644 index 0000000000..b5573625e7 --- /dev/null +++ b/.changeset/swift-webs-peel.md @@ -0,0 +1,5 @@ +--- +"roo-cline": minor +--- + +Add ability to Override tools descriptions system prompt. Example usage add your description in .roo/tools/read_file.md it will override default tool description it supports args param replacement with ${args.cwd} diff --git a/src/core/prompts/__tests__/tool-overrides.test.ts b/src/core/prompts/__tests__/tool-overrides.test.ts new file mode 100644 index 0000000000..998cbe2e0e --- /dev/null +++ b/src/core/prompts/__tests__/tool-overrides.test.ts @@ -0,0 +1,209 @@ +import { getToolDescriptionsForMode } from "../tools/index" +import { defaultModeSlug } from "../../../shared/modes" +import * as fs from "fs/promises" +import { toPosix } from "./utils" + +// Mock the fs/promises module +jest.mock("fs/promises", () => ({ + readFile: jest.fn(), + mkdir: jest.fn().mockResolvedValue(undefined), + access: jest.fn().mockResolvedValue(undefined), +})) + +// Get the mocked fs module +const mockedFs = fs as jest.Mocked + +describe("Tool Override System", () => { + beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks() + + // Default behavior: file doesn't exist + mockedFs.readFile.mockRejectedValue({ code: "ENOENT" }) + }) + + it("should return default read_file tool description when no override file exists", async () => { + const toolDescriptions = await getToolDescriptionsForMode( + defaultModeSlug, + "test/workspace", + false, // supportsComputerUse + undefined, // codeIndexManager + undefined, // diffStrategy + undefined, // browserViewportSize + undefined, // mcpHub + undefined, // customModes + undefined, // experiments + ) + + // Should contain the default read_file description + expect(toolDescriptions).toContain("## read_file") + expect(toolDescriptions).toContain("Request to read the contents of one or more files") + expect(toolDescriptions).toContain("relative to the current workspace directory test/workspace") + expect(toolDescriptions).toContain("") + expect(toolDescriptions).toContain("Examples:") + }) + + it("should use custom read_file tool description when override file exists", async () => { + // Mock the readFile to return content from an override file + const customReadFileDescription = `## read_file +Description: Custom read file description for testing +Parameters: +- path: (required) Custom path description for \${args.cwd} +- start_line: (optional) Custom start line description +- end_line: (optional) Custom end line description +Usage: + +Custom file path + +Custom example: + +custom-example.txt +` + + mockedFs.readFile.mockImplementation((filePath, options) => { + if (toPosix(filePath).includes(".roo/tools/read_file.md") && options === "utf-8") { + return Promise.resolve(customReadFileDescription) + } + return Promise.reject({ code: "ENOENT" }) + }) + + const toolDescriptions = await getToolDescriptionsForMode( + defaultModeSlug, + "test/workspace", + false, // supportsComputerUse + undefined, // codeIndexManager + undefined, // diffStrategy + undefined, // browserViewportSize + undefined, // mcpHub + undefined, // customModes + undefined, // experiments + ) + + // Should contain the custom read_file description + expect(toolDescriptions).toContain("Custom read file description for testing") + expect(toolDescriptions).toContain("Custom path description for test/workspace") + expect(toolDescriptions).toContain("Custom example:") + expect(toolDescriptions).toContain("custom-example.txt") + + // Should not contain the default description text + expect(toolDescriptions).not.toContain("Request to read the contents of one or more files") + expect(toolDescriptions).not.toContain("3. Reading lines 500-1000 of a CSV file:") + }) + + it("should interpolate args properties in override content", async () => { + // Mock the readFile to return content with args interpolation + const customReadFileDescription = `## read_file +Description: Custom read file description with interpolated args +Parameters: +- path: (required) File path relative to workspace \${args.cwd} +- workspace: Current workspace is \${args.cwd} +Usage: + +File relative to \${args.cwd} +` + + mockedFs.readFile.mockImplementation((filePath, options) => { + if (toPosix(filePath).includes(".roo/tools/read_file.md") && options === "utf-8") { + return Promise.resolve(customReadFileDescription) + } + return Promise.reject({ code: "ENOENT" }) + }) + + const toolDescriptions = await getToolDescriptionsForMode( + defaultModeSlug, + "test/workspace", + false, // supportsComputerUse + undefined, // codeIndexManager + undefined, // diffStrategy + undefined, // browserViewportSize + undefined, // mcpHub + undefined, // customModes + undefined, // experiments + ) + + // Should contain interpolated values + expect(toolDescriptions).toContain("File path relative to workspace test/workspace") + expect(toolDescriptions).toContain("Current workspace is test/workspace") + expect(toolDescriptions).toContain("File relative to test/workspace") + + // Should not contain the placeholder text + expect(toolDescriptions).not.toContain("\${args.cwd}") + }) + + it("should return multiple tool descriptions including read_file", async () => { + const toolDescriptions = await getToolDescriptionsForMode( + defaultModeSlug, + "test/workspace", + false, // supportsComputerUse + undefined, // codeIndexManager + undefined, // diffStrategy + undefined, // browserViewportSize + undefined, // mcpHub + undefined, // customModes + undefined, // experiments + ) + + // Should contain multiple tools from the default mode + expect(toolDescriptions).toContain("# Tools") + expect(toolDescriptions).toContain("## read_file") + expect(toolDescriptions).toContain("## write_to_file") + expect(toolDescriptions).toContain("## list_files") + expect(toolDescriptions).toContain("## search_files") + + // Tools should be separated by double newlines + const toolSections = toolDescriptions.split("\n\n") + expect(toolSections.length).toBeGreaterThan(1) + }) + + it("should handle empty override file gracefully", async () => { + // Mock the readFile to return empty content + mockedFs.readFile.mockImplementation((filePath, options) => { + if (toPosix(filePath).includes(".roo/tools/read_file.md") && options === "utf-8") { + return Promise.resolve("") + } + return Promise.reject({ code: "ENOENT" }) + }) + + const toolDescriptions = await getToolDescriptionsForMode( + defaultModeSlug, + "test/workspace", + false, // supportsComputerUse + undefined, // codeIndexManager + undefined, // diffStrategy + undefined, // browserViewportSize + undefined, // mcpHub + undefined, // customModes + undefined, // experiments + ) + + // Should fall back to default description when override file is empty + expect(toolDescriptions).toContain("## read_file") + expect(toolDescriptions).toContain("Request to read the contents of one or more files") + }) + + it("should handle whitespace-only override file gracefully", async () => { + // Mock the readFile to return whitespace-only content + mockedFs.readFile.mockImplementation((filePath, options) => { + if (toPosix(filePath).includes(".roo/tools/read_file.md") && options === "utf-8") { + return Promise.resolve(" \n \t \n ") + } + return Promise.reject({ code: "ENOENT" }) + }) + + const toolDescriptions = await getToolDescriptionsForMode( + defaultModeSlug, + "test/workspace", + false, // supportsComputerUse + undefined, // codeIndexManager + undefined, // diffStrategy + undefined, // browserViewportSize + undefined, // mcpHub + undefined, // customModes + undefined, // experiments + ) + + // Should fall back to default description when override file contains only whitespace + expect(toolDescriptions).toContain("## read_file") + expect(toolDescriptions).toContain("Request to read the contents of one or more files") + }) +}) diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 8b6afc6657..33f23f46eb 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -71,7 +71,7 @@ ${markdownFormattingSection()} ${getSharedToolUseSection()} -${getToolDescriptionsForMode( +${await getToolDescriptionsForMode( mode, cwd, supportsComputerUse, diff --git a/src/core/prompts/tools/index.ts b/src/core/prompts/tools/index.ts index 673227684a..69f5f00b21 100644 --- a/src/core/prompts/tools/index.ts +++ b/src/core/prompts/tools/index.ts @@ -1,6 +1,6 @@ import type { ToolName, ModeConfig } from "@roo-code/types" -import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, DiffStrategy } from "../../../shared/tools" +import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, DiffStrategy, readToolOverrideWithArgs } from "../../../shared/tools" import { McpHub } from "../../../services/mcp/McpHub" import { Mode, getModeConfig, isToolAllowedForMode, getGroupName } from "../../../shared/modes" @@ -25,29 +25,91 @@ import { getCodebaseSearchDescription } from "./codebase-search" import { CodeIndexManager } from "../../../services/code-index/manager" // Map of tool names to their description functions -const toolDescriptionMap: Record string | undefined> = { - execute_command: (args) => getExecuteCommandDescription(args), - read_file: (args) => getReadFileDescription(args), - fetch_instructions: () => getFetchInstructionsDescription(), - write_to_file: (args) => getWriteToFileDescription(args), - search_files: (args) => getSearchFilesDescription(args), - list_files: (args) => getListFilesDescription(args), - list_code_definition_names: (args) => getListCodeDefinitionNamesDescription(args), - browser_action: (args) => getBrowserActionDescription(args), - ask_followup_question: () => getAskFollowupQuestionDescription(), - attempt_completion: () => getAttemptCompletionDescription(), - use_mcp_tool: (args) => getUseMcpToolDescription(args), - access_mcp_resource: (args) => getAccessMcpResourceDescription(args), - codebase_search: () => getCodebaseSearchDescription(), - switch_mode: () => getSwitchModeDescription(), - new_task: (args) => getNewTaskDescription(args), - insert_content: (args) => getInsertContentDescription(args), - search_and_replace: (args) => getSearchAndReplaceDescription(args), - apply_diff: (args) => - args.diffStrategy ? args.diffStrategy.getToolDescription({ cwd: args.cwd, toolOptions: args.toolOptions }) : "", +const toolDescriptionMap: Record Promise> = { + execute_command: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "execute_command", args) + return overrideContent || getExecuteCommandDescription(args) + }, + read_file: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "read_file", args) + return overrideContent || getReadFileDescription(args) + }, + fetch_instructions: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "fetch_instructions", args) + return overrideContent || getFetchInstructionsDescription() + }, + write_to_file: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "write_to_file", args) + return overrideContent || getWriteToFileDescription(args) + }, + search_files: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "search_files", args) + return overrideContent || getSearchFilesDescription(args) + }, + list_files: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "list_files", args) + return overrideContent || getListFilesDescription(args) + }, + list_code_definition_names: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "list_code_definition_names", args) + return overrideContent || getListCodeDefinitionNamesDescription(args) + }, + browser_action: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "browser_action", args) + return overrideContent || getBrowserActionDescription(args) + }, + ask_followup_question: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "ask_followup_question", args) + return overrideContent || getAskFollowupQuestionDescription() + }, + attempt_completion: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "attempt_completion", args) + return overrideContent || getAttemptCompletionDescription() + }, + use_mcp_tool: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "use_mcp_tool", args) + return overrideContent || getUseMcpToolDescription(args) + }, + access_mcp_resource: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "access_mcp_resource", args) + return overrideContent || getAccessMcpResourceDescription(args) + }, + codebase_search: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "codebase_search", args) + return overrideContent || getCodebaseSearchDescription() + }, + switch_mode: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "switch_mode", args) + return overrideContent || getSwitchModeDescription() + }, + new_task: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "new_task", args) + return overrideContent || getNewTaskDescription(args) + }, + insert_content: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "insert_content", args) + return overrideContent || getInsertContentDescription(args) + }, + search_and_replace: async (args) => { + const overrideContent = await readToolOverrideWithArgs(args.cwd, "search_and_replace", args) + return overrideContent || getSearchAndReplaceDescription(args) + }, + apply_diff: async (args) => { + const overrideContent = await readToolOverrideWithArgs( + args.cwd, + `apply_diff${args.diffStrategy ? args.diffStrategy.getName() : ''}`, + args, + ) + return ( + overrideContent || + (args.diffStrategy + ? args.diffStrategy.getToolDescription({ cwd: args.cwd, toolOptions: args.toolOptions }) + : "") + ) + }, } -export function getToolDescriptionsForMode( +export async function getToolDescriptionsForMode( mode: Mode, cwd: string, supportsComputerUse: boolean, @@ -59,7 +121,7 @@ export function getToolDescriptionsForMode( experiments?: Record, partialReadsEnabled?: boolean, settings?: Record, -): string { +): Promise { const config = getModeConfig(mode, customModes) const args: ToolArgs = { cwd, @@ -107,18 +169,19 @@ export function getToolDescriptionsForMode( } // Map tool descriptions for allowed tools - const descriptions = Array.from(tools).map((toolName) => { + const descriptionPromises = Array.from(tools).map(async (toolName) => { const descriptionFn = toolDescriptionMap[toolName] if (!descriptionFn) { return undefined } - return descriptionFn({ + return await descriptionFn({ ...args, toolOptions: undefined, // No tool options in group-based approach }) }) + const descriptions = await Promise.all(descriptionPromises) return `# Tools\n\n${descriptions.filter(Boolean).join("\n\n")}` } diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 85a0cb318c..ea81fb9dbe 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -1,4 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" +import fs from "fs/promises" +import path from "path" import type { ClineAsk, ToolProgressStatus, ToolGroup, ToolName } from "@roo-code/types" @@ -267,3 +269,85 @@ export interface DiffStrategy { getProgressStatus?(toolUse: ToolUse, result?: any): ToolProgressStatus } + +/** + * Safely reads a file from the .roo directory with the given filename. + * Returns an empty string if the file doesn't exist or cannot be read. + * + * @param cwd - The working directory + * @param file - The filename to read from the .roo directory + * @returns A promise that resolves to the file content or empty string + */ +export async function readToolOverride(cwd: string, file: string): Promise { + try { + const filePath = path.join(cwd, ".roo", "tools", `${file}.md`) + const content = await fs.readFile(filePath, "utf-8") + return content.trim() + } catch (err) { + const errorCode = (err as NodeJS.ErrnoException).code + if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) { + throw err + } + return "" + } +} + +/** + * Interpolates args data into a tool description string. + * Replaces placeholders like ${args.cwd} or ${args.cwd.toPosix()} with actual values from the args object. + * + * @param content - The tool description content with placeholders + * @param args - The tool arguments object containing values to interpolate + * @returns The content with interpolated values + */ +function interpolateToolDescription(content: string, args: any): string { + // Replace ${args.property.method()} or ${args.property} patterns with actual values + return content.replace(/\$\{args\.([^}]+)\}/g, (match, expression) => { + try { + // Split the expression by dots to handle nested properties and methods + const parts = expression.split(".") + let value = args + + for (const part of parts) { + if (part.includes("()")) { + // Handle method calls + const methodName = part.replace("()", "") + if (value && typeof value[methodName] === "function") { + value = value[methodName]() + } else { + return match // Return original if method doesn't exist + } + } else { + // Handle properties + if (value && Object.hasOwn(value, part)) { + value = value[part] + } else { + return match // Return original if property doesn't exist + } + } + } + + return value !== undefined ? String(value) : match + } catch (error) { + // If any error occurs during evaluation, return the original placeholder + return match + } + }) +} + +/** + * Gets tool override content with args interpolation. + * Reads the override file and interpolates args data into the content. + * + * @param cwd - The working directory + * @param file - The filename to read from the .roo directory + * @param args - The tool arguments object for interpolation + * @returns The interpolated content or empty string + */ +export async function readToolOverrideWithArgs(cwd: string, file: string, args: any): Promise { + const content = await readToolOverride(cwd, file) + if (!content) { + return "" + } + return interpolateToolDescription(content, args) +}