Files
Sakurasan d5dac5a6fc 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。
2026-10-26 00:54:46 +08:00

431 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}