summaryrefslogtreecommitdiffhomepage
path: root/internal/llm/tools/patch.go
diff options
context:
space:
mode:
authorKujtim Hoxha <[email protected]>2025-04-17 00:00:19 +0200
committerKujtim Hoxha <[email protected]>2025-04-21 13:42:02 +0200
commit36172979b45facc8ccec6861f124193eaebc42e9 (patch)
tree41f7683c53bd1aaa550a7ec22e027e98c29e5d4e /internal/llm/tools/patch.go
parentcc07f7a186995f428436bc1adc66a264a95171a4 (diff)
downloadopencode-36172979b45facc8ccec6861f124193eaebc42e9.tar.gz
opencode-36172979b45facc8ccec6861f124193eaebc42e9.zip
Update agent prompt, improve TUI patch UI, remove obsolete tool tests
- Replace and expand agent coder prompt for clarity and safety - Add patch tool and TUI dialog support for patch diffs - Sort sidebar modified files by name - Remove Bash/Edit/Sourcegraph/Write tool tests 🤖 Generated with opencode Co-Authored-By: opencode <[email protected]>
Diffstat (limited to 'internal/llm/tools/patch.go')
-rw-r--r--internal/llm/tools/patch.go300
1 files changed, 300 insertions, 0 deletions
diff --git a/internal/llm/tools/patch.go b/internal/llm/tools/patch.go
new file mode 100644
index 000000000..12060d72a
--- /dev/null
+++ b/internal/llm/tools/patch.go
@@ -0,0 +1,300 @@
+package tools
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/kujtimiihoxha/opencode/internal/config"
+ "github.com/kujtimiihoxha/opencode/internal/diff"
+ "github.com/kujtimiihoxha/opencode/internal/history"
+ "github.com/kujtimiihoxha/opencode/internal/lsp"
+ "github.com/kujtimiihoxha/opencode/internal/permission"
+)
+
+type PatchParams struct {
+ FilePath string `json:"file_path"`
+ Patch string `json:"patch"`
+}
+
+type PatchPermissionsParams struct {
+ FilePath string `json:"file_path"`
+ Diff string `json:"diff"`
+}
+
+type PatchResponseMetadata struct {
+ Diff string `json:"diff"`
+ Additions int `json:"additions"`
+ Removals int `json:"removals"`
+}
+
+type patchTool struct {
+ lspClients map[string]*lsp.Client
+ permissions permission.Service
+ files history.Service
+}
+
+const (
+ // TODO: test if this works as expected
+ PatchToolName = "patch"
+ patchDescription = `Applies a patch to a file. This tool is similar to the edit tool but accepts a unified diff patch instead of old/new strings.
+
+Before using this tool:
+
+1. Use the FileRead tool to understand the file's contents and context
+
+2. Verify the directory path is correct:
+ - Use the LS tool to verify the parent directory exists and is the correct location
+
+To apply a patch, provide the following:
+1. file_path: The absolute path to the file to modify (must be absolute, not relative)
+2. patch: A unified diff patch to apply to the file
+
+The tool will apply the patch to the specified file. The patch must be in unified diff format.
+
+CRITICAL REQUIREMENTS FOR USING THIS TOOL:
+
+1. PATCH FORMAT: The patch must be in unified diff format, which includes:
+ - File headers (--- a/file_path, +++ b/file_path)
+ - Hunk headers (@@ -start,count +start,count @@)
+ - Added lines (prefixed with +)
+ - Removed lines (prefixed with -)
+
+2. CONTEXT: The patch must include sufficient context around the changes to ensure it applies correctly.
+
+3. VERIFICATION: Before using this tool:
+ - Ensure the patch applies cleanly to the current state of the file
+ - Check that the file exists and you have read it first
+
+WARNING: If you do not follow these requirements:
+ - The tool will fail if the patch doesn't apply cleanly
+ - You may change the wrong parts of the file if the context is insufficient
+
+When applying patches:
+ - Ensure the patch results in idiomatic, correct code
+ - Do not leave the code in a broken state
+ - Always use absolute file paths (starting with /)
+
+Remember: patches are a powerful way to make multiple related changes at once, but they require careful preparation.`
+)
+
+func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
+ return &patchTool{
+ lspClients: lspClients,
+ permissions: permissions,
+ files: files,
+ }
+}
+
+func (p *patchTool) Info() ToolInfo {
+ return ToolInfo{
+ Name: PatchToolName,
+ Description: patchDescription,
+ Parameters: map[string]any{
+ "file_path": map[string]any{
+ "type": "string",
+ "description": "The absolute path to the file to modify",
+ },
+ "patch": map[string]any{
+ "type": "string",
+ "description": "The unified diff patch to apply",
+ },
+ },
+ Required: []string{"file_path", "patch"},
+ }
+}
+
+func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
+ var params PatchParams
+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
+ return NewTextErrorResponse("invalid parameters"), nil
+ }
+
+ if params.FilePath == "" {
+ return NewTextErrorResponse("file_path is required"), nil
+ }
+
+ if params.Patch == "" {
+ return NewTextErrorResponse("patch is required"), nil
+ }
+
+ if !filepath.IsAbs(params.FilePath) {
+ wd := config.WorkingDirectory()
+ params.FilePath = filepath.Join(wd, params.FilePath)
+ }
+
+ // Check if file exists
+ fileInfo, err := os.Stat(params.FilePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
+ }
+ return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
+ }
+
+ if fileInfo.IsDir() {
+ return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
+ }
+
+ if getLastReadTime(params.FilePath).IsZero() {
+ return NewTextErrorResponse("you must read the file before patching it. Use the View tool first"), nil
+ }
+
+ modTime := fileInfo.ModTime()
+ lastRead := getLastReadTime(params.FilePath)
+ if modTime.After(lastRead) {
+ return NewTextErrorResponse(
+ fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
+ params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
+ )), nil
+ }
+
+ // Read the current file content
+ content, err := os.ReadFile(params.FilePath)
+ if err != nil {
+ return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
+ }
+
+ oldContent := string(content)
+
+ // Parse and apply the patch
+ diffResult, err := diff.ParseUnifiedDiff(params.Patch)
+ if err != nil {
+ return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %v", err)), nil
+ }
+
+ // Apply the patch to get the new content
+ newContent, err := applyPatch(oldContent, diffResult)
+ if err != nil {
+ return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %v", err)), nil
+ }
+
+ if oldContent == newContent {
+ return NewTextErrorResponse("patch did not result in any changes to the file"), nil
+ }
+
+ sessionID, messageID := GetContextValues(ctx)
+ if sessionID == "" || messageID == "" {
+ return ToolResponse{}, fmt.Errorf("session ID and message ID are required for patching a file")
+ }
+
+ // Generate a diff for permission request and metadata
+ diffText, additions, removals := diff.GenerateDiff(
+ oldContent,
+ newContent,
+ params.FilePath,
+ )
+
+ // Request permission to apply the patch
+ p.permissions.Request(
+ permission.CreatePermissionRequest{
+ Path: filepath.Dir(params.FilePath),
+ ToolName: PatchToolName,
+ Action: "patch",
+ Description: fmt.Sprintf("Apply patch to file %s", params.FilePath),
+ Params: PatchPermissionsParams{
+ FilePath: params.FilePath,
+ Diff: diffText,
+ },
+ },
+ )
+
+ // Write the new content to the file
+ err = os.WriteFile(params.FilePath, []byte(newContent), 0o644)
+ if err != nil {
+ return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
+ }
+
+ // Update file history
+ file, err := p.files.GetByPathAndSession(ctx, params.FilePath, sessionID)
+ if err != nil {
+ _, err = p.files.Create(ctx, sessionID, params.FilePath, oldContent)
+ if err != nil {
+ return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+ }
+ }
+ if file.Content != oldContent {
+ // User manually changed the content, store an intermediate version
+ _, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, oldContent)
+ if err != nil {
+ fmt.Printf("Error creating file history version: %v\n", err)
+ }
+ }
+ // Store the new version
+ _, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, newContent)
+ if err != nil {
+ fmt.Printf("Error creating file history version: %v\n", err)
+ }
+
+ recordFileWrite(params.FilePath)
+ recordFileRead(params.FilePath)
+
+ // Wait for LSP diagnostics and include them in the response
+ waitForLspDiagnostics(ctx, params.FilePath, p.lspClients)
+ text := fmt.Sprintf("<r>\nPatch applied to file: %s\n</r>\n", params.FilePath)
+ text += getDiagnostics(params.FilePath, p.lspClients)
+
+ return WithResponseMetadata(
+ NewTextResponse(text),
+ PatchResponseMetadata{
+ Diff: diffText,
+ Additions: additions,
+ Removals: removals,
+ }), nil
+}
+
+// applyPatch applies a parsed diff to a string and returns the resulting content
+func applyPatch(content string, diffResult diff.DiffResult) (string, error) {
+ lines := strings.Split(content, "\n")
+
+ // Process each hunk in the diff
+ for _, hunk := range diffResult.Hunks {
+ // Parse the hunk header to get line numbers
+ var oldStart, oldCount, newStart, newCount int
+ _, err := fmt.Sscanf(hunk.Header, "@@ -%d,%d +%d,%d @@", &oldStart, &oldCount, &newStart, &newCount)
+ if err != nil {
+ // Try alternative format with single line counts
+ _, err = fmt.Sscanf(hunk.Header, "@@ -%d +%d @@", &oldStart, &newStart)
+ if err != nil {
+ return "", fmt.Errorf("invalid hunk header format: %s", hunk.Header)
+ }
+ oldCount = 1
+ newCount = 1
+ }
+
+ // Adjust for 0-based array indexing
+ oldStart--
+ newStart--
+
+ // Apply the changes
+ newLines := make([]string, 0)
+ newLines = append(newLines, lines[:oldStart]...)
+
+ // Process the hunk lines in order
+ currentOldLine := oldStart
+ for _, line := range hunk.Lines {
+ switch line.Kind {
+ case diff.LineContext:
+ newLines = append(newLines, line.Content)
+ currentOldLine++
+ case diff.LineRemoved:
+ // Skip this line in the output (it's being removed)
+ currentOldLine++
+ case diff.LineAdded:
+ // Add the new line
+ newLines = append(newLines, line.Content)
+ }
+ }
+
+ // Append the rest of the file
+ newLines = append(newLines, lines[currentOldLine:]...)
+ lines = newLines
+ }
+
+ return strings.Join(lines, "\n"), nil
+}
+