From b2f36a20a0ec4f6f73f4897a7c2fef92aee0ffe8 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 8 Jul 2026 11:07:32 +0800 Subject: [PATCH] =?UTF-8?q?Phase=203.2:=20Function=20Calling=20=E2=80=94?= =?UTF-8?q?=20tool=5Fcall=20loop=20for=20AI=20services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/internal/app/bootstrap.go | 4 + backend/internal/llm/provider.go | 20 +- .../service/captain_conversation_service.go | 19 ++ .../service/tool_execution_service.go | 285 ++++++++++++++++++ 4 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 backend/internal/service/tool_execution_service.go diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index c727da1c..5caf90c5 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -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) diff --git a/backend/internal/llm/provider.go b/backend/internal/llm/provider.go index 499b6762..dc68151c 100644 --- a/backend/internal/llm/provider.go +++ b/backend/internal/llm/provider.go @@ -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. diff --git a/backend/internal/service/captain_conversation_service.go b/backend/internal/service/captain_conversation_service.go index c4edbe74..04d7fe55 100644 --- a/backend/internal/service/captain_conversation_service.go +++ b/backend/internal/service/captain_conversation_service.go @@ -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) diff --git a/backend/internal/service/tool_execution_service.go b/backend/internal/service/tool_execution_service.go new file mode 100644 index 00000000..954df908 --- /dev/null +++ b/backend/internal/service/tool_execution_service.go @@ -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() +}