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.
This commit is contained in:
tinkle-community
2026-03-08 17:10:07 +08:00
parent 5f47dd13db
commit 9fcf44af65
8 changed files with 472 additions and 320 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 <api_call>: 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 <api_call> tag appears in the accumulated text.
// - After an api_call iteration completes, onChunk("⏳") resets the display to a thinking indicator.
// - The final reply is streamed progressively via onChunk.
// 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 <api_call> tag appears.
// Also hold back the last (len("<api_call>")-1) chars of accumulated text to
// avoid showing partial opening tags (e.g. "<", "<ap") before we can detect them.
const tagLen = len("<api_call>") // 10
const safeOffset = tagLen - 1 // 9: max prefix of tag we might have received
var apiTagSeen bool
resp, err = llm.CallWithRequestStream(req, func(accumulated string) {
if apiTagSeen {
return
}
if idx := strings.Index(accumulated, "<api_call>"); idx >= 0 {
apiTagSeen = true
// Forward only the text that appeared before the tag.
if display := strings.TrimSpace(accumulated[:idx]); display != "" {
onChunk(display)
}
return
}
// Forward only the "safe" prefix — hold back the last safeOffset chars
// in case they are the beginning of an <api_call> tag.
if safe := len(accumulated) - safeOffset; safe > 0 {
onChunk(accumulated[:safe])
}
})
} else {
resp, err = llm.CallWithRequest(req)
}
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 <api_call>...</api_call> fragment from s.
// Used as a defensive layer to ensure tags never leak to the user.
func stripAPICallTag(s string) string {
if idx := strings.Index(s, "<api_call>"); idx >= 0 {
return strings.TrimSpace(s[:idx])
}
return s
}
// ResetMemory clears conversation history (called on /start).
func (a *Agent) ResetMemory() {
a.memory.ResetFull()

View File

@@ -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{
`<api_call>{"method":"GET","path":"/api/my-traders","body":{}}</api_call>`,
"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{
`<api_call>{"method":"GET","path":"/api/account","body":{}}</api_call>`,
`<api_call>{"method":"GET","path":"/api/positions","body":{}}</api_call>`,
"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{
`<api_call>{"method":"GET","path":"/api/account","body":{}}</api_call>`,
"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 <api_call> 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 + `<api_call>{"method":"GET","path":"/api/strategies","body":{}}</api_call>`,
"你有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, "<api_call>") {
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: <api_call> 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{
`<api_call>{"method":"GET","path":"/api/account","body":{}}</api_call>`,
`账户余额 500 USDT。<api_call>{"method":"GET","path":"/api/account","body":{}}</api_call>`,
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, "<api_call>") {
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
`<api_call>{"method":"POST","path":"/api/strategies","body":{"name":"BTC趋势","config":{}}}</api_call>`,
// Step 2: verify strategy
`<api_call>{"method":"GET","path":"/api/strategies/s1","body":{}}</api_call>`,
// 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, "<api_call>") {
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
`<api_call>{"method":"POST","path":"/api/strategies","body":{"name":"BTC趋势"}}</api_call>`,
// 2. verify strategy
`<api_call>{"method":"GET","path":"/api/strategies/s1","body":{}}</api_call>`,
// 3. create trader
`<api_call>{"method":"POST","path":"/api/traders","body":{"name":"BTC趋势交易员","strategy_id":"s1"}}</api_call>`,
// 4. start trader
`<api_call>{"method":"POST","path":"/api/traders/tr1/start","body":{}}</api_call>`,
// 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, "<api_call>") {
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{
`<api_call>{"method":"GET","path":"/api/my-traders","body":{}}</api_call>`,
`<api_call>{"method":"POST","path":"/api/traders/tr1/start","body":{}}</api_call>`,
"交易员 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, "<api_call>") {
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 := `<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 != "" {
t.Fatalf("expected empty text before tag, got: %q", text)
}
})
t.Run("text before tag is captured", 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 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")
}
})
}
// TestStripAPICallTag: defensive cleanup of stray tags in final reply.
func TestStripAPICallTag(t *testing.T) {
cases := []struct {
input string
want string
}{
{`正常回复`, `正常回复`},
{`回复<api_call>{"method":"GET","path":"/x"}</api_call>`, `回复`},
{`<api_call>{"method":"GET","path":"/x"}</api_call>`, ``},
}
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] = `<api_call>{"method":"GET","path":"/api/account","body":{}}</api_call>`
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, "<api_call>") {
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
}

View File

@@ -19,7 +19,7 @@ type apiCallTool struct {
client *http.Client
}
// apiRequest is the parsed structure from the LLM's <api_call> 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 <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])
}

View File

@@ -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:
<api_call>{"method":"GET","path":"/api/xxx","body":{}}</api_call>
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 <api_call> 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 <api_call> tag. Zero words before it.
2. Only ONE <api_call> 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: