mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
fd960921ca
- 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
389 lines
11 KiB
Go
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)
|
|
}
|