write.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  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. //go:embed write.md
  21. var writeDescription []byte
  22. type WriteParams struct {
  23. FilePath string `json:"file_path" description:"The path to the file to write"`
  24. Content string `json:"content" description:"The content to write to the file"`
  25. }
  26. type WritePermissionsParams struct {
  27. FilePath string `json:"file_path"`
  28. OldContent string `json:"old_content,omitempty"`
  29. NewContent string `json:"new_content,omitempty"`
  30. }
  31. type writeTool struct {
  32. lspClients *csync.Map[string, *lsp.Client]
  33. permissions permission.Service
  34. files history.Service
  35. workingDir string
  36. }
  37. type WriteResponseMetadata struct {
  38. Diff string `json:"diff"`
  39. Additions int `json:"additions"`
  40. Removals int `json:"removals"`
  41. }
  42. const WriteToolName = "write"
  43. func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
  44. return fantasy.NewAgentTool(
  45. WriteToolName,
  46. string(writeDescription),
  47. func(ctx context.Context, params WriteParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
  48. if params.FilePath == "" {
  49. return fantasy.NewTextErrorResponse("file_path is required"), nil
  50. }
  51. if params.Content == "" {
  52. return fantasy.NewTextErrorResponse("content is required"), nil
  53. }
  54. filePath := filepathext.SmartJoin(workingDir, params.FilePath)
  55. fileInfo, err := os.Stat(filePath)
  56. if err == nil {
  57. if fileInfo.IsDir() {
  58. return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
  59. }
  60. modTime := fileInfo.ModTime()
  61. lastRead := getLastReadTime(filePath)
  62. if modTime.After(lastRead) {
  63. return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
  64. filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
  65. }
  66. oldContent, readErr := os.ReadFile(filePath)
  67. if readErr == nil && string(oldContent) == params.Content {
  68. return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
  69. }
  70. } else if !os.IsNotExist(err) {
  71. return fantasy.ToolResponse{}, fmt.Errorf("error checking file: %w", err)
  72. }
  73. dir := filepath.Dir(filePath)
  74. if err = os.MkdirAll(dir, 0o755); err != nil {
  75. return fantasy.ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
  76. }
  77. oldContent := ""
  78. if fileInfo != nil && !fileInfo.IsDir() {
  79. oldBytes, readErr := os.ReadFile(filePath)
  80. if readErr == nil {
  81. oldContent = string(oldBytes)
  82. }
  83. }
  84. sessionID := GetSessionFromContext(ctx)
  85. if sessionID == "" {
  86. return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
  87. }
  88. diff, additions, removals := diff.GenerateDiff(
  89. oldContent,
  90. params.Content,
  91. strings.TrimPrefix(filePath, workingDir),
  92. )
  93. p := permissions.Request(
  94. permission.CreatePermissionRequest{
  95. SessionID: sessionID,
  96. Path: fsext.PathOrPrefix(filePath, workingDir),
  97. ToolCallID: call.ID,
  98. ToolName: WriteToolName,
  99. Action: "write",
  100. Description: fmt.Sprintf("Create file %s", filePath),
  101. Params: WritePermissionsParams{
  102. FilePath: filePath,
  103. OldContent: oldContent,
  104. NewContent: params.Content,
  105. },
  106. },
  107. )
  108. if !p {
  109. return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
  110. }
  111. err = os.WriteFile(filePath, []byte(params.Content), 0o644)
  112. if err != nil {
  113. return fantasy.ToolResponse{}, fmt.Errorf("error writing file: %w", err)
  114. }
  115. // Check if file exists in history
  116. file, err := files.GetByPathAndSession(ctx, filePath, sessionID)
  117. if err != nil {
  118. _, err = files.Create(ctx, sessionID, filePath, oldContent)
  119. if err != nil {
  120. // Log error but don't fail the operation
  121. return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
  122. }
  123. }
  124. if file.Content != oldContent {
  125. // User Manually changed the content store an intermediate version
  126. _, err = files.CreateVersion(ctx, sessionID, filePath, oldContent)
  127. if err != nil {
  128. slog.Error("Error creating file history version", "error", err)
  129. }
  130. }
  131. // Store the new version
  132. _, err = files.CreateVersion(ctx, sessionID, filePath, params.Content)
  133. if err != nil {
  134. slog.Error("Error creating file history version", "error", err)
  135. }
  136. recordFileWrite(filePath)
  137. recordFileRead(filePath)
  138. notifyLSPs(ctx, lspClients, params.FilePath)
  139. result := fmt.Sprintf("File successfully written: %s", filePath)
  140. result = fmt.Sprintf("<result>\n%s\n</result>", result)
  141. result += getDiagnostics(filePath, lspClients)
  142. return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result),
  143. WriteResponseMetadata{
  144. Diff: diff,
  145. Additions: additions,
  146. Removals: removals,
  147. },
  148. ), nil
  149. })
  150. }