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:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
@@ -0,0 +1,249 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseThinkingLevelAndEnabled(t *testing.T) {
|
||||
cases := map[string]ThinkingLevel{
|
||||
"": ThinkingOff,
|
||||
"off": ThinkingOff,
|
||||
"LOW": ThinkingLow,
|
||||
" medium ": ThinkingMedium,
|
||||
"High": ThinkingHigh,
|
||||
"bogus": ThinkingOff,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := ParseThinkingLevel(in); got != want {
|
||||
t.Fatalf("ParseThinkingLevel(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
// 零值 "" 必须视为关闭——Enabled 是唯一正确判据。
|
||||
var zero ThinkingLevel
|
||||
if zero.Enabled() {
|
||||
t.Fatal("zero value must not be enabled")
|
||||
}
|
||||
if ThinkingOff.Enabled() || !ThinkingLow.Enabled() || !ThinkingMedium.Enabled() || !ThinkingHigh.Enabled() {
|
||||
t.Fatal("Enabled() mapping wrong")
|
||||
}
|
||||
if IsThinkingLevel("bogus") || IsThinkingLevel("") || !IsThinkingLevel("off") || !IsThinkingLevel("HIGH") {
|
||||
t.Fatal("IsThinkingLevel mapping wrong")
|
||||
}
|
||||
}
|
||||
|
||||
// captureServer 返回一个记录最后一次请求体的 SSE 服务器。
|
||||
func captureServer(body *map[string]any, sse string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(body)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(sse))
|
||||
}))
|
||||
}
|
||||
|
||||
func TestOpenAIReasoningEffort(t *testing.T) {
|
||||
var body map[string]any
|
||||
srv := captureServer(&body, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
|
||||
defer srv.Close()
|
||||
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
|
||||
|
||||
if _, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{Thinking: ThinkingMedium}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["reasoning_effort"] != "medium" {
|
||||
t.Fatalf("reasoning_effort = %v, want medium", body["reasoning_effort"])
|
||||
}
|
||||
|
||||
body = nil
|
||||
if _, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := body["reasoning_effort"]; ok {
|
||||
t.Fatalf("off must not send reasoning_effort: %v", body["reasoning_effort"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesReasoning(t *testing.T) {
|
||||
var body map[string]any
|
||||
sse := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n"
|
||||
srv := captureServer(&body, sse)
|
||||
defer srv.Close()
|
||||
p := &ResponsesProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
|
||||
|
||||
if _, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{Thinking: ThinkingHigh}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, ok := body["reasoning"].(map[string]any)
|
||||
if !ok || r["effort"] != "high" {
|
||||
t.Fatalf("reasoning = %v, want effort=high", body["reasoning"])
|
||||
}
|
||||
if body["max_output_tokens"].(float64) != 16384 {
|
||||
t.Fatalf("max_output_tokens = %v, want 16384 when thinking on", body["max_output_tokens"])
|
||||
}
|
||||
|
||||
body = nil
|
||||
if _, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := body["reasoning"]; ok {
|
||||
t.Fatalf("off must not send reasoning: %v", body["reasoning"])
|
||||
}
|
||||
if body["max_output_tokens"].(float64) != 8192 {
|
||||
t.Fatalf("max_output_tokens = %v, want 8192 when off", body["max_output_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIReasoningDelta(t *testing.T) {
|
||||
srv := captureServer(&map[string]any{},
|
||||
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"R1\"}}]}\n\n"+
|
||||
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"R2\"}}]}\n\n"+
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"answer\"}}]}\n\n"+
|
||||
"data: [DONE]\n\n")
|
||||
defer srv.Close()
|
||||
|
||||
var got, reasoning string
|
||||
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
|
||||
final, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{Thinking: ThinkingLow},
|
||||
func(d StreamDelta) {
|
||||
got += d.Text
|
||||
reasoning += d.Reasoning
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if final.Reasoning != "R1R2" || reasoning != "R1R2" {
|
||||
t.Fatalf("reasoning = %q / %q, want R1R2", final.Reasoning, reasoning)
|
||||
}
|
||||
if final.Content != "answer" || got != "answer" {
|
||||
t.Fatalf("content = %q / %q", final.Content, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicThinkingDelta(t *testing.T) {
|
||||
srv := captureServer(&map[string]any{},
|
||||
"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"T1\"}}\n\n"+
|
||||
"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"signature_delta\",\"signature\":\"SIG\"}}\n\n"+
|
||||
"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n"+
|
||||
"data: {\"type\":\"message_stop\"}\n\n")
|
||||
defer srv.Close()
|
||||
|
||||
var reasoning string
|
||||
p := &AnthropicProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
|
||||
final, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{Thinking: ThinkingMedium},
|
||||
func(d StreamDelta) { reasoning += d.Reasoning })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if final.Reasoning != "T1" || reasoning != "T1" {
|
||||
t.Fatalf("reasoning = %q / %q", final.Reasoning, reasoning)
|
||||
}
|
||||
if final.Content != "hi" {
|
||||
t.Fatalf("content = %q", final.Content)
|
||||
}
|
||||
// 签名必须留存,否则工具调用场景下无法回传 thinking 块。
|
||||
if final.ReasoningSignature != "SIG" {
|
||||
t.Fatalf("signature = %q, want SIG", final.ReasoningSignature)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnthropicThinkingRoundTrip:工具调用时,上一轮 assistant 的 thinking 块
|
||||
// (原文 + 签名)必须原样回传且位于首位;关闭思考时必须剥离。
|
||||
func TestAnthropicThinkingRoundTrip(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: RoleUser, Content: "hi"},
|
||||
{
|
||||
Role: RoleAssistant, Content: "let me check", Reasoning: "R", ReasoningSignature: "SIG",
|
||||
ToolCalls: []ToolCall{{ID: "t1", Name: "read", Input: `{}`}},
|
||||
},
|
||||
{Role: RoleTool, ToolCallID: "t1", Content: "out"},
|
||||
}
|
||||
|
||||
out := anBuildMessages(msgs, true)
|
||||
content := out[1]["content"].([]any)
|
||||
if len(content) < 3 {
|
||||
t.Fatalf("expected thinking+text+tool_use, got %v", content)
|
||||
}
|
||||
think := content[0].(map[string]any)
|
||||
if think["type"] != "thinking" || think["thinking"] != "R" || think["signature"] != "SIG" {
|
||||
t.Fatalf("thinking block = %v", think)
|
||||
}
|
||||
|
||||
out = anBuildMessages(msgs, false)
|
||||
content = out[1]["content"].([]any)
|
||||
for _, b := range content {
|
||||
if m, ok := b.(map[string]any); ok && m["type"] == "thinking" {
|
||||
t.Fatal("thinking must be stripped when thinking is off")
|
||||
}
|
||||
}
|
||||
|
||||
// redacted_thinking 同样回传。
|
||||
out = anBuildMessages([]Message{{Role: RoleAssistant, Content: "x", RedactedThinking: "D"}}, true)
|
||||
content = out[0]["content"].([]any)
|
||||
red := content[0].(map[string]any)
|
||||
if red["type"] != "redacted_thinking" || red["data"] != "D" {
|
||||
t.Fatalf("redacted block = %v", red)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesReasoningDelta(t *testing.T) {
|
||||
srv := captureServer(&map[string]any{},
|
||||
"data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"S1\"}\n\n"+
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n"+
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
|
||||
defer srv.Close()
|
||||
|
||||
var reasoning string
|
||||
p := &ResponsesProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", HTTP: srv.Client()}
|
||||
final, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{Thinking: ThinkingHigh},
|
||||
func(d StreamDelta) { reasoning += d.Reasoning })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if final.Reasoning != "S1" || reasoning != "S1" {
|
||||
t.Fatalf("reasoning = %q / %q", final.Reasoning, reasoning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicThinkingBudget(t *testing.T) {
|
||||
wantBudget := map[ThinkingLevel]float64{
|
||||
ThinkingLow: 2048,
|
||||
ThinkingMedium: 8192,
|
||||
ThinkingHigh: 16384,
|
||||
}
|
||||
for level, budget := range wantBudget {
|
||||
var body map[string]any
|
||||
srv := captureServer(&body, "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n")
|
||||
p := &AnthropicProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", MaxTokens: 4096, HTTP: srv.Client()}
|
||||
if _, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{Thinking: level}, nil); err != nil {
|
||||
t.Fatalf("%s: %v", level, err)
|
||||
}
|
||||
srv.Close()
|
||||
|
||||
think, ok := body["thinking"].(map[string]any)
|
||||
if !ok || think["type"] != "enabled" || think["budget_tokens"].(float64) != budget {
|
||||
t.Fatalf("%s: thinking = %v, want budget %v", level, body["thinking"], budget)
|
||||
}
|
||||
mt := body["max_tokens"].(float64)
|
||||
if mt <= budget {
|
||||
t.Fatalf("%s: max_tokens %v must exceed budget %v", level, mt, budget)
|
||||
}
|
||||
}
|
||||
|
||||
// off:不发字段,max_tokens 保持配置值。
|
||||
var body map[string]any
|
||||
srv := captureServer(&body, "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n")
|
||||
defer srv.Close()
|
||||
p := &AnthropicProvider{BaseURL: srv.URL, APIKey: "k", Model: "m", MaxTokens: 4096, HTTP: srv.Client()}
|
||||
if _, err := p.Stream(context.Background(), "sys", nil, nil, StreamOptions{}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := body["thinking"]; ok {
|
||||
t.Fatalf("off must not send thinking: %v", body["thinking"])
|
||||
}
|
||||
if body["max_tokens"].(float64) != 4096 {
|
||||
t.Fatalf("max_tokens = %v, want 4096 when off", body["max_tokens"])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user