diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index ae4ddb72fb7..b3f389c2a2f 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -20,6 +20,7 @@ export const toolNames = [ "write_to_file", "apply_diff", "insert_content", + "search_and_replace", "search_files", "list_files", "list_code_definition_names", diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index d3ff5fd5677..6e1e44a1a58 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -635,6 +635,15 @@ export class NativeToolCallParser { } break + case "search_and_replace": + if (args.path !== undefined && args.operations !== undefined && Array.isArray(args.operations)) { + nativeArgs = { + path: args.path, + operations: args.operations, + } as NativeArgsFor + } + break + case "ask_followup_question": if (args.question !== undefined && args.follow_up !== undefined) { nativeArgs = { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 935970012e0..2e4b3ac4da7 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -17,6 +17,7 @@ import { shouldUseSingleFileRead, TOOL_PROTOCOL } from "@roo-code/types" import { writeToFileTool } from "../tools/WriteToFileTool" import { applyDiffTool } from "../tools/MultiApplyDiffTool" import { insertContentTool } from "../tools/InsertContentTool" +import { searchAndReplaceTool } from "../tools/SearchAndReplaceTool" import { listCodeDefinitionNamesTool } from "../tools/ListCodeDefinitionNamesTool" import { searchFilesTool } from "../tools/SearchFilesTool" import { browserActionTool } from "../tools/BrowserActionTool" @@ -379,6 +380,8 @@ export async function presentAssistantMessage(cline: Task) { }]` case "insert_content": return `[${block.name} for '${block.params.path}']` + case "search_and_replace": + return `[${block.name} for '${block.params.path}']` case "list_files": return `[${block.name} for '${block.params.path}']` case "list_code_definition_names": @@ -677,7 +680,14 @@ export async function presentAssistantMessage(cline: Task) { } // Validate tool use before execution. - const { mode, customModes } = (await cline.providerRef.deref()?.getState()) ?? {} + const { + mode, + customModes, + experiments: stateExperiments, + apiConfiguration, + } = (await cline.providerRef.deref()?.getState()) ?? {} + const modelInfo = cline.api.getModel() + const includedTools = modelInfo?.info?.includedTools try { validateToolUse( @@ -686,6 +696,8 @@ export async function presentAssistantMessage(cline: Task) { customModes ?? [], { apply_diff: cline.diffEnabled }, block.params, + stateExperiments, + includedTools, ) } catch (error) { cline.consecutiveMistakeCount++ @@ -806,6 +818,16 @@ export async function presentAssistantMessage(cline: Task) { toolProtocol, }) break + case "search_and_replace": + await checkpointSaveAndMark(cline) + await searchAndReplaceTool.handle(cline, block as ToolUse<"search_and_replace">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break case "read_file": // Check if this model should use the simplified single-file read tool // Only use simplified tool for XML protocol - native protocol works with standard tool diff --git a/src/core/prompts/tools/native-tools/apply_diff.ts b/src/core/prompts/tools/native-tools/apply_diff.ts index faa65291bf2..3938e4886a0 100644 --- a/src/core/prompts/tools/native-tools/apply_diff.ts +++ b/src/core/prompts/tools/native-tools/apply_diff.ts @@ -11,7 +11,7 @@ const DIFF_PARAMETER_DESCRIPTION = `A string containing one or more search/repla [new content to replace with] >>>>>>> REPLACE` -export const apply_diff_single_file = { +export const apply_diff = { type: "function", function: { name: "apply_diff", diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index bb0d50da88d..32d23459aa7 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -1,5 +1,6 @@ import type OpenAI from "openai" import accessMcpResource from "./access_mcp_resource" +import { apply_diff } from "./apply_diff" import askFollowupQuestion from "./ask_followup_question" import attemptCompletion from "./attempt_completion" import browserAction from "./browser_action" @@ -13,11 +14,11 @@ import listFiles from "./list_files" import newTask from "./new_task" import { createReadFileTool } from "./read_file" import runSlashCommand from "./run_slash_command" +import searchAndReplace from "./search_and_replace" import searchFiles from "./search_files" import switchMode from "./switch_mode" import updateTodoList from "./update_todo_list" import writeToFile from "./write_to_file" -import { apply_diff_single_file } from "./apply_diff" export { getMcpServerTools } from "./mcp_server" export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./converters" @@ -31,7 +32,7 @@ export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./c export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat.ChatCompletionTool[] { return [ accessMcpResource, - apply_diff_single_file, + apply_diff, askFollowupQuestion, attemptCompletion, browserAction, @@ -45,6 +46,7 @@ export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat newTask, createReadFileTool(partialReadsEnabled), runSlashCommand, + searchAndReplace, searchFiles, switchMode, updateTodoList, diff --git a/src/core/prompts/tools/native-tools/search_and_replace.ts b/src/core/prompts/tools/native-tools/search_and_replace.ts new file mode 100644 index 00000000000..ce785b6a165 --- /dev/null +++ b/src/core/prompts/tools/native-tools/search_and_replace.ts @@ -0,0 +1,44 @@ +import type OpenAI from "openai" + +const SEARCH_AND_REPLACE_DESCRIPTION = `Apply precise, targeted modifications to an existing file using search and replace operations. This tool is for surgical edits only; provide an array of operations where each operation specifies the exact text to search for and what to replace it with. The search text must exactly match the existing content, including whitespace and indentation.` + +const search_and_replace = { + type: "function", + function: { + name: "search_and_replace", + description: SEARCH_AND_REPLACE_DESCRIPTION, + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "The path of the file to modify, relative to the current workspace directory.", + }, + operations: { + type: "array", + description: "Array of search and replace operations to perform on the file.", + items: { + type: "object", + properties: { + search: { + type: "string", + description: + "The exact text to find in the file. Must match exactly, including whitespace.", + }, + replace: { + type: "string", + description: "The text to replace the search text with.", + }, + }, + required: ["search", "replace"], + }, + minItems: 1, + }, + }, + required: ["path", "operations"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool + +export default search_and_replace diff --git a/src/core/tools/SearchAndReplaceTool.ts b/src/core/tools/SearchAndReplaceTool.ts new file mode 100644 index 00000000000..49d159f4557 --- /dev/null +++ b/src/core/tools/SearchAndReplaceTool.ts @@ -0,0 +1,306 @@ +import fs from "fs/promises" +import path from "path" + +import { getReadablePath } from "../../utils/path" +import { isPathOutsideWorkspace } from "../../utils/pathUtils" +import { Task } from "../task/Task" +import { formatResponse } from "../prompts/responses" +import { ClineSayTool } from "../../shared/ExtensionMessage" +import { RecordSource } from "../context-tracking/FileContextTrackerTypes" +import { fileExistsAtPath } from "../../utils/fs" +import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" +import { BaseTool, ToolCallbacks } from "./BaseTool" +import type { ToolUse } from "../../shared/tools" + +interface SearchReplaceOperation { + search: string + replace: string +} + +interface SearchAndReplaceParams { + path: string + operations: SearchReplaceOperation[] +} + +export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { + readonly name = "search_and_replace" as const + + parseLegacy(params: Partial>): SearchAndReplaceParams { + // Parse operations from JSON string if provided + let operations: SearchReplaceOperation[] = [] + if (params.operations) { + try { + operations = JSON.parse(params.operations) + } catch { + operations = [] + } + } + + return { + path: params.path || "", + operations, + } + } + + async execute(params: SearchAndReplaceParams, task: Task, callbacks: ToolCallbacks): Promise { + const { path: relPath, operations } = params + const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks + + try { + // Validate required parameters + if (!relPath) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace") + pushToolResult(await task.sayAndCreateMissingParamError("search_and_replace", "path")) + return + } + + if (!operations || !Array.isArray(operations) || operations.length === 0) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace") + pushToolResult( + formatResponse.toolError( + "Missing or empty 'operations' parameter. At least one search/replace operation is required.", + ), + ) + return + } + + // Validate each operation has search and replace fields + for (let i = 0; i < operations.length; i++) { + const op = operations[i] + if (!op.search) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace") + pushToolResult(formatResponse.toolError(`Operation ${i + 1} is missing the 'search' field.`)) + return + } + if (op.replace === undefined) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace") + pushToolResult(formatResponse.toolError(`Operation ${i + 1} is missing the 'replace' field.`)) + return + } + } + + const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) + + if (!accessAllowed) { + await task.say("rooignore_error", relPath) + pushToolResult(formatResponse.rooIgnoreError(relPath, toolProtocol)) + return + } + + // Check if file is write-protected + const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false + + const absolutePath = path.resolve(task.cwd, relPath) + + const fileExists = await fileExistsAtPath(absolutePath) + if (!fileExists) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace") + const errorMessage = `File not found: ${relPath}. Cannot perform search and replace on a non-existent file.` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + + let fileContent: string + try { + fileContent = await fs.readFile(absolutePath, "utf8") + } catch (error) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace") + const errorMessage = `Failed to read file '${relPath}'. Please verify file permissions and try again.` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + + // Apply all operations sequentially + let newContent = fileContent + const errors: string[] = [] + + for (let i = 0; i < operations.length; i++) { + const { search, replace } = operations[i] + const searchPattern = new RegExp(escapeRegExp(search), "g") + + const matchCount = newContent.match(searchPattern)?.length ?? 0 + if (matchCount === 0) { + errors.push(`Operation ${i + 1}: No match found for search text.`) + continue + } + + if (matchCount > 1) { + errors.push( + `Operation ${i + 1}: Found ${matchCount} matches. Please provide more context to make a unique match.`, + ) + continue + } + + // Apply the replacement + newContent = newContent.replace(searchPattern, replace) + } + + // If all operations failed, return error + if (errors.length === operations.length) { + task.consecutiveMistakeCount++ + task.recordToolError("search_and_replace", "no_match") + pushToolResult(formatResponse.toolError(`All operations failed:\n${errors.join("\n")}`)) + return + } + + // Check if any changes were made + if (newContent === fileContent) { + pushToolResult(`No changes needed for '${relPath}'`) + return + } + + task.consecutiveMistakeCount = 0 + + // Initialize diff view + task.diffViewProvider.editType = "modify" + task.diffViewProvider.originalContent = fileContent + + // Generate and validate diff + const diff = formatResponse.createPrettyPatch(relPath, fileContent, newContent) + if (!diff) { + pushToolResult(`No changes needed for '${relPath}'`) + await task.diffViewProvider.reset() + return + } + + // Check if preventFocusDisruption experiment is enabled + const provider = task.providerRef.deref() + const state = await provider?.getState() + const diagnosticsEnabled = state?.diagnosticsEnabled ?? true + const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS + const isPreventFocusDisruptionEnabled = experiments.isEnabled( + state?.experiments ?? {}, + EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION, + ) + + const sanitizedDiff = sanitizeUnifiedDiff(diff) + const diffStats = computeDiffStats(sanitizedDiff) || undefined + const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(task.cwd, relPath), + diff: sanitizedDiff, + isOutsideWorkspace, + } + + // Include any partial errors in the message + let resultMessage = "" + if (errors.length > 0) { + resultMessage = `Some operations failed:\n${errors.join("\n")}\n\n` + } + + const completeMessage = JSON.stringify({ + ...sharedMessageProps, + content: sanitizedDiff, + isProtected: isWriteProtected, + diffStats, + } satisfies ClineSayTool) + + // Show diff view if focus disruption prevention is disabled + if (!isPreventFocusDisruptionEnabled) { + await task.diffViewProvider.open(relPath) + await task.diffViewProvider.update(newContent, true) + task.diffViewProvider.scrollToFirstDiff() + } + + const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) + + if (!didApprove) { + // Revert changes if diff view was shown + if (!isPreventFocusDisruptionEnabled) { + await task.diffViewProvider.revertChanges() + } + pushToolResult("Changes were rejected by the user.") + await task.diffViewProvider.reset() + return + } + + // Save the changes + if (isPreventFocusDisruptionEnabled) { + // Direct file write without diff view or opening the file + await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + } else { + // Call saveChanges to update the DiffViewProvider properties + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + } + + // Track file edit operation + if (relPath) { + await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + } + + task.didEditFile = true + + // Get the formatted response message + const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false) + + // Add error info if some operations failed + if (errors.length > 0) { + pushToolResult(`${resultMessage}${message}`) + } else { + pushToolResult(message) + } + + // Record successful tool usage and cleanup + task.recordToolUsage("search_and_replace") + await task.diffViewProvider.reset() + + // Process any queued messages after file edit completes + task.processQueuedMessages() + } catch (error) { + await handleError("search and replace", error as Error) + await task.diffViewProvider.reset() + } + } + + override async handlePartial(task: Task, block: ToolUse<"search_and_replace">): Promise { + const relPath: string | undefined = block.params.path + const operationsStr: string | undefined = block.params.operations + + let operationsPreview: string | undefined + if (operationsStr) { + try { + const ops = JSON.parse(operationsStr) + if (Array.isArray(ops) && ops.length > 0) { + operationsPreview = `${ops.length} operation(s)` + } + } catch { + operationsPreview = "parsing..." + } + } + + const absolutePath = relPath ? path.resolve(task.cwd, relPath) : "" + const isOutsideWorkspace = absolutePath ? isPathOutsideWorkspace(absolutePath) : false + + const sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(task.cwd, relPath || ""), + diff: operationsPreview, + isOutsideWorkspace, + } + + await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {}) + } +} + +/** + * Escapes special regex characters in a string + * @param input String to escape regex characters in + * @returns Escaped string safe for regex pattern matching + */ +function escapeRegExp(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +export const searchAndReplaceTool = new SearchAndReplaceTool() diff --git a/src/core/tools/validateToolUse.ts b/src/core/tools/validateToolUse.ts index f0ce9e16e62..a40b01cddec 100644 --- a/src/core/tools/validateToolUse.ts +++ b/src/core/tools/validateToolUse.ts @@ -8,8 +8,20 @@ export function validateToolUse( customModes?: ModeConfig[], toolRequirements?: Record, toolParams?: Record, + experiments?: Record, + includedTools?: string[], ): void { - if (!isToolAllowedForMode(toolName, mode, customModes ?? [], toolRequirements, toolParams)) { + if ( + !isToolAllowedForMode( + toolName, + mode, + customModes ?? [], + toolRequirements, + toolParams, + experiments, + includedTools, + ) + ) { throw new Error(`Tool "${toolName}" is not allowed in ${mode} mode.`) } } diff --git a/src/shared/__tests__/modes.spec.ts b/src/shared/__tests__/modes.spec.ts index 0ec6554fe2f..b9231dfd05b 100644 --- a/src/shared/__tests__/modes.spec.ts +++ b/src/shared/__tests__/modes.spec.ts @@ -339,6 +339,69 @@ describe("isToolAllowedForMode", () => { expect(isToolAllowedForMode("write_to_file", "markdown-editor", customModes, toolRequirements)).toBe(false) }) + + describe("customTools (opt-in tools)", () => { + const customModesWithEditGroup: ModeConfig[] = [ + { + slug: "test-custom-tools", + name: "Test Custom Tools Mode", + roleDefinition: "You are a test mode", + groups: ["read", "edit", "browser"], + }, + ] + + it("disallows customTools by default (not in includedTools)", () => { + // search_and_replace is a customTool in the edit group, should be disallowed by default + expect(isToolAllowedForMode("search_and_replace", "test-custom-tools", customModesWithEditGroup)).toBe( + false, + ) + }) + + it("allows customTools when included in includedTools", () => { + // search_and_replace should be allowed when explicitly included + expect( + isToolAllowedForMode( + "search_and_replace", + "test-custom-tools", + customModesWithEditGroup, + undefined, + undefined, + undefined, + ["search_and_replace"], + ), + ).toBe(true) + }) + + it("disallows customTools even in includedTools if mode doesn't have the group", () => { + const customModesWithoutEdit: ModeConfig[] = [ + { + slug: "no-edit-mode", + name: "No Edit Mode", + roleDefinition: "You have no edit powers", + groups: ["read", "browser"], // No edit group + }, + ] + + // Even if included, should be disallowed because the mode doesn't have edit group + expect( + isToolAllowedForMode( + "search_and_replace", + "no-edit-mode", + customModesWithoutEdit, + undefined, + undefined, + undefined, + ["search_and_replace"], + ), + ).toBe(false) + }) + + it("allows regular tools in the same group as customTools", () => { + // apply_diff (regular tool) should be allowed even without includedTools + expect(isToolAllowedForMode("apply_diff", "test-custom-tools", customModesWithEditGroup)).toBe(true) + expect(isToolAllowedForMode("write_to_file", "test-custom-tools", customModesWithEditGroup)).toBe(true) + }) + }) }) describe("FileRestrictionError", () => { diff --git a/src/shared/modes.ts b/src/shared/modes.ts index bbde43fd0ad..ed7c5b4d581 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -171,6 +171,7 @@ export function isToolAllowedForMode( toolRequirements?: Record, toolParams?: Record, // All tool parameters experiments?: Record, + includedTools?: string[], // Opt-in tools explicitly included (e.g., from modelInfo) ): boolean { // Always allow these tools if (ALWAYS_AVAILABLE_TOOLS.includes(tool as any)) { @@ -214,8 +215,14 @@ export function isToolAllowedForMode( return true } - // If the tool isn't in this group's tools, continue to next group - if (!groupConfig.tools.includes(tool)) { + // Check if the tool is in the group's regular tools + const isRegularTool = groupConfig.tools.includes(tool) + + // Check if the tool is a custom tool that has been explicitly included + const isCustomTool = groupConfig.customTools?.includes(tool) && includedTools?.includes(tool) + + // If the tool isn't in regular tools and isn't an included custom tool, continue to next group + if (!isRegularTool && !isCustomTool) { continue } diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 7246931e63a..71776cf1090 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -71,6 +71,7 @@ export const toolParamNames = [ "prompt", "image", "files", // Native protocol parameter for read_file + "operations", // search_and_replace parameter for multiple operations ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -88,6 +89,7 @@ export type NativeToolArgs = { execute_command: { command: string; cwd?: string } insert_content: { path: string; line: number; content: string } apply_diff: { path: string; diff: string } + search_and_replace: { path: string; operations: Array<{ search: string; replace: string }> } ask_followup_question: { question: string follow_up: Array<{ text: string; mode?: string }> @@ -246,6 +248,7 @@ export const TOOL_DISPLAY_NAMES: Record = { fetch_instructions: "fetch instructions", write_to_file: "write files", apply_diff: "apply changes", + search_and_replace: "apply changes using search and replace", search_files: "search files", list_files: "list files", list_code_definition_names: "list definitions", @@ -277,6 +280,7 @@ export const TOOL_GROUPS: Record = { }, edit: { tools: ["apply_diff", "write_to_file", "insert_content", "generate_image"], + customTools: ["search_and_replace"], }, browser: { tools: ["browser_action"],