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

+394
View File
@@ -0,0 +1,394 @@
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// AnthropicProvider 走 Anthropic Messages API(SSE 流式 + 工具调用)。
type AnthropicProvider struct {
BaseURL string
APIKey string
Model string
MaxTokens int // 单次回复上限;0 = 8192
HTTP *http.Client
}
// SetModel 运行时切换模型(ModelSwitcher)。
func (p *AnthropicProvider) SetModel(model string) {
p.Model = model
}
// CurrentModel 报告当前模型(CurrentModeler)。
func (p *AnthropicProvider) CurrentModel() string { return p.Model }
// Identity 自报协议与当前模型(Identifier)。
func (p *AnthropicProvider) Identity() (string, string) { return FormatAnthropic, p.Model }
type anBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content string `json:"content,omitempty"` // tool_result 内容
// Signature / Data 用于 thinking 回传签名与 redacted_thinking 块。
Signature string `json:"signature,omitempty"`
Data string `json:"data,omitempty"`
}
type anStreamEvent struct {
Type string `json:"type"`
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"` // thinking_delta 的推理文本
Signature string `json:"signature"` // signature_delta:thinking 块签名
PartialJSON string `json:"partial_json"` // input_json_delta
// StopReason 只在 message_delta 里出现(end_turn / max_tokens / tool_use …)。
// max_tokens 是"模型想写却写不下",见 Message.OutputLimited。
StopReason string `json:"stop_reason"`
} `json:"delta"`
ContentBlock anBlock `json:"content_block"`
Usage *struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
// 缓存明细:Anthropic 把命中/写入单列,**不计入** input_tokens。
CacheRead int `json:"cache_read_input_tokens"`
CacheWrite int `json:"cache_creation_input_tokens"`
} `json:"usage"`
Message *struct {
Usage struct {
InputTokens int `json:"input_tokens"`
CacheRead int `json:"cache_read_input_tokens"`
CacheWrite int `json:"cache_creation_input_tokens"`
} `json:"usage"`
} `json:"message"`
}
// anUsage 把 Anthropic 的分段计数归一成 Usage——口径见 llm.Usage 的注释:
// InputTokens 必须含缓存段。本文件确实给 system+tools 前缀打了 cache_control,
// 照抄 input_tokens 会把命中缓存的那一大段整个漏掉,终端计数与 stats 的
// TokensIn 因此低报一个数量级(压缩走字符估算,不受影响)。
func anUsage(input, cacheRead, cacheWrite, output int) *Usage {
return &Usage{
InputTokens: input + cacheRead + cacheWrite,
OutputTokens: output,
CacheReadTokens: cacheRead,
CacheCreationTokens: cacheWrite,
}
}
// thinkingBudget 返回 Anthropic 扩展思考的 token 预算;off/零值返回 ok=false。
func thinkingBudget(l ThinkingLevel) (int, bool) {
switch l {
case ThinkingLow:
return 2048, true
case ThinkingMedium:
return 8192, true
case ThinkingHigh:
return 16384, true
default:
return 0, false
}
}
func (p *AnthropicProvider) Stream(ctx context.Context, system string, msgs []Message, tools []ToolDef, opts StreamOptions, onDelta func(StreamDelta)) (Message, error) {
blocks := anBuildMessages(msgs, opts.Thinking.Enabled())
maxTokens := p.MaxTokens
if maxTokens <= 0 {
maxTokens = 8192
}
// 扩展思考:Anthropic 要求 max_tokens > budget_tokens,故按预算抬高上限,
// 并额外预留 8192 作为正文回答预算。
budget, thinking := thinkingBudget(opts.Thinking)
if thinking && maxTokens < budget+8192 {
maxTokens = budget + 8192
}
body := map[string]any{
"model": p.Model,
"max_tokens": maxTokens,
"messages": blocks,
}
if thinking {
body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget}
}
if system != "" || opts.SystemDynamic != "" {
// 稳定 system 单独成块并标记 cache_control:工具表 + 稳定前缀在同一
// 会话内逐字节不变,可命中 Anthropic prompt cache。动态部分(计划/记忆)
// 放在断点之后的第二块,它的变化不会让缓存前缀失效。
blocks := []map[string]any{}
if system != "" {
blocks = append(blocks, map[string]any{
"type": "text",
"text": system,
"cache_control": map[string]any{"type": "ephemeral"},
})
}
if opts.SystemDynamic != "" {
blocks = append(blocks, map[string]any{"type": "text", "text": opts.SystemDynamic})
}
body["system"] = blocks
}
if len(tools) > 0 {
ts := make([]map[string]any, 0, len(tools))
for _, t := range tools {
ts = append(ts, map[string]any{
"name": t.Name,
"description": t.Description,
"input_schema": t.Parameters,
})
}
// 最后一个工具上标记 cache_control,覆盖整个 tools 前缀。
ts[len(ts)-1]["cache_control"] = map[string]any{"type": "ephemeral"}
body["tools"] = ts
}
if opts.DisableTools && len(tools) > 0 {
body["tool_choice"] = map[string]any{"type": "none"}
}
payload, err := json.Marshal(body)
if err != nil {
return Message{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(p.BaseURL, "/")+"/v1/messages", bytes.NewReader(payload))
if err != nil {
return Message{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", p.APIKey)
req.Header.Set("anthropic-version", "2023-06-01")
resp, err := doWithRetry(ctx, p.http(), req, payload)
if err != nil {
return Message{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var buf bytes.Buffer
_, _ = buf.ReadFrom(io.LimitReader(resp.Body, 4096))
return Message{}, fmt.Errorf("anthropic: HTTP %d: %s", resp.StatusCode, buf.String())
}
final := Message{Role: RoleAssistant}
var st streamState
var curTool *ToolCall
// 分段计数在事件之间是分散给的:message_start 给提示侧(含缓存明细),
// message_delta 给输出侧,且常常**不重复**缓存字段。所以要学下来存着,
// 谁非零谁覆盖,否则归一出来的总量会漏掉 message_start 那一份。
var baseIn, cachedRead, cachedWrite int
sg := guardStream(resp.Body, streamIdleTimeout)
defer sg.Stop()
sc := bufio.NewScanner(sg)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
var ev anStreamEvent
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &ev); err != nil {
continue
}
switch ev.Type {
case "message_start":
if ev.Message != nil {
u := ev.Message.Usage
if u.InputTokens > 0 {
baseIn = u.InputTokens
}
if u.CacheRead > 0 {
cachedRead = u.CacheRead
}
if u.CacheWrite > 0 {
cachedWrite = u.CacheWrite
}
final.Usage = anUsage(baseIn, cachedRead, cachedWrite, 0)
}
case "content_block_start":
b := ev.ContentBlock
if b.Type == "tool_use" {
curTool = &ToolCall{ID: b.ID, Name: b.Name}
}
// 安全策略触发时返回脱敏思考块,需原样回传。
if b.Type == "redacted_thinking" && b.Data != "" {
final.RedactedThinking = b.Data
}
case "content_block_delta":
// 扩展思考:thinking_delta 携带推理文本,signature_delta 携带回传签名。
if ev.Delta.Type == "signature_delta" {
final.ReasoningSignature = ev.Delta.Signature
} else if ev.Delta.Type == "thinking_delta" && ev.Delta.Thinking != "" {
final.Reasoning += ev.Delta.Thinking
if onDelta != nil {
onDelta(StreamDelta{Reasoning: ev.Delta.Thinking})
}
} else if ev.Delta.Text != "" {
final.Content += ev.Delta.Text
if onDelta != nil {
onDelta(StreamDelta{Text: ev.Delta.Text})
}
} else if curTool != nil && ev.Delta.Type == "input_json_delta" {
curTool.Input += ev.Delta.PartialJSON
}
case "content_block_stop":
if curTool != nil {
final.ToolCalls = append(final.ToolCalls, *curTool)
// 参数到 content_block_stop 才算完整:提前告诉调用方,只读工具
// 可以趁模型继续生成后面的块先跑。
if onDelta != nil {
tc := *curTool
onDelta(StreamDelta{ToolCall: &tc})
}
curTool = nil
}
case "message_delta":
if marksOutputLimit(ev.Delta.StopReason) {
final.OutputLimited = true
}
if ev.Usage != nil {
st.usageSeen = true
if ev.Usage.InputTokens > 0 {
baseIn = ev.Usage.InputTokens
}
if ev.Usage.CacheRead > 0 {
cachedRead = ev.Usage.CacheRead
}
if ev.Usage.CacheWrite > 0 {
cachedWrite = ev.Usage.CacheWrite
}
final.Usage = anUsage(baseIn, cachedRead, cachedWrite, ev.Usage.OutputTokens)
}
case "message_stop":
st.doneSeen = true
return completion("anthropic", final, st)
}
}
if err := sc.Err(); err != nil {
if stall := sg.Err(); stall != nil {
return Message{}, fmt.Errorf("anthropic: %w", stall)
}
return Message{}, fmt.Errorf("anthropic: stream: %w", err)
}
// 没收到 message_stop 就结束,且是看门狗掐断的:绝不能把半截内容当成品返回。
if stall := sg.Err(); stall != nil {
return Message{}, fmt.Errorf("anthropic: %w", stall)
}
return completion("anthropic", final, st)
}
// anThinkingBlocks 构造需要回传的 thinking / redacted_thinking 块。
// 只在本次请求开启思考时回传:thinking 关闭时必须剥离,否则 Anthropic 会拒绝。
// 工具调用场景下不回传 thinking 块同样会被拒(要求完整原文 + 签名)。
func anThinkingBlocks(m Message) []map[string]any {
var out []map[string]any
if m.RedactedThinking != "" {
out = append(out, map[string]any{"type": "redacted_thinking", "data": m.RedactedThinking})
}
if m.Reasoning != "" {
block := map[string]any{"type": "thinking", "thinking": m.Reasoning}
if m.ReasoningSignature != "" {
block["signature"] = m.ReasoningSignature
}
out = append(out, block)
}
return out
}
func anBuildMessages(msgs []Message, thinking bool) []map[string]any {
out := []map[string]any{}
for _, m := range msgs {
switch {
case m.Role == RoleTool:
tr := map[string]any{"type": "tool_result", "tool_use_id": m.ToolCallID}
if len(m.Images) == 0 {
tr["content"] = m.Content
} else {
// tool_result.content 支持块数组:文本 + 图片(Anthropic 原生支持)。
content := []map[string]any{}
if m.Content != "" {
content = append(content, map[string]any{"type": "text", "text": m.Content})
}
content = append(content, anImageBlocks(m.Images)...)
tr["content"] = content
}
out = append(out, map[string]any{"role": "user", "content": []any{tr}})
case len(m.ToolCalls) > 0:
blocks := []any{}
// thinking 块必须是 assistant content 的第一个块。
if thinking {
for _, b := range anThinkingBlocks(m) {
blocks = append(blocks, b)
}
}
if m.Content != "" {
blocks = append(blocks, anBlock{Type: "text", Text: m.Content})
}
for _, tc := range m.ToolCalls {
input := json.RawMessage(tc.Input)
if len(input) == 0 {
input = json.RawMessage("{}")
}
blocks = append(blocks, anBlock{Type: "tool_use", ID: tc.ID, Name: tc.Name, Input: input})
}
out = append(out, map[string]any{"role": "assistant", "content": blocks})
case len(m.Images) > 0:
content := []map[string]any{}
if m.Content != "" {
content = append(content, map[string]any{"type": "text", "text": m.Content})
}
content = append(content, anImageBlocks(m.Images)...)
out = append(out, map[string]any{"role": string(m.Role), "content": content})
default:
// 纯文本 assistant 消息也可能带思考,同样需要回传(块数组形式)。
if thinking && (m.Reasoning != "" || m.RedactedThinking != "") {
content := []any{}
for _, b := range anThinkingBlocks(m) {
content = append(content, b)
}
if m.Content != "" {
content = append(content, map[string]any{"type": "text", "text": m.Content})
}
out = append(out, map[string]any{"role": string(m.Role), "content": content})
continue
}
out = append(out, map[string]any{"role": string(m.Role), "content": m.Content})
}
}
return out
}
// anImageBlocks 构造 Anthropic image 块;读取失败的图降级为文本说明。
// 支持两种图片引用方式:
// 1. Filename: 从缓存目录加载(优先)
// 2. Path: 直接文件路径(兼容旧格式)
func anImageBlocks(imgs []Image) []map[string]any {
blocks := []map[string]any{}
for _, img := range imgs {
b64, mt, err := loadImageRef(img)
if err != nil {
blocks = append(blocks, map[string]any{"type": "text", "text": imageUnavailable(img, err)})
continue
}
blocks = append(blocks, map[string]any{
"type": "image",
"source": map[string]any{"type": "base64", "media_type": mt, "data": b64},
})
}
return blocks
}
func (p *AnthropicProvider) http() *http.Client {
if p.HTTP != nil {
return p.HTTP
}
return defaultHTTPClient
}