From 44dde3d7c17073cf116045c4aff6dafde2e12ac0 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 24 Nov 2025 16:16:35 -0700 Subject: [PATCH 1/9] feat: add search_and_replace tool for batch text replacements Adds a new native tool that allows multiple search/replace operations in a single file edit, reducing round-trips for common editing patterns. - Add search_and_replace to tool types and tool groups - Implement SearchAndReplaceTool with streaming support - Add native tool schema and parser support - Support partial matching and error recovery for individual operations --- packages/types/src/tool.ts | 1 + .../assistant-message/NativeToolCallParser.ts | 9 + .../presentAssistantMessage.ts | 13 + src/core/prompts/tools/native-tools/index.ts | 2 + .../tools/native-tools/search_and_replace.ts | 44 +++ src/core/tools/SearchAndReplaceTool.ts | 299 ++++++++++++++++++ src/shared/tools.ts | 5 +- 7 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 src/core/prompts/tools/native-tools/search_and_replace.ts create mode 100644 src/core/tools/SearchAndReplaceTool.ts 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..74ab320302b 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": @@ -806,6 +809,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/index.ts b/src/core/prompts/tools/native-tools/index.ts index bb0d50da88d..63782965622 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -13,6 +13,7 @@ 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" @@ -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..782d63dc742 --- /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 = `Perform one or more text replacements in a file. This tool finds and replaces exact text matches. Use this for straightforward replacements where you know the exact text to find and replace. You can perform multiple replacements in a single call by providing an array of operations. For complex multi-line changes or when you need line number precision, use apply_diff instead.` + +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..8b647e7cc5e --- /dev/null +++ b/src/core/tools/SearchAndReplaceTool.ts @@ -0,0 +1,299 @@ +import fs from "fs/promises" +import path from "path" + +import { getReadablePath } from "../../utils/path" +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 sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(task.cwd, relPath), + diff: sanitizedDiff, + } + + // 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 sharedMessageProps: ClineSayTool = { + tool: "appliedDiff", + path: getReadablePath(task.cwd, relPath || ""), + diff: operationsPreview, + } + + 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/shared/tools.ts b/src/shared/tools.ts index 7246931e63a..df82654e193 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", @@ -276,7 +279,7 @@ export const TOOL_GROUPS: Record = { ], }, edit: { - tools: ["apply_diff", "write_to_file", "insert_content", "generate_image"], + tools: ["apply_diff", "search_and_replace", "write_to_file", "insert_content", "generate_image"], }, browser: { tools: ["browser_action"], From 311b6255bd527dab2c4590d0ec5098fbe7adf7a7 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 25 Nov 2025 07:39:25 -0700 Subject: [PATCH 2/9] Temporary changes made to run evals with the search_and_replace replacing the apply_diff. --- .../assistant-message/NativeToolCallParser.ts | 10 +++++++++- .../presentAssistantMessage.ts | 20 +++++++++++++++++++ src/core/prompts/tools/native-tools/index.ts | 2 +- .../tools/native-tools/search_and_replace.ts | 6 +++--- src/shared/tools.ts | 2 +- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 6e1e44a1a58..f1da82c5d9c 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -627,7 +627,15 @@ export class NativeToolCallParser { break case "apply_diff": - if (args.path !== undefined && args.diff !== undefined) { + // Handle new operations-based apply_diff (search_and_replace renamed to apply_diff) + if (args.path !== undefined && args.operations !== undefined && Array.isArray(args.operations)) { + nativeArgs = { + path: args.path, + operations: args.operations, + } as NativeArgsFor + } + // Also handle legacy diff format for backward compatibility + else if (args.path !== undefined && args.diff !== undefined) { nativeArgs = { path: args.path, diff: args.diff, diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 74ab320302b..7db79f99122 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -764,6 +764,26 @@ export async function presentAssistantMessage(cline: Task) { // Check if this tool call came from native protocol by checking for ID // Native calls always have IDs, XML calls never do if (toolProtocol === TOOL_PROTOCOL.NATIVE) { + // Check if this is the new operations-based format (search_and_replace logic) + const nativeArgs = (block as ToolUse<"apply_diff">).nativeArgs + if (nativeArgs && "operations" in nativeArgs && Array.isArray(nativeArgs.operations)) { + // Route to searchAndReplaceTool for operations-based apply_diff + // Convert the block to look like a search_and_replace tool call + const sarBlock = { + ...block, + name: "search_and_replace" as const, + nativeArgs: { path: nativeArgs.path, operations: nativeArgs.operations }, + } + await searchAndReplaceTool.handle(cline, sarBlock as ToolUse<"search_and_replace">, { + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolProtocol, + }) + break + } + // Legacy diff format - use original apply_diff handler await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { askApproval, handleError, diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 63782965622..9ef1839d27a 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_single_file } from "./apply_diff" import askFollowupQuestion from "./ask_followup_question" import attemptCompletion from "./attempt_completion" import browserAction from "./browser_action" @@ -18,7 +19,6 @@ 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" diff --git a/src/core/prompts/tools/native-tools/search_and_replace.ts b/src/core/prompts/tools/native-tools/search_and_replace.ts index 782d63dc742..ffde082f842 100644 --- a/src/core/prompts/tools/native-tools/search_and_replace.ts +++ b/src/core/prompts/tools/native-tools/search_and_replace.ts @@ -1,12 +1,12 @@ import type OpenAI from "openai" -const SEARCH_AND_REPLACE_DESCRIPTION = `Perform one or more text replacements in a file. This tool finds and replaces exact text matches. Use this for straightforward replacements where you know the exact text to find and replace. You can perform multiple replacements in a single call by providing an array of operations. For complex multi-line changes or when you need line number precision, use apply_diff instead.` +const APPLY_DIFF_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, + name: "apply_diff", + description: APPLY_DIFF_DESCRIPTION, parameters: { type: "object", properties: { diff --git a/src/shared/tools.ts b/src/shared/tools.ts index df82654e193..5aa34c8e21c 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -88,7 +88,7 @@ export type NativeToolArgs = { attempt_completion: { result: string } execute_command: { command: string; cwd?: string } insert_content: { path: string; line: number; content: string } - apply_diff: { path: string; diff: string } + apply_diff: { path: string; diff?: string; operations?: Array<{ search: string; replace: string }> } search_and_replace: { path: string; operations: Array<{ search: string; replace: string }> } ask_followup_question: { question: string From 993c34ba972710553c0811de1527507275fb386f Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 27 Nov 2025 14:31:21 -0500 Subject: [PATCH 3/9] feat: make search_and_replace a custom tool (opt-in only) --- src/shared/tools.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 5aa34c8e21c..f6ae2ecd733 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -279,7 +279,8 @@ export const TOOL_GROUPS: Record = { ], }, edit: { - tools: ["apply_diff", "search_and_replace", "write_to_file", "insert_content", "generate_image"], + tools: ["apply_diff", "write_to_file", "insert_content", "generate_image"], + customTools: ["search_and_replace"], }, browser: { tools: ["browser_action"], From 0c48afc5328898c7ca652186ad5c1c6d5f782932 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 27 Nov 2025 15:02:55 -0500 Subject: [PATCH 4/9] feat: implement customTools support in isToolAllowedForMode - Add search_and_replace as a customTool in the edit group (opt-in only) - Fix tool name from 'apply_diff' to 'search_and_replace' in native tools - Add includedTools parameter to isToolAllowedForMode and validateToolUse - customTools are only allowed when explicitly included via modelInfo.includedTools - Pass includedTools from modelInfo in presentAssistantMessage for validation - Add tests for customTools functionality in modes.spec.ts This makes search_and_replace a standalone opt-in tool that doesn't replace apply_diff. Users can enable it via modelInfo.includedTools configuration. --- src/api/providers/__tests__/roo.spec.ts | 10 +-- src/api/providers/roo.ts | 4 +- .../assistant-message/NativeToolCallParser.ts | 10 +-- .../presentAssistantMessage.ts | 31 +++------ .../tools/native-tools/search_and_replace.ts | 6 +- src/core/tools/validateToolUse.ts | 14 ++++- src/shared/__tests__/modes.spec.ts | 63 +++++++++++++++++++ src/shared/modes.ts | 11 +++- 8 files changed, 107 insertions(+), 42 deletions(-) diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index 8eadb9f6943..6c30566b54e 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -102,7 +102,7 @@ vitest.mock("../../providers/fetchers/modelCache", () => ({ inputPrice: 0, outputPrice: 0, }, - "minimax/minimax-m2": { + "minimax/minimax-m2:free": { maxTokens: 32_768, contextWindow: 1_000_000, supportsImages: false, @@ -421,12 +421,12 @@ describe("RooHandler", () => { } }) - it("should apply defaultToolProtocol: native for minimax/minimax-m2", () => { + it("should apply defaultToolProtocol: native for minimax/minimax-m2:free", () => { const handlerWithMinimax = new RooHandler({ - apiModelId: "minimax/minimax-m2", + apiModelId: "minimax/minimax-m2:free", }) const modelInfo = handlerWithMinimax.getModel() - expect(modelInfo.id).toBe("minimax/minimax-m2") + expect(modelInfo.id).toBe("minimax/minimax-m2:free") expect((modelInfo.info as any).defaultToolProtocol).toBe("native") // Verify cached model info is preserved expect(modelInfo.info.maxTokens).toBe(32_768) @@ -447,7 +447,7 @@ describe("RooHandler", () => { it("should not override existing properties when applying MODEL_DEFAULTS", () => { const handlerWithMinimax = new RooHandler({ - apiModelId: "minimax/minimax-m2", + apiModelId: "minimax/minimax-m2:free", }) const modelInfo = handlerWithMinimax.getModel() // The defaults should be merged, but not overwrite existing cached values diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index 1af477060a4..14e6fe56f36 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -22,8 +22,10 @@ import type { ModelInfo } from "@roo-code/types" // Model-specific defaults that should be applied even when models come from API cache const MODEL_DEFAULTS: Record> = { - "minimax/minimax-m2": { + "minimax/minimax-m2:free": { defaultToolProtocol: "native", + includedTools: ["search_and_replace"], + excludedTools: ["apply_diff"], }, "anthropic/claude-haiku-4.5": { defaultToolProtocol: "native", diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index f1da82c5d9c..6e1e44a1a58 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -627,15 +627,7 @@ export class NativeToolCallParser { break case "apply_diff": - // Handle new operations-based apply_diff (search_and_replace renamed to apply_diff) - if (args.path !== undefined && args.operations !== undefined && Array.isArray(args.operations)) { - nativeArgs = { - path: args.path, - operations: args.operations, - } as NativeArgsFor - } - // Also handle legacy diff format for backward compatibility - else if (args.path !== undefined && args.diff !== undefined) { + if (args.path !== undefined && args.diff !== undefined) { nativeArgs = { path: args.path, diff: args.diff, diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7db79f99122..2e4b3ac4da7 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -680,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( @@ -689,6 +696,8 @@ export async function presentAssistantMessage(cline: Task) { customModes ?? [], { apply_diff: cline.diffEnabled }, block.params, + stateExperiments, + includedTools, ) } catch (error) { cline.consecutiveMistakeCount++ @@ -764,26 +773,6 @@ export async function presentAssistantMessage(cline: Task) { // Check if this tool call came from native protocol by checking for ID // Native calls always have IDs, XML calls never do if (toolProtocol === TOOL_PROTOCOL.NATIVE) { - // Check if this is the new operations-based format (search_and_replace logic) - const nativeArgs = (block as ToolUse<"apply_diff">).nativeArgs - if (nativeArgs && "operations" in nativeArgs && Array.isArray(nativeArgs.operations)) { - // Route to searchAndReplaceTool for operations-based apply_diff - // Convert the block to look like a search_and_replace tool call - const sarBlock = { - ...block, - name: "search_and_replace" as const, - nativeArgs: { path: nativeArgs.path, operations: nativeArgs.operations }, - } - await searchAndReplaceTool.handle(cline, sarBlock as ToolUse<"search_and_replace">, { - askApproval, - handleError, - pushToolResult, - removeClosingTag, - toolProtocol, - }) - break - } - // Legacy diff format - use original apply_diff handler await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, { askApproval, handleError, diff --git a/src/core/prompts/tools/native-tools/search_and_replace.ts b/src/core/prompts/tools/native-tools/search_and_replace.ts index ffde082f842..ce785b6a165 100644 --- a/src/core/prompts/tools/native-tools/search_and_replace.ts +++ b/src/core/prompts/tools/native-tools/search_and_replace.ts @@ -1,12 +1,12 @@ import type OpenAI from "openai" -const APPLY_DIFF_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_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: "apply_diff", - description: APPLY_DIFF_DESCRIPTION, + name: "search_and_replace", + description: SEARCH_AND_REPLACE_DESCRIPTION, parameters: { type: "object", properties: { 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 } From e3fd37cc8654330a0079c035f7cba05f8eedb4d6 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 27 Nov 2025 15:19:02 -0500 Subject: [PATCH 5/9] refactor: rename apply_diff_single_file to apply_diff for consistency --- src/core/prompts/tools/native-tools/apply_diff.ts | 2 +- src/core/prompts/tools/native-tools/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 9ef1839d27a..32d23459aa7 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -1,6 +1,6 @@ import type OpenAI from "openai" import accessMcpResource from "./access_mcp_resource" -import { apply_diff_single_file } from "./apply_diff" +import { apply_diff } from "./apply_diff" import askFollowupQuestion from "./ask_followup_question" import attemptCompletion from "./attempt_completion" import browserAction from "./browser_action" @@ -32,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, From 7c6fb081018d33c1e4c16449e47ca7a694231167 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 27 Nov 2025 15:20:33 -0500 Subject: [PATCH 6/9] fix: update apply_diff type to remove unnecessary operations field --- src/shared/tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/tools.ts b/src/shared/tools.ts index f6ae2ecd733..c12c7c0c7c6 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -88,7 +88,7 @@ export type NativeToolArgs = { attempt_completion: { result: string } execute_command: { command: string; cwd?: string } insert_content: { path: string; line: number; content: string } - apply_diff: { path: string; diff?: string; operations?: Array<{ search: string; replace: string }> } + apply_diff: { path: string; diff?: string } search_and_replace: { path: string; operations: Array<{ search: string; replace: string }> } ask_followup_question: { question: string From 7f2a48ccc2bdff3f45f0d7639480b0f9c4fab2a4 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Thu, 27 Nov 2025 15:22:30 -0500 Subject: [PATCH 7/9] fix: enforce required 'diff' field in apply_diff arguments --- src/shared/tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/tools.ts b/src/shared/tools.ts index c12c7c0c7c6..71776cf1090 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -88,7 +88,7 @@ export type NativeToolArgs = { attempt_completion: { result: string } execute_command: { command: string; cwd?: string } insert_content: { path: string; line: number; content: string } - apply_diff: { path: string; diff?: string } + apply_diff: { path: string; diff: string } search_and_replace: { path: string; operations: Array<{ search: string; replace: string }> } ask_followup_question: { question: string From f71b3c35167c9847c6ad68e46b465d204b2c9f34 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 27 Nov 2025 20:10:12 -0700 Subject: [PATCH 8/9] chore: remove Roo provider changes from search_and_replace PR --- src/api/providers/__tests__/roo.spec.ts | 10 +++++----- src/api/providers/roo.ts | 4 +--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index 6c30566b54e..8eadb9f6943 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -102,7 +102,7 @@ vitest.mock("../../providers/fetchers/modelCache", () => ({ inputPrice: 0, outputPrice: 0, }, - "minimax/minimax-m2:free": { + "minimax/minimax-m2": { maxTokens: 32_768, contextWindow: 1_000_000, supportsImages: false, @@ -421,12 +421,12 @@ describe("RooHandler", () => { } }) - it("should apply defaultToolProtocol: native for minimax/minimax-m2:free", () => { + it("should apply defaultToolProtocol: native for minimax/minimax-m2", () => { const handlerWithMinimax = new RooHandler({ - apiModelId: "minimax/minimax-m2:free", + apiModelId: "minimax/minimax-m2", }) const modelInfo = handlerWithMinimax.getModel() - expect(modelInfo.id).toBe("minimax/minimax-m2:free") + expect(modelInfo.id).toBe("minimax/minimax-m2") expect((modelInfo.info as any).defaultToolProtocol).toBe("native") // Verify cached model info is preserved expect(modelInfo.info.maxTokens).toBe(32_768) @@ -447,7 +447,7 @@ describe("RooHandler", () => { it("should not override existing properties when applying MODEL_DEFAULTS", () => { const handlerWithMinimax = new RooHandler({ - apiModelId: "minimax/minimax-m2:free", + apiModelId: "minimax/minimax-m2", }) const modelInfo = handlerWithMinimax.getModel() // The defaults should be merged, but not overwrite existing cached values diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index 14e6fe56f36..1af477060a4 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -22,10 +22,8 @@ import type { ModelInfo } from "@roo-code/types" // Model-specific defaults that should be applied even when models come from API cache const MODEL_DEFAULTS: Record> = { - "minimax/minimax-m2:free": { + "minimax/minimax-m2": { defaultToolProtocol: "native", - includedTools: ["search_and_replace"], - excludedTools: ["apply_diff"], }, "anthropic/claude-haiku-4.5": { defaultToolProtocol: "native", From dc64f162ff6f9fb2d0ab8c74603f60d8ca5b5b26 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 27 Nov 2025 21:04:47 -0700 Subject: [PATCH 9/9] fix: add isPathOutsideWorkspace check to SearchAndReplaceTool Adds the missing isPathOutsideWorkspace check for consistency with other file-modifying tools like WriteToFileTool, ReadFileTool, and GenerateImageTool. This ensures files outside the workspace are properly flagged in the UI for user awareness during approval. --- src/core/tools/SearchAndReplaceTool.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/tools/SearchAndReplaceTool.ts b/src/core/tools/SearchAndReplaceTool.ts index 8b647e7cc5e..49d159f4557 100644 --- a/src/core/tools/SearchAndReplaceTool.ts +++ b/src/core/tools/SearchAndReplaceTool.ts @@ -2,6 +2,7 @@ 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" @@ -184,11 +185,13 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { 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 @@ -277,10 +280,14 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { } } + 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(() => {})