package agent import ( "bufio" "context" "encoding/json" "errors" "net/http" "strings" ) // normalizeAnthropicURL 把用户填写的 Base URL 规范化为 Anthropic Messages 接口地址。 // 兼容多种填写方式:https://api.anthropic.com、.../v1、.../v1/messages、.../v1/chat/completions。 func normalizeAnthropicURL(baseURL string) string { trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/") if trimmed == "" { return "https://api.anthropic.com/v1/messages" } switch { case strings.HasSuffix(trimmed, "/v1/chat/completions"): return strings.TrimSuffix(trimmed, "/chat/completions") + "/messages" case strings.HasSuffix(trimmed, "/v1/messages"): return trimmed case strings.HasSuffix(trimmed, "/v1"): return trimmed + "/messages" default: return trimmed + "/v1/messages" } } // extractSystemPrompt 汇总消息中的 system 角色内容,Anthropic 要求 system 放在顶层字段。 func extractSystemPrompt(messages []Message) string { var parts []string for _, message := range messages { if message.Role == "system" && message.Content != nil && strings.TrimSpace(*message.Content) != "" { parts = append(parts, *message.Content) } } return strings.Join(parts, "\n\n") } // toAnthropicTools 把内部工具定义转换为 Anthropic 的 tools 数组(input_schema 替代 parameters)。 func toAnthropicTools(tools []ToolDefinition) []map[string]any { result := make([]map[string]any, 0, len(tools)) for _, tool := range tools { schema := tool.Function.Parameters if schema == nil { schema = map[string]any{"type": "object"} } result = append(result, map[string]any{ "name": tool.Function.Name, "description": tool.Function.Description, "input_schema": schema, }) } return result } // toAnthropicMessages 把内部 Message 列表转换为 Anthropic messages 数组。 // system 消息被过滤(走顶层 system 字段);assistant 的 tool_use 与 user 的 // tool_result 都以 content block 形式表达。 // 注意:Anthropic 要求上一条 assistant 消息中所有 tool_use 的 tool_result // 必须放在紧邻的同一条 user 消息里,因此连续的 tool 结果消息需要合并。 func toAnthropicMessages(messages []Message) []map[string]any { result := make([]map[string]any, 0, len(messages)) for i := 0; i < len(messages); i++ { message := messages[i] switch message.Role { case "system": continue case "assistant": blocks := make([]map[string]any, 0, 1+len(message.ToolCalls)) if message.Content != nil && strings.TrimSpace(*message.Content) != "" { blocks = append(blocks, map[string]any{"type": "text", "text": *message.Content}) } for _, call := range message.ToolCalls { blocks = append(blocks, map[string]any{ "type": "tool_use", "id": call.ID, "name": call.Function.Name, "input": parseJSONValue(call.Function.Arguments), }) } if len(blocks) == 0 { blocks = append(blocks, map[string]any{"type": "text", "text": ""}) } result = append(result, map[string]any{"role": "assistant", "content": blocks}) case "tool": // 合并连续的 tool 消息:同一条 user 消息包含所有 tool_result 块 blocks := []map[string]any{{ "type": "tool_result", "tool_use_id": message.ToolCallID, "content": contentString(message.Content), }} for i+1 < len(messages) && messages[i+1].Role == "tool" { i++ next := messages[i] blocks = append(blocks, map[string]any{ "type": "tool_result", "tool_use_id": next.ToolCallID, "content": contentString(next.Content), }) } result = append(result, map[string]any{"role": "user", "content": blocks}) default: // user blocks := make([]map[string]any, 0, 1) if message.Content != nil && strings.TrimSpace(*message.Content) != "" { blocks = append(blocks, map[string]any{"type": "text", "text": *message.Content}) } if len(blocks) == 0 { blocks = append(blocks, map[string]any{"type": "text", "text": ""}) } result = append(result, map[string]any{"role": "user", "content": blocks}) } } return result } // parseJSONValue 把工具参数 JSON 字符串解析为任意值;解析失败时回退为空对象。 func parseJSONValue(raw string) any { var value any if err := json.Unmarshal([]byte(raw), &value); err != nil || value == nil { return map[string]any{} } return value } func contentString(content *string) string { if content == nil { return "" } return *content } // readAnthropicSSE 解析 Anthropic Messages API 的流式响应。 // 事件格式为 `event: ` 与 `data: ` 两行一组。 func readAnthropicSSE(ctx context.Context, resp *http.Response, ch chan<- StreamChunk) { scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 64*1024), 1024*1024) // content block index -> 正在累积的 tool_use 状态 type toolState struct { callIndex int id string name string } tools := make(map[int]*toolState) nextCallIndex := 0 var eventType string for scanner.Scan() { line := scanner.Text() switch { case strings.HasPrefix(line, "event:"): eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) continue case strings.HasPrefix(line, "data:"): default: continue } data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) switch eventType { case "content_block_start": var ev struct { Index int `json:"index"` Block struct { Type string `json:"type"` ID string `json:"id"` Name string `json:"name"` } `json:"content_block"` } if err := json.Unmarshal([]byte(data), &ev); err != nil { continue } if ev.Block.Type == "tool_use" { state := &toolState{callIndex: nextCallIndex, id: ev.Block.ID, name: ev.Block.Name} nextCallIndex++ tools[ev.Index] = state ch <- StreamChunk{ToolCalls: []ToolCallDelta{ {Index: state.callIndex, ID: state.id, Name: state.name}, }} } case "content_block_delta": var ev struct { Index int `json:"index"` Delta struct { Type string `json:"type"` Text string `json:"text"` PartialJSON string `json:"partial_json"` } `json:"delta"` } if err := json.Unmarshal([]byte(data), &ev); err != nil { continue } switch ev.Delta.Type { case "text_delta": ch <- StreamChunk{Content: ev.Delta.Text} case "input_json_delta": if state, ok := tools[ev.Index]; ok { ch <- StreamChunk{ToolCalls: []ToolCallDelta{ {Index: state.callIndex, ArgumentsDelta: ev.Delta.PartialJSON}, }} } } case "message_delta": var ev struct { Delta struct { StopReason string `json:"stop_reason"` } `json:"delta"` Usage *struct { OutputTokens int `json:"output_tokens"` } `json:"usage"` } if err := json.Unmarshal([]byte(data), &ev); err != nil { continue } if ev.Delta.StopReason != "" { ch <- StreamChunk{FinishReason: ev.Delta.StopReason} } if ev.Usage != nil { ch <- StreamChunk{Usage: &Usage{CompletionTokens: ev.Usage.OutputTokens}} } case "error": var ev struct { Error struct { Type string `json:"type"` Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal([]byte(data), &ev); err != nil { continue } msg := strings.TrimSpace(ev.Error.Message) if msg == "" { msg = "Anthropic API 错误" } ch <- StreamChunk{Error: errors.New(msg)} } } if err := scanner.Err(); err != nil && ctx.Err() == nil { ch <- StreamChunk{Error: err} } } // compressAnthropic 使用 Anthropic 非流式接口执行对话压缩。 func (c *LLMClient) compressAnthropic(ctx context.Context, messages []Message) (string, error) { body := map[string]any{ "model": c.apiConfig.Model(), "max_tokens": c.cfg.CompactionTokens, "stream": false, "temperature": 0.2, // 关闭思考:推理模型(如 deepseek-v4-flash)默认先输出 thinking 块, // 会把 max_tokens 预算耗尽而拿不到 text 块,导致压缩被判为失败。 "thinking": map[string]any{"type": "disabled"}, "messages": toAnthropicMessages(messages), } if system := extractSystemPrompt(messages); system != "" { body["system"] = system } ctx, cancel := context.WithTimeout(ctx, c.cfg.RequestTimeout) defer cancel() payload, err := json.Marshal(body) if err != nil { return "", err } respBody, err := c.doSyncRequestWithRetry(ctx, true, normalizeAnthropicURL(c.apiConfig.BaseURL()), payload) if err != nil { return "", err } var result struct { Content []struct { Type string `json:"type"` Text string `json:"text"` Thinking string `json:"thinking"` } `json:"content"` } if err := json.Unmarshal(respBody, &result); err != nil { return "", err } fallback := "" for _, block := range result.Content { if block.Type == "text" && strings.TrimSpace(block.Text) != "" { return strings.TrimSpace(block.Text), nil } // 记录 thinking 作为兜底(仅当端点不支持 thinking:disabled 时才会出现) if block.Type == "thinking" && fallback == "" { fallback = strings.TrimSpace(block.Thinking) } } if fallback != "" { return fallback, nil } return "", errors.New("模型没有返回压缩摘要") }