Phase 3.2: Function Calling — tool_call loop for AI services
- 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
This commit is contained in:
@@ -593,6 +593,10 @@ func Bootstrap(env string) (*App, error) {
|
||||
copilotService.SetWorkerPool(workerPool)
|
||||
captainConversationService := service.NewCaptainConversationService(db, llmProvider)
|
||||
captainConversationService.SetWorkerPool(workerPool)
|
||||
|
||||
// Tool execution service — LLM function calling (tool_call loop)
|
||||
toolExecutionService := service.NewToolExecutionService(captainCustomToolRepo, llmProvider)
|
||||
captainConversationService.SetToolExecutionService(toolExecutionService)
|
||||
copilotContextService := service.NewCopilotContextService(messageRepo, conversationRepo, contactRepo, llmProvider)
|
||||
captainTaskService := service.NewCaptainTaskService(captainAssistantRepo, captainAssistantResponseRepo, captainCustomToolRepo, conversationRepo, messageRepo, llmProvider, copilotContextService, copilotSuggestionRepo)
|
||||
conversationInsightService := service.NewConversationInsightService(conversationRepo, messageRepo, captainAssistantRepo, llmProvider)
|
||||
|
||||
@@ -42,8 +42,24 @@ type ChatRequest struct {
|
||||
|
||||
// ChatMessage represents a single message in a chat conversation.
|
||||
type ChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // assistant message: tool calls initiated by the model
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // tool role message: ID of the tool call this responds to
|
||||
Name string `json:"name,omitempty"` // tool role message: name of the tool
|
||||
}
|
||||
|
||||
// ToolCall represents a tool call requested by the LLM.
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // always "function"
|
||||
Function ToolCallFunction `json:"function"`
|
||||
}
|
||||
|
||||
// ToolCallFunction holds the function name and arguments for a tool call.
|
||||
type ToolCallFunction struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"` // JSON string of arguments
|
||||
}
|
||||
|
||||
// ToolDefinition represents a tool that the model can call.
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -46,12 +47,19 @@ type CaptainConversationService struct {
|
||||
llmProvider llm.Provider
|
||||
backend CaptainConversationResponseBackend
|
||||
worker *worker.WorkerPool
|
||||
// toolExecSvc enables LLM function calling (tool_call loop). nil = tools disabled.
|
||||
toolExecSvc *ToolExecutionService
|
||||
}
|
||||
|
||||
func NewCaptainConversationService(db *gorm.DB, llmProvider llm.Provider) *CaptainConversationService {
|
||||
return &CaptainConversationService{db: db, llmProvider: llmProvider}
|
||||
}
|
||||
|
||||
// SetToolExecutionService injects the tool execution service for function calling.
|
||||
func (s *CaptainConversationService) SetToolExecutionService(svc *ToolExecutionService) {
|
||||
s.toolExecSvc = svc
|
||||
}
|
||||
|
||||
func (s *CaptainConversationService) SetResponseBackend(backend CaptainConversationResponseBackend) {
|
||||
s.backend = backend
|
||||
}
|
||||
@@ -152,6 +160,17 @@ func (s *CaptainConversationService) generateConversationResponse(ctx context.Co
|
||||
temperature = 0.7
|
||||
}
|
||||
|
||||
// If tool execution service is available, run the full tool_call loop
|
||||
if s.toolExecSvc != nil {
|
||||
content, err := s.toolExecSvc.RunToolCallLoop(ctx, accountID, messages, modelName, temperature, 1024, 5)
|
||||
if err != nil {
|
||||
applogger.L().Warnf("Tool call loop failed, falling back to plain LLM: %v", err)
|
||||
// Fall through to plain LLM call below
|
||||
} else if strings.TrimSpace(content) != "" {
|
||||
return &CaptainConversationResponse{Content: content}, nil
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{Model: modelName, Messages: messages, Temperature: temperature, MaxTokens: 1024})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate captain conversation response: %w", err)
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user