edit.go 14 KB

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