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

+301
View File
@@ -0,0 +1,301 @@
package cdp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
neturl "net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
// Browser 管理一个 Chrome 实例(懒启动、进程内单例语义由 tool 层保证):
// 首次使用时以 --remote-debugging-port 启动并轮询就绪;DebugBase 非空则
// 直接连接已有调试端点(测试 / 外部浏览器),不启动进程。
type Browser struct {
DebugBase string // 直接使用的调试基址(http://127.0.0.1:9222)
Bin string // 浏览器可执行文件覆盖;空 = 自动发现
Headless bool // 默认 true;AUTOGO_BROWSER_HEADLESS=0 关闭
ProfileDir string // 用户数据目录;空 = ~/.autogo/browser-profile(持久,登录态留得住)
mu sync.Mutex
cmd *exec.Cmd
base string
current string // 当前 page target id(open/js/screenshot 的作用目标)
actionMu sync.Mutex
}
// Target 是调试端点上的一个 tab(page)。
type Target struct {
ID string `json:"id"`
Title string `json:"title"`
URL string `json:"url"`
Type string `json:"type"`
WebSocketDebuggerURL string `json:"webSocketDebuggerURL"`
}
func (b *Browser) baseURL(ctx context.Context) (string, error) {
if b.DebugBase != "" {
return strings.TrimSuffix(b.DebugBase, "/"), nil
}
b.mu.Lock()
defer b.mu.Unlock()
if b.base != "" {
return b.base, nil
}
bin, err := findBrowser(b.Bin)
if err != nil {
return "", err
}
port, err := freePort()
if err != nil {
return "", err
}
dir := b.ProfileDir
if dir == "" {
// 默认用**持久**目录,不用临时目录:Chrome 的登录态与扩展都在 user-data-dir 里,
// 临时目录等于每次启动都像一台从没登录过的新机器(登录一次也留不住),而且旧的
// 临时目录从来没人清理,会在 /tmp 里越堆越多。
dir, err = PersistentProfileDir()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
}
// profile 体检结论不在这里直接打印(TUI 的 alt-screen 打不得),而是挂到启动
// 失败的 error 上——那正是用户需要看到它们的时候(见下面 waitDebugReady 分支)。
profileWarns := CheckProfile(dir)
args := []string{
fmt.Sprintf("--remote-debugging-port=%d", port),
"--user-data-dir=" + dir,
"--no-first-run", "--no-default-browser-check",
"--disable-background-networking",
"--window-size=1366,900",
"about:blank",
}
if b.Headless {
args = append([]string{"--headless=new", "--hide-scrollbars"}, args...)
}
cmd := exec.Command(bin, args...)
cmd.Stdout, cmd.Stderr = io.Discard, io.Discard
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("launch %s: %w", bin, err)
}
base := fmt.Sprintf("http://127.0.0.1:%d", port)
if err := waitDebugReady(ctx, base, 10*time.Second); err != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
// 起不来时把 profile 和体检结论一起给出:绝大多数"浏览器连不上"都是 profile
// 相关(被运行中的 Chrome 占着 / 指向了默认目录 / 指向了 Default 子目录)。
msg := fmt.Sprintf("浏览器调试端口没起来(%s,profile=%s):%v", bin, dir, err)
for _, w := range profileWarns {
msg += "\n - " + w
}
if len(profileWarns) == 0 {
msg += "\n - profile 看起来正常;可以先手动跑一下该浏览器确认能启动,或用 browser.debug_base 连接一个已开的调试端口"
}
return "", errors.New(msg)
}
b.cmd, b.base = cmd, base
return base, nil
}
// waitDebugReady 轮询 /json/version 直到调试端点就绪。
func waitDebugReady(ctx context.Context, base string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
client := &http.Client{Timeout: 2 * time.Second}
for time.Now().Before(deadline) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/version", nil)
if req != nil {
if resp, err := client.Do(req); err == nil {
resp.Body.Close()
return nil
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
return fmt.Errorf("debug endpoint %s not ready within %s", base, timeout)
}
func freePort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
port := l.Addr().(*net.TCPAddr).Port
l.Close()
return port, nil
}
// findBrowser 按 GOOS 定位浏览器可执行文件(Chrome / Chromium / Edge 均支持 CDP)。
func findBrowser(explicit string) (string, error) {
if explicit != "" {
if _, err := os.Stat(explicit); err == nil {
return explicit, nil
}
if _, err := exec.LookPath(explicit); err == nil {
return explicit, nil
}
return "", fmt.Errorf("AUTOGO_BROWSER_BIN %q not found", explicit)
}
var candidates []string
switch runtime.GOOS {
case "darwin":
candidates = []string{
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
}
case "windows":
pf := os.Getenv("ProgramFiles")
pf86 := os.Getenv("ProgramFiles(x86)")
lap := os.Getenv("LOCALAPPDATA")
// ARM64 Windows 可能安装在 Program Files (Arm) 或 x86 目录
for _, root := range []string{pf, pf86, lap} {
if root == "" {
continue
}
candidates = append(candidates,
filepath.Join(root, `Google\Chrome\Application\chrome.exe`),
filepath.Join(root, `Microsoft\Edge\Application\msedge.exe`),
)
}
// Chrome SxS (Canary) 和 Edge Beta 也常见于 ARM64
if lap != "" {
candidates = append(candidates,
filepath.Join(lap, `Google\Chrome SxS\Application\chrome.exe`),
)
}
default: // linux 等 unix
// 标准 PATH 查找
for _, name := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "microsoft-edge-stable"} {
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
}
// Flatpak 路径
flatpakPaths := []string{
"/var/lib/flatpak/exports/bin/com.google.Chrome",
"/var/lib/flatpak/exports/bin/org.chromium.Chromium",
"/home/*/.local/share/flatpak/exports/bin/com.google.Chrome",
"/home/*/.local/share/flatpak/exports/bin/org.chromium.Chromium",
}
for _, p := range flatpakPaths {
if strings.Contains(p, "*") {
// 展开 home 目录通配符
home, _ := os.UserHomeDir()
if home != "" {
p = strings.Replace(p, "/home/*", home, 1)
}
}
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
// Snap 路径
snapPaths := []string{
"/snap/chromium/current/usr/bin/chromium",
"/snap/google-chrome/current/usr/bin/google-chrome",
}
for _, p := range snapPaths {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
}
for _, p := range candidates {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
for _, name := range []string{"google-chrome", "chromium", "chrome"} {
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
}
return "", fmt.Errorf("no Chrome/Chromium/Edge found (set AUTOGO_BROWSER_BIN; candidates tried: %v)", candidates)
}
// ListTargets 列出调试端点上的全部 target。
func (b *Browser) ListTargets(ctx context.Context) ([]Target, error) {
base, err := b.baseURL(ctx)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/json/list", nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var targets []Target
if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil {
return nil, err
}
return targets, nil
}
// NewTab 新开一个 page 并导航到 url。
func (b *Browser) NewTab(ctx context.Context, url string) (*Target, error) {
base, err := b.baseURL(ctx)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 10 * time.Second}
do := func(method string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, base+"/json/new?url="+neturl.QueryEscape(url), nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
resp, err := do(http.MethodPut)
if err == nil && resp.StatusCode == http.StatusMethodNotAllowed {
resp.Body.Close()
resp, err = do(http.MethodGet) // 老版本 Chrome 只支持 GET /json/new
}
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("/json/new: HTTP %d", resp.StatusCode)
}
var t Target
if err := json.NewDecoder(resp.Body).Decode(&t); err != nil {
return nil, err
}
return &t, nil
}
// Close 终止托管的 Chrome 进程(DebugBase 注入模式为空操作)。
func (b *Browser) Close() {
b.mu.Lock()
cmd := b.cmd
b.cmd, b.base = nil, ""
b.mu.Unlock()
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
}