Files
nofx/agent/agent.go
shinchan-zhai 75f271ad65 refactor: delete router.go — LLM is the brain, not regex
DELETED: agent/router.go (regex-based intent routing)

The fundamental architecture was wrong. A real AI Agent doesn't use
pattern matching to understand users. It uses AI.

New flow:
1. User says anything
2. /help and /status → instant response (no AI needed)
3. Setup keywords → onboard flow
4. EVERYTHING else → LLM with full context

The LLM receives:
- System prompt with NOFXi's capabilities and behavior rules
- Real-time market data (auto-detected from mentioned symbols)
- Live trader positions and status
- User's message

The LLM decides what to do. Not regex. Not if-else. The AI.

This is what makes it an Agent, not a chatbot.
2026-03-25 01:05:54 +08:00

362 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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"
"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 != "" {
client := mcp.NewClient()
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
## 行为准则
- 简洁、专业、有观点。不说废话。
- 用户问什么答什么,不要推销配置。
- 分析时给出具体的价位、方向、理由。
- 不确定的数据要诚实说明。
- 用交易相关的 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
## Behavior
- Concise, professional, opinionated. No fluff.
- Answer what's asked. Don't push setup.
- Give specific prices, directions, reasoning.
- Be honest about data freshness.
- 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
}