Skip to content

Commit 8a69e9e

Browse files
feat: rename search_and_replace tool to edit and unify edit-family UI (#11296)
1 parent 097f648 commit 8a69e9e

22 files changed

Lines changed: 1268 additions & 970 deletions

packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ describe("CustomToolRegistry", () => {
281281
const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
282282

283283
expect(result.loaded).toContain("cached")
284-
}, 30000)
284+
}, 120_000)
285285
})
286286

287287
describe.sequential("loadFromDirectories", () => {

packages/types/src/tool.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const toolNames = [
2020
"read_command_output",
2121
"write_to_file",
2222
"apply_diff",
23+
"edit",
2324
"search_and_replace",
2425
"search_replace",
2526
"edit_file",

src/core/assistant-message/NativeToolCallParser.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -599,11 +599,18 @@ export class NativeToolCallParser {
599599
}
600600
break
601601

602+
case "edit":
602603
case "search_and_replace":
603-
if (partialArgs.path !== undefined || partialArgs.operations !== undefined) {
604+
if (
605+
partialArgs.file_path !== undefined ||
606+
partialArgs.old_string !== undefined ||
607+
partialArgs.new_string !== undefined
608+
) {
604609
nativeArgs = {
605-
path: partialArgs.path,
606-
operations: partialArgs.operations,
610+
file_path: partialArgs.file_path,
611+
old_string: partialArgs.old_string,
612+
new_string: partialArgs.new_string,
613+
replace_all: this.coerceOptionalBoolean(partialArgs.replace_all),
607614
}
608615
}
609616
break
@@ -806,11 +813,18 @@ export class NativeToolCallParser {
806813
}
807814
break
808815

816+
case "edit":
809817
case "search_and_replace":
810-
if (args.path !== undefined && args.operations !== undefined && Array.isArray(args.operations)) {
818+
if (
819+
args.file_path !== undefined &&
820+
args.old_string !== undefined &&
821+
args.new_string !== undefined
822+
) {
811823
nativeArgs = {
812-
path: args.path,
813-
operations: args.operations,
824+
file_path: args.file_path,
825+
old_string: args.old_string,
826+
new_string: args.new_string,
827+
replace_all: this.coerceOptionalBoolean(args.replace_all),
814828
} as NativeArgsFor<TName>
815829
}
816830
break

src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, it, expect, beforeEach, vi } from "vitest"
44
import { presentAssistantMessage } from "../presentAssistantMessage"
5+
import { validateToolUse } from "../../tools/validateToolUse"
56

67
// Mock dependencies
78
vi.mock("../../task/Task")
@@ -301,6 +302,44 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
301302
})
302303
})
303304

305+
describe("Validation requirements", () => {
306+
it("normalizes disabledTools aliases before validateToolUse", async () => {
307+
const toolCallId = "tool_call_validation_alias_123"
308+
mockTask.assistantMessageContent = [
309+
{
310+
type: "tool_use",
311+
id: toolCallId,
312+
name: "some_unknown_tool",
313+
params: {},
314+
partial: false,
315+
},
316+
]
317+
318+
mockTask.providerRef = {
319+
deref: () => ({
320+
getState: vi.fn().mockResolvedValue({
321+
mode: "code",
322+
customModes: [],
323+
experiments: {
324+
customTools: false,
325+
},
326+
disabledTools: ["search_and_replace"],
327+
}),
328+
}),
329+
}
330+
331+
await presentAssistantMessage(mockTask)
332+
333+
const validateToolUseMock = vi.mocked(validateToolUse)
334+
expect(validateToolUseMock).toHaveBeenCalled()
335+
const toolRequirements = validateToolUseMock.mock.calls[0][3]
336+
expect(toolRequirements).toMatchObject({
337+
search_and_replace: false,
338+
edit: false,
339+
})
340+
})
341+
})
342+
304343
describe("Partial blocks", () => {
305344
it("should not record usage for partial custom tool blocks", async () => {
306345
mockTask.assistantMessageContent = [

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 7 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { listFilesTool } from "../tools/ListFilesTool"
1818
import { readFileTool } from "../tools/ReadFileTool"
1919
import { readCommandOutputTool } from "../tools/ReadCommandOutputTool"
2020
import { writeToFileTool } from "../tools/WriteToFileTool"
21-
import { searchAndReplaceTool } from "../tools/SearchAndReplaceTool"
21+
import { editTool } from "../tools/EditTool"
2222
import { searchReplaceTool } from "../tools/SearchReplaceTool"
2323
import { editFileTool } from "../tools/EditFileTool"
2424
import { applyPatchTool } from "../tools/ApplyPatchTool"
@@ -291,18 +291,6 @@ export async function presentAssistantMessage(cline: Task) {
291291
// Strip any streamed <thinking> tags from text output.
292292
content = content.replace(/<thinking>\s?/g, "")
293293
content = content.replace(/\s?<\/thinking>/g, "")
294-
295-
// Tool calling is native-only. If the model emits XML-style tool tags in a text block,
296-
// fail fast with a clear error.
297-
if (containsXmlToolMarkup(content)) {
298-
const errorMessage =
299-
"XML tool calls are no longer supported. Remove any XML tool markup (e.g. <read_file>...</read_file>) and use native tool calling instead."
300-
cline.consecutiveMistakeCount++
301-
await cline.say("error", errorMessage)
302-
cline.userMessageContent.push({ type: "text", text: errorMessage })
303-
cline.didAlreadyUseTool = true
304-
break
305-
}
306294
}
307295

308296
await cline.say("text", content, undefined, block.partial)
@@ -357,8 +345,9 @@ export async function presentAssistantMessage(cline: Task) {
357345
return `[${block.name} for '${block.params.regex}'${
358346
block.params.file_pattern ? ` in '${block.params.file_pattern}'` : ""
359347
}]`
348+
case "edit":
360349
case "search_and_replace":
361-
return `[${block.name} for '${block.params.path}']`
350+
return `[${block.name} for '${block.params.file_path}']`
362351
case "search_replace":
363352
return `[${block.name} for '${block.params.file_path}']`
364353
case "edit_file":
@@ -629,6 +618,8 @@ export async function presentAssistantMessage(cline: Task) {
629618
disabledTools?.reduce(
630619
(acc: Record<string, boolean>, tool: string) => {
631620
acc[tool] = false
621+
const resolvedToolName = resolveToolAlias(tool)
622+
acc[resolvedToolName] = false
632623
return acc
633624
},
634625
{} as Record<string, boolean>,
@@ -739,9 +730,10 @@ export async function presentAssistantMessage(cline: Task) {
739730
pushToolResult,
740731
})
741732
break
733+
case "edit":
742734
case "search_and_replace":
743735
await checkpointSaveAndMark(cline)
744-
await searchAndReplaceTool.handle(cline, block as ToolUse<"search_and_replace">, {
736+
await editTool.handle(cline, block as ToolUse<"edit">, {
745737
askApproval,
746738
handleError,
747739
pushToolResult,
@@ -1040,47 +1032,3 @@ async function checkpointSaveAndMark(task: Task) {
10401032
console.error(`[Task#presentAssistantMessage] Error saving checkpoint: ${error.message}`, error)
10411033
}
10421034
}
1043-
1044-
function containsXmlToolMarkup(text: string): boolean {
1045-
// Keep this intentionally narrow: only reject XML-style tool tags matching our tool names.
1046-
// Avoid regex so we don't keep legacy XML parsing artifacts around.
1047-
// Note: This is a best-effort safeguard; tool_use blocks without an id are rejected elsewhere.
1048-
1049-
// First, strip out content inside markdown code fences to avoid false positives
1050-
// when users paste documentation or examples containing tool tag references.
1051-
// This handles both fenced code blocks (```) and inline code (`).
1052-
const textWithoutCodeBlocks = text
1053-
.replace(/```[\s\S]*?```/g, "") // Remove fenced code blocks
1054-
.replace(/`[^`]+`/g, "") // Remove inline code
1055-
1056-
const lower = textWithoutCodeBlocks.toLowerCase()
1057-
if (!lower.includes("<") || !lower.includes(">")) {
1058-
return false
1059-
}
1060-
1061-
const toolNames = [
1062-
"access_mcp_resource",
1063-
"apply_diff",
1064-
"apply_patch",
1065-
"ask_followup_question",
1066-
"attempt_completion",
1067-
"browser_action",
1068-
"codebase_search",
1069-
"edit_file",
1070-
"execute_command",
1071-
"generate_image",
1072-
"list_files",
1073-
"new_task",
1074-
"read_command_output",
1075-
"read_file",
1076-
"search_and_replace",
1077-
"search_files",
1078-
"search_replace",
1079-
"switch_mode",
1080-
"update_todo_list",
1081-
"use_mcp_tool",
1082-
"write_to_file",
1083-
] as const
1084-
1085-
return toolNames.some((name) => lower.includes(`<${name}`) || lower.includes(`</${name}`))
1086-
}

src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ describe("filterNativeToolsForMode - disabledTools", () => {
2222
makeTool("write_to_file"),
2323
makeTool("browser_action"),
2424
makeTool("apply_diff"),
25+
makeTool("edit"),
2526
]
2627

2728
it("removes tools listed in settings.disabledTools", () => {
@@ -77,4 +78,19 @@ describe("filterNativeToolsForMode - disabledTools", () => {
7778
expect(resultNames).not.toContain("browser_action")
7879
expect(resultNames).toContain("read_file")
7980
})
81+
82+
it("disables canonical tool when disabledTools contains alias name", () => {
83+
const settings = {
84+
disabledTools: ["search_and_replace"],
85+
modelInfo: {
86+
includedTools: ["search_and_replace"],
87+
},
88+
}
89+
90+
const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings)
91+
92+
const resultNames = result.map((t) => (t as any).function.name)
93+
expect(resultNames).not.toContain("search_and_replace")
94+
expect(resultNames).not.toContain("edit")
95+
})
8096
})

src/core/prompts/tools/filter-tools-for-mode.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,10 @@ export function filterNativeToolsForMode(
299299
// Remove tools that are explicitly disabled via the disabledTools setting
300300
if (settings?.disabledTools?.length) {
301301
for (const toolName of settings.disabledTools) {
302-
allowedToolNames.delete(toolName)
302+
// Normalize aliases so disabling a legacy alias (e.g. "search_and_replace")
303+
// also disables the canonical tool (e.g. "edit").
304+
const resolvedToolName = resolveToolAlias(toolName)
305+
allowedToolNames.delete(resolvedToolName)
303306
}
304307
}
305308

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type OpenAI from "openai"
2+
3+
const EDIT_DESCRIPTION = `Performs exact string replacements in files.
4+
5+
Usage:
6+
- You must use your \`Read\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
7+
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
8+
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
9+
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
10+
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
11+
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`
12+
13+
const edit = {
14+
type: "function",
15+
function: {
16+
name: "edit",
17+
description: EDIT_DESCRIPTION,
18+
parameters: {
19+
type: "object",
20+
properties: {
21+
file_path: {
22+
type: "string",
23+
description: "The path of the file to edit (relative to the working directory)",
24+
},
25+
old_string: {
26+
type: "string",
27+
description:
28+
"The exact text to find in the file. Must match exactly, including all whitespace, indentation, and line endings.",
29+
},
30+
new_string: {
31+
type: "string",
32+
description:
33+
"The replacement text that will replace old_string. Must include all necessary whitespace and indentation.",
34+
},
35+
replace_all: {
36+
type: "boolean",
37+
description:
38+
"When true, replaces ALL occurrences of old_string in the file. When false (default), only replaces the first occurrence and errors if multiple matches exist.",
39+
default: false,
40+
},
41+
},
42+
required: ["file_path", "old_string", "new_string"],
43+
additionalProperties: false,
44+
},
45+
},
46+
} satisfies OpenAI.Chat.ChatCompletionTool
47+
48+
export default edit

src/core/prompts/tools/native-tools/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import askFollowupQuestion from "./ask_followup_question"
66
import attemptCompletion from "./attempt_completion"
77
import browserAction from "./browser_action"
88
import codebaseSearch from "./codebase_search"
9+
import editTool from "./edit"
910
import executeCommand from "./execute_command"
1011
import generateImage from "./generate_image"
1112
import listFiles from "./list_files"
@@ -14,7 +15,6 @@ import readCommandOutput from "./read_command_output"
1415
import { createReadFileTool, type ReadFileToolOptions } from "./read_file"
1516
import runSlashCommand from "./run_slash_command"
1617
import skill from "./skill"
17-
import searchAndReplace from "./search_and_replace"
1818
import searchReplace from "./search_replace"
1919
import edit_file from "./edit_file"
2020
import searchFiles from "./search_files"
@@ -63,9 +63,9 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch
6363
createReadFileTool(readFileOptions),
6464
runSlashCommand,
6565
skill,
66-
searchAndReplace,
6766
searchReplace,
6867
edit_file,
68+
editTool,
6969
searchFiles,
7070
switchMode,
7171
updateTodoList,

src/core/prompts/tools/native-tools/search_and_replace.ts

Lines changed: 0 additions & 44 deletions
This file was deleted.

0 commit comments

Comments
 (0)