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

+164
View File
@@ -0,0 +1,164 @@
package agent
import (
"context"
"errors"
"testing"
"git.gopher.ink/mirrors2/AutoZero/llm"
"git.gopher.ink/mirrors2/AutoZero/tools"
)
// TestToolEventMarksSideEffects:副作用标记要随工具事件传给宿主,前端才能在
// 回合失败时禁用自动重试。
func TestToolEventMarksSideEffects(t *testing.T) {
prov := &scriptedProvider{fn: func(call int, _ string, _ []llm.Message, _ []llm.ToolDef) (llm.Message, error) {
if call == 1 {
return llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{
{ID: "r1", Name: "rotool", Input: "{}"},
{ID: "s1", Name: "sidetool", Input: "{}"},
}}, nil
}
return llm.Message{Role: llm.RoleAssistant, Content: "done"}, nil
}}
ro := &tools.Tool{Name: "rotool", ReadOnly: true, ConcurrencySafe: true, Run: func(context.Context, string) (tools.Result, error) {
return tools.TextResult("r"), nil
}}
side := &tools.Tool{Name: "sidetool", SideEffect: true, Kind: tools.KindExec, Run: func(context.Context, string) (tools.Result, error) {
return tools.TextResult("s"), nil
}}
a, _ := newTestAgent(t, ro, side)
a.Provider = prov
started := map[string]bool{}
a.Opt.OnTool = func(ev ToolEvent) {
if !ev.Done {
started[ev.Name] = ev.SideEffect
}
}
if err := a.Run(context.Background(), "go"); err != nil {
t.Fatal(err)
}
if !started["sidetool"] {
t.Error("side-effect tool must be marked in ToolEvent")
}
if started["rotool"] {
t.Error("read-only tool must not be marked side-effect")
}
}
// TestSideEffectsDisableWholeTurnRetry:副作用已经执行后,回合失败要带
// ErrAfterSideEffects,宿主据此只给手动重试;工具本身只跑一次。
func TestSideEffectsDisableWholeTurnRetry(t *testing.T) {
runs := 0
calls := 0
prov := &scriptedProvider{fn: func(call int, _ string, _ []llm.Message, _ []llm.ToolDef) (llm.Message, error) {
calls++
if calls == 1 {
return llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{
{ID: "s1", Name: "sidetool", Input: "{}"},
}}, nil
}
return llm.Message{}, errors.New("boom")
}}
side := &tools.Tool{Name: "sidetool", SideEffect: true, Kind: tools.KindExec, Run: func(context.Context, string) (tools.Result, error) {
runs++
return tools.TextResult("done"), nil
}}
a, _ := newTestAgent(t, side)
a.Provider = prov
err := a.Run(context.Background(), "go")
if !errors.Is(err, ErrAfterSideEffects) {
t.Fatalf("err = %v, want ErrAfterSideEffects", err)
}
if runs != 1 {
t.Fatalf("side effect ran %d times, want 1", runs)
}
}
// TestSideEffectResultIsDurableBeforeNextTool:同一批串行工具里,前一个副作用
// 工具的结果必须在后一个开始前就进会话——崩溃/重试时才能知道它已经跑过。
func TestSideEffectResultIsDurableBeforeNextTool(t *testing.T) {
var sessHasPrior bool
var sessionMessages func() []llm.Message
prov := &scriptedProvider{fn: func(call int, _ string, _ []llm.Message, _ []llm.ToolDef) (llm.Message, error) {
if call == 1 {
return llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{
{ID: "t1", Name: "first", Input: "{}"},
{ID: "t2", Name: "second", Input: "{}"},
}}, nil
}
return llm.Message{Role: llm.RoleAssistant, Content: "done"}, nil
}}
first := &tools.Tool{Name: "first", SideEffect: true, Kind: tools.KindExec, Run: func(context.Context, string) (tools.Result, error) {
return tools.TextResult("first-result"), nil
}}
second := &tools.Tool{Name: "second", SideEffect: true, Kind: tools.KindExec, Run: func(context.Context, string) (tools.Result, error) {
for _, m := range sessionMessages() {
if m.Role == llm.RoleTool && m.ToolCallID == "t1" {
sessHasPrior = true
}
}
return tools.TextResult("second-result"), nil
}}
a, s := newTestAgent(t, first, second)
a.Provider = prov
sessionMessages = func() []llm.Message { return s.Messages }
if err := a.Run(context.Background(), "go"); err != nil {
t.Fatal(err)
}
if !sessHasPrior {
t.Fatal("second tool ran before the first tool's result was persisted")
}
}
// TestResumeKeepsSideEffectFence:续跑一个已经执行过副作用工具的回合时,
// 失败仍然不能自动整轮重试——历史里的 tool 结果就是证据。
func TestResumeKeepsSideEffectFence(t *testing.T) {
prov := &scriptedProvider{fn: func(int, string, []llm.Message, []llm.ToolDef) (llm.Message, error) {
return llm.Message{}, errors.New("boom")
}}
side := &tools.Tool{Name: "sidetool", SideEffect: true, Kind: tools.KindExec, Run: func(context.Context, string) (tools.Result, error) {
t.Error("resume must not re-run the side-effect tool")
return tools.TextResult(""), nil
}}
a, sess := newTestAgent(t, side)
a.Provider = prov
sess.Append(llm.Message{Role: llm.RoleUser, Content: "go"})
sess.Append(llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "t1", Name: "sidetool"}}})
sess.Append(llm.Message{Role: llm.RoleTool, ToolCallID: "t1", Tool: "sidetool", Content: "ok"})
err := a.Run(context.Background(), "")
if !errors.Is(err, ErrAfterSideEffects) {
t.Fatalf("err = %v, want ErrAfterSideEffects", err)
}
}
// TestSideEffectsDoNotWrapCancellation:用户主动停止必须保持 context.Canceled,
// 不能被副作用标记包成普通错误——界面靠它区分"停止"和"出错"。
func TestSideEffectsDoNotWrapCancellation(t *testing.T) {
calls := 0
prov := &scriptedProvider{fn: func(int, string, []llm.Message, []llm.ToolDef) (llm.Message, error) {
calls++
if calls == 1 {
return llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{
{ID: "s1", Name: "sidetool", Input: "{}"},
}}, nil
}
return llm.Message{}, context.Canceled
}}
side := &tools.Tool{Name: "sidetool", SideEffect: true, Kind: tools.KindExec, Run: func(context.Context, string) (tools.Result, error) {
return tools.TextResult("done"), nil
}}
a, _ := newTestAgent(t, side)
a.Provider = prov
err := a.Run(context.Background(), "go")
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
if errors.Is(err, ErrAfterSideEffects) {
t.Fatalf("cancellation must not be wrapped as a retryable failure: %v", err)
}
}