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

+218
View File
@@ -0,0 +1,218 @@
package agent
import (
"context"
"errors"
"io"
"strings"
"testing"
"git.gopher.ink/mirrors2/AutoZero/llm"
"git.gopher.ink/mirrors2/AutoZero/session"
"git.gopher.ink/mirrors2/AutoZero/tools"
)
// 中断时保留半截回复的测试。scriptedProvider 不发 delta(它直接返回整条消息),
// 所以这里要一个会"先流几个字再断"的 provider——真实事故就是这个形状。
type cuttingProvider struct {
text string // 断之前流出的正文
reasoning string
err error
calls int
}
func (p *cuttingProvider) Stream(_ context.Context, _ string, _ []llm.Message, _ []llm.ToolDef,
_ llm.StreamOptions, onDelta func(llm.StreamDelta)) (llm.Message, error) {
p.calls++
if p.reasoning != "" && onDelta != nil {
onDelta(llm.StreamDelta{Reasoning: p.reasoning})
}
if p.text != "" && onDelta != nil {
onDelta(llm.StreamDelta{Text: p.text})
}
// provider 出错时返回零值 Message:半截内容不在返回值里(这正是它丢字的根因)。
return llm.Message{}, p.err
}
func cutAgent(t *testing.T, prov llm.Provider) (*Agent, *session.Session) {
t.Helper()
a, sess := newTestAgent(t)
a.Provider = prov
a.Tools = tools.NewRegistry()
a.Opt.Stdout = io.Discard
a.Opt.Stderr = io.Discard
return a, sess
}
// TestInterruptKeepsStreamedText:屏幕上都打出字了,会话里却一个字都没有——
// 刷新/重进就消失,模型下一轮也不知道自己说到哪。
func TestInterruptKeepsStreamedText(t *testing.T) {
noWait(t)
prov := &cuttingProvider{text: "已经分析到第三步", err: errors.New("openai: 流被掐断")}
a, sess := cutAgent(t, prov)
err := a.Run(context.Background(), "分析这个仓库")
if err == nil {
t.Fatal("这一轮该以失败结束")
}
var as []llm.Message
for _, m := range sess.Messages {
if m.Role == llm.RoleAssistant {
as = append(as, m)
}
}
if len(as) != 1 {
t.Fatalf("assistant 消息 %d 条,want 1:%+v", len(as), as)
}
if as[0].Content != "已经分析到第三步" {
t.Errorf("半截内容没原样保留:%q", as[0].Content)
}
// 半截就是半截,得让消费方看得出来:webui 的重试判定靠它区分"这条没写完"和
// "这一轮已经答完",漏了标记就会把可续跑的回合判成"没有可重试的消息"。
if !as[0].Interrupted {
t.Error("没打 interrupted 标记")
}
// Step 必须盖上(否则 trace 里看不出这是第几步);耗时用度不苛求正数——
// 假 provider 是瞬时返回的,真跑起来才有毫秒。
if as[0].Step != 1 || as[0].DurationMs < 0 || as[0].StartedAt == 0 {
t.Errorf("trace 没盖上:%+v", as[0])
}
// 回合账仍然记的是失败/停止,不是"说完了"。
if got := sess.TurnEnds[len(sess.TurnEnds)-1].Reason; got == "completed" {
t.Error("失败被记成了 completed")
}
}
// TestInterruptKeepsReasoning:思考文本也一样保留(前端展开的"思考过程"不该空掉)。
func TestInterruptKeepsReasoning(t *testing.T) {
noWait(t)
prov := &cuttingProvider{reasoning: "先看看入口在哪", err: errors.New("stream stalled")}
a, sess := cutAgent(t, prov)
_ = a.Run(context.Background(), "讲")
if len(sess.Messages) < 2 || sess.Messages[len(sess.Messages)-1].Reasoning != "先看看入口在哪" {
t.Fatalf("思考文本没保留:%+v", sess.Messages)
}
}
// TestInterruptNothingStreamed:连接一开就断时**不能**留一条空 assistant 消息——
// 它会被"没有 tool_calls ⇒ 说完了"读成一次正常的空回答,正是我们在治的那个病。
func TestInterruptNothingStreamed(t *testing.T) {
noWait(t)
prov := &cuttingProvider{err: errors.New("connection reset by peer")}
a, sess := cutAgent(t, prov)
if err := a.Run(context.Background(), "讲"); err == nil {
t.Fatal("该失败")
}
for _, m := range sess.Messages {
if m.Role == llm.RoleAssistant {
t.Fatalf("什么都没流出来,却落了一条 assistant:%+v", m)
}
}
}
// TestInterruptNeverPersistsPartialToolCalls:半截的工具参数是没写完的 JSON。
// 落了就会有一条永远配不上对的 tool_use,Anthropic 会拒绝整条会话(见
// synthMissingToolResults 的注释)。这里断言它压根不会被写进会话。
func TestInterruptNeverPersistsPartialToolCalls(t *testing.T) {
noWait(t)
prov := &cuttingProvider{text: `我想调用 write,参数 {"path":"a.go",`, err: errors.New("aborted")}
a, sess := cutAgent(t, prov)
_ = a.Run(context.Background(), "写个文件")
last := sess.Messages[len(sess.Messages)-1]
if len(last.ToolCalls) != 0 {
t.Errorf("半截 tool_calls 被落盘了:%+v", last.ToolCalls)
}
if !strings.Contains(last.Content, "write") {
t.Error("正文没按原样保留")
}
}
// TestCancelKeepsPartialIsIdempotentAcrossRetries:空回复重试环里每次 call() 都会
// 清空累积器,落盘的只能是**最后一次**尝试流出的内容,不能把两轮的字拼在一起。
func TestCancelKeepsPartialIsIdempotentAcrossRetries(t *testing.T) {
noWait(t)
prov := &retryThenCutProvider{}
a, sess := cutAgent(t, prov)
_ = a.Run(context.Background(), "讲")
var texts []string
for _, m := range sess.Messages {
if m.Role == llm.RoleAssistant {
texts = append(texts, m.Content)
}
}
if len(texts) != 1 || texts[0] != "第二次才流出的字" {
t.Errorf("落盘内容不对(重试拼接或丢失):%+v", texts)
}
}
// TestAbandonStreamFiresBeforeRetry:被丢弃的那次尝试流出的字,宿主需要知道——
// 它已经打到屏幕上了,重跑一次就是两遍同样的话。通知只给**正文**的字节数
// (思考走另一条通道,服务端没有它的字节账)。
func TestAbandonStreamFiresBeforeRetry(t *testing.T) {
noWait(t)
type abandon struct {
reason string
bytes int
}
var got []abandon
prov := &cuttingProvider{text: "第一遍说出的话", err: llm.ErrTruncatedStream}
a, sess := cutAgent(t, prov)
a.Opt.OnAbandon = func(reason string, n int) { got = append(got, abandon{reason, n}) }
if err := a.Run(context.Background(), "讲"); err == nil {
t.Fatal("这一轮该以失败结束")
}
// 预算现在按时间算(不再是一个固定次数),所以这里只钉"每次重试都作废一次,
// 而且不止两次"——旧的 2 次上限正是"网关抖一下就中断整轮"的根源。
if len(got) < 3 {
t.Fatalf("作废通知只有 %d 次,重试预算没有生效:%+v", len(got), got)
}
for i, ab := range got {
if ab.bytes != len("第一遍说出的话") {
t.Errorf("第 %d 次通知带的字节数 = %d, want %d", i+1, ab.bytes, len("第一遍说出的话"))
}
if ab.reason == "" {
t.Errorf("第 %d 次通知没带原因", i+1)
}
}
// 会话里始终只有一份:作废只影响屏幕,落盘走的是最后一次尝试攒下的内容。
var kept int
for _, m := range sess.Messages {
if m.Role == llm.RoleAssistant {
kept++
if strings.Count(m.Content, "第一遍说出的话") != 1 {
t.Errorf("半截内容被拼成了多份:%q", m.Content)
}
}
}
if kept != 1 {
t.Fatalf("assistant 消息 %d 条,want 1", kept)
}
}
// TestAbandonStreamSilentWhenNothingStreamed:什么都没流出来时不发空通知——
// 宿主白擦一次屏幕,什么都没换回来。
func TestAbandonStreamSilentWhenNothingStreamed(t *testing.T) {
noWait(t)
called := 0
a, _ := cutAgent(t, &cuttingProvider{err: errors.New("connection reset by peer")})
a.Opt.OnAbandon = func(string, int) { called++ }
_ = a.Run(context.Background(), "讲")
if called != 0 {
t.Fatalf("什么都没流出,却通知了 %d 次", called)
}
}
// retryThenCutProvider:第一次报空补全(触发重试环),第二次流出几个字后断掉。
type retryThenCutProvider struct{ n int }
func (p *retryThenCutProvider) Stream(_ context.Context, _ string, _ []llm.Message, _ []llm.ToolDef,
_ llm.StreamOptions, onDelta func(llm.StreamDelta)) (llm.Message, error) {
p.n++
if p.n == 1 {
return llm.Message{}, llm.ErrEmptyCompletion
}
if onDelta != nil {
onDelta(llm.StreamDelta{Text: "第二次才流出的字"})
}
return llm.Message{}, errors.New("stream cut")
}