package chattool import ( "context" "encoding/json" "strings" "charm.land/fantasy" "github.com/coder/coder/v2/codersdk/workspacesdk" ) type EditFilesOptions struct { GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error) ResolvePlanPath func(context.Context) (chatPath string, home string, err error) IsPlanTurn bool } // EditFilesArgs is the tool input schema, auto-generated by the // fantasy framework from these struct tags. type EditFilesArgs struct { Files []editFileEdits `json:"files"` } type editFileEdits struct { Path string `json:"path"` Edits []editFileEdit `json:"edits"` } // editFileEdit uses "old_text"/"new_text" instead of "search"/"replace" // because models confused the direction (CODAGT-312). Deprecated // "search"/"replace" accepted via UnmarshalJSON; toSDKFiles maps back // to "search"/"replace" for agent/agentfiles. type editFileEdit struct { OldText string `json:"old_text"` NewText string `json:"new_text"` ReplaceAll bool `json:"replace_all,omitempty"` } // UnmarshalJSON falls back to deprecated "search"/"replace" when // "old_text"/"new_text" are empty. func (e *editFileEdit) UnmarshalJSON(data []byte) error { var raw struct { OldText string `json:"old_text"` Search string `json:"search"` NewText string `json:"new_text"` Replace string `json:"replace"` ReplaceAll bool `json:"replace_all"` } if err := json.Unmarshal(data, &raw); err != nil { return err } e.OldText = raw.OldText if e.OldText == "" { e.OldText = raw.Search } e.NewText = raw.NewText if e.NewText == "" { e.NewText = raw.Replace } e.ReplaceAll = raw.ReplaceAll return nil } func (a EditFilesArgs) toSDKFiles() []workspacesdk.FileEdits { files := make([]workspacesdk.FileEdits, len(a.Files)) for i, f := range a.Files { edits := make([]workspacesdk.FileEdit, len(f.Edits)) for j, e := range f.Edits { edits[j] = workspacesdk.FileEdit{ Search: e.OldText, Replace: e.NewText, ReplaceAll: e.ReplaceAll, } } files[i] = workspacesdk.FileEdits{ Path: f.Path, Edits: edits, } } return files } func EditFiles(options EditFilesOptions) fantasy.AgentTool { return fantasy.NewAgentTool( "edit_files", "Perform edits on one or more files by replacing old_text with"+ " new_text. Matching is fuzzy (tolerates whitespace and indentation"+ " differences) and preserves the file's existing indentation and"+ " line endings. Errors if old_text matches zero locations, or more"+ " than one unless replace_all is set. All edits in a batch are"+ " validated before any file is written.", func(ctx context.Context, args EditFilesArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) { var planPath string if options.IsPlanTurn && len(args.Files) > 0 { resolvedPlanPath, err := resolvePlanTurnPath(ctx, options.ResolvePlanPath) if err != nil { return fantasy.NewTextErrorResponse(err.Error()), nil } for i := range args.Files { args.Files[i].Path = strings.TrimSpace(args.Files[i].Path) if args.Files[i].Path != resolvedPlanPath { return fantasy.NewTextErrorResponse("during plan turns, edit_files is restricted to " + resolvedPlanPath), nil } } planPath = resolvedPlanPath } if options.GetWorkspaceConn == nil { return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil } conn, err := options.GetWorkspaceConn(ctx) if err != nil { return fantasy.NewTextErrorResponse(err.Error()), nil } if planPath != "" { if err := ensurePlanPathResolvesToItself(ctx, conn, planPath); err != nil { return fantasy.NewTextErrorResponse(err.Error()), nil } } return executeEditFilesTool(ctx, conn, args, options.ResolvePlanPath) }, ) } func executeEditFilesTool( ctx context.Context, conn workspacesdk.AgentConn, args EditFilesArgs, resolvePlanPath func(context.Context) (chatPath string, home string, err error), ) (fantasy.ToolResponse, error) { if len(args.Files) == 0 { return fantasy.NewTextErrorResponse("files is required"), nil } var ( chatPath string home string planPathErr error planPathLoaded bool ) for i := range args.Files { args.Files[i].Path = strings.TrimSpace(args.Files[i].Path) file := args.Files[i] hasPlanFileName := looksLikePlanFileName(file.Path) if hasPlanFileName && !isAbsolutePath(file.Path) { return fantasy.NewTextErrorResponse( "plan files must use absolute paths; use the chat-specific absolute plan path; no files in this batch were applied", ), nil } if resolvePlanPath == nil || !hasPlanFileName { continue } if !planPathLoaded { chatPath, home, planPathErr = resolvePlanPath(ctx) planPathLoaded = true } if resp, rejected := rejectSharedPlanPath(file.Path, home, chatPath, planPathErr); rejected { return fantasy.NewTextErrorResponse( resp.Content + "; no files in this batch were applied", ), nil } } resp, err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{ Files: args.toSDKFiles(), IncludeDiff: true, }) if err != nil { return fantasy.NewTextErrorResponse(err.Error()), nil } return toolResponse(map[string]any{ "ok": true, "files": resp.Files, }), nil }