Files
nofx/docs/plans/2026-03-06-telegram-agent-redesign.md
tinkle-community 21a15f98eb refactor: remove all backtest module code and references
Delete backtest/ engine (19 files), api/backtest.go, store/backtest.go,
web backtest components (7 files), API client, types, docs, screenshot.
Clean all backtest references from main.go, api/server.go, store/store.go,
App.tsx, HeaderBar.tsx, LandingPage.tsx, translations, README and docs.
2026-03-16 07:38:01 +08:00

1039 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Telegram Bot Agent Redesign (OpenClaw-Inspired)
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.
**Goal:** Replace the NLU intent-classification architecture with a true AI Agent that handles any user request — including scenarios never explicitly programmed. All code, comments, prompts, and bot responses in English.
**Architecture:** One generic tool (`api_call`) + dynamically generated API docs + unbounded LLM loop. The LLM reads auto-generated API docs and decides which endpoints to call. New features added to the web UI automatically become available via bot — zero code changes required.
**Tech Stack:** Go, `mcp.CallWithRequest` + `RequestBuilder`, `tgbotapi`, `auth.GenerateJWT`
---
## Core Design
OpenClaw gives LLM a `bash` tool — one generic primitive, unlimited capability.
We give LLM an `api_call(method, path, body)` tool — one generic primitive for 74+ REST endpoints.
**Auto-discovery:** Routes are registered via `s.route(group, method, path, description, handler)`.
`api.GetAPIDocs()` returns live documentation at startup — add a route and it's automatically in the bot's context.
```
User: "show positions and stop the trader if loss > 5%"
Iteration 1: api_call GET /api/positions?trader_id=...
Iteration 2: api_call GET /api/account?trader_id=...
Iteration 3: [sees -8% loss] api_call POST /api/traders/xxx/stop
Reply: "Detected -8% loss. Trader stopped."
```
No special code for this scenario. LLM figured it out from the API docs.
---
## What changes
| File | Action |
|------|--------|
| `api/route_registry.go` | **CREATE** — route registration + doc generation |
| `api/server.go` | Migrate all routes from `group.METHOD(path, handler)` to `s.route(group, method, path, desc, handler)` |
| `telegram/intent/parser.go` | **DELETE** |
| `telegram/handler/handler.go` | **DELETE** |
| `telegram/handler/handler_test.go` | **DELETE** |
| `telegram/session/session.go` | Simplify (remove Intent, Params) |
| `telegram/bot.go` | Use `agent.Manager`, pass `api.GetAPIDocs()` |
| `telegram/agent/prompt.go` | **CREATE** — system prompt template (API docs injected at runtime) |
| `telegram/agent/apicall.go` | **CREATE** — the single generic tool |
| `telegram/agent/agent.go` | **CREATE** — agent loop |
| `telegram/agent/manager.go` | **CREATE** — per-chat serialization |
| `telegram/agent/agent_test.go` | **CREATE** — tests |
`telegram/service/nofx.go` and `telegram/session/memory.go` are **unchanged**.
---
## Task 1: Create `api/route_registry.go`
**Files:**
- Create: `api/route_registry.go`
This is the single source of truth for API documentation. Routes registered here are automatically available to the bot.
```go
package api
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
)
// RouteDoc holds documentation for a single API route.
type RouteDoc struct {
Method string
Path string
Description string
}
// routeRegistry stores all documented routes. Populated via s.route() calls in setupRoutes.
var routeRegistry []RouteDoc
// route registers an HTTP route on the given group and records its documentation.
// This is the single registration point — add a route here and it is automatically
// included in GetAPIDocs(), making it available to the Telegram bot agent.
func (s *Server) route(g *gin.RouterGroup, method, path, description string, h gin.HandlerFunc) {
// Derive the full path: group prefix + local path
fullPath := strings.TrimSuffix(g.BasePath(), "/") + "/" + strings.TrimPrefix(path, "/")
routeRegistry = append(routeRegistry, RouteDoc{
Method: method,
Path: fullPath,
Description: description,
})
switch method {
case "GET":
g.GET(path, h)
case "POST":
g.POST(path, h)
case "PUT":
g.PUT(path, h)
case "DELETE":
g.DELETE(path, h)
}
}
// GetAPIDocs returns formatted API documentation for injection into the LLM system prompt.
// Called once at bot startup — reflects the live set of registered routes.
func GetAPIDocs() string {
var sb strings.Builder
for _, r := range routeRegistry {
sb.WriteString(fmt.Sprintf("%-8s %-50s %s\n", r.Method, r.Path, r.Description))
}
return sb.String()
}
```
**Step 1: Create the file**
**Step 2: Build**
```bash
cd /Users/yida/gopro/open-nofx && go build ./api/...
```
Expected: clean build.
**Step 3: Commit**
```bash
git add api/route_registry.go
git commit -m "feat(api): add route registry for auto-generated API documentation"
```
---
## Task 2: Migrate routes in `api/server.go`
**Files:**
- Modify: `api/server.go` (the `setupRoutes` / route registration block, lines ~109230)
Replace every direct `group.METHOD(path, handler)` call with `s.route(group, method, path, description, handler)`.
**Step 1: Read the current route registration block**
```bash
sed -n '109,230p' api/server.go
```
**Step 2: Replace all route registrations**
The full replacement (covers all routes found in lines 117223):
```go
// Public routes
s.route(api, "GET", "/supported-models", "List supported AI model providers", s.handleGetSupportedModels)
s.route(api, "GET", "/supported-exchanges", "List supported exchange types", s.handleGetSupportedExchanges)
s.route(api, "GET", "/config", "Get system configuration", s.handleGetSystemConfig)
s.route(api, "GET", "/traders", "Public trader list", s.handlePublicTraderList)
s.route(api, "GET", "/competition", "Public competition data", s.handlePublicCompetition)
s.route(api, "GET", "/top-traders", "Top traders leaderboard", s.handleTopTraders)
s.route(api, "GET", "/equity-history", "Equity history for a trader", s.handleEquityHistory)
s.route(api, "POST", "/equity-history-batch", "Batch equity history for multiple traders", s.handleEquityHistoryBatch)
s.route(api, "GET", "/traders/:id/public-config", "Public trader configuration", s.handleGetPublicTraderConfig)
s.route(api, "GET", "/klines", "Candlestick data (?symbol=&interval=&limit=)", s.handleKlines)
s.route(api, "GET", "/symbols", "Available trading symbols", s.handleSymbols)
s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies)
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin)
// Protected routes (JWT required)
s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout)
s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP)
// Trader management
s.route(protected, "GET", "/my-traders", "List user's traders", s.handleTraderList)
s.route(protected, "GET", "/traders/:id/config", "Get full trader configuration", s.handleGetTraderConfig)
s.route(protected, "POST", "/traders", "Create trader (body: name, strategy_id, exchange_id, model_id)", s.handleCreateTrader)
s.route(protected, "PUT", "/traders/:id", "Update trader configuration", s.handleUpdateTrader)
s.route(protected, "DELETE", "/traders/:id", "Delete trader", s.handleDeleteTrader)
s.route(protected, "POST", "/traders/:id/start", "Start trader", s.handleStartTrader)
s.route(protected, "POST", "/traders/:id/stop", "Stop trader", s.handleStopTrader)
s.route(protected, "PUT", "/traders/:id/prompt", "Update trader prompt (body: prompt)", s.handleUpdateTraderPrompt)
s.route(protected, "POST", "/traders/:id/sync-balance", "Sync account balance from exchange", s.handleSyncBalance)
s.route(protected, "POST", "/traders/:id/close-position", "Close position (body: symbol)", s.handleClosePosition)
s.route(protected, "PUT", "/traders/:id/competition", "Toggle competition visibility", s.handleToggleCompetition)
s.route(protected, "GET", "/traders/:id/grid-risk", "Get grid risk info", s.handleGetGridRiskInfo)
// AI model configuration
s.route(protected, "GET", "/models", "List AI model configurations", s.handleGetModelConfigs)
s.route(protected, "PUT", "/models", "Update AI model configurations", s.handleUpdateModelConfigs)
// Exchange configuration
s.route(protected, "GET", "/exchanges", "List exchange configurations", s.handleGetExchangeConfigs)
s.route(protected, "POST", "/exchanges", "Create exchange (body: exchange_type, api_key, secret_key, account_name)", s.handleCreateExchange)
s.route(protected, "PUT", "/exchanges", "Update exchange configurations", s.handleUpdateExchangeConfigs)
s.route(protected, "DELETE", "/exchanges/:id", "Delete exchange", s.handleDeleteExchange)
// Telegram configuration
s.route(protected, "GET", "/telegram", "Get Telegram bot configuration", s.handleGetTelegramConfig)
s.route(protected, "POST", "/telegram", "Update Telegram bot token/model", s.handleUpdateTelegramConfig)
s.route(protected, "POST", "/telegram/model", "Update Telegram bot AI model only", s.handleUpdateTelegramModel)
s.route(protected, "DELETE", "/telegram/binding", "Unbind Telegram account", s.handleUnbindTelegram)
// Strategy management
s.route(protected, "GET", "/strategies", "List user's strategies", s.handleGetStrategies)
s.route(protected, "GET", "/strategies/active", "Get active strategy", s.handleGetActiveStrategy)
s.route(protected, "GET", "/strategies/default-config", "Get default strategy config template", s.handleGetDefaultStrategyConfig)
s.route(protected, "POST", "/strategies/preview-prompt", "Preview generated strategy prompt", s.handlePreviewPrompt)
s.route(protected, "POST", "/strategies/test-run", "Test-run strategy AI analysis", s.handleStrategyTestRun)
s.route(protected, "GET", "/strategies/:id", "Get strategy by ID", s.handleGetStrategy)
s.route(protected, "POST", "/strategies", "Create strategy (body: name, config)", s.handleCreateStrategy)
s.route(protected, "PUT", "/strategies/:id", "Update strategy", s.handleUpdateStrategy)
s.route(protected, "DELETE", "/strategies/:id", "Delete strategy", s.handleDeleteStrategy)
s.route(protected, "POST", "/strategies/:id/activate", "Activate strategy", s.handleActivateStrategy)
s.route(protected, "POST", "/strategies/:id/duplicate", "Duplicate strategy", s.handleDuplicateStrategy)
// Debate arena
s.route(protected, "GET", "/debates", "List debates", s.debateHandler.HandleListDebates)
s.route(protected, "GET", "/debates/personalities", "Available AI personalities", s.debateHandler.HandleGetPersonalities)
s.route(protected, "GET", "/debates/:id", "Get debate details", s.debateHandler.HandleGetDebate)
s.route(protected, "POST", "/debates", "Create debate", s.debateHandler.HandleCreateDebate)
s.route(protected, "POST", "/debates/:id/start", "Start debate", s.debateHandler.HandleStartDebate)
s.route(protected, "POST", "/debates/:id/cancel", "Cancel debate", s.debateHandler.HandleCancelDebate)
s.route(protected, "POST", "/debates/:id/execute", "Execute debate consensus decision", s.debateHandler.HandleExecuteDebate)
s.route(protected, "DELETE", "/debates/:id", "Delete debate", s.debateHandler.HandleDeleteDebate)
s.route(protected, "GET", "/debates/:id/messages", "Get debate messages", s.debateHandler.HandleGetMessages)
s.route(protected, "GET", "/debates/:id/votes", "Get debate votes", s.debateHandler.HandleGetVotes)
s.route(protected, "GET", "/debates/:id/stream", "SSE stream for live debate", s.debateHandler.HandleDebateStream)
// Account and trading data (use ?trader_id=xxx query param)
s.route(protected, "GET", "/status", "Trader running status (?trader_id=)", s.handleStatus)
s.route(protected, "GET", "/account", "Account balance and equity (?trader_id=)", s.handleAccount)
s.route(protected, "GET", "/positions", "Current open positions (?trader_id=)", s.handlePositions)
s.route(protected, "GET", "/positions/history", "Position history (?trader_id=)", s.handlePositionHistory)
s.route(protected, "GET", "/trades", "Trade records (?trader_id=)", s.handleTrades)
s.route(protected, "GET", "/orders", "All orders (?trader_id=)", s.handleOrders)
s.route(protected, "GET", "/orders/:id/fills", "Order fill details", s.handleOrderFills)
s.route(protected, "GET", "/open-orders", "Open orders from exchange (?trader_id=)", s.handleOpenOrders)
s.route(protected, "GET", "/decisions", "AI trading decisions (?trader_id=)", s.handleDecisions)
s.route(protected, "GET", "/decisions/latest", "Latest AI decisions (?trader_id=)", s.handleLatestDecisions)
s.route(protected, "GET", "/statistics", "Trading statistics (?trader_id=)", s.handleStatistics)
```
Note: keep the existing special-case handlers that don't use `s.route` unchanged:
- `api.Any("/health", ...)` — health check, no need to document
- `api.GET("/crypto/...")` — crypto/encryption routes, bot doesn't need these
**Step 3: Build**
```bash
go build ./api/...
```
Expected: clean build. Fix any compilation errors (method signature mismatches).
**Step 4: Verify docs are generated**
```bash
go test ./api/... -run TestGetAPIDocs -v
```
(Write a quick inline test or just print in main to verify)
**Step 5: Commit**
```bash
git add api/route_registry.go api/server.go
git commit -m "feat(api): migrate routes to self-documenting s.route() registration"
```
---
## Task 3: Create `telegram/agent/prompt.go`
**Files:**
- Create: `telegram/agent/prompt.go`
The system prompt template. API docs are injected at runtime via `BuildAgentPrompt(apiDocs)`.
```go
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.
func BuildAgentPrompt(apiDocs string) string {
return fmt.Sprintf(`You are the NOFX quantitative trading system AI assistant.
You can have natural conversations with the user and call the API to operate the system.
## Tool
You have one tool: api_call
Call format (append at end of reply):
<api_call>{"method":"GET","path":"/api/xxx","body":{}}</api_call>
- method: "GET" | "POST" | "PUT" | "DELETE"
- path: API path from the documentation below
- body: request body as JSON object (use {} for GET requests)
- query parameters go in the path, e.g. /api/positions?trader_id=xxx
## NOFX API Documentation
All requests are pre-authenticated. Focus on paths and parameters.
%s
## Rules
1. When you need to perform a system operation, append <api_call>...</api_call> at the end of your reply
2. Only call one API per response; after receiving the result, decide whether to call another or give a final reply
3. For conversations, questions, or analysis that don't require system operations, reply directly without calling the API
4. If required parameters are unclear, ask the user — do not guess critical values like trader_id
5. Always reply in English`, apiDocs)
}
```
**Step 1: Create the file**
**Step 2: Build**
```bash
go build ./telegram/agent/...
```
**Step 3: Commit**
```bash
git add telegram/agent/prompt.go
git commit -m "feat(telegram/agent): add dynamic system prompt builder"
```
---
## Task 4: Create `telegram/agent/apicall.go`
**Files:**
- Create: `telegram/agent/apicall.go`
```go
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])
}
```
**Step 1: Create the file**
**Step 2: Commit**
```bash
git add telegram/agent/apicall.go
git commit -m "feat(telegram/agent): add generic api_call tool"
```
---
## Task 5: Create `telegram/agent/agent.go`
**Files:**
- Create: `telegram/agent/agent.go`
```go
package agent
import (
"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
}
// New creates an Agent for one chat session.
func New(apiPort int, botToken string, getLLM func() mcp.AIClient, systemPrompt string) *Agent {
return &Agent{
apiTool: newAPICallTool(apiPort, botToken),
getLLM: getLLM,
memory: session.NewMemory(getLLM()),
systemPrompt: systemPrompt,
}
}
// GenerateBotToken creates a long-lived JWT for the bot's internal API calls.
// Call once at bot startup before creating any Agent or Manager.
func GenerateBotToken() (string, error) {
return auth.GenerateJWT("default", "bot@internal")
}
// Run processes one user message through the agent loop.
// Loop: LLM decides -> if <api_call>: execute, append result, loop -> if no tag: return reply.
func (a *Agent) Run(userMessage 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
histCtx := a.memory.BuildContext()
firstMsg := userMessage
if histCtx != "" {
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
}
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
reply := strings.TrimSpace(resp)
a.memory.Add("user", userMessage)
a.memory.Add("assistant", reply)
return reply
}
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
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 := strings.TrimSpace(lastResp)
a.memory.Add("user", userMessage)
a.memory.Add("assistant", reply)
return reply
}
// ResetMemory clears conversation history (called on /start).
func (a *Agent) ResetMemory() {
a.memory.ResetFull()
}
```
**Step 1: Create the file**
**Step 2: Build**
```bash
go build ./telegram/agent/...
```
**Step 3: Commit**
```bash
git add telegram/agent/agent.go
git commit -m "feat(telegram/agent): add OpenClaw-style agent loop"
```
---
## Task 6: Create `telegram/agent/manager.go`
**Files:**
- Create: `telegram/agent/manager.go`
```go
package agent
import (
"nofx/mcp"
"sync"
)
// 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
getLLM func() mcp.AIClient
systemPrompt string
}
// NewManager creates a Manager. Call api.GetAPIDocs() before this and pass the result as apiDocs.
func NewManager(apiPort int, botToken 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,
getLLM: getLLM,
systemPrompt: BuildAgentPrompt(apiDocs),
}
}
// Run processes a message for the given chat ID.
// If the same chat is already processing a message, this call blocks until it completes.
func (m *Manager) Run(chatID int64, userMessage string) string {
a, lane := m.getOrCreate(chatID)
lane <- struct{}{}
defer func() { <-lane }()
return a.Run(userMessage)
}
// 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.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
}
```
**Step 1: Create the file**
**Step 2: Build**
```bash
go build ./telegram/agent/...
```
**Step 3: Commit**
```bash
git add telegram/agent/manager.go
git commit -m "feat(telegram/agent): add per-chat agent manager with lane serialization"
```
---
## Task 7: Write tests
**Files:**
- Create: `telegram/agent/agent_test.go`
```go
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) 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", mockGetLLM(llm), testPrompt)
reply := a.Run("hello")
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"}]`))
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", mockGetLLM(llm), testPrompt)
reply := a.Run("list my traders")
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}`))
}))
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", mockGetLLM(llm), testPrompt)
reply := a.Run("show me account status")
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}`))
}))
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", mockGetLLM(llm), testPrompt)
a.Run("show balance")
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")
}
})
}
```
**Step 1: Create the test file**
**Step 2: Run tests**
```bash
go test ./telegram/agent/... -v
```
Expected: all PASS.
**Step 3: Commit**
```bash
git add telegram/agent/agent_test.go
git commit -m "test(telegram/agent): add agent tests with mock HTTP server"
```
---
## Task 8: Simplify `telegram/session/session.go`
Replace file content:
```go
package session
import (
"nofx/mcp"
"sync"
"time"
)
// Session holds conversation memory for a single Telegram chat.
type Session struct {
ChatID int64
Memory *Memory
UpdatedAt time.Time
}
func (s *Session) ResetFull() { s.Memory.ResetFull() }
// Manager manages sessions by chat ID.
type Manager struct {
mu sync.RWMutex
sessions map[int64]*Session
llm mcp.AIClient
}
func NewManager(llm mcp.AIClient) *Manager {
return &Manager{sessions: make(map[int64]*Session), llm: llm}
}
func (m *Manager) Get(chatID int64) *Session {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[chatID]
if !ok {
s = &Session{ChatID: chatID, Memory: NewMemory(m.llm), UpdatedAt: time.Now()}
m.sessions[chatID] = s
}
s.UpdatedAt = time.Now()
return s
}
```
```bash
go build ./...
git add telegram/session/session.go
git commit -m "refactor(telegram/session): remove intent/params fields"
```
---
## Task 9: Wire `telegram/bot.go`
**Step 1: In `runBot`, replace old wiring with:**
```go
botToken, err := agent.GenerateBotToken()
if err != nil {
logger.Errorf("Failed to generate bot JWT: %v", err)
return false
}
agents := agent.NewManager(cfg.APIServerPort, botToken,
func() mcp.AIClient { return newLLMClient(st) },
api.GetAPIDocs(),
)
```
**Step 2: Replace `/start` reset:**
```go
// old: sessions.Get(chatID).ResetFull()
agents.Reset(chatID)
```
**Step 3: Replace message processing:**
```go
go func(chatID int64, text string) {
bot.Send(tgbotapi.NewChatAction(chatID, tgbotapi.ChatTyping)) //nolint:errcheck
reply := agents.Run(chatID, text)
msg := tgbotapi.NewMessage(chatID, reply)
msg.ParseMode = "Markdown"
if _, err := bot.Send(msg); err != nil {
msg.ParseMode = ""
bot.Send(msg) //nolint:errcheck
}
}(chatID, text)
```
**Step 4: Update imports** — remove `service`, `handler`, `intent`, `session`; add `agent`, `api`:
```go
import (
"nofx/config"
"nofx/logger"
"nofx/manager"
"nofx/mcp"
"nofx/store"
"nofx/api"
"nofx/telegram/agent"
"os"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
```
**Step 5: Full build**
```bash
go build ./...
git add telegram/bot.go
git commit -m "feat(telegram): wire agent.Manager with auto-generated API docs"
```
---
## Task 10: Delete old files
```bash
git rm telegram/intent/parser.go telegram/handler/handler.go telegram/handler/handler_test.go
rmdir telegram/intent telegram/handler 2>/dev/null || true
go build ./... && go test ./...
git commit -m "refactor(telegram): delete old intent/handler packages"
```
---
## Task 11: End-to-end verification
```bash
go test ./telegram/... ./api/... -v -count=1
go build ./...
```
Manual verification — none of these scenarios need any special code:
- [ ] "hello" → natural conversation reply
- [ ] "list my traders" → GET /api/my-traders, formatted reply
- [ ] "show positions" → GET /api/positions
- [ ] "check balance then stop trader if loss > 5%" → multi-step: GET /api/account → POST /api/traders/:id/stop
- [ ] "create a BTC strategy with 5% stop loss" → GET /api/strategies/default-config → POST /api/strategies
- [ ] "show latest trading decisions" → GET /api/decisions/latest
- [ ] "what's the BTC 1h chart looking like" → GET /api/klines?symbol=BTCUSDT&interval=1h
- [ ] "delete trader xxx" → DELETE /api/traders/:id
- [ ] Any unrecognized input → LLM replies naturally, no error