Files
Sakurasan d5dac5a6fc 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。
2026-10-26 00:54:46 +08:00

176 lines
5.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}