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

+332
View File
@@ -0,0 +1,332 @@
package cdp
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
"git.gopher.ink/mirrors2/AutoZero/llm"
"git.gopher.ink/mirrors2/AutoZero/textx"
"git.gopher.ink/mirrors2/AutoZero/tools"
)
// browser 工具:经 Chrome DevTools Protocol 跨平台驱动浏览器
// (替代旧 macOS-only 的 AppleScript 方案)。截图走 Page.captureScreenshot,
// 不依赖屏幕录制权限;js 执行走 Runtime.evaluate,无需任何浏览器开关。
const (
navigateTimeout = 15 * time.Second
evaluateTimeout = 30 * time.Second
maxJSOutput = 16000
)
var (
browserMu sync.Mutex
browserShared *Browser
defaultsMu sync.Mutex
defaultsSet bool
defaultsVal *Browser
)
// SetBrowserDefaults 由 main 在配置装载后调用(env 已并入 config);
// 未调用时 sharedBrowser 回退为直接读环境变量(库独立使用场景)。
func SetBrowserDefaults(bin string, headless bool, profile, debugBase string) {
defaultsMu.Lock()
defer defaultsMu.Unlock()
defaultsSet = true
defaultsVal = &Browser{Bin: bin, Headless: headless, ProfileDir: profile, DebugBase: debugBase}
}
func sharedBrowser() *Browser {
browserMu.Lock()
defer browserMu.Unlock()
if browserShared == nil {
defaultsMu.Lock()
if defaultsSet {
browserShared = defaultsVal
} else {
browserShared = &Browser{
Bin: os.Getenv("AUTOGO_BROWSER_BIN"),
Headless: os.Getenv("AUTOGO_BROWSER_HEADLESS") != "0",
ProfileDir: os.Getenv("AUTOGO_BROWSER_PROFILE"),
DebugBase: os.Getenv("AUTOGO_BROWSER_DEBUG_BASE"),
}
}
defaultsMu.Unlock()
}
return browserShared
}
// CloseBrowser 终止托管的浏览器进程(main 退出前调用)。
func CloseBrowser() {
browserMu.Lock()
defer browserMu.Unlock()
if browserShared != nil {
browserShared.Close()
}
}
// BrowserTool 返回跨平台 browser 工具。
func BrowserTool() *tools.Tool {
return browserToolWith(sharedBrowser())
}
func browserToolWith(br *Browser) *tools.Tool {
return &tools.Tool{
Name: "browser",
Description: "Control a browser via Chrome DevTools Protocol (cross-platform; spawns a headless Chrome by default, set AUTOGO_BROWSER_HEADLESS=0 to show a window). " +
"Actions: open (navigate the current tab to url), tabs (list open tabs), js (execute JavaScript in the current tab and return the result; async/await supported), screenshot (capture the current tab, returned as an image you can see). " +
"Prefer `js` to read and verify page state (fast, returns values like document.title or video.paused); use `open`/`js` to navigate and act. Use `screenshot` only to deliver a visual to the user, not for routine verification. " +
"AUTOGO_BROWSER_BIN overrides the browser binary (Chrome/Chromium/Edge).",
SideEffect: true,
Kind: tools.KindNetwork, // 访问外部网络
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{"type": "string", "enum": []string{"open", "tabs", "js", "screenshot"},
"description": "open: navigate to url; tabs: list tabs; js: run javascript in current tab; screenshot: capture current tab"},
"url": map[string]any{"type": "string", "description": "URL to open (action=open)"},
"javascript": map[string]any{"type": "string",
"description": "JavaScript to execute, should end with an expression or return value, e.g. document.title or JSON.stringify(document.body.innerText.slice(0,5000)) (action=js)"},
},
"required": []string{"action"},
},
Run: func(ctx context.Context, input string) (tools.Result, error) {
m, err := arg(input)
if err != nil {
return tools.Result{}, err
}
switch action := str(m, "action"); action {
case "open":
u := str(m, "url")
if strings.TrimSpace(u) == "" {
return tools.Result{}, fmt.Errorf("action open requires url")
}
return br.open(ctx, u)
case "tabs":
return br.tabs(ctx)
case "js":
js := str(m, "javascript")
if strings.TrimSpace(js) == "" {
return tools.Result{}, fmt.Errorf("action js requires javascript")
}
return br.js(ctx, js)
case "screenshot":
return br.screenshot(ctx)
default:
return tools.Result{}, fmt.Errorf("unknown action %q", action)
}
},
}
}
// resolveTarget 选定当前作用 tab:优先记住的 current(已关闭则回退),
// 其次第一个 page target;都没有则新开 about:blank。
func (b *Browser) resolveTarget(ctx context.Context) (*Target, error) {
targets, err := b.ListTargets(ctx)
if err != nil {
return nil, err
}
var pages []Target
for _, t := range targets {
if t.Type == "page" {
pages = append(pages, t)
}
}
for i := range pages {
if pages[i].ID == b.current {
return &pages[i], nil
}
}
if len(pages) > 0 {
b.current = pages[0].ID
return &pages[0], nil
}
t, err := b.NewTab(ctx, "about:blank")
if err != nil {
return nil, err
}
b.current = t.ID
return t, nil
}
// withTargetConn 解析目标 tab 并建立临时 CDP 连接(动作结束即关闭)。
func (b *Browser) withTargetConn(ctx context.Context, fn func(c *Conn) (tools.Result, error)) (tools.Result, error) {
b.actionMu.Lock()
defer b.actionMu.Unlock()
t, err := b.resolveTarget(ctx)
if err != nil {
return tools.Result{}, err
}
conn, err := Dial(ctx, t.WebSocketDebuggerURL)
if err != nil {
return tools.Result{}, err
}
defer conn.Close()
return fn(conn)
}
func (b *Browser) open(ctx context.Context, u string) (tools.Result, error) {
return b.withTargetConn(ctx, func(c *Conn) (tools.Result, error) {
if _, err := c.Call(ctx, "Page.enable", nil); err != nil {
return tools.Result{}, err
}
loaded := c.Subscribe("Page.loadEventFired")
if _, err := c.Call(ctx, "Page.navigate", map[string]any{"url": u}); err != nil {
return tools.Result{}, err
}
note := ""
if _, err := loaded.Wait(ctx, navigateTimeout); err != nil {
note = " (load event timeout, page may still be loading)"
}
title, _ := evaluateString(ctx, c, "document.title")
return tools.TextResult("opened " + u + " — title: " + title + note), nil
})
}
func (b *Browser) tabs(ctx context.Context) (tools.Result, error) {
targets, err := b.ListTargets(ctx)
if err != nil {
return tools.Result{}, err
}
var lines []string
for _, t := range targets {
if t.Type != "page" {
continue
}
lines = append(lines, fmt.Sprintf("- %s | %s | %s", t.ID, t.Title, t.URL))
}
if len(lines) == 0 {
return tools.TextResult("no open tabs"), nil
}
return tools.TextResult(strings.Join(lines, "\n")), nil
}
func (b *Browser) js(ctx context.Context, code string) (tools.Result, error) {
return b.withTargetConn(ctx, func(c *Conn) (tools.Result, error) {
out, err := evaluateString(ctx, c, jsWrap(code))
if err != nil {
return tools.Result{}, err
}
if len(out) > maxJSOutput {
// 页面文本几乎必然含中文:按字节切会在结果尾部留下半个字,
// 那既污染 transcript(模型看到乱码),也会让会话 JSONL 里出现非法序列。
out = textx.ClipBytes(out, maxJSOutput) + "...[truncated]"
}
return tools.TextResult(out), nil
})
}
func (b *Browser) screenshot(ctx context.Context) (tools.Result, error) {
return b.withTargetConn(ctx, func(c *Conn) (tools.Result, error) {
raw, err := c.Call(ctx, "Page.captureScreenshot", map[string]any{"format": "png"})
if err != nil {
return tools.Result{}, err
}
var res struct {
Data string `json:"data"`
}
if err := json.Unmarshal(raw, &res); err != nil || res.Data == "" {
return tools.Result{}, fmt.Errorf("captureScreenshot: bad result")
}
png, err := base64.StdEncoding.DecodeString(res.Data)
if err != nil {
return tools.Result{}, err
}
path := fmt.Sprintf("%s/autogo-page-%d.png", os.TempDir(), time.Now().UnixNano())
if err := os.WriteFile(path, png, 0o644); err != nil {
return tools.Result{}, err
}
if p, serr := tools.ShrinkForVision(path); serr == nil {
path = p
}
title, _ := evaluateString(ctx, c, "document.location.href")
return tools.Result{Text: "page screenshot: " + title, Images: []llm.Image{{Path: path}}}, nil
})
}
// evaluateString 执行 JS 并把结果转为字符串(错误详情带回)。
func evaluateString(ctx context.Context, c *Conn, expression string) (string, error) {
raw, err := c.Call(ctx, "Runtime.evaluate", map[string]any{
"expression": expression,
"returnByValue": true,
"awaitPromise": true,
"userGesture": true,
})
if err != nil {
return "", err
}
var res struct {
Result struct {
Type string `json:"type"`
Value any `json:"value"`
} `json:"result"`
ExceptionDetails *struct {
Text string `json:"text"`
Exception struct {
Description string `json:"description"`
} `json:"exception"`
} `json:"exceptionDetails"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return "", err
}
if res.ExceptionDetails != nil {
detail := res.ExceptionDetails.Exception.Description
if detail == "" {
detail = res.ExceptionDetails.Text
}
return "", fmt.Errorf("js error: %s", detail)
}
switch v := res.Result.Value.(type) {
case nil:
return "", nil
case string:
return v, nil
default:
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprint(v), nil
}
return string(b), nil
}
}
// jsWrap 包一层 async + try/catch:支持 await,保证返回字符串。
func jsWrap(js string) string {
return fmt.Sprintf(
`(async()=>{try{const r=await eval(%q);return typeof r==="string"?r:JSON.stringify(r)}catch(e){return "JS_ERROR: "+e.message}})()`,
js,
)
}
// arg 解析工具输入 JSON(与 tools 包内同名逻辑一致)。
// 兼容模型可能生成的 {"arguments": {...}} 包装格式。
func arg(input string) (map[string]any, error) {
m := map[string]any{}
if input == "" {
return m, nil
}
if err := json.Unmarshal([]byte(input), &m); err != nil {
return nil, fmt.Errorf("invalid tool arguments: %w", err)
}
// 兼容 {"arguments": {...}} 包装:提取内层对象
if args, ok := m["arguments"]; ok {
if argsMap, ok := args.(map[string]any); ok {
return argsMap, nil
}
}
return m, nil
}
func str(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}