all: remove anthropic edit tool support
The Anthropic str_replace_editor tool implementation has rotted;
it would require a bit of work to upgrade it for Claude 4.
Remove it for now.
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s6129e8703301600dk
diff --git a/claudetool/edit.go b/claudetool/edit.go
deleted file mode 100644
index b539cd6..0000000
--- a/claudetool/edit.go
+++ /dev/null
@@ -1,471 +0,0 @@
-package claudetool
-
-/*
-
-Note: 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
-
-## Implementation Notes
-This 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.
-
-*/
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
-
- "sketch.dev/llm"
-)
-
-// Constants for the AnthropicEditTool
-const (
- editName = "str_replace_editor"
-)
-
-// Constants used by the tool
-const (
- snippetLines = 4
- maxResponseLen = 16000
- 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>"
-)
-
-// Command represents the type of operation to perform
-type editCommand string
-
-const (
- viewCommand editCommand = "view"
- createCommand editCommand = "create"
- strReplaceCommand editCommand = "str_replace"
- insertCommand editCommand = "insert"
- undoEditCommand editCommand = "undo_edit"
-)
-
-// editInput represents the expected input format for the edit tool
-type editInput struct {
- Command string `json:"command"`
- Path string `json:"path"`
- FileText *string `json:"file_text,omitempty"`
- ViewRange []int `json:"view_range,omitempty"`
- OldStr *string `json:"old_str,omitempty"`
- NewStr *string `json:"new_str,omitempty"`
- InsertLine *int `json:"insert_line,omitempty"`
-}
-
-// fileHistory maintains a history of edits for each file to support undo functionality
-var fileHistory = make(map[string][]string)
-
-// AnthropicEditTool is a tool for viewing, creating, and editing files
-var AnthropicEditTool = &llm.Tool{
- // Note that Type is model-dependent, and would be different for Claude 3.5, for example.
- Type: "text_editor_20250124",
- Name: editName,
- Run: EditRun,
-}
-
-// EditRun is the implementation of the edit tool
-func EditRun(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
- var editRequest editInput
- if err := json.Unmarshal(input, &editRequest); err != nil {
- return nil, fmt.Errorf("failed to parse edit input: %v", err)
- }
-
- // Validate the command
- cmd := editCommand(editRequest.Command)
- if !isValidCommand(cmd) {
- return nil, fmt.Errorf("unrecognized command %s. The allowed commands are: view, create, str_replace, insert, undo_edit", cmd)
- }
-
- path := editRequest.Path
-
- // Validate the path
- if err := validatePath(cmd, path); err != nil {
- return nil, err
- }
-
- // Execute the appropriate command
- switch cmd {
- case viewCommand:
- result, err := handleView(ctx, path, editRequest.ViewRange)
- if err != nil {
- return nil, err
- }
- return llm.TextContent(result), nil
- case createCommand:
- if editRequest.FileText == nil {
- return nil, fmt.Errorf("parameter file_text is required for command: create")
- }
- result, err := handleCreate(path, *editRequest.FileText)
- if err != nil {
- return nil, err
- }
- return llm.TextContent(result), nil
- case strReplaceCommand:
- if editRequest.OldStr == nil {
- return nil, fmt.Errorf("parameter old_str is required for command: str_replace")
- }
- newStr := ""
- if editRequest.NewStr != nil {
- newStr = *editRequest.NewStr
- }
- result, err := handleStrReplace(path, *editRequest.OldStr, newStr)
- if err != nil {
- return nil, err
- }
- return llm.TextContent(result), nil
- case insertCommand:
- if editRequest.InsertLine == nil {
- return nil, fmt.Errorf("parameter insert_line is required for command: insert")
- }
- if editRequest.NewStr == nil {
- return nil, fmt.Errorf("parameter new_str is required for command: insert")
- }
- result, err := handleInsert(path, *editRequest.InsertLine, *editRequest.NewStr)
- if err != nil {
- return nil, err
- }
- return llm.TextContent(result), nil
- case undoEditCommand:
- result, err := handleUndoEdit(path)
- if err != nil {
- return nil, err
- }
- return llm.TextContent(result), nil
- default:
- return nil, fmt.Errorf("command %s is not implemented", cmd)
- }
-}
-
-// Utility function to check if a command is valid
-func isValidCommand(cmd editCommand) bool {
- switch cmd {
- case viewCommand, createCommand, strReplaceCommand, insertCommand, undoEditCommand:
- return true
- default:
- return false
- }
-}
-
-// validatePath checks if the path/command combination is valid
-func validatePath(cmd editCommand, path string) error {
- // Check if it's an absolute path
- if !filepath.IsAbs(path) {
- suggestedPath := "/" + path
- return fmt.Errorf("the path %s is not an absolute path, it should start with '/'. Maybe you meant %s?", path, suggestedPath)
- }
-
- // Get file info
- info, err := os.Stat(path)
-
- // Check if path exists (except for create command)
- if err != nil {
- if os.IsNotExist(err) && cmd != createCommand {
- return fmt.Errorf("the path %s does not exist. Please provide a valid path", path)
- } else if !os.IsNotExist(err) {
- return fmt.Errorf("error accessing path %s: %v", path, err)
- }
- } else {
- // Path exists, check if it's a directory
- if info.IsDir() && cmd != viewCommand {
- return fmt.Errorf("the path %s is a directory and only the 'view' command can be used on directories", path)
- }
-
- // For create command, check if file already exists
- if cmd == createCommand {
- return fmt.Errorf("file already exists at: %s. Cannot overwrite files using command 'create'", path)
- }
- }
-
- return nil
-}
-
-// handleView implements the view command
-func handleView(ctx context.Context, path string, viewRange []int) (string, error) {
- info, err := os.Stat(path)
- if err != nil {
- return "", fmt.Errorf("error accessing path %s: %v", path, err)
- }
-
- // Handle directory view
- if info.IsDir() {
- if viewRange != nil {
- return "", fmt.Errorf("the view_range parameter is not allowed when path points to a directory")
- }
-
- // List files in the directory (up to 2 levels deep)
- return listDirectory(ctx, path)
- }
-
- // Handle file view
- fileContent, err := readFile(path)
- if err != nil {
- return "", err
- }
-
- initLine := 1
- if viewRange != nil {
- if len(viewRange) != 2 {
- return "", fmt.Errorf("invalid view_range. It should be a list of two integers")
- }
-
- fileLines := strings.Split(fileContent, "\n")
- nLinesFile := len(fileLines)
- initLine, finalLine := viewRange[0], viewRange[1]
-
- if initLine < 1 || initLine > nLinesFile {
- return "", fmt.Errorf("invalid view_range: %v. Its first element %d should be within the range of lines of the file: [1, %d]",
- viewRange, initLine, nLinesFile)
- }
-
- if finalLine != -1 && finalLine < initLine {
- return "", fmt.Errorf("invalid view_range: %v. Its second element %d should be larger or equal than its first %d",
- viewRange, finalLine, initLine)
- }
-
- if finalLine > nLinesFile {
- return "", fmt.Errorf("invalid view_range: %v. Its second element %d should be smaller than the number of lines in the file: %d",
- viewRange, finalLine, nLinesFile)
- }
-
- if finalLine == -1 {
- fileContent = strings.Join(fileLines[initLine-1:], "\n")
- } else {
- fileContent = strings.Join(fileLines[initLine-1:finalLine], "\n")
- }
- }
-
- return makeOutput(fileContent, path, initLine), nil
-}
-
-// handleCreate implements the create command
-func handleCreate(path string, fileText string) (string, error) {
- // Ensure the directory exists
- dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return "", fmt.Errorf("failed to create directory %s: %v", dir, err)
- }
-
- // Write the file
- if err := writeFile(path, fileText); err != nil {
- return "", err
- }
-
- // Save to history
- fileHistory[path] = append(fileHistory[path], fileText)
-
- return fmt.Sprintf("File created successfully at: %s", path), nil
-}
-
-// handleStrReplace implements the str_replace command
-func handleStrReplace(path, oldStr, newStr string) (string, error) {
- // Read the file content
- fileContent, err := readFile(path)
- if err != nil {
- return "", err
- }
-
- // Replace tabs with spaces
- fileContent = maybeExpandTabs(path, fileContent)
- oldStr = maybeExpandTabs(path, oldStr)
- newStr = maybeExpandTabs(path, newStr)
-
- // Check if oldStr is unique in the file
- occurrences := strings.Count(fileContent, oldStr)
- if occurrences == 0 {
- return "", fmt.Errorf("no replacement was performed, old_str %q did not appear verbatim in %s", oldStr, path)
- } else if occurrences > 1 {
- // Find line numbers where oldStr appears
- fileContentLines := strings.Split(fileContent, "\n")
- var lines []int
- for idx, line := range fileContentLines {
- if strings.Contains(line, oldStr) {
- lines = append(lines, idx+1)
- }
- }
- return "", fmt.Errorf("no replacement was performed. Multiple occurrences of old_str %q in lines %v. Please ensure it is unique", oldStr, lines)
- }
-
- // Save the current content to history
- fileHistory[path] = append(fileHistory[path], fileContent)
-
- // Replace oldStr with newStr
- newFileContent := strings.Replace(fileContent, oldStr, newStr, 1)
-
- // Write the new content to the file
- if err := writeFile(path, newFileContent); err != nil {
- return "", err
- }
-
- // Create a snippet of the edited section
- parts := strings.Split(fileContent, oldStr)
- if len(parts) == 0 {
- // This should never happen due to the earlier check, but let's be safe
- parts = []string{""}
- }
- replacementLine := strings.Count(parts[0], "\n")
- startLine := max(0, replacementLine-snippetLines)
- endLine := replacementLine + snippetLines + strings.Count(newStr, "\n")
- fileLines := strings.Split(newFileContent, "\n")
- if len(fileLines) == 0 {
- fileLines = []string{""}
- }
- endLine = min(endLine+1, len(fileLines))
- snippet := strings.Join(fileLines[startLine:endLine], "\n")
-
- // Prepare the success message
- successMsg := fmt.Sprintf("The file %s has been edited. ", path)
- successMsg += makeOutput(snippet, fmt.Sprintf("a snippet of %s", path), startLine+1)
- successMsg += "Review the changes and make sure they are as expected. Edit the file again if necessary."
-
- return successMsg, nil
-}
-
-// handleInsert implements the insert command
-func handleInsert(path string, insertLine int, newStr string) (string, error) {
- // Read the file content
- fileContent, err := readFile(path)
- if err != nil {
- return "", err
- }
-
- // Replace tabs with spaces
- fileContent = maybeExpandTabs(path, fileContent)
- newStr = maybeExpandTabs(path, newStr)
-
- // Split the file content into lines
- fileTextLines := strings.Split(fileContent, "\n")
- nLinesFile := len(fileTextLines)
-
- // Validate insert line
- if insertLine < 0 || insertLine > nLinesFile {
- return "", fmt.Errorf("invalid insert_line parameter: %d. It should be within the range of lines of the file: [0, %d]",
- insertLine, nLinesFile)
- }
-
- // Save the current content to history
- fileHistory[path] = append(fileHistory[path], fileContent)
-
- // Split the new string into lines
- newStrLines := strings.Split(newStr, "\n")
-
- // Create new content by inserting the new lines
- newFileTextLines := make([]string, 0, nLinesFile+len(newStrLines))
- newFileTextLines = append(newFileTextLines, fileTextLines[:insertLine]...)
- newFileTextLines = append(newFileTextLines, newStrLines...)
- newFileTextLines = append(newFileTextLines, fileTextLines[insertLine:]...)
-
- // Create a snippet of the edited section
- snippetStart := max(0, insertLine-snippetLines)
- snippetEnd := min(insertLine+snippetLines, nLinesFile)
-
- snippetLines := make([]string, 0)
- snippetLines = append(snippetLines, fileTextLines[snippetStart:insertLine]...)
- snippetLines = append(snippetLines, newStrLines...)
- snippetLines = append(snippetLines, fileTextLines[insertLine:snippetEnd]...)
- snippet := strings.Join(snippetLines, "\n")
-
- // Write the new content to the file
- newFileText := strings.Join(newFileTextLines, "\n")
- if err := writeFile(path, newFileText); err != nil {
- return "", err
- }
-
- // Prepare the success message
- successMsg := fmt.Sprintf("The file %s has been edited. ", path)
- successMsg += makeOutput(snippet, "a snippet of the edited file", max(1, insertLine-4+1))
- successMsg += "Review the changes and make sure they are as expected (correct indentation, no duplicate lines, etc). Edit the file again if necessary."
-
- return successMsg, nil
-}
-
-// handleUndoEdit implements the undo_edit command
-func handleUndoEdit(path string) (string, error) {
- history, exists := fileHistory[path]
- if !exists || len(history) == 0 {
- return "", fmt.Errorf("no edit history found for %s", path)
- }
-
- // Get the last edit and remove it from history
- lastIdx := len(history) - 1
- oldText := history[lastIdx]
- fileHistory[path] = history[:lastIdx]
-
- // Write the old content back to the file
- if err := writeFile(path, oldText); err != nil {
- return "", err
- }
-
- return fmt.Sprintf("Last edit to %s undone successfully. %s", path, makeOutput(oldText, path, 1)), nil
-}
-
-// listDirectory lists files and directories up to 2 levels deep
-func listDirectory(ctx context.Context, path string) (string, error) {
- cmd := fmt.Sprintf("find %s -maxdepth 2 -not -path '*/\\.*'", path)
- output, err := executeCommand(ctx, cmd)
- if err != nil {
- return "", fmt.Errorf("failed to list directory: %v", err)
- }
-
- 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
-}
-
-// executeCommand executes a shell command and returns its output
-func executeCommand(ctx context.Context, cmd string) (string, error) {
- // This is a simplified version without timeouts for now
- bash := exec.CommandContext(ctx, "bash", "-c", cmd)
- bash.Dir = WorkingDir(ctx)
- output, err := bash.CombinedOutput()
- if err != nil {
- return "", fmt.Errorf("command execution failed: %v: %s", err, string(output))
- }
- return maybetruncate(string(output)), nil
-}
-
-// readFile reads the content of a file
-func readFile(path string) (string, error) {
- content, err := os.ReadFile(path)
- if err != nil {
- return "", fmt.Errorf("failed to read file %s: %v", path, err)
- }
- return string(content), nil
-}
-
-// writeFile writes content to a file
-func writeFile(path, content string) error {
- if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
- return fmt.Errorf("failed to write to file %s: %v", path, err)
- }
- return nil
-}
-
-// makeOutput generates a formatted output for the CLI
-func makeOutput(fileContent, fileDescriptor string, initLine int) string {
- fileContent = maybetruncate(fileContent)
- fileContent = maybeExpandTabs(fileDescriptor, fileContent)
-
- var lines []string
- for i, line := range strings.Split(fileContent, "\n") {
- lines = append(lines, fmt.Sprintf("%6d\t%s", i+initLine, line))
- }
-
- return fmt.Sprintf("Here's the result of running `cat -n` on %s:\n%s\n", fileDescriptor, strings.Join(lines, "\n"))
-}
-
-// maybetruncate truncates content and appends a notice if content exceeds the specified length
-func maybetruncate(content string) string {
- if len(content) <= maxResponseLen {
- return content
- }
- return content[:maxResponseLen] + truncatedMessage
-}
-
-// maybeExpandTabs is currently a no-op. The python
-// implementation replaces tabs with spaces, but this strikes
-// me as unwise for our tool.
-func maybeExpandTabs(path, s string) string {
- // return strings.ReplaceAll(s, "\t", " ")
- return s
-}
diff --git a/claudetool/edit_regression_test.go b/claudetool/edit_regression_test.go
deleted file mode 100644
index cb859fe..0000000
--- a/claudetool/edit_regression_test.go
+++ /dev/null
@@ -1,152 +0,0 @@
-package claudetool
-
-import (
- "context"
- "encoding/json"
- "strings"
- "testing"
-)
-
-// TestEmptyContentHandling tests handling of empty content in str_replace and related operations
-// This test specifically reproduces conditions that might lead to "index out of range [0]" panic
-func TestEmptyContentHandling(t *testing.T) {
- // Create a file with empty content
- emptyFile := setupTestFile(t, "")
-
- // Test running EditRun directly with empty content
- // This more closely simulates the actual call flow that led to the panic
- input := map[string]any{
- "command": "str_replace",
- "path": emptyFile,
- "old_str": "nonexistent text",
- "new_str": "new content",
- }
-
- inputJSON, err := json.Marshal(input)
- if err != nil {
- t.Fatalf("Failed to marshal input: %v", err)
- }
-
- // This should not panic but return an error
- _, err = EditRun(context.Background(), inputJSON)
- if err == nil {
- t.Fatalf("Expected error for empty file with str_replace but got none")
- }
-
- // Make sure the error message is as expected
- if !strings.Contains(err.Error(), "did not appear verbatim") {
- t.Errorf("Expected error message to indicate missing string, got: %s", err.Error())
- }
-}
-
-// TestNilParameterHandling tests error cases with nil parameters
-// This test validates proper error handling when nil or invalid parameters are provided
-func TestNilParameterHandling(t *testing.T) {
- // Create a test file
- testFile := setupTestFile(t, "test content")
-
- // Test case 1: nil old_str in str_replace
- input1 := map[string]any{
- "command": "str_replace",
- "path": testFile,
- // old_str is deliberately missing
- "new_str": "replacement",
- }
-
- inputJSON1, err := json.Marshal(input1)
- if err != nil {
- t.Fatalf("Failed to marshal input: %v", err)
- }
-
- _, err = EditRun(context.Background(), inputJSON1)
- if err == nil {
- t.Fatalf("Expected error for missing old_str but got none")
- }
- if !strings.Contains(err.Error(), "parameter old_str is required") {
- t.Errorf("Expected error message to indicate missing old_str, got: %s", err.Error())
- }
-
- // Test case 2: nil new_str in insert
- input2 := map[string]any{
- "command": "insert",
- "path": testFile,
- "insert_line": 1,
- // new_str is deliberately missing
- }
-
- inputJSON2, err := json.Marshal(input2)
- if err != nil {
- t.Fatalf("Failed to marshal input: %v", err)
- }
-
- _, err = EditRun(context.Background(), inputJSON2)
- if err == nil {
- t.Fatalf("Expected error for missing new_str but got none")
- }
- if !strings.Contains(err.Error(), "parameter new_str is required") {
- t.Errorf("Expected error message to indicate missing new_str, got: %s", err.Error())
- }
-
- // Test case 3: nil view_range in view
- // This doesn't cause an error, but tests the code path
- input3 := map[string]any{
- "command": "view",
- "path": testFile,
- // No view_range
- }
-
- inputJSON3, err := json.Marshal(input3)
- if err != nil {
- t.Fatalf("Failed to marshal input: %v", err)
- }
-
- // This should not result in an error
- _, err = EditRun(context.Background(), inputJSON3)
- if err != nil {
- t.Fatalf("Unexpected error for nil view_range: %v", err)
- }
-}
-
-// TestEmptySplitResult tests the specific scenario where strings.Split might return empty results
-// This directly reproduces conditions that might have led to the "index out of range [0]" panic
-func TestEmptySplitResult(t *testing.T) {
- // Direct test of strings.Split behavior and our handling of it
- emptyCases := []struct {
- content string
- oldStr string
- }{
- {"", "any string"},
- {"content", "not in string"},
- {"\n\n", "also not here"},
- }
-
- for _, tc := range emptyCases {
- parts := strings.Split(tc.content, tc.oldStr)
-
- // Verify that strings.Split with non-matching separator returns a slice with original content
- if len(parts) != 1 {
- t.Errorf("Expected strings.Split to return a slice with 1 element when separator isn't found, got %d elements", len(parts))
- }
-
- // Double check the content
- if len(parts) > 0 && parts[0] != tc.content {
- t.Errorf("Expected parts[0] to be original content %q, got %q", tc.content, parts[0])
- }
- }
-
- // Test the actual unsafe scenario with empty content
- emptyFile := setupTestFile(t, "")
-
- // Get the content and simulate the internal string splitting
- content, _ := readFile(emptyFile)
- oldStr := "nonexistent"
- parts := strings.Split(content, oldStr)
-
- // Validate that the defensive code would work
- if len(parts) == 0 {
- parts = []string{""} // This is the fix
- }
-
- // This would have panicked without the fix
- _ = strings.Count(parts[0], "\n")
-}
diff --git a/claudetool/edit_test.go b/claudetool/edit_test.go
deleted file mode 100644
index ab687fa..0000000
--- a/claudetool/edit_test.go
+++ /dev/null
@@ -1,399 +0,0 @@
-package claudetool
-
-import (
- "context"
- "encoding/json"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-// setupTestFile creates a temporary file with given content for testing
-func setupTestFile(t *testing.T, content string) string {
- t.Helper()
-
- // Create a temporary directory
- tempDir, err := os.MkdirTemp("", "anthropic_edit_test_*")
- if err != nil {
- t.Fatalf("Failed to create temp directory: %v", err)
- }
-
- // Create a test file in the temp directory
- testFile := filepath.Join(tempDir, "test_file.txt")
- if err := os.WriteFile(testFile, []byte(content), 0o644); err != nil {
- os.RemoveAll(tempDir)
- t.Fatalf("Failed to write test file: %v", err)
- }
-
- // Register cleanup function
- t.Cleanup(func() {
- os.RemoveAll(tempDir)
- })
-
- return testFile
-}
-
-// callEditTool is a helper to call the edit tool with specific parameters
-func callEditTool(t *testing.T, input map[string]any) string {
- t.Helper()
-
- // Convert input to JSON
- inputJSON, err := json.Marshal(input)
- if err != nil {
- t.Fatalf("Failed to marshal input: %v", err)
- }
-
- // Call the tool
- result, err := EditRun(context.Background(), inputJSON)
- if err != nil {
- t.Fatalf("Tool execution failed: %v", err)
- }
-
- return ContentToString(result)
-}
-
-// TestEditToolView tests the view command functionality
-func TestEditToolView(t *testing.T) {
- content := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
- testFile := setupTestFile(t, content)
-
- // Test the view command
- result := callEditTool(t, map[string]any{
- "command": "view",
- "path": testFile,
- })
-
- // Verify results
- if !strings.Contains(result, "Line 1") {
- t.Errorf("View result should contain the file content, got: %s", result)
- }
-
- // Test view with range
- result = callEditTool(t, map[string]any{
- "command": "view",
- "path": testFile,
- "view_range": []int{2, 4},
- })
-
- // Verify range results
- if strings.Contains(result, "Line 1") || !strings.Contains(result, "Line 2") {
- t.Errorf("View with range should show only specified lines, got: %s", result)
- }
-}
-
-// TestEditToolStrReplace tests the str_replace command functionality
-func TestEditToolStrReplace(t *testing.T) {
- content := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
- testFile := setupTestFile(t, content)
-
- // Test the str_replace command
- result := callEditTool(t, map[string]any{
- "command": "str_replace",
- "path": testFile,
- "old_str": "Line 3",
- "new_str": "Modified Line 3",
- })
-
- // Verify the file was modified
- modifiedContent, err := os.ReadFile(testFile)
- if err != nil {
- t.Fatalf("Failed to read test file: %v", err)
- }
-
- if !strings.Contains(string(modifiedContent), "Modified Line 3") {
- t.Errorf("File content should be modified, got: %s", string(modifiedContent))
- }
-
- // Verify the result contains a snippet
- if !strings.Contains(result, "Modified Line 3") {
- t.Errorf("Result should contain the modified content, got: %s", result)
- }
-}
-
-// TestEditToolInsert tests the insert command functionality
-func TestEditToolInsert(t *testing.T) {
- content := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
- testFile := setupTestFile(t, content)
-
- // Test the insert command
- result := callEditTool(t, map[string]any{
- "command": "insert",
- "path": testFile,
- "insert_line": 2,
- "new_str": "Inserted Line",
- })
-
- // Verify the file was modified
- modifiedContent, err := os.ReadFile(testFile)
- if err != nil {
- t.Fatalf("Failed to read test file: %v", err)
- }
-
- expected := "Line 1\nLine 2\nInserted Line\nLine 3\nLine 4\nLine 5"
- if string(modifiedContent) != expected {
- t.Errorf("File content incorrect after insert. Expected:\n%s\nGot:\n%s", expected, string(modifiedContent))
- }
-
- // Verify the result contains a snippet
- if !strings.Contains(result, "Inserted Line") {
- t.Errorf("Result should contain the inserted content, got: %s", result)
- }
-}
-
-// TestEditToolCreate tests the create command functionality
-func TestEditToolCreate(t *testing.T) {
- tempDir, err := os.MkdirTemp("", "anthropic_edit_test_create_*")
- if err != nil {
- t.Fatalf("Failed to create temp directory: %v", err)
- }
-
- t.Cleanup(func() {
- os.RemoveAll(tempDir)
- })
-
- newFilePath := filepath.Join(tempDir, "new_file.txt")
- content := "This is a new file\nWith multiple lines"
-
- // Test the create command
- result := callEditTool(t, map[string]any{
- "command": "create",
- "path": newFilePath,
- "file_text": content,
- })
-
- // Verify the file was created with the right content
- createdContent, err := os.ReadFile(newFilePath)
- if err != nil {
- t.Fatalf("Failed to read created file: %v", err)
- }
-
- if string(createdContent) != content {
- t.Errorf("Created file content incorrect. Expected:\n%s\nGot:\n%s", content, string(createdContent))
- }
-
- // Verify the result message
- if !strings.Contains(result, "File created successfully") {
- t.Errorf("Result should confirm file creation, got: %s", result)
- }
-}
-
-// TestEditToolUndoEdit tests the undo_edit command functionality
-func TestEditToolUndoEdit(t *testing.T) {
- originalContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
- testFile := setupTestFile(t, originalContent)
-
- // First modify the file
- callEditTool(t, map[string]any{
- "command": "str_replace",
- "path": testFile,
- "old_str": "Line 3",
- "new_str": "Modified Line 3",
- })
-
- // Then undo the edit
- result := callEditTool(t, map[string]any{
- "command": "undo_edit",
- "path": testFile,
- })
-
- // Verify the file was restored to original content
- restoredContent, err := os.ReadFile(testFile)
- if err != nil {
- t.Fatalf("Failed to read test file: %v", err)
- }
-
- if string(restoredContent) != originalContent {
- t.Errorf("File content should be restored to original, got: %s", string(restoredContent))
- }
-
- // Verify the result message
- if !strings.Contains(result, "undone successfully") {
- t.Errorf("Result should confirm undo operation, got: %s", result)
- }
-}
-
-// TestEditToolErrors tests various error conditions
-func TestEditToolErrors(t *testing.T) {
- content := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
- testFile := setupTestFile(t, content)
-
- testCases := []struct {
- name string
- input map[string]any
- errMsg string
- }{
- {
- name: "Invalid command",
- input: map[string]any{
- "command": "invalid_command",
- "path": testFile,
- },
- errMsg: "unrecognized command",
- },
- {
- name: "Non-existent file",
- input: map[string]any{
- "command": "view",
- "path": "/non/existent/file.txt",
- },
- errMsg: "does not exist",
- },
- {
- name: "Missing required parameter",
- input: map[string]any{
- "command": "str_replace",
- "path": testFile,
- // Missing old_str
- },
- errMsg: "parameter old_str is required",
- },
- {
- name: "Multiple occurrences in str_replace",
- input: map[string]any{
- "command": "str_replace",
- "path": testFile,
- "old_str": "Line", // Appears multiple times
- "new_str": "Modified Line",
- },
- errMsg: "Multiple occurrences",
- },
- {
- name: "Invalid view range",
- input: map[string]any{
- "command": "view",
- "path": testFile,
- "view_range": []int{10, 20}, // Out of range
- },
- errMsg: "invalid view_range",
- },
- }
-
- for _, tc := range testCases {
- t.Run(tc.name, func(t *testing.T) {
- inputJSON, err := json.Marshal(tc.input)
- if err != nil {
- t.Fatalf("Failed to marshal input: %v", err)
- }
-
- _, err = EditRun(context.Background(), inputJSON)
- if err == nil {
- t.Fatalf("Expected error but got none")
- }
-
- if !strings.Contains(err.Error(), tc.errMsg) {
- t.Errorf("Error message does not contain expected text. Expected to contain: %q, Got: %q", tc.errMsg, err.Error())
- }
- })
- }
-}
-
-// TestHandleStrReplaceEdgeCases tests the handleStrReplace function specifically for edge cases
-// that could cause panics like "index out of range [0] with length 0"
-func TestHandleStrReplaceEdgeCases(t *testing.T) {
- // The issue was with strings.Split returning an empty slice when the separator wasn't found
- // This test directly tests the internal implementation with conditions that might cause this
-
- // Create a test file with empty content
- emptyFile := setupTestFile(t, "")
-
- // Test with empty file content and arbitrary oldStr
- _, err := handleStrReplace(emptyFile, "some string that doesn't exist", "new content")
- if err == nil {
- t.Fatal("Expected error for empty file but got none")
- }
- if !strings.Contains(err.Error(), "did not appear verbatim") {
- t.Errorf("Expected error message to indicate missing string, got: %s", err.Error())
- }
-
- // Create a file with content that doesn't match oldStr
- nonMatchingFile := setupTestFile(t, "This is some content\nthat doesn't contain the target string")
-
- // Test with content that doesn't contain oldStr
- _, err = handleStrReplace(nonMatchingFile, "target string not present", "replacement")
- if err == nil {
- t.Fatal("Expected error for non-matching content but got none")
- }
- if !strings.Contains(err.Error(), "did not appear verbatim") {
- t.Errorf("Expected error message to indicate missing string, got: %s", err.Error())
- }
-
- // Test handling of the edge case that could potentially cause the "index out of range" panic
- // This directly verifies that the handleStrReplace function properly handles the case where
- // strings.Split returns an empty or unexpected result
-
- // Verify that the protection against empty parts slice works
- fileContent := ""
- oldStr := "some string"
- parts := strings.Split(fileContent, oldStr)
- if len(parts) == 0 {
- // This should match the protection in the code
- parts = []string{""}
- }
-
- // This should not panic with the fix in place
- _ = strings.Count(parts[0], "\n") // This line would have panicked without the fix
-}
-
-// TestViewRangeWithStrReplace tests that the view_range parameter works correctly
-// with the str_replace command (tests the full workflow)
-func TestViewRangeWithStrReplace(t *testing.T) {
- // Create test file with multiple lines
- content := "Line 1: First line\nLine 2: Second line\nLine 3: Third line\nLine 4: Fourth line\nLine 5: Fifth line"
- testFile := setupTestFile(t, content)
-
- // First view a subset of the file using view_range
- viewResult := callEditTool(t, map[string]any{
- "command": "view",
- "path": testFile,
- "view_range": []int{2, 4}, // Only lines 2-4
- })
-
- // Verify that we only see the specified lines
- if strings.Contains(viewResult, "Line 1:") || strings.Contains(viewResult, "Line 5:") {
- t.Errorf("View with range should only show lines 2-4, got: %s", viewResult)
- }
- if !strings.Contains(viewResult, "Line 2:") || !strings.Contains(viewResult, "Line 4:") {
- t.Errorf("View with range should show lines 2-4, got: %s", viewResult)
- }
-
- // Now perform a str_replace on one of the lines we viewed
- replaceResult := callEditTool(t, map[string]any{
- "command": "str_replace",
- "path": testFile,
- "old_str": "Line 3: Third line",
- "new_str": "Line 3: MODIFIED Third line",
- })
-
- // Check that the replacement was successful
- if !strings.Contains(replaceResult, "Line 3: MODIFIED Third line") {
- t.Errorf("Replace result should contain the modified line, got: %s", replaceResult)
- }
-
- // Verify the file content was updated correctly
- modifiedContent, err := os.ReadFile(testFile)
- if err != nil {
- t.Fatalf("Failed to read test file after modification: %v", err)
- }
-
- expectedContent := "Line 1: First line\nLine 2: Second line\nLine 3: MODIFIED Third line\nLine 4: Fourth line\nLine 5: Fifth line"
- if string(modifiedContent) != expectedContent {
- t.Errorf("File content after replacement is incorrect.\nExpected:\n%s\nGot:\n%s",
- expectedContent, string(modifiedContent))
- }
-
- // View the modified file with a different view_range
- finalViewResult := callEditTool(t, map[string]any{
- "command": "view",
- "path": testFile,
- "view_range": []int{3, 3}, // Only the modified line
- })
-
- // Verify we can see only the modified line
- if !strings.Contains(finalViewResult, "Line 3: MODIFIED Third line") {
- t.Errorf("Final view should show the modified line, got: %s", finalViewResult)
- }
- if strings.Contains(finalViewResult, "Line 2:") || strings.Contains(finalViewResult, "Line 4:") {
- t.Errorf("Final view should only show line 3, got: %s", finalViewResult)
- }
-}
diff --git a/cmd/sketch/main.go b/cmd/sketch/main.go
index 775da54..786d376 100644
--- a/cmd/sketch/main.go
+++ b/cmd/sketch/main.go
@@ -429,15 +429,14 @@
}
agentConfig := loop.AgentConfig{
- Context: ctx,
- Service: llmService,
- Budget: budget,
- GitUsername: flags.gitUsername,
- GitEmail: flags.gitEmail,
- SessionID: flags.sessionID,
- ClientGOOS: runtime.GOOS,
- ClientGOARCH: runtime.GOARCH,
- UseAnthropicEdit: os.Getenv("SKETCH_ANTHROPIC_EDIT") == "1",
+ Context: ctx,
+ Service: llmService,
+ Budget: budget,
+ GitUsername: flags.gitUsername,
+ GitEmail: flags.gitEmail,
+ SessionID: flags.sessionID,
+ ClientGOOS: runtime.GOOS,
+ ClientGOARCH: runtime.GOARCH,
OutsideHostname: flags.outsideHostname,
OutsideOS: flags.outsideOS,
OutsideWorkingDir: flags.outsideWorkingDir,
diff --git a/loop/agent.go b/loop/agent.go
index ce7d8e3..1527681 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -770,18 +770,17 @@
// AgentConfig contains configuration for creating a new Agent.
type AgentConfig struct {
- Context context.Context
- Service llm.Service
- Budget conversation.Budget
- GitUsername string
- GitEmail string
- SessionID string
- ClientGOOS string
- ClientGOARCH string
- InDocker bool
- UseAnthropicEdit bool
- OneShot bool
- WorkingDir string
+ Context context.Context
+ Service llm.Service
+ Budget conversation.Budget
+ GitUsername string
+ GitEmail string
+ SessionID string
+ ClientGOOS string
+ ClientGOARCH string
+ InDocker bool
+ OneShot bool
+ WorkingDir string
// Outside information
OutsideHostname string
OutsideOS string
@@ -1002,7 +1001,7 @@
browserTools = bTools
convo.Tools = []*llm.Tool{
- bashTool, claudetool.Keyword,
+ bashTool, claudetool.Keyword, claudetool.Patch,
claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
a.codereview.Tool(), claudetool.AboutSketch,
}
@@ -1013,11 +1012,6 @@
}
convo.Tools = append(convo.Tools, browserTools...)
- if a.config.UseAnthropicEdit {
- convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
- } else {
- convo.Tools = append(convo.Tools, claudetool.Patch)
- }
convo.Listener = a
return convo
}
@@ -1916,7 +1910,6 @@
// systemPromptData contains the data used to render the system prompt template
type systemPromptData struct {
- EditPrompt string
ClientGOOS string
ClientGOARCH string
WorkingDir string
@@ -1927,16 +1920,7 @@
// renderSystemPrompt renders the system prompt template.
func (a *Agent) renderSystemPrompt() string {
- // Determine the appropriate edit prompt based on config
- var editPrompt string
- if a.config.UseAnthropicEdit {
- editPrompt = "Then use the str_replace_editor tool to make those edits. For short complete file replacements, you may use the bash tool with cat and heredoc stdin."
- } else {
- editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
- }
-
data := systemPromptData{
- EditPrompt: editPrompt,
ClientGOOS: a.config.ClientGOOS,
ClientGOARCH: a.config.ClientGOARCH,
WorkingDir: a.workingDir,
diff --git a/loop/agent_system_prompt.txt b/loop/agent_system_prompt.txt
index 3831e98..cc4ac06 100644
--- a/loop/agent_system_prompt.txt
+++ b/loop/agent_system_prompt.txt
@@ -24,7 +24,7 @@
To make edits reliably and efficiently, first think about the intent of the edit,
and what set of patches will achieve that intent.
-{{.EditPrompt}}
+Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call.
Complete every task exhaustively - no matter how repetitive or tedious.
Partial work, pattern demonstrations, or stubs with TODOs are not acceptable, unless explicitly permitted by the user.
diff --git a/loop/testdata/agent_loop.httprr b/loop/testdata/agent_loop.httprr
index fbafd31..d86594e 100644
--- a/loop/testdata/agent_loop.httprr
+++ b/loop/testdata/agent_loop.httprr
@@ -1,5 +1,5 @@
httprr trace v1
-20299 2495
+20299 2778
POST https://api.anthropic.com/v1/messages HTTP/1.1
Host: api.anthropic.com
User-Agent: Go-http-client/1.1
@@ -74,6 +74,54 @@
}
},
{
+ "name": "patch",
+ "description": "File modification tool for precise text edits.\n\nOperations:\n- replace: Substitute text with new content\n- append_eof: Append new text at the end of the file\n- prepend_bof: Insert new text at the beginning of the file\n- overwrite: Replace the entire file with new content (automatically creates the file)\n\nUsage notes:\n- All inputs are interpreted literally (no automatic newline or whitespace handling)\n- For replace operations, oldText must appear EXACTLY ONCE in the file",
+ "input_schema": {
+ "type": "object",
+ "required": [
+ "path",
+ "patches"
+ ],
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Absolute path to the file to patch"
+ },
+ "patches": {
+ "type": "array",
+ "description": "List of patch requests to apply",
+ "items": {
+ "type": "object",
+ "required": [
+ "operation",
+ "newText"
+ ],
+ "properties": {
+ "operation": {
+ "type": "string",
+ "enum": [
+ "replace",
+ "append_eof",
+ "prepend_bof",
+ "overwrite"
+ ],
+ "description": "Type of operation to perform"
+ },
+ "oldText": {
+ "type": "string",
+ "description": "Text to locate for the operation (must be unique in file, required for replace)"
+ },
+ "newText": {
+ "type": "string",
+ "description": "The new text to use (empty for deletions)"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ {
"name": "think",
"description": "Think out loud, take notes, form plans. Has no external effects.",
"input_schema": {
@@ -542,54 +590,6 @@
"path"
]
}
- },
- {
- "name": "patch",
- "description": "File modification tool for precise text edits.\n\nOperations:\n- replace: Substitute text with new content\n- append_eof: Append new text at the end of the file\n- prepend_bof: Insert new text at the beginning of the file\n- overwrite: Replace the entire file with new content (automatically creates the file)\n\nUsage notes:\n- All inputs are interpreted literally (no automatic newline or whitespace handling)\n- For replace operations, oldText must appear EXACTLY ONCE in the file",
- "input_schema": {
- "type": "object",
- "required": [
- "path",
- "patches"
- ],
- "properties": {
- "path": {
- "type": "string",
- "description": "Absolute path to the file to patch"
- },
- "patches": {
- "type": "array",
- "description": "List of patch requests to apply",
- "items": {
- "type": "object",
- "required": [
- "operation",
- "newText"
- ],
- "properties": {
- "operation": {
- "type": "string",
- "enum": [
- "replace",
- "append_eof",
- "prepend_bof",
- "overwrite"
- ],
- "description": "Type of operation to perform"
- },
- "oldText": {
- "type": "string",
- "description": "Text to locate for the operation (must be unique in file, required for replace)"
- },
- "newText": {
- "type": "string",
- "description": "The new text to use (empty for deletions)"
- }
- }
- }
- }
- }
- }
}
],
"system": [
@@ -605,24 +605,24 @@
Anthropic-Organization-Id: 3c473a21-7208-450a-a9f8-80aebda45c1b
Anthropic-Ratelimit-Input-Tokens-Limit: 200000
Anthropic-Ratelimit-Input-Tokens-Remaining: 200000
-Anthropic-Ratelimit-Input-Tokens-Reset: 2025-05-29T17:27:03Z
+Anthropic-Ratelimit-Input-Tokens-Reset: 2025-05-30T15:57:41Z
Anthropic-Ratelimit-Output-Tokens-Limit: 80000
Anthropic-Ratelimit-Output-Tokens-Remaining: 80000
-Anthropic-Ratelimit-Output-Tokens-Reset: 2025-05-29T17:27:08Z
+Anthropic-Ratelimit-Output-Tokens-Reset: 2025-05-30T15:57:47Z
Anthropic-Ratelimit-Requests-Limit: 4000
Anthropic-Ratelimit-Requests-Remaining: 3999
-Anthropic-Ratelimit-Requests-Reset: 2025-05-29T17:27:01Z
+Anthropic-Ratelimit-Requests-Reset: 2025-05-30T15:57:39Z
Anthropic-Ratelimit-Tokens-Limit: 280000
Anthropic-Ratelimit-Tokens-Remaining: 280000
-Anthropic-Ratelimit-Tokens-Reset: 2025-05-29T17:27:03Z
+Anthropic-Ratelimit-Tokens-Reset: 2025-05-30T15:57:41Z
Cf-Cache-Status: DYNAMIC
-Cf-Ray: 9477b339e89417ee-SJC
+Cf-Ray: 947f6db1dc082349-SJC
Content-Type: application/json
-Date: Thu, 29 May 2025 17:27:09 GMT
-Request-Id: req_011CPcDNHrEZY5uavYRd4Wsk
+Date: Fri, 30 May 2025 15:57:47 GMT
+Request-Id: req_011CPdzMxBJ1hScfsMghVkJA
Server: cloudflare
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Via: 1.1 google
X-Robots-Tag: none
-{"id":"msg_01PbteLNxXTMFWyxMUWxhpPM","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Here are the tools available to me:\n\n**File & Code Operations:**\n- `patch` - Modify files with precise text edits\n- `keyword_search` - Search codebase with keywords\n- `bash` - Execute shell commands\n- `codereview` - Run automated code review\n\n**Project Management:**\n- `todo_read` / `todo_write` - Manage task lists\n- `think` - Record thoughts and plans\n- `precommit` - Create git branch for work\n- `done` - Complete work with checklist verification\n\n**Browser Automation:**\n- `browser_navigate` - Navigate to URLs\n- `browser_click` - Click elements\n- `browser_type` - Type into inputs\n- `browser_wait_for` - Wait for elements\n- `browser_get_text` - Read page text\n- `browser_eval` - Execute JavaScript\n- `browser_scroll_into_view` - Scroll elements into view\n- `browser_resize` - Resize browser window\n- `browser_take_screenshot` - Capture screenshots\n- `browser_read_image` - Read image files\n- `browser_recent_console_logs` / `browser_clear_console_logs` - Manage console logs\n\n**User Interface:**\n- `title` - Set conversation title\n- `multiplechoice` - Present multiple choice questions\n- `about_sketch` - Get Sketch platform information"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":4648,"cache_read_input_tokens":0,"output_tokens":320,"service_tier":"standard"}}
\ No newline at end of file
+{"id":"msg_01WmU5PvHrPn5NU59qBJUsu8","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[{"type":"text","text":"Here are the tools available to me:\n\n**File Operations:**\n- `patch` - Modify files with precise text edits (replace, append, prepend, overwrite)\n- `keyword_search` - Search for files using keywords and queries\n\n**Shell & System:**\n- `bash` - Execute shell commands with optional background execution\n- `think` - Internal note-taking and planning tool\n\n**Task Management:**\n- `todo_read` - Read current todo list\n- `todo_write` - Create and manage structured task lists\n\n**Git & Code Review:**\n- `precommit` - Create git branch and get commit message guidance\n- `codereview` - Run automated code review\n\n**Browser Automation:**\n- `browser_navigate` - Navigate to URLs\n- `browser_click` - Click elements by CSS selector\n- `browser_type` - Type text into input fields\n- `browser_wait_for` - Wait for elements to appear\n- `browser_get_text` - Read text from elements\n- `browser_eval` - Execute JavaScript\n- `browser_scroll_into_view` - Scroll elements into view\n- `browser_resize` - Resize browser window\n- `browser_take_screenshot` - Capture screenshots\n- `browser_read_image` - Read image files\n- `browser_recent_console_logs` - Get console logs\n- `browser_clear_console_logs` - Clear console logs\n\n**Meta Tools:**\n- `title` - Set conversation title\n- `done` - Complete work with verification checklist\n- `about_sketch` - Get help about Sketch functionality\n- `multiplechoice` - Present multiple choice questions to user"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":4648,"cache_read_input_tokens":0,"output_tokens":376,"service_tier":"standard"}}
\ No newline at end of file
diff --git a/termui/termui.go b/termui/termui.go
index 0146190..b14593d 100644
--- a/termui/termui.go
+++ b/termui/termui.go
@@ -50,8 +50,6 @@
🌱 git branch: sketch/{{.input.branch_name}}
{{else if eq .msg.ToolName "about_sketch" -}}
📚 About Sketch
-{{else if eq .msg.ToolName "str_replace_editor" -}}
- ✏️ {{.input.file_path -}}
{{else if eq .msg.ToolName "codereview" -}}
🐛 Running automated code review, may be slow
{{else if eq .msg.ToolName "multiplechoice" -}}