Files
AutoZero/agent/subagent_test.go
T
Sakurasan d5dac5a6fc 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。
2026-10-26 00:54:46 +08:00

293 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}