// Package agent implements the NOFXi Agent Core. // // Architecture: ALL user messages go to the LLM. The LLM understands intent // and calls tools to execute actions. No regex routing, no pattern matching. // The LLM IS the brain — just like how OpenClaw works. package agent import ( "context" "fmt" "log/slog" "net/http" "strconv" "strings" "time" "nofx/manager" "nofx/market" "nofx/mcp" "nofx/store" ) type Agent struct { traderManager *manager.TraderManager store *store.Store aiClient mcp.AIClient config *Config sentinel *Sentinel brain *Brain scheduler *Scheduler logger *slog.Logger history *chatHistory pending *pendingTrades NotifyFunc func(userID int64, text string) error } type Config struct { Language string `json:"language"` WatchSymbols []string `json:"watch_symbols"` EnableBriefs bool `json:"enable_briefs"` EnableNews bool `json:"enable_news"` EnableSentinel bool `json:"enable_sentinel"` BriefTimes []int `json:"brief_times"` } func DefaultConfig() *Config { return &Config{ Language: "zh", WatchSymbols: []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"}, EnableBriefs: true, EnableNews: true, EnableSentinel: true, BriefTimes: []int{8, 20}, } } func New(tm *manager.TraderManager, st *store.Store, cfg *Config, logger *slog.Logger) *Agent { if cfg == nil { cfg = DefaultConfig() } return &Agent{traderManager: tm, store: st, config: cfg, logger: logger, history: newChatHistory(20), pending: newPendingTrades()} } func (a *Agent) SetAIClient(c mcp.AIClient) { a.aiClient = c } func (a *Agent) EnsureAIClient() { if a.aiClient != nil { return } if a.store != nil { models, err := a.store.AIModel().List("default") if err == nil { for _, m := range models { apiKey := string(m.APIKey) if apiKey != "" && m.CustomAPIURL != "" { // Use standard HTTP client (no SSRF protection) since we control the URLs httpClient := &http.Client{Timeout: 60 * time.Second} client := mcp.NewClient(mcp.WithHTTPClient(httpClient)) name := m.CustomModelName if name == "" { name = m.ID } client.SetAPIKey(apiKey, m.CustomAPIURL, name) a.aiClient = client a.logger.Info("agent AI client ready", "model", name) return } } } } a.logger.Warn("no AI client — agent will have limited capabilities") } func (a *Agent) Start() { a.logger.Info("starting NOFXi agent...") a.EnsureAIClient() if a.config.EnableSentinel { a.sentinel = NewSentinel(a.config.WatchSymbols, a.handleSignal, a.logger) a.sentinel.Start() } a.brain = NewBrain(a, a.logger) if a.config.EnableNews { a.brain.StartNewsScan(5 * time.Minute) } if a.config.EnableBriefs { a.brain.StartMarketBriefs(a.config.BriefTimes) } a.scheduler = NewScheduler(a, a.logger) a.scheduler.Start(context.Background()) a.logger.Info("NOFXi agent is online 🚀") } func (a *Agent) Stop() { if a.sentinel != nil { a.sentinel.Stop() } if a.brain != nil { a.brain.Stop() } if a.scheduler != nil { a.scheduler.Stop() } } // HandleMessage — the core. Everything goes through the LLM. func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (string, error) { lang := a.config.Language if strings.HasPrefix(text, "[lang:") { if end := strings.Index(text, "] "); end > 0 { lang = text[6:end]; text = text[end+2:] } } a.logger.Info("message", "user_id", userID, "text", text) // Setup flow — only when user explicitly asks if resp, handled := a.handleSetupFlow(userID, text, lang); handled { return resp, nil } // Only handle bare slash commands directly (instant, no AI needed) if text == "/help" || text == "/start" { return a.msg(lang, "help"), nil } if text == "/status" { return a.handleStatus(lang), nil } if text == "/clear" { a.history.Clear(userID) if lang == "zh" { return "🧹 对话记忆已清除。", nil } return "🧹 Conversation history cleared.", nil } // Check for trade confirmation (e.g. "确认 trade_xxx" or "confirm trade_xxx") if resp, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled { return resp, nil } // Check for direct trade commands (e.g. "做多 BTC 0.01") if trade := parseTradeCommand(text); trade != nil { a.pending.Add(trade) a.pending.CleanExpired() return formatTradeConfirmation(trade, lang), nil } // EVERYTHING else → LLM with tools return a.thinkAndAct(ctx, userID, lang, text) } // thinkAndAct sends the user message to LLM with full context and tools. // The LLM decides what to do — analyze, query, trade, or just chat. // Supports a tool-calling loop: LLM can call tools, get results, and continue. func (a *Agent) thinkAndAct(ctx context.Context, userID int64, lang, text string) (string, error) { if a.aiClient == nil { return a.noAIFallback(lang, text) } // Build rich context for the LLM systemPrompt := a.buildSystemPrompt(lang) // Enrich with real-time data if any asset is mentioned enrichment := a.gatherContext(text) userPrompt := text if enrichment != "" { userPrompt = text + "\n\n---\n[NOFXi System Context - real-time data for reference]\n" + enrichment } // Build messages with conversation history messages := []mcp.Message{mcp.NewSystemMessage(systemPrompt)} // Add conversation history (up to last N messages) history := a.history.Get(userID) for _, msg := range history { messages = append(messages, mcp.NewMessage(msg.Role, msg.Content)) } // Add current user message messages = append(messages, mcp.NewUserMessage(userPrompt)) // Record user message in history a.history.Add(userID, "user", text) // Define tools for the LLM tools := agentTools() // Tool-calling loop (max 5 iterations to prevent infinite loops) const maxToolRounds = 5 for round := 0; round < maxToolRounds; round++ { req := &mcp.Request{ Messages: messages, Tools: tools, ToolChoice: "auto", } resp, err := a.aiClient.CallWithRequestFull(req) if err != nil { a.logger.Error("LLM call failed", "error", err, "round", round) if round == 0 { // First round failed — try without tools as fallback plainReq := &mcp.Request{Messages: messages} plainResp, plainErr := a.aiClient.CallWithRequest(plainReq) if plainErr != nil { return a.noAIFallback(lang, text) } a.history.Add(userID, "assistant", plainResp) return plainResp, nil } return a.noAIFallback(lang, text) } // If LLM returned a text response (no tool calls), we're done if len(resp.ToolCalls) == 0 { a.history.Add(userID, "assistant", resp.Content) return resp.Content, nil } // LLM wants to call tools — process each one a.logger.Info("LLM tool calls", "count", len(resp.ToolCalls), "round", round) // Add assistant message with tool calls to conversation assistantMsg := mcp.Message{ Role: "assistant", ToolCalls: resp.ToolCalls, } if resp.Content != "" { assistantMsg.Content = resp.Content } messages = append(messages, assistantMsg) // Execute each tool call and add results for _, tc := range resp.ToolCalls { a.logger.Info("executing tool", "name", tc.Function.Name, "args", tc.Function.Arguments, "call_id", tc.ID, ) result := a.handleToolCall(ctx, userID, lang, tc) // Add tool result message messages = append(messages, mcp.Message{ Role: "tool", Content: result, ToolCallID: tc.ID, }) } // Continue the loop — LLM will see tool results and either respond or call more tools } // If we exhausted all rounds, ask LLM for a final text response without tools finalReq := &mcp.Request{Messages: messages} finalResp, err := a.aiClient.CallWithRequest(finalReq) if err != nil { return a.noAIFallback(lang, text) } a.history.Add(userID, "assistant", finalResp) return finalResp, nil } // buildSystemPrompt creates the system prompt that makes NOFXi behave like a real agent. func (a *Agent) buildSystemPrompt(lang string) string { // Gather live system state traderInfo := a.getTradersSummary() watchlist := "" if a.sentinel != nil { watchlist = a.sentinel.FormatWatchlist(lang) } if lang == "zh" { return fmt.Sprintf(`你是 NOFXi,一个专业的 AI 交易 Agent。你不是一个简单的聊天机器人——你是用户的交易伙伴。 ## 你的核心能力 1. **市场分析** — 加密货币(BTC/ETH/SOL等)有实时数据,A股/港股/美股/外汇你可以基于知识分析 2. **交易管理** — 查看持仓、余额、交易历史、Trader 状态 3. **策略建议** — 根据用户需求制定交易策略 4. **风险管理** — 评估风险、建议止损止盈 5. **配置引导** — 用户说"开始配置"时引导配置交易所和AI模型 ## 当前系统状态 %s %s ## 数据说明 - 加密货币(BTC/ETH等):交易所实时数据,标注 [Real-time] - A股/港股/美股:通过 search_stock 工具获取**实时行情**(新浪财经数据源) - 美股支持**盘前盘后数据**:search_stock 返回的 quote 中 ext_price/ext_change_pct/ext_time 为盘前盘后价格 - 当返回数据包含 is_ext_hours=true 时,**必须**向用户展示盘前/盘后价格 - 外汇:没有实时数据,需要说明 - 对于没有实时数据的标的,严禁编造具体价格 - **有数据就给数据,没有才说没有。诚实但不过度声明。** ## 工具使用 你可以调用以下工具来执行操作: - **search_stock** — 搜索股票(支持中文名、英文名、代码)。当用户提到你不认识的股票时,先用这个工具搜索。 - **execute_trade** — 下单交易(做多/做空/平多/平空)。调用后会创建待确认订单,用户需回复"确认 trade_xxx"才会真正执行。 - **get_positions** — 查看当前所有持仓 - **get_balance** — 查看账户余额 - **get_market_price** — 获取交易所实时价格 ### 交易安全规则 - 用户明确要求交易时才调用 execute_trade - 分析和建议不需要调用工具,直接回复即可 - 交易确认信息要清晰展示:品种、方向、数量、杠杆 - 提醒用户确认命令格式 ## 行为准则 - 简洁、专业、有观点。不说废话。 - 用户问什么答什么,不要推销配置。 - 有实时数据时给具体价位,没有时给策略框架和思路。 - **诚实是第一原则** — 不确定就说不确定,没数据就说没数据。 - 用交易相关的 emoji 让回复更直观。 - 用中文回复。 当前时间: %s`, traderInfo, watchlist, time.Now().Format("2006-01-02 15:04:05")) } return fmt.Sprintf(`You are NOFXi, a professional AI trading agent. Not a chatbot — a trading partner. ## Capabilities 1. Market analysis — crypto with real-time data, stocks/forex with knowledge 2. Trade management — positions, balance, history, trader status 3. Strategy — build trading strategies based on user needs 4. Risk management — assess risk, suggest stop-loss/take-profit 5. Setup — guide exchange/AI configuration when user asks ## Current System State %s %s ## Data Notice - Crypto (BTC/ETH): Exchange real-time data, marked [Real-time] - A-shares/HK/US stocks: Real-time quotes via search_stock tool (Sina Finance data) - US stocks include **pre-market/after-hours data**: ext_price/ext_change_pct/ext_time in quote - When is_ext_hours=true, you MUST show pre-market/after-hours prices to the user - Forex: No real-time data — must inform user - NEVER fabricate prices for assets without data - Show data when you have it. Only disclaim when you don't. ## Tools You can call these tools to take action: - **search_stock** — Search for stocks by name, ticker, or code. Covers A-share, HK, and US markets. Use when the user mentions an unknown stock. - **execute_trade** — Place a trade order (open_long/open_short/close_long/close_short). Creates a pending order that requires user confirmation. - **get_positions** — View all current open positions - **get_balance** — View account balance and equity - **get_market_price** — Get real-time price from the exchange ### Trade Safety Rules - Only call execute_trade when user explicitly requests a trade - Analysis and advice don't need tools — just reply directly - Show trade details clearly: symbol, direction, quantity, leverage - Remind user of the confirmation command format ## Behavior - Concise, professional, opinionated. No fluff. - Answer what's asked. Don't push setup. - With real-time data: give specific levels. Without: give strategy frameworks. - **Honesty is rule #1** — uncertain = say uncertain, no data = say no data. - Use trading emojis. Current time: %s`, traderInfo, watchlist, time.Now().Format("2006-01-02 15:04:05")) } // gatherContext collects real-time market data relevant to the user's message. func (a *Agent) gatherContext(text string) string { var parts []string upper := strings.ToUpper(text) // Crypto — try to get real-time data cryptoSymbols := []string{"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "DOT", "LINK"} for _, sym := range cryptoSymbols { if strings.Contains(upper, sym) { md, err := market.Get(sym + "USDT") if err == nil { parts = append(parts, fmt.Sprintf("[%s/USDT Real-time]\nPrice: $%.4f | 1h: %+.2f%% | 4h: %+.2f%% | RSI7: %.1f | EMA20: %.4f | MACD: %.6f | Funding: %.4f%%", sym, md.CurrentPrice, md.PriceChange1h, md.PriceChange4h, md.CurrentRSI7, md.CurrentEMA20, md.CurrentMACD, md.FundingRate*100)) } } } // A-share / stocks — try Sina Finance (dynamic search as fallback) stockCode, stockName := resolveStockCodeDynamic(text) if stockCode != "" { quote, err := fetchStockQuote(stockCode) if err == nil && quote.Price > 0 { parts = append(parts, fmt.Sprintf("[%s(%s) Real-time A-share Data]\n%s", quote.Name, quote.Code, formatStockQuote(quote))) } else if err != nil { a.logger.Error("fetch stock quote", "code", stockCode, "name", stockName, "error", err) } } // Trader positions if a.traderManager != nil { for _, t := range a.traderManager.GetAllTraders() { positions, err := t.GetPositions() if err != nil { continue } for _, p := range positions { size := toFloat(p["size"]) if size == 0 { continue } parts = append(parts, fmt.Sprintf("[Position] %s %s: size=%.4f entry=$%.4f mark=$%.4f pnl=$%.2f", p["symbol"], p["side"], size, toFloat(p["entryPrice"]), toFloat(p["markPrice"]), toFloat(p["unrealizedPnl"]))) } } } return strings.Join(parts, "\n") } func (a *Agent) getTradersSummary() string { if a.traderManager == nil { return "Traders: none configured" } traders := a.traderManager.GetAllTraders() if len(traders) == 0 { return "Traders: none configured" } var lines []string for id, t := range traders { s := t.GetStatus() running, _ := s["is_running"].(bool) status := "stopped" if running { status = "running" } tid := id if len(tid) > 8 { tid = tid[:8] } lines = append(lines, fmt.Sprintf("• %s [%s] %s | %s", t.GetName(), tid, status, t.GetExchange())) } return "Traders:\n" + strings.Join(lines, "\n") } func (a *Agent) handleStatus(L string) string { tc, rc := 0, 0 if a.traderManager != nil { all := a.traderManager.GetAllTraders() tc = len(all) for _, t := range all { if s := t.GetStatus(); s["is_running"] == true { rc++ } } } wc := 0 if a.sentinel != nil { wc = a.sentinel.SymbolCount() } ai := "❌" if a.aiClient != nil { ai = "✅" } return fmt.Sprintf(a.msg(L, "status"), rc, tc, wc, ai, time.Now().Format("2006-01-02 15:04:05")) } // noAIFallback — when no AI is available, still try to be useful. func (a *Agent) noAIFallback(lang, text string) (string, error) { upper := strings.ToUpper(text) // Try to provide market data directly for _, sym := range []string{"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE"} { if strings.Contains(upper, sym) { md, err := market.Get(sym + "USDT") if err == nil { return fmt.Sprintf("📊 *%s/USDT*\n\n%s\n\n💡 配置 AI 模型后我能给你更深度的分析。发送 *开始配置* 开始。", sym, market.Format(md)), nil } } } // Check if asking about positions/balance if strings.Contains(text, "持仓") || strings.Contains(upper, "POSITION") { return a.queryPositionsDirect(lang) } if strings.Contains(text, "余额") || strings.Contains(upper, "BALANCE") { return a.queryBalancesDirect(lang) } if lang == "zh" { return "🤖 我是 NOFXi。配置 AI 模型后我就能理解你的任何问题——分析股票、制定策略、管理交易。\n\n现在可用:\n• 加密货币实时行情(试试「BTC」)\n• `/status` 系统状态\n\n发送 *开始配置* 配置 AI 模型。", nil } return "🤖 I'm NOFXi. Configure an AI model and I can understand anything — analyze stocks, build strategies, manage trades.\n\nAvailable now:\n• Crypto real-time data (try 'BTC')\n• `/status` system status\n\nSend *setup* to configure AI.", nil } func (a *Agent) queryPositionsDirect(L string) (string, error) { if a.traderManager == nil { return a.msg(L, "no_traders"), nil } var sb strings.Builder sb.WriteString("📊 *Positions*\n\n") hasAny := false for id, t := range a.traderManager.GetAllTraders() { positions, err := t.GetPositions() if err != nil { continue } for _, p := range positions { size := toFloat(p["size"]) if size == 0 { continue } hasAny = true pnl := toFloat(p["unrealizedPnl"]) e := "🟢"; if pnl < 0 { e = "🔴" } sb.WriteString(fmt.Sprintf("%s *%s* %s — $%.2f | Trader: %s\n", e, p["symbol"], p["side"], pnl, id[:8])) } } if !hasAny { return a.msg(L, "no_positions"), nil } return sb.String(), nil } func (a *Agent) queryBalancesDirect(L string) (string, error) { if a.traderManager == nil { return a.msg(L, "no_traders"), nil } var sb strings.Builder sb.WriteString("💰 *Balance*\n\n") for id, t := range a.traderManager.GetAllTraders() { info, err := t.GetAccountInfo() if err != nil { continue } tid := id; if len(tid) > 8 { tid = tid[:8] } sb.WriteString(fmt.Sprintf("*%s* (%s): $%.2f\n", t.GetName(), tid, toFloat(info["total_equity"]))) } return sb.String(), nil } func (a *Agent) handleSignal(sig Signal) { if a.brain != nil { a.brain.HandleSignal(sig) } } func (a *Agent) notifyAll(text string) { if a.NotifyFunc != nil { a.NotifyFunc(0, text) } } func toFloat(v interface{}) float64 { switch x := v.(type) { case float64: return x case float32: return float64(x) case int: return float64(x) case int64: return float64(x) case string: f, _ := strconv.ParseFloat(x, 64); return f } return 0 }