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
+332
@@ -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 ""
|
||||
}
|
||||
Reference in new issue
Block a user