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,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")
|
||||
}
|
||||
Reference in new issue
Block a user