Files
nofx/telegram/agent/agent.go
tinkle-community 3168a18c0d feat(telegram): add AI agent bot with streaming and account context
- Add Telegram bot with long-polling and AI agent loop (api_call tool)
- SSE streaming with real-time message editing and  placeholder
- Account state injection at conversation start (models, exchanges,
  strategies, traders, per-trader PnL and statistics)
- Lane semaphore per chat serializes concurrent messages (60s timeout)
- Idle timeout watchdog (60s) prevents hung streaming connections
- Look-ahead buffer prevents partial <api_call> tag leaking to user
- Fix PUT /strategies/:id to merge config (read-then-merge pattern)
- Add route registry with full API schema for LLM documentation
- Add TelegramConfig store and Web UI config modal
- Add GetAnyEnabled to AIModel store for bot LLM client selection
2026-03-08 00:19:38 +08:00

229 lines
7.7 KiB
Go

package agent
import (
"encoding/json"
"fmt"
"nofx/auth"
"nofx/logger"
"nofx/mcp"
"nofx/telegram/session"
"strings"
)
const maxIterations = 10
// Agent is a stateful AI agent for one Telegram chat.
// It has a single tool (api_call) and an unbounded decision loop.
type Agent struct {
apiTool *apiCallTool
getLLM func() mcp.AIClient
memory *session.Memory
systemPrompt string
userID string
}
// New creates an Agent for one chat session.
func New(apiPort int, botToken, userID string, getLLM func() mcp.AIClient, systemPrompt string) *Agent {
return &Agent{
apiTool: newAPICallTool(apiPort, botToken),
getLLM: getLLM,
memory: session.NewMemory(getLLM()),
systemPrompt: systemPrompt,
userID: userID,
}
}
// GenerateBotToken creates a long-lived JWT for the bot's internal API calls.
// userID must match the actual registered user's ID so that bot-made changes
// are visible in the frontend (they share the same user namespace).
func GenerateBotToken(userID string) (string, error) {
return auth.GenerateJWT(userID, "bot@internal")
}
// buildAccountContext fetches the live account state (models, exchanges, strategies, traders,
// and per-trader account summary + statistics) via the local API and returns it as a formatted
// string for injection into the LLM context. This gives the LLM immediate awareness of what
// is already configured and the current financial state, so it never asks the user for
// information that already exists.
func (a *Agent) buildAccountContext() string {
type q struct {
label string
path string
}
queries := []q{
{"AI Models", "/api/models"},
{"Exchanges", "/api/exchanges"},
{"Strategies", "/api/strategies"},
{"Traders", "/api/my-traders"},
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("[Current Account State - Authenticated User ID: %s]\n\n", a.userID))
var tradersJSON string
for _, query := range queries {
result := a.apiTool.execute(&apiRequest{Method: "GET", Path: query.path})
sb.WriteString(fmt.Sprintf("%s:\n%s\n\n", query.label, result))
if query.path == "/api/my-traders" {
tradersJSON = result
}
}
// For each running trader, fetch real-time account balance and trading statistics.
var traders []struct {
TraderID string `json:"trader_id"`
Name string `json:"trader_name"`
IsRunning bool `json:"is_running"`
}
if err := json.Unmarshal([]byte(tradersJSON), &traders); err == nil {
for _, t := range traders {
if !t.IsRunning {
continue
}
acct := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/account?trader_id=" + t.TraderID})
sb.WriteString(fmt.Sprintf("Account [%s] (trader_id=%s):\n%s\n\n", t.Name, t.TraderID, acct))
stats := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/statistics?trader_id=" + t.TraderID})
sb.WriteString(fmt.Sprintf("Statistics [%s] (trader_id=%s):\n%s\n\n", t.Name, t.TraderID, stats))
}
}
return sb.String()
}
// Run processes one user message through the agent loop.
// Loop: LLM decides -> if <api_call>: execute, append result, loop -> if no tag: return reply.
//
// On the first message of a conversation, the current account state (models, exchanges,
// strategies, traders) is automatically fetched and injected so the LLM knows what is
// already configured without asking the user to repeat themselves.
//
// onChunk is optional. When non-nil, each LLM call is streamed:
// - Chunks are forwarded to onChunk until an <api_call> tag appears in the accumulated text.
// - After an api_call iteration completes, onChunk("⏳") resets the display to a thinking indicator.
// - The final reply is streamed progressively via onChunk.
func (a *Agent) Run(userMessage string, onChunk func(string)) string {
llm := a.getLLM()
if llm == nil {
return "AI assistant unavailable. Please configure an AI model in the Web UI."
}
// Build turn messages: history context prefix + current user message.
// On the very first message (no history), prepend a live account state snapshot so the
// LLM immediately knows what models, exchanges, strategies, and traders are configured.
histCtx := a.memory.BuildContext()
var firstMsg string
if histCtx == "" {
// First message in this conversation — fetch and inject account state.
accountCtx := a.buildAccountContext()
firstMsg = accountCtx + "\n[User Message]\n" + userMessage
} else {
firstMsg = histCtx + "\n---\nUser: " + userMessage
}
turnMsgs := []mcp.Message{mcp.NewUserMessage(firstMsg)}
var lastResp string
for i := 0; i < maxIterations; i++ {
req, err := mcp.NewRequestBuilder().
WithSystemPrompt(a.systemPrompt).
AddConversationHistory(turnMsgs).
Build()
if err != nil {
logger.Errorf("Agent: failed to build request: %v", err)
break
}
var resp string
if onChunk != nil {
// Stream this call; suppress chunks once an <api_call> tag appears.
// Also hold back the last (len("<api_call>")-1) chars of accumulated text to
// avoid showing partial opening tags (e.g. "<", "<ap") before we can detect them.
const tagLen = len("<api_call>") // 10
const safeOffset = tagLen - 1 // 9: max prefix of tag we might have received
var apiTagSeen bool
resp, err = llm.CallWithRequestStream(req, func(accumulated string) {
if apiTagSeen {
return
}
if idx := strings.Index(accumulated, "<api_call>"); idx >= 0 {
apiTagSeen = true
// Forward only the text that appeared before the tag.
if display := strings.TrimSpace(accumulated[:idx]); display != "" {
onChunk(display)
}
return
}
// Forward only the "safe" prefix — hold back the last safeOffset chars
// in case they are the beginning of an <api_call> tag.
if safe := len(accumulated) - safeOffset; safe > 0 {
onChunk(accumulated[:safe])
}
})
} else {
resp, err = llm.CallWithRequest(req)
}
if err != nil {
logger.Errorf("Agent: LLM call failed (iteration %d): %v", i+1, err)
return "AI assistant temporarily unavailable. Please try again."
}
lastResp = resp
apiReq, textBefore := parseAPICall(resp)
if apiReq == nil {
// No api_call tag — LLM gave a final answer (already streamed if onChunk set).
reply := stripAPICallTag(strings.TrimSpace(resp))
a.memory.Add("user", userMessage)
a.memory.Add("assistant", reply)
return reply
}
// api_call iteration — reset display to thinking indicator before executing.
if onChunk != nil {
onChunk("⏳")
}
logger.Infof("Agent: iter=%d %s %s", i+1, apiReq.Method, apiReq.Path)
result := a.apiTool.execute(apiReq)
if textBefore != "" {
turnMsgs = append(turnMsgs, mcp.NewAssistantMessage(textBefore))
}
turnMsgs = append(turnMsgs, mcp.NewUserMessage(
fmt.Sprintf("[API result: %s %s]\n%s", apiReq.Method, apiReq.Path, result),
))
}
// Safety: max iterations reached — ask LLM for a final summary (non-streaming).
logger.Warnf("Agent: max iterations (%d) reached", maxIterations)
turnMsgs = append(turnMsgs, mcp.NewUserMessage("Please summarize the results and give the user a final reply."))
if finalReq, err := mcp.NewRequestBuilder().
WithSystemPrompt(a.systemPrompt).
AddConversationHistory(turnMsgs).
Build(); err == nil {
if finalResp, err := llm.CallWithRequest(finalReq); err == nil {
lastResp = finalResp
}
}
reply := stripAPICallTag(strings.TrimSpace(lastResp))
a.memory.Add("user", userMessage)
a.memory.Add("assistant", reply)
return reply
}
// stripAPICallTag removes any <api_call>...</api_call> fragment from s.
// Used as a defensive layer to ensure tags never leak to the user.
func stripAPICallTag(s string) string {
if idx := strings.Index(s, "<api_call>"); idx >= 0 {
return strings.TrimSpace(s[:idx])
}
return s
}
// ResetMemory clears conversation history (called on /start).
func (a *Agent) ResetMemory() {
a.memory.ResetFull()
}