diff --git a/pkg/agents/toolbox/file_replace_lines.go b/pkg/agents/toolbox/file_replace_lines.go deleted file mode 100644 index 0319beb..0000000 --- a/pkg/agents/toolbox/file_replace_lines.go +++ /dev/null @@ -1,133 +0,0 @@ -package toolbox - -import ( - "fmt" - "os" - "strings" - - toolutil "git.mlow.ca/mlow/lmcli/pkg/agents/toolbox/util" - "git.mlow.ca/mlow/lmcli/pkg/api" -) - -const FILE_REPLACE_LINES_DESCRIPTION = `Replace or remove a range of lines within a file, must specify path. - -Useful for re-writing snippets/blocks of code or entire functions. - -Plan your edits carefully and ensure any new content matches the flow and indentation of surrounding text.` - -var FileReplaceLinesTool = api.ToolSpec{ - Name: "file_replace_lines", - Description: FILE_REPLACE_LINES_DESCRIPTION, - Parameters: []api.ToolParameter{ - { - Name: "path", - Type: "string", - Description: "Path of the file to be modified, relative to the current working directory.", - Required: true, - }, - { - Name: "start_line", - Type: "integer", - Description: `Line number which specifies the start of the replacement range (inclusive).`, - Required: true, - }, - { - Name: "end_line", - Type: "integer", - Description: `Line number which specifies the end of the replacement range (inclusive). If unset, range extends to end of file.`, - }, - { - Name: "content", - Type: "string", - Description: `Content to replace specified range. Omit to remove the specified range.`, - }, - }, - Impl: func(tool *api.ToolSpec, args map[string]interface{}) (string, error) { - tmp, ok := args["path"] - if !ok { - return "", fmt.Errorf("path parameter to write_file was not included.") - } - path, ok := tmp.(string) - if !ok { - return "", fmt.Errorf("Invalid path in function arguments: %v", tmp) - } - var start_line int - tmp, ok = args["start_line"] - if ok { - tmp, ok := tmp.(float64) - if !ok { - return "", fmt.Errorf("Invalid start_line in function arguments: %v", tmp) - } - start_line = int(tmp) - } - var end_line int - tmp, ok = args["end_line"] - if ok { - tmp, ok := tmp.(float64) - if !ok { - return "", fmt.Errorf("Invalid end_line in function arguments: %v", tmp) - } - end_line = int(tmp) - } - var content string - tmp, ok = args["content"] - if ok { - content, ok = tmp.(string) - if !ok { - return "", fmt.Errorf("Invalid content in function arguments: %v", tmp) - } - } - - result := fileReplaceLines(path, start_line, end_line, content) - ret, err := result.ToJson() - if err != nil { - return "", fmt.Errorf("Could not serialize result: %v", err) - } - return ret, nil - }, -} - -func fileReplaceLines(path string, startLine int, endLine int, content string) api.CallResult { - ok, reason := toolutil.IsPathWithinCWD(path) - if !ok { - return api.CallResult{Message: reason} - } - - // Read the existing file's content - data, err := os.ReadFile(path) - if err != nil { - if !os.IsNotExist(err) { - return api.CallResult{Message: fmt.Sprintf("Could not read path: %s", err.Error())} - } - _, err = os.Create(path) - if err != nil { - return api.CallResult{Message: fmt.Sprintf("Could not create new file: %s", err.Error())} - } - data = []byte{} - } - - if startLine < 1 { - return api.CallResult{Message: "start_line cannot be less than 1"} - } - - lines := strings.Split(string(data), "\n") - contentLines := strings.Split(strings.Trim(content, "\n"), "\n") - - if endLine == 0 || endLine > len(lines) { - endLine = len(lines) - } - - before := lines[:startLine-1] - after := lines[endLine:] - - lines = append(before, append(contentLines, after...)...) - newContent := strings.Join(lines, "\n") - - // Join the lines and write back to the file - err = os.WriteFile(path, []byte(newContent), 0644) - if err != nil { - return api.CallResult{Message: fmt.Sprintf("Could not write to path: %s", err.Error())} - } - - return api.CallResult{Result: newContent} -} diff --git a/pkg/agents/toolbox/modify_file.go b/pkg/agents/toolbox/modify_file.go new file mode 100644 index 0000000..53657c0 --- /dev/null +++ b/pkg/agents/toolbox/modify_file.go @@ -0,0 +1,178 @@ +package toolbox + +import ( + "fmt" + "os" + "strings" + + "git.mlow.ca/mlow/lmcli/pkg/agents/toolbox/util" + toolutil "git.mlow.ca/mlow/lmcli/pkg/agents/toolbox/util" + "git.mlow.ca/mlow/lmcli/pkg/api" +) + +var MODIFY_FILE_DESCRIPTION = []string{ + "Modify a file. If the file does not exist, it will be created.", + "", + "Content can be either inserted, replaced, or removed through a combination of the start, stop, and content parameters.", + "Use the start and stop line numbers to limit the range of modification to the file.", + "If both `start` and `stop` are left unset (or set to 0), the entire file's contents will be updated.", + "If `start` is set to n and `stop` to n+1, content will be inserted at line n (the content that was at line n will be shifted below the newly inserted content).", + "If only `start` is set, content from the given line and onwards will be updated.", + "If only `stop` is set, content up to but not including the given line will be updated.", + "", + "Examples:", + "1. Append to a file:", + " {\"path\": \"example.txt\", \"start\": , \"content\": \"New content to append\"}", + "", + "2. Insert at a specific line:", + " {\"path\": \"example.txt\", \"start\": 5, \"stop\": 5, \"content\": \"New line inserted above the previous line 5\"}", + "", + "3. Replace a range of lines:", + " {\"path\": \"example.txt\", \"start\": 3, \"stop\": 7, \"content\": \"New content replacing lines 3-7\"}", + "", + "4. Remove a range of lines:", + " {\"path\": \"example.txt\", \"start\": 2, \"stop\": 5}", + "", + "5. Replace entire file contents:", + " {\"path\": \"example.txt\", \"content\": \"New file contents\"}", + "", + "6. Update from a specific line to the end of the file:", + " {\"path\": \"example.txt\", \"start\": 10, \"content\": \"New content from line 10 onwards\"}", + "", + "7. Update from the beginning of the file to a specific line:", + " {\"path\": \"example.txt\", \"stop\": 6, \"content\": \"New content for first 5 lines\"}", + "", + "Note: Always use specific line numbers based on the current file content. Avoid using arbitrarily large numbers for start or stop.", +} + +var ModifyFile = api.ToolSpec{ + Name: "modify_file", + Description: strings.Join(MODIFY_FILE_DESCRIPTION, "\n"), + Parameters: []api.ToolParameter{ + { + Name: "path", + Type: "string", + Description: "Path of the file to be modified, relative to the current working directory.", + Required: true, + }, + { + Name: "start", + Type: "integer", + Description: `Start line of the range to modify (inclusive). If omitted, the beginning of the file is implied.`, + }, + { + Name: "stop", + Type: "integer", + Description: `End line of the range to modify (inclusive). If omitted, the end of the file is implied.`, + }, + { + Name: "content", + Type: "string", + Description: "Content to insert/replace at the range defined by `start` and `stop`. If omitted, the range is removed.", + }, + }, + Impl: func(tool *api.ToolSpec, args map[string]interface{}) (string, error) { + tmp, ok := args["path"] + if !ok { + return "", fmt.Errorf("path parameter to modify_file was not included.") + } + path, ok := tmp.(string) + if !ok { + return "", fmt.Errorf("Invalid path in function arguments: %v", tmp) + } + var start int + tmp, ok = args["start"] + if ok { + tmp, ok := tmp.(float64) + if !ok { + return "", fmt.Errorf("Invalid start in function arguments: %v", tmp) + } + start = int(tmp) + } + var stop int + tmp, ok = args["stop"] + if ok { + tmp, ok := tmp.(float64) + if !ok { + return "", fmt.Errorf("Invalid stop in function arguments: %v", tmp) + } + stop = int(tmp) + } + var content string + tmp, ok = args["content"] + if ok { + content, ok = tmp.(string) + if !ok { + return "", fmt.Errorf("Invalid content in function arguments: %v", tmp) + } + } + + result := fileModifyContents(path, start, stop, content) + ret, err := result.ToJson() + if err != nil { + return "", fmt.Errorf("Could not serialize result: %v", err) + } + return ret, nil + }, +} + + +func fileModifyContents(path string, startLine int, stopLine int, content string) api.CallResult { + ok, reason := toolutil.IsPathWithinCWD(path) + if !ok { + return api.CallResult{Message: reason} + } + + // Read the existing file's content + data, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + return api.CallResult{Message: fmt.Sprintf("Could not read path: %s", err.Error())} + } + _, err = os.Create(path) + if err != nil { + return api.CallResult{Message: fmt.Sprintf("Could not create new file: %s", err.Error())} + } + data = []byte{} + } + + lines := strings.Split(string(data), "\n") + contentLines := strings.Split(strings.TrimSuffix(content, "\n"), "\n") + + // If both start and stop are unset, update the entire file + if startLine == 0 && stopLine == 0 { + lines = contentLines + } else { + if startLine < 1 { + startLine = 1 + } + if stopLine == 0 || stopLine > len(lines) { + stopLine = len(lines) + } + + before := lines[:startLine-1] + after := lines[stopLine:] + + // Handle insertion case + if startLine == stopLine { + lines = append(before, append(contentLines, lines[startLine-1:]...)...) + } else { + // If content is omitted, remove the specified range + if content == "" { + lines = append(before, after...) + } else { + lines = append(before, append(contentLines, after...)...) + } + } + } + + newContent := strings.Join(lines, "\n") + + // Write back to the file + err = os.WriteFile(path, []byte(newContent), 0644) + if err != nil { + return api.CallResult{Message: fmt.Sprintf("Could not write to path: %s", err.Error())} + } + + return api.CallResult{Result: util.AddLineNumbers(newContent)} +} diff --git a/pkg/agents/toolbox/read_file.go b/pkg/agents/toolbox/read_file.go index 7427d0c..f41c8c2 100644 --- a/pkg/agents/toolbox/read_file.go +++ b/pkg/agents/toolbox/read_file.go @@ -3,7 +3,6 @@ package toolbox import ( "fmt" "os" - "strings" toolutil "git.mlow.ca/mlow/lmcli/pkg/agents/toolbox/util" "git.mlow.ca/mlow/lmcli/pkg/api" @@ -60,14 +59,7 @@ func readFile(path string) api.CallResult { if err != nil { return api.CallResult{Message: fmt.Sprintf("Could not read path: %s", err.Error())} } - - lines := strings.Split(string(data), "\n") - content := strings.Builder{} - for i, line := range lines { - content.WriteString(fmt.Sprintf("%d\t%s\n", i+1, line)) - } - return api.CallResult{ - Result: content.String(), + Result: toolutil.AddLineNumbers(string(data)), } } diff --git a/pkg/agents/toolbox/util/util.go b/pkg/agents/toolbox/util/util.go index eb8a8b7..94ef811 100644 --- a/pkg/agents/toolbox/util/util.go +++ b/pkg/agents/toolbox/util/util.go @@ -65,3 +65,14 @@ func IsPathWithinCWD(path string) (bool, string) { } return true, "" } + +// AddLineNumbers takes a string of content and returns a new string with line +// numbers prefixed +func AddLineNumbers(content string) string { + lines := strings.Split(strings.TrimSuffix(content, "\n"), "\n") + result := strings.Builder{} + for i, line := range lines { + result.WriteString(fmt.Sprintf("%d\t%s\n", i+1, line)) + } + return result.String() +} diff --git a/pkg/agents/tools.go b/pkg/agents/tools.go index aba83ed..5fada49 100644 --- a/pkg/agents/tools.go +++ b/pkg/agents/tools.go @@ -8,12 +8,11 @@ import ( ) var AvailableTools map[string]api.ToolSpec = map[string]api.ToolSpec{ - "dir_tree": toolbox.DirTreeTool, - "read_dir": toolbox.ReadDirTool, - "read_file": toolbox.ReadFileTool, - "write_file": toolbox.WriteFileTool, - "file_insert_lines": toolbox.FileInsertLinesTool, - "file_replace_lines": toolbox.FileReplaceLinesTool, + "dir_tree": toolbox.DirTreeTool, + "read_dir": toolbox.ReadDirTool, + "read_file": toolbox.ReadFileTool, + "modify_file": toolbox.ModifyFile, + "write_file": toolbox.WriteFileTool, } func ExecuteToolCalls(calls []api.ToolCall, available []api.ToolSpec) ([]api.ToolResult, error) {