Files
nofx/telegram/agent/agent.go
tinkle-community 9fcf44af65 refactor(agent): replace XML api_call with native function calling
Migrate the Telegram bot agent from an XML tag hack (<api_call>) to
OpenAI-native function calling via CallWithRequestFull.

Key changes:
- mcp/interface.go: add parseMCPResponseFull to clientHooks interface
- mcp/client.go: route callWithRequestFull through hooks for overridability
- mcp/claude_client.go: override parseMCPResponseFull for Claude response
  format (tool_use blocks instead of choices[].message.tool_calls)
- telegram/agent/agent.go: rewrite Run() to use CallWithRequestFull;
  define api_request tool with JSON Schema; implement tool-call loop
  with role="tool" result messages; remove XML parsing entirely
- telegram/agent/apicall.go: remove parseAPICall (dead code)
- telegram/agent/prompt.go: simplify — remove XML format instructions,
  replace with concise api_request tool usage instructions
- telegram/agent/agent_test.go: rebuild all tests using LLMResponse
  objects; add TestNarrationStructurallyImpossible, TestOnChunkCalledWithFinalReply,
  TestToolCallIDPropagated; remove XML-specific tests

Architecture advantage: with native function calling, the LLM returns
EITHER ToolCalls OR Content — never both. Narration is now structurally
impossible at the protocol level, not just enforced by prompt rules.

All 11 agent tests pass. mcp package tests pass.
2026-03-08 17:10:07 +08:00

225 lines
7.2 KiB
Go

package agent
import (
"encoding/json"
"fmt"
"nofx/auth"
"nofx/logger"
"nofx/mcp"
"nofx/telegram/session"
"strings"
)
const maxIterations = 10
// apiRequestTool is the single tool exposed to the LLM.
// Native function calling means the LLM returns EITHER ToolCalls OR Content — never both.
// This makes narration structurally impossible: text cannot appear alongside a tool call.
var apiRequestTool = mcp.Tool{
Type: "function",
Function: mcp.FunctionDef{
Name: "api_request",
Description: "Call the NOFX trading system REST API",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"method": map[string]any{
"type": "string",
"enum": []string{"GET", "POST", "PUT", "DELETE"},
"description": "HTTP method",
},
"path": map[string]any{
"type": "string",
"description": "API path; include query params in path: /api/positions?trader_id=xxx",
},
"body": map[string]any{
"type": "object",
"description": "Request body; use {} for GET requests",
},
},
"required": []string{"method", "path", "body"},
},
},
}
// Agent is a stateful AI agent for one Telegram chat.
// It exposes a single "api_request" tool and runs a loop until the LLM
// returns a plain-text reply (no tool calls).
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 bot-made changes
// are visible in the frontend (shared 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) and returns it as a formatted string for
// injection into the LLM context at the start of each conversation.
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 native function-calling agent loop.
//
// Architecture:
// - LLM receives the api_request tool definition alongside conversation history.
// - LLM response is EITHER ToolCalls (execute API) OR Content (final reply) — never both.
// This is enforced by the protocol: narration is structurally impossible.
// - Loop continues until the LLM returns a plain-text reply (no tool calls).
//
// On the first message of a conversation the live account state is fetched and injected.
// onChunk is optional; when set it is called once with the complete final reply text.
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 initial user message: prepend account state on first turn, history on subsequent turns.
histCtx := a.memory.BuildContext()
var firstUserContent string
if histCtx == "" {
accountCtx := a.buildAccountContext()
firstUserContent = accountCtx + "\n[User Message]\n" + userMessage
} else {
firstUserContent = histCtx + "\n---\nUser: " + userMessage
}
turnMsgs := []mcp.Message{mcp.NewUserMessage(firstUserContent)}
for i := 0; i < maxIterations; i++ {
req, err := mcp.NewRequestBuilder().
WithSystemPrompt(a.systemPrompt).
AddConversationHistory(turnMsgs).
AddTool(apiRequestTool).
WithToolChoice("auto").
Build()
if err != nil {
logger.Errorf("Agent: failed to build request: %v", err)
break
}
resp, err := llm.CallWithRequestFull(req)
if err != nil {
logger.Errorf("Agent: LLM call failed (iteration %d): %v", i+1, err)
return "AI assistant temporarily unavailable. Please try again."
}
// No tool calls → LLM returned a final text reply.
if len(resp.ToolCalls) == 0 {
reply := strings.TrimSpace(resp.Content)
if onChunk != nil {
onChunk(reply)
}
a.memory.Add("user", userMessage)
a.memory.Add("assistant", reply)
return reply
}
// Tool call iteration — show thinking indicator.
if onChunk != nil {
onChunk("⏳")
}
// Append assistant message carrying the tool calls (no content field).
turnMsgs = append(turnMsgs, mcp.Message{
Role: "assistant",
ToolCalls: resp.ToolCalls,
})
// Execute each tool call and append the results as tool messages.
for _, tc := range resp.ToolCalls {
var apiReq apiRequest
if err := json.Unmarshal([]byte(tc.Function.Arguments), &apiReq); err != nil {
logger.Errorf("Agent: invalid tool args for call %s: %v", tc.ID, err)
turnMsgs = append(turnMsgs, mcp.Message{
Role: "tool",
ToolCallID: tc.ID,
Content: fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err.Error()),
})
continue
}
logger.Infof("Agent: iter=%d tool=%s %s %s", i+1, tc.ID, apiReq.Method, apiReq.Path)
result := a.apiTool.execute(&apiReq)
turnMsgs = append(turnMsgs, mcp.Message{
Role: "tool",
ToolCallID: tc.ID,
Content: result,
})
}
}
// Safety: max iterations reached.
logger.Warnf("Agent: max iterations (%d) reached for message: %q", maxIterations, userMessage)
reply := "操作已完成,请检查您的账户查看最新状态。"
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()
}