multiedit.go 14 KB

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