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:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user