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

+225
View File
@@ -0,0 +1,225 @@
package agent
import (
"context"
"errors"
"io"
"strings"
"testing"
"time"
"git.gopher.ink/mirrors2/AutoZero/llm"
"git.gopher.ink/mirrors2/AutoZero/session"
)
// 「从这里开始总结」:/rewind 摘掉一条分支之后,那段历史不该就此从模型视野里彻底消失。
// 它查出来的事实是重走一遍最贵的部分;但它同时也全是"未完成动作",原样留着模型就会
// 把用户刚放弃的活重新捡起来。所以压成一条**写明已放弃**的背景说明,注入 system 后缀。
// branchProvider 记下每次调用的 system,并按用例要求答复或失败。
type branchProvider struct {
systems []string
summary string
err error
}
func (p *branchProvider) Stream(_ context.Context, system string, _ []llm.Message, _ []llm.ToolDef,
_ llm.StreamOptions, _ func(llm.StreamDelta)) (llm.Message, error) {
p.systems = append(p.systems, system)
if p.err != nil {
return llm.Message{}, p.err
}
return llm.Message{Role: llm.RoleAssistant, Content: p.summary}, nil
}
// branchAgent 按真实装配挂 SystemSuffix:cmd/autogo 的 buildAgent 就是这么接的,
// 背景说明经这条后缀下发,不挂就等于没测。
func branchAgent(t *testing.T, p llm.Provider) *Agent {
t.Helper()
t.Setenv("HOME", t.TempDir())
sess, err := session.New()
if err != nil {
t.Fatal(err)
}
a := &Agent{Provider: p, Session: sess, System: "test",
Opt: Options{Stdout: io.Discard, Stderr: io.Discard}}
a.SystemSuffix = a.DynamicSections
return a
}
// bigBranch 造一段"值得总结"的被摘消息(估算过 abandonedMinTokens 那条地板线)。
func bigBranch(n int) []llm.Message {
msgs := make([]llm.Message, 0, n)
for i := 0; i < n; i++ {
if i%2 == 0 {
msgs = append(msgs, llm.Message{Role: llm.RoleUser, Content: strings.Repeat("排查", 1200)})
} else {
msgs = append(msgs, llm.Message{Role: llm.RoleAssistant, Content: strings.Repeat("结论", 1200)})
}
}
return msgs
}
func TestSummarizeAbandonedGoesIntoSystemSuffix(t *testing.T) {
p := &branchProvider{summary: "<summary>- 发现 nginx 里 upstream 端口写错</summary>"}
a := branchAgent(t, p)
a.Session.Append(llm.Message{Role: llm.RoleUser, Content: "换个方向重来"})
res, err := a.SummarizeAbandoned(context.Background(), bigBranch(4))
if err != nil {
t.Fatalf("该压成背景说明:%v", err)
}
if res.Summarized != 4 || res.TokensAfter >= res.TokensBefore {
t.Errorf("账不对:%+v", res)
}
sys := a.sysNow()
for _, want := range []string{"已被用户回退掉的分支", "不要执行其中未完成的动作", "nginx"} {
if !strings.Contains(sys, want) {
t.Errorf("system 后缀缺 %q:%s", want, sys)
}
}
// transcript 一行不改:摘掉的消息只以摘要的形态回来。
if len(a.Session.Messages) != 1 {
t.Errorf("会话被动了:%d 条", len(a.Session.Messages))
}
// 喂给摘要那条调用的 system 必须是"放弃分支"口径,不是常规压缩那条"接着干"。
if len(p.systems) != 1 || !strings.Contains(p.systems[0], "ABANDONED") {
t.Errorf("用了错的摘要指令:%v", p.systems)
}
}
// 这条钉住踩过的坑:常规压缩的收尾话术是"根据这份摘要接着干"。背景说明一旦被卷进
// 压缩输入,"别接着干"就会被改写成待办——用户刚放弃的分支原地复活。所以它住 system,
// 压多少轮都只有一份,且绝不进消息列表。
func TestAbandonedNoteNeverEntersCompaction(t *testing.T) {
a := branchAgent(t, &branchProvider{summary: "<summary>- 端口写错</summary>"})
a.ContextWindow, a.CompactAt, a.MinCompactTokens = 400, 0.85, 50
for i := 0; i < 6; i++ {
a.Session.Append(llm.Message{Role: llm.RoleUser, Content: strings.Repeat("当下的活", 300)})
a.Session.Append(llm.Message{Role: llm.RoleAssistant, Content: strings.Repeat("做了一半", 300)})
}
if _, err := a.SummarizeAbandoned(context.Background(), bigBranch(2)); err != nil {
t.Fatal(err)
}
for round := 0; round < 2; round++ {
view, err := a.contextView(context.Background())
if err != nil {
t.Fatal(err)
}
for j, m := range view {
if strings.Contains(m.Content, "已被用户回退掉的分支") {
t.Fatalf("第 %d 轮:说明混进了消息列表 view[%d]", round, j)
}
}
}
if n := strings.Count(a.sysNow(), "已被用户回退掉的分支"); n != 1 {
t.Errorf("压缩之后 system 里有 %d 份说明,want 1", n)
}
}
// 太短的分支不值得为它烧一次调用,而且一次请求都不该发出去。
func TestSummarizeAbandonedSkipsTinyBranch(t *testing.T) {
p := &branchProvider{summary: "x"}
a := branchAgent(t, p)
_, err := a.SummarizeAbandoned(context.Background(), []llm.Message{
{Role: llm.RoleUser, Content: "就一句话"}, {Role: llm.RoleAssistant, Content: "就一个答"}})
if !errors.Is(err, ErrAbandonedTooSmall) {
t.Fatalf("err = %v, want ErrAbandonedTooSmall", err)
}
if len(p.systems) != 0 {
t.Error("太短的分支不该发摘要请求")
}
if n := a.AbandonedNotes(); n != 0 {
t.Errorf("背景说明条数 = %d, want 0", n)
}
}
// 反复回退时旧的先掉:不然 system 会被一串越来越长的"背景"拖死。
func TestSummarizeAbandonedCapsNotes(t *testing.T) {
a := branchAgent(t, &branchProvider{summary: "s"})
for i := 0; i < 6; i++ {
if _, err := a.SummarizeAbandoned(context.Background(), bigBranch(2)); err != nil {
t.Fatal(err)
}
}
if n := a.AbandonedNotes(); n != abandonedNoteCap {
t.Errorf("背景说明条数 = %d, want %d", n, abandonedNoteCap)
}
}
// 摘要失败不能把回退本身弄成失败:原因要如实交出去,也不留下半条背景。
func TestSummarizeAbandonedFailureIsHonest(t *testing.T) {
boom := errors.New("gateway refused")
a := branchAgent(t, &branchProvider{err: boom})
res, err := a.SummarizeAbandoned(context.Background(), bigBranch(2))
if !errors.Is(err, boom) {
t.Fatalf("err = %v, want 原样交出", err)
}
if !strings.Contains(res.Err, "gateway refused") {
t.Errorf("结果里没记下失败原因:%+v", res)
}
if a.AbandonedNotes() != 0 {
t.Error("失败后残留了背景说明")
}
}
// undo 把原文接回来了,"这条路已放弃"的背景说明就该撤掉。
func TestClearAbandonedNotes(t *testing.T) {
a := branchAgent(t, &branchProvider{summary: "s"})
if _, err := a.SummarizeAbandoned(context.Background(), bigBranch(2)); err != nil {
t.Fatal(err)
}
a.ClearAbandonedNotes()
if a.AbandonedNotes() != 0 {
t.Fatal("没清干净")
}
if strings.Contains(a.sysNow(), "已被用户回退掉的分支") {
t.Error("清空后 system 里还带着背景说明")
}
}
// 手动 /compact 之后说明必须还在:它讲的是另一条分支,被顺手清掉就是又一次失忆。
func TestAbandonedNoteSurvivesManualCompact(t *testing.T) {
a := branchAgent(t, &branchProvider{summary: "s"})
a.ContextWindow, a.CompactAt, a.MinCompactTokens = 100000, 0.85, 100
a.Session.Append(llm.Message{Role: llm.RoleUser, Content: strings.Repeat("当下的活", 3000)})
if _, err := a.SummarizeAbandoned(context.Background(), bigBranch(2)); err != nil {
t.Fatal(err)
}
if _, err := a.CompactNow(context.Background()); err != nil {
t.Fatal(err)
}
if n := a.AbandonedNotes(); n != 1 {
t.Fatalf("压缩把背景说明也清了:%d", n)
}
}
// 死锁守卫:AbandonedSection 经 sysNow 注入,而 sysNow 在 contextView/CompactNow 里是
// **持着 compactMu** 跑的。说明若跟着抢 compactMu 就是自己等自己——真死过一次,
// 栈是 AbandonedSection ← SummarizeAbandoned / ← estimateTokens。
func TestAbandonedSectionDoesNotDeadlockCompaction(t *testing.T) {
a := branchAgent(t, &branchProvider{summary: "s"})
a.ContextWindow, a.CompactAt, a.MinCompactTokens = 400, 0.85, 50
for i := 0; i < 4; i++ {
a.Session.Append(llm.Message{Role: llm.RoleUser, Content: strings.Repeat("活", 400)})
a.Session.Append(llm.Message{Role: llm.RoleAssistant, Content: strings.Repeat("做", 400)})
}
if _, err := a.SummarizeAbandoned(context.Background(), bigBranch(2)); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() {
defer close(done)
if _, err := a.contextView(context.Background()); err != nil {
t.Error(err)
}
if _, err := a.CompactNow(context.Background()); err != nil {
t.Error(err)
}
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("压缩路径上取背景说明把自己锁死了")
}
}