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/diff"
  13. "github.com/charmbracelet/crush/internal/filepathext"
  14. "github.com/charmbracelet/crush/internal/filetracker"
  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(
  51. lspManager *lsp.Manager,
  52. permissions permission.Service,
  53. files history.Service,
  54. filetracker filetracker.Service,
  55. workingDir string,
  56. ) fantasy.AgentTool {
  57. return fantasy.NewAgentTool(
  58. MultiEditToolName,
  59. string(multieditDescription),
  60. func(ctx context.Context, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
  61. if params.FilePath == "" {
  62. return fantasy.NewTextErrorResponse("file_path is required"), nil
  63. }
  64. if len(params.Edits) == 0 {
  65. return fantasy.NewTextErrorResponse("at least one edit operation is required"), nil
  66. }
  67. params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)
  68. // Validate all edits before applying any
  69. if err := validateEdits(params.Edits); err != nil {
  70. return fantasy.NewTextErrorResponse(err.Error()), nil
  71. }
  72. var response fantasy.ToolResponse
  73. var err error
  74. editCtx := editContext{ctx, permissions, files, filetracker, workingDir}
  75. // Handle file creation case (first edit has empty old_string)
  76. if len(params.Edits) > 0 && params.Edits[0].OldString == "" {
  77. response, err = processMultiEditWithCreation(editCtx, params, call)
  78. } else {
  79. response, err = processMultiEditExistingFile(editCtx, params, call)
  80. }
  81. if err != nil {
  82. return response, err
  83. }
  84. if response.IsError {
  85. return response, nil
  86. }
  87. // Notify LSP clients about the change
  88. notifyLSPs(ctx, lspManager, params.FilePath)
  89. // Wait for LSP diagnostics and add them to the response
  90. text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
  91. text += getDiagnostics(params.FilePath, lspManager)
  92. response.Content = text
  93. return response, nil
  94. })
  95. }
  96. func validateEdits(edits []MultiEditOperation) error {
  97. for i, edit := range edits {
  98. // Only the first edit can have empty old_string (for file creation)
  99. if i > 0 && edit.OldString == "" {
  100. return fmt.Errorf("edit %d: only the first edit can have empty old_string (for file creation)", i+1)
  101. }
  102. }
  103. return nil
  104. }
  105. func processMultiEditWithCreation(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
  106. // First edit creates the file
  107. firstEdit := params.Edits[0]
  108. if firstEdit.OldString != "" {
  109. return fantasy.NewTextErrorResponse("first edit must have empty old_string for file creation"), nil
  110. }
  111. // Check if file already exists
  112. if _, err := os.Stat(params.FilePath); err == nil {
  113. return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", params.FilePath)), nil
  114. } else if !os.IsNotExist(err) {
  115. return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
  116. }
  117. // Create parent directories
  118. dir := filepath.Dir(params.FilePath)
  119. if err := os.MkdirAll(dir, 0o755); err != nil {
  120. return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
  121. }
  122. // Start with the content from the first edit
  123. currentContent := firstEdit.NewString
  124. // Apply remaining edits to the content, tracking failures
  125. var failedEdits []FailedEdit
  126. for i := 1; i < len(params.Edits); i++ {
  127. edit := params.Edits[i]
  128. newContent, err := applyEditToContent(currentContent, edit)
  129. if err != nil {
  130. failedEdits = append(failedEdits, FailedEdit{
  131. Index: i + 1,
  132. Error: err.Error(),
  133. Edit: edit,
  134. })
  135. continue
  136. }
  137. currentContent = newContent
  138. }
  139. // Get session and message IDs
  140. sessionID := GetSessionFromContext(edit.ctx)
  141. if sessionID == "" {
  142. return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
  143. }
  144. // Check permissions
  145. _, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
  146. editsApplied := len(params.Edits) - len(failedEdits)
  147. var description string
  148. if len(failedEdits) > 0 {
  149. description = fmt.Sprintf("Create file %s with %d of %d edits (%d failed)", params.FilePath, editsApplied, len(params.Edits), len(failedEdits))
  150. } else {
  151. description = fmt.Sprintf("Create file %s with %d edits", params.FilePath, editsApplied)
  152. }
  153. p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{
  154. SessionID: sessionID,
  155. Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
  156. ToolCallID: call.ID,
  157. ToolName: MultiEditToolName,
  158. Action: "write",
  159. Description: description,
  160. Params: MultiEditPermissionsParams{
  161. FilePath: params.FilePath,
  162. OldContent: "",
  163. NewContent: currentContent,
  164. },
  165. })
  166. if err != nil {
  167. return fantasy.ToolResponse{}, err
  168. }
  169. if !p {
  170. return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
  171. }
  172. // Write the file
  173. err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
  174. if err != nil {
  175. return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
  176. }
  177. // Update file history
  178. _, err = edit.files.Create(edit.ctx, sessionID, params.FilePath, "")
  179. if err != nil {
  180. return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
  181. }
  182. _, err = edit.files.CreateVersion(edit.ctx, sessionID, params.FilePath, currentContent)
  183. if err != nil {
  184. slog.Error("Error creating file history version", "error", err)
  185. }
  186. edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath)
  187. var message string
  188. if len(failedEdits) > 0 {
  189. message = fmt.Sprintf("File created with %d of %d edits: %s (%d edit(s) failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
  190. } else {
  191. message = fmt.Sprintf("File created with %d edits: %s", len(params.Edits), params.FilePath)
  192. }
  193. return fantasy.WithResponseMetadata(
  194. fantasy.NewTextResponse(message),
  195. MultiEditResponseMetadata{
  196. OldContent: "",
  197. NewContent: currentContent,
  198. Additions: additions,
  199. Removals: removals,
  200. EditsApplied: editsApplied,
  201. EditsFailed: failedEdits,
  202. },
  203. ), nil
  204. }
  205. func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
  206. // Validate file exists and is readable
  207. fileInfo, err := os.Stat(params.FilePath)
  208. if err != nil {
  209. if os.IsNotExist(err) {
  210. return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
  211. }
  212. return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
  213. }
  214. if fileInfo.IsDir() {
  215. return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
  216. }
  217. sessionID := GetSessionFromContext(edit.ctx)
  218. if sessionID == "" {
  219. return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file")
  220. }
  221. // Check if file was read before editing
  222. lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, params.FilePath)
  223. if lastRead.IsZero() {
  224. return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
  225. }
  226. // Check if file was modified since last read.
  227. modTime := fileInfo.ModTime().Truncate(time.Second)
  228. if modTime.After(lastRead) {
  229. return fantasy.NewTextErrorResponse(
  230. fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
  231. params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
  232. )), nil
  233. }
  234. // Read current file content
  235. content, err := os.ReadFile(params.FilePath)
  236. if err != nil {
  237. return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
  238. }
  239. oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
  240. currentContent := oldContent
  241. // Apply all edits sequentially, tracking failures
  242. var failedEdits []FailedEdit
  243. for i, edit := range params.Edits {
  244. newContent, err := applyEditToContent(currentContent, edit)
  245. if err != nil {
  246. failedEdits = append(failedEdits, FailedEdit{
  247. Index: i + 1,
  248. Error: err.Error(),
  249. Edit: edit,
  250. })
  251. continue
  252. }
  253. currentContent = newContent
  254. }
  255. // Check if content actually changed
  256. if oldContent == currentContent {
  257. // If we have failed edits, report them
  258. if len(failedEdits) > 0 {
  259. return fantasy.WithResponseMetadata(
  260. fantasy.NewTextErrorResponse(fmt.Sprintf("no changes made - all %d edit(s) failed", len(failedEdits))),
  261. MultiEditResponseMetadata{
  262. EditsApplied: 0,
  263. EditsFailed: failedEdits,
  264. },
  265. ), nil
  266. }
  267. return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil
  268. }
  269. // Generate diff and check permissions
  270. _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
  271. editsApplied := len(params.Edits) - len(failedEdits)
  272. var description string
  273. if len(failedEdits) > 0 {
  274. description = fmt.Sprintf("Apply %d of %d edits to file %s (%d failed)", editsApplied, len(params.Edits), params.FilePath, len(failedEdits))
  275. } else {
  276. description = fmt.Sprintf("Apply %d edits to file %s", editsApplied, params.FilePath)
  277. }
  278. p, err := edit.permissions.Request(edit.ctx, permission.CreatePermissionRequest{
  279. SessionID: sessionID,
  280. Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
  281. ToolCallID: call.ID,
  282. ToolName: MultiEditToolName,
  283. Action: "write",
  284. Description: description,
  285. Params: MultiEditPermissionsParams{
  286. FilePath: params.FilePath,
  287. OldContent: oldContent,
  288. NewContent: currentContent,
  289. },
  290. })
  291. if err != nil {
  292. return fantasy.ToolResponse{}, err
  293. }
  294. if !p {
  295. return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
  296. }
  297. normalizedNewContent := currentContent
  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, normalizedNewContent)
  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: normalizedNewContent,
  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. }