Skip to content

Commit fcfed58

Browse files
committed
feat: enhanced apply_diff
1 parent a4abf4f commit fcfed58

File tree

8 files changed

+316
-11
lines changed

8 files changed

+316
-11
lines changed

src/core/assistant-message/AssistantMessageParser.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ export class AssistantMessageParser {
6363
const char = chunk[i]
6464
this.accumulator += char
6565
const currentPosition = accumulatorStartLength + i
66+
if (this.currentToolUse && toolCallParam?.anthropicContent) {
67+
this.currentToolUse.toolUseParam = toolCallParam.anthropicContent
68+
}
6669

6770
// There should not be a param without a tool use.
6871
if (this.currentToolUse && this.currentParamName) {
@@ -99,9 +102,6 @@ export class AssistantMessageParser {
99102
const currentToolValue = this.accumulator.slice(this.currentToolUseStartIndex)
100103
const toolUseClosingTag = `</${this.currentToolUse.name}>`
101104
if (currentToolValue.endsWith(toolUseClosingTag)) {
102-
if (toolCallParam?.anthropicContent && this.currentToolUse) {
103-
this.currentToolUse.toolUseParam = toolCallParam.anthropicContent
104-
}
105105
// End of a tool use.
106106
this.currentToolUse.partial = false
107107

src/core/assistant-message/parseAssistantMessage.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ export function parseAssistantMessage(
2121
for (let i = 0; i < assistantMessage.length; i++) {
2222
const char = assistantMessage[i]
2323
accumulator += char
24-
24+
// During streaming, opportunistically attach temporary toolUseParam (if available)
25+
if (currentToolUse && toolCallParam?.anthropicContent) {
26+
currentToolUse.toolUseParam = toolCallParam.anthropicContent
27+
}
2528
// There should not be a param without a tool use.
2629
if (currentToolUse && currentParamName) {
2730
const currentParamValue = accumulator.slice(currentParamValueStartIndex)
@@ -48,9 +51,6 @@ export function parseAssistantMessage(
4851
const currentToolValue = accumulator.slice(currentToolUseStartIndex)
4952
const toolUseClosingTag = `</${currentToolUse.name}>`
5053
if (currentToolValue.endsWith(toolUseClosingTag)) {
51-
if (toolCallParam?.anthropicContent && currentToolUse) {
52-
currentToolUse.toolUseParam = toolCallParam.anthropicContent
53-
}
5454
// End of a tool use.
5555
currentToolUse.partial = false
5656
contentBlocks.push(currentToolUse)

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ export async function presentAssistantMessage(cline: Task) {
492492
)
493493
}
494494

495-
if (isMultiFileApplyDiffEnabled || toolCallEnabled) {
495+
if (isMultiFileApplyDiffEnabled) {
496496
await checkpointSaveAndMark(cline)
497497
await applyDiffTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
498498
} else {

src/core/prompts/tools/schemas/apply-diff-schema.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,151 @@ import { ToolArgs } from "../types"
22
import { BaseToolSchema } from "./base-tool-schema"
33

44
export function generateApplyDiffSchema(args: ToolArgs): BaseToolSchema {
5+
if (args?.diffStrategy?.getName() === "MultiFileSearchReplace") {
6+
return generateMultipleApplyDiffSchema(args)
7+
}
8+
const schema: BaseToolSchema = {
9+
name: "apply_diff",
10+
description:
11+
"apply PRECISE, TARGETED modifications to an existing file by searching for specific sections of content and replacing them. This tool is for SURGICAL EDITS ONLY - specific changes to existing code",
12+
parameters: [
13+
{
14+
name: "path",
15+
type: "string",
16+
description: "The relative path to the file that needs to be modified.",
17+
required: true,
18+
},
19+
{
20+
name: "diff",
21+
type: "array",
22+
description:
23+
"An array of diff operations to be applied to the file. CRITICAL: For efficiency, include a large surrounding context (3-5 lines above and below) to combine multiple nearby changes into one operation instead of creating separate diffs for each line.",
24+
required: true,
25+
items: {
26+
name: "diffItem",
27+
type: "object",
28+
description: "A single search-and-replace operation.",
29+
required: true,
30+
properties: {
31+
d1: {
32+
name: "start_line",
33+
type: "number",
34+
description:
35+
"The starting line number of the 'search' block. Required when applying multiple diffs to a single file.",
36+
required: true,
37+
},
38+
d2: {
39+
name: "search",
40+
type: "string",
41+
description:
42+
"The exact multi-line block of content to search for in the file. MUST match the original file content exactly (including all whitespace, indentation, tabs, and line breaks). Copy the exact text from the original file with perfect whitespace preservation.",
43+
required: true,
44+
},
45+
d3: {
46+
name: "replace",
47+
type: "string",
48+
description:
49+
"The new multi-line content that will replace the search block. Preserve the original indentation structure and include all the context lines from the search block, making only the necessary changes to the target lines. This should be the complete replacement for the entire search block.",
50+
required: true,
51+
},
52+
},
53+
},
54+
},
55+
],
56+
57+
systemPrompt: `## apply_diff
58+
Description: Request to apply PRECISE, TARGETED modifications to an existing file by searching for specific sections of content and replacing them. This tool is for SURGICAL EDITS ONLY - specific changes to existing code.
59+
You can perform multiple distinct search and replace operations within a single \`apply_diff\` call by providing multiple SEARCH/REPLACE blocks in the \`diff\` parameter. This is the preferred way to make several targeted changes efficiently.
60+
The SEARCH section must exactly match existing content including whitespace and indentation.
61+
If you're not confident in the exact content to search for, use the read_file tool first to get the exact content.
62+
When applying the diffs, be extra careful to remember to change any closing brackets or other syntax that may be affected by the diff farther down in the file.
63+
ALWAYS make as many changes in a single 'apply_diff' request as possible using multiple SEARCH/REPLACE blocks
64+
65+
Parameters:
66+
- path: (required) The path of the file to modify (relative to the current workspace directory ${args.cwd})
67+
- diff: (required) The search/replace block defining the changes.
68+
69+
Diff format:
70+
\`\`\`
71+
<<<<<<< SEARCH
72+
:start_line: (required) The line number of original content where the search block starts.
73+
-------
74+
[exact content to find including whitespace]
75+
=======
76+
[new content to replace with]
77+
>>>>>>> REPLACE
78+
79+
\`\`\`
80+
81+
82+
Example:
83+
84+
Original file:
85+
\`\`\`
86+
1 | def calculate_total(items):
87+
2 | total = 0
88+
3 | for item in items:
89+
4 | total += item
90+
5 | return total
91+
\`\`\`
92+
93+
Search/Replace content:
94+
\`\`\`
95+
<<<<<<< SEARCH
96+
:start_line:1
97+
-------
98+
def calculate_total(items):
99+
total = 0
100+
for item in items:
101+
total += item
102+
return total
103+
=======
104+
def calculate_total(items):
105+
"""Calculate total with 10% markup"""
106+
return sum(item * 1.1 for item in items)
107+
>>>>>>> REPLACE
108+
109+
\`\`\`
110+
111+
Search/Replace content with multiple edits:
112+
\`\`\`
113+
<<<<<<< SEARCH
114+
:start_line:1
115+
-------
116+
def calculate_total(items):
117+
sum = 0
118+
=======
119+
def calculate_sum(items):
120+
sum = 0
121+
>>>>>>> REPLACE
122+
123+
<<<<<<< SEARCH
124+
:start_line:4
125+
-------
126+
total += item
127+
return total
128+
=======
129+
sum += item
130+
return sum
131+
>>>>>>> REPLACE
132+
\`\`\`
133+
134+
135+
Usage:
136+
<apply_diff>
137+
<path>File path here</path>
138+
<diff>
139+
Your search/replace content here
140+
You can use multi search/replace block in one diff block, but make sure to include the line numbers for each block.
141+
Only use a single line of '=======' between search and replacement content, because multiple '=======' will corrupt the file.
142+
</diff>
143+
</apply_diff>`,
144+
}
145+
146+
return schema
147+
}
148+
149+
function generateMultipleApplyDiffSchema(args: ToolArgs): BaseToolSchema {
5150
const schema: BaseToolSchema = {
6151
name: "apply_diff",
7152
description:

src/core/task/Task.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
378378
EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF,
379379
)
380380

381-
if (isMultiFileApplyDiffEnabled || this.apiConfiguration?.toolCallEnabled === true) {
381+
if (isMultiFileApplyDiffEnabled) {
382382
this.diffStrategy = new MultiFileSearchReplaceDiffStrategy(this.fuzzyMatchThreshold)
383383
}
384384
})
@@ -2083,7 +2083,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
20832083
const streamingFailedMessage = this.abort
20842084
? undefined
20852085
: (error.message ?? JSON.stringify(serializeError(error), null, 2))
2086-
2086+
console.log(error)
20872087
// Now call abortTask after determining the cancel reason.
20882088
await this.abortTask()
20892089
await abortStream(cancelReason, streamingFailedMessage)

src/core/task/tool-call-helper.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,20 @@ export class StreamingToolCallProcessor {
161161
originContent: this.accumulatedToolCalls,
162162
}
163163

164+
// Provide a temporary anthropicContent (input) during streaming before final closure
165+
const currentState = this.processingStates.get(index)
166+
if (currentState && !currentState.functionClosed) {
167+
const tmpInput = this.tryBuildTemporaryJson(currentState, toolCall.function.arguments)
168+
if (tmpInput != null) {
169+
result.anthropicContent = {
170+
id: result.toolUserId,
171+
name: result.toolName,
172+
input: tmpInput,
173+
type: "tool_use",
174+
}
175+
}
176+
}
177+
164178
if (this.processingStates.get(index)?.functionClosed) {
165179
let input
166180
try {
@@ -484,6 +498,120 @@ export class StreamingToolCallProcessor {
484498
return str.substring(pos, pos + 2)
485499
}
486500

501+
/**
502+
* Try to synthesize a temporarily-closable JSON string from the current streaming buffer,
503+
* so we can JSON.parse it and expose a usable anthropicContent.input during partial streaming.
504+
* This function does NOT mutate parser state; it only operates on copies.
505+
*
506+
* Strategy:
507+
* - Safely close an in-flight string (key or value), trimming incomplete escapes/unicode.
508+
* - If a key has just finished (no value yet), inject ": null" to complete the pair.
509+
* - If a primitive token is mid-flight (true/false/null/number), attempt to complete it minimally.
510+
* - Trim trailing commas before closing braces.
511+
* - Close any open objects/arrays according to bracketStack, injecting "null" where a value is expected.
512+
* - Try parse; on failure, add one more "null" if needed and retry.
513+
*/
514+
private tryBuildTemporaryJson(state: ToolCallProcessingState, rawArgs: string): any | null {
515+
let s = rawArgs
516+
517+
if (!s || s.trim().length === 0) {
518+
return null
519+
}
520+
521+
const trimTrailingComma = (str: string): string => str.replace(/,(\s*)$/, "$1")
522+
523+
const completePrimitiveSuffix = (pb: string): string => {
524+
// Complete booleans/null prefixes
525+
if (/^(t|tr|tru)$/.test(pb)) return "e" // true
526+
if (/^(f|fa|fal|fals)$/.test(pb)) return "e" // false
527+
if (/^(n|nu|nul)$/.test(pb)) return "l" // null
528+
// Complete numeric partials like "-" or "12."
529+
if (/^-?$/.test(pb)) return "0"
530+
if (/^-?\d+\.$/.test(pb)) return "0"
531+
return ""
532+
}
533+
534+
const stripIncompleteUnicodeAtEnd = (input: string): string => {
535+
const uniIndex = input.lastIndexOf("\\u")
536+
if (uniIndex !== -1) {
537+
const tail = input.slice(uniIndex + 2)
538+
if (!/^[0-9a-fA-F]{4}$/.test(tail)) {
539+
return input.slice(uniIndex) ? input.slice(0, uniIndex) : input
540+
}
541+
}
542+
return input
543+
}
544+
545+
// 1) Handle in-flight strings
546+
if (state.inString) {
547+
// Drop dangling backslash to avoid invalid escape at buffer end
548+
if (s.endsWith("\\")) s = s.slice(0, -1)
549+
// Trim incomplete unicode escape (e.g. \u12)
550+
s = stripIncompleteUnicodeAtEnd(s)
551+
// Close the string
552+
s += `"`
553+
} else {
554+
// 2) Not inside a string; if a primitive token is partially accumulated, try to complete it minimally
555+
if (state.primitiveBuffer && state.primitiveBuffer.length > 0) {
556+
const suffix = completePrimitiveSuffix(state.primitiveBuffer)
557+
if (suffix) s += suffix
558+
}
559+
}
560+
561+
// 4) Before closing brackets, remove trailing commas to avoid JSON syntax errors
562+
s = trimTrailingComma(s)
563+
564+
// 5) Close any open objects/arrays per the current stack
565+
if (state.bracketStack.length > 0) {
566+
for (let i = state.bracketStack.length - 1; i >= 0; i--) {
567+
// Always ensure no trailing comma before we append a closer
568+
s = trimTrailingComma(s)
569+
570+
const b = state.bracketStack[i]
571+
572+
s += b === "{" ? "}" : "]"
573+
}
574+
}
575+
576+
// 6) First parse attempt
577+
try {
578+
return JSON.parse(s)
579+
} catch {
580+
// 7) Second attempt: add one more null if still dangling and retry
581+
try {
582+
let s2 = s
583+
const lastNonWs = this.findLastNonWhitespaceChar(s2)
584+
if (lastNonWs === ":" || state.parserState === ParserState.EXPECT_VALUE) {
585+
s2 += "null"
586+
}
587+
s2 = trimTrailingComma(s2)
588+
return JSON.parse(s2)
589+
} catch {
590+
// 8) Final fallback: repeatedly trim trailing commas and retry
591+
let s3 = s
592+
for (let k = 0; k < 3; k++) {
593+
const trimmed = trimTrailingComma(s3)
594+
if (trimmed === s3) break
595+
s3 = trimmed
596+
try {
597+
return JSON.parse(s3)
598+
} catch {
599+
// continue
600+
}
601+
}
602+
return null
603+
}
604+
}
605+
}
606+
607+
private findLastNonWhitespaceChar(str: string): string {
608+
for (let i = str.length - 1; i >= 0; i--) {
609+
const ch = str[i]
610+
if (!/\s/.test(ch)) return ch
611+
}
612+
return ""
613+
}
614+
487615
private onOpenTag(tag: string, toolName: string): string {
488616
return `<${tag}>`
489617
}

0 commit comments

Comments
 (0)