package chattool import ( "context" "encoding/json" "fmt" "strings" "charm.land/fantasy" "github.com/coder/coder/v2/codersdk" "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" description:"Files to edit. Every entry must include path and at least one edit."` } type editFileEdits struct { Path string `json:"path" description:"The absolute path of the file to edit, for example /home/coder/project/main.go."` Edits []editFileEdit `json:"edits" description:"Search and replace operations applied to this file in order."` } // 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" description:"Existing text in the file to replace. Matching is fuzzy: whitespace and indentation differences are tolerated."` NewText string `json:"new_text" description:"Text that replaces old_text."` ReplaceAll bool `json:"replace_all,omitempty" description:"Replace every match of old_text instead of erroring when it matches more than once."` } // 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"` NewText string `json:"new_text"` ReplaceAll bool `json:"replace_all"` } if err := json.Unmarshal(data, &raw); err != nil { return err } e.OldText = raw.OldText e.NewText = raw.NewText e.ReplaceAll = raw.ReplaceAll if e.OldText != "" && e.NewText != "" { return nil } // The aliases are absent from the advertised schema, so tool-input // validation cannot reject case variants of them the way it does for // declared properties. Matching them exactly keeps the keys this // decoder reads identical to the ones a policy sees. var exact map[string]json.RawMessage if err := json.Unmarshal(data, &exact); err != nil { return err } if err := decodeAlias(exact, "search", &e.OldText); err != nil { return err } return decodeAlias(exact, "replace", &e.NewText) } func decodeAlias(raw map[string]json.RawMessage, key string, target *string) error { value, present := raw[key] if *target != "" || !present { return nil } return json.Unmarshal(value, target) } 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. Each entry in files must include the absolute path"+ " of the file to edit and at least one edit. 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) { if len(args.Files) == 0 { return fantasy.NewTextErrorResponse("files is required"), nil } for i := range args.Files { args.Files[i].Path = strings.TrimSpace(args.Files[i].Path) if args.Files[i].Path == "" { return fantasy.NewTextErrorResponse(fmt.Sprintf( "files[%d].path is required; provide the absolute path of the file to edit; no files in this batch were applied", i, )), nil } if len(args.Files[i].Edits) == 0 { return fantasy.NewTextErrorResponse(fmt.Sprintf( "files[%d].edits must contain at least one edit; no files in this batch were applied", i, )), nil } } var planPath string if options.IsPlanTurn { resolvedPlanPath, err := resolvePlanTurnPath(ctx, options.ResolvePlanPath) if err != nil { return fantasy.NewTextErrorResponse(err.Error()), nil } for _, f := range args.Files { if f.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) { var ( chatPath string home string planPathErr error planPathLoaded bool ) for _, file := range args.Files { 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(agentAPIErrorMessage(err)), nil } return toolResponse(map[string]any{ "ok": true, "files": resp.Files, }), nil } // agentAPIErrorMessage preserves the agent's actionable message while // dropping the transport metadata (HTTP method, URL, status code) that // codersdk.Error.Error() prefixes. func agentAPIErrorMessage(err error) string { sdkErr, ok := codersdk.AsError(err) if !ok || sdkErr.Message == "" { return err.Error() } var sb strings.Builder _, _ = sb.WriteString(sdkErr.Message) if sdkErr.Helper != "" { _, _ = sb.WriteString(": " + sdkErr.Helper) } if sdkErr.Detail != "" { _, _ = sb.WriteString(": " + sdkErr.Detail) } for _, v := range sdkErr.Validations { _, _ = sb.WriteString("\n- " + v.Field + ": " + v.Detail) } return sb.String() }