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

+292
View File
@@ -0,0 +1,292 @@
package agent
import (
"context"
"fmt"
"regexp"
"strings"
"sync/atomic"
"testing"
"time"
"git.gopher.ink/mirrors2/AutoZero/llm"
"git.gopher.ink/mirrors2/AutoZero/tools"
)
// subAgentTester 建一个父 agent 并挂上 agent 工具,返回父 agent 与工具本身
// (用例常要往父级上改档位/审批通道)。
func subAgentTester(t *testing.T, prov *scriptedProvider, maxDepth, conc int, approveAll bool) (*Agent, *tools.Tool) {
t.Helper()
a, _ := newTestAgent(t)
a.Provider = prov
a.MaxDepth = maxDepth
a.SubAgentConcurrency = conc
a.Opt.ApproveAll = approveAll
if err := a.Tools.Register(a.SubAgentTool()); err != nil {
t.Fatal(err)
}
tl, ok := a.Tools.Get("agent")
if !ok {
t.Fatal("agent tool not registered")
}
return a, tl
}
// TestSubAgentSessionResume:结果里带 sessionID,带回去可以接着聊;
// 第二次调用必须看得到上一轮的提问与回答,而不是从空上下文重来。
func TestSubAgentSessionResume(t *testing.T) {
calls := 0
var secondMsgs []llm.Message
prov := &scriptedProvider{fn: func(_ int, _ string, msgs []llm.Message, _ []llm.ToolDef) (llm.Message, error) {
calls++
if calls == 1 {
return llm.Message{Role: llm.RoleAssistant, Content: "FIRST"}, nil
}
secondMsgs = append([]llm.Message(nil), msgs...)
return llm.Message{Role: llm.RoleAssistant, Content: "SECOND"}, nil
}}
_, tl := subAgentTester(t, prov, 1, 1, true)
res, err := tl.Run(context.Background(), `{"prompt":"child task"}`)
if err != nil {
t.Fatal(err)
}
m := regexp.MustCompile(`sessionID="([^"]+)"`).FindStringSubmatch(res.Text)
if m == nil {
t.Fatalf("result has no sessionID: %q", res.Text)
}
id := m[1]
res2, err := tl.Run(context.Background(), fmt.Sprintf(`{"prompt":"dig deeper","session_id":%q}`, id))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(res2.Text, "SECOND") || !strings.Contains(res2.Text, id) {
t.Fatalf("resume result: %q", res2.Text)
}
var sawTask, sawFirst bool
for _, msg := range secondMsgs {
if strings.Contains(msg.Content, "child task") {
sawTask = true
}
if strings.Contains(msg.Content, "FIRST") {
sawFirst = true
}
}
if !sawTask || !sawFirst {
t.Fatalf("resumed sub-agent did not see its history: %+v", secondMsgs)
}
}
// TestSubAgentUnknownSession:进程里没有那个子会话时要说清楚,而不是悄悄开新的。
func TestSubAgentUnknownSession(t *testing.T) {
prov := &scriptedProvider{fn: func(int, string, []llm.Message, []llm.ToolDef) (llm.Message, error) {
return llm.Message{Role: llm.RoleAssistant, Content: "x"}, nil
}}
_, tl := subAgentTester(t, prov, 1, 1, true)
_, err := tl.Run(context.Background(), `{"prompt":"x","session_id":"sa-999"}`)
if err == nil || !strings.Contains(err.Error(), "no such subagent session") {
t.Fatalf("want unknown-session error, got %v", err)
}
}
// TestSubAgentDepthLadder:深度逐层递增,且每层重新绑定自己的 agent 工具
// (否则子 agent 手里的 agent 仍指向顶层,MaxDepth 失效)。
func TestSubAgentDepthLadder(t *testing.T) {
var childHasAgent, grandchildHasAgent bool
prov := &scriptedProvider{fn: func(call int, _ string, _ []llm.Message, defs []llm.ToolDef) (llm.Message, error) {
switch call {
case 1: // 子 agent(depth 1 < MaxDepth 2):应能再派发
childHasAgent = hasName(defs, "agent")
return llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{
{ID: "a2", Name: "agent", Input: `{"prompt":"grandchild"}`},
}}, nil
case 2: // 孙 agent(depth 2 == MaxDepth 2):不能再派发
grandchildHasAgent = hasName(defs, "agent")
return llm.Message{Role: llm.RoleAssistant, Content: "GRAND-FINAL"}, nil
default: // 子 agent 拿到孙结果后的最终回复
return llm.Message{Role: llm.RoleAssistant, Content: "CHILD-FINAL"}, nil
}
}}
_, tl := subAgentTester(t, prov, 2, 2, true)
res, err := tl.Run(context.Background(), `{"prompt":"child"}`)
if err != nil {
t.Fatal(err)
}
if !childHasAgent {
t.Error("child (depth 1 < max 2) should be able to delegate further")
}
if grandchildHasAgent {
t.Error("grandchild (depth 2 == max 2) must not have the agent tool")
}
if !strings.Contains(res.Text, "CHILD-FINAL") {
t.Errorf("child final reply not returned: %q", res.Text)
}
}
// TestSubAgentDepthZeroDisables:深度上限为 0 时工具直接拒绝(零值 fail-closed)。
func TestSubAgentDepthZeroDisables(t *testing.T) {
prov := &scriptedProvider{fn: func(int, string, []llm.Message, []llm.ToolDef) (llm.Message, error) {
t.Error("provider must not be called when depth is 0")
return llm.Message{}, nil
}}
_, tl := subAgentTester(t, prov, 0, 2, true)
if _, err := tl.Run(context.Background(), `{"prompt":"x"}`); err == nil {
t.Fatal("depth 0 must refuse to spawn a sub-agent")
}
}
// taskProvider 按任务名(消息末条内容)返回结果,并记录并发峰值。
func taskProvider(delay time.Duration, failOn string, peak *int64) *scriptedProvider {
var inflight, maxSeen int64
return &scriptedProvider{fn: func(_ int, _ string, msgs []llm.Message, _ []llm.ToolDef) (llm.Message, error) {
cur := atomic.AddInt64(&inflight, 1)
for {
old := atomic.LoadInt64(&maxSeen)
if cur <= old || atomic.CompareAndSwapInt64(&maxSeen, old, cur) {
break
}
}
defer atomic.AddInt64(&inflight, -1)
var last string
if len(msgs) > 0 {
last = msgs[len(msgs)-1].Content
}
if failOn != "" && strings.Contains(last, failOn) {
return llm.Message{}, fmt.Errorf("boom on %s", last)
}
time.Sleep(delay)
if peak != nil {
seen := atomic.LoadInt64(&maxSeen) // 必须原子读:其他 goroutine 在原子写
for {
old := atomic.LoadInt64(peak)
if seen <= old || atomic.CompareAndSwapInt64(peak, old, seen) {
break
}
}
}
return llm.Message{Role: llm.RoleAssistant, Content: "R-" + strings.TrimSpace(last)}, nil
}}
}
const subDelay = 150 * time.Millisecond
// TestSubAgentTasksParallel:ApproveAll 下多任务并行(总耗时接近单任务)。
func TestSubAgentTasksParallel(t *testing.T) {
_, tl := subAgentTester(t, taskProvider(subDelay, "", nil), 1, 2, true)
start := time.Now()
res, err := tl.Run(context.Background(), `{"tasks":["T1","T2"]}`)
elapsed := time.Since(start)
if err != nil {
t.Fatal(err)
}
if elapsed >= 2*subDelay {
t.Errorf("sub-tasks look serialized: elapsed=%v", elapsed)
}
if !strings.Contains(res.Text, "R-T1") || !strings.Contains(res.Text, "R-T2") {
t.Errorf("both sub-results expected: %q", res.Text)
}
// 汇总按任务顺序编号。
if strings.Index(res.Text, "[1/2]") > strings.Index(res.Text, "[2/2]") {
t.Errorf("summary should be ordered: %q", res.Text)
}
}
// TestSubAgentTasksSerialWhenApproverExists:只要问得到人(挂了审批回调,或真有
// stdin),多任务就串行——危险门不看 ApproveAll,而宿主只有一个待裁决槽位,并行会
// 让前一条询问挂到超时。真无人值守(两个通道都没有)才并行,见下一个用例。
func TestSubAgentTasksSerialWhenApproverExists(t *testing.T) {
a, tl := subAgentTester(t, taskProvider(subDelay, "", nil), 1, 2, true)
a.Opt.Approval = func(ApprovalRequest) ApprovalVerdict { return ApprovalOnce }
start := time.Now()
if _, err := tl.Run(context.Background(), `{"tasks":["T1","T2"]}`); err != nil {
t.Fatal(err)
}
if elapsed := time.Since(start); elapsed < 2*subDelay {
t.Errorf("有审批通道时必须串行: elapsed=%v", elapsed)
}
}
// TestSubAgentTasksParallelWhenNoApprover:ApproveAll 且没有任何审批通道
// (-p / cron / 渠道)才是并行的正当场景,即使档位不是 bypass。
func TestSubAgentTasksParallelWhenNoApprover(t *testing.T) {
_, tl := subAgentTester(t, taskProvider(subDelay, "", nil), 1, 2, false)
start := time.Now()
if _, err := tl.Run(context.Background(), `{"tasks":["T1","T2"]}`); err != nil {
t.Fatal(err)
}
if elapsed := time.Since(start); elapsed >= 2*subDelay {
t.Errorf("无人可问时应并行: elapsed=%v", elapsed)
}
}
// TestSubAgentTasksFailureIsolation:单个子任务失败不拖垮整批,工具整体仍算成功。
func TestSubAgentTasksFailureIsolation(t *testing.T) {
_, tl := subAgentTester(t, taskProvider(0, "BAD", nil), 1, 2, true)
res, err := tl.Run(context.Background(), `{"tasks":["GOOD","BAD"]}`)
if err != nil {
t.Fatalf("batch with one failure should not fail the whole tool: %v", err)
}
if !strings.Contains(res.Text, "R-GOOD") {
t.Errorf("successful task result missing: %q", res.Text)
}
if !strings.Contains(res.Text, "[2/2] failed") {
t.Errorf("failed task should be marked: %q", res.Text)
}
}
// TestSubAgentAllTasksFail:全部失败时以 Result.Err 返回(同 bash 约定:保留文本
// 同时表明失败),让父级失败熔断计数生效。
func TestSubAgentAllTasksFail(t *testing.T) {
_, tl := subAgentTester(t, taskProvider(0, "T", nil), 1, 2, true)
res, err := tl.Run(context.Background(), `{"tasks":["T1","T2"]}`)
if err != nil {
t.Fatalf("partial output should ride in Result, not the error slot: %v", err)
}
if res.Err == nil {
t.Fatal("all-failed batch must report failure via Result.Err")
}
// 文本仍然保留各任务失败原因,便于父模型判断。
if !strings.Contains(res.Text, "failed") {
t.Errorf("failure detail should be preserved: %q", res.Text)
}
}
// TestSubAgentConcurrencyCap:并发度不超过 SubAgentConcurrency。
func TestSubAgentConcurrencyCap(t *testing.T) {
var peak int64
_, tl := subAgentTester(t, taskProvider(40*time.Millisecond, "", &peak), 1, 2, true)
if _, err := tl.Run(context.Background(), `{"tasks":["A","B","C","D","E"]}`); err != nil {
t.Fatal(err)
}
if peak > 2 {
t.Errorf("in-flight sub-agents = %d, want <= 2", peak)
}
if peak < 2 {
t.Errorf("expected the batch to actually run in parallel, peak = %d", peak)
}
}
// TestSubAgentInputValidation:prompt/tasks 互斥、空项与超量都要拒绝。
func TestSubAgentInputValidation(t *testing.T) {
_, tl := subAgentTester(t, taskProvider(0, "", nil), 1, 2, true)
ctx := context.Background()
for _, in := range []string{
`{}`,
`{"prompt":"a","tasks":["b"]}`,
`{"tasks":[]}`,
`{"tasks":["ok"," "]}`,
`{"tasks":["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17"]}`,
} {
if _, err := tl.Run(ctx, in); err == nil {
t.Errorf("input %s should be rejected", in)
}
}
}