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

+374
View File
@@ -0,0 +1,374 @@
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// imageCache 是全局图片缓存实例。
var imageCache ImageCache
// ImageCache 是图片缓存接口。
type ImageCache interface {
LoadWithMIME(filename string) (string, string, error)
}
// SetImageCache 设置全局图片缓存。
func SetImageCache(cache ImageCache) {
imageCache = cache
}
// OpenAIProvider 走 OpenAI 兼容的 /chat/completions 协议(含大多数国产网关)。
type OpenAIProvider struct {
BaseURL string
APIKey string
Model string
HTTP *http.Client
}
type oaFunction struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// modelListResponse 是 OpenAI 兼容 GET /models 的响应。
type modelListResponse struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
// SetModel 运行时切换模型(ModelSwitcher)。
func (p *OpenAIProvider) SetModel(model string) {
p.Model = model
}
// CurrentModel 报告当前模型(CurrentModeler)。
func (p *OpenAIProvider) CurrentModel() string { return p.Model }
// Identity 自报协议与当前模型(Identifier)。
func (p *OpenAIProvider) Identity() (string, string) { return FormatChatCompletions, p.Model }
// ListModels 枚举网关可用模型(ModelLister)。
func (p *OpenAIProvider) ListModels(ctx context.Context) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
strings.TrimRight(p.BaseURL, "/")+"/models", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+p.APIKey)
resp, err := p.http().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var buf bytes.Buffer
_, _ = buf.ReadFrom(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("openai: ListModels: HTTP %d: %s", resp.StatusCode, buf.String())
}
var out modelListResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
models := make([]string, 0, len(out.Data))
for _, d := range out.Data {
if d.ID != "" {
models = append(models, d.ID)
}
}
return models, nil
}
type oaChunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
// 思考内容:网关字段名不统一(DeepSeek 等用 reasoning_content,
// 部分 OpenAI 兼容网关用 reasoning),两者都收。
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Function oaFunction `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
// 明细:Chat Completions 的 prompt_tokens 已含缓存命中部分,这里只取 breakdown。
// 兼容网关不一定返回,缺省为 0。
PromptDetails struct {
Cached int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
CompletionDetails struct {
Reasoning int `json:"reasoning_tokens"`
} `json:"completion_tokens_details"`
} `json:"usage"`
}
func (p *OpenAIProvider) Stream(ctx context.Context, system string, msgs []Message, tools []ToolDef, opts StreamOptions, onDelta func(StreamDelta)) (Message, error) {
body := map[string]any{
"model": p.Model,
"messages": oaBuildMessages(joinSystem(system, opts.SystemDynamic), msgs),
"stream": true,
"stream_options": map[string]any{"include_usage": true},
}
if len(tools) > 0 {
body["tools"] = oaBuildTools(tools)
// 只禁用调用、不撤定义:工具数组保持不变,prompt cache 前缀才能命中。
if opts.DisableTools {
body["tool_choice"] = "none"
}
}
// 思考级别:仅开启时发送,默认关闭保持请求体不变。
if opts.Thinking.Enabled() {
body["reasoning_effort"] = string(opts.Thinking)
}
payload, err := json.Marshal(body)
if err != nil {
return Message{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(p.BaseURL, "/")+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return Message{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.APIKey)
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("openai: HTTP %d: %s", resp.StatusCode, buf.String())
}
final := Message{Role: RoleAssistant}
var callsByIndex = map[int]*ToolCall{}
maxIndex := -1
emitted := map[int]bool{}
// emitReady 把 index < upTo 的工具调用视为参数完整并提前通知。
// OpenAI 的增量协议没有显式"调用结束"事件:下一个 index 出现、或
// finish_reason 到达,前一个调用的参数就完整了。
emitReady := func(upTo int) {
if onDelta == nil {
return
}
for i := 0; i < upTo; i++ {
if emitted[i] {
continue
}
c := callsByIndex[i]
if c == nil || c.Name == "" {
continue
}
emitted[i] = true
tc := *c
onDelta(StreamDelta{ToolCall: &tc})
}
}
sg := guardStream(resp.Body, streamIdleTimeout)
defer sg.Stop()
sc := bufio.NewScanner(sg)
sc.Buffer(make([]byte, 1<<20), 1<<20)
var st streamState
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
st.doneSeen = true
break
}
var ck oaChunk
if err := json.Unmarshal([]byte(data), &ck); err != nil {
continue
}
if ck.Usage != nil {
st.usageSeen = true
final.Usage = &Usage{
InputTokens: ck.Usage.PromptTokens,
OutputTokens: ck.Usage.CompletionTokens,
CacheReadTokens: ck.Usage.PromptDetails.Cached,
ReasoningTokens: ck.Usage.CompletionDetails.Reasoning,
}
}
for _, ch := range ck.Choices {
if ch.FinishReason != "" {
st.doneSeen = true // finish_reason 也是终止证据
if marksOutputLimit(ch.FinishReason) {
final.OutputLimited = true
}
}
reasoning := ch.Delta.ReasoningContent
if reasoning == "" {
reasoning = ch.Delta.Reasoning
}
if reasoning != "" {
final.Reasoning += reasoning
if onDelta != nil {
onDelta(StreamDelta{Reasoning: reasoning})
}
}
if ch.Delta.Content != "" {
final.Content += ch.Delta.Content
if onDelta != nil {
onDelta(StreamDelta{Text: ch.Delta.Content})
}
}
for _, tc := range ch.Delta.ToolCalls {
cur := callsByIndex[tc.Index]
if cur == nil {
cur = &ToolCall{}
callsByIndex[tc.Index] = cur
}
if tc.Index > maxIndex {
maxIndex = tc.Index
}
if tc.ID != "" {
cur.ID = tc.ID
}
if tc.Function.Name != "" {
cur.Name = tc.Function.Name
}
if tc.Function.Arguments != "" {
cur.Input += tc.Function.Arguments
}
emitReady(tc.Index)
}
if ch.FinishReason != "" {
emitReady(maxIndex + 1)
}
}
}
if err := sc.Err(); err != nil {
// 被静默看门狗掐断时,说真实原因:那句 "read on closed response body"
// 对排查毫无帮助,看起来像我们自己的连接坏了。
if stall := sg.Err(); stall != nil {
return Message{}, fmt.Errorf("openai: %w", stall)
}
return Message{}, fmt.Errorf("openai: stream: %w", err)
}
// 没等到 [DONE] 就结束,且是看门狗掐断的:不能把半截回复当完整结果返回。
if stall := sg.Err(); stall != nil {
return Message{}, fmt.Errorf("openai: %w", stall)
}
// 流结束还没发完的通知(例如只有一个 tool call、没有下一个 index)在这里补齐。
emitReady(maxIndex + 1)
for i := 0; i <= maxIndex; i++ {
if c, ok := callsByIndex[i]; ok {
final.ToolCalls = append(final.ToolCalls, *c)
}
}
return completion("openai", final, st)
}
// joinSystem 把动态 system 追加到稳定 system 尾部。只要动态段在整条消息的末尾,
// 稳定前缀仍然逐 token 相同,OpenAI 的自动前缀缓存就能继续命中;用一条消息也
// 避免部分兼容网关不接受多条 system 消息。
func joinSystem(system, dynamic string) string {
if dynamic == "" {
return system
}
if system == "" {
return dynamic
}
return system + "\n\n" + dynamic
}
func oaBuildMessages(system string, msgs []Message) []map[string]any {
out := []map[string]any{}
if system != "" {
out = append(out, map[string]any{"role": "system", "content": system})
}
for _, m := range msgs {
switch {
case m.Role == RoleTool:
out = append(out, map[string]any{"role": "tool", "content": m.Content, "tool_call_id": m.ToolCallID})
// OpenAI tool 消息不支持图片:以 user 消息附图(紧随 tool result)。
if len(m.Images) > 0 {
out = append(out, map[string]any{
"role": "user",
"content": oaContentParts("[image(s) attached from tool result]", m.Images),
})
}
case len(m.ToolCalls) > 0:
tcs := []map[string]any{}
for _, tc := range m.ToolCalls {
tcs = append(tcs, map[string]any{
"id": tc.ID, "type": "function",
"function": map[string]any{"name": tc.Name, "arguments": tc.Input},
})
}
out = append(out, map[string]any{"role": "assistant", "content": m.Content, "tool_calls": tcs})
case len(m.Images) > 0:
out = append(out, map[string]any{"role": string(m.Role), "content": oaContentParts(m.Content, m.Images)})
default:
out = append(out, map[string]any{"role": string(m.Role), "content": m.Content})
}
}
return out
}
// oaContentParts 构造 OpenAI 多模态 content 数组;读取失败的图降级为文本说明。
// 支持两种图片引用方式:
// 1. Filename: 从缓存目录加载(优先)
// 2. Path: 直接文件路径(兼容旧格式)
func oaContentParts(text string, imgs []Image) []map[string]any {
parts := []map[string]any{}
if text != "" {
parts = append(parts, map[string]any{"type": "text", "text": text})
}
for _, img := range imgs {
b64, mt, err := loadImageRef(img)
if err != nil {
parts = append(parts, map[string]any{"type": "text", "text": imageUnavailable(img, err)})
continue
}
parts = append(parts, map[string]any{
"type": "image_url",
"image_url": map[string]any{"url": "data:" + mt + ";base64," + b64},
})
}
return parts
}
func oaBuildTools(tools []ToolDef) []map[string]any {
out := make([]map[string]any, 0, len(tools))
for _, t := range tools {
out = append(out, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": t.Parameters,
},
})
}
return out
}
func (p *OpenAIProvider) http() *http.Client {
if p.HTTP != nil {
return p.HTTP
}
return defaultHTTPClient
}