mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -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.
128 lines
4.4 KiB
Go
128 lines
4.4 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const sampleHTML = `<!DOCTYPE html>
|
|
<html><head><title>ignored</title><style>body{color:red}</style></head>
|
|
<body>
|
|
<script>var tracking = "noise";</script>
|
|
<h1>Main Title</h1>
|
|
<p>First paragraph with a <a href="https://example.com/docs">docs link</a> and & entity.</p>
|
|
<ul><li>alpha</li><li>beta</li></ul>
|
|
<!-- hidden comment -->
|
|
<h2>Section</h2>
|
|
<p>Body with extra spaces.</p>
|
|
</body></html>`
|
|
|
|
func TestHTMLToMarkdown(t *testing.T) {
|
|
markdown := htmlToMarkdown(sampleHTML)
|
|
|
|
for _, want := range []string{
|
|
"# Main Title",
|
|
"## Section",
|
|
"[docs link](https://example.com/docs)",
|
|
"- alpha",
|
|
"- beta",
|
|
"with a", // paragraph text survives
|
|
"& entity",
|
|
} {
|
|
if !strings.Contains(markdown, want) {
|
|
t.Fatalf("markdown missing %q:\n%s", want, markdown)
|
|
}
|
|
}
|
|
for _, banned := range []string{"tracking", "color:red", "hidden comment", "<p>", "</html>", "ignored"} {
|
|
if strings.Contains(markdown, banned) {
|
|
t.Fatalf("markdown must not contain %q:\n%s", banned, markdown)
|
|
}
|
|
}
|
|
if strings.Contains(markdown, "extra spaces") {
|
|
t.Fatalf("space runs must collapse:\n%s", markdown)
|
|
}
|
|
if strings.Contains(markdown, "\n\n\n") {
|
|
t.Fatalf("blank-line runs must be capped:\n%s", markdown)
|
|
}
|
|
}
|
|
|
|
func TestLooksLikeHTML(t *testing.T) {
|
|
cases := []struct {
|
|
contentType string
|
|
body string
|
|
want bool
|
|
}{
|
|
{"text/html; charset=utf-8", "anything", true},
|
|
{"application/xhtml+xml", "anything", true},
|
|
{"application/json", `{"html":"<html>"}`, false},
|
|
{"text/plain", "<!doctype html><html>", true}, // mislabeled HTML sniffed
|
|
{"text/plain", "just text", false},
|
|
{"", "<html><body>x</body></html>", true},
|
|
}
|
|
for _, c := range cases {
|
|
if got := looksLikeHTML(c.contentType, c.body); got != c.want {
|
|
t.Errorf("looksLikeHTML(%q, %.20q) = %v, want %v", c.contentType, c.body, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWebFetchConvertsHTMLByDefault(t *testing.T) {
|
|
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
|
return webFetchTestResponse(request, http.StatusOK, "text/html; charset=utf-8", sampleHTML), nil
|
|
}))
|
|
|
|
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/page"})
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "# Main Title") || strings.Contains(result.Output, "<h1>") {
|
|
t.Fatalf("HTML must convert to markdown by default:\n%s", result.Output)
|
|
}
|
|
if result.Meta["converted"] != "true" {
|
|
t.Fatalf("converted meta must be true: %#v", result.Meta)
|
|
}
|
|
}
|
|
|
|
func TestWebFetchRawFormatSkipsConversion(t *testing.T) {
|
|
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
|
return webFetchTestResponse(request, http.StatusOK, "text/html", sampleHTML), nil
|
|
}))
|
|
|
|
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/page", "format": "raw"})
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "<h1>Main Title</h1>") {
|
|
t.Fatalf("format=raw must keep original HTML:\n%s", result.Output)
|
|
}
|
|
if result.Meta["converted"] != "false" {
|
|
t.Fatalf("converted meta must be false: %#v", result.Meta)
|
|
}
|
|
}
|
|
|
|
func TestWebFetchLeavesNonHTMLUntouched(t *testing.T) {
|
|
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
|
return webFetchTestResponse(request, http.StatusOK, "application/json", `{"key":"<b>value</b>"}`), nil
|
|
}))
|
|
|
|
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com/api"})
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, `{"key":"<b>value</b>"}`) {
|
|
t.Fatalf("non-HTML must pass through untouched:\n%s", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestWebFetchRejectsInvalidFormat(t *testing.T) {
|
|
tool := newWebFetchToolWithClient(webFetchTestClient(func(request *http.Request) (*http.Response, error) {
|
|
return webFetchTestResponse(request, http.StatusOK, "text/plain", "x"), nil
|
|
}))
|
|
result := tool.Run(context.Background(), map[string]any{"url": "https://example.com", "format": "xml"})
|
|
if result.Status != StatusError || !strings.Contains(result.Output, "format must be") {
|
|
t.Fatalf("invalid format must be rejected: %s %s", result.Status, result.Output)
|
|
}
|
|
}
|