Files
coder/coderd/x/chatd/chattool
Michael Suchacz 48e1e28638 fix(coderd/x/chatd/chattool): make edit_files schema and errors actionable for models (#28121)
## Problem

Chat `45b87e40-ffe7-49e5-8932-5fd0bdb9e542` on dev.coder.com failed 57
of 75 `edit_files` tool calls. Every failure was the same: the model
omitted `files[].path` (it batched edits per file but only filled in
`edits`), and the error relayed back to the model was:

```
POST http://[fd7a:115c:...]:4/api/v0/edit-files: unexpected status code 400: "path" is required
```

The model retried the identical malformed call dozens of times. Two gaps
made this sticky:

1. The `edit_files` input schema had no field descriptions, so `path`
was only a bare required property.
2. The agent API error reached the model wrapped in HTTP transport noise
(method, internal tailnet URL, status code) with no indication of which
`files` entry was broken.

## Changes

- Add `description` tags to every `edit_files` schema field and state
the path requirement in the tool description.
- Validate `files` entries in the tool before plan-turn checks and the
workspace connection lookup, returning entry-indexed errors such as
`files[1].path is required; provide the absolute path of the file to
edit; no files in this batch were applied`.
- Relay agent API failures with `Message`, `Helper`, `Detail`, and
`Validations` from `codersdk.Error` instead of the raw
transport-prefixed string.

## Validation

- `go test ./coderd/x/chatd/chattool` passes; new tests cover the schema
description, entry-indexed validation errors, and transport-noise
stripping (each verified red-green by toggling the fix off).
- `go build ./...`, `go vet`, and pre-commit (fmt + lint) pass.

> Mux created this PR on Mike's behalf.
2026-08-13 19:57:58 +02:00
..

package chattool

import (
	"bufio"
	"strings"
	"unicode"

	"github.com/coder/coder/v2/coderd/render"
	coderstrings "github.com/coder/coder/v2/coderd/util/strings"
)

// readmeInputMaxBytes caps how many README bytes are parsed so a giant README
// can't OOM coderd. It sits well above the output rune cap, so it never trims a
// real excerpt.
const readmeInputMaxBytes = 64 * 1024

// readmeText returns the README as bounded, frontmatter-stripped plain text
// truncated to maxRunes, or "" when the README is blank or conversion fails.
func readmeText(readme string, maxRunes int) string {
	// Cap the parse input first (see readmeInputMaxBytes); goldmark and the
	// tokenizer tolerate a mid-line or mid-rune cut.
	bounded := readme[:min(len(readme), readmeInputMaxBytes)]
	text, err := render.InnerTextFromMarkdown(stripReadmeFrontmatter(bounded))
	if err != nil {
		return ""
	}
	return coderstrings.Truncate(text, maxRunes, coderstrings.TruncateWithEllipsis)
}

// stripReadmeFrontmatter strips a leading frontmatter block from the README if it
// exists. An unterminated frontmatter block is treated as a regular body section.
// UTF-8 BOMs are stripped if present, and CRLF is normalized to LF. Leading
// whitespace is also stripped.
func stripReadmeFrontmatter(readme string) string {
	trimmed := strings.TrimLeftFunc(readme, func(r rune) bool {
		return unicode.IsSpace(r) || r == '\ufeff'
	})

	var out strings.Builder
	scn := bufio.NewScanner(strings.NewReader(trimmed))
	scn.Buffer(nil, readmeInputMaxBytes+1) // headroom
	var lineNumber int
	var fences int
	for scn.Scan() {
		line := scn.Text()
		lineNumber++

		// Only handle fences if we haven't already found an
		// opening and closing fence.
		if fences < 2 {
			isFence := strings.TrimRight(line, " \t\r") == "---"
			if isFence {
				fences++
				continue
			} else if lineNumber == 1 {
				// No leading fence -> no frontmatter. Return the entire document.
				return trimmed
			}
			// We are still in the frontmatter block. Skip writing this line.
			continue
		}
		_, _ = out.WriteString(line)
		_, _ = out.WriteString("\n")
	}

	// Can err if input is too big. Shouldn't happen normally.
	if scn.Err() != nil {
		return trimmed
	}

	// Scanner did not scan any lines. No frontmatter to strip.
	if lineNumber == 0 {
		return trimmed
	}

	// If we are still fenced but we reached the end of the document
	// we have an unterminated fence.
	if fences == 1 {
		return trimmed
	}
	return out.String()
}