feat: add multi-file apply_diff support for native tool calling protocol

- Create multi_apply_diff.ts schema with files array parameter
- Add createApplyDiffTool factory function to swap schemas based on experiment flag
- Update getNativeTools to accept multiFileApplyDiffEnabled parameter
- Update NativeToolCallParser to handle both single-file and multi-file formats
- Update presentAssistantMessage to route based on nativeArgs format
- Update MultiApplyDiffTool to handle nativeArgs.files array
- Update NativeToolArgs type to union type supporting both formats
- Add 8 tests for apply_diff parsing in both formats
This commit is contained in:
Hannes Rudolph 2025-12-15 11:34:00 -07:00
parent d92d729219
commit 7e52525523
9 changed files with 393 additions and 51 deletions

View file

@ -400,7 +400,17 @@ export class NativeToolCallParser {
break
case "apply_diff":
if (partialArgs.path !== undefined || partialArgs.diff !== undefined) {
// Multi-file format (from multi_apply_diff schema)
if (partialArgs.files && Array.isArray(partialArgs.files)) {
nativeArgs = {
files: partialArgs.files.map((f: any) => ({
path: f.path,
diff: f.diff,
})),
}
}
// Single-file format (from apply_diff schema)
else if (partialArgs.path !== undefined || partialArgs.diff !== undefined) {
nativeArgs = {
path: partialArgs.path,
diff: partialArgs.diff,
@ -633,7 +643,17 @@ export class NativeToolCallParser {
break
case "apply_diff":
if (args.path !== undefined && args.diff !== undefined) {
// Multi-file format (from multi_apply_diff schema)
if (args.files && Array.isArray(args.files)) {
nativeArgs = {
files: args.files.map((f: any) => ({
path: f.path,
diff: f.diff,
})),
} as NativeArgsFor<TName>
}
// Single-file format (from apply_diff schema)
else if (args.path !== undefined && args.diff !== undefined) {
nativeArgs = {
path: args.path,
diff: args.diff,

View file

@ -237,5 +237,206 @@ describe("NativeToolCallParser", () => {
}
})
})
describe("parseToolCall", () => {
describe("apply_diff tool", () => {
it("should handle single-file format (path and diff)", () => {
const toolCall = {
id: "toolu_123",
name: "apply_diff" as const,
arguments: JSON.stringify({
path: "src/test.ts",
diff: "<<<<<<< SEARCH\nold code\n=======\nnew code\n>>>>>>> REPLACE",
}),
}
const result = NativeToolCallParser.parseToolCall(toolCall)
expect(result).not.toBeNull()
expect(result?.type).toBe("tool_use")
if (result?.type === "tool_use") {
expect(result.nativeArgs).toBeDefined()
const nativeArgs = result.nativeArgs as { path: string; diff: string }
expect(nativeArgs.path).toBe("src/test.ts")
expect(nativeArgs.diff).toContain("<<<<<<< SEARCH")
expect(nativeArgs.diff).toContain(">>>>>>> REPLACE")
}
})
it("should handle multi-file format (files array)", () => {
const toolCall = {
id: "toolu_456",
name: "apply_diff" as const,
arguments: JSON.stringify({
files: [
{
path: "src/file1.ts",
diff: "<<<<<<< SEARCH\nold code 1\n=======\nnew code 1\n>>>>>>> REPLACE",
},
{
path: "src/file2.ts",
diff: "<<<<<<< SEARCH\nold code 2\n=======\nnew code 2\n>>>>>>> REPLACE",
},
],
}),
}
const result = NativeToolCallParser.parseToolCall(toolCall)
expect(result).not.toBeNull()
expect(result?.type).toBe("tool_use")
if (result?.type === "tool_use") {
expect(result.nativeArgs).toBeDefined()
const nativeArgs = result.nativeArgs as {
files: Array<{ path: string; diff: string }>
}
expect(nativeArgs.files).toHaveLength(2)
expect(nativeArgs.files[0].path).toBe("src/file1.ts")
expect(nativeArgs.files[0].diff).toContain("old code 1")
expect(nativeArgs.files[1].path).toBe("src/file2.ts")
expect(nativeArgs.files[1].diff).toContain("old code 2")
}
})
it("should handle multi-file format with single file", () => {
const toolCall = {
id: "toolu_789",
name: "apply_diff" as const,
arguments: JSON.stringify({
files: [
{
path: "src/single.ts",
diff: "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE",
},
],
}),
}
const result = NativeToolCallParser.parseToolCall(toolCall)
expect(result).not.toBeNull()
expect(result?.type).toBe("tool_use")
if (result?.type === "tool_use") {
const nativeArgs = result.nativeArgs as {
files: Array<{ path: string; diff: string }>
}
expect(nativeArgs.files).toHaveLength(1)
expect(nativeArgs.files[0].path).toBe("src/single.ts")
}
})
})
})
describe("processStreamingChunk", () => {
describe("apply_diff tool", () => {
it("should handle single-file format during streaming", () => {
const id = "toolu_streaming_apply_diff_single"
NativeToolCallParser.startStreamingToolCall(id, "apply_diff")
const fullArgs = JSON.stringify({
path: "streaming/test.ts",
diff: "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE",
})
const result = NativeToolCallParser.processStreamingChunk(id, fullArgs)
expect(result).not.toBeNull()
expect(result?.nativeArgs).toBeDefined()
const nativeArgs = result?.nativeArgs as { path: string; diff: string }
expect(nativeArgs.path).toBe("streaming/test.ts")
expect(nativeArgs.diff).toContain("<<<<<<< SEARCH")
})
it("should handle multi-file format during streaming", () => {
const id = "toolu_streaming_apply_diff_multi"
NativeToolCallParser.startStreamingToolCall(id, "apply_diff")
const fullArgs = JSON.stringify({
files: [
{
path: "streaming/file1.ts",
diff: "<<<<<<< SEARCH\nold1\n=======\nnew1\n>>>>>>> REPLACE",
},
{
path: "streaming/file2.ts",
diff: "<<<<<<< SEARCH\nold2\n=======\nnew2\n>>>>>>> REPLACE",
},
],
})
const result = NativeToolCallParser.processStreamingChunk(id, fullArgs)
expect(result).not.toBeNull()
expect(result?.nativeArgs).toBeDefined()
const nativeArgs = result?.nativeArgs as {
files: Array<{ path: string; diff: string }>
}
expect(nativeArgs.files).toHaveLength(2)
expect(nativeArgs.files[0].path).toBe("streaming/file1.ts")
expect(nativeArgs.files[1].path).toBe("streaming/file2.ts")
})
})
})
describe("finalizeStreamingToolCall", () => {
describe("apply_diff tool", () => {
it("should finalize single-file format correctly", () => {
const id = "toolu_finalize_apply_diff_single"
NativeToolCallParser.startStreamingToolCall(id, "apply_diff")
NativeToolCallParser.processStreamingChunk(
id,
JSON.stringify({
path: "finalized/single.ts",
diff: "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE",
}),
)
const result = NativeToolCallParser.finalizeStreamingToolCall(id)
expect(result).not.toBeNull()
expect(result?.type).toBe("tool_use")
if (result?.type === "tool_use") {
const nativeArgs = result.nativeArgs as { path: string; diff: string }
expect(nativeArgs.path).toBe("finalized/single.ts")
expect(nativeArgs.diff).toContain("<<<<<<< SEARCH")
}
})
it("should finalize multi-file format correctly", () => {
const id = "toolu_finalize_apply_diff_multi"
NativeToolCallParser.startStreamingToolCall(id, "apply_diff")
NativeToolCallParser.processStreamingChunk(
id,
JSON.stringify({
files: [
{
path: "finalized/file1.ts",
diff: "<<<<<<< SEARCH\nold1\n=======\nnew1\n>>>>>>> REPLACE",
},
{
path: "finalized/file2.ts",
diff: "<<<<<<< SEARCH\nold2\n=======\nnew2\n>>>>>>> REPLACE",
},
],
}),
)
const result = NativeToolCallParser.finalizeStreamingToolCall(id)
expect(result).not.toBeNull()
expect(result?.type).toBe("tool_use")
if (result?.type === "tool_use") {
const nativeArgs = result.nativeArgs as {
files: Array<{ path: string; diff: string }>
}
expect(nativeArgs.files).toHaveLength(2)
expect(nativeArgs.files[0].path).toBe("finalized/file1.ts")
expect(nativeArgs.files[1].path).toBe("finalized/file2.ts")
}
})
})
})
})
})

View file

@ -377,8 +377,24 @@ export async function presentAssistantMessage(cline: Task) {
case "write_to_file":
return `[${block.name} for '${block.params.path}']`
case "apply_diff":
// Handle both legacy format and new multi-file format
if (block.params.path) {
// Handle native multi-file format (from multi_apply_diff schema)
if (block.nativeArgs?.files && Array.isArray(block.nativeArgs.files)) {
const files = block.nativeArgs.files
const firstPath = files[0]?.path
if (firstPath) {
if (files.length > 1) {
return `[${block.name} for '${firstPath}' and ${files.length - 1} more file${files.length > 2 ? "s" : ""}]`
} else {
return `[${block.name} for '${firstPath}']`
}
}
}
// Handle native single-file format
else if (block.nativeArgs?.path) {
return `[${block.name} for '${block.nativeArgs.path}']`
}
// Handle XML legacy format
else if (block.params.path) {
return `[${block.name} for '${block.params.path}']`
} else if (block.params.args) {
// Try to extract first file path from args for display
@ -817,17 +833,38 @@ 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) {
await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
askApproval,
handleError,
pushToolResult,
removeClosingTag,
toolProtocol,
})
// For native protocol, route based on nativeArgs format:
// - nativeArgs.files (array) -> multi-file tool (from multi_apply_diff schema)
// - nativeArgs.path (string) -> single-file tool (from apply_diff schema)
const nativeArgs = block.nativeArgs as
| { files: Array<{ path: string; diff: string }> }
| { path: string; diff: string }
| undefined
if (nativeArgs && "files" in nativeArgs && Array.isArray(nativeArgs.files)) {
// Multi-file format: use MultiApplyDiffTool
await applyDiffTool(
cline,
block,
askApproval,
handleError,
pushToolResult,
removeClosingTag,
)
} else {
// Single-file format: use ApplyDiffTool
await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
askApproval,
handleError,
pushToolResult,
removeClosingTag,
toolProtocol,
})
}
break
}
// Get the provider and state to check experiment settings
// For XML protocol, check experiment settings to determine routing
const provider = cline.providerRef.deref()
let isMultiFileApplyDiffEnabled = false

View file

@ -1,4 +1,5 @@
import type OpenAI from "openai"
import { multi_apply_diff } from "./multi_apply_diff"
const APPLY_DIFF_DESCRIPTION = `Apply precise, targeted modifications to an existing file using one or more search/replace blocks. This tool is for surgical edits only; the 'SEARCH' block must exactly match the existing content, including whitespace and indentation. To make multiple targeted changes, provide multiple SEARCH/REPLACE blocks in the 'diff' parameter. Use the 'read_file' tool first if you are not confident in the exact content to search for.`
@ -33,3 +34,14 @@ export const apply_diff = {
},
},
} satisfies OpenAI.Chat.ChatCompletionTool
/**
* Creates the apply_diff tool definition, selecting between single-file and multi-file
* schemas based on whether the multi-file experiment is enabled.
*
* @param multiFileEnabled - Whether to use the multi-file schema (default: false)
* @returns Native tool definition for apply_diff
*/
export function createApplyDiffTool(multiFileEnabled: boolean = false): OpenAI.Chat.ChatCompletionTool {
return multiFileEnabled ? multi_apply_diff : apply_diff
}

View file

@ -1,6 +1,6 @@
import type OpenAI from "openai"
import accessMcpResource from "./access_mcp_resource"
import { apply_diff } from "./apply_diff"
import { createApplyDiffTool } from "./apply_diff"
import applyPatch from "./apply_patch"
import askFollowupQuestion from "./ask_followup_question"
import attemptCompletion from "./attempt_completion"
@ -27,12 +27,16 @@ export { convertOpenAIToolToAnthropic, convertOpenAIToolsToAnthropic } from "./c
* Get native tools array, optionally customizing based on settings.
*
* @param partialReadsEnabled - Whether to include line_ranges support in read_file tool (default: true)
* @param multiFileApplyDiffEnabled - Whether to use multi-file apply_diff schema (default: false)
* @returns Array of native tool definitions
*/
export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat.ChatCompletionTool[] {
export function getNativeTools(
partialReadsEnabled: boolean = true,
multiFileApplyDiffEnabled: boolean = false,
): OpenAI.Chat.ChatCompletionTool[] {
return [
accessMcpResource,
apply_diff,
createApplyDiffTool(multiFileApplyDiffEnabled),
applyPatch,
askFollowupQuestion,
attemptCompletion,

View file

@ -0,0 +1,54 @@
import type OpenAI from "openai"
const MULTI_APPLY_DIFF_DESCRIPTION = `Apply precise, targeted modifications to one or more files using search/replace blocks. This tool supports batch operations across multiple files in a single request, maximizing efficiency. For each file, the 'SEARCH' block must exactly match the existing content, including whitespace and indentation. Use the 'read_file' tool first if you are not confident in the exact content to search for.`
const DIFF_PARAMETER_DESCRIPTION = `A string containing one or more search/replace blocks defining the changes. The ':start_line:' is required and indicates the starting line number of the original content. You must not add a start line for the replacement content. Each block must follow this format:
<<<<<<< SEARCH
:start_line:[line_number]
-------
[exact content to find]
=======
[new content to replace with]
>>>>>>> REPLACE`
/**
* Multi-file apply_diff schema for native tool calling.
* This schema is used when the MULTI_FILE_APPLY_DIFF experiment is enabled.
* It allows batch operations across multiple files in a single tool call.
*/
export const multi_apply_diff = {
type: "function",
function: {
name: "apply_diff", // Same name - model sees "apply_diff"
description: MULTI_APPLY_DIFF_DESCRIPTION,
parameters: {
type: "object",
properties: {
files: {
type: "array",
description:
"List of files to modify with their diffs. Include multiple files to batch related changes efficiently.",
items: {
type: "object",
properties: {
path: {
type: "string",
description:
"The path of the file to modify, relative to the current workspace directory.",
},
diff: {
type: "string",
description: DIFF_PARAMETER_DESCRIPTION,
},
},
required: ["path", "diff"],
additionalProperties: false,
},
minItems: 1,
},
},
required: ["files"],
additionalProperties: false,
},
},
} satisfies OpenAI.Chat.ChatCompletionTool

View file

@ -3,6 +3,7 @@ import type { ProviderSettings, ModeConfig, ModelInfo } from "@roo-code/types"
import type { ClineProvider } from "../webview/ClineProvider"
import { getNativeTools, getMcpServerTools } from "../prompts/tools/native-tools"
import { filterNativeToolsForMode, filterMcpToolsForMode } from "../prompts/tools/filter-tools-for-mode"
import { experiments as experimentsModule, EXPERIMENT_IDS } from "../../shared/experiments"
interface BuildToolsOptions {
provider: ClineProvider
@ -55,8 +56,14 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise
// Determine if partial reads are enabled based on maxReadFileLine setting
const partialReadsEnabled = maxReadFileLine !== -1
// Build native tools with dynamic read_file tool based on partialReadsEnabled
const nativeTools = getNativeTools(partialReadsEnabled)
// Determine if multi-file apply_diff is enabled based on experiment flag
const multiFileApplyDiffEnabled = experimentsModule.isEnabled(
experiments ?? {},
EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF,
)
// Build native tools with dynamic read_file and apply_diff tools based on settings
const nativeTools = getNativeTools(partialReadsEnabled, multiFileApplyDiffEnabled)
// Filter native tools based on mode restrictions
const filteredNativeTools = filterNativeToolsForMode(

View file

@ -61,45 +61,20 @@ export async function applyDiffTool(
pushToolResult: PushToolResult,
removeClosingTag: RemoveClosingTag,
) {
// Check if native protocol is enabled - if so, always use single-file class-based tool
const toolProtocol = resolveToolProtocol(cline.apiConfiguration, cline.api.getModel().info)
if (isNativeProtocol(toolProtocol)) {
return applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
askApproval,
handleError,
pushToolResult,
removeClosingTag,
toolProtocol,
})
}
// Check if MULTI_FILE_APPLY_DIFF experiment is enabled
const provider = cline.providerRef.deref()
const state = await provider?.getState()
if (provider && state) {
const isMultiFileApplyDiffEnabled = experiments.isEnabled(
state.experiments ?? {},
EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF,
)
// Note: Routing between single-file and multi-file tools is now done in presentAssistantMessage.ts
// based on nativeArgs format. This function is only called for multi-file operations.
// If experiment is disabled, use single-file class-based tool
if (!isMultiFileApplyDiffEnabled) {
return applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
askApproval,
handleError,
pushToolResult,
removeClosingTag,
toolProtocol,
})
}
}
// Otherwise, continue with new multi-file implementation
// Multi-file implementation
const argsXmlTag: string | undefined = block.params.args
const legacyPath: string | undefined = block.params.path
const legacyDiffContent: string | undefined = block.params.diff
const legacyStartLineStr: string | undefined = block.params.start_line
// Native multi-file format from nativeArgs.files
const nativeFiles = (block.nativeArgs as { files?: Array<{ path: string; diff: string }> } | undefined)?.files
let operationsMap: Record<string, DiffOperation> = {}
let usingLegacyParams = false
let filteredOperationErrors: string[] = []
@ -107,7 +82,12 @@ export async function applyDiffTool(
// Handle partial message first
if (block.partial) {
let filePath = ""
if (argsXmlTag) {
// Native multi-file format
if (nativeFiles && nativeFiles.length > 0) {
filePath = nativeFiles[0].path || ""
}
// XML args format
else if (argsXmlTag) {
const match = argsXmlTag.match(/<file>.*?<path>([^<]+)<\/path>/s)
if (match) {
filePath = match[1]
@ -126,7 +106,33 @@ export async function applyDiffTool(
return
}
if (argsXmlTag) {
// Handle native multi-file format (from nativeArgs.files via multi_apply_diff schema)
if (nativeFiles && nativeFiles.length > 0) {
for (const file of nativeFiles) {
if (!file.path || !file.diff) continue
const filePath = file.path
// Initialize the operation in the map if it doesn't exist
if (!operationsMap[filePath]) {
operationsMap[filePath] = {
path: filePath,
diff: [],
}
}
// Native format has a single diff content per file entry
// The diff content contains the full SEARCH/REPLACE block(s)
if (file.diff) {
operationsMap[filePath].diff.push({
content: file.diff,
startLine: undefined, // Native format doesn't include start_line per file
})
}
}
}
// Handle XML args format (from XML protocol)
else if (argsXmlTag) {
// Parse file entries from XML (new way)
try {
// IMPORTANT: We use parseXmlForDiff here instead of parseXml to prevent HTML entity decoding

View file

@ -90,7 +90,8 @@ export type NativeToolArgs = {
read_file: { files: FileEntry[] }
attempt_completion: { result: string }
execute_command: { command: string; cwd?: string }
apply_diff: { path: string; diff: string }
// Union type to support both single-file and multi-file formats
apply_diff: { path: string; diff: string } | { files: Array<{ path: string; diff: string }> }
search_and_replace: { path: string; operations: Array<{ search: string; replace: string }> }
search_replace: { file_path: string; old_string: string; new_string: string }
apply_patch: { patch: string }