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。
431 lines
14 KiB
Go
431 lines
14 KiB
Go
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")
|
||
}
|