multiedit.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. package tools
  2. import (
  3. "context"
  4. _ "embed"
  5. "fmt"
  6. "log/slog"
  7. "os"
  8. "path/filepath"
  9. "strings"
  10. "time"
  11. "charm.land/fantasy"
  12. "github.com/charmbracelet/crush/internal/csync"
  13. "github.com/charmbracelet/crush/internal/diff"
  14. "github.com/charmbracelet/crush/internal/filepathext"
  15. "github.com/charmbracelet/crush/internal/fsext"
  16. "github.com/charmbracelet/crush/internal/history"
  17. "github.com/charmbracelet/crush/internal/lsp"
  18. "github.com/charmbracelet/crush/internal/permission"
  19. )
  20. type MultiEditOperation struct {
  21. OldString string `json:"old_string" description:"The text to replace"`
  22. NewString string `json:"new_string" description:"The text to replace it with"`
  23. ReplaceAll bool `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)."`
  24. }
  25. type MultiEditParams struct {
  26. FilePath string `json:"file_path" description:"The absolute path to the file to modify"`
  27. Edits []MultiEditOperation `json:"edits" description:"Array of edit operations to perform sequentially on the file"`
  28. }
  29. type MultiEditPermissionsParams struct {
  30. FilePath string `json:"file_path"`
  31. OldContent string `json:"old_content,omitempty"`
  32. NewContent string `json:"new_content,omitempty"`
  33. }
  34. type FailedEdit struct {
  35. Index int `json:"index"`
  36. Error string `json:"error"`
  37. Edit MultiEditOperation `json:"edit"`
  38. }
  39. type MultiEditResponseMetadata struct {
  40. Additions int `json:"additions"`
  41. Removals int `json:"removals"`
  42. OldContent string `json:"old_content,omitempty"`
  43. NewContent string `json:"new_content,omitempty"`
  44. EditsApplied int `json:"edits_applied"`
  45. EditsFailed []FailedEdit `json:"edits_failed,omitempty"`
  46. }
  47. const MultiEditToolName = "multiedit"
  48. //go:embed multiedit.md
  49. var multieditDescription []byte
  50. func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
  51. return fantasy.NewAgentTool(
  52. MultiEditToolName,
  53. string(multieditDescription),
  54. func(ctx context.Context, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
  55. if params.FilePath == "" {
  56. return fantasy.NewTextErrorResponse("file_path is required"), nil
  57. }
  58. if len(params.Edits) == 0 {
  59. return fantasy.NewTextErrorResponse("at least one edit operation is required"), nil
  60. }
  61. params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)
  62. // Validate all edits before applying any
  63. if err := validateEdits(params.Edits); err != nil {
  64. return fantasy.NewTextErrorResponse(err.Error()), nil
  65. }
  66. var response fantasy.ToolResponse
  67. var err error
  68. editCtx := editContext{ctx, permissions, files, workingDir}
  69. // Handle file creation case (first edit has empty old_string)
  70. if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
  71. response, err = processMultiEditWithCreation(editCtx, params, call)
  72. } else {
  73. response, err = processMultiEditExistingFile(editCtx, params, call)
  74. }
  75. if err != nil {
  76. return response, err
  77. }
  78. if response.IsError {
  79. return response, nil
  80. }
  81. // Notify LSP clients about the change
  82. notifyLSPs(ctx, lspClients, params.FilePath)
  83. // Wait for LSP diagnostics and add them to the response
  84. text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
  85. text += getDiagnostics(params.FilePath, lspClients)
  86. response.Content = text
  87. return response, nil
  88. })
  89. }
  90. func validateEdits(edits []MultiEditOperation) error {
  91. for i, edit := range edits {
  92. // Only the first edit can have empty old_string (for file creation)
  93. if i > 0 && edit.OldString == "" {
  94. return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
  95. }
  96. }
  97. return nil
  98. }
  99. func processMultiEditWithCreation(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
  100. // First edit creates the file
  101. firstEdit := params.Edits[0]
  102. if firstEdit.OldString != "" {
  103. return fantasy.NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
  104. }
  105. // Check if file already exists
  106. if _, err := os.Stat(params.FilePath); err == nil {
  107. return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
  108. } else if !os.IsNotExist(err) {
  109. return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
  110. }
  111. // Create parent directories
  112. dir := filepath.Dir(params.FilePath)
  113. if err := os.MkdirAll(dir, 0o755); err != nil {
  114. return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
  115. }
  116. // Start with the content from the first edit
  117. currentContent := firstEdit.NewString
  118. // Apply remaining edits to the content, tracking failures
  119. var failedEdits []FailedEdit
  120. for i := 1; i < len(params.Edits); i++ {
  121. edit := params.Edits[i]
  122. newContent, err := applyEditToContent(currentContent, edit)
  123. if err != nil {
  124. failedEdits = append(failedEdits, FailedEdit{
  125. Index: i + 1,
  126. Error: err.Error(),
  127. Edit: edit,
  128. })
  129. continue
  130. }
  131. currentContent = newContent
  132. }
  133. // Get session and message IDs
  134. sessionID := GetSessionFromContext(edit.ctx)
  135. if sessionID == "" {
  136. return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
  137. }
  138. // Check permissions
  139. _, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
  140. p := edit.permissions.Request(permission.CreatePermissionRequest{
  141. SessionID: sessionID,
  142. Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
  143. ToolCallID: call.ID,
  144. ToolName: MultiEditToolName,
  145. Action: "write",
  146. Description: fmt.Sprintf("Create file %s with %d edits", params.FilePath, len(params.Edits)),
  147. Params: MultiEditPermissionsParams{
  148. FilePath: params.FilePath,
  149. OldContent: "",
  150. NewContent: currentContent,
  151. },
  152. })
  153. if !p {
  154. return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
  155. }
  156. // Write the file
  157. err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
  158. if err != nil {
  159. return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
  160. }
  161. // Update file history
  162. _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, "")
  163. if err != nil {
  164. return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
  165. }
  166. _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
  167. if err != nil {
  168. slog.Debug("Error creating file history version", "error", err)
  169. }
  170. recordFileWrite(params.FilePath)
  171. recordFileRead(params.FilePath)
  172. editsApplied := len(params.Edits) - len(failedEdits)
  173. var message string
  174. if len(failedEdits) > 0 {
  175. message = fmt.Sprintf("File created with %d of %d edits: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
  176. } else {
  177. message = fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)
  178. }
  179. return fantasy.WithResponseMetadata(
  180. fantasy.NewTextResponse(message),
  181. MultiEditResponseMetadata{
  182. OldContent: "",
  183. NewContent: currentContent,
  184. Additions: additions,
  185. Removals: removals,
  186. EditsApplied: editsApplied,
  187. EditsFailed: failedEdits,
  188. },
  189. ), nil
  190. }
  191. func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
  192. // Validate file exists and is readable
  193. fileInfo, err := os.Stat(params.FilePath)
  194. if err != nil {
  195. if os.IsNotExist(err) {
  196. return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
  197. }
  198. return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
  199. }
  200. if fileInfo.IsDir() {
  201. return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
  202. }
  203. // Check if file was read before editing
  204. if getLastReadTime(params.FilePath).IsZero() {
  205. return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
  206. }
  207. // Check if file was modified since last read
  208. modTime := fileInfo.ModTime()
  209. lastRead := getLastReadTime(params.FilePath)
  210. if modTime.After(lastRead) {
  211. return fantasy.NewTextErrorResponse(
  212. fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
  213. params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
  214. )), nil
  215. }
  216. // Read current file content
  217. content, err := os.ReadFile(params.FilePath)
  218. if err != nil {
  219. return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
  220. }
  221. oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
  222. currentContent := oldContent
  223. // Apply all edits sequentially, tracking failures
  224. var failedEdits []FailedEdit
  225. for i, edit := range params.Edits {
  226. newContent, err := applyEditToContent(currentContent, edit)
  227. if err != nil {
  228. failedEdits = append(failedEdits, FailedEdit{
  229. Index: i + 1,
  230. Error: err.Error(),
  231. Edit: edit,
  232. })
  233. continue
  234. }
  235. currentContent = newContent
  236. }
  237. // Check if content actually changed
  238. if oldContent == currentContent {
  239. // If we have failed edits, report them
  240. if len(failedEdits) > 0 {
  241. return fantasy.WithResponseMetadata(
  242. fantasy.NewTextErrorResponse(fmt.Sprintf("no changes made - all %d edit(s) failed", len(failedEdits))),
  243. MultiEditResponseMetadata{
  244. EditsApplied: 0,
  245. EditsFailed: failedEdits,
  246. },
  247. ), nil
  248. }
  249. return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
  250. }
  251. // Get session and message IDs
  252. sessionID := GetSessionFromContext(edit.ctx)
  253. if sessionID == "" {
  254. return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
  255. }
  256. // Generate diff and check permissions
  257. _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
  258. p := edit.permissions.Request(permission.CreatePermissionRequest{
  259. SessionID: sessionID,
  260. Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
  261. ToolCallID: call.ID,
  262. ToolName: MultiEditToolName,
  263. Action: "write",
  264. Description: fmt.Sprintf("Apply %d edits to file %s", len(params.Edits), params.FilePath),
  265. Params: MultiEditPermissionsParams{
  266. FilePath: params.FilePath,
  267. OldContent: oldContent,
  268. NewContent: currentContent,
  269. },
  270. })
  271. if !p {
  272. return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
  273. }
  274. if isCrlf {
  275. currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
  276. }
  277. // Write the updated content
  278. err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
  279. if err != nil {
  280. return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
  281. }
  282. // Update file history
  283. file, err := edit.files.GetByPathAndSession(edit.ctx, params.FilePath, sessionID)
  284. if err != nil {
  285. _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, oldContent)
  286. if err != nil {
  287. return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
  288. }
  289. }
  290. if file.Content != oldContent {
  291. // User manually changed the content, store an intermediate version
  292. _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, oldContent)
  293. if err != nil {
  294. slog.Debug("Error creating file history version", "error", err)
  295. }
  296. }
  297. // Store the new version
  298. _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
  299. if err != nil {
  300. slog.Debug("Error creating file history version", "error", err)
  301. }
  302. recordFileWrite(params.FilePath)
  303. recordFileRead(params.FilePath)
  304. editsApplied := len(params.Edits) - len(failedEdits)
  305. var message string
  306. if len(failedEdits) > 0 {
  307. message = fmt.Sprintf("Applied %d of %d edits to file: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
  308. } else {
  309. message = fmt.Sprintf("Applied %d edits to file: %s", len(params.Edits), params.FilePath)
  310. }
  311. return fantasy.WithResponseMetadata(
  312. fantasy.NewTextResponse(message),
  313. MultiEditResponseMetadata{
  314. OldContent: oldContent,
  315. NewContent: currentContent,
  316. Additions: additions,
  317. Removals: removals,
  318. EditsApplied: editsApplied,
  319. EditsFailed: failedEdits,
  320. },
  321. ), nil
  322. }
  323. func applyEditToContent(content string, edit MultiEditOperation) (string, error) {
  324. if edit.OldString == "" && edit.NewString == "" {
  325. return content, nil
  326. }
  327. if edit.OldString == "" {
  328. return "", fmt.Errorf("old_string cannot be empty for content replacement")
  329. }
  330. var newContent string
  331. var replacementCount int
  332. if edit.ReplaceAll {
  333. newContent = strings.ReplaceAll(content, edit.OldString, edit.NewString)
  334. replacementCount = strings.Count(content, edit.OldString)
  335. if replacementCount == 0 {
  336. return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
  337. }
  338. } else {
  339. index := strings.Index(content, edit.OldString)
  340. if index == -1 {
  341. return "", fmt.Errorf("old_string not found in content. Make sure it matches exactly, including whitespace and line breaks")
  342. }
  343. lastIndex := strings.LastIndex(content, edit.OldString)
  344. if index != lastIndex {
  345. return "", fmt.Errorf("old_string appears multiple times in the content. Please provide more context to ensure a unique match, or set replace_all to true")
  346. }
  347. newContent = content[:index] + edit.NewString + content[index+len(edit.OldString):]
  348. replacementCount = 1
  349. }
  350. return newContent, nil
  351. }