Files
AutoZero/cdp/tool.go
T
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

333 lines
10 KiB
Go
Raw 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 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 ""
}