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}