edit.go

  1package tools
  2
  3import (
  4	"context"
  5	_ "embed"
  6	"fmt"
  7	"log/slog"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11	"time"
 12
 13	"charm.land/fantasy"
 14	"git.secluded.site/crush/internal/diff"
 15	"git.secluded.site/crush/internal/filepathext"
 16	"git.secluded.site/crush/internal/filetracker"
 17	"git.secluded.site/crush/internal/fsext"
 18	"git.secluded.site/crush/internal/history"
 19
 20	"git.secluded.site/crush/internal/lsp"
 21	"git.secluded.site/crush/internal/permission"
 22)
 23
 24type EditParams struct {
 25	FilePath   string `json:"file_path" description:"The absolute path to the file to modify"`
 26	OldString  string `json:"old_string" description:"The text to replace"`
 27	NewString  string `json:"new_string" description:"The text to replace it with"`
 28	ReplaceAll bool   `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)"`
 29}
 30
 31type EditPermissionsParams struct {
 32	FilePath   string `json:"file_path"`
 33	OldContent string `json:"old_content,omitempty"`
 34	NewContent string `json:"new_content,omitempty"`
 35}
 36
 37type EditResponseMetadata struct {
 38	Additions  int    `json:"additions"`
 39	Removals   int    `json:"removals"`
 40	OldContent string `json:"old_content,omitempty"`
 41	NewContent string `json:"new_content,omitempty"`
 42}
 43
 44const EditToolName = "edit"
 45
 46var (
 47	oldStringNotFoundErr        = fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks.")
 48	oldStringMultipleMatchesErr = fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true")
 49)
 50
 51//go:embed edit.md
 52var editDescription string
 53
 54type editContext struct {
 55	ctx         context.Context
 56	permissions permission.Service
 57	files       history.Service
 58	filetracker filetracker.Service
 59	workingDir  string
 60}
 61
 62func NewEditTool(
 63	lspManager *lsp.Manager,
 64	permissions permission.Service,
 65	files history.Service,
 66	filetracker filetracker.Service,
 67	workingDir string,
 68) fantasy.AgentTool {
 69	return fantasy.NewAgentTool(
 70		EditToolName,
 71		editDescription,
 72		func(ctx context.Context, params EditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 73			if params.FilePath == "" {
 74				return fantasy.NewTextErrorResponse("file_path is required"), nil
 75			}
 76
 77			params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)
 78
 79			var response fantasy.ToolResponse
 80			var err error
 81
 82			editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
 83
 84			if params.OldString == "" {
 85				response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
 86			} else if params.NewString == "" {
 87				response, err = deleteContent(editCtx, params.FilePath, params.OldString, params.ReplaceAll, call)
 88			} else {
 89				response, err = replaceContent(editCtx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
 90			}
 91
 92			if err != nil {
 93				return response, err
 94			}
 95			if response.IsError {
 96				// Return early if there was an error during content replacement
 97				// This prevents unnecessary LSP diagnostics processing
 98				return response, nil
 99			}
100
101			notifyLSPs(ctx, lspManager, params.FilePath)
102
103			text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
104			text += getDiagnostics(params.FilePath, lspManager)
105			response.Content = text
106			return response, nil
107		},
108	)
109}
110
111func createNewFile(edit editContext, filePath, content string, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
112	fileInfo, err := os.Stat(filePath)
113	if err == nil {
114		if fileInfo.IsDir() {
115			return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
116		}
117		return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
118	} else if !os.IsNotExist(err) {
119		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
120	}
121
122	dir := filepath.Dir(filePath)
123	if err = os.MkdirAll(dir, 0o755); err != nil {
124		return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
125	}
126
127	sessionID := GetSessionFromContext(edit.ctx)
128	if sessionID == "" {
129		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
130	}
131
132	_, additions, removals := diff.GenerateDiff(
133		"",
134		content,
135		strings.TrimPrefix(filePath, edit.workingDir),
136	)
137	p, err := edit.permissions.Request(
138		edit.ctx,
139		permission.CreatePermissionRequest{
140			SessionID:   sessionID,
141			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
142			ToolCallID:  call.ID,
143			ToolName:    EditToolName,
144			Action:      "write",
145			Description: fmt.Sprintf("Create file %s", filePath),
146			Params: EditPermissionsParams{
147				FilePath:   filePath,
148				OldContent: "",
149				NewContent: content,
150			},
151		},
152	)
153	if err != nil {
154		return fantasy.ToolResponse{}, err
155	}
156	if !p {
157		return NewPermissionDeniedResponse(), nil
158	}
159
160	err = os.WriteFile(filePath, []byte(content), 0o644)
161	if err != nil {
162		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
163	}
164
165	// File can't be in the history so we create a new file history
166	_, err = edit.files.Create(edit.ctx, sessionID, filePath, "")
167	if err != nil {
168		// Log error but don't fail the operation
169		return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
170	}
171
172	// Add the new content to the file history
173	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, content)
174	if err != nil {
175		// Log error but don't fail the operation
176		slog.Error("Error creating file history version", "error", err)
177	}
178
179	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
180
181	return fantasy.WithResponseMetadata(
182		fantasy.NewTextResponse("File created: "+filePath),
183		EditResponseMetadata{
184			OldContent: "",
185			NewContent: content,
186			Additions:  additions,
187			Removals:   removals,
188		},
189	), nil
190}
191
192func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
193	fileInfo, err := os.Stat(filePath)
194	if err != nil {
195		if os.IsNotExist(err) {
196			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
197		}
198		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
199	}
200
201	if fileInfo.IsDir() {
202		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
203	}
204
205	sessionID := GetSessionFromContext(edit.ctx)
206	if sessionID == "" {
207		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content")
208	}
209
210	lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
211	if lastRead.IsZero() {
212		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
213	}
214
215	modTime := fileInfo.ModTime().Truncate(time.Second)
216	if modTime.After(lastRead) {
217		return fantasy.NewTextErrorResponse(
218			fmt.Sprintf(
219				"file %s has been modified since it was last read (mod time: %s, last read: %s)",
220				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
221			),
222		), nil
223	}
224
225	content, err := os.ReadFile(filePath)
226	if err != nil {
227		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
228	}
229
230	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
231
232	var newContent string
233
234	if replaceAll {
235		newContent = strings.ReplaceAll(oldContent, oldString, "")
236		if newContent == oldContent {
237			return oldStringNotFoundErr, nil
238		}
239	} else {
240		index := strings.Index(oldContent, oldString)
241		if index == -1 {
242			return oldStringNotFoundErr, nil
243		}
244
245		lastIndex := strings.LastIndex(oldContent, oldString)
246		if index != lastIndex {
247			return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
248		}
249
250		newContent = oldContent[:index] + oldContent[index+len(oldString):]
251	}
252
253	_, additions, removals := diff.GenerateDiff(
254		oldContent,
255		newContent,
256		strings.TrimPrefix(filePath, edit.workingDir),
257	)
258
259	p, err := edit.permissions.Request(
260		edit.ctx,
261		permission.CreatePermissionRequest{
262			SessionID:   sessionID,
263			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
264			ToolCallID:  call.ID,
265			ToolName:    EditToolName,
266			Action:      "write",
267			Description: fmt.Sprintf("Delete content from file %s", filePath),
268			Params: EditPermissionsParams{
269				FilePath:   filePath,
270				OldContent: oldContent,
271				NewContent: newContent,
272			},
273		},
274	)
275	if err != nil {
276		return fantasy.ToolResponse{}, err
277	}
278	if !p {
279		return NewPermissionDeniedResponse(), nil
280	}
281
282	if isCrlf {
283		newContent, _ = fsext.ToWindowsLineEndings(newContent)
284	}
285
286	err = os.WriteFile(filePath, []byte(newContent), 0o644)
287	if err != nil {
288		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
289	}
290
291	// Check if file exists in history
292	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
293	if err != nil {
294		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
295		if err != nil {
296			// Log error but don't fail the operation
297			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
298		}
299	}
300	if file.Content != oldContent {
301		// User manually changed the content; store an intermediate version
302		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
303		if err != nil {
304			slog.Error("Error creating file history version", "error", err)
305		}
306	}
307	// Store the new version
308	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
309	if err != nil {
310		slog.Error("Error creating file history version", "error", err)
311	}
312
313	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
314
315	return fantasy.WithResponseMetadata(
316		fantasy.NewTextResponse("Content deleted from file: "+filePath),
317		EditResponseMetadata{
318			OldContent: oldContent,
319			NewContent: newContent,
320			Additions:  additions,
321			Removals:   removals,
322		},
323	), nil
324}
325
326func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
327	fileInfo, err := os.Stat(filePath)
328	if err != nil {
329		if os.IsNotExist(err) {
330			return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
331		}
332		return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
333	}
334
335	if fileInfo.IsDir() {
336		return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
337	}
338
339	sessionID := GetSessionFromContext(edit.ctx)
340	if sessionID == "" {
341		return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for edit a file")
342	}
343
344	lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath)
345	if lastRead.IsZero() {
346		return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
347	}
348
349	modTime := fileInfo.ModTime().Truncate(time.Second)
350	if modTime.After(lastRead) {
351		return fantasy.NewTextErrorResponse(
352			fmt.Sprintf(
353				"file %s has been modified since it was last read (mod time: %s, last read: %s)",
354				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
355			),
356		), nil
357	}
358
359	content, err := os.ReadFile(filePath)
360	if err != nil {
361		return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
362	}
363
364	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
365
366	var newContent string
367
368	if replaceAll {
369		newContent = strings.ReplaceAll(oldContent, oldString, newString)
370	} else {
371		index := strings.Index(oldContent, oldString)
372		if index == -1 {
373			return oldStringNotFoundErr, nil
374		}
375
376		lastIndex := strings.LastIndex(oldContent, oldString)
377		if index != lastIndex {
378			return oldStringMultipleMatchesErr, nil
379		}
380
381		newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
382	}
383
384	if oldContent == newContent {
385		return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
386	}
387	_, additions, removals := diff.GenerateDiff(
388		oldContent,
389		newContent,
390		strings.TrimPrefix(filePath, edit.workingDir),
391	)
392
393	p, err := edit.permissions.Request(
394		edit.ctx,
395		permission.CreatePermissionRequest{
396			SessionID:   sessionID,
397			Path:        fsext.PathOrPrefix(filePath, edit.workingDir),
398			ToolCallID:  call.ID,
399			ToolName:    EditToolName,
400			Action:      "write",
401			Description: fmt.Sprintf("Replace content in file %s", filePath),
402			Params: EditPermissionsParams{
403				FilePath:   filePath,
404				OldContent: oldContent,
405				NewContent: newContent,
406			},
407		},
408	)
409	if err != nil {
410		return fantasy.ToolResponse{}, err
411	}
412	if !p {
413		return NewPermissionDeniedResponse(), nil
414	}
415
416	if isCrlf {
417		newContent, _ = fsext.ToWindowsLineEndings(newContent)
418	}
419
420	err = os.WriteFile(filePath, []byte(newContent), 0o644)
421	if err != nil {
422		return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
423	}
424
425	// Check if file exists in history
426	file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
427	if err != nil {
428		_, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
429		if err != nil {
430			// Log error but don't fail the operation
431			return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
432		}
433	}
434	if file.Content != oldContent {
435		// User manually changed the content; store an intermediate version
436		_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
437		if err != nil {
438			slog.Debug("Error creating file history version", "error", err)
439		}
440	}
441	// Store the new version
442	_, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
443	if err != nil {
444		slog.Error("Error creating file history version", "error", err)
445	}
446
447	edit.filetracker.RecordRead(edit.ctx, sessionID, filePath)
448
449	return fantasy.WithResponseMetadata(
450		fantasy.NewTextResponse("Content replaced in file: "+filePath),
451		EditResponseMetadata{
452			OldContent: oldContent,
453			NewContent: newContent,
454			Additions:  additions,
455			Removals:   removals,
456		},
457	), nil
458}