feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)

llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、
builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、
编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。

产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt)
留在 AutoGo,宿主接入方式见 MIGRATION.md。
This commit is contained in:
Sakurasan committed 2026-10-26 00:54:46 +08:00
commit d5dac5a6fc
140 files changed
+26497

No files matched your search

+430
View File
@@ -0,0 +1,430 @@
package tools
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/net/html"
"git.gopher.ink/mirrors2/AutoZero/execx"
)
// websearch 工具:关键词 → 一组带标题/链接/摘要的搜索结果。
//
// 为什么必须有:一次真实的「B 站投币视频下载工具」任务里,模型没有检索能力,
// 只能凭记忆猜接口,于是连着吃了十几次 `code:-400`、手写 wbi 签名、又试图
// clone 文档仓库(撞 120 秒超时挂死),烧掉约 20 步。同样的问题在别家有检索
// 工具的运行时里是**三次搜索调用**就定位到 `bilibili-API-collect` 仓库。
// 这就是 G1:工具箱里缺那把扳手,模型就绕行。
//
// 后端选择:Brave 的搜索结果页,**无需 API key**。本项目已有
// `github.com/imroc/req/v3` 的指纹仿真与 `golang.org/x/net/html`(都在依赖里)。
//
// 实测的诚实结论(2026-10-09,本机):解析没问题——真实页面上稳定拿到
// `SocialSisterYi/bilibili-API-collect` 作为第 1 条。但公开搜索页对程序化请求
// 会**间歇 429**:同一分钟内 curl 走 HTTP/2 稳定 200,而 Go 客户端在
// chrome / 无仿真 / firefox / safari 四种画像下时通时不通,与画像无关,是
// 按来源的累计限流。所以这个后端不能当"永远在"的依赖:限流时给出的建议是
// 换 browser 工具(真浏览器指纹)或 fetch 已知 URL,而不是让模型继续打。
// 若要稳定,正解是接一个有 key 的搜索 API(Brave Search API / Tavily / Exa)——
// 那属于配置项,不在本次范围。
const (
searchTimeout = 25 * time.Second
searchMaxResult = 8
searchSnippetBytes = 220 // 每条摘要的字节上限(按字符边界截)
// 后端很慷慨地回 429,所以:全局最小间隔 + 有限次退避重试。
searchMinInterval = 2 * time.Second
searchRetryCap = 30 * time.Second
searchAttempts = 3 // 首次 + 两次退避;再多就是替后端扛流量,且一次调用拖几十秒
)
// searchBackoff 是两次重试之间的等待(受 Retry-After 下限约束)。
var searchBackoff = []time.Duration{3 * time.Second, 8 * time.Second}
// searchEngines 是可试的后端;顺序即优先级。留成切片是为了 Brave 失效时
// 加第二个后端(各家的解析规则不同,加之前先实测确认能通)。
var searchEngines = []struct {
name string
build func(q string) string
lookup func(*html.Node) []searchResult
}{
{"brave", braveSearchURL, extractBrave},
}
// searchResult 是一条搜索结果。
type searchResult struct {
Title string
URL string
Snippet string
}
// WebSearch 返回 websearch 工具。与 fetch 同为网络类:审批档位一致,
// 一次批准后按工具名缓存(会话内不再打扰)。
func WebSearch() *Tool {
return &Tool{
Name: "websearch",
Description: "Search the web by keywords and get back titles + URLs + snippets — use it BEFORE guessing: " +
"library/API names, error messages, doc locations. Cheap (one HTTP round trip) and the usual way to " +
"find the authoritative repo/doc, which fetch/bash-curl cannot do (they need a URL you already know). " +
"Then read the promising link with the fetch tool.",
SideEffect: true,
Kind: KindNetwork,
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string",
"description": "search keywords; put the exact error string or API name in quotes"},
"count": map[string]any{"type": "integer",
"description": fmt.Sprintf("results to return, default %d", searchMaxResult)},
},
"required": []string{"query"},
},
Run: func(ctx context.Context, input string) (Result, error) {
m, err := arg(input)
if err != nil {
return Result{}, err
}
q := strings.TrimSpace(str(m, "query"))
if q == "" {
return Result{}, fmt.Errorf("query is empty")
}
limit := searchMaxResult
if v, ok := m["count"].(float64); ok && int(v) > 0 && int(v) <= searchMaxResult {
limit = int(v)
}
rctx, cancel := context.WithTimeout(ctx, searchTimeout)
defer cancel()
var tried []string
for _, eng := range searchEngines {
body, err := searchHTTPGet(rctx, eng.build(q))
if err != nil {
// 把后端真实状态带上:429/403(被限流)与"版面不认"是两种
// 完全不同的故障,混成一句会让模型做出错误的重试决定。
tried = append(tried, eng.name+": "+err.Error())
continue
}
root, perr := html.Parse(strings.NewReader(body))
if perr != nil {
tried = append(tried, eng.name+": 页面解析失败 "+perr.Error())
continue
}
if rs := dedupe(eng.lookup(root)); len(rs) > 0 {
return TextResult(renderResults(q, rs, limit)), nil
}
tried = append(tried, fmt.Sprintf("%s: 响应 %d 字节但没解析出结果(版面可能改了)",
eng.name, len(body)))
}
// 两种故障给两种建议:限流是"换条路,别再打",版面不认是"重试一次再换工具"。
joined := strings.Join(tried, "; ")
if strings.Contains(joined, "rate limited") {
return Result{}, fmt.Errorf("web search rate limited by the engine (%s) — retrying now makes it worse. "+
"Options: use the browser tool on https://search.brave.com/search?q=… (real browser fingerprint), "+
"fetch a URL you already know, or answer from what you have", joined)
}
return Result{}, fmt.Errorf("web search failed — %s; "+
"the engine markup may have changed: retry once with different keywords, then use the fetch tool", joined)
},
}
}
// ---- HTTP ----
// searchHTTPGet 复用 fetch 的客户端:同一套浏览器指纹仿真与代理配置,
// 不另开一份连接池。
//
// 两次实时试验之间就被 Brave 打过 429,所以这里必须有:
// - 全局最小间隔(并发批次里模型可能一口气发好几条搜索,自己把自己打爆);
// - 429/503 的有限退避重试(尊重 Retry-After)。
//
// 仍然失败时把真实状态交给上层——"限流"和"版面不认"要给出不同的建议。
func searchHTTPGet(ctx context.Context, u string) (string, error) {
waitSearchSlot(ctx)
var (
lastErr error
retryAfter time.Duration
)
for attempt := 0; attempt < searchAttempts; attempt++ {
if attempt > 0 {
d := searchBackoff[attempt-1]
if retryAfter > d {
d = retryAfter
}
select {
case <-time.After(d):
case <-ctx.Done():
return "", ctx.Err()
}
}
waitSearchSlot(ctx)
resp, err := fetchHTTPClient().R().SetContext(ctx).Get(u)
if err != nil {
lastErr = err
continue
}
switch {
case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable:
retryAfter = retryAfterDelay(resp.Header.Get("Retry-After"))
lastErr = fmt.Errorf("HTTP %d (rate limited)", resp.StatusCode)
continue
case resp.StatusCode >= 400:
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
return string(resp.Bytes()), nil
}
return "", lastErr
}
// retryAfterDelay 解析 Retry-After 的秒数形式;解析失败或非正值返回 0。
// 上限 searchRetryCap:头部写"一小时"这种,不该让工具挂着不走。
func retryAfterDelay(v string) time.Duration {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || n <= 0 {
return 0
}
if d := time.Duration(n) * time.Second; d > searchRetryCap {
return searchRetryCap
} else {
return d
}
}
// searchMinInterval 是全局限流闸:两次搜索请求之间至少隔这么多。
// 用 Mutex 跨睡眠持有——并发批次里的多条搜索自然被串行化。
var searchGate = struct {
sync.Mutex
last time.Time
}{}
func waitSearchSlot(ctx context.Context) {
searchGate.Lock()
defer searchGate.Unlock()
if d := time.Since(searchGate.last); d < searchMinInterval {
select {
case <-time.After(searchMinInterval - d):
case <-ctx.Done():
return
}
}
searchGate.last = time.Now()
}
// ---- Brave 版面解析 ----
func braveSearchURL(q string) string {
return "https://search.brave.com/search?q=" + url.QueryEscape(q) + "&source=web"
}
// braveSkipHosts 是导航/图片代理一类不该出现在结果里的域名。
var braveSkipHosts = []string{"search.brave.com", "imgs.search.brave.com", "brave.com"}
// extractBrave 取所有 class 含 "snippet" 的结果块。
//
// Brave 的 class 带 svelte 哈希(`snippet svelte-jmfu5f`),所以按**词**匹配而不是
// 全字符串比较。找不到时返回空,由调用方决定怎么交代。
func extractBrave(root *html.Node) []searchResult {
var out []searchResult
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && hasClass(n, "snippet") {
// 只认真正的结果块;视频/图片等其它 data-type 不是我们要的
if dt := attr(n, "data-type"); dt != "" && dt != "web" {
return
}
if r, ok := parseBraveSnippet(n); ok {
out = append(out, r)
return // 结果块内部不再递归(避免嵌套块重复计数)
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(root)
return out
}
// parseBraveSnippet 从一个结果块里抽 URL / 标题 / 摘要。
func parseBraveSnippet(n *html.Node) (searchResult, bool) {
var r searchResult
a := firstAnchor(n)
if a != nil {
r.URL = absURL(attr(a, "href"))
// 锚点整棵子树还包含站点名与面包屑("GitHub github.com › x › y"),
// 直接 textOf(a) 会把它们混进标题;优先取 .title。
if t := firstByClass(a, "title"); t != nil {
r.Title = textOf(t)
} else {
r.Title = textOf(a)
}
}
if r.URL == "" || !httpURL(r.URL) || hostIn(r.URL, braveSkipHosts) {
return r, false
}
if r.Title == "" {
r.Title = r.URL
}
// 摘要:Brave 两种版面都见过(generic-snippet 与 snippet-description),都认。
// 认不出就留空——**不要**退回整块文本,那会把站点名、面包屑、URL 一起吐给模型。
for _, class := range []string{"generic-snippet", "snippet-description"} {
if d := firstByClass(n, class); d != nil {
r.Snippet = textOf(d)
break
}
}
return r, true
}
// firstAnchor 返回块里第一个 http(s) 链接(Brave 把图标包在 <a> 里,需要跳过空 href)。
func firstAnchor(n *html.Node) *html.Node {
var found *html.Node
var walk func(*html.Node)
walk = func(x *html.Node) {
if found != nil {
return
}
if x.Type == html.ElementNode && x.Data == "a" {
if h := absURL(attr(x, "href")); httpURL(h) {
found = x
return
}
}
for c := x.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(n)
return found
}
// firstByClass 按 class 词查找第一个元素(含自身)。
func firstByClass(n *html.Node, class string) *html.Node {
if n.Type == html.ElementNode {
if hasClass(n, class) {
return n
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if got := firstByClass(c, class); got != nil {
return got
}
}
return nil
}
// ---- DOM 小工具 ----
func attr(n *html.Node, key string) string {
for _, a := range n.Attr {
if a.Key == key {
return a.Val
}
}
return ""
}
// hasClass 判断 class 属性里是否有某个**完整词**(svelte 哈希不会误命中)。
func hasClass(n *html.Node, class string) bool {
for _, w := range strings.Fields(attr(n, "class")) {
if w == class {
return true
}
}
return false
}
// textOf 收集子树可见文本(跳过 script/style,空白压缩)。
func textOf(n *html.Node) string {
var b strings.Builder
var walk func(*html.Node)
walk = func(x *html.Node) {
if x.Type == html.ElementNode && fetchSkipElems[strings.ToLower(x.Data)] {
return
}
if x.Type == html.TextNode {
b.WriteString(x.Data)
b.WriteString(" ")
}
for c := x.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(n)
return strings.TrimSpace(fetchSpaceRe.ReplaceAllString(b.String(), " "))
}
// ---- 输出 ----
// absURL 把结果里可能出现的相对链接补成绝对链接。
func absURL(h string) string {
h = strings.TrimSpace(h)
if h == "" || strings.HasPrefix(h, "//") {
if h != "" {
return "https:" + h
}
return h
}
// 带跟踪跳转的 brave 链接(/search?...)不是我们要的结果
if strings.HasPrefix(h, "/") {
return "https://search.brave.com" + h
}
return h
}
func httpURL(u string) bool {
return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://")
}
func hostIn(u string, hosts []string) bool {
pu, err := url.Parse(u)
if err != nil {
return false
}
for _, h := range hosts {
if pu.Host == h || strings.HasSuffix(pu.Host, "."+h) {
return true
}
}
return false
}
// dedupe 按 URL 去重(Brave 同一结果常出现两次:图标 + 标题)。
func dedupe(rs []searchResult) []searchResult {
seen := map[string]bool{}
out := make([]searchResult, 0, len(rs))
for _, r := range rs {
key := strings.TrimSuffix(strings.TrimPrefix(r.URL, "https://"), "/")
if key == "" || seen[key] {
continue
}
seen[key] = true
out = append(out, r)
}
return out
}
// renderResults 排版给模型看:编号 + 标题 + URL + 摘要。
func renderResults(q string, rs []searchResult, limit int) string {
var b strings.Builder
fmt.Fprintf(&b, "web search: %q (%d results)\n", q, min(limit, len(rs)))
for i, r := range rs {
if i >= limit {
break
}
snip, cut := execx.Truncate([]byte(r.Snippet), searchSnippetBytes)
if cut {
snip = append(snip, "…"...)
}
fmt.Fprintf(&b, "\n%d. %s\n %s\n %s\n", i+1, r.Title, r.URL, snip)
}
b.WriteString("\nnext: pick the most authoritative link and read it with fetch")
return strings.TrimRight(b.String(), "\n")
}