blob: b539cd6e93e88840d55ed8b2f6e9cbb679bf4127 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package claudetool
2
3/*
4
5Note: sketch wrote this based on translating https://raw.githubusercontent.com/anthropics/anthropic-quickstarts/refs/heads/main/computer-use-demo/computer_use_demo/tools/edit.py
6
7## Implementation Notes
8This tool is based on Anthropic's Python implementation of the `text_editor_20250124` tool. It maintains a history of file edits to support the undo functionality, and verifies text uniqueness for the str_replace operation to ensure safe edits.
9
10*/
11
12import (
13 "context"
14 "encoding/json"
15 "fmt"
16 "os"
17 "os/exec"
18 "path/filepath"
19 "strings"
20
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070021 "sketch.dev/llm"
Earl Lee2e463fb2025-04-17 11:22:22 -070022)
23
24// Constants for the AnthropicEditTool
25const (
26 editName = "str_replace_editor"
27)
28
29// Constants used by the tool
30const (
31 snippetLines = 4
32 maxResponseLen = 16000
33 truncatedMessage = "<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>"
34)
35
36// Command represents the type of operation to perform
37type editCommand string
38
39const (
40 viewCommand editCommand = "view"
41 createCommand editCommand = "create"
42 strReplaceCommand editCommand = "str_replace"
43 insertCommand editCommand = "insert"
44 undoEditCommand editCommand = "undo_edit"
45)
46
47// editInput represents the expected input format for the edit tool
48type editInput struct {
49 Command string `json:"command"`
50 Path string `json:"path"`
51 FileText *string `json:"file_text,omitempty"`
52 ViewRange []int `json:"view_range,omitempty"`
53 OldStr *string `json:"old_str,omitempty"`
54 NewStr *string `json:"new_str,omitempty"`
55 InsertLine *int `json:"insert_line,omitempty"`
56}
57
58// fileHistory maintains a history of edits for each file to support undo functionality
59var fileHistory = make(map[string][]string)
60
61// AnthropicEditTool is a tool for viewing, creating, and editing files
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070062var AnthropicEditTool = &llm.Tool{
Earl Lee2e463fb2025-04-17 11:22:22 -070063 // Note that Type is model-dependent, and would be different for Claude 3.5, for example.
64 Type: "text_editor_20250124",
65 Name: editName,
66 Run: EditRun,
67}
68
69// EditRun is the implementation of the edit tool
Philip Zeyliger72252cb2025-05-10 17:00:08 -070070func EditRun(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -070071 var editRequest editInput
72 if err := json.Unmarshal(input, &editRequest); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -070073 return nil, fmt.Errorf("failed to parse edit input: %v", err)
Earl Lee2e463fb2025-04-17 11:22:22 -070074 }
75
76 // Validate the command
77 cmd := editCommand(editRequest.Command)
78 if !isValidCommand(cmd) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -070079 return nil, fmt.Errorf("unrecognized command %s. The allowed commands are: view, create, str_replace, insert, undo_edit", cmd)
Earl Lee2e463fb2025-04-17 11:22:22 -070080 }
81
82 path := editRequest.Path
83
84 // Validate the path
85 if err := validatePath(cmd, path); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -070086 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -070087 }
88
89 // Execute the appropriate command
90 switch cmd {
91 case viewCommand:
Philip Zeyliger72252cb2025-05-10 17:00:08 -070092 result, err := handleView(ctx, path, editRequest.ViewRange)
93 if err != nil {
94 return nil, err
95 }
96 return llm.TextContent(result), nil
Earl Lee2e463fb2025-04-17 11:22:22 -070097 case createCommand:
98 if editRequest.FileText == nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -070099 return nil, fmt.Errorf("parameter file_text is required for command: create")
Earl Lee2e463fb2025-04-17 11:22:22 -0700100 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700101 result, err := handleCreate(path, *editRequest.FileText)
102 if err != nil {
103 return nil, err
104 }
105 return llm.TextContent(result), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700106 case strReplaceCommand:
107 if editRequest.OldStr == nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700108 return nil, fmt.Errorf("parameter old_str is required for command: str_replace")
Earl Lee2e463fb2025-04-17 11:22:22 -0700109 }
110 newStr := ""
111 if editRequest.NewStr != nil {
112 newStr = *editRequest.NewStr
113 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700114 result, err := handleStrReplace(path, *editRequest.OldStr, newStr)
115 if err != nil {
116 return nil, err
117 }
118 return llm.TextContent(result), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700119 case insertCommand:
120 if editRequest.InsertLine == nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700121 return nil, fmt.Errorf("parameter insert_line is required for command: insert")
Earl Lee2e463fb2025-04-17 11:22:22 -0700122 }
123 if editRequest.NewStr == nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700124 return nil, fmt.Errorf("parameter new_str is required for command: insert")
Earl Lee2e463fb2025-04-17 11:22:22 -0700125 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700126 result, err := handleInsert(path, *editRequest.InsertLine, *editRequest.NewStr)
127 if err != nil {
128 return nil, err
129 }
130 return llm.TextContent(result), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700131 case undoEditCommand:
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700132 result, err := handleUndoEdit(path)
133 if err != nil {
134 return nil, err
135 }
136 return llm.TextContent(result), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700137 default:
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700138 return nil, fmt.Errorf("command %s is not implemented", cmd)
Earl Lee2e463fb2025-04-17 11:22:22 -0700139 }
140}
141
142// Utility function to check if a command is valid
143func isValidCommand(cmd editCommand) bool {
144 switch cmd {
145 case viewCommand, createCommand, strReplaceCommand, insertCommand, undoEditCommand:
146 return true
147 default:
148 return false
149 }
150}
151
152// validatePath checks if the path/command combination is valid
153func validatePath(cmd editCommand, path string) error {
154 // Check if it's an absolute path
155 if !filepath.IsAbs(path) {
156 suggestedPath := "/" + path
157 return fmt.Errorf("the path %s is not an absolute path, it should start with '/'. Maybe you meant %s?", path, suggestedPath)
158 }
159
160 // Get file info
161 info, err := os.Stat(path)
162
163 // Check if path exists (except for create command)
164 if err != nil {
165 if os.IsNotExist(err) && cmd != createCommand {
166 return fmt.Errorf("the path %s does not exist. Please provide a valid path", path)
167 } else if !os.IsNotExist(err) {
168 return fmt.Errorf("error accessing path %s: %v", path, err)
169 }
170 } else {
171 // Path exists, check if it's a directory
172 if info.IsDir() && cmd != viewCommand {
173 return fmt.Errorf("the path %s is a directory and only the 'view' command can be used on directories", path)
174 }
175
176 // For create command, check if file already exists
177 if cmd == createCommand {
178 return fmt.Errorf("file already exists at: %s. Cannot overwrite files using command 'create'", path)
179 }
180 }
181
182 return nil
183}
184
185// handleView implements the view command
186func handleView(ctx context.Context, path string, viewRange []int) (string, error) {
187 info, err := os.Stat(path)
188 if err != nil {
189 return "", fmt.Errorf("error accessing path %s: %v", path, err)
190 }
191
192 // Handle directory view
193 if info.IsDir() {
194 if viewRange != nil {
195 return "", fmt.Errorf("the view_range parameter is not allowed when path points to a directory")
196 }
197
198 // List files in the directory (up to 2 levels deep)
199 return listDirectory(ctx, path)
200 }
201
202 // Handle file view
203 fileContent, err := readFile(path)
204 if err != nil {
205 return "", err
206 }
207
208 initLine := 1
209 if viewRange != nil {
210 if len(viewRange) != 2 {
211 return "", fmt.Errorf("invalid view_range. It should be a list of two integers")
212 }
213
214 fileLines := strings.Split(fileContent, "\n")
215 nLinesFile := len(fileLines)
216 initLine, finalLine := viewRange[0], viewRange[1]
217
218 if initLine < 1 || initLine > nLinesFile {
219 return "", fmt.Errorf("invalid view_range: %v. Its first element %d should be within the range of lines of the file: [1, %d]",
220 viewRange, initLine, nLinesFile)
221 }
222
223 if finalLine != -1 && finalLine < initLine {
224 return "", fmt.Errorf("invalid view_range: %v. Its second element %d should be larger or equal than its first %d",
225 viewRange, finalLine, initLine)
226 }
227
228 if finalLine > nLinesFile {
229 return "", fmt.Errorf("invalid view_range: %v. Its second element %d should be smaller than the number of lines in the file: %d",
230 viewRange, finalLine, nLinesFile)
231 }
232
233 if finalLine == -1 {
234 fileContent = strings.Join(fileLines[initLine-1:], "\n")
235 } else {
236 fileContent = strings.Join(fileLines[initLine-1:finalLine], "\n")
237 }
238 }
239
240 return makeOutput(fileContent, path, initLine), nil
241}
242
243// handleCreate implements the create command
244func handleCreate(path string, fileText string) (string, error) {
245 // Ensure the directory exists
246 dir := filepath.Dir(path)
247 if err := os.MkdirAll(dir, 0o755); err != nil {
248 return "", fmt.Errorf("failed to create directory %s: %v", dir, err)
249 }
250
251 // Write the file
252 if err := writeFile(path, fileText); err != nil {
253 return "", err
254 }
255
256 // Save to history
257 fileHistory[path] = append(fileHistory[path], fileText)
258
259 return fmt.Sprintf("File created successfully at: %s", path), nil
260}
261
262// handleStrReplace implements the str_replace command
263func handleStrReplace(path, oldStr, newStr string) (string, error) {
264 // Read the file content
265 fileContent, err := readFile(path)
266 if err != nil {
267 return "", err
268 }
269
270 // Replace tabs with spaces
271 fileContent = maybeExpandTabs(path, fileContent)
272 oldStr = maybeExpandTabs(path, oldStr)
273 newStr = maybeExpandTabs(path, newStr)
274
275 // Check if oldStr is unique in the file
276 occurrences := strings.Count(fileContent, oldStr)
277 if occurrences == 0 {
278 return "", fmt.Errorf("no replacement was performed, old_str %q did not appear verbatim in %s", oldStr, path)
279 } else if occurrences > 1 {
280 // Find line numbers where oldStr appears
281 fileContentLines := strings.Split(fileContent, "\n")
282 var lines []int
283 for idx, line := range fileContentLines {
284 if strings.Contains(line, oldStr) {
285 lines = append(lines, idx+1)
286 }
287 }
288 return "", fmt.Errorf("no replacement was performed. Multiple occurrences of old_str %q in lines %v. Please ensure it is unique", oldStr, lines)
289 }
290
291 // Save the current content to history
292 fileHistory[path] = append(fileHistory[path], fileContent)
293
294 // Replace oldStr with newStr
295 newFileContent := strings.Replace(fileContent, oldStr, newStr, 1)
296
297 // Write the new content to the file
298 if err := writeFile(path, newFileContent); err != nil {
299 return "", err
300 }
301
302 // Create a snippet of the edited section
303 parts := strings.Split(fileContent, oldStr)
304 if len(parts) == 0 {
305 // This should never happen due to the earlier check, but let's be safe
306 parts = []string{""}
307 }
308 replacementLine := strings.Count(parts[0], "\n")
309 startLine := max(0, replacementLine-snippetLines)
310 endLine := replacementLine + snippetLines + strings.Count(newStr, "\n")
311 fileLines := strings.Split(newFileContent, "\n")
312 if len(fileLines) == 0 {
313 fileLines = []string{""}
314 }
315 endLine = min(endLine+1, len(fileLines))
316 snippet := strings.Join(fileLines[startLine:endLine], "\n")
317
318 // Prepare the success message
319 successMsg := fmt.Sprintf("The file %s has been edited. ", path)
320 successMsg += makeOutput(snippet, fmt.Sprintf("a snippet of %s", path), startLine+1)
321 successMsg += "Review the changes and make sure they are as expected. Edit the file again if necessary."
322
323 return successMsg, nil
324}
325
326// handleInsert implements the insert command
327func handleInsert(path string, insertLine int, newStr string) (string, error) {
328 // Read the file content
329 fileContent, err := readFile(path)
330 if err != nil {
331 return "", err
332 }
333
334 // Replace tabs with spaces
335 fileContent = maybeExpandTabs(path, fileContent)
336 newStr = maybeExpandTabs(path, newStr)
337
338 // Split the file content into lines
339 fileTextLines := strings.Split(fileContent, "\n")
340 nLinesFile := len(fileTextLines)
341
342 // Validate insert line
343 if insertLine < 0 || insertLine > nLinesFile {
344 return "", fmt.Errorf("invalid insert_line parameter: %d. It should be within the range of lines of the file: [0, %d]",
345 insertLine, nLinesFile)
346 }
347
348 // Save the current content to history
349 fileHistory[path] = append(fileHistory[path], fileContent)
350
351 // Split the new string into lines
352 newStrLines := strings.Split(newStr, "\n")
353
354 // Create new content by inserting the new lines
355 newFileTextLines := make([]string, 0, nLinesFile+len(newStrLines))
356 newFileTextLines = append(newFileTextLines, fileTextLines[:insertLine]...)
357 newFileTextLines = append(newFileTextLines, newStrLines...)
358 newFileTextLines = append(newFileTextLines, fileTextLines[insertLine:]...)
359
360 // Create a snippet of the edited section
361 snippetStart := max(0, insertLine-snippetLines)
362 snippetEnd := min(insertLine+snippetLines, nLinesFile)
363
364 snippetLines := make([]string, 0)
365 snippetLines = append(snippetLines, fileTextLines[snippetStart:insertLine]...)
366 snippetLines = append(snippetLines, newStrLines...)
367 snippetLines = append(snippetLines, fileTextLines[insertLine:snippetEnd]...)
368 snippet := strings.Join(snippetLines, "\n")
369
370 // Write the new content to the file
371 newFileText := strings.Join(newFileTextLines, "\n")
372 if err := writeFile(path, newFileText); err != nil {
373 return "", err
374 }
375
376 // Prepare the success message
377 successMsg := fmt.Sprintf("The file %s has been edited. ", path)
378 successMsg += makeOutput(snippet, "a snippet of the edited file", max(1, insertLine-4+1))
379 successMsg += "Review the changes and make sure they are as expected (correct indentation, no duplicate lines, etc). Edit the file again if necessary."
380
381 return successMsg, nil
382}
383
384// handleUndoEdit implements the undo_edit command
385func handleUndoEdit(path string) (string, error) {
386 history, exists := fileHistory[path]
387 if !exists || len(history) == 0 {
388 return "", fmt.Errorf("no edit history found for %s", path)
389 }
390
391 // Get the last edit and remove it from history
392 lastIdx := len(history) - 1
393 oldText := history[lastIdx]
394 fileHistory[path] = history[:lastIdx]
395
396 // Write the old content back to the file
397 if err := writeFile(path, oldText); err != nil {
398 return "", err
399 }
400
401 return fmt.Sprintf("Last edit to %s undone successfully. %s", path, makeOutput(oldText, path, 1)), nil
402}
403
404// listDirectory lists files and directories up to 2 levels deep
405func listDirectory(ctx context.Context, path string) (string, error) {
406 cmd := fmt.Sprintf("find %s -maxdepth 2 -not -path '*/\\.*'", path)
407 output, err := executeCommand(ctx, cmd)
408 if err != nil {
409 return "", fmt.Errorf("failed to list directory: %v", err)
410 }
411
412 return fmt.Sprintf("Here's the files and directories up to 2 levels deep in %s, excluding hidden items:\n%s\n", path, output), nil
413}
414
415// executeCommand executes a shell command and returns its output
416func executeCommand(ctx context.Context, cmd string) (string, error) {
417 // This is a simplified version without timeouts for now
418 bash := exec.CommandContext(ctx, "bash", "-c", cmd)
419 bash.Dir = WorkingDir(ctx)
420 output, err := bash.CombinedOutput()
421 if err != nil {
422 return "", fmt.Errorf("command execution failed: %v: %s", err, string(output))
423 }
424 return maybetruncate(string(output)), nil
425}
426
427// readFile reads the content of a file
428func readFile(path string) (string, error) {
429 content, err := os.ReadFile(path)
430 if err != nil {
431 return "", fmt.Errorf("failed to read file %s: %v", path, err)
432 }
433 return string(content), nil
434}
435
436// writeFile writes content to a file
437func writeFile(path, content string) error {
438 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
439 return fmt.Errorf("failed to write to file %s: %v", path, err)
440 }
441 return nil
442}
443
444// makeOutput generates a formatted output for the CLI
445func makeOutput(fileContent, fileDescriptor string, initLine int) string {
446 fileContent = maybetruncate(fileContent)
447 fileContent = maybeExpandTabs(fileDescriptor, fileContent)
448
449 var lines []string
450 for i, line := range strings.Split(fileContent, "\n") {
451 lines = append(lines, fmt.Sprintf("%6d\t%s", i+initLine, line))
452 }
453
454 return fmt.Sprintf("Here's the result of running `cat -n` on %s:\n%s\n", fileDescriptor, strings.Join(lines, "\n"))
455}
456
457// maybetruncate truncates content and appends a notice if content exceeds the specified length
458func maybetruncate(content string) string {
459 if len(content) <= maxResponseLen {
460 return content
461 }
462 return content[:maxResponseLen] + truncatedMessage
463}
464
465// maybeExpandTabs is currently a no-op. The python
466// implementation replaces tabs with spaces, but this strikes
467// me as unwise for our tool.
468func maybeExpandTabs(path, s string) string {
469 // return strings.ReplaceAll(s, "\t", " ")
470 return s
471}