From 4f309ad9d5f7cd5ee952dd03acc9e5280cbd749a Mon Sep 17 00:00:00 2001 From: Lizandro GD Date: Mon, 13 Jul 2026 19:16:11 +0000 Subject: [PATCH] feat: soporte nativo Anthropic en el agente (tool use format) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agrega callAnthropicAI() que convierte mensajes y herramientas al formato nativo de Anthropic Messages API (tool_use / tool_result), con conversión de vuelta al formato interno OpenAI para mantener el historial unificado. Co-Authored-By: Claude Sonnet 4.6 --- pkg/services/telegram_agent_service.go | 209 ++++++++++++++++++++++++- 1 file changed, 206 insertions(+), 3 deletions(-) diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go index 56171a1..c82d793 100644 --- a/pkg/services/telegram_agent_service.go +++ b/pkg/services/telegram_agent_service.go @@ -675,9 +675,58 @@ func coolifyAppAction(configIDInt int, uuid, action string) (interface{}, error) return coolifyCall("GET", "/applications/"+uuid+"/"+action, nil, cfg) } +// ─── Tipos Anthropic ───────────────────────────────────────────────────────── + +type anthropicTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema agentToolParam `json:"input_schema"` +} + +type anthropicContentBlock 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"` +} + +type anthropicMsg struct { + Role string `json:"role"` + Content interface{} `json:"content"` // string o []anthropicContentBlock +} + +type anthropicReq struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System string `json:"system,omitempty"` + Messages []anthropicMsg `json:"messages"` + Tools []anthropicTool `json:"tools,omitempty"` +} + +type anthropicResp struct { + Content []anthropicContentBlock `json:"content"` + StopReason string `json:"stop_reason"` + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` +} + // ─── Llamada al AI con function calling ────────────────────────────────────── +// callAI despacha al provider correcto (Anthropic o OpenAI-compatible). func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) { + if strings.ToLower(ai.Provider) == "anthropic" { + return callAnthropicAI(ai, messages, tools) + } + return callOpenAICompatibleAI(ai, messages, tools) +} + +// callOpenAICompatibleAI usa el formato de OpenAI (también vale para qwen, groq, deepseek, etc.) +func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) { baseURL := ai.BaseURL if baseURL == "" { baseURL = providerDefaultURL(ai.Provider) @@ -722,13 +771,167 @@ func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*a return &msg, nil } +// callAnthropicAI llama a la API nativa de Anthropic con tool use. +func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) { + // Convertir herramientas al formato Anthropic + anthropicTools := make([]anthropicTool, len(tools)) + for i, t := range tools { + anthropicTools[i] = anthropicTool{ + Name: t.Function.Name, + Description: t.Function.Description, + InputSchema: t.Function.Parameters, + } + } + + // Extraer system prompt y convertir mensajes + var systemPrompt string + var anthropicMsgs []anthropicMsg + + for _, m := range messages { + switch m.Role { + case "system": + if s, ok := m.Content.(string); ok { + systemPrompt = s + } + + case "user": + content := "" + if s, ok := m.Content.(string); ok { + content = s + } + // Si el último mensaje ya es user, agregar tool_result como bloque adicional + if len(anthropicMsgs) > 0 && anthropicMsgs[len(anthropicMsgs)-1].Role == "user" { + last := anthropicMsgs[len(anthropicMsgs)-1] + if blocks, ok := last.Content.([]anthropicContentBlock); ok { + anthropicMsgs[len(anthropicMsgs)-1].Content = append(blocks, anthropicContentBlock{ + Type: "text", + Text: content, + }) + continue + } + } + anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "user", Content: content}) + + case "assistant": + if len(m.ToolCalls) > 0 { + // Convertir tool_calls a bloques tool_use + var blocks []anthropicContentBlock + if s, ok := m.Content.(string); ok && s != "" { + blocks = append(blocks, anthropicContentBlock{Type: "text", Text: s}) + } + for _, tc := range m.ToolCalls { + blocks = append(blocks, anthropicContentBlock{ + Type: "tool_use", + ID: tc.ID, + Name: tc.Function.Name, + Input: json.RawMessage(tc.Function.Arguments), + }) + } + anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "assistant", Content: blocks}) + } else { + content := "" + if s, ok := m.Content.(string); ok { + content = s + } + anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "assistant", Content: content}) + } + + case "tool": + // Los resultados de tool deben ir en un mensaje de usuario con tipo tool_result + block := anthropicContentBlock{ + Type: "tool_result", + ToolUseID: m.ToolCallID, + Content: fmt.Sprintf("%v", m.Content), + } + // Agrupar en el último mensaje user si existe, o crear uno nuevo + if len(anthropicMsgs) > 0 && anthropicMsgs[len(anthropicMsgs)-1].Role == "user" { + last := anthropicMsgs[len(anthropicMsgs)-1] + switch c := last.Content.(type) { + case []anthropicContentBlock: + anthropicMsgs[len(anthropicMsgs)-1].Content = append(c, block) + default: + anthropicMsgs[len(anthropicMsgs)-1].Content = []anthropicContentBlock{block} + } + } else { + anthropicMsgs = append(anthropicMsgs, anthropicMsg{ + Role: "user", + Content: []anthropicContentBlock{block}, + }) + } + } + } + + reqBody := anthropicReq{ + Model: ai.ModelName, + MaxTokens: 4096, + System: systemPrompt, + Messages: anthropicMsgs, + Tools: anthropicTools, + } + + payload, _ := json.Marshal(reqBody) + req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("x-api-key", ai.ApiKey) + req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) + + var anthropicRsp anthropicResp + if err := json.Unmarshal(raw, &anthropicRsp); err != nil { + return nil, fmt.Errorf("respuesta inesperada de Anthropic: %s", string(raw[:min(200, len(raw))])) + } + if anthropicRsp.Error != nil { + return nil, fmt.Errorf("error de Anthropic: %s", anthropicRsp.Error.Message) + } + + // Convertir respuesta Anthropic → agentMessage (formato interno OpenAI) + result := &agentMessage{Role: "assistant"} + var textParts []string + var toolCalls []agentToolCall + + for _, block := range anthropicRsp.Content { + switch block.Type { + case "text": + if block.Text != "" { + textParts = append(textParts, block.Text) + } + case "tool_use": + inputJSON := "{}" + if block.Input != nil { + inputJSON = string(block.Input) + } + toolCalls = append(toolCalls, agentToolCall{ + ID: block.ID, + Type: "function", + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{Name: block.Name, Arguments: inputJSON}, + }) + } + } + + if len(textParts) > 0 { + result.Content = strings.Join(textParts, "\n") + } + result.ToolCalls = toolCalls + return result, nil +} + func providerDefaultURL(provider string) string { switch strings.ToLower(provider) { case "openai": return "https://api.openai.com/v1" - case "anthropic": - // Anthropic usa un formato diferente; para compatibilidad usar proxy OpenAI-compatible - return "https://api.openai.com/v1" case "qwen", "dashscope": return "https://dashscope.aliyuncs.com/compatible-mode/v1" case "groq":