mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-13 07:46:54 +08:00
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
This commit is contained in:
228
telegram/agent/agent.go
Normal file
228
telegram/agent/agent.go
Normal file
@@ -0,0 +1,228 @@
|
||||
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()
|
||||
}
|
||||
183
telegram/agent/agent_test.go
Normal file
183
telegram/agent/agent_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
type mockLLM struct {
|
||||
responses []string
|
||||
calls int
|
||||
lastMsgs []mcp.Message
|
||||
}
|
||||
|
||||
func (m *mockLLM) SetAPIKey(_, _, _ string) {}
|
||||
func (m *mockLLM) SetTimeout(_ time.Duration) {}
|
||||
func (m *mockLLM) CallWithMessages(_, _ string) (string, error) { return m.next() }
|
||||
func (m *mockLLM) CallWithRequest(req *mcp.Request) (string, error) {
|
||||
m.lastMsgs = req.Messages
|
||||
return m.next()
|
||||
}
|
||||
func (m *mockLLM) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) {
|
||||
m.lastMsgs = req.Messages
|
||||
r, err := m.next()
|
||||
if onChunk != nil {
|
||||
onChunk(r)
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
func (m *mockLLM) next() (string, error) {
|
||||
if m.calls < len(m.responses) {
|
||||
r := m.responses[m.calls]
|
||||
m.calls++
|
||||
return r, nil
|
||||
}
|
||||
return "OK", nil
|
||||
}
|
||||
|
||||
func mockGetLLM(llm *mockLLM) func() mcp.AIClient {
|
||||
return func() mcp.AIClient { return llm }
|
||||
}
|
||||
|
||||
const testPrompt = "You are a test assistant."
|
||||
|
||||
// TestAgentDirectReply: LLM replies without api_call — one call, direct reply.
|
||||
func TestAgentDirectReply(t *testing.T) {
|
||||
llm := &mockLLM{responses: []string{"Hello! How can I help you?"}}
|
||||
a := New(8080, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
|
||||
reply := a.Run("hello", nil)
|
||||
|
||||
if reply != "Hello! How can I help you?" {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
if llm.calls != 1 {
|
||||
t.Fatalf("expected 1 LLM call, got %d", llm.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentAPICall: LLM calls API, gets result, gives final reply — two LLM calls.
|
||||
func TestAgentAPICall(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/my-traders" {
|
||||
w.Write([]byte(`[{"id":"t1","name":"BTC Strategy"}]`)) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var port int
|
||||
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
|
||||
|
||||
llm := &mockLLM{responses: []string{
|
||||
`Let me check.<api_call>{"method":"GET","path":"/api/my-traders","body":{}}</api_call>`,
|
||||
"You have one trader: BTC Strategy.",
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
|
||||
reply := a.Run("list my traders", nil)
|
||||
|
||||
if reply != "You have one trader: BTC Strategy." {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
if llm.calls != 2 {
|
||||
t.Fatalf("expected 2 LLM calls, got %d", llm.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentMultiStep: LLM chains two API calls before final reply — three LLM calls.
|
||||
func TestAgentMultiStep(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var port int
|
||||
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
|
||||
|
||||
llm := &mockLLM{responses: []string{
|
||||
`Checking account.<api_call>{"method":"GET","path":"/api/account","body":{}}</api_call>`,
|
||||
`Now checking positions.<api_call>{"method":"GET","path":"/api/positions","body":{}}</api_call>`,
|
||||
"Account looks healthy and no open positions.",
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
|
||||
reply := a.Run("show me account status", nil)
|
||||
|
||||
if llm.calls != 3 {
|
||||
t.Fatalf("expected 3 LLM calls (2 api + 1 final), got %d", llm.calls)
|
||||
}
|
||||
if reply != "Account looks healthy and no open positions." {
|
||||
t.Fatalf("unexpected final reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentAPIResultInContext: API result must appear in next LLM message.
|
||||
func TestAgentAPIResultInContext(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"balance":1234.56}`)) //nolint:errcheck
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var port int
|
||||
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
|
||||
|
||||
llm := &mockLLM{responses: []string{
|
||||
`<api_call>{"method":"GET","path":"/api/account","body":{}}</api_call>`,
|
||||
"Balance is 1234.56 USDT.",
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
a.Run("show balance", nil)
|
||||
|
||||
found := false
|
||||
for _, msg := range llm.lastMsgs {
|
||||
if strings.Contains(msg.Content, "API result") || strings.Contains(msg.Content, "balance") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("API result not found in subsequent LLM context")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseAPICall: unit tests for the XML tag parser.
|
||||
func TestParseAPICall(t *testing.T) {
|
||||
t.Run("valid call", func(t *testing.T) {
|
||||
resp := `Stopping trader.<api_call>{"method":"POST","path":"/api/traders/t1/stop","body":{}}</api_call>`
|
||||
req, text := parseAPICall(resp)
|
||||
if req == nil {
|
||||
t.Fatal("expected api_call, got nil")
|
||||
}
|
||||
if req.Method != "POST" || req.Path != "/api/traders/t1/stop" {
|
||||
t.Fatalf("unexpected req: %+v", req)
|
||||
}
|
||||
if text != "Stopping trader." {
|
||||
t.Fatalf("unexpected text before tag: %q", text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no call tag", func(t *testing.T) {
|
||||
req, text := parseAPICall("Just a reply.")
|
||||
if req != nil {
|
||||
t.Fatal("expected nil api_call")
|
||||
}
|
||||
if text != "Just a reply." {
|
||||
t.Fatalf("expected original text, got %q", text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed JSON", func(t *testing.T) {
|
||||
req, _ := parseAPICall(`<api_call>NOT JSON</api_call>`)
|
||||
if req != nil {
|
||||
t.Fatal("expected nil for malformed JSON")
|
||||
}
|
||||
})
|
||||
}
|
||||
109
telegram/agent/apicall.go
Normal file
109
telegram/agent/apicall.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// apiCallTool executes HTTP requests against the NOFX API server.
|
||||
// This is the only tool available to the agent.
|
||||
type apiCallTool struct {
|
||||
baseURL string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// apiRequest is the parsed structure from the LLM's <api_call> tag.
|
||||
type apiRequest struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body map[string]any `json:"body"`
|
||||
}
|
||||
|
||||
func newAPICallTool(port int, token string) *apiCallTool {
|
||||
return &apiCallTool{
|
||||
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
||||
token: token,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// execute calls the API and returns the response as a string for LLM consumption.
|
||||
func (t *apiCallTool) execute(req *apiRequest) string {
|
||||
if req.Method == "" || req.Path == "" {
|
||||
return "error: method and path are required"
|
||||
}
|
||||
if !strings.HasPrefix(req.Path, "/") {
|
||||
req.Path = "/" + req.Path
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if req.Method != "GET" && len(req.Body) > 0 {
|
||||
b, err := json.Marshal(req.Body)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error marshaling body: %v", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest(req.Method, t.baseURL+req.Path, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error creating request: %v", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+t.token)
|
||||
|
||||
resp, err := t.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("API call failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error reading response: %v", err)
|
||||
}
|
||||
|
||||
logger.Infof("Agent api_call: %s %s -> %d", req.Method, req.Path, resp.StatusCode)
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Sprintf("API error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Pretty-print JSON for better LLM readability
|
||||
var v any
|
||||
if json.Unmarshal(body, &v) == nil {
|
||||
if pretty, err := json.MarshalIndent(v, "", " "); err == nil {
|
||||
return string(pretty)
|
||||
}
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// parseAPICall extracts <api_call>...</api_call> from LLM response.
|
||||
// Returns (nil, original) if not found or malformed JSON.
|
||||
func parseAPICall(resp string) (*apiRequest, string) {
|
||||
const openTag = "<api_call>"
|
||||
const closeTag = "</api_call>"
|
||||
|
||||
start := strings.Index(resp, openTag)
|
||||
end := strings.Index(resp, closeTag)
|
||||
if start < 0 || end < 0 || end <= start {
|
||||
return nil, resp
|
||||
}
|
||||
|
||||
jsonStr := strings.TrimSpace(resp[start+len(openTag) : end])
|
||||
var req apiRequest
|
||||
if err := json.Unmarshal([]byte(jsonStr), &req); err != nil {
|
||||
logger.Warnf("Agent: failed to parse api_call JSON %q: %v", jsonStr, err)
|
||||
return nil, resp
|
||||
}
|
||||
|
||||
return &req, strings.TrimSpace(resp[:start])
|
||||
}
|
||||
78
telegram/agent/manager.go
Normal file
78
telegram/agent/manager.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"nofx/logger"
|
||||
"nofx/mcp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Manager holds one Agent per Telegram chat ID.
|
||||
// Messages for the same chat are serialized (OpenClaw Lane Queue pattern).
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
agents map[int64]*Agent
|
||||
lanes map[int64]chan struct{}
|
||||
apiPort int
|
||||
botToken string
|
||||
userID string
|
||||
getLLM func() mcp.AIClient
|
||||
systemPrompt string
|
||||
}
|
||||
|
||||
// NewManager creates a Manager. Call api.GetAPIDocs() before this and pass the result as apiDocs.
|
||||
// userID is the database user ID the bot authenticates as (used in system prompt context).
|
||||
func NewManager(apiPort int, botToken, userID string, getLLM func() mcp.AIClient, apiDocs string) *Manager {
|
||||
return &Manager{
|
||||
agents: make(map[int64]*Agent),
|
||||
lanes: make(map[int64]chan struct{}),
|
||||
apiPort: apiPort,
|
||||
botToken: botToken,
|
||||
userID: userID,
|
||||
getLLM: getLLM,
|
||||
systemPrompt: BuildAgentPrompt(apiDocs, userID),
|
||||
}
|
||||
}
|
||||
|
||||
// Run processes a message for the given chat ID.
|
||||
// If the same chat is already processing a message, this call blocks until it completes
|
||||
// or the lane wait times out (60 s), whichever comes first.
|
||||
// onChunk is optional — when set, LLM reply chunks are forwarded progressively (SSE streaming).
|
||||
func (m *Manager) Run(chatID int64, userMessage string, onChunk func(string)) string {
|
||||
a, lane := m.getOrCreate(chatID)
|
||||
select {
|
||||
case lane <- struct{}{}:
|
||||
case <-time.After(60 * time.Second):
|
||||
logger.Warnf("Agent: lane wait timeout for chat %d — previous message still processing", chatID)
|
||||
return "上一条消息仍在处理中,请稍等片刻后再试。"
|
||||
}
|
||||
defer func() { <-lane }()
|
||||
return a.Run(userMessage, onChunk)
|
||||
}
|
||||
|
||||
// Reset clears memory for the given chat (called on /start).
|
||||
func (m *Manager) Reset(chatID int64) {
|
||||
m.mu.Lock()
|
||||
a, ok := m.agents[chatID]
|
||||
m.mu.Unlock()
|
||||
if ok {
|
||||
a.ResetMemory()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) getOrCreate(chatID int64) (*Agent, chan struct{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
a, ok := m.agents[chatID]
|
||||
if !ok {
|
||||
a = New(m.apiPort, m.botToken, m.userID, m.getLLM, m.systemPrompt)
|
||||
m.agents[chatID] = a
|
||||
}
|
||||
lane, ok := m.lanes[chatID]
|
||||
if !ok {
|
||||
lane = make(chan struct{}, 1) // binary semaphore: one message at a time per chat
|
||||
m.lanes[chatID] = lane
|
||||
}
|
||||
return a, lane
|
||||
}
|
||||
107
telegram/agent/prompt.go
Normal file
107
telegram/agent/prompt.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package agent
|
||||
|
||||
import "fmt"
|
||||
|
||||
// BuildAgentPrompt constructs the full system prompt with live API documentation injected.
|
||||
// apiDocs is the output of api.GetAPIDocs() — reflects all currently registered routes with full schemas.
|
||||
// userID is the actual database user ID the bot authenticates as.
|
||||
func BuildAgentPrompt(apiDocs, userID string) string {
|
||||
return fmt.Sprintf(`You are the NOFX quantitative trading system AI assistant.
|
||||
|
||||
## Your Identity
|
||||
- You are authenticated as user ID: %s
|
||||
- All API calls are made on behalf of this user
|
||||
- When asked "which user / username / email" — answer with this user ID directly, no API call needed
|
||||
|
||||
## Tool: api_call
|
||||
|
||||
Append EXACTLY ONE tag at the very end of your reply when you need to call the API:
|
||||
<api_call>{"method":"GET","path":"/api/xxx","body":{}}</api_call>
|
||||
|
||||
Rules:
|
||||
- The tag must be the LAST thing in your message — nothing after it
|
||||
- NEVER more than one <api_call> tag per response
|
||||
- 【CRITICAL】NEVER say "让我查询..."、"现在获取..."、"I will call..."、"Let me check..." — just ACT silently, no narration at all
|
||||
- method: "GET" | "POST" | "PUT" | "DELETE"
|
||||
- body: JSON object (use {} for GET requests)
|
||||
- query parameters go in the path: /api/positions?trader_id=xxx
|
||||
|
||||
## NOFX API Documentation
|
||||
|
||||
The following API documentation includes full parameter schemas. Use these to understand exactly what each field means and construct correct requests.
|
||||
|
||||
%s
|
||||
|
||||
## Behavior Rules
|
||||
1. 【NO NARRATION】Never tell the user what API you are calling. Zero narration. Just act.
|
||||
2. Only ONE <api_call> tag per response, always at the very end
|
||||
3. After getting an API result, decide: call another API or give a final reply
|
||||
4. If the API returns success (2xx), the operation succeeded — do not retry
|
||||
5. Reply in the same language the user used (中文→中文, English→English)
|
||||
6. Keep replies concise — show results, not process
|
||||
7. Ask for ALL required information in ONE message — never ask one field at a time
|
||||
8. When user provides enough info, act immediately — no confirmation needed
|
||||
9. Be decisive — infer intent from context, use schema to fill in smart defaults
|
||||
|
||||
## Verification Rule (CRITICAL)
|
||||
After ANY PUT or POST that creates or modifies a resource:
|
||||
1. Immediately GET the resource to read actual saved values
|
||||
2. Show the user the KEY fields they care about from the GET response
|
||||
3. NEVER just say "updated successfully" without showing the actual values
|
||||
4. If saved values look wrong, correct them automatically
|
||||
|
||||
## Error Handling
|
||||
- 400: explain what was wrong, ask user to correct
|
||||
- 404: resource doesn't exist, check IDs
|
||||
- "AI model not enabled": tell user to enable the model first via PUT /api/models
|
||||
- "Exchange not enabled": tell user to enable the exchange first
|
||||
- 5xx: server error, ask user to try again
|
||||
- stream interrupted / unavailable: apologize briefly and ask user to retry
|
||||
|
||||
## How to Use the API Schema
|
||||
All API knowledge comes from the documentation above. Use field descriptions to:
|
||||
- Know exactly which fields are required vs optional
|
||||
- Understand semantics and build correct request bodies from natural language
|
||||
- For StrategyConfig: intelligently fill all fields based on user's trading style
|
||||
|
||||
## Account State (injected at conversation start)
|
||||
At the start of each new conversation, a [Current Account State] block is provided with:
|
||||
- AI Models: all configured models with their IDs and enabled status
|
||||
- Exchanges: all configured exchanges with their IDs and enabled status
|
||||
- Strategies: all existing strategies with their IDs
|
||||
- Traders: all existing traders with their IDs and running status
|
||||
|
||||
Use this to:
|
||||
- NEVER ask for exchange/model info that is already configured — use the existing IDs directly
|
||||
- Know instantly if the user has 0 or N resources of each type
|
||||
- If only one exchange/model exists and user doesn't specify, use it directly without asking
|
||||
- If multiple exist, list them and ask which one to use
|
||||
|
||||
## Common Workflows
|
||||
|
||||
**Configure model**: Ask only for api_key. Set enabled:true, send empty strings for URL/model (backend applies provider defaults).
|
||||
|
||||
**Configure exchange**: Ask for all required fields in ONE message (see schema). Always set enabled:true.
|
||||
|
||||
**Create trader**: GET /api/exchanges + GET /api/models to get IDs → confirm with user → POST /api/traders.
|
||||
|
||||
**Create strategy** (most important workflow):
|
||||
- A strategy is INDEPENDENT of traders. Never GET trader info just to create a strategy.
|
||||
- If user specifies style + coins (e.g. "BTC trend"), build and POST immediately — no questions needed.
|
||||
- Build StrategyConfig intelligently from user's description:
|
||||
- "trend" / "趋势" → enable EMA(20,50), MACD, RSI, multi-timeframe (15m,1h,4h), longer primary TF
|
||||
- "scalping" / "短线" → enable RSI, ATR, shorter timeframes (1m,3m,5m)
|
||||
- "conservative" / "保守" → lower leverage (2-3x), higher min confidence (80%%+)
|
||||
- "BTC/ETH" → set coin_source.source_type="static", static_coins=["BTC/USDT"] or similar
|
||||
- After POST: GET /api/strategies/:id to verify → show user: name, coins, key indicators, leverage
|
||||
|
||||
**Update strategy config**:
|
||||
1. GET /api/strategies/:id to read current full config
|
||||
2. Modify only what user asked (keep all other fields)
|
||||
3. PUT /api/strategies/:id with complete merged config
|
||||
4. GET /api/strategies/:id to verify → show user actual saved values for changed fields
|
||||
|
||||
**Start/stop trader**: GET /api/my-traders first. If only one trader, act directly. If multiple, list and ask.
|
||||
|
||||
**Query data**: GET /api/my-traders to get trader_id, then query /api/positions?trader_id=xxx or /api/account?trader_id=xxx etc.`, userID, apiDocs)
|
||||
}
|
||||
310
telegram/bot.go
Normal file
310
telegram/bot.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"nofx/api"
|
||||
"nofx/config"
|
||||
"nofx/logger"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"nofx/telegram/agent"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
// Start initializes and runs the Telegram bot in a blocking supervisor loop.
|
||||
// Supports hot-reload: when a signal is sent on reloadCh, the bot restarts
|
||||
// with the latest token (re-read from DB or env). Must be called as a goroutine from main.go.
|
||||
// Deployment note: uses long-polling (not webhook) — safe for private networks,
|
||||
// no inbound ports required.
|
||||
func Start(cfg *config.Config, st *store.Store, reloadCh <-chan struct{}) {
|
||||
for {
|
||||
token := resolveToken(cfg, st)
|
||||
if token == "" {
|
||||
logger.Info("Telegram bot disabled (no token configured), waiting for reload signal...")
|
||||
// Block until a reload signal arrives, then re-check for a token.
|
||||
<-reloadCh
|
||||
continue
|
||||
}
|
||||
|
||||
stopped := runBot(token, cfg, st)
|
||||
if !stopped {
|
||||
// Bot exited with an unrecoverable error; do not restart automatically.
|
||||
return
|
||||
}
|
||||
|
||||
// Bot was stopped cleanly. Wait for a reload signal before restarting.
|
||||
select {
|
||||
case <-reloadCh:
|
||||
logger.Info("Reloading Telegram bot with new token...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveToken returns the bot token, preferring the DB-stored value over the env/config value.
|
||||
func resolveToken(cfg *config.Config, st *store.Store) string {
|
||||
dbCfg, err := st.TelegramConfig().Get()
|
||||
if err == nil && dbCfg.BotToken != "" {
|
||||
return dbCfg.BotToken
|
||||
}
|
||||
return cfg.TelegramBotToken
|
||||
}
|
||||
|
||||
// runBot runs the bot until StopReceivingUpdates is called (clean stop → true)
|
||||
// or a fatal error occurs (false).
|
||||
func runBot(token string, cfg *config.Config, st *store.Store) bool {
|
||||
bot, err := tgbotapi.NewBotAPI(token)
|
||||
if err != nil {
|
||||
logger.Errorf("Telegram bot failed to start: %v", err)
|
||||
return false
|
||||
}
|
||||
logger.Infof("Telegram bot @%s started (long-polling mode)", bot.Self.UserName)
|
||||
|
||||
// Determine allowed chat ID:
|
||||
// Priority 1: env var TELEGRAM_ADMIN_CHAT_ID (explicit)
|
||||
// Priority 2: DB-stored bound chat ID (set by /start)
|
||||
// Priority 3: 0 = no binding yet, first /start will bind
|
||||
allowedChatID := cfg.TelegramAdminChatID
|
||||
if allowedChatID == 0 {
|
||||
if id, err := st.TelegramConfig().GetBoundChatID(); err == nil && id != 0 {
|
||||
allowedChatID = id
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the real user ID: use the first registered user so that bot-made
|
||||
// changes (model/exchange configs) are visible in the frontend under that user.
|
||||
// Falls back to "default" if no users exist yet (fresh install).
|
||||
botUserID := "default"
|
||||
if ids, err := st.User().GetAllIDs(); err == nil && len(ids) > 0 {
|
||||
botUserID = ids[0]
|
||||
}
|
||||
|
||||
// Generate a bot JWT for authenticated API calls. Re-generated on each bot start.
|
||||
botToken, err := agent.GenerateBotToken(botUserID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to generate bot JWT: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Wire the AI agent manager. API docs are auto-generated from registered routes.
|
||||
agents := agent.NewManager(cfg.APIServerPort, botToken, botUserID,
|
||||
func() mcp.AIClient { return newLLMClient(st) },
|
||||
api.GetAPIDocs(),
|
||||
)
|
||||
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 60
|
||||
updates := bot.GetUpdatesChan(u)
|
||||
|
||||
for update := range updates {
|
||||
if update.Message == nil {
|
||||
continue
|
||||
}
|
||||
chatID := update.Message.Chat.ID
|
||||
text := update.Message.Text
|
||||
|
||||
// Handle /start: auto-bind or welcome
|
||||
if text == "/start" {
|
||||
if allowedChatID == 0 {
|
||||
// First user to /start becomes the bound admin
|
||||
username := update.Message.From.UserName
|
||||
if err := st.TelegramConfig().BindUser(chatID, "@"+username); err != nil {
|
||||
logger.Errorf("Failed to bind Telegram user: %v", err)
|
||||
sendMsg(bot, chatID, "Binding failed, please check server logs.")
|
||||
continue
|
||||
}
|
||||
allowedChatID = chatID
|
||||
logger.Infof("Telegram bound to @%s (chatID: %d)", username, chatID)
|
||||
sendMsg(bot, chatID, "Bound successfully! "+welcomeMessage())
|
||||
} else if chatID == allowedChatID {
|
||||
// Already bound, same user: reset session and show welcome
|
||||
agents.Reset(chatID)
|
||||
sendMsg(bot, chatID, welcomeMessage())
|
||||
} else {
|
||||
sendMsg(bot, chatID, "This bot is already bound to another user.")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle /help
|
||||
if text == "/help" {
|
||||
sendMsg(bot, chatID, helpMessage())
|
||||
continue
|
||||
}
|
||||
|
||||
// Access control
|
||||
if allowedChatID != 0 && chatID != allowedChatID {
|
||||
sendMsg(bot, chatID, "Unauthorized access.")
|
||||
continue
|
||||
}
|
||||
if allowedChatID == 0 {
|
||||
sendMsg(bot, chatID, "Please send /start to bind your account first.")
|
||||
continue
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Send a placeholder immediately, then stream-edit as reply arrives.
|
||||
go func(chatID int64, text string) {
|
||||
// Send ⏳ placeholder so the user sees an instant response.
|
||||
sent, err := bot.Send(tgbotapi.NewMessage(chatID, "⏳"))
|
||||
placeholderID := 0
|
||||
if err == nil {
|
||||
placeholderID = sent.MessageID
|
||||
}
|
||||
|
||||
// Rate-limited edit helper: edits the placeholder at most once per second.
|
||||
// Exception: "⏳" thinking-indicator resets always go through immediately
|
||||
// so the user always sees the state change between agent iterations.
|
||||
var (
|
||||
mu sync.Mutex
|
||||
lastEdit time.Time
|
||||
)
|
||||
onChunk := func(accumulated string) {
|
||||
if placeholderID == 0 {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
isThinking := accumulated == "⏳"
|
||||
if !isThinking && time.Since(lastEdit) < time.Second {
|
||||
return
|
||||
}
|
||||
lastEdit = time.Now()
|
||||
edit := tgbotapi.NewEditMessageText(chatID, placeholderID, accumulated)
|
||||
bot.Send(edit) //nolint:errcheck
|
||||
}
|
||||
|
||||
reply := agents.Run(chatID, text, onChunk)
|
||||
|
||||
// Final edit: use Markdown, fall back to plain text on parse error.
|
||||
if placeholderID != 0 {
|
||||
edit := tgbotapi.NewEditMessageText(chatID, placeholderID, reply)
|
||||
edit.ParseMode = "Markdown"
|
||||
if _, err := bot.Send(edit); err != nil {
|
||||
edit2 := tgbotapi.NewEditMessageText(chatID, placeholderID, reply)
|
||||
bot.Send(edit2) //nolint:errcheck
|
||||
}
|
||||
} else {
|
||||
msg := tgbotapi.NewMessage(chatID, reply)
|
||||
msg.ParseMode = "Markdown"
|
||||
if _, err := bot.Send(msg); err != nil {
|
||||
msg.ParseMode = ""
|
||||
bot.Send(msg) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
}(chatID, text)
|
||||
}
|
||||
|
||||
// updates channel was closed — bot stopped cleanly
|
||||
return true
|
||||
}
|
||||
|
||||
func sendMsg(bot *tgbotapi.BotAPI, chatID int64, text string) {
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
bot.Send(msg) //nolint:errcheck
|
||||
}
|
||||
|
||||
// newLLMClient builds an LLM client for the agent.
|
||||
// Priority: DB-configured model (Web UI) > environment variables.
|
||||
// Uses provider-specific constructors to ensure correct default base URLs and models.
|
||||
func newLLMClient(st *store.Store) mcp.AIClient {
|
||||
// 1. Try any enabled model from DB (user configured via Web UI, any user_id)
|
||||
if model, err := st.AIModel().GetAnyEnabled(); err == nil {
|
||||
apiKey := string(model.APIKey)
|
||||
if apiKey != "" {
|
||||
client := clientForProvider(model.Provider)
|
||||
client.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
logger.Infof("Telegram agent: provider=%s user_id=%s model=%q url=%q",
|
||||
model.Provider, model.UserID, model.CustomModelName, model.CustomAPIURL)
|
||||
return client
|
||||
}
|
||||
logger.Warnf("Telegram: DB model found (provider=%s) but API key is empty after decryption", model.Provider)
|
||||
} else {
|
||||
logger.Warnf("Telegram: no enabled model in DB (%v), trying env vars", err)
|
||||
}
|
||||
|
||||
// 2. Fall back to environment variables
|
||||
for _, pair := range []struct{ provider, key, url string }{
|
||||
{"deepseek", os.Getenv("DEEPSEEK_API_KEY"), mcp.DefaultDeepSeekBaseURL},
|
||||
{"openai", os.Getenv("OPENAI_API_KEY"), ""},
|
||||
{"claude", os.Getenv("ANTHROPIC_API_KEY"), ""},
|
||||
} {
|
||||
if pair.key != "" {
|
||||
client := clientForProvider(pair.provider)
|
||||
client.SetAPIKey(pair.key, pair.url, "")
|
||||
logger.Infof("Telegram agent: using %s from env var", pair.provider)
|
||||
return client
|
||||
}
|
||||
}
|
||||
|
||||
logger.Warn("Telegram: no AI key found in DB or env — agent will fail. Configure a model in the Web UI.")
|
||||
return mcp.NewDeepSeekClient() // return a typed client so caller gets a clear API error
|
||||
}
|
||||
|
||||
// clientForProvider returns the appropriate provider-specific client.
|
||||
// Each constructor sets correct default base URL and model for that provider.
|
||||
func clientForProvider(provider string) mcp.AIClient {
|
||||
switch provider {
|
||||
case "openai":
|
||||
return mcp.NewOpenAIClient()
|
||||
case "deepseek":
|
||||
return mcp.NewDeepSeekClient()
|
||||
default:
|
||||
// Qwen, Kimi, Grok, Gemini, Claude, custom: fall back to DeepSeek-format client.
|
||||
// These providers use OpenAI-compatible APIs; CustomAPIURL and CustomModelName are required.
|
||||
return mcp.NewDeepSeekClient()
|
||||
}
|
||||
}
|
||||
|
||||
func welcomeMessage() string {
|
||||
return `*NOFX Trading Assistant Connected!*
|
||||
|
||||
You can manage your trading system with natural language:
|
||||
|
||||
*Query*
|
||||
- Show current positions
|
||||
- Show account balance
|
||||
|
||||
*Control*
|
||||
- Start trader
|
||||
- Stop trader
|
||||
|
||||
*Configure*
|
||||
- Create a BTC strategy with 8% stop loss
|
||||
- Configure Binance exchange API
|
||||
- Add DeepSeek AI model
|
||||
- Update strategy prompt
|
||||
|
||||
Send /help for detailed help
|
||||
Send /start to reset session`
|
||||
}
|
||||
|
||||
func helpMessage() string {
|
||||
return `*NOFX Trading Assistant Guide*
|
||||
|
||||
*Query examples:*
|
||||
- "Show current positions"
|
||||
- "Show account balance"
|
||||
- "List my traders"
|
||||
|
||||
*Control examples:*
|
||||
- "Start trader"
|
||||
- "Stop trader [name]"
|
||||
|
||||
*Configure examples:*
|
||||
- "Create a BTC strategy with RSI+MACD, 8% stop loss, 20% max position"
|
||||
- "Configure Binance exchange, API Key is xxx, Secret is xxx"
|
||||
- "Add DeepSeek model, Key is xxx"
|
||||
- "Update strategy prompt for my main strategy to: you are a conservative trader..."
|
||||
|
||||
*Other commands:*
|
||||
- /start - Reset current session
|
||||
- /help - Show this help
|
||||
|
||||
You can use natural language — no need to memorize specific command formats.`
|
||||
}
|
||||
105
telegram/session/memory.go
Normal file
105
telegram/session/memory.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/mcp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
compactionThresholdTokens = 3000
|
||||
charsPerToken = 3 // rough estimate for token counting
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Role string // "user" or "assistant"
|
||||
Content string
|
||||
}
|
||||
|
||||
// Memory manages conversation history with automatic compaction.
|
||||
// Inspired by openclaw's compaction pattern:
|
||||
// when ShortTerm exceeds threshold, LLM silently summarizes it into LongTerm.
|
||||
type Memory struct {
|
||||
LongTerm string // Durable summary (survives compaction, user never sees this happen)
|
||||
ShortTerm []Message // Recent conversation (cleared on compaction)
|
||||
llm mcp.AIClient
|
||||
}
|
||||
|
||||
func NewMemory(llm mcp.AIClient) *Memory {
|
||||
return &Memory{llm: llm}
|
||||
}
|
||||
|
||||
// Add appends a message and triggers compaction if threshold exceeded
|
||||
func (m *Memory) Add(role, content string) {
|
||||
m.ShortTerm = append(m.ShortTerm, Message{Role: role, Content: content})
|
||||
if m.estimateTokens() > compactionThresholdTokens {
|
||||
m.compact()
|
||||
}
|
||||
}
|
||||
|
||||
// BuildContext returns context string for the agent's conversation history.
|
||||
func (m *Memory) BuildContext() string {
|
||||
var sb strings.Builder
|
||||
if m.LongTerm != "" {
|
||||
sb.WriteString("[Summary of earlier conversation]\n")
|
||||
sb.WriteString(m.LongTerm)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
if len(m.ShortTerm) > 0 {
|
||||
sb.WriteString("[Recent conversation]\n")
|
||||
for _, msg := range m.ShortTerm {
|
||||
sb.WriteString(fmt.Sprintf("%s: %s\n", msg.Role, msg.Content))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Reset clears short-term history (LongTerm preserved intentionally)
|
||||
func (m *Memory) Reset() {
|
||||
m.ShortTerm = []Message{}
|
||||
}
|
||||
|
||||
// ResetFull clears everything including long-term memory
|
||||
func (m *Memory) ResetFull() {
|
||||
m.ShortTerm = []Message{}
|
||||
m.LongTerm = ""
|
||||
}
|
||||
|
||||
func (m *Memory) estimateTokens() int {
|
||||
total := len(m.LongTerm)
|
||||
for _, msg := range m.ShortTerm {
|
||||
total += len(msg.Content)
|
||||
}
|
||||
return total / charsPerToken
|
||||
}
|
||||
|
||||
// compact summarizes short-term history into long-term memory.
|
||||
// This runs silently - the user never sees it happen.
|
||||
// If LLM call fails, short-term is preserved as-is (no data loss).
|
||||
func (m *Memory) compact() {
|
||||
if m.llm == nil || len(m.ShortTerm) == 0 {
|
||||
return
|
||||
}
|
||||
history := m.BuildContext()
|
||||
systemPrompt := `You are a conversation summarizer. Compress the following trading assistant conversation into a concise summary.
|
||||
|
||||
Must preserve:
|
||||
- What the user is configuring (strategy/exchange/model/trader)
|
||||
- Confirmed parameters (trading pairs, leverage, stop loss, indicators, etc.)
|
||||
- Pending or missing parameters
|
||||
- User preferences and requirements
|
||||
|
||||
Output: plain text summary, under 200 words.`
|
||||
|
||||
summary, err := m.llm.CallWithMessages(systemPrompt, history)
|
||||
if err != nil {
|
||||
// Compaction failed: keep short-term as-is, never lose user data
|
||||
return
|
||||
}
|
||||
if m.LongTerm != "" {
|
||||
m.LongTerm = m.LongTerm + "\n" + summary
|
||||
} else {
|
||||
m.LongTerm = summary
|
||||
}
|
||||
m.ShortTerm = []Message{}
|
||||
}
|
||||
Reference in New Issue
Block a user