Files
Ed Zynda fd960921ca refactor: address code audit findings across SDK, cmd, and internals
- Remove deprecated GenerateWithLoopAndStreaming and TreeManager
  AppendFantasyMessage / AddFantasyMessages / GetFantasyMessages to
  close the SDK leakage caused by the kit.TreeManager type alias
- Switch extensionAPI method signatures to local Extension* aliases so
  pkg.go.dev signatures no longer expose internal package names
- Bundle runNormalMode dependencies into a runModeDeps struct, shrinking
  the runNonInteractive and runInteractive call sites from 40+ positional
  args to (ctx, deps)
- Add generic subscribeTyped[E Event] helper and collapse ~30 typed OnXxx
  wrappers in pkg/kit/events.go onto it (public signatures unchanged)
- Extract setupBashPipes / interpretBashExit in internal/core/bash.go to
  deduplicate the buffered and streaming execution paths
- Extract resolveAutoRouteAPIKey and wrapProviderErr helpers in
  internal/models/providers.go and uniformly apply them across every
  createXxxProvider site
- Reimplement internal/extensions/watcher.go as a thin wrapper over the
  general-purpose internal/watcher.ContentWatcher, eliminating ~130 LOC
  of duplicated fsnotify logic while preserving the existing test API
- Add ctx.Err() pre-flight checks in executeRead / Write / Edit / Ls so
  cancellation actually short-circuits pure file-IO tools
2026-06-06 19:22:05 +03:00

389 lines
11 KiB
Go

package core
import (
"context"
"fmt"
"os"
"sort"
"strings"
"unicode"
"unicode/utf8"
"charm.land/fantasy"
udiff "github.com/aymanbagabas/go-udiff"
)
// Edit represents a single replacement in a multi-edit operation.
type Edit struct {
OldText string `json:"old_text"`
NewText string `json:"new_text"`
}
// editArgs holds the arguments for the edit tool.
type editArgs struct {
Path string `json:"path"`
Edits []Edit `json:"edits"`
}
// replacement represents a normalized edit ready for processing.
type replacement struct {
oldText string // normalized old text for matching
newText string // normalized new text
originalOld string // original old text for metadata
originalNew string // original new text for metadata
index int // index in the original edits array (for error messages)
}
// matchedReplacement represents a replacement with its match location.
type matchedReplacement struct {
replacement
start int // start index in normalized content
end int // end index in normalized content
usedFuzzyMatch bool // true if fuzzy matching was used
}
// NewEditTool creates the edit core tool.
func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
cfg := ApplyOptions(opts)
return &coreTool{
info: fantasy.ToolInfo{
Name: "edit",
Description: "Edit a file by replacing exact text. All edits in the array are matched against the original file content (non-incremental) and must be non-overlapping.",
Parameters: map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file to edit (relative or absolute)",
},
"edits": map[string]any{
"type": "array",
"description": "Array of edits for multi-region replacement. Each edit must have unique, non-overlapping old_text. All matches are against the original file content.",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"old_text": map[string]any{
"type": "string",
"description": "Exact text to find and replace for this edit",
},
"new_text": map[string]any{
"type": "string",
"description": "New text for this edit",
},
},
"required": []string{"old_text", "new_text"},
},
},
},
Required: []string{"path", "edits"},
},
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
return executeEdit(ctx, call, cfg.WorkDir)
},
}
}
func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
if err := ctx.Err(); err != nil {
return fantasy.ToolResponse{}, err
}
var args editArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("failed to parse arguments: " + err.Error()), nil
}
if args.Path == "" {
return fantasy.NewTextErrorResponse("path parameter is required"), nil
}
absPath, err := resolvePathWithWorkDir(args.Path, workDir)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil
}
contentBytes, err := os.ReadFile(absPath)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to read file: %v", err)), nil
}
content := string(contentBytes)
// Normalize and validate input
replacements, err := normalizeEditInput(args)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
// Apply all edits
newContent, applied, err := applyEdits(content, replacements)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
// Write the file
if err := os.WriteFile(absPath, []byte(newContent), 0644); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
}
// Generate diff
normalizedContent := strings.ReplaceAll(content, "\r\n", "\n")
diff := generateDiff(absPath, normalizedContent, newContent)
// Build response with fuzzy match indication
fuzzyCount := 0
for _, m := range applied {
if m.usedFuzzyMatch {
fuzzyCount++
}
}
var msg string
if len(applied) == 1 {
if fuzzyCount > 0 {
msg = fmt.Sprintf("Applied edit (fuzzy match) to %s\n%s", args.Path, diff)
} else {
msg = fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff)
}
} else {
if fuzzyCount > 0 {
msg = fmt.Sprintf("Applied %d edits (%d fuzzy) to %s\n%s", len(applied), fuzzyCount, args.Path, diff)
} else {
msg = fmt.Sprintf("Applied %d edits to %s\n%s", len(applied), args.Path, diff)
}
}
resp := fantasy.NewTextResponse(msg)
return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, applied)), nil
}
// normalizeEditInput validates and normalizes the edit input.
func normalizeEditInput(args editArgs) ([]replacement, error) {
if len(args.Edits) == 0 {
return nil, fmt.Errorf("edits array is required and must not be empty")
}
var reps []replacement
for i, edit := range args.Edits {
if edit.OldText == "" {
return nil, fmt.Errorf("edits[%d].old_text is required", i)
}
reps = append(reps, replacement{
oldText: strings.ReplaceAll(edit.OldText, "\r\n", "\n"),
newText: strings.ReplaceAll(edit.NewText, "\r\n", "\n"),
originalOld: edit.OldText,
originalNew: edit.NewText,
index: i,
})
}
return reps, nil
}
// applyEdits applies multiple replacements to the content.
// All matches are against the original content (non-incremental).
// Returns the new content, the applied matches, and any error.
func applyEdits(content string, edits []replacement) (string, []matchedReplacement, error) {
normalizedContent := strings.ReplaceAll(content, "\r\n", "\n")
// Find all matches
var matched []matchedReplacement
for _, edit := range edits {
m, err := findMatch(normalizedContent, edit)
if err != nil {
return "", nil, err
}
matched = append(matched, *m)
}
// Sort by position
sort.Slice(matched, func(i, j int) bool {
return matched[i].start < matched[j].start
})
// Check for overlaps
for i := 1; i < len(matched); i++ {
if matched[i-1].end > matched[i].start {
return "", nil, fmt.Errorf("edits[%d] and edits[%d] overlap; merge them into a single edit",
matched[i-1].index, matched[i].index)
}
}
// Apply edits in reverse order (end to start) to maintain stable offsets
result := normalizedContent
for i := len(matched) - 1; i >= 0; i-- {
m := matched[i]
result = result[:m.start] + m.newText + result[m.end:]
}
return result, matched, nil
}
// findMatch finds a unique match for the edit in the content.
// Returns error if not found or ambiguous.
func findMatch(content string, edit replacement) (*matchedReplacement, error) {
// Try exact match first
count := strings.Count(content, edit.oldText)
if count == 0 {
// Try fuzzy match
idx, matchLen := fuzzyMatch(content, edit.oldText)
if idx < 0 {
return nil, fmt.Errorf("edits[%d]: could not find old_text in file. The text must match exactly (including whitespace)", edit.index)
}
// Use the matched text from content for the replacement
matchedText := content[idx : idx+matchLen]
return &matchedReplacement{
replacement: replacement{
oldText: matchedText,
newText: edit.newText,
originalOld: edit.originalOld,
originalNew: edit.originalNew,
index: edit.index,
},
start: idx,
end: idx + matchLen,
usedFuzzyMatch: true,
}, nil
}
if count > 1 {
return nil, fmt.Errorf("found %d matches for edits[%d].old_text; each old_text must be unique, provide more context to identify the correct match", count, edit.index)
}
// Single exact match
idx := strings.Index(content, edit.oldText)
return &matchedReplacement{
replacement: edit,
start: idx,
end: idx + len(edit.oldText),
}, nil
}
// editDiffMeta builds the structured metadata attached to edit tool responses.
func editDiffMeta(path string, applied []matchedReplacement) map[string]any {
var diffBlocks []map[string]any
totalAdditions, totalDeletions := 0, 0
for _, m := range applied {
diffBlocks = append(diffBlocks, map[string]any{
"old_text": m.originalOld,
"new_text": m.originalNew,
})
totalAdditions += strings.Count(m.originalNew, "\n") + 1
totalDeletions += strings.Count(m.originalOld, "\n") + 1
}
return map[string]any{
"file_diffs": []map[string]any{{
"path": path,
"additions": totalAdditions,
"deletions": totalDeletions,
"diff_blocks": diffBlocks,
}},
}
}
// fuzzyMatch tries to find old_text with relaxed matching:
// - Strips trailing whitespace per line
// - Normalizes unicode quotes to ASCII
// - Normalizes unicode dashes/spaces
//
// Returns (index, matchLength) in the original content, or (-1, 0) if not
// found or ambiguous (multiple matches).
func fuzzyMatch(content, search string) (int, int) {
normContent, contentMap := normalizeWithMap(content)
normSearch := normalizeForFuzzy(search)
if normSearch == "" {
return -1, 0
}
idx := strings.Index(normContent, normSearch)
if idx < 0 {
return -1, 0
}
// Reject ambiguous matches — if there are multiple fuzzy matches
// we can't safely pick one.
if strings.Count(normContent, normSearch) > 1 {
return -1, 0
}
// Map normalized byte positions back to original byte positions.
origStart := contentMap[idx]
endNorm := idx + len(normSearch)
var origEnd int
if endNorm >= len(normContent) {
origEnd = len(content)
} else {
origEnd = contentMap[endNorm]
}
return origStart, origEnd - origStart
}
// normalizeWithMap normalizes s for fuzzy matching and returns both the
// normalized string and a byte-position mapping where mapping[i] is the
// original byte position corresponding to normalized byte position i.
//
// Normalization: trim trailing whitespace per line, replace unicode
// quotes/dashes/spaces with their ASCII equivalents.
func normalizeWithMap(s string) (string, []int) {
var result []byte
var mapping []int // mapping[i] = original byte position for result byte i
lines := strings.Split(s, "\n")
origPos := 0
for li, line := range lines {
if li > 0 {
result = append(result, '\n')
mapping = append(mapping, origPos)
origPos++ // skip \n in original
}
trimmed := strings.TrimRightFunc(line, unicode.IsSpace)
for j := 0; j < len(trimmed); {
r, size := utf8.DecodeRuneInString(trimmed[j:])
repl := normalizeRune(r)
for k := 0; k < len(repl); k++ {
mapping = append(mapping, origPos+j)
}
result = append(result, repl...)
j += size
}
origPos += len(line) // advance past full original line including trailing ws
}
return string(result), mapping
}
// normalizeRune maps unicode quotes, dashes, and non-breaking spaces to
// their ASCII equivalents. Returns the original rune as a string for all
// other characters.
func normalizeRune(r rune) string {
switch r {
case '\u201c', '\u201d': // left/right double quote
return "\""
case '\u2018', '\u2019': // left/right single quote
return "'"
case '\u2013', '\u2014': // en dash, em dash
return "-"
case '\u00a0': // non-breaking space
return " "
default:
return string(r)
}
}
// normalizeForFuzzy normalizes s for fuzzy matching (without position mapping).
// Used for the search string where position mapping is not needed.
func normalizeForFuzzy(s string) string {
norm, _ := normalizeWithMap(s)
return norm
}
// generateDiff creates a unified diff showing the change between old and new
// file contents. Uses the go-udiff library for correct diff computation.
func generateDiff(path, old, new string) string {
return udiff.Unified(path, path, old, new)
}