Files
gochat/backend/internal/service/tool_execution_service.go
Rogeeandrogee 6c78820a1f H-338: close H-335 release blockers (#59)
* H-16: align takeover with channel AI workflow (#2)

* feat(conversations): complete manual AI takeover

* fix(conversations): align AI takeover flow with channel AI

* fix(conversations): close takeover review gaps

---------

Co-authored-by: Rogee <rogee@ipao.vip>

* feat(shangwutong): sync customer names back to channel (#3)

Co-authored-by: Rogee <rogee@ipao.vip>

* fix(shangwutong): close contact sync review gaps (#4)

Co-authored-by: Rogee <rogee@ipao.vip>

* H-28: harden Shangwutong CID sync (#5)

* fix(shangwutong): close contact sync review gaps

* fix(shangwutong): harden CID sync boundaries

---------

Co-authored-by: Rogee <rogee@ipao.vip>

* fix(conversations): sync AI takeover exit in realtime (#6)

Co-authored-by: Rogee <rogee@ipao.vip>

* test(shangwutong): cover CID rename reliability (#7)

Co-authored-by: Rogee <rogee@ipao.vip>

* H-43: fix WEB Captain takeover E2E flow (#8)

* test(shangwutong): cover CID rename reliability

* H-43: fix WEB Captain takeover flow

* H-48: preserve compatible provider model

* H-49: make Captain takeover atomic

* H-50: prevent duplicate widget initialization

---------

Co-authored-by: Rogee <rogee@ipao.vip>

* H-55: make Captain bindings atomic (#9)

Co-authored-by: Rogee <rogee@ipao.vip>

* H-60: harden Captain migration rollback and concurrency

* chore(agent): baseline — uncommitted work from the local directory

* H-335: add safe Captain skills and user deactivation

* H-338: close auth and Captain review blockers

* H-338: close assignment and session races

* H-338: close assignment and websocket invalidation gaps

* H-338: enforce assignment write invariants

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-20 10:21:19 +08:00

396 lines
12 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
skillRepo *repository.CaptainSkillRepo
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},
}
}
func (s *ToolExecutionService) SetCaptainSkillRepo(repo *repository.CaptainSkillRepo) {
s.skillRepo = repo
}
// 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, &params)
}
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", resp.StatusCode)
}
applogger.L().Infof("ToolExecutionService: tool %s returned status %d bytes=%d",
call.Function.Name, resp.StatusCode, len(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) {
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
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
}
return s.runToolCallLoop(ctx, accountID, messages, modelName, temperature, maxTokens, maxIterations, tools, func(ctx context.Context, call llm.ToolCall) (string, error) {
return s.ExecuteToolCall(ctx, accountID, call)
})
}
func (s *ToolExecutionService) RunAssistantToolCallLoop(
ctx context.Context,
scope CaptainToolScope,
messages []llm.ChatMessage,
modelName string,
temperature float64,
maxTokens int,
maxIterations int,
allowCustomTools bool,
) (string, bool, error) {
if s.skillRepo == nil {
if allowCustomTools {
content, err := s.RunToolCallLoop(ctx, scope.AccountID, messages, modelName, temperature, maxTokens, maxIterations)
return content, false, err
}
content, err := s.runToolCallLoop(ctx, scope.AccountID, messages, modelName, temperature, maxTokens, maxIterations, nil, nil)
return content, false, err
}
skills, err := s.skillRepo.ListActiveForAssistant(ctx, scope.AccountID, scope.AssistantID)
if err != nil {
return "", true, captainSkillRuntimeError("skill_catalog_unavailable")
}
if len(skills) == 0 {
if allowCustomTools {
content, err := s.RunToolCallLoop(ctx, scope.AccountID, messages, modelName, temperature, maxTokens, maxIterations)
return content, false, err
}
content, err := s.runToolCallLoop(ctx, scope.AccountID, messages, modelName, temperature, maxTokens, maxIterations, nil, nil)
return content, false, err
}
// External Skill content never authorizes side effects. Keep account HTTP
// tools out of the model-visible tool set whenever a Skill is bound.
allowCustomTools = false
ctx = llm.WithAccountFeature(ctx, scope.AccountID, "assistant")
actualModel := modelName
if resolver, ok := s.llmProvider.(interface {
ResolveChatModel(context.Context) (string, error)
}); ok {
actualModel, err = resolver.ResolveChatModel(ctx)
if err != nil {
return "", true, captainSkillRuntimeError("skill_model_unavailable")
}
}
if !captainSkillModelSupported(actualModel) {
return "", true, captainSkillRuntimeError("skill_model_unsupported")
}
tools := captainSkillTools()
if allowCustomTools {
customTools, err := s.GetToolsForAccount(ctx, scope.AccountID)
if err != nil {
return "", true, captainSkillRuntimeError("custom_tool_catalog_unavailable")
}
for _, tool := range customTools {
if tool.Function.Name == activateSkillToolName || tool.Function.Name == readSkillReferenceToolName {
return "", true, captainSkillRuntimeError("reserved_tool_name_conflict")
}
}
tools = append(tools, customTools...)
}
runtime := newCaptainSkillRuntime(scope, s.skillRepo)
messages = appendCaptainSkillCatalog(messages, skills)
content, err := s.runToolCallLoop(ctx, scope.AccountID, messages, modelName, temperature, maxTokens, maxIterations, tools, func(ctx context.Context, call llm.ToolCall) (string, error) {
if call.Function.Name == activateSkillToolName || call.Function.Name == readSkillReferenceToolName {
return runtime.execute(ctx, call)
}
if !allowCustomTools {
return "", captainSkillRuntimeError("skill_unknown_tool")
}
return s.ExecuteToolCall(ctx, scope.AccountID, call)
})
return content, true, err
}
func (s *ToolExecutionService) runToolCallLoop(
ctx context.Context,
accountID uint,
messages []llm.ChatMessage,
modelName string,
temperature float64,
maxTokens int,
maxIterations int,
tools []llm.ToolDefinition,
execute func(context.Context, llm.ToolCall) (string, error),
) (string, error) {
ctx = llm.WithAccountFeature(ctx, accountID, "assistant")
if s.llmProvider == nil {
return "", fmt.Errorf("LLM provider not configured")
}
if maxIterations <= 0 {
maxIterations = 5
}
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 {
if execute == nil {
return "", fmt.Errorf("tool %s is not available", call.Function.Name)
}
result, execErr := execute(ctx, call)
if execErr != nil {
if _, safeFailure := execErr.(captainSkillRuntimeError); safeFailure {
return "", execErr
}
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()
}