- llm/provider.go: extend ChatMessage with ToolCalls, ToolCallID, Name fields; add ToolCall + ToolCallFunction structs for parsing LLM function call responses - tool_execution_service.go (new): ToolExecutionService that converts CaptainCustomTool → LLM ToolDefinition, executes HTTP tool calls (GET/POST/PUT with bearer/basic/api-key auth), and runs the full tool_call loop (LLM → tool_call → execute → result → LLM → final answer) with maxIterations safeguard - captain_conversation_service.go: add toolExecSvc field + SetToolExecutionService method; use RunToolCallLoop in generateConversationResponse when tools are available, with graceful fallback to plain LLM call on error - bootstrap.go: instantiate ToolExecutionService and inject into CaptainConversationService Verified: go build + go vet + go test all pass
286 lines
8.0 KiB
Go
286 lines
8.0 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/llm"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// ToolExecutionService handles LLM function calling: converts CaptainCustomTool
|
|
// definitions to LLM ToolDefinitions, executes HTTP tool calls, and runs the
|
|
// tool_call loop (LLM → tool_call → execute → result → LLM → final answer).
|
|
//
|
|
// Reference: AI_FEATURE_ROADMAP.md §3.2 — Function Calling complete implementation
|
|
type ToolExecutionService struct {
|
|
toolRepo *repository.CaptainCustomToolRepo
|
|
llmProvider llm.Provider
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewToolExecutionService creates a new ToolExecutionService.
|
|
func NewToolExecutionService(toolRepo *repository.CaptainCustomToolRepo, llmProvider llm.Provider) *ToolExecutionService {
|
|
return &ToolExecutionService{
|
|
toolRepo: toolRepo,
|
|
llmProvider: llmProvider,
|
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
// GetToolsForAssistant returns enabled custom tools for an account as LLM ToolDefinitions.
|
|
func (s *ToolExecutionService) GetToolsForAccount(ctx context.Context, accountID uint) ([]llm.ToolDefinition, error) {
|
|
// ListByAccount returns all tools; we filter for enabled ones
|
|
tools, _, err := s.toolRepo.ListByAccount(ctx, accountID, 0, 100)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch custom tools: %w", err)
|
|
}
|
|
|
|
defs := make([]llm.ToolDefinition, 0, len(tools))
|
|
for _, tool := range tools {
|
|
if !tool.Enabled {
|
|
continue
|
|
}
|
|
def := customToolToDefinition(tool)
|
|
defs = append(defs, def)
|
|
}
|
|
return defs, nil
|
|
}
|
|
|
|
// customToolToDefinition converts a CaptainCustomTool to an LLM ToolDefinition.
|
|
func customToolToDefinition(tool model.CaptainCustomTool) llm.ToolDefinition {
|
|
var params map[string]interface{}
|
|
if len(tool.ParamSchema) > 0 && string(tool.ParamSchema) != "null" {
|
|
_ = json.Unmarshal(tool.ParamSchema, ¶ms)
|
|
}
|
|
if params == nil {
|
|
params = map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}
|
|
}
|
|
|
|
return llm.ToolDefinition{
|
|
Type: "function",
|
|
Function: llm.ToolFunction{
|
|
Name: tool.Slug,
|
|
Description: tool.Description,
|
|
Parameters: params,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ExecuteToolCall executes a single tool call by making the configured HTTP request.
|
|
// Returns the result as a string (typically JSON).
|
|
func (s *ToolExecutionService) ExecuteToolCall(ctx context.Context, accountID uint, call llm.ToolCall) (string, error) {
|
|
// Find the tool by slug (function name)
|
|
tools, _, err := s.toolRepo.ListByAccount(ctx, accountID, 0, 100)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch tools: %w", err)
|
|
}
|
|
|
|
var tool *model.CaptainCustomTool
|
|
for i := range tools {
|
|
if tools[i].Enabled && tools[i].Slug == call.Function.Name {
|
|
tool = &tools[i]
|
|
break
|
|
}
|
|
}
|
|
if tool == nil {
|
|
return "", fmt.Errorf("tool %s not found or not enabled", call.Function.Name)
|
|
}
|
|
|
|
// Parse arguments
|
|
var args map[string]interface{}
|
|
if call.Function.Arguments != "" {
|
|
if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err != nil {
|
|
return "", fmt.Errorf("parse tool arguments: %w", err)
|
|
}
|
|
}
|
|
|
|
// Build HTTP request
|
|
method := strings.ToUpper(tool.HTTPMethod)
|
|
if method == "" {
|
|
method = "GET"
|
|
}
|
|
|
|
var bodyReader io.Reader
|
|
if method == "POST" || method == "PUT" || method == "PATCH" {
|
|
bodyBytes, _ := json.Marshal(args)
|
|
bodyReader = bytes.NewReader(bodyBytes)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, tool.EndpointURL, bodyReader)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create tool request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
// Apply auth
|
|
applyToolAuth(req, tool)
|
|
|
|
// Execute
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("execute tool request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 10*1024)) // max 10KB response
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return "", fmt.Errorf("tool endpoint returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
applogger.L().Infof("ToolExecutionService: tool %s returned status %d, body=%s",
|
|
call.Function.Name, resp.StatusCode, string(body))
|
|
|
|
return string(body), nil
|
|
}
|
|
|
|
// applyToolAuth applies authentication to the HTTP request based on the tool's auth config.
|
|
func applyToolAuth(req *http.Request, tool *model.CaptainCustomTool) {
|
|
switch tool.AuthType {
|
|
case model.ToolAuthTypeBearer:
|
|
var authCfg struct {
|
|
Token string `json:"token"`
|
|
}
|
|
_ = json.Unmarshal(tool.AuthConfig, &authCfg)
|
|
if authCfg.Token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+authCfg.Token)
|
|
}
|
|
case model.ToolAuthTypeBasic:
|
|
var authCfg struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
_ = json.Unmarshal(tool.AuthConfig, &authCfg)
|
|
if authCfg.Username != "" {
|
|
req.SetBasicAuth(authCfg.Username, authCfg.Password)
|
|
}
|
|
case model.ToolAuthTypeApiKey:
|
|
var authCfg struct {
|
|
Header string `json:"header"`
|
|
Key string `json:"key"`
|
|
}
|
|
_ = json.Unmarshal(tool.AuthConfig, &authCfg)
|
|
if authCfg.Header != "" && authCfg.Key != "" {
|
|
req.Header.Set(authCfg.Header, authCfg.Key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RunToolCallLoop executes the full LLM tool_call loop:
|
|
// 1. Send messages + tools to LLM
|
|
// 2. If LLM returns tool_calls, execute each and add results as tool messages
|
|
// 3. Re-send to LLM with tool results
|
|
// 4. Repeat until LLM returns a normal content response (no tool_calls)
|
|
// 5. Return final content
|
|
//
|
|
// maxIterations prevents infinite loops (default 5).
|
|
func (s *ToolExecutionService) RunToolCallLoop(
|
|
ctx context.Context,
|
|
accountID uint,
|
|
messages []llm.ChatMessage,
|
|
modelName string,
|
|
temperature float64,
|
|
maxTokens int,
|
|
maxIterations int,
|
|
) (string, error) {
|
|
if s.llmProvider == nil {
|
|
return "", fmt.Errorf("LLM provider not configured")
|
|
}
|
|
if maxIterations <= 0 {
|
|
maxIterations = 5
|
|
}
|
|
|
|
// Get tools for this account
|
|
tools, err := s.GetToolsForAccount(ctx, accountID)
|
|
if err != nil {
|
|
applogger.L().Warnf("ToolExecutionService: failed to get tools: %v, continuing without tools", err)
|
|
tools = nil
|
|
}
|
|
|
|
for iteration := 0; iteration < maxIterations; iteration++ {
|
|
req := llm.ChatRequest{
|
|
Model: modelName,
|
|
Messages: messages,
|
|
Temperature: temperature,
|
|
MaxTokens: maxTokens,
|
|
}
|
|
if len(tools) > 0 {
|
|
req.Tools = tools
|
|
}
|
|
|
|
resp, err := s.llmProvider.ChatCompletion(ctx, req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("LLM call iteration %d: %w", iteration, err)
|
|
}
|
|
if resp == nil || len(resp.Choices) == 0 {
|
|
return "", fmt.Errorf("empty LLM response at iteration %d", iteration)
|
|
}
|
|
|
|
choice := resp.Choices[0]
|
|
|
|
// If finish_reason is "tool_calls" or message has tool_calls, execute them
|
|
if len(choice.Message.ToolCalls) > 0 {
|
|
// Add assistant message with tool_calls to conversation
|
|
messages = append(messages, llm.ChatMessage{
|
|
Role: "assistant",
|
|
ToolCalls: choice.Message.ToolCalls,
|
|
Content: choice.Message.Content,
|
|
})
|
|
|
|
// Execute each tool call and add results
|
|
for _, call := range choice.Message.ToolCalls {
|
|
result, execErr := s.ExecuteToolCall(ctx, accountID, call)
|
|
if execErr != nil {
|
|
applogger.L().Errorf("ToolExecutionService: tool %s failed: %v", call.Function.Name, execErr)
|
|
result = fmt.Sprintf(`{"error": "%s"}`, escapeJSONString(execErr.Error()))
|
|
}
|
|
|
|
messages = append(messages, llm.ChatMessage{
|
|
Role: "tool",
|
|
Content: result,
|
|
ToolCallID: call.ID,
|
|
Name: call.Function.Name,
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
|
|
// No tool_calls — this is the final answer
|
|
return choice.Message.Content, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("tool call loop exceeded %d iterations", maxIterations)
|
|
}
|
|
|
|
func escapeJSONString(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
switch r {
|
|
case '"':
|
|
b.WriteString(`\"`)
|
|
case '\\':
|
|
b.WriteString(`\\`)
|
|
case '\n':
|
|
b.WriteString(`\n`)
|
|
case '\t':
|
|
b.WriteString(`\t`)
|
|
case '\r':
|
|
b.WriteString(`\r`)
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|