Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 167 additions & 67 deletions coderd/x/chatd/chattool/editfiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"io"
"net/http"
"slices"
"strings"

Expand Down Expand Up @@ -244,8 +243,9 @@ func EditFiles(options EditFilesOptions) fantasy.AgentTool {
" (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.",
" replace_all is set. Each file's edits are validated before that"+
" file is written: a file with any error is left unchanged, and"+
" files without errors are still applied.",
func(ctx context.Context, args EditFilesArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if len(args.Edits) == 0 {
return rejectEditFiles("Add at least one edit to edits"), nil
Expand Down Expand Up @@ -292,81 +292,199 @@ func EditFiles(options EditFilesOptions) fantasy.AgentTool {
)}
}

// executeEditFilesTool applies each file in its own agent request, in
// order of each path's first edit, so a file with an error does not
// stop the others. Results index edits by their position in args.
func executeEditFilesTool(
ctx context.Context,
conn workspacesdk.AgentConn,
args EditFilesArgs,
resolvePlanPath func(context.Context) (chatPath string, home string, err error),
) (fantasy.ToolResponse, error) {
editIndexes := make(map[string][]int)
for i, edit := range args.Edits {
editIndexes[edit.Path] = append(editIndexes[edit.Path], i)
}

var (
chatPath string
home string
planPathErr error
planPathLoaded bool
)
for _, edit := range args.Edits {
hasPlanFileName := looksLikePlanFileName(edit.Path)
if hasPlanFileName && !isAbsolutePath(edit.Path) {
return rejectEditFiles(
"Use the chat-specific absolute plan path; plan files must use absolute paths",
), nil
// checkPath runs the coderd checks tied to one path and returns the
// reason to reject that file, or "" when it passes.
checkPath := func(path string) string {
hasPlanFileName := looksLikePlanFileName(path)
if hasPlanFileName && !isAbsolutePath(path) {
return "Use the chat-specific absolute plan path; plan files must use absolute paths"
}
if resolvePlanPath == nil || !hasPlanFileName {
continue
return ""
}
if !planPathLoaded {
chatPath, home, planPathErr = resolvePlanPath(ctx)
planPathLoaded = true
}
if resp, rejected := rejectSharedPlanPath(edit.Path, home, chatPath, planPathErr); rejected {
return rejectEditFiles(resp.Content), nil
if resp, rejected := rejectSharedPlanPath(path, home, chatPath, planPathErr); rejected {
return resp.Content
}
return ""
}

request := workspacesdk.FileEditRequest{
Files: GroupEditsByPath(args.Edits),
IncludeDiff: true,
var applied, notApplied []editFilesFileResult
for _, file := range GroupEditsByPath(args.Edits) {
indexes := editIndexes[file.Path]
// The interrupt handler can persist this result, so a file not yet
// sent is reported as not applied; sending it now would fail and
// report its outcome as unknown. This check runs before checkPath
// so the interrupt is the reason given.
if ctx.Err() != nil {
notApplied = append(notApplied, editFilesFileResult{
Path: file.Path, Status: editFilesStatusRejected, Edits: indexes, Error: editFilesInterruptedError,
})
continue
}
if reason := checkPath(file.Path); reason != "" {
notApplied = append(notApplied, editFilesFileResult{
Path: file.Path, Status: editFilesStatusRejected, Edits: indexes, Error: reason,
})
continue
}
resp, err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{
Files: []workspacesdk.FileEdits{file},
IncludeDiff: true,
})
if err != nil {
// An agent response means nothing was written: the agent
// validates the file, writes a temporary file and renames it
// into place, and only a panic after the rename returns an
// error after writing. A dropped connection may follow a
// completed write.
status := editFilesStatusUnknown
if isAgentResponse(err) {
status = editFilesStatusRejected
}
notApplied = append(notApplied, editFilesFileResult{
Path: file.Path, Status: status, Edits: indexes, Error: agentAPIErrorMessage(err),
})
continue
}
result := editFilesFileResult{Path: file.Path, Status: editFilesStatusApplied}
// Agents that predate per-file results return none.
if len(resp.Files) > 0 {
diff := resp.Files[0].Diff
result.Diff = &diff
}
applied = append(applied, result)
}
resp, err := conn.EditFiles(ctx, request)
if err != nil {
if agentWroteNothing(err, len(request.Files)) {
return fantasy.NewTextErrorResponse(
editFilesNoneApplied + " " + agentAPIErrorMessage(err) + "\nFix the failing edit and resend all edits.",
), nil

switch {
case len(notApplied) == 0:
return marshalToolResponse(editFilesResult{
Status: editFilesStatusApplied,
Message: fmt.Sprintf("Applied edits to %d %s.", len(applied), pluralFiles(len(applied))),
Files: applied,
}), nil
case len(applied) > 0:
return marshalToolResponse(editFilesResult{
Status: editFilesStatusPartial,
Message: partialEditFilesMessage(len(applied), notApplied),
Files: append(notApplied, applied...),
}), nil
default:
return fantasy.NewTextErrorResponse(noneAppliedEditFilesMessage(notApplied)), nil
}
}

// isAgentResponse reports whether an EditFiles error carries the
// agent's response rather than a transport or decoding failure.
func isAgentResponse(err error) bool {
_, ok := codersdk.AsError(err)
return ok
}

// partialEditFilesMessage summarizes a result where some files were
// applied, naming each file that was not applied, in the order of
// notApplied.
func partialEditFilesMessage(applied int, notApplied []editFilesFileResult) string {
var sb strings.Builder
_, _ = fmt.Fprintf(&sb, "Applied %d %s.", applied, pluralFiles(applied))
for _, file := range notApplied {
indexes := formatEditIndexes(file.Edits)
switch {
case file.Status == editFilesStatusUnknown:
_, _ = fmt.Fprintf(&sb, " It is unknown whether %s was applied (%s): re-read %s before resending its edits.", file.Path, indexes, file.Path)
case file.Error == editFilesInterruptedError:
_, _ = fmt.Fprintf(&sb, " %s (%s).", interruptedFileSentence(file.Path), indexes)
case len(file.Edits) == 1:
_, _ = fmt.Fprintf(&sb, " %s was not applied (%s): fix and resend only the edits for %s.", file.Path, indexes, file.Path)
default:
_, _ = fmt.Fprintf(&sb, " %s was not applied (none of %s were applied): fix and resend only the edits for %s.", file.Path, indexes, file.Path)
}
return fantasy.NewTextErrorResponse(
"It is unknown whether any files were applied. " + agentAPIErrorMessage(err) + "\nRe-read the files before resending edits.",
), nil
}
return sb.String()
}

result := editFilesResult{
Status: editFilesStatusApplied,
Files: make([]editFilesFileResult, 0, len(resp.Files)),
// noneAppliedEditFilesMessage is the error text when no file was
// applied: a heading, then one line per file with its edits and error.
// The heading does not claim that unknown files were not applied.
func noneAppliedEditFilesMessage(files []editFilesFileResult) string {
heading := editFilesNoneApplied
if slices.ContainsFunc(files, func(file editFilesFileResult) bool {
return file.Status == editFilesStatusUnknown
}) {
heading = "No files were applied, except that files marked unknown may have been."
}
for _, file := range resp.Files {
result.Files = append(result.Files, editFilesFileResult{
Path: file.Path,
Status: editFilesStatusApplied,
Diff: file.Diff,
})
var sb strings.Builder
_, _ = sb.WriteString(heading)
for _, file := range files {
indexes := formatEditIndexes(file.Edits)
switch {
case file.Status == editFilesStatusUnknown:
_, _ = fmt.Fprintf(&sb, "\n- %s (%s): unknown whether applied (%s); re-read it before resending its edits", file.Path, indexes, file.Error)
case file.Error == editFilesInterruptedError:
_, _ = fmt.Fprintf(&sb, "\n- %s (%s)", interruptedFileSentence(file.Path), indexes)
default:
_, _ = fmt.Fprintf(&sb, "\n- %s (%s): %s", file.Path, indexes, file.Error)
}
}
// Agents that predate per-file results return none on success,
// having written every file in the request.
applied := len(resp.Files)
if applied == 0 {
applied = len(request.Files)
return sb.String()
}

// editFilesInterruptedError is the error of a file that was not sent
// because the tool call was interrupted. Nothing is known to be wrong
// with such a file, and the user may have interrupted to stop it, so
// messages name it without a resend instruction.
const editFilesInterruptedError = "not sent because the tool call was interrupted"

func interruptedFileSentence(path string) string {
return path + " was not applied because the tool call was interrupted"
}

// formatEditIndexes renders indexes as "edits[1], edits[3]".
func formatEditIndexes(indexes []int) string {
parts := make([]string, 0, len(indexes))
for _, i := range indexes {
parts = append(parts, fmt.Sprintf("edits[%d]", i))
}
result.Message = fmt.Sprintf("Applied edits to %d %s.", applied, pluralFiles(applied))
return marshalToolResponse(result), nil
return strings.Join(parts, ", ")
}

// editFilesNoneApplied appears in every edit_files error result for a
// call that is known to have written nothing.
const editFilesNoneApplied = "No files were applied."

const editFilesStatusApplied = "applied"
// File and result statuses in edit_files results.
const (
editFilesStatusApplied = "applied"
editFilesStatusPartial = "partial"
editFilesStatusRejected = "rejected"
editFilesStatusUnknown = "unknown"
)

// editFilesResult is the successful edit_files tool result.
// editFilesResult is the edit_files tool result when at least one file
// was applied. Files that were not applied come first.
type editFilesResult struct {
Status string `json:"status"`
Message string `json:"message"`
Expand All @@ -377,7 +495,13 @@ type editFilesResult struct {
type editFilesFileResult struct {
Path string `json:"path"`
Status string `json:"status"`
Diff string `json:"diff"`
// Edits holds the edits[i] indexes of a file that was not applied.
// The agent does not say which edit failed, so it lists them all.
Edits []int `json:"edits,omitempty"`
Error string `json:"error,omitempty"`
// Diff is the agent's diff for an applied file, nil when the agent
// returned no per-file result.
Diff *string `json:"diff,omitempty"`
}

func pluralFiles(n int) string {
Expand All @@ -393,30 +517,6 @@ func rejectEditFiles(reason string) fantasy.ToolResponse {
return fantasy.NewTextErrorResponse(reason + "\n" + editFilesNoneApplied)
}

// agentWroteNothing reports whether an EditFiles error proves that the
// agent wrote no file. Only an agent response can prove it; a dropped
// connection may follow a completed write. The agent writes each file
// to a temporary file and renames it into place, so a failed
// single-file request wrote nothing unless the agent panicked after the
// rename. With several files, 400 and 404 come only from validation,
// before any write, but 403 and 500 can come after earlier files were
// written.
func agentWroteNothing(err error, requestFiles int) bool {
sdkErr, ok := codersdk.AsError(err)
if !ok {
return false
}
if requestFiles == 1 {
return true
}
switch sdkErr.StatusCode() {
case http.StatusBadRequest, http.StatusNotFound:
return true
default:
return false
}
}

// agentAPIErrorMessage preserves the agent's actionable message while
// dropping the transport metadata (HTTP method, URL, status code) that
// codersdk.Error.Error() prefixes.
Expand Down
Loading
Loading