mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-11 14:56:57 +08:00
fix: context propagation in LLM calls, SSE error handling, security hardening
- Propagate request context through MCP client (Request.Ctx field) — Client disconnects now cancel in-flight LLM API calls (both streaming and non-streaming) — Prevents wasted LLM tokens when user navigates away mid-response - proxyBinance: use http.NewRequestWithContext for context-aware upstream calls — Client disconnects cancel Binance proxy requests — Distinguishes client cancellation from upstream failures - Fix SSE parse bug in AgentChatPage: catch block was swallowing error events — throw new Error(data) for 'error' events was caught by the same try/catch — Now parse JSON separately, then handle events outside try/catch - Add io.LimitReader to Hyperliquid coins.go JSON decoder (4MB limit) - Use safe.ReadAllLimited in batch ticker handler for consistency - Sanitize URL validation error in handler_ai_model.go (was leaking raw error) - Cache agent tool definitions (built once, reused per message) - Sort trade history by exit time for consistent ordering
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"nofx/manager"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/safe"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
@@ -95,6 +97,16 @@ func (a *Agent) Start() {
|
||||
if a.config.EnableBriefs { a.brain.StartMarketBriefs(a.config.BriefTimes) }
|
||||
a.scheduler = NewScheduler(a, a.logger)
|
||||
a.scheduler.Start(context.Background())
|
||||
|
||||
// Periodic cleanup of stale chat sessions (older than 4 hours)
|
||||
safe.GoNamed("chat-history-cleanup", func() {
|
||||
ticker := time.NewTicker(30 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
a.history.CleanOld(4 * time.Hour)
|
||||
}
|
||||
})
|
||||
|
||||
a.logger.Info("NOFXi agent is online 🚀")
|
||||
}
|
||||
|
||||
@@ -234,6 +246,7 @@ func (a *Agent) thinkAndAct(ctx context.Context, userID int64, lang, text string
|
||||
Messages: messages,
|
||||
Tools: tools,
|
||||
ToolChoice: "auto",
|
||||
Ctx: ctx,
|
||||
}
|
||||
|
||||
resp, err := a.aiClient.CallWithRequestFull(req)
|
||||
@@ -241,7 +254,7 @@ func (a *Agent) thinkAndAct(ctx context.Context, userID int64, lang, text string
|
||||
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}
|
||||
plainReq := &mcp.Request{Messages: messages, Ctx: ctx}
|
||||
plainResp, plainErr := a.aiClient.CallWithRequest(plainReq)
|
||||
if plainErr != nil {
|
||||
return a.noAIFallback(lang, text)
|
||||
@@ -293,7 +306,7 @@ func (a *Agent) thinkAndAct(ctx context.Context, userID int64, lang, text string
|
||||
}
|
||||
|
||||
// If we exhausted all rounds, ask LLM for a final text response without tools
|
||||
finalReq := &mcp.Request{Messages: messages}
|
||||
finalReq := &mcp.Request{Messages: messages, Ctx: ctx}
|
||||
finalResp, err := a.aiClient.CallWithRequest(finalReq)
|
||||
if err != nil {
|
||||
return a.noAIFallback(lang, text)
|
||||
@@ -343,13 +356,13 @@ func (a *Agent) thinkAndActStream(ctx context.Context, userID int64, lang, text
|
||||
toolsUsed := false
|
||||
|
||||
for round := 0; round < maxToolRounds; round++ {
|
||||
req := &mcp.Request{Messages: messages, Tools: tools, ToolChoice: "auto"}
|
||||
req := &mcp.Request{Messages: messages, Tools: tools, ToolChoice: "auto", Ctx: ctx}
|
||||
resp, err := a.aiClient.CallWithRequestFull(req)
|
||||
if err != nil {
|
||||
a.logger.Error("LLM call failed (stream)", "error", err, "round", round)
|
||||
if round == 0 {
|
||||
// First round failed — try streaming without tools as fallback
|
||||
streamReq := &mcp.Request{Messages: messages}
|
||||
streamReq := &mcp.Request{Messages: messages, Ctx: ctx}
|
||||
streamText, streamErr := a.aiClient.CallWithRequestStream(streamReq, func(chunk string) {
|
||||
onEvent(StreamEventDelta, chunk)
|
||||
})
|
||||
@@ -396,7 +409,7 @@ func (a *Agent) thinkAndActStream(ctx context.Context, userID int64, lang, text
|
||||
|
||||
// After tool execution, stream the next LLM response for real-time UX.
|
||||
// Omit tools so LLM can't start another tool round — it must produce text.
|
||||
streamReq := &mcp.Request{Messages: messages}
|
||||
streamReq := &mcp.Request{Messages: messages, Ctx: ctx}
|
||||
streamText, streamErr := a.aiClient.CallWithRequestStream(streamReq, func(chunk string) {
|
||||
onEvent(StreamEventDelta, chunk)
|
||||
})
|
||||
@@ -409,7 +422,7 @@ func (a *Agent) thinkAndActStream(ctx context.Context, userID int64, lang, text
|
||||
}
|
||||
|
||||
// Exhausted all tool rounds — stream the final synthesis response
|
||||
finalReq := &mcp.Request{Messages: messages}
|
||||
finalReq := &mcp.Request{Messages: messages, Ctx: ctx}
|
||||
finalText, err := a.aiClient.CallWithRequestStream(finalReq, func(chunk string) {
|
||||
onEvent(StreamEventDelta, chunk)
|
||||
})
|
||||
@@ -582,9 +595,16 @@ func (a *Agent) gatherContext(text string) string {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Collect and sort matched symbols for deterministic selection
|
||||
sortedSymbols := make([]string, 0, len(matched))
|
||||
for sym := range matched {
|
||||
sortedSymbols = append(sortedSymbols, sym)
|
||||
}
|
||||
sort.Strings(sortedSymbols)
|
||||
|
||||
// Cap at 5 symbols to avoid slow context gathering
|
||||
count := 0
|
||||
for sym := range matched {
|
||||
for _, sym := range sortedSymbols {
|
||||
if count >= 5 { break }
|
||||
md, err := market.Get(sym + "USDT")
|
||||
if err == nil && md.CurrentPrice > 0 {
|
||||
|
||||
@@ -4,14 +4,20 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
// agentTools defines the tools available to the LLM for autonomous action.
|
||||
func agentTools() []mcp.Tool {
|
||||
// cachedTools holds the static tool definitions (built once, reused per message).
|
||||
var cachedTools = buildAgentTools()
|
||||
|
||||
// agentTools returns the tools available to the LLM for autonomous action.
|
||||
func agentTools() []mcp.Tool { return cachedTools }
|
||||
|
||||
func buildAgentTools() []mcp.Tool {
|
||||
return []mcp.Tool{
|
||||
{
|
||||
Type: "function",
|
||||
@@ -423,6 +429,13 @@ func (a *Agent) toolGetTradeHistory(argsJSON string) string {
|
||||
return `{"trades": [], "message": "no closed trades found"}`
|
||||
}
|
||||
|
||||
// Sort trades by exit time (most recent first) for consistent ordering across traders
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
ti, _ := trades[i]["exit_time"].(string)
|
||||
tj, _ := trades[j]["exit_time"].(string)
|
||||
return ti > tj // reverse chronological
|
||||
})
|
||||
|
||||
// Only return up to the limit
|
||||
if len(trades) > args.Limit {
|
||||
trades = trades[:args.Limit]
|
||||
|
||||
109
agent/web.go
109
agent/web.go
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"nofx/safe"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
@@ -171,7 +172,7 @@ func (w *WebHandler) HandleKlines(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
proxyBinance(rw, fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=300", symbol, interval))
|
||||
proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=300", symbol, interval))
|
||||
}
|
||||
|
||||
// HandleTicker proxies ticker data from Binance.
|
||||
@@ -184,22 +185,22 @@ func (w *WebHandler) HandleTicker(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
proxyBinance(rw, fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
|
||||
proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
|
||||
}
|
||||
|
||||
// HandleTickers handles GET /api/agent/tickers?symbols=BTCUSDT,ETHUSDT,SOLUSDT
|
||||
// Batch endpoint: fetches multiple tickers in one API call to Binance.
|
||||
// Batch endpoint: fetches multiple tickers concurrently, returns array.
|
||||
func (w *WebHandler) HandleTickers(rw http.ResponseWriter, r *http.Request) {
|
||||
symbolsParam := r.URL.Query().Get("symbols")
|
||||
if symbolsParam == "" {
|
||||
symbolsParam = "BTCUSDT,ETHUSDT,SOLUSDT"
|
||||
}
|
||||
|
||||
// Validate and build JSON array of symbols
|
||||
symbols := []string{}
|
||||
for _, s := range splitAndTrim(symbolsParam) {
|
||||
// Validate symbols
|
||||
var symbols []string
|
||||
for _, s := range splitComma(symbolsParam) {
|
||||
if validSymbolRe.MatchString(s) {
|
||||
symbols = append(symbols, `"`+s+`"`)
|
||||
symbols = append(symbols, s)
|
||||
}
|
||||
}
|
||||
if len(symbols) == 0 {
|
||||
@@ -211,15 +212,70 @@ func (w *WebHandler) HandleTickers(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Binance supports batch ticker: /fapi/v1/ticker/24hr with multiple symbols
|
||||
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbols=[%s]", joinStrings(symbols, ","))
|
||||
proxyBinance(rw, url)
|
||||
// Fetch all tickers concurrently with context propagation
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
idx int
|
||||
data json.RawMessage
|
||||
}
|
||||
results := make(chan result, len(symbols))
|
||||
for i, sym := range symbols {
|
||||
idx, s := i, sym
|
||||
safe.GoNamed("ticker-fetch-"+s, func() {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET",
|
||||
fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", s), nil)
|
||||
if err != nil {
|
||||
results <- result{idx: idx}
|
||||
return
|
||||
}
|
||||
resp, err := binanceClient.Do(req)
|
||||
if err != nil {
|
||||
results <- result{idx: idx}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
results <- result{idx: idx}
|
||||
return
|
||||
}
|
||||
body, err := safe.ReadAllLimited(resp.Body, 16*1024)
|
||||
if err != nil {
|
||||
results <- result{idx: idx}
|
||||
return
|
||||
}
|
||||
results <- result{idx: idx, data: body}
|
||||
})
|
||||
}
|
||||
|
||||
// Collect results in order
|
||||
ordered := make([]json.RawMessage, len(symbols))
|
||||
for range symbols {
|
||||
r := <-results
|
||||
if r.data != nil {
|
||||
ordered[r.idx] = r.data
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out nil entries and write response
|
||||
out := make([]json.RawMessage, 0, len(ordered))
|
||||
for _, d := range ordered {
|
||||
if d != nil {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(rw).Encode(out)
|
||||
}
|
||||
|
||||
// splitAndTrim splits a comma-separated string and trims whitespace.
|
||||
func splitAndTrim(s string) []string {
|
||||
parts := []string{}
|
||||
for _, p := range regexp.MustCompile(`\s*,\s*`).Split(s, -1) {
|
||||
// commaRe is pre-compiled for splitComma — avoids recompiling on every call.
|
||||
var commaRe = regexp.MustCompile(`\s*,\s*`)
|
||||
|
||||
// splitComma splits a comma-separated string, trims whitespace, skips empty.
|
||||
func splitComma(s string) []string {
|
||||
var parts []string
|
||||
for _, p := range commaRe.Split(s, -1) {
|
||||
if p != "" {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
@@ -227,21 +283,18 @@ func splitAndTrim(s string) []string {
|
||||
return parts
|
||||
}
|
||||
|
||||
// joinStrings joins strings with a separator.
|
||||
func joinStrings(ss []string, sep string) string {
|
||||
result := ""
|
||||
for i, s := range ss {
|
||||
if i > 0 {
|
||||
result += sep
|
||||
}
|
||||
result += s
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func proxyBinance(rw http.ResponseWriter, url string) {
|
||||
resp, err := binanceClient.Get(url)
|
||||
func proxyBinance(rw http.ResponseWriter, ctx context.Context, url string) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
writeJSON(rw, 500, map[string]string{"error": "failed to create request"})
|
||||
return
|
||||
}
|
||||
resp, err := binanceClient.Do(req)
|
||||
if err != nil {
|
||||
// Distinguish client cancellation from upstream failures
|
||||
if ctx.Err() != nil {
|
||||
return // Client disconnected, no point writing response
|
||||
}
|
||||
writeJSON(rw, 502, map[string]string{"error": "upstream request failed"})
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user