// 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 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} } 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 } // 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. 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 } // Call LLM resp, err := a.aiClient.CallWithMessages(systemPrompt, userPrompt) if err != nil { a.logger.Error("LLM call failed", "error", err) return a.noAIFallback(lang, text) } return resp, 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股/港股/美股/外汇:我**没有**实时价格数据 - 对于没有实时数据的标的,**严禁编造具体价格** - 必须明确告诉用户"以下分析基于历史知识,不含实时数据,请以实际行情为准" - 可以分析趋势、逻辑、策略框架,但具体价位必须让用户自己查看 ## 行为准则 - 简洁、专业、有观点。不说废话。 - 用户问什么答什么,不要推销配置。 - 有实时数据时给具体价位,没有时给策略框架和思路。 - **诚实是第一原则** — 不确定就说不确定,没数据就说没数据。 - 用交易相关的 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): I have real-time data, marked [Real-time] in context - Stocks/Forex: I do NOT have real-time prices - For assets without real-time data, NEVER fabricate specific prices - Must tell user: "Analysis based on historical knowledge, not live data. Verify with actual quotes." - Can analyze trends, logic, strategy frameworks — but specific prices must be verified by user ## 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)) } } } // 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 }