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

+163
View File
@@ -0,0 +1,163 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
"git.gopher.ink/mirrors2/AutoZero/llm"
"git.gopher.ink/mirrors2/AutoZero/session"
"git.gopher.ink/mirrors2/AutoZero/tools"
)
// quote 把字符串编码为 JSON 字符串字面量(用于拼接工具参数)。
func quote(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
func newTestAgent(t *testing.T, extra ...*tools.Tool) (*Agent, *session.Session) {
t.Helper()
t.Setenv("HOME", t.TempDir())
reg := tools.NewRegistry()
for _, tl := range tools.Builtin() {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
for _, tl := range extra {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
sess, err := session.New()
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
a := &Agent{
Tools: reg,
Session: sess,
System: "test",
Opt: Options{Stdout: &out, Stderr: &out, ApproveAll: true},
}
return a, sess
}
// TestSynthMissingToolResults 验证为未配对的 tool_use 合成错误结果,且幂等。
func TestSynthMissingToolResults(t *testing.T) {
a, sess := newTestAgent(t)
sess.Messages = []llm.Message{
{Role: llm.RoleUser, Content: "go"},
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{
{ID: "c1", Name: "ls"}, {ID: "c2", Name: "grep"},
}},
{Role: llm.RoleTool, ToolCallID: "c1", Content: "ok"},
}
a.synthMissingToolResults()
var found bool
for _, m := range sess.Messages {
if m.ToolCallID == "c2" {
found = true
if m.Role != llm.RoleTool || m.Content == "" {
t.Fatalf("bad synthesized message: %+v", m)
}
}
}
if !found {
t.Fatal("missing tool_result for c2 was not synthesized")
}
// 幂等:重复调用不追加
n := len(sess.Messages)
a.synthMissingToolResults()
if len(sess.Messages) != n {
t.Fatalf("synth is not idempotent: %d -> %d", n, len(sess.Messages))
}
}
// TestOrchestrationOrderAndConcurrency:两段只读并发、中间夹一个写工具,
// 验证回填顺序与调用顺序一致,且总耗时接近并发而非串行。
func TestOrchestrationOrderAndConcurrency(t *testing.T) {
const delay = 150 * time.Millisecond
slow := func(name string) *tools.Tool {
return &tools.Tool{
Name: name,
Description: "slow read",
ReadOnly: true,
ConcurrencySafe: true,
Parameters: map[string]any{"type": "object"},
Run: func(_ context.Context, _ string) (tools.Result, error) {
time.Sleep(delay)
return tools.TextResult(name + " done"), nil
},
}
}
a, sess := newTestAgent(t, slow("ra"), slow("rb"), slow("rc"))
target := filepath.Join(t.TempDir(), "out.txt")
calls := []llm.ToolCall{
{ID: "1", Name: "ra"}, {ID: "2", Name: "rb"},
{ID: "3", Name: "write", Input: `{"path":` + quote(target) + `,"content":"x"}`},
{ID: "4", Name: "rc"},
}
start := time.Now()
if err := a.execTools(context.Background(), calls); err != nil {
t.Fatal(err)
}
elapsed := time.Since(start)
// 串行 >= 450ms(3×delay+写开销);并发批次 ~2×delay。留出余量断言。
if elapsed >= 4*delay {
t.Fatalf("calls appear serialized: elapsed=%v", elapsed)
}
// 回填必须与调用顺序一致且 ID 正确配对
results := sess.Messages[len(sess.Messages)-4:]
wantOrder := []string{"ra", "rb", "wrote", "rc"}
wantID := []string{"1", "2", "3", "4"}
for i, m := range results {
if m.Role != llm.RoleTool {
t.Fatalf("message %d is not a tool result: %+v", i, m)
}
if m.ToolCallID != wantID[i] {
t.Fatalf("result %d: got ToolCallID %q, want %q", i, m.ToolCallID, wantID[i])
}
if !strings.Contains(m.Content, wantOrder[i]) {
t.Fatalf("result %d content %q does not contain %q", i, m.Content, wantOrder[i])
}
}
}
// TestRunInterruptedPairing:assistant 发起多个调用但只回填了部分
// (模拟 Append 中途失败),synth 必须把缺口补齐。
func TestRunInterruptedPairing(t *testing.T) {
a, sess := newTestAgent(t)
// 构造:assistant 发起 3 个调用,只回填了 2 个(模拟 Append 中途失败)
sess.Messages = []llm.Message{
{Role: llm.RoleUser, Content: "go"},
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{
{ID: "a", Name: "ls"}, {ID: "b", Name: "grep"}, {ID: "c", Name: "read"},
}},
{Role: llm.RoleTool, ToolCallID: "a", Content: "ok"},
{Role: llm.RoleTool, ToolCallID: "b", Content: "ok"},
}
a.synthMissingToolResults()
for _, id := range []string{"a", "b", "c"} {
found := false
for _, m := range sess.Messages {
if m.ToolCallID == id && m.Role == llm.RoleTool {
found = true
}
}
if !found {
t.Fatalf("tool result for %q missing after synth", id)
}
}
}