Back to Nofx

Telegram Bot Agent Redesign (OpenClaw-Inspired)

docs/plans/2026-03-06-telegram-agent-redesign.md

latest33.7 KB
Original Source

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

FileAction
api/route_registry.goCREATE — route registration + doc generation
api/server.goMigrate all routes from group.METHOD(path, handler) to s.route(group, method, path, desc, handler)
telegram/intent/parser.goDELETE
telegram/handler/handler.goDELETE
telegram/handler/handler_test.goDELETE
telegram/session/session.goSimplify (remove Intent, Params)
telegram/bot.goUse agent.Manager, pass api.GetAPIDocs()
telegram/agent/prompt.goCREATE — system prompt template (API docs injected at runtime)
telegram/agent/apicall.goCREATE — the single generic tool
telegram/agent/agent.goCREATE — agent loop
telegram/agent/manager.goCREATE — per-chat serialization
telegram/agent/agent_test.goCREATE — 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 ~109–230)

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 117–223):

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