mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-05 12:00:59 +08:00
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.
89 lines
2.1 KiB
Go
89 lines
2.1 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"nofx/logger"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// apiCallTool executes HTTP requests against the NOFX API server.
|
|
// This is the only tool available to the agent.
|
|
type apiCallTool struct {
|
|
baseURL string
|
|
token string
|
|
client *http.Client
|
|
}
|
|
|
|
// apiRequest holds the arguments decoded from the LLM's api_request tool call.
|
|
type apiRequest struct {
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
Body map[string]any `json:"body"`
|
|
}
|
|
|
|
func newAPICallTool(port int, token string) *apiCallTool {
|
|
return &apiCallTool{
|
|
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
|
token: token,
|
|
client: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
// execute calls the API and returns the response as a string for LLM consumption.
|
|
func (t *apiCallTool) execute(req *apiRequest) string {
|
|
if req.Method == "" || req.Path == "" {
|
|
return "error: method and path are required"
|
|
}
|
|
if !strings.HasPrefix(req.Path, "/") {
|
|
req.Path = "/" + req.Path
|
|
}
|
|
|
|
var bodyReader io.Reader
|
|
if req.Method != "GET" && len(req.Body) > 0 {
|
|
b, err := json.Marshal(req.Body)
|
|
if err != nil {
|
|
return fmt.Sprintf("error marshaling body: %v", err)
|
|
}
|
|
bodyReader = bytes.NewReader(b)
|
|
}
|
|
|
|
httpReq, err := http.NewRequest(req.Method, t.baseURL+req.Path, bodyReader)
|
|
if err != nil {
|
|
return fmt.Sprintf("error creating request: %v", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Authorization", "Bearer "+t.token)
|
|
|
|
resp, err := t.client.Do(httpReq)
|
|
if err != nil {
|
|
return fmt.Sprintf("API call failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Sprintf("error reading response: %v", err)
|
|
}
|
|
|
|
logger.Infof("Agent api_call: %s %s -> %d", req.Method, req.Path, resp.StatusCode)
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return fmt.Sprintf("API error %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
// Pretty-print JSON for better LLM readability
|
|
var v any
|
|
if json.Unmarshal(body, &v) == nil {
|
|
if pretty, err := json.MarshalIndent(v, "", " "); err == nil {
|
|
return string(pretty)
|
|
}
|
|
}
|
|
return string(body)
|
|
}
|
|
|