Files
zero/internal/tools/write_file.go
Kevin Codex 3c81fea228 feat: agent quality, caching, retry, and tooling upgrades (#506)
* feat: agent quality, caching, retry, and tooling upgrades

Ten self-contained improvements to the core agent engine and tools:

- edit_file: fuzzy fallback matching (7-strategy cascade with uniqueness
  protection and a disproportion guard) when old_string does not match
  byte-for-byte; exact match remains the fast path
- edit_file/write_file: inline error-severity LSP diagnostics in tool
  output so the model sees a break it introduced in the same turn
- lsp: expand server registry from 4 to 28 languages; doctor grades only
  tier-1 servers and lists the long tail informationally
- agent: consecutive read-only tool calls execute concurrently (bounded
  at 8), consumed in original order, never spanning a mutating call
- anthropic: cache_control breakpoints on the last two conversation
  messages so the transcript prefix stays a prompt-cache hit
- providerio: retry schedule raised to 6 attempts with exponential
  2s-30s backoff when the server sends no Retry-After
- tools: truncated exec/bash output spills to a redacted temp file
  referenced in the truncation notice; bash output (previously
  unbounded) now shares exec_command's token budget
- web_fetch: dependency-free HTML-to-markdown conversion (format:
  auto|raw|markdown) and larger limits (256KiB default, 2MiB max, 30s)
- modelregistry: live models.dev overlay refreshes context limits and
  base pricing from a TTL-cached fetch; opt-in at the CLI entrypoint
- tools: opt-in format-on-write (ZERO_FORMAT_ON_WRITE=1) runs standard
  formatters across 27 extensions, ordered before the file-tracker
  re-baseline so conflict detection is preserved

* fix: address PR review feedback on fuzzy edits, diagnostics, spill

- edit_file: adapt the replacement to the fuzzy-resolved span — when the
  span is a uniformly deeper-indented copy of old_string (line-trimmed /
  indentation-flexible matches), the same indentation delta is applied to
  new_string, and a span ending in a bare CR keeps its CR so CRLF files
  never gain a mixed line ending; block-anchor matches with drifted
  interiors are deliberately left untouched
- diagnostics: render workspace-relative paths in inline post-edit
  diagnostics (base name outside the workspace) so absolute paths never
  leak the local username or directory layout into the model prompt or
  session transcript
- spill: harden the spill directory for shared temp dirs — per-uid name,
  reject symlinked or non-directory paths (MkdirAll follows symlinks),
  and on Unix require current-user ownership
- bash: the branch's output cap and truncation-flag threading are
  superseded by main's capture-time bounding (boundedBuffer +
  budgetBashCapture), which already sets Result.Truncated; this rebase
  adopts that design and keeps spill-to-disk on exec_command, where the
  full output is still buffered and a spill is genuinely complete

* fix: address review round 2 — permission-event race, fuzzy ambiguity, spill reachability

- agent: serialize OnPermission behind the same mutex as
  OnPermissionRequest in the parallel read batch. buildPermissionEvent
  fires for batched reads with a non-trivial sandbox decision (e.g. a
  grant-matched extra root), and front-end handlers append to shared
  session-recording state without their own locking — two batched reads
  emitting concurrently was a data race the pre-batch serial loop never
  allowed.
- edit_file: reject two or more DISTINCT candidate spans from one fuzzy
  strategy as ambiguous instead of silently editing the first. Duplicate
  blocks at different indentation each resolve to a unique literal, so
  the previous occurrence-uniqueness check passed and wrote the wrong
  span. A single candidate occurring at multiple positions still
  cascades to stricter strategies as before.
- tools: make spill files actually reachable by the recovery path the
  truncation notice advertises — resolveScopedReadPath now recognizes
  files inside the per-uid spill dir (containment verified after symlink
  resolution, so a planted link cannot smuggle an out-of-scope file
  through the gate); read_file/grep can follow the notice instead of
  re-running the command.

* fix: surface competing block-anchor spans; make write-barrier test observe cross-probe order

- edit_file: blockAnchorReplacer now returns every span clearing the
  similarity threshold (best first) instead of silently picking the
  single most-similar block. Hiding competitors from the cascade's
  distinct-candidate check could edit the wrong block when two blocks
  share anchors and both interiors are plausible; one clear winner still
  resolves, two plausible blocks now report ambiguity (covered by a
  two-block test that asserts the file is untouched).
- agent tests: the parallel write-barrier test now records both probes
  into one mutex-guarded shared log and asserts the cross-probe ordering
  it always claimed to (write starts after the first read batch ends;
  second read batch starts after the write ends). The previous version
  only compared indices within the read probe's own log, which a
  regression letting the write overlap a batch could still pass; the
  stray no-op mutex assignment is gone.
2026-07-05 17:25:26 +08:00

152 lines
6.0 KiB
Go

package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
)
type writeFileTool struct {
baseTool
workspaceRoot string
scope PathScope
}
func NewWriteFileTool(workspaceRoot string) Tool {
return NewScopedWriteFileTool(workspaceRoot, nil)
}
func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool {
return writeFileTool{
baseTool: baseTool{
name: "write_file",
description: "Create a new file, refusing to overwrite existing files unless overwrite is true.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"path": {Type: "string", Description: "Absolute or relative path of the file to write."},
"content": {Type: "string", Description: "Full file contents to write."},
"overwrite": {Type: "boolean", Description: "Whether to allow overwriting an existing file.", Default: false},
},
Required: []string{"path", "content"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectWrite, "Creates or overwrites files."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
}
}
func (tool writeFileTool) Run(ctx context.Context, args map[string]any) Result {
return tool.RunWithOptions(ctx, args, RunOptions{})
}
func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filename"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
}
content, err := fileContentArg(args)
if err != nil {
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
}
overwrite, err := boolArg(args, "overwrite", false)
if err != nil {
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
}
absolutePath, relativePath, err := resolveScopedTargetPath(tool.workspaceRoot, tool.scope, requestedPath)
if err != nil {
return errorResult("Error writing file " + requestedPath + ": " + err.Error())
}
existed := false
if _, err := os.Stat(absolutePath); err == nil {
existed = true
if !overwrite {
return errorResult("Error: " + relativePath + " already exists. Pass overwrite: true to replace it.")
}
} else if !os.IsNotExist(err) {
return errorResult("Error writing file " + relativePath + ": " + err.Error())
}
// On overwrite, refuse to clobber a tracked file that changed on disk outside
// Zero since it was last read — the new content was likely composed against a
// stale view. Only read current bytes when there is a baseline to compare,
// so a first-touch create/overwrite stays a single write with no extra read.
if existed {
if _, tracked := options.FileTracker.Version(absolutePath); tracked {
// Fail CLOSED: if the tracked file can't be re-read to verify it, refuse
// the overwrite rather than clobbering a file whose current state is
// unknown (it may have been replaced or removed out from under us).
current, rerr := os.ReadFile(absolutePath)
if rerr != nil {
return errorResult(fileConflictMessage(relativePath))
}
if cerr := options.FileTracker.CheckConflict(absolutePath, current); cerr != nil {
return errorResult(fileConflictMessage(relativePath))
}
}
}
// Capture the prior content (before we replace it) so an overwrite can show a
// real diff; a fresh create stays "" and previews as all-additions.
priorContent := ""
if existed {
if prev, rerr := os.ReadFile(absolutePath); rerr == nil {
priorContent = string(prev)
}
}
if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil {
return errorResult("Error writing file " + relativePath + ": " + err.Error())
}
if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil {
return errorResult("Error writing file " + relativePath + ": " + err.Error())
}
if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil {
return errorResult("Error writing file " + relativePath + ": " + err.Error())
}
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
// FileTracker baseline: recording pre-format content would make the very
// next edit look like an external modification and trip the conflict guard.
content = maybeFormatWrittenFile(ctx, absolutePath, content)
// Baseline the freshly written content so a later edit/overwrite in this
// session compares against what is now on disk.
newInfo, _ := os.Stat(absolutePath)
options.FileTracker.Record(absolutePath, []byte(content), newInfo)
verb := "Created"
if existed {
verb = "Overwrote"
}
// Report line count (not bytes): "Wrote 282 lines" reads as real work at a
// glance, where a byte total is opaque noise.
lines := strings.Count(content, "\n")
if content != "" && !strings.HasSuffix(content, "\n") {
lines++
}
summary := fmt.Sprintf("%s %s (%d lines).", verb, relativePath, lines)
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
result := okResult(summary)
result.ChangedFiles = []string{relativePath}
// Card-only preview: a real unified diff (all-green for a create, red/green for
// an overwrite) on Display.Preview. Output stays the summary, so the model never
// re-reads the file — the rich preview costs zero model tokens.
result.Display = Display{Summary: summary, Kind: "file", Preview: boundedUnifiedDiff(relativePath, priorContent, content)}
return result
}
// fileContentArg reads the file body from "content" or a common alias that weaker
// models sometimes use instead (contents/text/body/data/file_content). It
// delegates to the shared aliasedStringArg so the present-but-non-string type
// error ("content must be a string") and the required-but-missing error
// ("content is required") stay consistent with every other tool. An empty string
// is allowed (writing an empty file), so allowEmpty is true.
func fileContentArg(args map[string]any) (string, error) {
return aliasedStringArg(args, []string{"content", "contents", "text", "body", "data", "file_content"}, "", true, true)
}