edit.go 14 KB

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