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

+66
View File
@@ -0,0 +1,66 @@
// Package diag 提供进程级的诊断输出去向。
//
// 为什么需要它:TUI 跑在 alt-screen 下,**任何**直接写终端的行为都会打穿界面——
// 项目里已有这条约定(见 tui.Run 的注释:"服务日志写文件而非 stderr:alt-screen 下
// 任何直接写终端的行为都会打穿界面")。但工具层与协议客户端没有 agent 的 Opt.Stderr
// 可用,直接 fmt.Fprintf(os.Stderr, ...) 就会打穿。
//
// 所以:库层写诊断信息一律经过这里;入口(TUI)在进入全屏前把去向换成日志文件。
// 默认仍是 os.Stderr——CLI / headless / -serve 下本来就该直接看到。
//
// 注意区分两类东西:
// - **我们自己的**诊断 → diag.Printf
// - **子进程的** stderr → diag.Indirect()(子进程的 stderr 只能在启动时给一次,
// 而那时入口还没拿到日志文件,所以要把"解析去向"推迟到每次写入)
package diag
import (
"fmt"
"io"
"os"
"sync"
)
var (
mu sync.Mutex
w io.Writer = os.Stderr
)
// SetWriter 替换诊断去向(传 nil 表示丢弃)。TUI 应在进入 alt-screen 前调用。
func SetWriter(nw io.Writer) {
if nw == nil {
nw = io.Discard
}
mu.Lock()
w = nw
mu.Unlock()
}
// Writer 返回当前去向。
func Writer() io.Writer {
mu.Lock()
defer mu.Unlock()
return w
}
// Printf 按当前去向写一条诊断。
func Printf(format string, args ...any) {
_, _ = fmt.Fprintf(Writer(), format, args...)
}
// Println 按当前去向写一行诊断。
func Println(args ...any) {
_, _ = fmt.Fprintln(Writer(), args...)
}
// Indirect 返回一个"写入时才解析去向"的 writer,给子进程的 cmd.Stderr 用。
//
// 不能直接传 Writer():子进程的 stderr 只在启动时赋值一次,而启动那一刻入口还没
// 拿到日志文件(TUI 是在启动服务之前才建日志文件的),传值等于把 os.Stderr 钉死。
// Indirect 把解析推迟到每次 Write;exec 见到非 *os.File 的 writer 会自建管道并拷贝,
// 于是入口随后的重定向对已启动的子进程同样生效。
func Indirect() io.Writer { return indirect{} }
type indirect struct{}
func (indirect) Write(p []byte) (int, error) { return Writer().Write(p) }
+66
View File
@@ -0,0 +1,66 @@
package diag
import (
"bytes"
"strings"
"testing"
)
// TestIndirectResolvesAtWriteTime:**关键性质**——Indirect 在写入时才去向,
// 所以在它被交给子进程之后、再 SetWriter 重定向,后续写入仍会走新去向。
//
// 这正是 MCP 子进程 stderr 的场景:子进程在 buildRuntime 里启动(那时 TUI 还没建
// 日志文件),日志文件的绑定必须晚一步生效,否则就钉死在 os.Stderr 上、打穿 alt-screen。
func TestIndirectResolvesAtWriteTime(t *testing.T) {
before := Writer()
defer SetWriter(before) // 别污染其他测试
var early, late bytes.Buffer
if _, err := Indirect().Write([]byte("early\n")); err != nil {
t.Fatal(err)
}
// 默认去向是 os.Stderr,这里只验证"能写不报错";重点在下面。
SetWriter(&early)
w := Indirect()
if _, err := w.Write([]byte("a\n")); err != nil {
t.Fatal(err)
}
// 重定向之后:同一个 w 应当写到新去向
SetWriter(&late)
if _, err := w.Write([]byte("b\n")); err != nil {
t.Fatal(err)
}
if !strings.Contains(early.String(), "a") {
t.Errorf("重定向前应写进第一个 sink,实际 %q", early.String())
}
if !strings.Contains(late.String(), "b") {
t.Errorf("重定向后同一个 writer 应写进新 sink,实际 %q", late.String())
}
}
// TestSetWriterNilDiscards:传 nil 表示丢弃(TUI 想彻底静音时用)。
func TestSetWriterNilDiscards(t *testing.T) {
before := Writer()
defer SetWriter(before)
SetWriter(nil)
if Writer() == nil {
t.Fatal("nil 应被换成非 nil 的丢弃 writer")
}
Printf("这行不该 panic,也不该出现在任何地方\n")
}
// TestPrintfGoesToWriter:Printf 走当前去向。
func TestPrintfGoesToWriter(t *testing.T) {
before := Writer()
defer SetWriter(before)
var buf bytes.Buffer
SetWriter(&buf)
Printf("hello %s\n", "autogo")
if !strings.Contains(buf.String(), "hello autogo") {
t.Errorf("Printf 没写到当前去向:%q", buf.String())
}
}
+88
View File
@@ -0,0 +1,88 @@
package diag
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
)
// TestNoDirectTerminalWrites:TUI 跑在 alt-screen 下,**任何**直写 os.Stdout/os.Stderr
// 都会打穿界面。工具可达的包(工具实现、协议客户端、调度器……)都拿不到 agent 的
// Opt.Stderr,所以很容易顺手 fmt.Fprintf(os.Stderr, ...) 或 log.Printf——这条约定以前
// 只写在注释里,靠人记得,而实际就漏过好几处:
//
// - webui 的 11 处 log.Printf(默认 logger 直写 os.Stderr;而 WebUI 就在 TUI 里跑,
// SetLogOutput 只接管了 gin 的 writer);
// - MCP 子进程的 stderr 直连终端(而 MCP 的惯例恰恰是往 stderr 打日志);
// - computer 工具的三处区域截图警告与 Windows 子进程 stderr。
//
// 所以做成静态检查:工具可达包里出现 os.Stdout / os.Stderr / fmt.Print(log|f)(
// 就失败。诊断请走 diag;子进程的 stderr 用 diag.Indirect()。
//
// 用 go/ast 而不是 grep:注释里也会出现 "os.Stderr" 字样(本来就是在讲这件事),
// 文本匹配会误伤。
func TestNoDirectTerminalWrites(t *testing.T) {
// 工具调用可达的包(能跑在 TUI 里的路径)。列出当前与规划中的框架包;
// 尚未迁入的包 ReadDir 失败自动跳过。
pkgs := []string{
"tools", "agent", "cdp", "mcp", "session", "llm",
"execx", "jsonl", "textx", "scratch",
}
// 允许清单:file → 理由。目前为空——确有必要时再加,并写清为什么。
allowed := map[string]string{}
fset := token.NewFileSet()
var offenders []string
for _, pkg := range pkgs {
dir := filepath.Join("..", pkg)
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
path := filepath.Join(dir, name)
if _, ok := allowed[path]; ok {
continue
}
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
t.Fatalf("解析 %s 失败: %v", path, err)
}
ast.Inspect(f, func(n ast.Node) bool {
switch v := n.(type) {
case *ast.SelectorExpr:
// os.Stdout / os.Stderr
if id, ok := v.X.(*ast.Ident); ok && id.Name == "os" &&
(v.Sel.Name == "Stdout" || v.Sel.Name == "Stderr") {
pos := fset.Position(v.Pos())
offenders = append(offenders, pos.String()+" 直写 os."+v.Sel.Name)
}
// fmt.Print / Println / Printf(默认写 stdout)
if id, ok := v.X.(*ast.Ident); ok && id.Name == "fmt" {
switch v.Sel.Name {
case "Print", "Println", "Printf":
pos := fset.Position(v.Pos())
offenders = append(offenders, pos.String()+" fmt."+v.Sel.Name+" 写 stdout")
}
}
}
return true
})
}
}
if len(offenders) > 0 {
t.Errorf("工具可达的包里有 %d 处直接写终端——TUI 的 alt-screen 会被打穿。\n"+
"诊断请走 internal/diag(Printf/Println),子进程 stderr 用 diag.Indirect():\n %s",
len(offenders), strings.Join(offenders, "\n "))
}
}