mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 12:30:59 +08:00
- Add assistant package with AI Agent runtime - agent.go: Core agent loop with tool calling - session.go: Conversation memory management - tool.go: Tool interface and base implementation - trading_tools.go: Trading-specific tools (13 tools) - prompts.go: Trading expert system prompts (EN/ZH) - Add telegram package for Telegram bot integration - bot.go: Telegram bot with rate limiting & access control - config.go: Environment-based configuration - Update main.go to initialize Telegram bot on startup - Update .env.example with new configuration options - Add gopkg.in/telebot.v3 dependency Trading tools available: - Query: get_balance, get_positions, list_traders, get_trader_status - Control: start_trader, stop_trader - Trading: get_market_price, open_long, open_short, close_position - Config: list_strategies, list_exchanges, list_ai_models
351 lines
8.7 KiB
Go
351 lines
8.7 KiB
Go
// Package assistant implements the AI Agent runtime with tool calling capabilities
|
|
// Inspired by moltbot's agent architecture, specialized for trading
|
|
package assistant
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"nofx/logger"
|
|
"nofx/mcp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Agent represents an AI assistant with tool-calling capabilities
|
|
type Agent struct {
|
|
// AI client for LLM calls
|
|
aiClient mcp.AIClient
|
|
|
|
// Tool registry
|
|
tools map[string]Tool
|
|
toolsLock sync.RWMutex
|
|
|
|
// Session/memory management
|
|
sessions map[string]*Session
|
|
sessionsLock sync.RWMutex
|
|
|
|
// Configuration
|
|
config AgentConfig
|
|
|
|
// System prompt
|
|
systemPrompt string
|
|
}
|
|
|
|
// AgentConfig holds agent configuration
|
|
type AgentConfig struct {
|
|
// Max tool calls per turn (prevent infinite loops)
|
|
MaxToolCalls int `json:"max_tool_calls"`
|
|
|
|
// Max conversation history to keep
|
|
MaxHistoryMessages int `json:"max_history_messages"`
|
|
|
|
// Timeout for single AI call
|
|
AITimeout time.Duration `json:"ai_timeout"`
|
|
|
|
// Model to use
|
|
Model string `json:"model"`
|
|
}
|
|
|
|
// DefaultAgentConfig returns sensible defaults
|
|
func DefaultAgentConfig() AgentConfig {
|
|
return AgentConfig{
|
|
MaxToolCalls: 10,
|
|
MaxHistoryMessages: 50,
|
|
AITimeout: 120 * time.Second,
|
|
Model: "deepseek-chat",
|
|
}
|
|
}
|
|
|
|
// NewAgent creates a new AI agent
|
|
func NewAgent(aiClient mcp.AIClient, config AgentConfig) *Agent {
|
|
agent := &Agent{
|
|
aiClient: aiClient,
|
|
tools: make(map[string]Tool),
|
|
sessions: make(map[string]*Session),
|
|
config: config,
|
|
}
|
|
|
|
// Set default system prompt
|
|
agent.systemPrompt = DefaultTradingSystemPrompt()
|
|
|
|
return agent
|
|
}
|
|
|
|
// RegisterTool adds a tool to the agent's toolkit
|
|
func (a *Agent) RegisterTool(tool Tool) {
|
|
a.toolsLock.Lock()
|
|
defer a.toolsLock.Unlock()
|
|
a.tools[tool.Name()] = tool
|
|
logger.Infof("🔧 Registered tool: %s", tool.Name())
|
|
}
|
|
|
|
// RegisterTools adds multiple tools
|
|
func (a *Agent) RegisterTools(tools ...Tool) {
|
|
for _, tool := range tools {
|
|
a.RegisterTool(tool)
|
|
}
|
|
}
|
|
|
|
// SetSystemPrompt sets the agent's system prompt
|
|
func (a *Agent) SetSystemPrompt(prompt string) {
|
|
a.systemPrompt = prompt
|
|
}
|
|
|
|
// GetSession returns or creates a session for the given ID
|
|
func (a *Agent) GetSession(sessionID string) *Session {
|
|
a.sessionsLock.Lock()
|
|
defer a.sessionsLock.Unlock()
|
|
|
|
if session, ok := a.sessions[sessionID]; ok {
|
|
return session
|
|
}
|
|
|
|
session := NewSession(sessionID, a.config.MaxHistoryMessages)
|
|
a.sessions[sessionID] = session
|
|
return session
|
|
}
|
|
|
|
// Chat processes a user message and returns the agent's response
|
|
// This is the main entry point for the agent loop
|
|
func (a *Agent) Chat(ctx context.Context, sessionID string, userMessage string) (*AgentResponse, error) {
|
|
session := a.GetSession(sessionID)
|
|
|
|
// Add user message to history
|
|
session.AddMessage(Message{
|
|
Role: "user",
|
|
Content: userMessage,
|
|
Timestamp: time.Now(),
|
|
})
|
|
|
|
// Build the full prompt with tools
|
|
systemPrompt := a.buildSystemPromptWithTools()
|
|
conversationPrompt := a.buildConversationPrompt(session)
|
|
|
|
// Agent loop - keep calling AI until it's done or max iterations
|
|
var finalResponse string
|
|
toolCallCount := 0
|
|
|
|
for {
|
|
// Check context cancellation
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
// Check max tool calls
|
|
if toolCallCount >= a.config.MaxToolCalls {
|
|
logger.Warnf("⚠️ Max tool calls reached (%d), stopping agent loop", a.config.MaxToolCalls)
|
|
break
|
|
}
|
|
|
|
// Call AI
|
|
response, err := a.aiClient.CallWithMessages(systemPrompt, conversationPrompt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("AI call failed: %w", err)
|
|
}
|
|
|
|
// Parse response for tool calls
|
|
toolCalls, textResponse, err := a.parseResponse(response)
|
|
if err != nil {
|
|
// If parsing fails, treat entire response as text
|
|
finalResponse = response
|
|
break
|
|
}
|
|
|
|
// If no tool calls, we're done
|
|
if len(toolCalls) == 0 {
|
|
finalResponse = textResponse
|
|
break
|
|
}
|
|
|
|
// Execute tool calls
|
|
toolResults := a.executeToolCalls(ctx, toolCalls)
|
|
toolCallCount += len(toolCalls)
|
|
|
|
// Add tool calls and results to conversation for next iteration
|
|
conversationPrompt += fmt.Sprintf("\n\nAssistant called tools:\n%s\n\nTool results:\n%s\n\nBased on the tool results, please provide your response to the user:",
|
|
formatToolCalls(toolCalls),
|
|
formatToolResults(toolResults))
|
|
|
|
// If there's also a text response, capture it
|
|
if textResponse != "" {
|
|
finalResponse = textResponse
|
|
}
|
|
}
|
|
|
|
// Add assistant response to history
|
|
session.AddMessage(Message{
|
|
Role: "assistant",
|
|
Content: finalResponse,
|
|
Timestamp: time.Now(),
|
|
})
|
|
|
|
return &AgentResponse{
|
|
Text: finalResponse,
|
|
SessionID: sessionID,
|
|
}, nil
|
|
}
|
|
|
|
// buildSystemPromptWithTools creates the system prompt including tool definitions
|
|
func (a *Agent) buildSystemPromptWithTools() string {
|
|
a.toolsLock.RLock()
|
|
defer a.toolsLock.RUnlock()
|
|
|
|
var toolDefs []string
|
|
for _, tool := range a.tools {
|
|
toolDef := fmt.Sprintf(`- **%s**: %s
|
|
Parameters: %s`, tool.Name(), tool.Description(), tool.ParameterSchema())
|
|
toolDefs = append(toolDefs, toolDef)
|
|
}
|
|
|
|
toolsSection := ""
|
|
if len(toolDefs) > 0 {
|
|
toolsSection = fmt.Sprintf(`
|
|
|
|
## Available Tools
|
|
|
|
You can call tools by responding with JSON in this format:
|
|
{"tool_calls": [{"name": "tool_name", "arguments": {"param": "value"}}]}
|
|
|
|
After receiving tool results, provide a natural language response to the user.
|
|
|
|
Tools:
|
|
%s
|
|
`, strings.Join(toolDefs, "\n"))
|
|
}
|
|
|
|
return a.systemPrompt + toolsSection
|
|
}
|
|
|
|
// buildConversationPrompt builds the conversation history as a prompt
|
|
func (a *Agent) buildConversationPrompt(session *Session) string {
|
|
messages := session.GetMessages()
|
|
var parts []string
|
|
|
|
for _, msg := range messages {
|
|
parts = append(parts, fmt.Sprintf("%s: %s", strings.Title(msg.Role), msg.Content))
|
|
}
|
|
|
|
return strings.Join(parts, "\n\n")
|
|
}
|
|
|
|
// parseResponse extracts tool calls and text from AI response
|
|
func (a *Agent) parseResponse(response string) ([]ToolCall, string, error) {
|
|
// Try to find JSON tool calls in response
|
|
// Look for {"tool_calls": [...]} pattern
|
|
|
|
var toolCalls []ToolCall
|
|
textResponse := response
|
|
|
|
// Try to parse as JSON
|
|
if strings.Contains(response, "tool_calls") {
|
|
// Find JSON block
|
|
start := strings.Index(response, "{")
|
|
end := strings.LastIndex(response, "}")
|
|
|
|
if start >= 0 && end > start {
|
|
jsonStr := response[start : end+1]
|
|
|
|
var parsed struct {
|
|
ToolCalls []struct {
|
|
Name string `json:"name"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
} `json:"tool_calls"`
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(jsonStr), &parsed); err == nil {
|
|
for _, tc := range parsed.ToolCalls {
|
|
toolCalls = append(toolCalls, ToolCall{
|
|
Name: tc.Name,
|
|
Arguments: tc.Arguments,
|
|
})
|
|
}
|
|
|
|
// Extract text before/after JSON
|
|
textResponse = strings.TrimSpace(response[:start] + response[end+1:])
|
|
}
|
|
}
|
|
}
|
|
|
|
return toolCalls, textResponse, nil
|
|
}
|
|
|
|
// executeToolCalls runs the requested tools
|
|
func (a *Agent) executeToolCalls(ctx context.Context, calls []ToolCall) []ToolResult {
|
|
a.toolsLock.RLock()
|
|
defer a.toolsLock.RUnlock()
|
|
|
|
var results []ToolResult
|
|
|
|
for _, call := range calls {
|
|
tool, ok := a.tools[call.Name]
|
|
if !ok {
|
|
results = append(results, ToolResult{
|
|
Name: call.Name,
|
|
Error: fmt.Sprintf("unknown tool: %s", call.Name),
|
|
})
|
|
continue
|
|
}
|
|
|
|
logger.Infof("🔧 Executing tool: %s", call.Name)
|
|
|
|
result, err := tool.Execute(ctx, call.Arguments)
|
|
if err != nil {
|
|
logger.Errorf("❌ Tool %s failed: %v", call.Name, err)
|
|
results = append(results, ToolResult{
|
|
Name: call.Name,
|
|
Error: err.Error(),
|
|
})
|
|
} else {
|
|
logger.Infof("✅ Tool %s completed", call.Name)
|
|
results = append(results, ToolResult{
|
|
Name: call.Name,
|
|
Result: result,
|
|
})
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// ToolCall represents a tool invocation request from the AI
|
|
type ToolCall struct {
|
|
Name string `json:"name"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
}
|
|
|
|
// ToolResult represents the result of a tool execution
|
|
type ToolResult struct {
|
|
Name string `json:"name"`
|
|
Result interface{} `json:"result,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// AgentResponse is the final response from the agent
|
|
type AgentResponse struct {
|
|
Text string `json:"text"`
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
|
|
func formatToolCalls(calls []ToolCall) string {
|
|
var parts []string
|
|
for _, c := range calls {
|
|
parts = append(parts, fmt.Sprintf("- %s(%s)", c.Name, string(c.Arguments)))
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
func formatToolResults(results []ToolResult) string {
|
|
var parts []string
|
|
for _, r := range results {
|
|
if r.Error != "" {
|
|
parts = append(parts, fmt.Sprintf("- %s: ERROR: %s", r.Name, r.Error))
|
|
} else {
|
|
resultJSON, _ := json.Marshal(r.Result)
|
|
parts = append(parts, fmt.Sprintf("- %s: %s", r.Name, string(resultJSON)))
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|