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:
shinchan-zhai
2026-03-23 15:37:36 +08:00
parent 4c73a7960d
commit 3f1dd71f2b
8 changed files with 189 additions and 77 deletions

View File

@@ -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 {