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

+172
View File
@@ -0,0 +1,172 @@
package llm
import (
"context"
"encoding/json"
"strings"
"testing"
)
// 三家 provider 的 token 记账必须归一到同一口径(见 Usage 注释):
// InputTokens 含缓存段。Anthropic 尤其容易写错——它把命中/写入单列,
// 照抄 input_tokens 就会在缓存命中时把 10 万上下文记成 5 千。
func TestAnthropicUsageIncludesCache(t *testing.T) {
sse := strings.Join([]string{
`data: {"type":"message_start","message":{"usage":{"input_tokens":120,"cache_read_input_tokens":90000,"cache_creation_input_tokens":500}}}`,
"",
`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
"",
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`,
"",
`data: {"type":"content_block_stop","index":0}`,
"",
// message_delta 常常不回缓存明细:必须沿用 message_start 学到的数
`data: {"type":"message_delta","usage":{"output_tokens":7}}`,
"",
`data: {"type":"message_stop"}`,
"",
}, "\n")
var got map[string]any
srv := captureServer(&got, sse)
defer srv.Close()
p := &AnthropicProvider{BaseURL: srv.URL, APIKey: "k", Model: "claude-x"}
msg, err := p.Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if err != nil {
t.Fatal(err)
}
u := msg.Usage
if u == nil {
t.Fatal("没有 usage")
}
if u.InputTokens != 90620 {
t.Errorf("InputTokens = %d, want 90620(120 + cache_read 90000 + cache_write 500)", u.InputTokens)
}
if u.CacheReadTokens != 90000 || u.CacheCreationTokens != 500 {
t.Errorf("缓存明细 = %d/%d, want 90000/500", u.CacheReadTokens, u.CacheCreationTokens)
}
if u.OutputTokens != 7 {
t.Errorf("OutputTokens = %d, want 7", u.OutputTokens)
}
if msg.Content != "ok" {
t.Errorf("Content = %q", msg.Content)
}
}
func TestOpenAIUsageDetails(t *testing.T) {
sse := strings.Join([]string{
`data: {"choices":[{"delta":{"content":"hi"}}]}`,
"",
`data: {"choices":[{"finish_reason":"stop"}],"usage":{"prompt_tokens":1000,"completion_tokens":20,` +
`"prompt_tokens_details":{"cached_tokens":950},"completion_tokens_details":{"reasoning_tokens":8}}}`,
"",
`data: [DONE]`,
"",
}, "\n")
var got map[string]any
srv := captureServer(&got, sse)
defer srv.Close()
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "gpt-x"}
msg, err := p.Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if err != nil {
t.Fatal(err)
}
u := msg.Usage
if u == nil {
t.Fatal("没有 usage")
}
// prompt_tokens 本身已含缓存:总量不许二次相加。
if u.InputTokens != 1000 || u.CacheReadTokens != 950 || u.ReasoningTokens != 8 || u.OutputTokens != 20 {
t.Errorf("usage 归一错误:%+v", u)
}
}
func TestResponsesUsageDetails(t *testing.T) {
sse := strings.Join([]string{
`data: {"type":"response.output_text.delta","delta":"hi"}`,
"",
`data: {"type":"response.completed","response":{"status":"completed","usage":` +
`{"input_tokens":800,"output_tokens":15,"input_tokens_details":{"cached_tokens":700},` +
`"output_tokens_details":{"reasoning_tokens":5}}}}`,
"",
}, "\n")
var got map[string]any
srv := captureServer(&got, sse)
defer srv.Close()
p := &ResponsesProvider{BaseURL: srv.URL, APIKey: "k", Model: "rsp-x"}
msg, err := p.Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if err != nil {
t.Fatal(err)
}
u := msg.Usage
if u == nil {
t.Fatal("没有 usage")
}
if u.InputTokens != 800 || u.CacheReadTokens != 700 || u.ReasoningTokens != 5 || u.OutputTokens != 15 {
t.Errorf("usage 归一错误:%+v", u)
}
}
// TestProviderIdentity:三家都自报协议与当前模型(会话落盘的归因戳)。
// 换了模型(SetModel)之后 Identity 必须跟着变,否则戳是假的。
func TestProviderIdentity(t *testing.T) {
var cases = []struct {
p Provider
wantFmt string
wantAttr string
}{
{&OpenAIProvider{Model: "gpt-a"}, FormatChatCompletions, "gpt-a"},
{&AnthropicProvider{Model: "claude-a"}, FormatAnthropic, "claude-a"},
{&ResponsesProvider{Model: "rsp-a"}, FormatResponses, "rsp-a"},
}
for _, c := range cases {
id, ok := c.p.(Identifier)
if !ok {
t.Fatalf("%T 没实现 Identifier", c.p)
}
f, m := id.Identity()
if f != c.wantFmt || m != c.wantAttr {
t.Errorf("%T Identity = %q/%q, want %q/%q", c.p, f, m, c.wantFmt, c.wantAttr)
}
if sw, ok := c.p.(ModelSwitcher); !ok {
t.Errorf("%T 应可切模型", c.p)
} else {
sw.SetModel("后到")
if _, m := id.Identity(); m != "后到" {
t.Errorf("SetModel 后 Identity 仍报 %q", m)
}
}
}
}
// TestUsageSerializesIntoTranscript:新增明细字段要能落进会话文件,
// 同时不给旧消息添噪声(omitempty)。
func TestUsageSerializesIntoTranscript(t *testing.T) {
old := Message{Role: RoleAssistant, Usage: &Usage{InputTokens: 5, OutputTokens: 2}}
b, err := json.Marshal(old)
if err != nil {
t.Fatal(err)
}
for _, noise := range []string{"cache_read_tokens", "reasoning_tokens", "format", "model"} {
if strings.Contains(string(b), noise) {
t.Errorf("零值不该序列化出 %q:%s", noise, b)
}
}
withCache := Message{Role: RoleAssistant, Format: FormatAnthropic, Model: "claude-x",
Usage: &Usage{InputTokens: 90620, OutputTokens: 7, CacheReadTokens: 90000}}
b2, err := json.Marshal(withCache)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"cache_read_tokens", "90000", `"format":"anthropic"`, `"model":"claude-x"`} {
if !strings.Contains(string(b2), want) {
t.Errorf("序列化缺 %q:%s", want, b2)
}
}
}