mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-15 08:46:58 +08:00
Migrate the Telegram bot agent from an XML tag hack (<api_call>) to OpenAI-native function calling via CallWithRequestFull. Key changes: - mcp/interface.go: add parseMCPResponseFull to clientHooks interface - mcp/client.go: route callWithRequestFull through hooks for overridability - mcp/claude_client.go: override parseMCPResponseFull for Claude response format (tool_use blocks instead of choices[].message.tool_calls) - telegram/agent/agent.go: rewrite Run() to use CallWithRequestFull; define api_request tool with JSON Schema; implement tool-call loop with role="tool" result messages; remove XML parsing entirely - telegram/agent/apicall.go: remove parseAPICall (dead code) - telegram/agent/prompt.go: simplify — remove XML format instructions, replace with concise api_request tool usage instructions - telegram/agent/agent_test.go: rebuild all tests using LLMResponse objects; add TestNarrationStructurallyImpossible, TestOnChunkCalledWithFinalReply, TestToolCallIDPropagated; remove XML-specific tests Architecture advantage: with native function calling, the LLM returns EITHER ToolCalls OR Content — never both. Narration is now structurally impossible at the protocol level, not just enforced by prompt rules. All 11 agent tests pass. mcp package tests pass.
207 lines
5.7 KiB
Go
207 lines
5.7 KiB
Go
package mcp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
)
|
|
|
|
const (
|
|
ProviderClaude = "claude"
|
|
DefaultClaudeBaseURL = "https://api.anthropic.com/v1"
|
|
DefaultClaudeModel = "claude-opus-4-6"
|
|
)
|
|
|
|
type ClaudeClient struct {
|
|
*Client
|
|
}
|
|
|
|
// NewClaudeClient creates Claude client (backward compatible)
|
|
func NewClaudeClient() AIClient {
|
|
return NewClaudeClientWithOptions()
|
|
}
|
|
|
|
// NewClaudeClientWithOptions creates Claude client (supports options pattern)
|
|
func NewClaudeClientWithOptions(opts ...ClientOption) AIClient {
|
|
// 1. Create Claude preset options
|
|
claudeOpts := []ClientOption{
|
|
WithProvider(ProviderClaude),
|
|
WithModel(DefaultClaudeModel),
|
|
WithBaseURL(DefaultClaudeBaseURL),
|
|
}
|
|
|
|
// 2. Merge user options (user options have higher priority)
|
|
allOpts := append(claudeOpts, opts...)
|
|
|
|
// 3. Create base client
|
|
baseClient := NewClient(allOpts...).(*Client)
|
|
|
|
// 4. Create Claude client
|
|
claudeClient := &ClaudeClient{
|
|
Client: baseClient,
|
|
}
|
|
|
|
// 5. Set hooks to point to ClaudeClient (implement dynamic dispatch)
|
|
baseClient.hooks = claudeClient
|
|
|
|
return claudeClient
|
|
}
|
|
|
|
func (c *ClaudeClient) SetAPIKey(apiKey string, customURL string, customModel string) {
|
|
c.APIKey = apiKey
|
|
|
|
if len(apiKey) > 8 {
|
|
c.logger.Infof("🔧 [MCP] Claude API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
|
|
}
|
|
if customURL != "" {
|
|
c.BaseURL = customURL
|
|
c.logger.Infof("🔧 [MCP] Claude using custom BaseURL: %s", customURL)
|
|
} else {
|
|
c.logger.Infof("🔧 [MCP] Claude using default BaseURL: %s", c.BaseURL)
|
|
}
|
|
if customModel != "" {
|
|
c.Model = customModel
|
|
c.logger.Infof("🔧 [MCP] Claude using custom Model: %s", customModel)
|
|
} else {
|
|
c.logger.Infof("🔧 [MCP] Claude using default Model: %s", c.Model)
|
|
}
|
|
}
|
|
|
|
// setAuthHeader Claude uses x-api-key header instead of Authorization Bearer
|
|
func (c *ClaudeClient) setAuthHeader(reqHeaders http.Header) {
|
|
reqHeaders.Set("x-api-key", c.APIKey)
|
|
reqHeaders.Set("anthropic-version", "2023-06-01")
|
|
}
|
|
|
|
// buildUrl Claude uses /messages endpoint
|
|
func (c *ClaudeClient) buildUrl() string {
|
|
return fmt.Sprintf("%s/messages", c.BaseURL)
|
|
}
|
|
|
|
// buildMCPRequestBody Claude has different request format
|
|
func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
|
|
requestBody := map[string]any{
|
|
"model": c.Model,
|
|
"max_tokens": c.MaxTokens,
|
|
"system": systemPrompt,
|
|
"messages": []map[string]string{
|
|
{"role": "user", "content": userPrompt},
|
|
},
|
|
}
|
|
|
|
return requestBody
|
|
}
|
|
|
|
// parseMCPResponseFull Claude response format — handles both text and tool_use blocks.
|
|
func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
|
|
var response struct {
|
|
Content []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"`
|
|
} `json:"content"`
|
|
Usage struct {
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
} `json:"usage"`
|
|
Error *struct {
|
|
Type string `json:"type"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &response); err != nil {
|
|
return nil, fmt.Errorf("failed to parse Claude response: %w, body: %s", err, string(body))
|
|
}
|
|
if response.Error != nil {
|
|
return nil, fmt.Errorf("Claude API error: %s - %s", response.Error.Type, response.Error.Message)
|
|
}
|
|
|
|
totalTokens := response.Usage.InputTokens + response.Usage.OutputTokens
|
|
if TokenUsageCallback != nil && totalTokens > 0 {
|
|
TokenUsageCallback(TokenUsage{
|
|
Provider: c.Provider,
|
|
Model: c.Model,
|
|
PromptTokens: response.Usage.InputTokens,
|
|
CompletionTokens: response.Usage.OutputTokens,
|
|
TotalTokens: totalTokens,
|
|
})
|
|
}
|
|
|
|
result := &LLMResponse{}
|
|
for _, block := range response.Content {
|
|
switch block.Type {
|
|
case "text":
|
|
result.Content = block.Text
|
|
case "tool_use":
|
|
// Claude returns arguments as a JSON object (Input field); convert to string.
|
|
argsJSON, err := json.Marshal(block.Input)
|
|
if err != nil {
|
|
argsJSON = []byte("{}")
|
|
}
|
|
result.ToolCalls = append(result.ToolCalls, ToolCall{
|
|
ID: block.ID,
|
|
Type: "function",
|
|
Function: ToolCallFunction{
|
|
Name: block.Name,
|
|
Arguments: string(argsJSON),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// parseMCPResponse Claude has different response format
|
|
func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) {
|
|
var response struct {
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
Usage struct {
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
} `json:"usage"`
|
|
Error *struct {
|
|
Type string `json:"type"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &response); err != nil {
|
|
return "", fmt.Errorf("failed to parse Claude response: %w, body: %s", err, string(body))
|
|
}
|
|
|
|
if response.Error != nil {
|
|
return "", fmt.Errorf("Claude API error: %s - %s", response.Error.Type, response.Error.Message)
|
|
}
|
|
|
|
if len(response.Content) == 0 {
|
|
return "", fmt.Errorf("Claude returned empty content, body: %s", string(body))
|
|
}
|
|
|
|
// Report token usage if callback is set
|
|
totalTokens := response.Usage.InputTokens + response.Usage.OutputTokens
|
|
if TokenUsageCallback != nil && totalTokens > 0 {
|
|
TokenUsageCallback(TokenUsage{
|
|
Provider: c.Provider,
|
|
Model: c.Model,
|
|
PromptTokens: response.Usage.InputTokens,
|
|
CompletionTokens: response.Usage.OutputTokens,
|
|
TotalTokens: totalTokens,
|
|
})
|
|
}
|
|
|
|
// Find text content
|
|
for _, content := range response.Content {
|
|
if content.Type == "text" {
|
|
return content.Text, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("no text content in Claude response")
|
|
}
|