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

+175
View File
@@ -0,0 +1,175 @@
package agent
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.gopher.ink/mirrors2/AutoZero/llm"
"git.gopher.ink/mirrors2/AutoZero/session"
"git.gopher.ink/mirrors2/AutoZero/tools"
)
// TestModelCallTrace:每轮模型调用都应记录 StartedAt/DurationMs/Step。
func TestModelCallTrace(t *testing.T) {
prov := &scriptedProvider{fn: func(_ int, _ string, _ []llm.Message, _ []llm.ToolDef) (llm.Message, error) {
time.Sleep(6 * time.Millisecond)
return llm.Message{Role: llm.RoleAssistant, Content: "done"}, nil
}}
a := &Agent{
Provider: prov,
Tools: tools.NewRegistry(),
Session: &session.Session{},
System: "sys",
Opt: Options{Stdout: io.Discard, Stderr: io.Discard},
}
if err := a.Run(context.Background(), "hi"); err != nil {
t.Fatal(err)
}
msgs := a.Session.Messages
last := msgs[len(msgs)-1]
if last.Role != llm.RoleAssistant {
t.Fatalf("last message should be assistant, got %s", last.Role)
}
if last.StartedAt == 0 {
t.Error("assistant StartedAt not recorded")
}
if last.DurationMs < 5 {
t.Errorf("assistant DurationMs = %d, want >= 5", last.DurationMs)
}
if last.Step != 1 {
t.Errorf("assistant Step = %d, want 1", last.Step)
}
}
// slowTool 是一个可计时的假工具,delay 控制耗时。
func slowTool(name string, ro, safe bool, delay time.Duration) *tools.Tool {
return &tools.Tool{
Name: name,
Parameters: map[string]any{"type": "object"},
ReadOnly: ro,
ConcurrencySafe: safe,
Run: func(_ context.Context, _ string) (tools.Result, error) {
time.Sleep(delay)
return tools.TextResult("ok"), nil
},
}
}
// toolMsgFor 取某次工具调用的回填消息。
func toolMsgFor(sess *session.Session, id string) (llm.Message, bool) {
for _, m := range sess.Messages {
if m.Role == llm.RoleTool && m.ToolCallID == id {
return m, true
}
}
return llm.Message{}, false
}
// TestToolCallTrace:工具耗时与所属轮次写进 tool 消息,且 done 事件带耗时。
func TestToolCallTrace(t *testing.T) {
a, sess := newTestAgent(t, slowTool("slow", false, false, 6*time.Millisecond))
a.curStep = 3 // 模拟处于第 3 次模型调用
var events []ToolEvent
a.Opt.OnTool = func(ev ToolEvent) { events = append(events, ev) }
if err := a.execTools(context.Background(), []llm.ToolCall{{ID: "c1", Name: "slow", Input: "{}"}}); err != nil {
t.Fatal(err)
}
msg, ok := toolMsgFor(sess, "c1")
if !ok {
t.Fatal("tool result message not appended")
}
if msg.StartedAt == 0 || msg.DurationMs < 5 {
t.Errorf("tool trace missing: %+v", msg)
}
if msg.Step != 3 {
t.Errorf("tool Step = %d, want 3 (parent turn)", msg.Step)
}
// done 事件应带耗时(供实时 UI 使用)。
var done *ToolEvent
for i := range events {
if events[i].ID == "c1" && events[i].Done {
done = &events[i]
}
}
if done == nil {
t.Fatal("no done event for c1")
}
if done.DurationMs < 5 || done.StartedAt == 0 {
t.Errorf("done event missing trace: %+v", *done)
}
}
// TestConcurrentToolTraceAttribution:并发批次的耗时按各自索引归属,不串号。
func TestConcurrentToolTraceAttribution(t *testing.T) {
a, sess := newTestAgent(t,
slowTool("fast", true, true, 5*time.Millisecond),
slowTool("slowish", true, true, 30*time.Millisecond),
)
calls := []llm.ToolCall{
{ID: "c1", Name: "fast", Input: "{}"},
{ID: "c2", Name: "slowish", Input: "{}"},
}
if err := a.execTools(context.Background(), calls); err != nil {
t.Fatal(err)
}
fast, _ := toolMsgFor(sess, "c1")
slowish, _ := toolMsgFor(sess, "c2")
if fast.DurationMs <= 0 || slowish.DurationMs <= 0 {
t.Fatalf("durations not recorded: fast=%d slowish=%d", fast.DurationMs, slowish.DurationMs)
}
if fast.DurationMs >= slowish.DurationMs {
t.Errorf("durations attributed to the wrong call: fast=%d slowish=%d", fast.DurationMs, slowish.DurationMs)
}
}
// TestModelIdentityStamped:assistant 消息落盘要带「协议 + 模型」归因戳,
// 缓存明细也得跟着落进会话文件;没实现 Identifier 的 provider(测试替身)
// 必须留空而不是炸掉。
func TestModelIdentityStamped(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n")
io.WriteString(w, "data: {\"choices\":[{\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":11,"+
"\"completion_tokens\":2,\"prompt_tokens_details\":{\"cached_tokens\":9}}}\n\n")
io.WriteString(w, "data: [DONE]\n\n")
}))
defer srv.Close()
t.Setenv("HOME", t.TempDir())
a := &Agent{
Provider: &llm.OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "gpt-test"},
Tools: tools.NewRegistry(), Session: &session.Session{}, System: "sys",
Opt: Options{Stdout: io.Discard, Stderr: io.Discard},
}
if err := a.Run(context.Background(), "hi"); err != nil {
t.Fatal(err)
}
last := a.Session.Messages[len(a.Session.Messages)-1]
if last.Format != llm.FormatChatCompletions || last.Model != "gpt-test" {
t.Errorf("归因戳 = %q/%q, want chat_completions/gpt-test", last.Format, last.Model)
}
if last.Usage == nil || last.Usage.CacheReadTokens != 9 || last.Usage.InputTokens != 11 {
t.Errorf("明细没落到会话:%+v", last.Usage)
}
prov := &scriptedProvider{fn: func(_ int, _ string, _ []llm.Message, _ []llm.ToolDef) (llm.Message, error) {
return llm.Message{Role: llm.RoleAssistant, Content: "x"}, nil
}}
b := &Agent{Provider: prov, Tools: tools.NewRegistry(), Session: &session.Session{}, System: "sys",
Opt: Options{Stdout: io.Discard, Stderr: io.Discard}}
if err := b.Run(context.Background(), "hi"); err != nil {
t.Fatal(err)
}
end := b.Session.Messages[len(b.Session.Messages)-1]
if end.Format != "" || end.Model != "" {
t.Errorf("未实现 Identifier 时不该有戳:%q/%q", end.Format, end.Model)
}
}