Refactor AI Agent configuration and workflow handling
- Removed runtime mode handling from AIAgentConfigWorkbench and related components. - Updated tests to reflect changes in AI Agent policy copy and configuration. - Changed terminology from "workflow" to "revision" in various components and API responses. - Simplified agent binding logic in channel editing. - Cleaned up unused variables and types related to runtime modes. - Updated localization files for consistency with new terminology.
This commit is contained in:
@@ -0,0 +1,826 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ai "agent-desk/internal/ai"
|
||||
"agent-desk/internal/ai/runtime/instruction"
|
||||
"agent-desk/internal/ai/runtime/readtools"
|
||||
"agent-desk/internal/ai/runtime/retrievers"
|
||||
runtimetooling "agent-desk/internal/ai/runtime/tooling"
|
||||
workflowexecutor "agent-desk/internal/ai/runtime/workflow"
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
svc "agent-desk/internal/services"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
// AgentLoopEngine is the only Agent runtime. The model chooses among the
|
||||
// Agent's published Skills, Workflows, knowledge capabilities, and MCP tools.
|
||||
type AgentLoopEngine struct {
|
||||
history func(int64, int) []models.Message
|
||||
retrieve func(context.Context, models.AIAgent, string) (string, int, error)
|
||||
loop func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error)
|
||||
}
|
||||
|
||||
func NewAgentLoopEngine() *AgentLoopEngine {
|
||||
return &AgentLoopEngine{
|
||||
history: func(conversationID int64, limit int) []models.Message {
|
||||
items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, limit, "", "")
|
||||
return items
|
||||
},
|
||||
retrieve: retrieveAgentLoopKnowledge,
|
||||
loop: einoAgentLoop,
|
||||
}
|
||||
}
|
||||
|
||||
func newAgentLoopEngineWithLoop(loop func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error)) *AgentLoopEngine {
|
||||
engine := NewAgentLoopEngine()
|
||||
engine.loop = loop
|
||||
return engine
|
||||
}
|
||||
|
||||
func (e *AgentLoopEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) {
|
||||
startedAt := time.Now()
|
||||
req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content)
|
||||
snapshot, err := svc.AgentRevisionService.ResolvePublishedSnapshot(req.AIAgent, req.AIConfig)
|
||||
if err != nil {
|
||||
_, _ = writeAgentLoopRun(req, startedAt, nil, "", 0, 0, nil, agentLoopSkillContext{}, agentLoopResponsePolicy{}, nil, err, false, nil)
|
||||
return nil, err
|
||||
}
|
||||
req.AIAgent = snapshot.Agent
|
||||
req.AIConfig = snapshot.AIConfig
|
||||
turn := e.prepareTurn(ctx, req, snapshot)
|
||||
var toolCalls []svc.AgentLoopToolCallInput
|
||||
state := agentLoopExecutionState{}
|
||||
loopResult, loopErr := e.loop(ctx, req.AIConfig, turn.SystemPrompt, turn.UserPrompt, []ai.ToolDefinition{agentLoopToolSearchDefinition()}, req.AIAgent.MaxSteps,
|
||||
e.toolSearchExecutor(req, turn, &state, &toolCalls))
|
||||
if state.Interrupted != nil {
|
||||
result := state.Interrupted
|
||||
runID, recordErr := writeAgentLoopRun(req, startedAt, &ai.ChatCompletionResult{Content: result.ReplyText, ModelName: req.AIConfig.ModelName}, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, state.SkillContext, turn.ResponsePolicy, toolCalls, nil, true, state.WorkflowSteps)
|
||||
if recordErr != nil {
|
||||
return nil, recordErr
|
||||
}
|
||||
result.AgentRunID = runID
|
||||
return result, nil
|
||||
}
|
||||
if loopErr != nil {
|
||||
_, _ = writeAgentLoopRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, state.SkillContext, turn.ResponsePolicy, toolCalls, loopErr, false, state.WorkflowSteps)
|
||||
return nil, loopErr
|
||||
}
|
||||
if loopResult == nil {
|
||||
err = errorsx.InvalidParam("agent loop returned an empty result")
|
||||
_, _ = writeAgentLoopRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, state.SkillContext, turn.ResponsePolicy, toolCalls, err, false, state.WorkflowSteps)
|
||||
return nil, err
|
||||
}
|
||||
result := &loopResult.ChatCompletionResult
|
||||
if strings.TrimSpace(result.Content) == "" {
|
||||
err = errorsx.InvalidParam("Agent Loop returned an empty reply")
|
||||
_, _ = writeAgentLoopRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, state.SkillContext, turn.ResponsePolicy, toolCalls, err, false, state.WorkflowSteps)
|
||||
return nil, err
|
||||
}
|
||||
result.Content, err = aitooling.NormalizeCustomerReply(result.Content)
|
||||
if err != nil {
|
||||
_, _ = writeAgentLoopRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, state.SkillContext, turn.ResponsePolicy, toolCalls, err, false, state.WorkflowSteps)
|
||||
return nil, err
|
||||
}
|
||||
runID, recordErr := writeAgentLoopRun(req, startedAt, result, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, state.SkillContext, turn.ResponsePolicy, toolCalls, nil, false, state.WorkflowSteps)
|
||||
if recordErr != nil {
|
||||
return nil, recordErr
|
||||
}
|
||||
trace, _ := json.Marshal(map[string]any{
|
||||
"runtime": "agent-loop",
|
||||
"historyMessageCount": turn.HistoryCount,
|
||||
"retrieverCount": turn.RetrieverCount,
|
||||
"skillID": state.SkillContext.SkillID(),
|
||||
"responsePolicyAction": turn.ResponsePolicy.Action,
|
||||
"responsePolicyReason": turn.ResponsePolicy.Reason,
|
||||
"debug": req.Debug,
|
||||
})
|
||||
return &RunResult{
|
||||
Status: "completed",
|
||||
ReplyText: strings.TrimSpace(result.Content),
|
||||
ModelName: result.ModelName,
|
||||
PromptTokens: result.PromptTokens,
|
||||
CompletionTokens: result.CompletionTokens,
|
||||
HistoryMessageCount: turn.HistoryCount,
|
||||
RetrieverCount: turn.RetrieverCount,
|
||||
PlannedSkillID: state.SkillContext.SkillID(),
|
||||
PlannedSkillName: state.SkillContext.SkillName(),
|
||||
SkillAllowedToolCodes: append([]string(nil), state.SkillContext.AllowedToolCodes...),
|
||||
ToolCallCount: len(toolCalls),
|
||||
InvokedToolCodes: agentLoopInvokedToolCodes(toolCalls),
|
||||
WorkflowRunID: state.WorkflowRunID,
|
||||
AgentRunID: runID,
|
||||
HandoffRequested: turn.ResponsePolicy.RequestHandoff && !req.Debug,
|
||||
TraceData: string(trace),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *AgentLoopEngine) buildUserPrompt(req RunInput) (string, int) {
|
||||
limit := req.AIAgent.ContextWindow
|
||||
if limit <= 0 {
|
||||
limit = 12
|
||||
}
|
||||
if limit > 20 {
|
||||
limit = 20
|
||||
}
|
||||
items := []models.Message(nil)
|
||||
if e.history != nil && req.Conversation.ID > 0 {
|
||||
// The triggering customer message is already persisted in most reply
|
||||
// paths. Fetch one extra item so it does not consume history capacity.
|
||||
items = e.history(req.Conversation.ID, limit+1)
|
||||
}
|
||||
lines := make([]string, 0, len(items)+2)
|
||||
for _, item := range items {
|
||||
if item.ID == req.UserMessage.ID || strings.TrimSpace(item.Content) == "" {
|
||||
continue
|
||||
}
|
||||
role := agentLoopMessageRole(item)
|
||||
if role == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, role+": "+utils.BuildRuntimeMessageText(item.MessageType, item.Content))
|
||||
}
|
||||
if len(lines) > limit {
|
||||
lines = lines[len(lines)-limit:]
|
||||
}
|
||||
current := strings.TrimSpace(req.UserMessage.Content)
|
||||
customerContext := buildAgentLoopCustomerContext(req.Conversation)
|
||||
if len(lines) == 0 && customerContext == "" {
|
||||
return current, 0
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
if customerContext != "" {
|
||||
parts = append(parts, "Customer context:\n"+customerContext)
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
parts = append(parts, "Conversation history:\n"+strings.Join(lines, "\n"))
|
||||
}
|
||||
parts = append(parts, "Current customer message:\n"+current)
|
||||
return strings.Join(parts, "\n\n"), len(lines)
|
||||
}
|
||||
|
||||
func buildAgentLoopCustomerContext(conversation models.Conversation) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if name := strings.TrimSpace(conversation.CustomerName); name != "" {
|
||||
parts = append(parts, "Customer: "+name)
|
||||
}
|
||||
if summary := strings.TrimSpace(conversation.LastMessageSummary); summary != "" {
|
||||
parts = append(parts, "Recent summary: "+summary)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func agentLoopMessageRole(message models.Message) string {
|
||||
switch message.SenderType {
|
||||
case "customer":
|
||||
return "Customer"
|
||||
case "ai", "agent":
|
||||
return "Assistant"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AgentLoopEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
|
||||
interrupt := svc.ConversationInterruptService.GetByCheckPointID(req.CheckPointID)
|
||||
if interrupt == nil || strings.TrimSpace(interrupt.RequestData) == "" {
|
||||
return nil, errorsx.InvalidParam("Agent Loop checkpoint does not exist")
|
||||
}
|
||||
snapshot, err := svc.AgentRevisionService.ResolvePublishedSnapshot(req.AIAgent, req.AIConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.AIAgent, req.AIConfig = snapshot.Agent, snapshot.AIConfig
|
||||
if interrupt.WorkflowRunID > 0 {
|
||||
workflowRun, _ := svc.AIWorkflowService.GetRunDetail(interrupt.WorkflowRunID)
|
||||
if workflowRun == nil {
|
||||
return nil, errorsx.InvalidParam("Workflow run does not exist")
|
||||
}
|
||||
workflow, err := resolveWorkflowVersion(workflowRun.WorkflowVersionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := workflowexecutor.NewExecutor().Resume(ctx, workflowexecutor.Input{
|
||||
Definition: workflow.Definition, Conversation: req.Conversation, UserMessage: req.UserMessage,
|
||||
AIAgent: req.AIAgent, AIConfig: req.AIConfig, Debug: req.Debug,
|
||||
}, interrupt.RequestData, firstAgentLoopResumeText(req.ResumeData))
|
||||
if result != nil {
|
||||
if _, persistErr := writeWorkflowRunWithExistingID(RunInput{
|
||||
Conversation: req.Conversation, UserMessage: req.UserMessage, AIAgent: req.AIAgent, AIConfig: req.AIConfig, Debug: req.Debug,
|
||||
}, workflow, result, errorString(err), interrupt.WorkflowRunID); persistErr != nil {
|
||||
return nil, persistErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret := toWorkflowResult(result, req.AIConfig.ModelName, workflow, interrupt.WorkflowRunID)
|
||||
ret.AgentRunID = interrupt.AgentRunID
|
||||
if err := recordAgentLoopResume(interrupt.AgentRunID, interrupt.WorkflowRunID, ret.Status, ret.ReplyText, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
var checkpoint agentLoopMCPCheckpoint
|
||||
if err := json.Unmarshal([]byte(interrupt.RequestData), &checkpoint); err != nil {
|
||||
return nil, errorsx.InvalidParam("invalid MCP checkpoint data")
|
||||
}
|
||||
if !isAgentLoopConfirmation(firstAgentLoopResumeText(req.ResumeData)) {
|
||||
ret := &RunResult{
|
||||
Status: "completed", ReplyText: "操作已取消。", ModelName: req.AIConfig.ModelName,
|
||||
AgentRunID: interrupt.AgentRunID,
|
||||
}
|
||||
return ret, recordAgentLoopResume(interrupt.AgentRunID, 0, ret.Status, ret.ReplyText, nil)
|
||||
}
|
||||
tool, err := configuredMCPTool(req.AIAgent.AllowedMCPTools, checkpoint.ToolCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
policy := parseAgentLoopToolPolicy(req.AIAgent.ToolPolicy)
|
||||
executionPolicy := aitooling.Policy{
|
||||
AllowedToolCodes: []string{checkpoint.ToolCode}, AllowedRiskLevels: policy.AllowedRiskLevels,
|
||||
MaxTotalCalls: 1, MaxArgumentBytes: policy.MaxArgumentBytes, Confirmed: true,
|
||||
}
|
||||
definition := aitooling.Definition{
|
||||
Code: checkpoint.ToolCode, Name: tool.Title, RiskLevel: tool.RiskLevel, RequireConfirmation: tool.RequireConfirmation,
|
||||
}
|
||||
if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{
|
||||
Definition: definition, Arguments: checkpoint.Arguments, Policy: executionPolicy,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startedAt := time.Now()
|
||||
_, result, err := aitooling.DefaultMCPExecutor.Execute(ctx, checkpoint.ToolCode, checkpoint.Arguments, executionPolicy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
argumentsJSON, _ := json.Marshal(checkpoint.Arguments)
|
||||
toolCall := &svc.AgentLoopToolCallInput{
|
||||
ToolCode: checkpoint.ToolCode, RiskLevel: aitooling.RiskLevelWrite, RequireConfirm: true, Status: "completed",
|
||||
ArgumentsPreview: aitooling.SanitizePreview(string(argumentsJSON)),
|
||||
ResultPreview: runtimetooling.BuildReducedToolResultSummary(result),
|
||||
DurationMS: int(time.Since(startedAt).Milliseconds()),
|
||||
}
|
||||
ret := &RunResult{
|
||||
Status: "completed", ReplyText: "操作已执行:" + toolCall.ResultPreview,
|
||||
ModelName: req.AIConfig.ModelName, AgentRunID: interrupt.AgentRunID, ToolCallCount: 1,
|
||||
InvokedToolCodes: []string{tool.ToolCode},
|
||||
}
|
||||
return ret, recordAgentLoopResume(interrupt.AgentRunID, 0, ret.Status, ret.ReplyText, toolCall)
|
||||
}
|
||||
|
||||
func recordAgentLoopResume(agentRunID, workflowRunID int64, status, replyText string, toolCall *svc.AgentLoopToolCallInput) error {
|
||||
return sqls.WithTransaction(func(tx *sqls.TxContext) error {
|
||||
return svc.AgentRunService.RecordResume(tx.Tx, agentRunID, workflowRunID, status, replyText, toolCall)
|
||||
})
|
||||
}
|
||||
|
||||
func firstAgentLoopResumeText(data map[string]string) string {
|
||||
for _, value := range data {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isAgentLoopConfirmation(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "确认", "确认执行", "同意", "继续", "是", "yes", "y", "confirm", "approve", "approved":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AgentLoopEngine) retrieveKnowledge(ctx context.Context, agent models.AIAgent, query string) (string, int, error) {
|
||||
if e.retrieve == nil || len(utils.SplitInt64s(agent.KnowledgeIDs)) == 0 {
|
||||
return "", 0, nil
|
||||
}
|
||||
return e.retrieve(ctx, agent, query)
|
||||
}
|
||||
|
||||
type agentLoopSkillContext struct {
|
||||
Skill *models.SkillDefinition
|
||||
AllowedToolCodes []string
|
||||
}
|
||||
|
||||
type agentLoopResponsePolicy struct {
|
||||
Action string
|
||||
Reason string
|
||||
RequestHandoff bool
|
||||
}
|
||||
|
||||
func evaluateAgentLoopResponsePolicy(agent models.AIAgent, knowledgeContext string, retrieveErr error) agentLoopResponsePolicy {
|
||||
if len(utils.SplitInt64s(agent.KnowledgeIDs)) == 0 || strings.TrimSpace(knowledgeContext) != "" && retrieveErr == nil {
|
||||
return agentLoopResponsePolicy{}
|
||||
}
|
||||
if retrieveErr != nil {
|
||||
return agentLoopKnowledgeFallbackPolicy(agent, "retrieval_unavailable", "knowledge_retrieve_error")
|
||||
}
|
||||
// Knowledge retrieval is an evidence signal, not a replacement for the
|
||||
// model's ability to handle greetings and other non-factual conversation.
|
||||
return agentLoopKnowledgeFallbackPolicy(agent, "evidence_required", "knowledge_evidence_missing")
|
||||
}
|
||||
|
||||
func agentLoopKnowledgeFallbackPolicy(agent models.AIAgent, action, reason string) agentLoopResponsePolicy {
|
||||
return agentLoopResponsePolicy{
|
||||
Action: action, Reason: reason,
|
||||
RequestHandoff: agent.FallbackMode == enums.AIAgentFallbackModeHandoff,
|
||||
}
|
||||
}
|
||||
|
||||
func (c agentLoopSkillContext) SkillID() int64 {
|
||||
if c.Skill == nil {
|
||||
return 0
|
||||
}
|
||||
return c.Skill.ID
|
||||
}
|
||||
|
||||
func (c agentLoopSkillContext) SkillName() string {
|
||||
if c.Skill == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(c.Skill.Name)
|
||||
}
|
||||
|
||||
func parseSkillToolWhitelist(raw string) []string {
|
||||
var items []string
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &items) != nil {
|
||||
return nil
|
||||
}
|
||||
ret := make([]string, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
item = toolx.NormalizeToolCodeAlias(strings.TrimSpace(item))
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[item]; exists {
|
||||
continue
|
||||
}
|
||||
seen[item] = struct{}{}
|
||||
ret = append(ret, item)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
type agentLoopToolSearchRequest struct {
|
||||
ToolCode string `json:"toolCode"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
type agentLoopToolPolicy struct {
|
||||
MaxTotalCalls int `json:"maxTotalCalls"`
|
||||
MaxArgumentBytes int `json:"maxArgumentBytes"`
|
||||
AllowedRiskLevels []string `json:"allowedRiskLevels"`
|
||||
}
|
||||
|
||||
func parseAgentLoopToolPolicy(raw string) agentLoopToolPolicy {
|
||||
policy := agentLoopToolPolicy{MaxTotalCalls: 3, MaxArgumentBytes: 32 * 1024}
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &policy) != nil {
|
||||
return policy
|
||||
}
|
||||
if policy.MaxTotalCalls <= 0 || policy.MaxTotalCalls > 8 {
|
||||
policy.MaxTotalCalls = 3
|
||||
}
|
||||
if policy.MaxArgumentBytes <= 0 || policy.MaxArgumentBytes > 64*1024 {
|
||||
policy.MaxArgumentBytes = 32 * 1024
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func agentLoopToolSearchDefinition() ai.ToolDefinition {
|
||||
return ai.ToolDefinition{
|
||||
Name: "tool_search",
|
||||
Description: "Activate a configured Skill or execute a configured Workflow, builtin capability, or MCP tool. Pass the exact capability code and arguments.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"toolCode": map[string]any{"type": "string"},
|
||||
"arguments": map[string]any{"type": "object"},
|
||||
},
|
||||
"required": []string{"toolCode", "arguments"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func agentLoopSafeBuiltinCodes() []string {
|
||||
return []string{
|
||||
toolx.BuiltinConversationContext.Code,
|
||||
toolx.BuiltinKnowledgeRetrieve.Code,
|
||||
toolx.GraphTriageServiceRequest.Code,
|
||||
toolx.GraphAnalyzeConversation.Code,
|
||||
toolx.GraphPrepareTicketDraft.Code,
|
||||
}
|
||||
}
|
||||
|
||||
func agentLoopSkillCode(id int64) string {
|
||||
return "skill/" + strconv.FormatInt(id, 10)
|
||||
}
|
||||
|
||||
func agentLoopWorkflowCode(versionID int64) string {
|
||||
return "workflow/" + strconv.FormatInt(versionID, 10)
|
||||
}
|
||||
|
||||
func agentLoopInvokedToolCodes(items []svc.AgentLoopToolCallInput) []string {
|
||||
ret := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if code := strings.TrimSpace(item.ToolCode); code != "" {
|
||||
ret = append(ret, code)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func errorString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
type agentLoopExecutionState struct {
|
||||
SkillContext agentLoopSkillContext
|
||||
WorkflowRunID int64
|
||||
WorkflowSteps []svc.AgentLoopStepInput
|
||||
Interrupted *RunResult
|
||||
}
|
||||
|
||||
type agentLoopInterruptError struct {
|
||||
reason string
|
||||
}
|
||||
|
||||
func (e *agentLoopInterruptError) Error() string {
|
||||
return e.reason
|
||||
}
|
||||
|
||||
func (e *AgentLoopEngine) toolSearchExecutor(runInput RunInput, turn agentLoopTurn, state *agentLoopExecutionState, records *[]svc.AgentLoopToolCallInput) ai.ToolCallExecutor {
|
||||
return func(ctx context.Context, call ai.ToolCall) (string, error) {
|
||||
startedAt := time.Now()
|
||||
if call.Name != "tool_search" {
|
||||
return "", fmt.Errorf("unsupported agent loop tool: %s", call.Name)
|
||||
}
|
||||
var toolRequest agentLoopToolSearchRequest
|
||||
if err := json.Unmarshal([]byte(call.Arguments), &toolRequest); err != nil {
|
||||
return "", fmt.Errorf("invalid tool_search arguments: %w", err)
|
||||
}
|
||||
toolCode := strings.TrimSpace(toolRequest.ToolCode)
|
||||
if !slices.Contains(turn.AllowedTools, toolCode) {
|
||||
return "", fmt.Errorf("capability is not configured for this Agent: %s", toolCode)
|
||||
}
|
||||
if state.SkillContext.Skill != nil && !strings.HasPrefix(toolCode, "skill/") &&
|
||||
!slices.Contains(state.SkillContext.AllowedToolCodes, toolx.NormalizeToolCodeAlias(toolCode)) {
|
||||
return "", fmt.Errorf("capability is not allowed by the active Skill: %s", toolCode)
|
||||
}
|
||||
policy := aitooling.Policy{
|
||||
AllowedToolCodes: turn.AllowedTools, SkillAllowedToolCodes: state.SkillContext.AllowedToolCodes, AllowedRiskLevels: turn.ToolPolicy.AllowedRiskLevels,
|
||||
CallCount: agentLoopToolCallCount(*records, toolCode),
|
||||
TotalCallCount: len(*records),
|
||||
MaxTotalCalls: turn.ToolPolicy.MaxTotalCalls,
|
||||
MaxArgumentBytes: turn.ToolPolicy.MaxArgumentBytes,
|
||||
Confirmed: false,
|
||||
}
|
||||
definition := aitooling.Definition{Code: toolCode, RiskLevel: aitooling.RiskLevelRead}
|
||||
var resultPreview string
|
||||
var err error
|
||||
switch {
|
||||
case strings.HasPrefix(toolCode, "skill/"):
|
||||
resultPreview, err = activateAgentLoopSkill(toolCode, turn.Skills, state)
|
||||
case strings.HasPrefix(toolCode, "workflow/"):
|
||||
definition.RiskLevel = aitooling.RiskLevelWrite
|
||||
definition.RequireConfirmation = true
|
||||
workflowPolicy := policy
|
||||
workflowPolicy.Confirmed = true
|
||||
if err = aitooling.DefaultRegistry.Authorize(definition, workflowPolicy); err == nil {
|
||||
resultPreview, err = executeAgentLoopWorkflow(ctx, runInput, toolCode, turn.Workflows, state)
|
||||
}
|
||||
default:
|
||||
definition, resultPreview, err = executeAgentLoopReadTool(ctx, runInput.Conversation, runInput.AIAgent, toolCode, toolRequest.Arguments, policy)
|
||||
if err != nil && definition.Code == "" {
|
||||
definition, resultPreview, err = executeAgentLoopMCP(ctx, runInput, toolCode, toolRequest.Arguments, policy, state)
|
||||
}
|
||||
}
|
||||
durationMS := int(time.Since(startedAt).Milliseconds())
|
||||
record := svc.AgentLoopToolCallInput{
|
||||
ToolCode: toolCode, Status: "completed", ArgumentsPreview: aitooling.SanitizePreview(call.Arguments), DurationMS: durationMS,
|
||||
}
|
||||
if definition.Code != "" {
|
||||
record.ToolCode = definition.Code
|
||||
record.RiskLevel = definition.RiskLevel
|
||||
record.RequireConfirm = definition.RequireConfirmation
|
||||
}
|
||||
if err != nil {
|
||||
record.Status = "failed"
|
||||
var interruptErr *agentLoopInterruptError
|
||||
if errors.As(err, &interruptErr) {
|
||||
record.Status = "interrupted"
|
||||
}
|
||||
record.ErrorMessage = err.Error()
|
||||
*records = append(*records, record)
|
||||
return "", err
|
||||
}
|
||||
record.ResultPreview = aitooling.SanitizePreview(resultPreview)
|
||||
*records = append(*records, record)
|
||||
return record.ResultPreview, nil
|
||||
}
|
||||
}
|
||||
|
||||
func activateAgentLoopSkill(code string, skills map[int64]models.SkillDefinition, state *agentLoopExecutionState) (string, error) {
|
||||
id, err := strconv.ParseInt(strings.TrimPrefix(code, "skill/"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return "", errorsx.InvalidParam("invalid Skill capability code")
|
||||
}
|
||||
skill, ok := skills[id]
|
||||
if !ok {
|
||||
return "", errorsx.InvalidParam("Skill is not configured for this Agent")
|
||||
}
|
||||
state.SkillContext = agentLoopSkillContext{Skill: &skill, AllowedToolCodes: parseSkillToolWhitelist(skill.ToolWhitelist)}
|
||||
return instruction.BuildSkillDocument(&skill, nil), nil
|
||||
}
|
||||
|
||||
func executeAgentLoopWorkflow(ctx context.Context, runInput RunInput, code string, bindings map[int64]svc.AgentRevisionWorkflowBinding, state *agentLoopExecutionState) (string, error) {
|
||||
versionID, err := strconv.ParseInt(strings.TrimPrefix(code, "workflow/"), 10, 64)
|
||||
if err != nil || versionID <= 0 {
|
||||
return "", errorsx.InvalidParam("invalid Workflow capability code")
|
||||
}
|
||||
if _, ok := bindings[versionID]; !ok {
|
||||
return "", errorsx.InvalidParam("Workflow is not configured for this Agent")
|
||||
}
|
||||
workflow, err := resolveWorkflowVersion(versionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result, err := workflowexecutor.NewExecutor().Execute(ctx, workflowexecutor.Input{
|
||||
Definition: workflow.Definition, Conversation: runInput.Conversation, UserMessage: runInput.UserMessage,
|
||||
AIAgent: runInput.AIAgent, AIConfig: runInput.AIConfig, Debug: runInput.Debug,
|
||||
})
|
||||
if result == nil {
|
||||
return "", err
|
||||
}
|
||||
runID, persistErr := writeWorkflowRun(runInput, workflow, result, errorString(err))
|
||||
if persistErr != nil {
|
||||
return "", persistErr
|
||||
}
|
||||
state.WorkflowRunID = runID
|
||||
state.WorkflowSteps = append(state.WorkflowSteps, svc.AgentLoopStepInput{
|
||||
StepType: "workflow", StepCode: code, WorkflowRunID: runID, Status: workflowAgentRunStatus(result.Status, errorString(err)),
|
||||
InputPreview: strings.TrimSpace(runInput.UserMessage.Content), OutputPreview: strings.Join(result.NodePath, ","), ErrorMessage: errorString(err),
|
||||
})
|
||||
if result.Interrupted {
|
||||
state.Interrupted = toWorkflowResult(result, runInput.AIConfig.ModelName, workflow, runID)
|
||||
return "", &agentLoopInterruptError{reason: "Agent Loop interrupted for Workflow confirmation"}
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, _ := json.Marshal(map[string]any{"workflowRunId": runID, "status": result.Status, "replyText": result.ReplyText})
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func executeAgentLoopMCP(ctx context.Context, runInput RunInput, toolCode string, arguments map[string]any, policy aitooling.Policy, state *agentLoopExecutionState) (aitooling.Definition, string, error) {
|
||||
configured, err := configuredMCPTool(runInput.AIAgent.AllowedMCPTools, toolCode)
|
||||
if err != nil {
|
||||
return aitooling.Definition{}, "", err
|
||||
}
|
||||
definition := aitooling.Definition{Code: toolCode, Name: configured.Title, RiskLevel: configured.RiskLevel, RequireConfirmation: configured.RequireConfirmation}
|
||||
preflightPolicy := policy
|
||||
preflightPolicy.Confirmed = true
|
||||
if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{
|
||||
Definition: definition, Arguments: arguments, Policy: preflightPolicy,
|
||||
}); err != nil {
|
||||
return definition, "", err
|
||||
}
|
||||
if definition.RiskLevel == aitooling.RiskLevelWrite && definition.RequireConfirmation {
|
||||
checkpoint := agentLoopMCPCheckpoint{ToolCode: toolCode, Arguments: arguments}
|
||||
data, _ := json.Marshal(checkpoint)
|
||||
checkPointID := fmt.Sprintf("tool:%d:%d", runInput.Conversation.ID, time.Now().UnixNano())
|
||||
state.Interrupted = &RunResult{
|
||||
Status: "interrupted", ReplyText: "请确认是否执行该操作。", CheckPointID: checkPointID, CheckPointData: string(data), Interrupted: true,
|
||||
Interrupts: []InterruptContextSummary{{Type: "tool_confirmation", ID: toolCode, InfoPreview: configured.Title}},
|
||||
}
|
||||
return definition, "", &agentLoopInterruptError{reason: "Agent Loop interrupted for MCP confirmation"}
|
||||
}
|
||||
policy.Confirmed = !definition.RequireConfirmation
|
||||
_, result, err := aitooling.DefaultMCPExecutor.Execute(ctx, toolCode, arguments, policy)
|
||||
return definition, runtimetooling.BuildReducedToolResultSummary(result), err
|
||||
}
|
||||
|
||||
type agentLoopMCPCheckpoint struct {
|
||||
ToolCode string `json:"toolCode"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
func configuredMCPTool(raw, toolCode string) (request.AIAgentMCPToolRequest, error) {
|
||||
items, err := toolx.ParseAgentMCPToolsJSON(raw)
|
||||
if err != nil {
|
||||
return request.AIAgentMCPToolRequest{}, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.ToolCode == toolCode {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("MCP tool is not configured for this Agent")
|
||||
}
|
||||
|
||||
func executeAgentLoopReadTool(ctx context.Context, conversation models.Conversation, agent models.AIAgent, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, string, error) {
|
||||
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
|
||||
if toolCode != toolx.BuiltinConversationContext.Code && toolCode != toolx.BuiltinKnowledgeRetrieve.Code && toolCode != toolx.GraphTriageServiceRequest.Code && toolCode != toolx.GraphAnalyzeConversation.Code && toolCode != toolx.GraphPrepareTicketDraft.Code {
|
||||
return aitooling.Definition{}, "", fmt.Errorf("tool is not a built-in read tool")
|
||||
}
|
||||
if toolCode == toolx.GraphTriageServiceRequest.Code || toolCode == toolx.GraphAnalyzeConversation.Code || toolCode == toolx.GraphPrepareTicketDraft.Code {
|
||||
return readtools.ExecuteGraphTool(ctx, conversation, toolCode, arguments, policy)
|
||||
}
|
||||
definition, err := aitooling.DefaultRegistry.Resolve(toolCode)
|
||||
if err != nil {
|
||||
return aitooling.Definition{}, "", err
|
||||
}
|
||||
if err := aitooling.DefaultRegistry.Authorize(definition, policy); err != nil {
|
||||
return definition, "", err
|
||||
}
|
||||
if definition.TimeoutMS > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond)
|
||||
defer cancel()
|
||||
}
|
||||
if toolCode == toolx.BuiltinKnowledgeRetrieve.Code {
|
||||
query, _ := arguments["query"].(string)
|
||||
contextText, count, err := retrieveAgentLoopKnowledge(ctx, agent, query)
|
||||
if err != nil {
|
||||
return definition, "", err
|
||||
}
|
||||
result, err := json.Marshal(map[string]any{"query": strings.TrimSpace(query), "resultCount": count, "context": contextText})
|
||||
return definition, string(result), err
|
||||
}
|
||||
result, err := json.Marshal(map[string]any{
|
||||
"conversationId": conversation.ID,
|
||||
"customerName": strings.TrimSpace(conversation.CustomerName),
|
||||
"lastMessageSummary": strings.TrimSpace(conversation.LastMessageSummary),
|
||||
"currentAssigneeId": conversation.CurrentAssigneeID,
|
||||
"recentMessages": agentLoopToolConversationMessages(conversation.ID),
|
||||
})
|
||||
if err != nil {
|
||||
return definition, "", err
|
||||
}
|
||||
return definition, string(result), nil
|
||||
}
|
||||
|
||||
func agentLoopToolConversationMessages(conversationID int64) []map[string]string {
|
||||
if conversationID <= 0 {
|
||||
return []map[string]string{}
|
||||
}
|
||||
items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, 6, "", "")
|
||||
ret := make([]map[string]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
role := agentLoopMessageRole(item)
|
||||
content := strings.TrimSpace(utils.BuildRuntimeMessageText(item.MessageType, item.Content))
|
||||
if role == "" || content == "" {
|
||||
continue
|
||||
}
|
||||
if runes := []rune(content); len(runes) > 240 {
|
||||
content = string(runes[:240]) + "..."
|
||||
}
|
||||
ret = append(ret, map[string]string{"role": role, "content": content})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func agentLoopToolCallCount(records []svc.AgentLoopToolCallInput, toolCode string) int {
|
||||
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
|
||||
count := 0
|
||||
for _, item := range records {
|
||||
if toolx.NormalizeToolCodeAlias(strings.TrimSpace(item.ToolCode)) == toolCode {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func retrieveAgentLoopKnowledge(ctx context.Context, agent models.AIAgent, query string) (string, int, error) {
|
||||
retrieved, err := retrievers.NewKnowledgeRetriever(agent, utils.SplitInt64s(agent.KnowledgeIDs)).RetrieveContext(ctx, query)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if retrieved == nil {
|
||||
return "", 0, nil
|
||||
}
|
||||
return strings.TrimSpace(retrieved.ContextText), len(retrieved.ContextResults), nil
|
||||
}
|
||||
|
||||
func buildAgentLoopSystemPrompt(agent models.AIAgent, hasKnowledgeBase bool, knowledgeContext string, retrieveErr error) string {
|
||||
prompt := strings.TrimSpace(agent.SystemPrompt)
|
||||
if prompt == "" {
|
||||
prompt = "You are a customer service assistant. Answer accurately, ask for clarification when evidence is insufficient, and do not invent facts."
|
||||
}
|
||||
if retrieveErr != nil {
|
||||
prompt += "\n\nKnowledge retrieval is temporarily unavailable for this message. You may answer greetings, acknowledgements, gratitude, farewells, and requests for clarification naturally. For product facts, policies, pricing, functions, procedures, timing, refunds, accounts, permissions, or after-sales questions, do not claim that any detail is verified. Explain that you cannot verify it now, ask one focused question when useful, or offer human handoff."
|
||||
} else if hasKnowledgeBase && strings.TrimSpace(knowledgeContext) == "" {
|
||||
prompt += "\n\nKnowledge retrieval found no supporting evidence for this message. You may answer greetings, acknowledgements, gratitude, farewells, and requests for clarification naturally. For product facts, policies, pricing, functions, procedures, timing, refunds, accounts, permissions, or after-sales questions, do not infer or invent an answer. State that the available information is insufficient, ask one focused question when useful, or offer human handoff."
|
||||
}
|
||||
if hasKnowledgeBase && (retrieveErr != nil || strings.TrimSpace(knowledgeContext) == "") {
|
||||
if fallback := strings.TrimSpace(agent.FallbackMessage); fallback != "" {
|
||||
prompt += "\nUse this configured fallback wording when knowledge evidence is insufficient: " + fallback
|
||||
}
|
||||
switch agent.FallbackMode {
|
||||
case enums.AIAgentFallbackModeSuggestRetry:
|
||||
prompt += "\nPrefer asking the customer for one specific missing detail."
|
||||
case enums.AIAgentFallbackModeHandoff:
|
||||
prompt += "\nTell the customer that a human handoff will be requested."
|
||||
default:
|
||||
prompt += "\nState plainly that the available knowledge is insufficient."
|
||||
}
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
func writeAgentLoopRun(req RunInput, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount int, retrieverCount int, retrieveErr error, skillContext agentLoopSkillContext, responsePolicy agentLoopResponsePolicy, toolCalls []svc.AgentLoopToolCallInput, cause error, interrupted bool, workflowSteps []svc.AgentLoopStepInput) (int64, error) {
|
||||
endedAt := time.Now()
|
||||
status := "completed"
|
||||
errorMessage := ""
|
||||
outputPreview := ""
|
||||
promptTokens := 0
|
||||
completionTokens := 0
|
||||
if interrupted {
|
||||
status = "interrupted"
|
||||
} else if cause != nil {
|
||||
status = "failed"
|
||||
errorMessage = cause.Error()
|
||||
} else if result != nil {
|
||||
outputPreview = strings.TrimSpace(result.Content)
|
||||
promptTokens = result.PromptTokens
|
||||
completionTokens = result.CompletionTokens
|
||||
}
|
||||
trace, _ := json.Marshal(map[string]any{"runtime": "agent-loop", "status": status, "historyMessageCount": historyCount, "retrieverCount": retrieverCount})
|
||||
additionalSteps := agentLoopAdditionalSteps(req, retrieverCount, retrieveErr, skillContext, responsePolicy)
|
||||
additionalSteps = append(additionalSteps, workflowSteps...)
|
||||
var runID int64
|
||||
err := sqls.WithTransaction(func(tx *sqls.TxContext) error {
|
||||
var recordErr error
|
||||
runID, recordErr = svc.AgentRunService.RecordAgentLoopRun(tx.Tx, svc.AgentLoopRunInput{
|
||||
ConversationID: req.Conversation.ID, AIAgentID: req.AIAgent.ID, AgentRevisionID: req.AIAgent.PublishedRevisionID,
|
||||
SourceMessageID: req.UserMessage.ID, WorkflowRunID: firstWorkflowStepRunID(workflowSteps), Status: status,
|
||||
PromptTokens: promptTokens, CompletionTokens: completionTokens, StartedAt: startedAt, EndedAt: &endedAt,
|
||||
ErrorMessage: errorMessage, TraceData: string(trace), StepType: "model", StepCode: "chat_completion",
|
||||
StepInputPreview: strings.TrimSpace(inputPreview), StepOutputPreview: outputPreview,
|
||||
AdditionalSteps: additionalSteps,
|
||||
ToolCalls: toolCalls,
|
||||
})
|
||||
return recordErr
|
||||
})
|
||||
return runID, err
|
||||
}
|
||||
|
||||
func firstWorkflowStepRunID(items []svc.AgentLoopStepInput) int64 {
|
||||
for _, item := range items {
|
||||
if item.WorkflowRunID > 0 {
|
||||
return item.WorkflowRunID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func agentLoopAdditionalSteps(req RunInput, retrieverCount int, retrieveErr error, skillContext agentLoopSkillContext, responsePolicy agentLoopResponsePolicy) []svc.AgentLoopStepInput {
|
||||
steps := make([]svc.AgentLoopStepInput, 0, 3)
|
||||
if skillContext.Skill != nil {
|
||||
steps = append(steps, svc.AgentLoopStepInput{
|
||||
StepType: "skill", StepCode: agentLoopSkillCode(skillContext.SkillID()), Status: "completed",
|
||||
InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "activated Skill: " + skillContext.SkillName(),
|
||||
})
|
||||
}
|
||||
if len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0 {
|
||||
status := "completed"
|
||||
errorMessage := ""
|
||||
if retrieveErr != nil {
|
||||
status = "failed"
|
||||
errorMessage = retrieveErr.Error()
|
||||
}
|
||||
steps = append(steps, svc.AgentLoopStepInput{
|
||||
StepType: "knowledge", StepCode: "knowledge_retrieve", Status: status,
|
||||
InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "retrieved context items: " + strconv.Itoa(retrieverCount), ErrorMessage: errorMessage,
|
||||
})
|
||||
}
|
||||
if responsePolicy.Reason != "" {
|
||||
policyCode := "knowledge_evidence"
|
||||
steps = append(steps, svc.AgentLoopStepInput{
|
||||
StepType: "policy", StepCode: policyCode, Status: "completed",
|
||||
InputPreview: responsePolicy.Reason, OutputPreview: responsePolicy.Action,
|
||||
})
|
||||
}
|
||||
return steps
|
||||
}
|
||||
Reference in New Issue
Block a user