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

+178
View File
@@ -0,0 +1,178 @@
package agent
import (
"context"
"encoding/json"
"errors"
"io"
"strings"
"sync"
"testing"
"git.gopher.ink/mirrors2/AutoZero/llm"
)
// 请求过大自救。网关的 token 计数与我们的 bytes/3 估算必然有偏差,真撞墙时拿到的
// 往往是一句 400(param=messages)或一个空补全——按字面意思处理就是把错误丢回给用户,
// 而正确动作是先把上下文压下来,再重发这一次。
// sizeProvider 记录摘要调用与真实调用各发生了几次。
type sizeProvider struct {
mu sync.Mutex
real int
compact int
sizes []int // 每次真实调用发出去的消息字节数(断言重发用的是压缩后的视图)
failReal int // 前 N 次真实调用返回这个错误
err error
reply string
}
func (p *sizeProvider) Stream(_ context.Context, system string, msgs []llm.Message, _ []llm.ToolDef,
_ llm.StreamOptions, _ func(llm.StreamDelta)) (llm.Message, error) {
p.mu.Lock()
defer p.mu.Unlock()
if strings.HasPrefix(system, "You are a context compaction") {
p.compact++
return llm.Message{Role: llm.RoleAssistant, Content: "<summary>压缩后的交接</summary>"}, nil
}
p.real++
if b, err := json.Marshal(msgs); err == nil {
p.sizes = append(p.sizes, len(b))
}
if p.err != nil && p.real <= p.failReal {
return llm.Message{}, p.err
}
return llm.Message{Role: llm.RoleAssistant, Content: p.reply}, nil
}
const badRequest = "openai: HTTP 400: {\"error\":{\"message\":\"inference request is invalid\"," +
"\"type\":\"invalid_request_error\",\"param\":\"messages\",\"code\":\"400002\"}}"
// oversizedAgent 造一个"上下文已经不小、但还没到自动压缩阈值"的回合:
// 估算 ~7000 tokens,阈值 8500,窗口 10000(六成线 = 6000)。
// 这样常规压缩不会触发,只有"请求过大自救"这条路会去压。
func oversizedAgent(t *testing.T, p *sizeProvider) (*Agent, int) {
t.Helper()
a, sess := newTestAgent(t)
a.Provider = p
a.Opt.Stderr, a.Opt.Stdout = io.Discard, io.Discard
a.ContextWindow, a.CompactAt, a.MinCompactTokens = 10000, 0.85, 10000
sess.Append(llm.Message{Role: llm.RoleUser, Content: "把这件事做完"})
sess.Append(llm.Message{Role: llm.RoleAssistant, Content: strings.Repeat("a", 21000)})
view, err := a.contextView(context.Background())
if err != nil {
t.Fatal(err)
}
if got := estimateTokens(view, a.sysNow()); got >= 8500 || got < 6000 {
t.Fatalf("夹具的上下文占比不对:est=%d,want 6000..8499", got)
}
return a, len(view)
}
// TestOversizedRequestCompactsAndResends:400 之后压一次、重发一次,回合正常收尾。
func TestOversizedRequestCompactsAndResends(t *testing.T) {
p := &sizeProvider{failReal: 1, err: errors.New(badRequest), reply: "压完就能发出去了"}
a, _ := oversizedAgent(t, p)
if err := a.Run(context.Background(), ""); err != nil {
t.Fatalf("该自救成功而不是失败:%v", err)
}
if p.compact != 1 {
t.Errorf("摘要调用 %d 次,want 1", p.compact)
}
if p.real != 2 {
t.Errorf("真实调用 %d 次,want 2(失败一次 + 压缩后重发一次)", p.real)
}
last := a.Session.Messages[len(a.Session.Messages)-1]
if last.Content != "压完就能发出去了" {
t.Errorf("最终回复不对:%q", last.Content)
}
// 重发必须用压缩后的视图:端到端实测里漏了这一步,等于把同一个 1.4MB 的请求
// 原样再撞一次墙(第一次单测是绿的,因为假 provider 不看消息内容)。
if len(p.sizes) != 2 {
t.Fatalf("真实调用记录 %v,want 2 次", p.sizes)
}
if p.sizes[1] >= p.sizes[0] {
t.Errorf("重发的请求没变小:%d → %d(视图没重取)", p.sizes[0], p.sizes[1])
}
}
// TestOversizedRetryOnlyOncePerStep:压完还是 400 时不无限自救——一个 step 一次,
// 之后把原错误如实交出去。
func TestOversizedRetryOnlyOncePerStep(t *testing.T) {
p := &sizeProvider{failReal: 99, err: errors.New(badRequest)}
a, _ := oversizedAgent(t, p)
err := a.Run(context.Background(), "")
if err == nil {
t.Fatal("一直 400 时该失败")
}
if !strings.Contains(err.Error(), "HTTP 400") {
t.Errorf("错误没原样交出:%v", err)
}
if p.compact != 1 {
t.Errorf("摘要调用 %d 次,want 1(同一 step 不该反复自救)", p.compact)
}
if p.real != 2 {
t.Errorf("真实调用 %d 次,want 2", p.real)
}
}
// TestRequestLooksOversizedNeedsBigContext:判定必须带"上下文本来就大"这个前提。
// 小上下文下的 400 是请求本身写错了,空补全是网关抖动——去压缩只是白费一次调用。
func TestRequestLooksOversizedNeedsBigContext(t *testing.T) {
a, _ := newTestAgent(t)
a.Opt.Stderr = io.Discard
a.ContextWindow, a.CompactAt = 10000, 0.85
big := []llm.Message{{Role: llm.RoleUser, Content: strings.Repeat("a", 21000)}}
small := []llm.Message{{Role: llm.RoleUser, Content: "hi"}}
medium := []llm.Message{{Role: llm.RoleUser, Content: strings.Repeat("a", 12600)}}
cases := []struct {
name string
err error
view []llm.Message
want bool
}{
{"大上下文 + 400 param=messages", errors.New(badRequest), big, true},
{"大上下文 + 空补全", llm.ErrEmptyCompletion, big, true},
{"大上下文 + 坏流", llm.ErrTruncatedStream, big, true},
{"小上下文 + 400", errors.New(badRequest), small, false},
{"小上下文 + 空补全", llm.ErrEmptyCompletion, small, false},
// 把 context_window 按真实模型配大(1M 的模型本该如此)之后,"占窗口六成"
// 这条线高得够不着;400 是网关在点名说请求本身有问题,必须仍然能自救。
{"大窗口 + 400(六成线够不着)", errors.New(badRequest), big, true},
{"刚过地板线 + 400", errors.New(badRequest), medium, true},
{"大窗口 + 空补全(弱证据要占比)", llm.ErrEmptyCompletion, medium, false},
{"大上下文 + 权限错误", errors.New("permission denied"), big, false},
{"大上下文 + 超时", errors.New("context deadline exceeded"), big, false},
{"没配窗口", errors.New(badRequest), big, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
switch c.name {
case "没配窗口":
a.ContextWindow = 0
case "大窗口 + 400(六成线够不着)", "大窗口 + 空补全(弱证据要占比)":
a.ContextWindow = 500000
default:
a.ContextWindow = 10000
}
if got := a.requestLooksOversized(c.err, c.view); got != c.want {
t.Errorf("requestLooksOversized = %v, want %v", got, c.want)
}
})
}
}
// TestOversizedRetryClearsGaveUpFlag:上一 step 压缩失败留下的"本回合放弃"不该挡住
// 这次的自救——那时还没有"请求过大"这个新证据。
func TestOversizedRetryWorksAfterGaveUp(t *testing.T) {
p := &sizeProvider{failReal: 1, err: errors.New(badRequest), reply: "压完就能发出去了"}
a, _ := oversizedAgent(t, p)
a.compactGaveUp = true // 模拟本回合早前压缩失败过
if err := a.Run(context.Background(), ""); err != nil {
t.Fatalf("放弃标记不该挡住新证据下的自救:%v", err)
}
if p.compact != 1 {
t.Errorf("摘要调用 %d 次,want 1", p.compact)
}
}