feat(ai): Agent SSE + Anthropic/responses tools + P1 确认写

- Agent 支持 SSE(步骤边推、正文分块),JSON 非流式 fallback
- tool calling 覆盖 openai-chat / anthropic / responses(单测解析回灌)
- P1:mcp.json mode=read_write 才可写;需 confirm_danger;Agent 剥离模型自带令牌
- GET /api/ai/mcp-status、POST /api/ai/agent/confirm;审计 source=mcp|agent
This commit is contained in:
logan committed 2026-10-15 13:00:40 +08:00
1 parent 1d6e89449a
commit 2336c9433c
17 files changed
+1324 -197

No files matched your search

+276 -31
View File
@@ -24,35 +24,33 @@ type ToolChat interface {
ChatWithTools(ctx context.Context, model string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error)
}
// ChatWithTools openai-chat 兼容协议的 tools 调用(DeepSeek / OpenAI / 多数兼容网关)。
// anthropic / responses 本轮不支持 Agent tool calling,返回明确错误。
// ChatWithTools 按协议分派:openai-chat / anthropic messages / openai-responses。
func (p *RemoteProvider) ChatWithTools(ctx context.Context, model string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error) {
if err := validateEndpoint(p.baseURL, p.allowHTTP); err != nil {
return ToolsTurn{}, err
}
if p.protocol != "" && p.protocol != ProtoChat {
return ToolsTurn{}, fmt.Errorf("Agent 模式目前仅支持 openai-chat 协议(当前: %s);请改用 DeepSeek / OpenAI 兼容端点", p.protocol)
}
m := strings.TrimSpace(model)
if m == "" {
m = p.model
}
urlStr := endpoint(p.baseURL, "chat/completions")
payload := map[string]any{
"model": m,
"messages": messages,
"tools": tools,
"tool_choice": "auto",
"max_tokens": remoteMaxTokens,
"temperature": 0.2,
switch p.protocol {
case ProtoAnthropic:
return p.chatToolsAnthropic(ctx, m, messages, tools)
case ProtoResponses:
return p.chatToolsResponses(ctx, m, messages, tools)
default:
return p.chatToolsOpenAI(ctx, m, messages, tools)
}
}
func (p *RemoteProvider) doJSON(ctx context.Context, urlStr string, payload any) ([]byte, int, error) {
body, err := json.Marshal(payload)
if err != nil {
return ToolsTurn{}, err
return nil, 0, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, bytes.NewReader(body))
if err != nil {
return ToolsTurn{}, err
return nil, 0, err
}
httpReq.Header.Set("Content-Type", "application/json")
switch p.authStyle {
@@ -65,17 +63,174 @@ func (p *RemoteProvider) ChatWithTools(ctx context.Context, model string, messag
}
resp, err := p.hc.Do(httpReq)
if err != nil {
return ToolsTurn{}, fmt.Errorf("AI 请求失败: %w", err)
return nil, 0, fmt.Errorf("AI 请求失败: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return ToolsTurn{}, fmt.Errorf("读取 AI 响应失败: %w", err)
return nil, resp.StatusCode, fmt.Errorf("读取 AI 响应失败: %w", err)
}
return parseToolsTurn(raw, resp.StatusCode)
return raw, resp.StatusCode, nil
}
func parseToolsTurn(raw []byte, status int) (ToolsTurn, error) {
func (p *RemoteProvider) chatToolsOpenAI(ctx context.Context, model string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error) {
urlStr := endpoint(p.baseURL, "chat/completions")
payload := map[string]any{
"model": model,
"messages": messages,
"tools": tools,
"tool_choice": "auto",
"max_tokens": remoteMaxTokens,
"temperature": 0.2,
}
raw, status, err := p.doJSON(ctx, urlStr, payload)
if err != nil {
return ToolsTurn{}, err
}
return parseToolsTurnChat(raw, status)
}
func (p *RemoteProvider) chatToolsAnthropic(ctx context.Context, model string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error) {
urlStr := anthropicEndpoint(p.baseURL)
system, anthMsgs := splitAnthropicMessages(messages)
payload := map[string]any{
"model": model,
"max_tokens": remoteMaxTokens,
"temperature": 0.2,
"tools": openaiToolsToAnthropic(tools),
"messages": anthMsgs,
}
if system != "" {
payload["system"] = system
}
raw, status, err := p.doJSON(ctx, urlStr, payload)
if err != nil {
return ToolsTurn{}, err
}
return parseToolsTurnAnthropic(raw, status)
}
func (p *RemoteProvider) chatToolsResponses(ctx context.Context, model string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error) {
urlStr := endpoint(p.baseURL, "responses")
instructions, input := splitResponsesInput(messages)
payload := map[string]any{
"model": model,
"instructions": instructions,
"input": input,
"tools": openaiToolsToResponses(tools),
"max_output_tokens": remoteMaxTokens,
}
raw, status, err := p.doJSON(ctx, urlStr, payload)
if err != nil {
return ToolsTurn{}, err
}
return parseToolsTurnResponses(raw, status)
}
func splitAnthropicMessages(messages []Message) (system string, out []map[string]any) {
for _, m := range messages {
switch m.Role {
case "system":
if system == "" {
system = m.Content
} else {
system += "\n" + m.Content
}
case "assistant":
content := []map[string]any{}
if strings.TrimSpace(m.Content) != "" {
content = append(content, map[string]any{"type": "text", "text": m.Content})
}
for _, tc := range m.ToolCalls {
var input any = map[string]any{}
_ = json.Unmarshal([]byte(tc.Function.Arguments), &input)
content = append(content, map[string]any{
"type": "tool_use",
"id": tc.ID,
"name": tc.Function.Name,
"input": input,
})
}
out = append(out, map[string]any{"role": "assistant", "content": content})
case "tool":
out = append(out, map[string]any{
"role": "user",
"content": []map[string]any{{
"type": "tool_result",
"tool_use_id": m.ToolCallID,
"content": m.Content,
}},
})
default: // user
out = append(out, map[string]any{"role": "user", "content": m.Content})
}
}
return system, out
}
func splitResponsesInput(messages []Message) (instructions string, input []map[string]any) {
for _, m := range messages {
switch m.Role {
case "system":
if instructions == "" {
instructions = m.Content
} else {
instructions += "\n" + m.Content
}
case "assistant":
item := map[string]any{"role": "assistant", "content": m.Content}
if len(m.ToolCalls) > 0 {
calls := make([]map[string]any, 0, len(m.ToolCalls))
for _, tc := range m.ToolCalls {
calls = append(calls, map[string]any{
"id": tc.ID,
"type": "function_call",
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
})
}
item["tool_calls"] = calls
}
input = append(input, item)
case "tool":
input = append(input, map[string]any{
"type": "function_call_output",
"call_id": m.ToolCallID,
"output": m.Content,
})
default:
input = append(input, map[string]any{"role": "user", "content": m.Content})
}
}
return instructions, input
}
func openaiToolsToAnthropic(tools []aitools.OpenAITool) []map[string]any {
out := make([]map[string]any, 0, len(tools))
for _, t := range tools {
out = append(out, map[string]any{
"name": t.Function.Name,
"description": t.Function.Description,
"input_schema": t.Function.Parameters,
})
}
return out
}
func openaiToolsToResponses(tools []aitools.OpenAITool) []map[string]any {
out := make([]map[string]any, 0, len(tools))
for _, t := range tools {
out = append(out, map[string]any{
"type": "function",
"name": t.Function.Name,
"description": t.Function.Description,
"parameters": t.Function.Parameters,
})
}
return out
}
func httpErrMessage(raw []byte, status int) error {
var errBody struct {
Error *struct {
Message string `json:"message"`
@@ -90,7 +245,14 @@ func parseToolsTurn(raw []byte, status int) (ToolsTurn, error) {
} else if errBody.Message != "" {
msg = errBody.Message
}
return ToolsTurn{}, fmt.Errorf("AI 服务 HTTP %d: %s", status, msg)
return fmt.Errorf("AI 服务 HTTP %d: %s", status, msg)
}
return nil
}
func parseToolsTurnChat(raw []byte, status int) (ToolsTurn, error) {
if err := httpErrMessage(raw, status); err != nil {
return ToolsTurn{}, err
}
var out struct {
Choices []struct {
@@ -119,25 +281,108 @@ func parseToolsTurn(raw []byte, status int) (ToolsTurn, error) {
if ch.Message.Content != nil {
content = strings.TrimSpace(*ch.Message.Content)
}
return ToolsTurn{
Content: content,
ToolCalls: ch.Message.ToolCalls,
RawFinish: ch.FinishReason,
}, nil
return ToolsTurn{Content: content, ToolCalls: ch.Message.ToolCalls, RawFinish: ch.FinishReason}, nil
}
func parseToolsTurnAnthropic(raw []byte, status int) (ToolsTurn, error) {
if err := httpErrMessage(raw, status); err != nil {
return ToolsTurn{}, err
}
var out struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
} `json:"content"`
StopReason string `json:"stop_reason"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return ToolsTurn{}, fmt.Errorf("解析 Anthropic 响应失败(HTTP %d)", status)
}
var content strings.Builder
var calls []ToolCall
for _, c := range out.Content {
switch c.Type {
case "text":
content.WriteString(c.Text)
case "tool_use":
args := "{}"
if len(c.Input) > 0 {
args = string(c.Input)
}
calls = append(calls, ToolCall{
ID: c.ID,
Type: "function",
Function: ToolCallFunc{Name: c.Name, Arguments: args},
})
}
}
return ToolsTurn{Content: strings.TrimSpace(content.String()), ToolCalls: calls, RawFinish: out.StopReason}, nil
}
func parseToolsTurnResponses(raw []byte, status int) (ToolsTurn, error) {
if err := httpErrMessage(raw, status); err != nil {
return ToolsTurn{}, err
}
var out struct {
OutputText string `json:"output_text"`
Output []struct {
Type string `json:"type"`
ID string `json:"id"`
CallID string `json:"call_id"`
Name string `json:"name"`
Arguments string `json:"arguments"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
} `json:"output"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return ToolsTurn{}, fmt.Errorf("解析 responses 响应失败(HTTP %d)", status)
}
var content strings.Builder
if out.OutputText != "" {
content.WriteString(out.OutputText)
}
var calls []ToolCall
for _, o := range out.Output {
switch o.Type {
case "message":
for _, c := range o.Content {
if c.Type == "output_text" || c.Type == "text" || c.Text != "" {
content.WriteString(c.Text)
}
}
case "function_call":
id := o.CallID
if id == "" {
id = o.ID
}
args := o.Arguments
if args == "" {
args = "{}"
}
calls = append(calls, ToolCall{
ID: id,
Type: "function",
Function: ToolCallFunc{Name: o.Name, Arguments: args},
})
}
}
return ToolsTurn{Content: strings.TrimSpace(content.String()), ToolCalls: calls}, nil
}
// DefaultToolChat 返回可跑 Agent 的远端 Provider;stub / 缺 key 时返回错误说明。
func DefaultToolChat() (ToolChat, error) {
cfg := LoadConfig()
if cfg.Provider == "" || cfg.Provider == "stub" {
return nil, fmt.Errorf("Agent 需要真实 LLM:请在设置中配置 DeepSeek / OpenAI 等(stub 仅支持 Ask 生成)")
return nil, fmt.Errorf("Agent 需要真实 LLM:请在设置中配置 DeepSeek / OpenAI / Anthropic 等(stub 仅支持 Ask 生成)")
}
if cfg.APIKey == "" && cfg.AuthStyle != AuthNone {
return nil, fmt.Errorf("Agent 需要 API Key:请在设置中填写后重试")
}
p := NewRemoteProvider(cfg)
if p.protocol != "" && p.protocol != ProtoChat {
return nil, fmt.Errorf("Agent 模式目前仅支持 openai-chat 协议(当前: %s)", p.protocol)
}
return p, nil
return NewRemoteProvider(cfg), nil
}