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

+339
View File
@@ -0,0 +1,339 @@
// 后台任务的 kill/等待用 syscall.Kill 驱动,Windows 上没有这个调用。
//go:build !windows
package tools
import (
"context"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"syscall"
"testing"
"time"
"unicode/utf8"
"git.gopher.ink/mirrors2/AutoZero/scratch"
)
var jobIDRe = regexp.MustCompile(`\b(bg-[0-9a-f]{8}|bg-\d+)\b`)
// resetBG 清空进程级作业表。作业表故意跨回合共享(同一次 a2go 进程里都看得见),
// 所以测试必须自己隔开,否则前一个用例的作业会出现在后一个用例的列表里。
func resetBG(t *testing.T) {
t.Helper()
bgMu.Lock()
bgJobs = map[string]*bgJob{}
bgSeq = nil
bgMu.Unlock()
}
func bgStart(t *testing.T, command string) (id, logPath string) {
t.Helper()
tl := lookupTool(t, "bash_bg")
res, err := tl.Run(context.Background(), `{"action":"start","command":`+jsonQuote(command)+`}`)
if err != nil {
t.Fatalf("start: %v", err)
}
id = jobIDRe.FindString(res.Text)
if id == "" {
t.Fatalf("没拿到作业号:%q", res.Text)
}
line := regexp.MustCompile(`log: (\S+)`).FindStringSubmatch(res.Text)
if line == nil {
t.Fatalf("没给出日志路径:%q", res.Text)
}
return id, line[1]
}
func jsonQuote(s string) string {
// 只在测试里用最省事的方式造 JSON 字符串字面量。
return `"` + strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), `"`, `\"`) + `"`
}
func bgStatus(t *testing.T, args string) string {
t.Helper()
tl := lookupTool(t, "bg_status")
res, err := tl.Run(context.Background(), args)
if err != nil {
t.Fatalf("bg_status: %v", err)
}
return res.Text
}
// TestBackgroundJobPushesCompletion:作业结束要主动通知启动它的会话,
// 带上作业号、退出状态、日志路径与输出尾部;模型不必再轮询。
func TestBackgroundJobPushesCompletion(t *testing.T) {
resetBG(t)
t.Setenv("HOME", t.TempDir())
done := make(chan BackgroundDone, 1)
j, err := startJob(t.TempDir(), "printf 'hello-bg'", "sess-42", func(ev BackgroundDone) {
done <- ev
})
if err != nil {
t.Fatal(err)
}
select {
case ev := <-done:
if ev.JobID != j.ID || ev.SessionID != "sess-42" {
t.Fatalf("notification identity: %+v (job %s)", ev, j.ID)
}
if ev.Exit != "exit 0" {
t.Fatalf("exit = %q", ev.Exit)
}
if !strings.Contains(ev.Tail, "hello-bg") {
t.Fatalf("tail missing output: %q", ev.Tail)
}
if ev.LogPath == "" {
t.Fatal("log path missing")
}
case <-time.After(5 * time.Second):
t.Fatal("no completion notification")
}
}
// TestBackgroundJobNotifiesOnFailure:非零退出同样推送,状态要如实。
func TestBackgroundJobNotifiesOnFailure(t *testing.T) {
resetBG(t)
t.Setenv("HOME", t.TempDir())
done := make(chan BackgroundDone, 1)
if _, err := startJob(t.TempDir(), "echo boom >&2; exit 3", "s1", func(ev BackgroundDone) {
done <- ev
}); err != nil {
t.Fatal(err)
}
select {
case ev := <-done:
if !strings.Contains(ev.Exit, "3") {
t.Fatalf("exit = %q", ev.Exit)
}
if !strings.Contains(ev.Tail, "boom") {
t.Fatalf("tail missing stderr: %q", ev.Tail)
}
case <-time.After(5 * time.Second):
t.Fatal("no completion notification")
}
}
// TestBashBgStartReturnsImmediately:start 不等命令跑完。
// 这是这条通道存在的理由——长任务不该占着 120 秒的同步窗口。
func TestBashBgStartReturnsImmediately(t *testing.T) {
resetBG(t)
t.Setenv("HOME", t.TempDir())
start := time.Now()
id, logPath := bgStart(t, "sleep 30")
if el := time.Since(start); el > 2*time.Second {
t.Errorf("start 阻塞了 %v,后台通道等于没有", el)
}
defer func() { _, _ = logPath, id }()
// 日志必须落在 scratch 根里:那才是 read 工具够得着的地方(围栏内)。
if !strings.HasPrefix(logPath, filepath.Clean(scratch.Dir())) {
t.Errorf("日志不在 scratch 根内:%s (scratch=%s)", logPath, scratch.Dir())
}
out := bgStatus(t, `{"id":`+jsonQuote(id)+`}`)
if !strings.Contains(out, "running") {
t.Errorf("状态应是 running:%q", out)
}
// 收尾:停掉,别留 sleep 30 给测试进程。
tl := lookupTool(t, "bash_bg")
if _, err := tl.Run(context.Background(), `{"action":"stop","id":`+jsonQuote(id)+`}`); err != nil {
t.Fatal(err)
}
}
// TestBashBgStopKillsWholeTree:stop 必须带走作业派生的子进程。
// 只杀 `sh -c` 本体的话,`sleep 60 &` 会作为孤儿继续跑(bash 工具那类挂死的
// 同一根源),模型看到的"已停止"和实际的 CPU/网络占用就对不上。
func TestBashBgStopKillsWholeTree(t *testing.T) {
resetBG(t)
if runtime.GOOS == "windows" {
t.Skip("依赖 POSIX 进程组语义")
}
t.Setenv("HOME", t.TempDir())
// 后台孙子 + 前台本体;把孙子的 pid 写进日志,供事后验尸。
id, logPath := bgStart(t, "sleep 60 & echo $! ; sleep 60")
// 先等孙子真的起来(日志里出现 pid)。先 stop 再找 pid 的话,stop 可能早于
// echo 执行,日志永远是空的,测试就什么都没验证。
deadline := time.Now().Add(5 * time.Second)
pid := 0
for time.Now().Before(deadline) && pid == 0 {
if data, err := os.ReadFile(logPath); err == nil {
if n, ok := firstPID(string(data)); ok {
pid = n
}
}
time.Sleep(20 * time.Millisecond)
}
if pid == 0 {
t.Fatal("日志里读不到孙子 pid,无法验证进程树")
}
if err := syscall.Kill(pid, 0); err != nil {
t.Fatalf("孙子进程 %d 本该在跑:%v", pid, err)
}
tl := lookupTool(t, "bash_bg")
if _, err := tl.Run(context.Background(), `{"action":"stop","id":`+jsonQuote(id)+`}`); err != nil {
t.Fatal(err)
}
// stop 之后连孙子一起没:只杀 sh 本体的话它就变成孤儿。
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if syscall.Kill(pid, 0) != nil {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Errorf("孙子进程 %d 在 stop 之后仍在运行:没带走进程组", pid)
}
// TestBashBgCapturesOutputAndReportsExit:作业结束要报退出原因,
// 输出要能取回(含中文,验证尾部截断不切字符)。
func TestBashBgCapturesOutputAndReportsExit(t *testing.T) {
resetBG(t)
t.Setenv("HOME", t.TempDir())
id, _ := bgStart(t, "printf '第一段\\n'; sleep 0.2; printf '中文输出行\\n'")
deadline := time.Now().Add(15 * time.Second)
var out string
for time.Now().Before(deadline) {
out = bgStatus(t, `{"id":`+jsonQuote(id)+`,"tail":4000}`)
if strings.Contains(out, "exited") {
break
}
time.Sleep(50 * time.Millisecond)
}
if !strings.Contains(out, "exited (exit 0)") {
t.Errorf("应报退出:\n%s", out)
}
for _, want := range []string{"第一段", "中文输出行"} {
if !strings.Contains(out, want) {
t.Errorf("输出尾部缺 %q:\n%s", want, out)
}
}
}
// TestBgTailSnapsToRuneBoundary:尾部起点落在汉字中间时必须往后推到首字节,
// 否则送给模型的是半个字(进 JSON 就是替换字符)。
func TestBgTailSnapsToRuneBoundary(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "job.log")
body := strings.Repeat("中文测试行", 2000) // 每个汉字 3 字节
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
j := &bgJob{LogPath: path}
// 逐个 tail 长度扫一遍:任何一种切法都必须产出合法 UTF-8。
for n := 1; n < 40; n++ {
got := j.tail(n)
if !utf8.ValidString(got) {
t.Fatalf("tail=%d 切出非法 UTF-8:%q", n, got)
}
}
if !strings.HasPrefix(j.tail(10), "…") {
t.Error("被截断的尾部应带省略号,提示模型前面还有内容")
}
}
// TestBgTailHandlesBigLog:后台作业最常见的正是下载/构建,进度条能刷出几十 MB。
// 取尾部只许读末尾那一段(实现走 ReadAt,见 bashbg.go)。
func TestBgTailHandlesBigLog(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "big.log")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
line := strings.Repeat("x", 1024) + "\n"
for i := 0; i < 20000; i++ { // ~20MB
if _, err := f.WriteString(line); err != nil {
t.Fatal(err)
}
}
if _, err := f.WriteString("最后一行\n"); err != nil {
t.Fatal(err)
}
_ = f.Close()
j := &bgJob{LogPath: path}
start := time.Now()
got := j.tail(bgDefaultTail)
if el := time.Since(start); el > time.Second {
t.Errorf("取尾部花了 %v,疑似把整个日志读进内存", el)
}
if !strings.Contains(got, "最后一行") {
t.Errorf("尾部没带上最后一行:%q", got)
}
}
// TestBashBgListAndUnknownID:不传 id 是列表;查不存在的 id 要自解释。
func TestBashBgListAndUnknownID(t *testing.T) {
resetBG(t)
t.Setenv("HOME", t.TempDir())
out := bgStatus(t, `{}`)
if !strings.Contains(out, "no background jobs") {
t.Errorf("空表提示:%q", out)
}
t.Cleanup(func() { resetBG(t) })
id, _ := bgStart(t, "sleep 5")
out = bgStatus(t, `{}`)
if !strings.Contains(out, id) || !strings.Contains(out, "job(s)") {
t.Errorf("列表里应有该作业:%q", out)
}
out = bgStatus(t, `{"id":"bg-deadbeef"}`)
if !strings.Contains(out, "no such job") {
t.Errorf("未知 id 应说明:\n%s", out)
}
// 收尾
tl := lookupTool(t, "bash_bg")
_, _ = tl.Run(context.Background(), `{"action":"stop","id":`+jsonQuote(id)+`}`)
}
// TestBackgroundToolsRegistered:工具必须在内置表里,且语义标注正确——
// 标注错了,轮询输出就会每次都弹确认(后台的意义当场归零)。
func TestBackgroundToolsRegistered(t *testing.T) {
reg := NewRegistry()
for _, tl := range Builtin() {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
start, ok := reg.Get("bash_bg")
if !ok {
t.Fatal("bash_bg 没注册")
}
if !start.SideEffect || start.Kind != KindExec {
t.Errorf("bash_bg 必须是副作用 + 执行类:%+v", start)
}
status, ok := reg.Get("bg_status")
if !ok {
t.Fatal("bg_status 没注册")
}
if !status.ReadOnly || !status.ConcurrencySafe || status.SideEffect {
t.Errorf("bg_status 必须是只读可并发(轮询不该要审批):%+v", status)
}
}
// firstPID 从日志里取第一个数字串当 pid。
func firstPID(s string) (int, bool) {
for _, f := range strings.Fields(s) {
n := 0
for _, r := range f {
if r < '0' || r > '9' {
goto next
}
n = n*10 + int(r-'0')
}
if n > 0 {
return n, true
}
next:
}
return 0, false
}