file_edit.go
1 package tools 2 3 import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "os" 9 "strings" 10 11 "github.com/Kocoro-lab/ShanClaw/internal/agent" 12 "github.com/Kocoro-lab/ShanClaw/internal/cwdctx" 13 ) 14 15 type FileEditTool struct{} 16 17 type fileEditArgs struct { 18 Path string `json:"path"` 19 OldString string `json:"old_string"` 20 NewString string `json:"new_string"` 21 } 22 23 func (t *FileEditTool) Info() agent.ToolInfo { 24 return agent.ToolInfo{ 25 Name: "file_edit", 26 Description: "Replace an exact string in a file (old_string must appear exactly once). Prefer this over file_write for targeted edits. If old_string is not unique, use file_read then file_write instead.", 27 Parameters: map[string]any{ 28 "type": "object", 29 "properties": map[string]any{ 30 "path": map[string]any{"type": "string", "description": "File path to edit"}, 31 "old_string": map[string]any{"type": "string", "description": "Exact string to find (must be unique)"}, 32 "new_string": map[string]any{"type": "string", "description": "Replacement string"}, 33 }, 34 }, 35 Required: []string{"path", "old_string", "new_string"}, 36 } 37 } 38 39 func (t *FileEditTool) Run(ctx context.Context, argsJSON string) (agent.ToolResult, error) { 40 var args fileEditArgs 41 if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { 42 return agent.ToolResult{Content: fmt.Sprintf("invalid arguments: %v", err), IsError: true}, nil 43 } 44 resolved, resolveErr := cwdctx.ResolveFilesystemPath(ctx, args.Path) 45 if resolveErr != nil { 46 if errors.Is(resolveErr, cwdctx.ErrNoSessionCWD) { 47 return agent.ValidationError( 48 "file_edit: no session working directory is set. Pass an absolute path.", 49 ), nil 50 } 51 return agent.ValidationError(fmt.Sprintf("file_edit: %v", resolveErr)), nil 52 } 53 args.Path = resolved 54 55 // Enforce read-before-edit 56 if err := agent.CheckReadBeforeWrite(ctx, args.Path); err != nil { 57 return agent.ToolResult{Content: err.Error(), IsError: true}, nil 58 } 59 60 // Block file_edit on the agent's MEMORY.md — use memory_append instead. 61 // file_edit is a read-modify-write that races under concurrent sessions. 62 if agent.IsMemoryFile(ctx, args.Path) { 63 return agent.ToolResult{ 64 Content: "Cannot edit MEMORY.md with file_edit — it races under concurrent sessions. Use the memory_append tool instead.", 65 IsError: true, 66 }, nil 67 } 68 69 data, err := os.ReadFile(args.Path) 70 if err != nil { 71 if os.IsPermission(err) { 72 return agent.PermissionError(fmt.Sprintf("cannot read %s: permission denied", args.Path)), nil 73 } 74 return agent.ToolResult{Content: fmt.Sprintf("error reading file: %v", err), IsError: true}, nil 75 } 76 77 if args.OldString == "" { 78 return agent.ValidationError("old_string must not be empty"), nil 79 } 80 81 content := string(data) 82 count := strings.Count(content, args.OldString) 83 if count == 0 { 84 return agent.ValidationError("old_string not found in file"), nil 85 } 86 if count > 1 { 87 return agent.ValidationError(fmt.Sprintf("old_string found %d times (must be unique)", count)), nil 88 } 89 90 newContent := strings.Replace(content, args.OldString, args.NewString, 1) 91 // Preserve original file permissions 92 perm := os.FileMode(0644) 93 if info, err := os.Stat(args.Path); err == nil { 94 perm = info.Mode().Perm() 95 } 96 if err := os.WriteFile(args.Path, []byte(newContent), perm); err != nil { 97 if os.IsPermission(err) { 98 return agent.PermissionError(fmt.Sprintf("cannot write %s: permission denied", args.Path)), nil 99 } 100 return agent.ToolResult{Content: fmt.Sprintf("error writing file: %v", err), IsError: true}, nil 101 } 102 103 return agent.ToolResult{Content: fmt.Sprintf("edited %s: replaced 1 occurrence", args.Path)}, nil 104 } 105 106 func (t *FileEditTool) RequiresApproval() bool { return true } 107 108 func (t *FileEditTool) IsReadOnlyCall(string) bool { return false }