diff --git a/mcp/claude_client.go b/mcp/claude_client.go index 6b277e4a..57f3b891 100644 --- a/mcp/claude_client.go +++ b/mcp/claude_client.go @@ -92,6 +92,68 @@ func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[ return requestBody } +// parseMCPResponseFull Claude response format — handles both text and tool_use blocks. +func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) { + var response struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + } `json:"content"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse Claude response: %w, body: %s", err, string(body)) + } + if response.Error != nil { + return nil, fmt.Errorf("Claude API error: %s - %s", response.Error.Type, response.Error.Message) + } + + totalTokens := response.Usage.InputTokens + response.Usage.OutputTokens + if TokenUsageCallback != nil && totalTokens > 0 { + TokenUsageCallback(TokenUsage{ + Provider: c.Provider, + Model: c.Model, + PromptTokens: response.Usage.InputTokens, + CompletionTokens: response.Usage.OutputTokens, + TotalTokens: totalTokens, + }) + } + + result := &LLMResponse{} + for _, block := range response.Content { + switch block.Type { + case "text": + result.Content = block.Text + case "tool_use": + // Claude returns arguments as a JSON object (Input field); convert to string. + argsJSON, err := json.Marshal(block.Input) + if err != nil { + argsJSON = []byte("{}") + } + result.ToolCalls = append(result.ToolCalls, ToolCall{ + ID: block.ID, + Type: "function", + Function: ToolCallFunction{ + Name: block.Name, + Arguments: string(argsJSON), + }, + }) + } + } + return result, nil +} + // parseMCPResponse Claude has different response format func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) { var response struct { diff --git a/mcp/client.go b/mcp/client.go index cd78717c..3b46c365 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -234,10 +234,21 @@ func (client *Client) marshalRequestBody(requestBody map[string]any) ([]byte, er } func (client *Client) parseMCPResponse(body []byte) (string, error) { + r, err := client.parseMCPResponseFull(body) + if err != nil { + return "", err + } + return r.Content, nil +} + +// parseMCPResponseFull parses the OpenAI-format response body and returns both +// the text content and any tool calls. +func (client *Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) { var result struct { Choices []struct { Message struct { - Content string `json:"content"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls"` } `json:"message"` } `json:"choices"` Usage struct { @@ -248,11 +259,11 @@ func (client *Client) parseMCPResponse(body []byte) (string, error) { } if err := json.Unmarshal(body, &result); err != nil { - return "", fmt.Errorf("failed to parse response: %w", err) + return nil, fmt.Errorf("failed to parse response: %w", err) } if len(result.Choices) == 0 { - return "", fmt.Errorf("API returned empty response") + return nil, fmt.Errorf("API returned empty response") } // Report token usage if callback is set @@ -266,7 +277,11 @@ func (client *Client) parseMCPResponse(body []byte) (string, error) { }) } - return result.Choices[0].Message.Content, nil + msg := result.Choices[0].Message + return &LLMResponse{ + Content: msg.Content, + ToolCalls: msg.ToolCalls, + }, nil } func (client *Client) buildUrl() string { @@ -427,6 +442,70 @@ func (client *Client) CallWithRequest(req *Request) (string, error) { return "", fmt.Errorf("still failed after %d retries: %w", maxRetries, lastErr) } +// CallWithRequestFull calls the AI API and returns both text content and tool calls. +func (client *Client) CallWithRequestFull(req *Request) (*LLMResponse, error) { + if client.APIKey == "" { + return nil, fmt.Errorf("AI API key not set, please call SetAPIKey first") + } + if req.Model == "" { + req.Model = client.Model + } + + var lastErr error + maxRetries := client.config.MaxRetries + for attempt := 1; attempt <= maxRetries; attempt++ { + if attempt > 1 { + client.logger.Warnf("⚠️ AI API call failed, retrying (%d/%d)...", attempt, maxRetries) + } + result, err := client.callWithRequestFull(req) + if err == nil { + return result, nil + } + lastErr = err + if !client.hooks.isRetryableError(err) { + return nil, err + } + if attempt < maxRetries { + waitTime := client.config.RetryWaitBase * time.Duration(attempt) + time.Sleep(waitTime) + } + } + return nil, fmt.Errorf("still failed after %d retries: %w", maxRetries, lastErr) +} + +// callWithRequestFull single call that returns LLMResponse (content + tool calls). +func (client *Client) callWithRequestFull(req *Request) (*LLMResponse, error) { + client.logger.Infof("📡 [%s] Request AI Server (full): BaseURL: %s", client.String(), client.BaseURL) + + requestBody := client.buildRequestBodyFromRequest(req) + jsonData, err := client.hooks.marshalRequestBody(requestBody) + if err != nil { + return nil, err + } + + url := client.hooks.buildUrl() + httpReq, err := client.hooks.buildRequest(url, jsonData) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body)) + } + + return client.hooks.parseMCPResponseFull(body) +} + // callWithRequest single AI API call (using Request object) func (client *Client) callWithRequest(req *Request) (string, error) { // Print current AI configuration @@ -481,13 +560,23 @@ func (client *Client) callWithRequest(req *Request) (string, error) { // buildRequestBodyFromRequest builds request body from Request object func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any { - // Convert Message to API format - messages := make([]map[string]string, 0, len(req.Messages)) + // Convert Message to API format — must use map[string]any to support + // tool-call messages (tool_calls, tool_call_id fields). + messages := make([]map[string]any, 0, len(req.Messages)) for _, msg := range req.Messages { - messages = append(messages, map[string]string{ - "role": msg.Role, - "content": msg.Content, - }) + m := map[string]any{"role": msg.Role} + if len(msg.ToolCalls) > 0 { + // Assistant message that contains tool invocations. + // content must be null/omitted for OpenAI compatibility. + m["tool_calls"] = msg.ToolCalls + } else if msg.ToolCallID != "" { + // Tool result message (role="tool"). + m["tool_call_id"] = msg.ToolCallID + m["content"] = msg.Content + } else { + m["content"] = msg.Content + } + messages = append(messages, m) } // Build basic request body diff --git a/mcp/interface.go b/mcp/interface.go index 2dbcb03c..33604861 100644 --- a/mcp/interface.go +++ b/mcp/interface.go @@ -15,6 +15,11 @@ type AIClient interface { // onChunk is called with the full accumulated text so far (not raw deltas). // Returns the complete final text when done. CallWithRequestStream(req *Request, onChunk func(string)) (string, error) + // CallWithRequestFull returns both text content and tool calls. + // Use this when the request includes Tools — the LLM may respond with + // either a plain text reply (LLMResponse.Content) or tool invocations + // (LLMResponse.ToolCalls), but not both. + CallWithRequestFull(req *Request) (*LLMResponse, error) } // clientHooks internal hook interface (for subclass to override specific steps) @@ -30,5 +35,6 @@ type clientHooks interface { setAuthHeader(reqHeaders http.Header) marshalRequestBody(requestBody map[string]any) ([]byte, error) parseMCPResponse(body []byte) (string, error) + parseMCPResponseFull(body []byte) (*LLMResponse, error) isRetryableError(err error) bool } diff --git a/mcp/request.go b/mcp/request.go index 3ade2d71..548ef094 100644 --- a/mcp/request.go +++ b/mcp/request.go @@ -1,9 +1,34 @@ package mcp -// Message represents a conversation message +// Message represents a conversation message. +// Supports plain messages (Role+Content), assistant tool-call messages (ToolCalls), +// and tool result messages (Role="tool", ToolCallID, Content). type Message struct { - Role string `json:"role"` // "system", "user", "assistant" - Content string `json:"content"` // Message content + Role string `json:"role"` // "system", "user", "assistant", "tool" + Content string `json:"content,omitempty"` // Text content (omitted when ToolCalls present) + ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Set by assistant when calling tools + ToolCallID string `json:"tool_call_id,omitempty"` // Set on role="tool" result messages +} + +// ToolCall is a single function call requested by the LLM. +type ToolCall struct { + ID string `json:"id"` // Unique call ID (e.g. "call_abc123") + Type string `json:"type"` // Always "function" + Function ToolCallFunction `json:"function"` // Function name and JSON-serialised arguments +} + +// ToolCallFunction holds the function name and raw JSON arguments string. +type ToolCallFunction struct { + Name string `json:"name"` // Function name + Arguments string `json:"arguments"` // JSON-encoded argument object +} + +// LLMResponse is returned by CallWithRequestFull and carries both the assistant +// text reply (Content) and any structured tool calls (ToolCalls). +// Exactly one of the two fields will be non-empty for a well-formed response. +type LLMResponse struct { + Content string // Plain-text reply (final answer) + ToolCalls []ToolCall // Structured tool invocations } // Tool represents a tool/function that AI can call diff --git a/telegram/agent/agent.go b/telegram/agent/agent.go index 0a16998b..550f95c7 100644 --- a/telegram/agent/agent.go +++ b/telegram/agent/agent.go @@ -12,8 +12,39 @@ import ( 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 has a single tool (api_call) and an unbounded decision loop. +// 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 @@ -34,17 +65,15 @@ func New(apiPort int, botToken, userID string, getLLM func() mcp.AIClient, syste } // 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). +// 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) 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. +// 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 @@ -91,137 +120,104 @@ func (a *Agent) buildAccountContext() string { return sb.String() } -// Run processes one user message through the agent loop. -// Loop: LLM decides -> if : execute, append result, loop -> if no tag: return reply. +// Run processes one user message through the native function-calling agent loop. // -// 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. +// 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). // -// onChunk is optional. When non-nil, each LLM call is streamed: -// - Chunks are forwarded to onChunk until an 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. +// 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 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. + // Build initial user message: prepend account state on first turn, history on subsequent turns. histCtx := a.memory.BuildContext() - var firstMsg string + var firstUserContent string if histCtx == "" { - // First message in this conversation — fetch and inject account state. accountCtx := a.buildAccountContext() - firstMsg = accountCtx + "\n[User Message]\n" + userMessage + firstUserContent = accountCtx + "\n[User Message]\n" + userMessage } else { - firstMsg = histCtx + "\n---\nUser: " + userMessage + firstUserContent = histCtx + "\n---\nUser: " + userMessage } - turnMsgs := []mcp.Message{mcp.NewUserMessage(firstMsg)} - var lastResp string + 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 } - var resp string - if onChunk != nil { - // Stream this call; suppress chunks once an tag appears. - // Also hold back the last (len("")-1) chars of accumulated text to - // avoid showing partial opening tags (e.g. "<", "") // 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, ""); 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 tag. - if safe := len(accumulated) - safeOffset; safe > 0 { - onChunk(accumulated[:safe]) - } - }) - } else { - resp, err = llm.CallWithRequest(req) - } + 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." } - 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)) + // 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 } - // api_call iteration — reset display to thinking indicator before executing. + // Tool call iteration — show thinking indicator. if onChunk != nil { onChunk("⏳") } - logger.Infof("Agent: iter=%d %s %s", i+1, apiReq.Method, apiReq.Path) - result := a.apiTool.execute(apiReq) + // Append assistant message carrying the tool calls (no content field). + turnMsgs = append(turnMsgs, mcp.Message{ + Role: "assistant", + ToolCalls: resp.ToolCalls, + }) - 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 + // 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, + }) } } - reply := stripAPICallTag(strings.TrimSpace(lastResp)) + // 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 } -// stripAPICallTag removes any ... 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, ""); idx >= 0 { - return strings.TrimSpace(s[:idx]) - } - return s -} - // ResetMemory clears conversation history (called on /start). func (a *Agent) ResetMemory() { a.memory.ResetFull() diff --git a/telegram/agent/agent_test.go b/telegram/agent/agent_test.go index aa736aa1..ff2e0e41 100644 --- a/telegram/agent/agent_test.go +++ b/telegram/agent/agent_test.go @@ -11,34 +11,73 @@ import ( "nofx/mcp" ) +// mockLLM implements mcp.AIClient using pre-programmed LLMResponse objects. +// Native function calling: CallWithRequestFull is the primary method; +// CallWithRequest and CallWithRequestStream are stubs kept for interface compliance. type mockLLM struct { - responses []string + responses []*mcp.LLMResponse 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) SetAPIKey(_, _, _ string) {} +func (m *mockLLM) SetTimeout(_ time.Duration) {} + +func (m *mockLLM) CallWithMessages(_, _ string) (string, error) { return "", nil } + func (m *mockLLM) CallWithRequest(req *mcp.Request) (string, error) { + r, err := m.next() + if err != nil { + return "", err + } + return r.Content, nil +} + +func (m *mockLLM) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) { + r, err := m.next() + if err != nil { + return "", err + } + if onChunk != nil { + onChunk(r.Content) + } + return r.Content, nil +} + +func (m *mockLLM) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, 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) { + +func (m *mockLLM) next() (*mcp.LLMResponse, error) { if m.calls < len(m.responses) { r := m.responses[m.calls] m.calls++ return r, nil } - return "OK", nil + return &mcp.LLMResponse{Content: "OK"}, nil +} + +// toolCall builds a mock LLM response that contains a single tool invocation. +func toolCall(id, method, path string, body string) *mcp.LLMResponse { + if body == "" { + body = "{}" + } + return &mcp.LLMResponse{ + ToolCalls: []mcp.ToolCall{{ + ID: id, + Type: "function", + Function: mcp.ToolCallFunction{ + Name: "api_request", + Arguments: fmt.Sprintf(`{"method":%q,"path":%q,"body":%s}`, method, path, body), + }, + }}, + } +} + +// textReply builds a mock LLM response with a plain-text final answer. +func textReply(content string) *mcp.LLMResponse { + return &mcp.LLMResponse{Content: content} } func mockGetLLM(llm *mockLLM) func() mcp.AIClient { @@ -70,9 +109,9 @@ func mockAPIServer(handlers map[string]string) (*httptest.Server, int) { // ── Basic agent behaviour ────────────────────────────────────────────────── -// TestAgentDirectReply: LLM replies without api_call — one call, direct reply. +// TestAgentDirectReply: LLM replies with text (no tool calls) — one LLM call. func TestAgentDirectReply(t *testing.T) { - llm := &mockLLM{responses: []string{"Hello! How can I help you?"}} + llm := &mockLLM{responses: []*mcp.LLMResponse{textReply("Hello! How can I help you?")}} a := New(8080, "tok", "test-user", mockGetLLM(llm), testPrompt) reply := a.Run("hello", nil) @@ -85,16 +124,16 @@ func TestAgentDirectReply(t *testing.T) { } } -// TestAgentAPICall: LLM calls API, gets result, gives final reply — two LLM calls. +// TestAgentAPICall: LLM makes one tool call, gets result, gives final reply — two LLM calls. func TestAgentAPICall(t *testing.T) { srv, port := mockAPIServer(map[string]string{ "/api/my-traders": `[{"trader_id":"t1","trader_name":"BTC Trader","is_running":false}]`, }) defer srv.Close() - llm := &mockLLM{responses: []string{ - `{"method":"GET","path":"/api/my-traders","body":{}}`, - "You have one trader: BTC Trader.", + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("c1", "GET", "/api/my-traders", "{}"), + textReply("You have one trader: BTC Trader."), }} a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) @@ -108,7 +147,7 @@ func TestAgentAPICall(t *testing.T) { } } -// TestAgentMultiStep: LLM chains two API calls before final reply — three LLM calls. +// TestAgentMultiStep: LLM chains two tool calls before final reply — three LLM calls. func TestAgentMultiStep(t *testing.T) { srv, port := mockAPIServer(map[string]string{ "/api/account": `{"total_equity":1000}`, @@ -116,105 +155,118 @@ func TestAgentMultiStep(t *testing.T) { }) defer srv.Close() - llm := &mockLLM{responses: []string{ - `{"method":"GET","path":"/api/account","body":{}}`, - `{"method":"GET","path":"/api/positions","body":{}}`, - "Account looks healthy and no open positions.", + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("c1", "GET", "/api/account", "{}"), + toolCall("c2", "GET", "/api/positions", "{}"), + textReply("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) + t.Fatalf("expected 3 LLM calls (2 tool + 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. +// TestAgentAPIResultInContext: tool result must appear as a tool message in the next LLM call. func TestAgentAPIResultInContext(t *testing.T) { srv, port := mockAPIServer(map[string]string{ "/api/account": `{"balance":1234.56}`, }) defer srv.Close() - llm := &mockLLM{responses: []string{ - `{"method":"GET","path":"/api/account","body":{}}`, - "Balance is 1234.56 USDT.", + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("c1", "GET", "/api/account", "{}"), + textReply("Balance is 1234.56 USDT."), }} a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) a.Run("show balance", nil) + // The last request must contain a tool-result message with the balance data. found := false for _, msg := range llm.lastMsgs { - if strings.Contains(msg.Content, "API result") || strings.Contains(msg.Content, "balance") { + if msg.Role == "tool" && strings.Contains(msg.Content, "balance") { found = true break } } if !found { - t.Fatalf("API result not found in subsequent LLM context") + t.Fatalf("tool result message not found in subsequent LLM context; messages: %+v", llm.lastMsgs) } } -// ── NO NARRATION tests ───────────────────────────────────────────────────── +// ── Narration-free architecture tests ───────────────────────────────────── -// TestNoNarrationBeforeAPICall: any text before must NOT reach the user. -// The agent strips text-before-tag and only forwards it as assistant context. -func TestNoNarrationBeforeAPICall(t *testing.T) { +// TestNarrationStructurallyImpossible: when ToolCalls are present in the response, +// any Content field is ignored and never surfaced to the user. +// In real LLM APIs, Content is always empty alongside ToolCalls, but we verify +// our agent handles a malformed response defensively. +func TestNarrationStructurallyImpossible(t *testing.T) { srv, port := mockAPIServer(map[string]string{ "/api/strategies": `[{"id":"s1","name":"BTC Trend"}]`, }) defer srv.Close() - narrations := []string{ - "现在我将为您创建策略。\n", - "好的,我来帮你查询。", - "Let me check this for you. ", - "正在处理...", - "I will call the API now. ", + // Simulate a (malformed) response that has both Content and ToolCalls. + malformed := &mcp.LLMResponse{ + Content: "现在我将为您查询策略。", // narration — must NOT reach user + ToolCalls: []mcp.ToolCall{{ + ID: "c1", + Type: "function", + Function: mcp.ToolCallFunction{ + Name: "api_request", + Arguments: `{"method":"GET","path":"/api/strategies","body":{}}`, + }, + }}, } - for _, narration := range narrations { - llm := &mockLLM{responses: []string{ - // LLM outputs narration before the api_call tag (bad behaviour we must handle) - narration + `{"method":"GET","path":"/api/strategies","body":{}}`, - "你有1个策略:BTC Trend。", - }} - a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) - reply := a.Run("查询我的策略", nil) + llm := &mockLLM{responses: []*mcp.LLMResponse{ + malformed, + textReply("你有1个策略:BTC Trend。"), + }} + a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) + reply := a.Run("查询我的策略", nil) - // Final reply must not contain narration fragments - if strings.Contains(reply, "现在我将") || strings.Contains(reply, "Let me") || - strings.Contains(reply, "正在处理") || strings.Contains(reply, "好的,我来") || - strings.Contains(reply, "I will call") { - t.Fatalf("narration leaked into reply for input %q: got %q", narration, reply) - } - // api_call tag must not appear in reply - if strings.Contains(reply, "") { - t.Fatalf("api_call tag leaked into reply: %q", reply) - } + if strings.Contains(reply, "现在我将") { + t.Fatalf("narration leaked into final reply: %q", reply) + } + if reply != "你有1个策略:BTC Trend。" { + t.Fatalf("unexpected reply: %q", reply) } } -// TestAPICallTagNotLeakedToUser: tag must never appear in returned reply. -func TestAPICallTagNotLeakedToUser(t *testing.T) { +// TestOnChunkCalledWithFinalReply: onChunk receives the complete final reply. +func TestOnChunkCalledWithFinalReply(t *testing.T) { srv, port := mockAPIServer(map[string]string{ - "/api/account": `{"total_equity":500}`, + "/api/account": `{"equity":500}`, }) defer srv.Close() - llm := &mockLLM{responses: []string{ - `{"method":"GET","path":"/api/account","body":{}}`, - `账户余额 500 USDT。{"method":"GET","path":"/api/account","body":{}}`, + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("c1", "GET", "/api/account", "{}"), + textReply("Equity: 500 USDT."), }} a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) - reply := a.Run("show balance", nil) - if strings.Contains(reply, "") { - t.Fatalf("api_call tag leaked to user: %q", reply) + var chunks []string + reply := a.Run("show equity", func(chunk string) { + chunks = append(chunks, chunk) + }) + + if reply != "Equity: 500 USDT." { + t.Fatalf("unexpected reply: %q", reply) + } + // Should have received ⏳ for the tool call, then the final reply. + if len(chunks) < 2 { + t.Fatalf("expected at least 2 chunks (⏳ + final), got: %v", chunks) + } + lastChunk := chunks[len(chunks)-1] + if lastChunk != "Equity: 500 USDT." { + t.Fatalf("last chunk should be final reply, got: %q", lastChunk) } } @@ -224,18 +276,15 @@ func TestAPICallTagNotLeakedToUser(t *testing.T) { // Verifies: POST strategy → GET verify → final reply shows strategy info. func TestCreateStrategyWorkflow(t *testing.T) { srv, port := mockAPIServer(map[string]string{ - "POST /api/strategies": `{"id":"s1","name":"BTC趋势"}`, - "GET /api/strategies/s1": `{"id":"s1","name":"BTC趋势","config":{"coin_source":{"source_type":"static","static_coins":["BTC/USDT"]},"leverage":5}}`, + "POST /api/strategies": `{"id":"s1","name":"BTC趋势"}`, + "GET /api/strategies/s1": `{"id":"s1","name":"BTC趋势","config":{"coin_source":{"source_type":"static","static_coins":["BTC/USDT"]},"leverage":5}}`, }) defer srv.Close() - llm := &mockLLM{responses: []string{ - // Step 1: create strategy - `{"method":"POST","path":"/api/strategies","body":{"name":"BTC趋势","config":{}}}`, - // Step 2: verify strategy - `{"method":"GET","path":"/api/strategies/s1","body":{}}`, - // Step 3: final reply - "策略已创建:BTC趋势,币种 BTC/USDT,杠杆 5x。", + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("c1", "POST", "/api/strategies", `{"name":"BTC趋势","config":{}}`), + toolCall("c2", "GET", "/api/strategies/s1", "{}"), + textReply("策略已创建:BTC趋势,币种 BTC/USDT,杠杆 5x。"), }} a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) reply := a.Run("帮我配置个btc趋势交易的策略", nil) @@ -243,12 +292,12 @@ func TestCreateStrategyWorkflow(t *testing.T) { if llm.calls != 3 { t.Fatalf("expected 3 LLM calls, got %d", llm.calls) } - if reply == "" || strings.Contains(reply, "") { - t.Fatalf("bad final reply: %q", reply) + if reply == "" { + t.Fatalf("empty final reply") } } -// TestFullSetupWorkflow: create strategy → create trader → start trader. +// TestFullSetupWorkflow: create strategy → verify → create trader → start trader. // This is the "帮我配置策略并跑起来" workflow. func TestFullSetupWorkflow(t *testing.T) { calls := map[string]int{} @@ -272,17 +321,12 @@ func TestFullSetupWorkflow(t *testing.T) { var port int fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port) - llm := &mockLLM{responses: []string{ - // 1. create strategy - `{"method":"POST","path":"/api/strategies","body":{"name":"BTC趋势"}}`, - // 2. verify strategy - `{"method":"GET","path":"/api/strategies/s1","body":{}}`, - // 3. create trader - `{"method":"POST","path":"/api/traders","body":{"name":"BTC趋势交易员","strategy_id":"s1"}}`, - // 4. start trader - `{"method":"POST","path":"/api/traders/tr1/start","body":{}}`, - // 5. final reply - "策略和交易员已创建并启动!BTC趋势交易员正在运行。", + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("c1", "POST", "/api/strategies", `{"name":"BTC趋势"}`), + toolCall("c2", "GET", "/api/strategies/s1", "{}"), + toolCall("c3", "POST", "/api/traders", `{"name":"BTC趋势交易员","strategy_id":"s1"}`), + toolCall("c4", "POST", "/api/traders/tr1/start", "{}"), + textReply("策略和交易员已创建并启动!BTC趋势交易员正在运行。"), }} a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) reply := a.Run("帮我配置个btc趋势交易的策略交易 跑起来", nil) @@ -290,7 +334,6 @@ func TestFullSetupWorkflow(t *testing.T) { if llm.calls != 5 { t.Fatalf("expected 5 LLM calls, got %d", llm.calls) } - // Verify each API was called if calls["POST /api/strategies"] != 1 { t.Errorf("expected 1 POST /api/strategies, got %d", calls["POST /api/strategies"]) } @@ -300,8 +343,8 @@ func TestFullSetupWorkflow(t *testing.T) { if calls["POST /api/traders/tr1/start"] != 1 { t.Errorf("expected 1 POST /api/traders/tr1/start, got %d", calls["POST /api/traders/tr1/start"]) } - if strings.Contains(reply, "") { - t.Fatalf("api_call tag in final reply: %q", reply) + if reply == "" { + t.Fatalf("empty final reply") } } @@ -324,10 +367,10 @@ func TestStartExistingTrader(t *testing.T) { var port int fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port) - llm := &mockLLM{responses: []string{ - `{"method":"GET","path":"/api/my-traders","body":{}}`, - `{"method":"POST","path":"/api/traders/tr1/start","body":{}}`, - "交易员 BTC Trader 已启动。", + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("c1", "GET", "/api/my-traders", "{}"), + toolCall("c2", "POST", "/api/traders/tr1/start", "{}"), + textReply("交易员 BTC Trader 已启动。"), }} a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) reply := a.Run("启动交易员", nil) @@ -335,96 +378,62 @@ func TestStartExistingTrader(t *testing.T) { if calls["POST /api/traders/tr1/start"] != 1 { t.Errorf("expected trader to be started, got %d start calls", calls["POST /api/traders/tr1/start"]) } - if strings.Contains(reply, "") { - t.Fatalf("api_call tag in reply: %q", reply) + if reply != "交易员 BTC Trader 已启动。" { + t.Fatalf("unexpected reply: %q", reply) } } -// ── Parser tests ─────────────────────────────────────────────────────────── +// ── Safety limit ─────────────────────────────────────────────────────────── -// TestParseAPICall: unit tests for the XML tag parser. -func TestParseAPICall(t *testing.T) { - t.Run("valid call no text before", func(t *testing.T) { - resp := `{"method":"POST","path":"/api/traders/t1/stop","body":{}}` - 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 != "" { - t.Fatalf("expected empty text before tag, got: %q", text) - } - }) - - t.Run("text before tag is captured", func(t *testing.T) { - resp := `Stopping trader.{"method":"POST","path":"/api/traders/t1/stop","body":{}}` - req, text := parseAPICall(resp) - if req == nil { - t.Fatal("expected api_call, got nil") - } - 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(`NOT JSON`) - if req != nil { - t.Fatal("expected nil for malformed JSON") - } - }) -} - -// TestStripAPICallTag: defensive cleanup of stray tags in final reply. -func TestStripAPICallTag(t *testing.T) { - cases := []struct { - input string - want string - }{ - {`正常回复`, `正常回复`}, - {`回复{"method":"GET","path":"/x"}`, `回复`}, - {`{"method":"GET","path":"/x"}`, ``}, - } - for _, c := range cases { - got := stripAPICallTag(c.input) - if strings.TrimSpace(got) != c.want { - t.Errorf("stripAPICallTag(%q) = %q, want %q", c.input, got, c.want) - } - } -} - -// TestMaxIterations: agent stops after maxIterations and returns a summary. +// TestMaxIterations: agent terminates after maxIterations and returns fallback message. func TestMaxIterations(t *testing.T) { srv, port := mockAPIServer(map[string]string{ "/api/account": `{"ok":true}`, }) defer srv.Close() - // Always returns another api_call — should hit max iterations - responses := make([]string, maxIterations+2) + // Always returns another tool call — should hit max iterations. + responses := make([]*mcp.LLMResponse, maxIterations+2) for i := range responses { - responses[i] = `{"method":"GET","path":"/api/account","body":{}}` + responses[i] = toolCall(fmt.Sprintf("c%d", i), "GET", "/api/account", "{}") } - responses[maxIterations] = "Final summary after max iterations." llm := &mockLLM{responses: responses} a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) reply := a.Run("loop forever", nil) - if strings.Contains(reply, "") { - t.Fatalf("api_call tag in reply after max iterations: %q", reply) + if reply == "" { + t.Fatalf("expected a fallback reply, got empty string") + } + // Agent should have made exactly maxIterations tool-call LLM calls. + if llm.calls != maxIterations { + t.Fatalf("expected %d LLM calls (max iterations), got %d", maxIterations, llm.calls) + } +} + +// TestToolCallIDPropagated: tool result messages carry the correct ToolCallID. +func TestToolCallIDPropagated(t *testing.T) { + srv, port := mockAPIServer(map[string]string{ + "/api/account": `{"balance":999}`, + }) + defer srv.Close() + + llm := &mockLLM{responses: []*mcp.LLMResponse{ + toolCall("call-xyz-123", "GET", "/api/account", "{}"), + textReply("Balance is 999."), + }} + a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt) + a.Run("check balance", nil) + + // Find the tool result message and verify ToolCallID matches. + found := false + for _, msg := range llm.lastMsgs { + if msg.Role == "tool" && msg.ToolCallID == "call-xyz-123" { + found = true + break + } + } + if !found { + t.Fatalf("tool result with ToolCallID='call-xyz-123' not found in messages: %+v", llm.lastMsgs) } - _ = reply // just confirm it terminates } diff --git a/telegram/agent/apicall.go b/telegram/agent/apicall.go index 4321a064..eca6b9d5 100644 --- a/telegram/agent/apicall.go +++ b/telegram/agent/apicall.go @@ -19,7 +19,7 @@ type apiCallTool struct { client *http.Client } -// apiRequest is the parsed structure from the LLM's tag. +// apiRequest holds the arguments decoded from the LLM's api_request tool call. type apiRequest struct { Method string `json:"method"` Path string `json:"path"` @@ -86,24 +86,3 @@ func (t *apiCallTool) execute(req *apiRequest) string { return string(body) } -// parseAPICall extracts ... from LLM response. -// Returns (nil, original) if not found or malformed JSON. -func parseAPICall(resp string) (*apiRequest, string) { - const openTag = "" - const closeTag = "" - - 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]) -} diff --git a/telegram/agent/prompt.go b/telegram/agent/prompt.go index bc0d8d01..075ef5d3 100644 --- a/telegram/agent/prompt.go +++ b/telegram/agent/prompt.go @@ -13,35 +13,22 @@ func BuildAgentPrompt(apiDocs, userID string) string { - 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 - -When you need to call the API, your ENTIRE response must be ONLY the tag — nothing else: -{"method":"GET","path":"/api/xxx","body":{}} - -When you have a final answer (no more API calls needed), reply with plain text — NO tag at all. - -ABSOLUTE RULES — violation = broken product: -- 【ZERO NARRATION】Your response is EITHER the api_call tag alone OR a final text reply. NEVER both except api_call at the very end. -- NEVER output ANY text before an api_call tag. No "好的", no "现在", no "我将", no "Let me", no "I will", no "正在", no "Creating...", no ellipsis, NOTHING. -- NEVER more than one tag per response +## Tool: api_request +Use the api_request tool to call the NOFX REST API: - method: "GET" | "POST" | "PUT" | "DELETE" +- path: API path; query params go in the path: /api/positions?trader_id=xxx - body: JSON object (use {} for GET requests) -- query parameters go in the path: /api/positions?trader_id=xxx ## NOFX API Documentation %s ## Behavior Rules -1. 【SILENT ACTION】When you need to call an API: output ONLY the tag. Zero words before it. -2. Only ONE tag per response, always alone with nothing else -3. After getting an API result, decide: call another API (output tag only) or give final reply (text only) -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 final replies concise — show results, not process -7. Ask for ALL missing required info 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 +1. Reply in the same language the user used (中文→中文, English→English) +2. Keep final replies concise — show results, not process +3. Ask for ALL missing required info in ONE message — never ask one field at a time +4. When user provides enough info, act immediately — no confirmation needed +5. 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: @@ -56,7 +43,6 @@ After ANY PUT or POST that creates or modifies a resource: - "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 ## Account State (injected at conversation start) At the start of each new conversation, a [Current Account State] block is provided with: