Files
zero/internal/tools/web_fetch_markdown.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

93 lines
3.9 KiB
Go

package tools
import (
"fmt"
"html"
"regexp"
"strings"
)
// HTML-to-markdown conversion for web_fetch. Raw HTML is mostly boilerplate —
// on a typical page well under a tenth of the bytes are readable content — so
// returning it raw wastes nearly the whole response budget on markup. The
// converter is deliberately dependency-free (regex passes + stdlib entity
// unescaping, no HTML parser): the output feeds a language model, not a
// browser, so best-effort structure (headings, links, list markers) is enough,
// and the format=raw escape hatch covers pages it mangles.
var (
webFetchDropBlockRe = regexp.MustCompile(`(?is)<(script|style|head|noscript|svg|template|iframe)\b[^>]*>.*?</\s*(?:script|style|head|noscript|svg|template|iframe)\s*>`)
webFetchCommentRe = regexp.MustCompile(`(?s)<!--.*?-->`)
webFetchAnchorRe = regexp.MustCompile(`(?is)<a\b[^>]*?href\s*=\s*["']?([^"'\s>]+)["']?[^>]*>(.*?)</\s*a\s*>`)
webFetchHeadingRe = regexp.MustCompile(`(?is)<h([1-6])[^>]*>(.*?)</\s*h[1-6]\s*>`)
webFetchListItemRe = regexp.MustCompile(`(?i)<li\b[^>]*>`)
webFetchBreakRe = regexp.MustCompile(`(?i)<(?:br|hr)\s*/?>`)
webFetchBlockTagRe = regexp.MustCompile(`(?i)</?(?:p|div|section|article|main|header|footer|nav|aside|table|tr|ul|ol|blockquote|pre|figure|form)\b[^>]*>`)
webFetchAnyTagRe = regexp.MustCompile(`(?s)<[^>]*>`)
webFetchSpaceRunRe = regexp.MustCompile(`[ \t]+`)
webFetchBlankRunRe = regexp.MustCompile(`\n{3,}`)
)
// htmlToMarkdown converts an HTML document to compact markdown-flavored text:
// headings become #-prefixed lines, anchors become [text](href), list items
// become "- " bullets, block boundaries become blank lines, all other markup
// is stripped, and entities are unescaped.
func htmlToMarkdown(body string) string {
text := webFetchCommentRe.ReplaceAllString(body, " ")
text = webFetchDropBlockRe.ReplaceAllString(text, " ")
text = webFetchHeadingRe.ReplaceAllStringFunc(text, func(match string) string {
groups := webFetchHeadingRe.FindStringSubmatch(match)
level := int(groups[1][0] - '0')
if level < 1 || level > 6 {
level = 1
}
title := strings.TrimSpace(webFetchAnyTagRe.ReplaceAllString(groups[2], " "))
return "\n\n" + strings.Repeat("#", level) + " " + title + "\n\n"
})
text = webFetchAnchorRe.ReplaceAllStringFunc(text, func(match string) string {
groups := webFetchAnchorRe.FindStringSubmatch(match)
href := groups[1]
label := strings.TrimSpace(webFetchAnyTagRe.ReplaceAllString(groups[2], " "))
if label == "" {
return " "
}
if strings.HasPrefix(href, "#") || strings.HasPrefix(strings.ToLower(href), "javascript:") {
return label
}
return fmt.Sprintf("[%s](%s)", label, href)
})
text = webFetchListItemRe.ReplaceAllString(text, "\n- ")
text = webFetchBreakRe.ReplaceAllString(text, "\n")
text = webFetchBlockTagRe.ReplaceAllString(text, "\n\n")
text = webFetchAnyTagRe.ReplaceAllString(text, " ")
text = html.UnescapeString(text)
// Whitespace normalization: collapse space runs, trim line edges, cap blank
// runs at one empty line.
lines := strings.Split(text, "\n")
for i, line := range lines {
lines[i] = strings.TrimSpace(webFetchSpaceRunRe.ReplaceAllString(line, " "))
}
text = strings.Join(lines, "\n")
text = webFetchBlankRunRe.ReplaceAllString(text, "\n\n")
return strings.TrimSpace(text)
}
// looksLikeHTML reports whether a response should be treated as an HTML
// document, by Content-Type first and a body sniff as fallback (servers
// mislabel HTML as text/plain often enough to matter).
func looksLikeHTML(contentType, body string) bool {
lowered := strings.ToLower(contentType)
if strings.Contains(lowered, "text/html") || strings.Contains(lowered, "application/xhtml") {
return true
}
if lowered != "" && !strings.Contains(lowered, "text/plain") {
return false
}
head := strings.ToLower(strings.TrimSpace(body))
if len(head) > 512 {
head = head[:512]
}
return strings.Contains(head, "<!doctype html") || strings.Contains(head, "<html")
}