* 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>
396 lines
12 KiB
Go
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, ¶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", 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()
|
|
}
|