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:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
@@ -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("压缩路径上取背景说明把自己锁死了")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user