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
+374
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user