From 92838ef8da0ce233873cfd242aa2371830f420d5 Mon Sep 17 00:00:00 2001 From: Acbox Date: Mon, 9 Feb 2026 18:29:33 +0800 Subject: [PATCH] feat(mcp): change patch of edit tool to old_text-new_text --- agent/src/prompts/system.ts | 24 ++-- internal/mcp/tools.go | 224 +++++++++++++++++++-------------- packages/cli/src/cli/stream.ts | 70 ++++++++++- 3 files changed, 207 insertions(+), 111 deletions(-) diff --git a/agent/src/prompts/system.ts b/agent/src/prompts/system.ts index c3caf6dc..8508f14d 100644 --- a/agent/src/prompts/system.ts +++ b/agent/src/prompts/system.ts @@ -49,26 +49,20 @@ ${quote('/data')} is your HOME, you are allowed to read and write files in it, t - ${quote('read')}: read file content - ${quote('write')}: write file content - ${quote('list')}: list directory entries -- ${quote('edit')}: apply unified diff patch. Format: +- ${quote('edit')}: replace exact text in a file. Input format: ${block([ - '@@ -, +, @@', - '-old line', - '+new line', - '', - '@@ -3,1 +3,2 @@', - ' existing line 3', - '+added line after 3', - '', - '@@ -2,1 +2,0 @@', - '-deleted line', + '{', + ' "path": "relative/path/to/file.txt",', + ' "old_text": "exact text to find (must match exactly)",', + ' "new_text": "replacement text"', + '}', ].join('\n'))} Rules: - - Lines prefixed with ${quote(' ')} (space) are context (unchanged) lines - - Lines prefixed with ${quote('-')} are removed, ${quote('+')} are added - - ${quote('orig_count')} / ${quote('new_count')} must match the actual number of lines (context + removed / context + added) - - Multiple hunks allowed in one patch + - ${quote('old_text')} must be unique in the file + - Matching is exact (including whitespace and newlines) + - If multiple occurrences exist, include more context in ${quote('old_text')} - ${quote('exec')}: execute command diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index e1bfa38b..7c440871 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -8,9 +8,9 @@ import ( "os" "os/exec" "path/filepath" - "strconv" "strings" "time" + "unicode" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -51,8 +51,9 @@ type FSListOutput struct { } type FSEditInput struct { - Path string `json:"path" jsonschema:"relative file path"` - Patch string `json:"patch" jsonschema:"unified diff patch"` + Path string `json:"path" jsonschema:"relative file path"` + OldText string `json:"old_text" jsonschema:"exact text to find"` + NewText string `json:"new_text" jsonschema:"replacement text"` } type FSEditOutput struct { @@ -75,7 +76,7 @@ func RegisterTools(server *sdkmcp.Server) { sdkmcp.AddTool(server, &sdkmcp.Tool{Name: "read", Description: "read file content"}, fsReadTool) sdkmcp.AddTool(server, &sdkmcp.Tool{Name: "write", Description: "write file content"}, fsWriteTool) sdkmcp.AddTool(server, &sdkmcp.Tool{Name: "list", Description: "list directory entries"}, fsListTool) - sdkmcp.AddTool(server, &sdkmcp.Tool{Name: "edit", Description: "apply unified diff patch"}, fsEditTool) + sdkmcp.AddTool(server, &sdkmcp.Tool{Name: "edit", Description: "replace exact text in a file"}, fsEditTool) sdkmcp.AddTool(server, &sdkmcp.Tool{Name: "exec", Description: "execute command"}, execTool) } @@ -196,15 +197,47 @@ func fsEditTool(ctx context.Context, req *sdkmcp.CallToolRequest, input FSEditIn if err != nil { return nil, FSEditOutput{}, err } - updated, err := applyUnifiedPatch(string(orig), input.Patch) - if err != nil { - return nil, FSEditOutput{}, err + raw := string(orig) + bom, content := stripBOM(raw) + originalEnding := detectLineEnding(content) + normalizedContent := normalizeToLF(content) + normalizedOld := normalizeToLF(input.OldText) + normalizedNew := normalizeToLF(input.NewText) + + match := fuzzyFindText(normalizedContent, normalizedOld) + if !match.Found { + return nil, FSEditOutput{}, fmt.Errorf( + "could not find the exact text in %s. the old text must match exactly including all whitespace and newlines", + input.Path, + ) } + + fuzzyContent := normalizeForFuzzyMatch(normalizedContent) + fuzzyOld := normalizeForFuzzyMatch(normalizedOld) + occurrences := strings.Count(fuzzyContent, fuzzyOld) + if occurrences > 1 { + return nil, FSEditOutput{}, fmt.Errorf( + "found %d occurrences of the text in %s. the text must be unique. please provide more context to make it unique", + occurrences, + input.Path, + ) + } + + baseContent := match.ContentForReplacement + updated := baseContent[:match.Index] + normalizedNew + baseContent[match.Index+match.MatchLength:] + if baseContent == updated { + return nil, FSEditOutput{}, fmt.Errorf( + "no changes made to %s. the replacement produced identical content. this might indicate an issue with special characters or the text not existing as expected", + input.Path, + ) + } + + finalContent := bom + restoreLineEndings(updated, originalEnding) info, err := os.Stat(target) if err != nil { return nil, FSEditOutput{}, err } - if err := os.WriteFile(target, []byte(updated), info.Mode().Perm()); err != nil { + if err := os.WriteFile(target, []byte(finalContent), info.Mode().Perm()); err != nil { return nil, FSEditOutput{}, err } return nil, FSEditOutput{OK: true}, nil @@ -290,96 +323,99 @@ func entryForPath(root, target string, info os.FileInfo) (FSFileEntry, error) { }, nil } -func applyUnifiedPatch(original, patch string) (string, error) { - lines := strings.Split(original, "\n") - out := make([]string, 0, len(lines)) - index := 0 - patchLines := strings.Split(patch, "\n") - hunksApplied := 0 - - for i := 0; i < len(patchLines); i++ { - line := patchLines[i] - if !strings.HasPrefix(line, "@@") { - continue - } - - origStart, err := parseUnifiedHunkHeader(line) - if err != nil { - return "", err - } - origStart-- - if origStart < 0 { - origStart = 0 - } - if origStart > len(lines) { - return "", fmt.Errorf("patch out of range") - } - - out = append(out, lines[index:origStart]...) - index = origStart - hunksApplied++ - - for i+1 < len(patchLines) { - next := patchLines[i+1] - if strings.HasPrefix(next, "@@") { - break - } - i++ - - if next == "" { - if i == len(patchLines)-1 { - break - } - return "", fmt.Errorf("invalid patch line") - } - if next[0] == '\\' { - continue - } - op := next[0] - text := next[1:] - switch op { - case ' ': - if index >= len(lines) || lines[index] != text { - return "", fmt.Errorf("patch context mismatch") - } - out = append(out, text) - index++ - case '-': - if index >= len(lines) || lines[index] != text { - return "", fmt.Errorf("patch delete mismatch") - } - index++ - case '+': - out = append(out, text) - default: - return "", fmt.Errorf("invalid patch operation") - } - } - } - if hunksApplied == 0 { - return "", fmt.Errorf("patch contains no hunks") - } - - out = append(out, lines[index:]...) - return strings.Join(out, "\n"), nil +type FuzzyMatchResult struct { + Found bool + Index int + MatchLength int + UsedFuzzyMatch bool + ContentForReplacement string } -func parseUnifiedHunkHeader(header string) (int, error) { - trimmed := strings.TrimPrefix(header, "@@") - trimmed = strings.TrimSpace(trimmed) - if !strings.HasPrefix(trimmed, "-") { - return 0, fmt.Errorf("invalid hunk header") +func detectLineEnding(content string) string { + crlfIdx := strings.Index(content, "\r\n") + lfIdx := strings.Index(content, "\n") + if lfIdx == -1 { + return "\n" } - parts := strings.SplitN(trimmed, " ", 2) - if len(parts) < 2 { - return 0, fmt.Errorf("invalid hunk header") + if crlfIdx == -1 { + return "\n" + } + if crlfIdx < lfIdx { + return "\r\n" + } + return "\n" +} + +func normalizeToLF(text string) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + return strings.ReplaceAll(text, "\r", "\n") +} + +func restoreLineEndings(text, ending string) string { + if ending == "\r\n" { + return strings.ReplaceAll(text, "\n", "\r\n") + } + return text +} + +func stripBOM(content string) (string, string) { + if strings.HasPrefix(content, "\uFEFF") { + return "\uFEFF", content[1:] + } + return "", content +} + +func normalizeForFuzzyMatch(text string) string { + lines := strings.Split(text, "\n") + for i, line := range lines { + lines[i] = strings.TrimRightFunc(line, unicode.IsSpace) + } + trimmed := strings.Join(lines, "\n") + return strings.Map(func(r rune) rune { + switch r { + case '\u2018', '\u2019', '\u201A', '\u201B': + return '\'' + case '\u201C', '\u201D', '\u201E', '\u201F': + return '"' + case '\u2010', '\u2011', '\u2012', '\u2013', '\u2014', '\u2015', '\u2212': + return '-' + case '\u00A0', '\u2002', '\u2003', '\u2004', '\u2005', '\u2006', '\u2007', '\u2008', '\u2009', '\u200A', '\u202F', '\u205F', '\u3000': + return ' ' + default: + return r + } + }, trimmed) +} + +func fuzzyFindText(content, oldText string) FuzzyMatchResult { + exactIndex := strings.Index(content, oldText) + if exactIndex != -1 { + return FuzzyMatchResult{ + Found: true, + Index: exactIndex, + MatchLength: len(oldText), + UsedFuzzyMatch: false, + ContentForReplacement: content, + } } - origPart := strings.TrimPrefix(parts[0], "-") - origFields := strings.SplitN(origPart, ",", 2) - origStart, err := strconv.Atoi(origFields[0]) - if err != nil { - return 0, fmt.Errorf("invalid hunk header") + fuzzyContent := normalizeForFuzzyMatch(content) + fuzzyOld := normalizeForFuzzyMatch(oldText) + fuzzyIndex := strings.Index(fuzzyContent, fuzzyOld) + if fuzzyIndex == -1 { + return FuzzyMatchResult{ + Found: false, + Index: -1, + MatchLength: 0, + UsedFuzzyMatch: false, + ContentForReplacement: content, + } + } + return FuzzyMatchResult{ + Found: true, + Index: fuzzyIndex, + MatchLength: len(fuzzyOld), + UsedFuzzyMatch: true, + ContentForReplacement: fuzzyContent, } - return origStart, nil } diff --git a/packages/cli/src/cli/stream.ts b/packages/cli/src/cli/stream.ts index 0e46ff2e..e9b1d94a 100644 --- a/packages/cli/src/cli/stream.ts +++ b/packages/cli/src/cli/stream.ts @@ -23,7 +23,6 @@ interface ToolDisplayConfig { const TOOL_DISPLAY: Record = { exec: { mode: 'expanded', label: 'exec' }, write: { mode: 'expanded', expandParam: 'content', label: 'write' }, - edit: { mode: 'expanded', expandParam: 'patch', label: 'edit' }, } const getToolDisplay = (toolName: string): ToolDisplayConfig => { @@ -61,6 +60,71 @@ const formatExecCall = (toolInput: unknown) => { return chalk.dim(' ▶ ') + chalk.white('$ ') + chalk.bold.white(cmd) } +// --------------------------------------------------------------------------- +// edit-specific helpers +// --------------------------------------------------------------------------- + +const extractEditInput = (toolInput: unknown) => { + if (!toolInput || typeof toolInput !== 'object') { + return { path: '', oldText: '', newText: '' } + } + const input = toolInput as Record + const path = typeof input.path === 'string' ? input.path : '' + const oldText = + typeof input.old_text === 'string' + ? input.old_text + : typeof input.oldText === 'string' + ? input.oldText + : '' + const newText = + typeof input.new_text === 'string' + ? input.new_text + : typeof input.newText === 'string' + ? input.newText + : '' + return { path, oldText, newText } +} + +const countLines = (text: string) => (text ? text.split('\n').length : 0) + +const pushDetailBlock = (lines: string[], title: string, content: string) => { + lines.push(chalk.cyan('│ ') + chalk.dim(title)) + const detailLines = content ? content.split('\n') : [] + if (!detailLines.length) { + lines.push(chalk.cyan('│ ') + chalk.dim('∅')) + return + } + const maxLines = 12 + const shown = detailLines.slice(0, maxLines) + for (const dl of shown) { + const truncated = dl.length > BOX_WIDTH - 4 ? dl.slice(0, BOX_WIDTH - 7) + '...' : dl + lines.push(chalk.cyan('│ ') + chalk.white(truncated)) + } + if (detailLines.length > maxLines) { + lines.push(chalk.cyan('│ ') + chalk.dim(`... (${detailLines.length - maxLines} more lines)`)) + } +} + +const formatEditCall = (toolInput: unknown) => { + const { path, oldText, newText } = extractEditInput(toolInput) + const oldLines = countLines(oldText) + const newLines = countLines(newText) + const summary = ` path: ${path || '(unknown)'} · old: ${oldLines} lines · new: ${newLines} lines` + + const topBorder = '┌' + '─'.repeat(BOX_WIDTH - 2) + '┐' + const botBorder = '└' + '─'.repeat(BOX_WIDTH - 2) + '┘' + + const lines: string[] = [] + lines.push(chalk.cyan(topBorder)) + lines.push(chalk.cyan('│ ') + chalk.bold.white('edit') + chalk.gray(summary)) + lines.push(chalk.cyan('│ ') + chalk.dim('─'.repeat(BOX_WIDTH - 4))) + pushDetailBlock(lines, 'old_text', oldText) + lines.push(chalk.cyan('│ ') + chalk.dim('─'.repeat(BOX_WIDTH - 4))) + pushDetailBlock(lines, 'new_text', newText) + lines.push(chalk.cyan(botBorder)) + return lines.join('\n') +} + /** Try to unwrap MCP content-block results into a plain object */ const unwrapToolResult = (result: unknown): Record | null => { if (!result) return null @@ -204,7 +268,7 @@ const formatToolResult = (toolName: string, result: unknown) => { return formatExecResult(result) } const config = getToolDisplay(toolName) - if (config.mode === 'expanded') { + if (config.mode === 'expanded' || toolName === 'edit') { const r = unwrapToolResult(result) if (r) { if ('ok' in r) { @@ -342,6 +406,8 @@ export const streamChat = async (query: string, botId: string, sessionId: string const toolInput = event.input if (toolName === 'exec') { console.log(formatExecCall(toolInput)) + } else if (toolName === 'edit') { + console.log(formatEditCall(toolInput)) } else { const displayConfig = getToolDisplay(toolName) if (displayConfig.mode === 'expanded') {