feat(agent): LLM tool-calling for auto trade execution

- New agent/tools.go: defines LLM tools (execute_trade, get_positions,
  get_balance, get_market_price)
- thinkAndAct now uses CallWithRequestFull with tool-calling loop
  (max 5 rounds) — LLM autonomously decides when to call tools
- Trade safety: execute_trade creates pending orders requiring user
  confirmation ('确认 trade_xxx' / 'confirm trade_xxx')
- Pending trades expire after 5 minutes
- System prompt updated with tool usage instructions and safety rules
- Both paths work: regex shortcut ('做多 BTC 0.01') and natural
  language ('帮我开一个BTC多单') via LLM tool calling
- Fallback: if tool-calling fails, degrades to plain LLM call
This commit is contained in:
shinchan-zhai
2026-03-23 02:27:25 +08:00
parent 752e90dbe2
commit 4fa375771d
3 changed files with 695 additions and 11 deletions

View File

@@ -30,6 +30,7 @@ type Agent struct {
scheduler *Scheduler
logger *slog.Logger
history *chatHistory
pending *pendingTrades
NotifyFunc func(userID int64, text string) error
}
@@ -51,7 +52,7 @@ func DefaultConfig() *Config {
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)}
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 }
@@ -133,12 +134,25 @@ func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (s
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)
@@ -170,18 +184,82 @@ func (a *Agent) thinkAndAct(ctx context.Context, userID int64, lang, text string
// Record user message in history
a.history.Add(userID, "user", text)
// Call LLM with full conversation context
req := &mcp.Request{Messages: messages}
resp, err := a.aiClient.CallWithRequest(req)
if err != nil {
a.logger.Error("LLM call failed", "error", err)
return a.noAIFallback(lang, 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
}
// Record assistant response in history
a.history.Add(userID, "assistant", resp)
return resp, nil
// 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.
@@ -214,6 +292,19 @@ func (a *Agent) buildSystemPrompt(lang string) string {
- 必须明确告诉用户"以下分析基于历史知识,不含实时数据,请以实际行情为准"
- 可以分析趋势、逻辑、策略框架,但具体价位必须让用户自己查看
## 工具使用
你可以调用以下工具来执行操作:
- **execute_trade** — 下单交易(做多/做空/平多/平空)。调用后会创建待确认订单,用户需回复"确认 trade_xxx"才会真正执行。
- **get_positions** — 查看当前所有持仓
- **get_balance** — 查看账户余额
- **get_market_price** — 获取交易所实时价格
### 交易安全规则
- 用户明确要求交易时才调用 execute_trade
- 分析和建议不需要调用工具,直接回复即可
- 交易确认信息要清晰展示:品种、方向、数量、杠杆
- 提醒用户确认命令格式
## 行为准则
- 简洁、专业、有观点。不说废话。
- 用户问什么答什么,不要推销配置。
@@ -245,6 +336,19 @@ func (a *Agent) buildSystemPrompt(lang string) string {
- 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
## Tools
You can call these tools to take action:
- **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.

260
agent/tools.go Normal file
View File

@@ -0,0 +1,260 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"nofx/mcp"
)
// agentTools defines the tools available to the LLM for autonomous action.
func agentTools() []mcp.Tool {
return []mcp.Tool{
{
Type: "function",
Function: mcp.FunctionDef{
Name: "execute_trade",
Description: "Execute a trade order. Use this when the user explicitly asks to open/close a position. This will create a pending trade that requires user confirmation before execution.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"open_long", "open_short", "close_long", "close_short"},
"description": "Trade action: open_long (做多/buy), open_short (做空/sell), close_long (平多), close_short (平空)",
},
"symbol": map[string]any{
"type": "string",
"description": "Trading symbol, e.g. BTCUSDT, ETHUSDT. Always append USDT if not present.",
},
"quantity": map[string]any{
"type": "number",
"description": "Trade quantity/amount. Required for opening positions. Use 0 to close entire position.",
},
"leverage": map[string]any{
"type": "number",
"description": "Leverage multiplier (e.g. 5, 10, 20). Optional, defaults to trader's current setting.",
},
},
"required": []string{"action", "symbol", "quantity"},
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "get_positions",
Description: "Get all current open positions across all traders. Returns symbol, side, size, entry price, mark price, and unrealized PnL.",
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "get_balance",
Description: "Get account balance and equity across all traders.",
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "get_market_price",
Description: "Get the current market price for a symbol from the trader's exchange.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"symbol": map[string]any{
"type": "string",
"description": "Trading symbol, e.g. BTCUSDT",
},
},
"required": []string{"symbol"},
},
},
},
}
}
// handleToolCall processes a single tool call from the LLM and returns the result.
func (a *Agent) handleToolCall(ctx context.Context, userID int64, lang string, tc mcp.ToolCall) string {
switch tc.Function.Name {
case "execute_trade":
return a.toolExecuteTrade(ctx, userID, lang, tc.Function.Arguments)
case "get_positions":
return a.toolGetPositions()
case "get_balance":
return a.toolGetBalance()
case "get_market_price":
return a.toolGetMarketPrice(tc.Function.Arguments)
default:
return fmt.Sprintf(`{"error": "unknown tool: %s"}`, tc.Function.Name)
}
}
func (a *Agent) toolExecuteTrade(_ context.Context, userID int64, lang, argsJSON string) string {
var args struct {
Action string `json:"action"`
Symbol string `json:"symbol"`
Quantity float64 `json:"quantity"`
Leverage int `json:"leverage"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return fmt.Sprintf(`{"error": "invalid arguments: %s"}`, err)
}
// Normalize symbol
sym := strings.ToUpper(args.Symbol)
if !strings.HasSuffix(sym, "USDT") {
sym += "USDT"
}
// Validate action
validActions := map[string]bool{
"open_long": true, "open_short": true,
"close_long": true, "close_short": true,
}
if !validActions[args.Action] {
return fmt.Sprintf(`{"error": "invalid action: %s"}`, args.Action)
}
// For open actions, quantity must be > 0
if (args.Action == "open_long" || args.Action == "open_short") && args.Quantity <= 0 {
return `{"error": "quantity must be > 0 for opening positions"}`
}
// Create pending trade — requires user confirmation
trade := &TradeAction{
ID: fmt.Sprintf("trade_%d", time.Now().UnixNano()),
Action: args.Action,
Symbol: sym,
Quantity: args.Quantity,
Leverage: args.Leverage,
Status: "pending_confirmation",
CreatedAt: time.Now().Unix(),
}
a.pending.Add(trade)
a.pending.CleanExpired()
// Return confirmation info to LLM so it can present it to the user
result, _ := json.Marshal(map[string]any{
"status": "pending_confirmation",
"trade_id": trade.ID,
"action": trade.Action,
"symbol": trade.Symbol,
"quantity": trade.Quantity,
"leverage": trade.Leverage,
"message": fmt.Sprintf("Trade created. User must confirm with: 确认 %s (or: confirm %s)", trade.ID, trade.ID),
"expires": "5 minutes",
})
return string(result)
}
func (a *Agent) toolGetPositions() string {
if a.traderManager == nil {
return `{"error": "no trader manager configured"}`
}
var positions []map[string]any
for id, t := range a.traderManager.GetAllTraders() {
pos, err := t.GetPositions()
if err != nil {
continue
}
for _, p := range pos {
size := toFloat(p["size"])
if size == 0 {
continue
}
tid := id
if len(tid) > 8 {
tid = tid[:8]
}
positions = append(positions, map[string]any{
"trader": tid,
"symbol": p["symbol"],
"side": p["side"],
"size": size,
"entry_price": toFloat(p["entryPrice"]),
"mark_price": toFloat(p["markPrice"]),
"unrealized_pnl": toFloat(p["unrealizedPnl"]),
"leverage": p["leverage"],
})
}
}
if len(positions) == 0 {
return `{"positions": [], "message": "no open positions"}`
}
result, _ := json.Marshal(map[string]any{"positions": positions})
return string(result)
}
func (a *Agent) toolGetBalance() string {
if a.traderManager == nil {
return `{"error": "no trader manager configured"}`
}
var balances []map[string]any
for id, t := range a.traderManager.GetAllTraders() {
info, err := t.GetAccountInfo()
if err != nil {
continue
}
tid := id
if len(tid) > 8 {
tid = tid[:8]
}
balances = append(balances, map[string]any{
"trader": tid,
"name": t.GetName(),
"exchange": t.GetExchange(),
"total_equity": toFloat(info["total_equity"]),
"available": toFloat(info["available_balance"]),
"used_margin": toFloat(info["used_margin"]),
})
}
result, _ := json.Marshal(map[string]any{"balances": balances})
return string(result)
}
func (a *Agent) toolGetMarketPrice(argsJSON string) string {
var args struct {
Symbol string `json:"symbol"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return fmt.Sprintf(`{"error": "invalid arguments: %s"}`, err)
}
sym := strings.ToUpper(args.Symbol)
if !strings.HasSuffix(sym, "USDT") {
sym += "USDT"
}
if a.traderManager == nil {
return `{"error": "no trader manager configured"}`
}
for _, t := range a.traderManager.GetAllTraders() {
underlying := t.GetUnderlyingTrader()
if underlying == nil {
continue
}
price, err := underlying.GetMarketPrice(sym)
if err == nil && price > 0 {
result, _ := json.Marshal(map[string]any{
"symbol": sym,
"price": price,
})
return string(result)
}
}
return fmt.Sprintf(`{"error": "could not get price for %s"}`, sym)
}

320
agent/trade.go Normal file
View File

@@ -0,0 +1,320 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
)
// TradeAction represents a parsed trade intent from the LLM or user.
type TradeAction struct {
ID string `json:"id"`
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short"
Symbol string `json:"symbol"` // e.g. "BTCUSDT"
Quantity float64 `json:"quantity"` // amount
Leverage int `json:"leverage"` // leverage multiplier
TraderID string `json:"trader_id"` // which trader to use
Status string `json:"status"` // "pending", "confirmed", "executed", "failed", "expired"
CreatedAt int64 `json:"created_at"`
Error string `json:"error,omitempty"`
}
// pendingTrades stores pending trade confirmations.
type pendingTrades struct {
mu sync.RWMutex
trades map[string]*TradeAction // id -> trade
}
func newPendingTrades() *pendingTrades {
return &pendingTrades{trades: make(map[string]*TradeAction)}
}
func (p *pendingTrades) Add(t *TradeAction) {
p.mu.Lock()
defer p.mu.Unlock()
p.trades[t.ID] = t
}
func (p *pendingTrades) Get(id string) *TradeAction {
p.mu.RLock()
defer p.mu.RUnlock()
return p.trades[id]
}
func (p *pendingTrades) Remove(id string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.trades, id)
}
// CleanExpired removes trades older than 5 minutes.
func (p *pendingTrades) CleanExpired() {
p.mu.Lock()
defer p.mu.Unlock()
cutoff := time.Now().Add(-5 * time.Minute).Unix()
for id, t := range p.trades {
if t.CreatedAt < cutoff {
delete(p.trades, id)
}
}
}
// parseTradeCommand parses natural language trade commands.
// Returns nil if the message is not a trade command.
func parseTradeCommand(text string) *TradeAction {
upper := strings.ToUpper(strings.TrimSpace(text))
// Pattern: "做多 BTC 0.01" / "做空 ETH 0.1" / "long BTC 0.01" / "short ETH 0.1"
// Also: "平多 BTC" / "平空 ETH" / "close long BTC" / "close short ETH"
var action, symbol string
var quantity float64
var leverage int
words := strings.Fields(upper)
if len(words) < 2 {
return nil
}
switch words[0] {
case "做多", "LONG", "BUY":
action = "open_long"
case "做空", "SHORT", "SELL":
action = "open_short"
case "平多":
action = "close_long"
case "平空":
action = "close_short"
case "CLOSE":
if len(words) >= 3 {
switch words[1] {
case "LONG":
action = "close_long"
words = append(words[:1], words[2:]...) // remove "LONG"
case "SHORT":
action = "close_short"
words = append(words[:1], words[2:]...) // remove "SHORT"
}
}
if action == "" {
return nil
}
default:
return nil
}
// Parse symbol
if len(words) < 2 {
return nil
}
symbol = words[1]
if !strings.HasSuffix(symbol, "USDT") {
symbol += "USDT"
}
// Parse quantity (optional)
if len(words) >= 3 {
fmt.Sscanf(words[2], "%f", &quantity)
}
// Parse leverage (optional, "x10" or "10x")
if len(words) >= 4 {
lev := strings.TrimSuffix(strings.TrimPrefix(words[3], "X"), "X")
fmt.Sscanf(lev, "%d", &leverage)
}
if action == "" || symbol == "" {
return nil
}
return &TradeAction{
ID: fmt.Sprintf("trade_%d", time.Now().UnixNano()),
Action: action,
Symbol: symbol,
Quantity: quantity,
Leverage: leverage,
Status: "pending",
CreatedAt: time.Now().Unix(),
}
}
// executeTrade performs the actual trade execution via TraderManager.
func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error {
if a.traderManager == nil {
return fmt.Errorf("no trader manager available")
}
traders := a.traderManager.GetAllTraders()
if len(traders) == 0 {
return fmt.Errorf("no traders configured")
}
// Find a running trader's underlying exchange interface
var underlyingTrader interface {
OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
CloseLong(symbol string, quantity float64) (map[string]interface{}, error)
CloseShort(symbol string, quantity float64) (map[string]interface{}, error)
}
for _, t := range traders {
s := t.GetStatus()
running, _ := s["is_running"].(bool)
if running {
ut := t.GetUnderlyingTrader()
if ut == nil {
continue
}
underlyingTrader = ut
break
}
}
if underlyingTrader == nil {
return fmt.Errorf("no running trader supports trade execution")
}
switch trade.Action {
case "open_long":
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
_, err := underlyingTrader.OpenLong(trade.Symbol, trade.Quantity, trade.Leverage)
return err
case "open_short":
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
_, err := underlyingTrader.OpenShort(trade.Symbol, trade.Quantity, trade.Leverage)
return err
case "close_long":
_, err := underlyingTrader.CloseLong(trade.Symbol, trade.Quantity)
return err
case "close_short":
_, err := underlyingTrader.CloseShort(trade.Symbol, trade.Quantity)
return err
default:
return fmt.Errorf("unknown action: %s", trade.Action)
}
}
// formatTradeConfirmation creates a confirmation message for a pending trade.
func formatTradeConfirmation(trade *TradeAction, lang string) string {
actionNames := map[string]string{
"open_long": "做多 (Long)",
"open_short": "做空 (Short)",
"close_long": "平多 (Close Long)",
"close_short": "平空 (Close Short)",
}
symbol := strings.TrimSuffix(trade.Symbol, "USDT")
actionName := actionNames[trade.Action]
if actionName == "" {
actionName = trade.Action
}
if lang == "zh" {
msg := fmt.Sprintf("⚠️ **交易确认**\n\n"+
"操作: %s\n"+
"品种: %s\n", actionName, symbol)
if trade.Quantity > 0 {
msg += fmt.Sprintf("数量: %.4f\n", trade.Quantity)
}
if trade.Leverage > 0 {
msg += fmt.Sprintf("杠杆: %dx\n", trade.Leverage)
}
msg += fmt.Sprintf("\n发送 `确认 %s` 执行交易,或忽略取消。", trade.ID)
return msg
}
msg := fmt.Sprintf("⚠️ **Trade Confirmation**\n\n"+
"Action: %s\n"+
"Symbol: %s\n", actionName, symbol)
if trade.Quantity > 0 {
msg += fmt.Sprintf("Quantity: %.4f\n", trade.Quantity)
}
if trade.Leverage > 0 {
msg += fmt.Sprintf("Leverage: %dx\n", trade.Leverage)
}
msg += fmt.Sprintf("\nSend `confirm %s` to execute, or ignore to cancel.", trade.ID)
return msg
}
// handleTradeConfirmation processes a trade confirmation message.
func (a *Agent) handleTradeConfirmation(ctx context.Context, userID int64, text, lang string) (string, bool) {
upper := strings.ToUpper(strings.TrimSpace(text))
var tradeID string
if strings.HasPrefix(upper, "确认 ") || strings.HasPrefix(upper, "CONFIRM ") {
parts := strings.Fields(text)
if len(parts) >= 2 {
tradeID = parts[1]
}
}
if tradeID == "" {
return "", false
}
if a.pending == nil {
return "", false
}
trade := a.pending.Get(tradeID)
if trade == nil {
if lang == "zh" {
return "❌ 交易已过期或不存在。", true
}
return "❌ Trade expired or not found.", true
}
a.pending.Remove(tradeID)
trade.Status = "confirmed"
a.logger.Info("executing trade",
slog.String("id", trade.ID),
slog.String("action", trade.Action),
slog.String("symbol", trade.Symbol),
slog.Float64("quantity", trade.Quantity),
)
err := a.executeTrade(ctx, trade)
if err != nil {
trade.Status = "failed"
trade.Error = err.Error()
if lang == "zh" {
return fmt.Sprintf("❌ 交易执行失败: %s", err.Error()), true
}
return fmt.Sprintf("❌ Trade execution failed: %s", err.Error()), true
}
trade.Status = "executed"
symbol := strings.TrimSuffix(trade.Symbol, "USDT")
actionEmoji := "📈"
if strings.Contains(trade.Action, "short") {
actionEmoji = "📉"
}
if strings.Contains(trade.Action, "close") {
actionEmoji = "✅"
}
qtyStr := ""
if trade.Quantity > 0 {
qtyStr = fmt.Sprintf(" %.4f", trade.Quantity)
}
if lang == "zh" {
return fmt.Sprintf("%s 交易已执行!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
}
return fmt.Sprintf("%s Trade executed!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
}
// marshals trade action to JSON for embedding in responses
func marshalTradeAction(trade *TradeAction) string {
b, _ := json.Marshal(trade)
return string(b)
}