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。
226 lines
8.3 KiB
Go
226 lines
8.3 KiB
Go
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("压缩路径上取背景说明把自己锁死了")
|
||
}
|
||
}
|