mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* 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.
175 lines
7.7 KiB
Go
175 lines
7.7 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
type editFileTool struct {
|
|
baseTool
|
|
workspaceRoot string
|
|
scope PathScope
|
|
}
|
|
|
|
func NewEditFileTool(workspaceRoot string) Tool {
|
|
return NewScopedEditFileTool(workspaceRoot, nil)
|
|
}
|
|
|
|
func NewScopedEditFileTool(workspaceRoot string, scope PathScope) Tool {
|
|
return editFileTool{
|
|
baseTool: baseTool{
|
|
name: "edit_file",
|
|
description: "Replace an exact string in an existing file with uniqueness protection by default.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"path": {Type: "string", Description: "Path of the file to edit."},
|
|
"old_string": {Type: "string", Description: "Exact string to replace. Must match byte-for-byte."},
|
|
"new_string": {Type: "string", Description: "Replacement string. May be empty."},
|
|
"replace_all": {Type: "boolean", Description: "Replace every occurrence instead of requiring uniqueness.", Default: false},
|
|
},
|
|
Required: []string{"path", "old_string", "new_string"},
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: promptSafety(SideEffectWrite, "Edits files in place."),
|
|
},
|
|
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
|
scope: scope,
|
|
}
|
|
}
|
|
|
|
func (tool editFileTool) Run(ctx context.Context, args map[string]any) Result {
|
|
return tool.RunWithOptions(ctx, args, RunOptions{})
|
|
}
|
|
|
|
func (tool editFileTool) 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 edit_file: " + err.Error())
|
|
}
|
|
oldString, err := aliasedStringArg(args, []string{"old_string", "old", "search", "find", "old_str"}, "", true, false)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
|
|
}
|
|
newString, err := aliasedStringArg(args, []string{"new_string", "new", "replace", "replacement", "new_str"}, "", true, true)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
|
|
}
|
|
replaceAll, err := boolArg(args, "replace_all", false)
|
|
if err != nil {
|
|
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
|
|
}
|
|
|
|
absolutePath, relativePath, err := resolveScopedPath(tool.workspaceRoot, tool.scope, requestedPath)
|
|
if err != nil {
|
|
return errorResult("Error reading " + requestedPath + ": " + err.Error())
|
|
}
|
|
contentBytes, err := os.ReadFile(absolutePath)
|
|
if err != nil {
|
|
return errorResult("Error reading " + relativePath + ": " + err.Error())
|
|
}
|
|
// Refuse to edit a file that changed on disk outside Zero since it was last
|
|
// read: the model's old_string was formed against a stale view, so applying it
|
|
// now could silently corrupt the newer content.
|
|
if cerr := options.FileTracker.CheckConflict(absolutePath, contentBytes); cerr != nil {
|
|
return errorResult(fileConflictMessage(relativePath))
|
|
}
|
|
content := string(contentBytes)
|
|
occurrences := strings.Count(content, oldString)
|
|
|
|
// CRLF fallback: read_file normalizes \r\n → \n before presenting content to
|
|
// the model, so the model's old_string will use \n line endings. When the raw
|
|
// file uses \r\n (common on Windows), the \n-based old_string won't match.
|
|
// Detect this and transparently normalize: if the direct match fails, translate
|
|
// old_string's line endings to \r\n and search again. If found, use the CRLF-translated
|
|
// old_string and new_string for the replacement to preserve the file's existing EOL style
|
|
// without rewriting EOLs in unrelated parts of the file.
|
|
if occurrences == 0 && strings.Contains(content, "\r\n") && !strings.Contains(oldString, "\r\n") {
|
|
crlfOldString := strings.ReplaceAll(oldString, "\n", "\r\n")
|
|
normalizedOccurrences := strings.Count(content, crlfOldString)
|
|
if normalizedOccurrences > 0 {
|
|
occurrences = normalizedOccurrences
|
|
oldString = crlfOldString
|
|
if !strings.Contains(newString, "\r\n") {
|
|
newString = strings.ReplaceAll(newString, "\n", "\r\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fuzzy fallback: when the exact string (and its CRLF translation) is absent,
|
|
// run a cascade of tolerant matchers (trimmed lines, block anchors, collapsed
|
|
// whitespace, indentation drift, escape normalization) to locate the span the
|
|
// model intended. Only a span that occurs literally in the file is accepted,
|
|
// so the replacement applied below is still exact.
|
|
if occurrences == 0 {
|
|
findOld, findNew := oldString, newString
|
|
if strings.Contains(content, "\r\n") && !strings.Contains(findOld, "\r\n") {
|
|
findOld = strings.ReplaceAll(findOld, "\n", "\r\n")
|
|
findNew = strings.ReplaceAll(findNew, "\n", "\r\n")
|
|
}
|
|
search, ferr := fuzzyEditMatch(content, findOld, replaceAll)
|
|
switch {
|
|
case ferr == nil:
|
|
oldString = search
|
|
// The model's new_string was written at old_string's (mismatched)
|
|
// indentation; re-shape it to the span actually being replaced so a
|
|
// tolerant match never strips indentation or a trailing CR.
|
|
newString = adaptReplacementToSpan(search, findOld, findNew)
|
|
occurrences = strings.Count(content, search)
|
|
case errors.Is(ferr, errEditFuzzyAmbiguous):
|
|
return errorResult("Error: old_string matches multiple locations in " + relativePath + " even after fuzzy matching. Provide more surrounding context to make the match unique, or pass replace_all: true.")
|
|
case errors.Is(ferr, errEditFuzzyNotFound):
|
|
// Fall through to the exact-match error below.
|
|
default:
|
|
return errorResult("Error editing " + relativePath + ": " + ferr.Error())
|
|
}
|
|
}
|
|
|
|
if occurrences == 0 {
|
|
return errorResult("Error: Could not find the exact string to replace in " + relativePath + ". The old_string must match the file byte-for-byte.")
|
|
}
|
|
if !replaceAll && occurrences > 1 {
|
|
return errorResult(fmt.Sprintf("Error: old_string matches %d locations in %s. Either make old_string more specific, or pass replace_all: true to replace every occurrence.", occurrences, relativePath))
|
|
}
|
|
|
|
updated := strings.Replace(content, oldString, newString, 1)
|
|
replacedCount := 1
|
|
if replaceAll {
|
|
updated = strings.ReplaceAll(content, oldString, newString)
|
|
replacedCount = occurrences
|
|
}
|
|
if updated == content {
|
|
return okResult("No changes: new_string is identical to old_string.")
|
|
}
|
|
if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil {
|
|
return errorResult("Error writing " + relativePath + ": " + err.Error())
|
|
}
|
|
if err := os.WriteFile(absolutePath, []byte(updated), 0o644); err != nil {
|
|
return errorResult("Error writing " + relativePath + ": " + err.Error())
|
|
}
|
|
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
|
|
// FileTracker re-baseline: recording pre-format content would make the very
|
|
// next edit look like an external modification and trip the conflict guard.
|
|
updated = maybeFormatWrittenFile(ctx, absolutePath, updated)
|
|
// Re-baseline to the content we just wrote so subsequent edits in this session
|
|
// compare against the current on-disk state, not the pre-edit version.
|
|
newInfo, _ := os.Stat(absolutePath)
|
|
options.FileTracker.Record(absolutePath, []byte(updated), newInfo)
|
|
|
|
suffix := ""
|
|
if replacedCount != 1 {
|
|
suffix = "s"
|
|
}
|
|
summary := fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix)
|
|
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
|
|
result := okResult(summary)
|
|
result.ChangedFiles = []string{relativePath}
|
|
// Card-only preview (Display.Preview): the model's Output stays the one-line
|
|
// summary, so the red/green diff costs zero model tokens.
|
|
result.Display = Display{Summary: fmt.Sprintf("Edited %s", relativePath), Kind: "diff", Preview: boundedUnifiedDiff(relativePath, content, updated)}
|
|
return result
|
|
}
|