Files
ai-agent/internal/ai/application/runtime/agent_loop_engine.go
T

1113 lines
48 KiB
Go
Raw Normal View History

package runtime
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"slices"
"strconv"
"strings"
"time"
"code.tczkiot.com/wlw/ai-agent/contract"
ai "code.tczkiot.com/wlw/ai-agent/internal/ai"
"code.tczkiot.com/wlw/ai-agent/internal/ai/runtime/graphs"
"code.tczkiot.com/wlw/ai-agent/internal/ai/runtime/readtools"
"code.tczkiot.com/wlw/ai-agent/internal/ai/runtime/retrievers"
aitooling "code.tczkiot.com/wlw/ai-agent/internal/ai/tooling"
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/toolx"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
svc "code.tczkiot.com/wlw/ai-agent/internal/services"
"github.com/google/uuid"
"github.com/mlogclub/simple/sqls"
)
// AgentLoopEngine is the only Agent runtime. It combines conversation context,
// knowledge retrieval, fixed built-in tools, handoff decisions, and audit data.
type AgentLoopEngine struct {
history func(int64, int) []models.Message
businessMemory func(int64, int) []svc.BusinessToolMemory
retrieve func(context.Context, models.AIAgent, string) (string, int, error)
loop func(context.Context, models.AIConfig, string, string, []ai.ImageInput, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error)
complete func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error)
}
func NewAgentLoopEngine() *AgentLoopEngine {
return &AgentLoopEngine{
history: func(conversationID int64, limit int) []models.Message {
items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, limit, "", "")
return items
},
businessMemory: svc.AgentRunService.FindRecentBusinessToolMemory,
retrieve: retrieveAgentLoopKnowledge,
loop: einoAgentLoop,
complete: ai.LLM.ChatWithConfig,
}
}
func newAgentLoopEngineWithLoop(loop func(context.Context, models.AIConfig, string, string, []ai.ImageInput, []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()
snapshot, err := svc.AgentRevisionService.ResolvePublishedSnapshot(req.AIAgent, req.AIConfig)
if err != nil {
_, _ = writeAgentLoopRun(req, startedAt, nil, "", 0, 0, nil, agentLoopResponsePolicy{}, nil, err)
return nil, err
}
req.AIAgent = snapshot.Agent
req.AIConfig = snapshot.AIConfig
platformRequestBase := fmt.Sprintf("conversation:%d:message:%d:revision:%d", req.Conversation.ID, req.UserMessage.ID, req.AIAgent.PublishedRevisionID)
ctx = withPlatformRequestIDBase(ctx, platformRequestBase)
ctx = ai.WithPlatformAIRequestScope(ctx, platformRequestBase)
if err = validatePlatformVisionCapability(req.AIConfig, req.UserMessage.MessageType); err != nil {
_, _ = writeAgentLoopRun(req, startedAt, nil, "", 0, 0, nil, agentLoopResponsePolicy{}, nil, err)
return nil, err
}
imageInputs := e.buildVisionInputs(req)
selectPlatformVisionModel(&req.AIConfig, len(imageInputs))
req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content)
turn := e.prepareTurn(ctx, req, snapshot)
if req.UserMessage.MessageType == enums.IMMessageTypeImage && len(imageInputs) == 0 {
turn.SystemPrompt += "\n\n" + visionUnavailableInstruction
}
toolCalls := append([]svc.AgentLoopToolCallInput(nil), turn.PrefetchedToolCalls...)
if pending, matched, matchErr := e.prepareMatchedBusinessAction(ctx, req, turn, &toolCalls); matched {
result := &ai.ChatCompletionResult{ModelName: req.AIConfig.ModelName}
if matchErr != nil {
return e.buildBusinessActionPreparationFailureResult(req, startedAt, result, turn, toolCalls, matchErr)
}
return e.buildPendingBusinessActionResult(ctx, req, startedAt, result, turn, toolCalls, pending)
}
state := agentLoopExecutionState{VerifiedToolResults: cloneVerifiedToolResults(turn.VerifiedToolResults)}
definitions := agentLoopToolDefinitions(turn)
loopResult, loopErr := e.loop(ctx, req.AIConfig, turn.SystemPrompt, turn.UserPrompt, imageInputs, definitions, req.AIAgent.MaxSteps,
e.toolSearchExecutor(req, turn, &state, &toolCalls))
if loopErr != nil {
_, _ = writeAgentLoopRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.ResponsePolicy, toolCalls, loopErr)
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, turn.ResponsePolicy, toolCalls, err)
return nil, err
}
result := &loopResult.ChatCompletionResult
if state.PendingAction != nil {
return e.buildPendingBusinessActionResult(ctx, req, startedAt, result, turn, toolCalls, state.PendingAction)
}
2026-07-29 17:02:40 +08:00
replyText, handoffRequested, handoffReason, err := resolveAgentLoopReply(result.Content, state.Decision)
if err != nil {
_, _ = writeAgentLoopRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.ResponsePolicy, toolCalls, err)
2026-07-29 17:02:40 +08:00
return nil, err
}
result.Content = replyText
if result.Content == "" && !handoffRequested {
err = errorsx.InvalidParam("Agent Loop returned an empty reply")
_, _ = writeAgentLoopRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.ResponsePolicy, toolCalls, err)
return nil, err
}
2026-07-29 17:02:40 +08:00
result.Content, err = normalizeAgentLoopReply(result.Content, handoffRequested)
if err != nil {
_, _ = writeAgentLoopRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.ResponsePolicy, toolCalls, err)
return nil, err
}
result.Content = enforceVerifiedPackageReply(result.Content, state.VerifiedToolResults)
runID, recordErr := writeAgentLoopRun(req, startedAt, result, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.ResponsePolicy, toolCalls, nil)
if recordErr != nil {
return nil, recordErr
}
trace, _ := json.Marshal(map[string]any{
"runtime": "agent-loop",
"history_message_count": turn.HistoryCount,
"retriever_count": turn.RetrieverCount,
"response_policy_action": turn.ResponsePolicy.Action,
"response_policy_reason": 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,
ToolCallCount: len(toolCalls),
InvokedToolCodes: agentLoopInvokedToolCodes(toolCalls),
AgentRunID: runID,
HandoffRequested: handoffRequested && !req.Debug,
HandoffReason: handoffReason,
ConversationDecision: state.Decision,
TraceData: string(trace),
}, nil
}
func validatePlatformVisionCapability(config models.AIConfig, messageType enums.IMMessageType) error {
if messageType != enums.IMMessageTypeImage || !config.Platform {
return nil
}
if !config.VisionEnabled {
return errorsx.InvalidParam("系统内置设备图片理解模型未启用")
}
if strings.TrimSpace(config.VisionModel) == "" {
return errorsx.InvalidParam("系统内置设备图片理解模型未配置")
}
return nil
}
func selectPlatformVisionModel(config *models.AIConfig, imageCount int) {
if config == nil || !config.Platform || imageCount <= 0 {
return
}
// The gateway URL and credential remain shared, while the logical model
// identifies which configured task route is being consumed and audited.
config.ModelName = strings.TrimSpace(config.VisionModel)
}
func (e *AgentLoopEngine) buildVisionInputs(req RunInput) []ai.ImageInput {
if req.Conversation.ID <= 0 || !supportsVisionInput(req.AIConfig) {
return nil
}
// Multi-select clients persist each image as a separate message. Treat only
// the immediately preceding customer images from the same short upload
// window as one explicitly-authorized batch. Older conversation photos are
// never silently reattached to a later unrelated question.
messages := []models.Message{req.UserMessage}
if req.UserMessage.MessageType == enums.IMMessageTypeImage && req.UserMessage.ID > 0 && e.history != nil {
messages = collectCurrentVisionBatch(req.UserMessage, e.history(req.Conversation.ID, 8))
}
resolved := svc.AssetService.LoadConversationVisionImages(req.Conversation.ID, messages, 9)
inputs := make([]ai.ImageInput, 0, len(resolved))
for _, image := range resolved {
inputs = append(inputs, ai.ImageInput{
AssetID: image.AssetID, Filename: image.Filename, MIMEType: image.MIMEType,
Base64Data: image.Base64Data, FileSize: image.FileSize,
})
}
return inputs
}
const currentVisionBatchWindow = 30 * time.Second
func collectCurrentVisionBatch(current models.Message, recent []models.Message) []models.Message {
if current.ID <= 0 || current.MessageType != enums.IMMessageTypeImage || current.SenderType != enums.IMSenderTypeCustomer {
return []models.Message{current}
}
batch := make([]models.Message, 0, 6)
seen := make(map[int64]struct{}, 6)
for _, message := range recent {
if message.ID <= 0 || message.ID > current.ID || message.ConversationID != current.ConversationID ||
message.SenderType != enums.IMSenderTypeCustomer || message.MessageType != enums.IMMessageTypeImage {
continue
}
if !current.CreatedAt.IsZero() && !message.CreatedAt.IsZero() {
delta := current.CreatedAt.Sub(message.CreatedAt)
if delta < 0 || delta > currentVisionBatchWindow {
continue
}
}
if _, ok := seen[message.ID]; ok {
continue
}
seen[message.ID] = struct{}{}
batch = append(batch, message)
}
if _, ok := seen[current.ID]; !ok {
batch = append(batch, current)
}
slices.SortFunc(batch, func(left, right models.Message) int {
return cmp.Compare(left.ID, right.ID)
})
if len(batch) > 6 {
batch = batch[len(batch)-6:]
}
return batch
}
2026-07-29 17:02:40 +08:00
func resolveAgentLoopReply(modelReply string, decision *ConversationDecision) (reply string, handoffRequested bool, handoffReason string, err error) {
if decision == nil {
return strings.TrimSpace(modelReply), false, "", nil
}
switch decision.Action {
case ConversationActionHandoff:
return "", true, decision.Reason, nil
case ConversationActionReply, ConversationActionAskHandoffConfirmation:
return strings.TrimSpace(decision.Reply), false, "", nil
default:
return "", false, "", fmt.Errorf("invalid conversation decision action: %s", decision.Action)
}
}
func normalizeAgentLoopReply(reply string, handoffRequested bool) (string, error) {
if handoffRequested {
return "", nil
}
return aitooling.NormalizeCustomerReply(reply)
}
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) == "" || excludeAgentLoopHistoryMessage(item) {
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 excludeAgentLoopHistoryMessage(message models.Message) bool {
if message.SenderType != enums.IMSenderTypeAI {
return false
}
if strings.HasPrefix(strings.TrimSpace(message.ClientMsgID), "ai_error_") {
return true
}
// Older runs may have successfully persisted the generic failure text as a
// normal ai_reply message after a provider failure. Never teach the next
// model turn to imitate an operational notice as if it were a valid answer.
return strings.TrimSpace(utils.BuildRuntimeMessageText(message.MessageType, message.Content)) ==
"已收到您的消息,但本次智能处理没有完成。请稍后重试,或回复“人工客服”继续处理。"
}
func buildAgentLoopCustomerContext(conversation models.Conversation) string {
parts := make([]string, 0, 4)
if name := strings.TrimSpace(conversation.CustomerName); name != "" {
parts = append(parts, "Customer: "+name)
}
if segment := customerAfterSalesSegmentName(conversation.CustomerType); segment != "" {
parts = append(parts, "Customer segment: "+segment)
}
if hasBoundBusinessIdentity(conversation) {
parts = append(parts, "Verified business identity: already bound. Do not ask the customer to repeat the card number, device number, account, or another identifier unless they explicitly want to switch objects.")
}
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 || interrupt.ConversationID != req.Conversation.ID {
return nil, errorsx.InvalidParam("pending conversation interrupt does not exist")
}
pending := &pendingBusinessAction{}
if err := json.Unmarshal([]byte(strings.TrimSpace(interrupt.RequestData)), pending); err != nil || strings.TrimSpace(pending.ToolCode) == "" {
return nil, errorsx.InvalidParam("business action confirmation data is invalid")
}
switch graphs.ParseConfirmationDecision(firstResumeValue(req.ResumeData)) {
case graphs.ConfirmationDecisionCancel:
return &RunResult{Status: "cancelled", ReplyText: "操作已取消。", CheckPointID: req.CheckPointID}, nil
case graphs.ConfirmationDecisionConfirm:
tool, ok := svc.BusinessActionToolService.ResolveForCustomerType(pending.ToolCode, req.Conversation.CustomerType)
if !ok {
return nil, errorsx.InvalidParam("business action is no longer available")
}
result, _, err := svc.BusinessActionToolService.Execute(ctx, req.Conversation.ID, req.AIAgent.ID, req.CheckPointID, tool, businessReadContext(ctx, req.Conversation, req.CheckPointID), pending.Arguments)
if err != nil {
slog.Error("AI business action execution failed",
"conversation_id", req.Conversation.ID,
"ai_agent_id", req.AIAgent.ID,
"tool_code", tool.Code,
"checkpoint_id", req.CheckPointID,
"error", businessActionInternalError(err),
)
message := "操作未完成,请稍后重试或联系人工客服。"
var publicErr *contract.BusinessActionError
if errors.As(err, &publicErr) && strings.TrimSpace(publicErr.Message) != "" {
message = strings.TrimSpace(publicErr.Message)
}
return &RunResult{Status: "failed", ReplyText: message, CheckPointID: req.CheckPointID, ErrorMessage: err.Error()}, nil
}
return &RunResult{Status: "completed", ReplyText: strings.TrimSpace(result.Message), CheckPointID: req.CheckPointID, InvokedToolCodes: []string{tool.Code}, ToolCallCount: 1}, nil
default:
prompt := "请明确回复“确认”或“取消”。\n\n" + strings.TrimSpace(pending.PromptText)
return &RunResult{
Status: "interrupted", ReplyText: prompt, CheckPointID: req.CheckPointID,
CheckPointData: interrupt.RequestData, Interrupted: true,
Interrupts: []InterruptContextSummary{{Type: "tool_confirmation", ID: pending.InterruptID, DisplayName: pending.ToolCode, PromptText: prompt}},
}, nil
}
}
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 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,
}
}
type agentLoopToolSearchRequest struct {
ToolCode string `json:"tool_code"`
Arguments map[string]any `json:"arguments"`
}
type agentLoopToolPolicy struct {
MaxTotalCalls int `json:"max_total_calls"`
MaxArgumentBytes int `json:"max_argument_bytes"`
AllowedRiskLevels []string `json:"allowed_risk_levels"`
}
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
}
2026-07-29 17:02:40 +08:00
var (
agentLoopToolSearchTool = ai.ToolDefinition{
Name: "tool_search",
Description: "Execute a fixed built-in capability. Pass the exact capability code and arguments.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"tool_code": map[string]any{"type": "string"},
"arguments": map[string]any{"type": "object"},
},
"required": []string{"tool_code", "arguments"},
},
}
2026-07-29 17:02:40 +08:00
agentLoopDecisionTool = ai.ToolDefinition{
Name: "conversation_decision",
Description: "Return the final structured conversation decision after completing any needed analysis or tool calls.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{"type": "string", "enum": []string{"reply", "handoff", "ask_handoff_confirmation"}},
"reason": map[string]any{"type": "string"},
"reply": map[string]any{"type": "string"},
"handoff_initiator": map[string]any{"type": "string", "enum": []string{"none", "customer", "agent"}},
"handoff_confirmed": map[string]any{"type": "boolean"},
2026-07-29 17:02:40 +08:00
},
"required": []string{"action", "reason", "reply", "handoff_initiator", "handoff_confirmed"},
2026-07-29 17:02:40 +08:00
},
}
)
// agentLoopToolDefinitions registers the capability codes as compatibility
// aliases in addition to tool_search. Some OpenAI-compatible providers invoke
// a capability code mentioned in the prompt directly instead of wrapping it in
// tool_search. Eino validates the function name before our executor runs, so
// those calls must be registered here and then routed through the same policy
// boundary below.
func agentLoopToolDefinitions(turn agentLoopTurn) []ai.ToolDefinition {
definitions := []ai.ToolDefinition{agentLoopToolSearchTool, agentLoopDecisionTool}
seen := map[string]struct{}{"tool_search": {}, "conversation_decision": {}}
for _, code := range turn.AllowedTools {
code = strings.TrimSpace(code)
if code == "" {
continue
}
if _, exists := seen[code]; exists {
continue
}
seen[code] = struct{}{}
description := "Execute the configured capability " + code + " with its arguments."
parameters := map[string]any{"type": "object", "additionalProperties": true}
if hostTool, ok := svc.BusinessReadToolService.Resolve(code); ok {
description = hostTool.Description
parameters = hostTool.InputSchema
} else if hostTool, ok := svc.BusinessActionToolService.Resolve(code); ok {
description = hostTool.Description
parameters = hostTool.InputSchema
}
definitions = append(definitions, ai.ToolDefinition{
Name: code,
Description: description,
Parameters: parameters,
})
}
return definitions
}
func agentLoopSafeBuiltinCodes() []string {
return []string{
toolx.BuiltinConversationContext.Code,
toolx.BuiltinKnowledgeRetrieve.Code,
toolx.GraphTriageServiceRequest.Code,
toolx.GraphAnalyzeConversation.Code,
}
}
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
}
type agentLoopExecutionState struct {
Decision *ConversationDecision
PendingAction *pendingBusinessAction
VerifiedToolResults map[string]string
}
type pendingBusinessAction struct {
InterruptID string `json:"interrupt_id"`
ToolCode string `json:"tool_code"`
Arguments map[string]any `json:"arguments"`
PromptText string `json:"prompt_text"`
}
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()
2026-07-29 17:02:40 +08:00
if call.Name == "conversation_decision" {
decision, err := parseConversationDecision(call.Arguments)
if err != nil {
return "", err
}
state.Decision = decision
result, _ := json.Marshal(decision)
*records = append(*records, svc.AgentLoopToolCallInput{
ToolCode: "conversation_decision",
RiskLevel: aitooling.RiskLevelRead,
Status: "completed",
ArgumentsPreview: aitooling.SanitizePreview(call.Arguments),
ResultPreview: aitooling.SanitizePreview(string(result)),
DurationMS: int(time.Since(startedAt).Milliseconds()),
})
return string(result), nil
}
toolCode, arguments, err := resolveAgentLoopToolCall(call)
if err != nil {
return "", err
}
if !slices.Contains(turn.AllowedTools, toolCode) {
return "", fmt.Errorf("capability is not configured for this Agent: %s", toolCode)
}
explicitRecords := agentLoopExplicitToolCalls(*records, turn.PrefetchedToolCalls)
policy := aitooling.Policy{
AllowedToolCodes: turn.AllowedTools, AllowedRiskLevels: turn.ToolPolicy.AllowedRiskLevels,
CallCount: agentLoopToolCallCount(explicitRecords, toolCode),
TotalCallCount: len(explicitRecords),
MaxTotalCalls: turn.ToolPolicy.MaxTotalCalls,
MaxArgumentBytes: turn.ToolPolicy.MaxArgumentBytes,
Confirmed: false,
}
if hostTool, ok := svc.BusinessActionToolService.ResolveForCustomerType(toolCode, runInput.Conversation.CustomerType); ok {
if state.PendingAction != nil {
return "", fmt.Errorf("only one business action may be prepared at a time")
}
definition := businessActionDefinition(hostTool)
policy.Confirmed = true // Preparation only; execution is exclusive to Resume after user confirmation.
if err := aitooling.DefaultRegistry.Authorize(definition, policy); err != nil {
return "", err
}
prompt, err := svc.BusinessActionToolService.Preview(ctx, hostTool, businessReadContext(ctx, runInput.Conversation, ""), arguments)
record := svc.AgentLoopToolCallInput{
ToolCode: definition.Code, RiskLevel: definition.RiskLevel, RequireConfirm: true,
Status: "pending_confirmation", ArgumentsPreview: aitooling.SanitizePreview(call.Arguments),
DurationMS: int(time.Since(startedAt).Milliseconds()),
}
if err != nil {
record.Status = "failed"
record.ErrorMessage = err.Error()
*records = append(*records, record)
return "", err
}
state.PendingAction = &pendingBusinessAction{
InterruptID: "business_action_confirmation", ToolCode: definition.Code,
Arguments: arguments, PromptText: strings.TrimSpace(prompt),
}
record.ResultPreview = aitooling.SanitizePreview(prompt)
*records = append(*records, record)
encoded, _ := json.Marshal(map[string]any{"status": "confirmation_required", "message": prompt})
return string(encoded), nil
}
if hostTool, ok := svc.BusinessReadToolService.ResolveForCustomerType(toolCode, runInput.Conversation.CustomerType); ok && len(arguments) == 0 {
if prefetched, found := findPrefetchedToolCall(turn.PrefetchedToolCalls, hostTool.Code); found {
definition := businessReadDefinition(hostTool)
record := svc.AgentLoopToolCallInput{
ToolCode: definition.Code, RiskLevel: definition.RiskLevel, Status: "completed",
ArgumentsPreview: aitooling.SanitizePreview(call.Arguments),
DurationMS: int(time.Since(startedAt).Milliseconds()),
}
if err := aitooling.DefaultRegistry.Authorize(definition, policy); err != nil {
record.Status = "failed"
record.ErrorMessage = err.Error()
*records = append(*records, record)
return "", err
}
if prefetched.Status != "completed" {
err := fmt.Errorf("fresh business lookup is temporarily unavailable for this turn")
record.Status = "failed"
record.ErrorMessage = err.Error()
*records = append(*records, record)
return "", err
}
result, ok := prefetchedToolResult(turn.PrefetchedToolResults, definition.Code, arguments)
if !ok {
err := fmt.Errorf("fresh business lookup result is unavailable for this turn")
record.Status = "failed"
record.ErrorMessage = err.Error()
*records = append(*records, record)
return "", err
}
record.ResultPreview = aitooling.SanitizePreview(result)
record.DurationMS = int(time.Since(startedAt).Milliseconds())
*records = append(*records, record)
return result, nil
}
}
definition, resultPreview, executeErr := executeAgentLoopReadTool(ctx, runInput.Conversation, runInput.AIAgent, toolCode, arguments, policy)
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 executeErr != nil {
record.Status = "failed"
record.ErrorMessage = executeErr.Error()
*records = append(*records, record)
return "", executeErr
}
if state.VerifiedToolResults == nil {
state.VerifiedToolResults = make(map[string]string)
}
state.VerifiedToolResults[record.ToolCode] = resultPreview
record.ResultPreview = aitooling.SanitizePreview(resultPreview)
*records = append(*records, record)
return resultPreview, nil
}
}
// prepareMatchedBusinessAction routes explicit host-defined commands directly
// into the existing preview/confirmation flow. Transactional customer commands
// must not depend on a probabilistic model deciding whether to call a tool.
func (e *AgentLoopEngine) prepareMatchedBusinessAction(ctx context.Context, runInput RunInput, turn agentLoopTurn, records *[]svc.AgentLoopToolCallInput) (*pendingBusinessAction, bool, error) {
message := strings.TrimSpace(runInput.UserMessage.Content)
if message == "" {
return nil, false, nil
}
var matched *contract.BusinessActionTool
for _, tool := range svc.BusinessActionToolService.ListForCustomerType(runInput.Conversation.CustomerType) {
if tool.MatchIntent == nil || !tool.MatchIntent(message) {
continue
}
if matched != nil {
return nil, true, fmt.Errorf("multiple business actions matched the customer command")
}
candidate := tool
matched = &candidate
}
if matched == nil {
return nil, false, nil
}
startedAt := time.Now()
definition := businessActionDefinition(*matched)
explicitRecords := agentLoopExplicitToolCalls(*records, turn.PrefetchedToolCalls)
policy := aitooling.Policy{
AllowedToolCodes: turn.AllowedTools, AllowedRiskLevels: turn.ToolPolicy.AllowedRiskLevels,
CallCount: 0, TotalCallCount: len(explicitRecords), MaxTotalCalls: turn.ToolPolicy.MaxTotalCalls,
MaxArgumentBytes: turn.ToolPolicy.MaxArgumentBytes, Confirmed: true,
}
record := svc.AgentLoopToolCallInput{
ToolCode: definition.Code, RiskLevel: definition.RiskLevel, RequireConfirm: true,
Status: "pending_confirmation", ArgumentsPreview: "{}",
}
if err := aitooling.DefaultRegistry.Authorize(definition, policy); err != nil {
record.Status = "failed"
record.ErrorMessage = err.Error()
record.DurationMS = int(time.Since(startedAt).Milliseconds())
*records = append(*records, record)
return nil, true, err
}
arguments := map[string]any{}
prompt, err := svc.BusinessActionToolService.Preview(ctx, *matched, businessReadContext(ctx, runInput.Conversation, ""), arguments)
record.DurationMS = int(time.Since(startedAt).Milliseconds())
if err != nil {
record.Status = "failed"
record.ErrorMessage = err.Error()
*records = append(*records, record)
return nil, true, err
}
prompt = strings.TrimSpace(prompt)
record.ResultPreview = aitooling.SanitizePreview(prompt)
*records = append(*records, record)
return &pendingBusinessAction{
InterruptID: "business_action_confirmation", ToolCode: definition.Code,
Arguments: arguments, PromptText: prompt,
}, true, nil
}
func businessActionDefinition(tool contract.BusinessActionTool) aitooling.Definition {
return aitooling.Definition{
Code: tool.Code, Name: tool.Code, Description: tool.Description, InputSchema: tool.InputSchema,
SourceType: enums.ToolSourceTypeBuiltin, RiskLevel: aitooling.RiskLevelWrite,
RequireConfirmation: true, MaxCallsPerRun: 1, TimeoutMS: 30000, IdempotencyMode: "business",
}
}
func businessReadContext(ctx context.Context, conversation models.Conversation, checkPointID string) contract.BusinessReadContext {
businessContext := contract.BusinessReadContext{
ConversationID: conversation.ID, CustomerType: conversation.CustomerType, CustomerID: conversation.CustomerID,
CustomerExternalID: strings.TrimSpace(conversation.CustomerExternalID), CustomerName: strings.TrimSpace(conversation.CustomerName),
CheckPointID: strings.TrimSpace(checkPointID),
}
if proof, ok := contract.CustomerAccessProofFromContext(ctx); ok {
businessContext.AccessProof = &proof
businessContext.RequestMessageID = proof.MessageID
businessContext.RequestID = proof.RequestID
}
return businessContext
}
func (e *AgentLoopEngine) buildPendingBusinessActionResult(ctx context.Context, req RunInput, startedAt time.Time, result *ai.ChatCompletionResult, turn agentLoopTurn, toolCalls []svc.AgentLoopToolCallInput, pending *pendingBusinessAction) (*RunResult, error) {
if pending == nil || strings.TrimSpace(pending.PromptText) == "" {
return nil, errorsx.InvalidParam("business action confirmation prompt is empty")
}
checkPointID := "business_action_" + uuid.NewString()
if tool, ok := svc.BusinessActionToolService.ResolveForCustomerType(pending.ToolCode, req.Conversation.CustomerType); ok && tool.BindConfirmation != nil {
businessContext := businessReadContext(ctx, req.Conversation, checkPointID)
if err := tool.BindConfirmation(ctx, businessContext, pending.Arguments, checkPointID); err != nil {
return nil, err
}
}
data, err := json.Marshal(pending)
if err != nil {
return nil, err
}
result.Content = pending.PromptText
runID, err := writeAgentLoopRun(req, startedAt, result, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.ResponsePolicy, toolCalls, nil)
if err != nil {
return nil, err
}
return &RunResult{
Status: "interrupted", ReplyText: pending.PromptText, ModelName: result.ModelName,
PromptTokens: result.PromptTokens, CompletionTokens: result.CompletionTokens,
HistoryMessageCount: turn.HistoryCount, RetrieverCount: turn.RetrieverCount,
ToolCallCount: len(toolCalls), InvokedToolCodes: agentLoopInvokedToolCodes(toolCalls), AgentRunID: runID,
CheckPointID: checkPointID, CheckPointData: string(data), Interrupted: true,
Interrupts: []InterruptContextSummary{{Type: "tool_confirmation", ID: pending.InterruptID, DisplayName: pending.ToolCode, PromptText: pending.PromptText}},
}, nil
}
func (e *AgentLoopEngine) buildBusinessActionPreparationFailureResult(req RunInput, startedAt time.Time, result *ai.ChatCompletionResult, turn agentLoopTurn, toolCalls []svc.AgentLoopToolCallInput, cause error) (*RunResult, error) {
message := businessActionCustomerMessage(cause)
result.Content = message
runID, err := writeAgentLoopRun(req, startedAt, result, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.ResponsePolicy, toolCalls, nil)
if err != nil {
return nil, err
}
return &RunResult{
Status: "completed", ReplyText: message, ModelName: result.ModelName,
HistoryMessageCount: turn.HistoryCount, RetrieverCount: turn.RetrieverCount,
ToolCallCount: len(toolCalls), InvokedToolCodes: agentLoopInvokedToolCodes(toolCalls), AgentRunID: runID,
}, nil
}
func businessActionCustomerMessage(err error) string {
message := "操作暂时无法办理,请稍后重试或联系人工客服。"
var publicErr *contract.BusinessActionError
if errors.As(err, &publicErr) && strings.TrimSpace(publicErr.Message) != "" {
message = strings.TrimSpace(publicErr.Message)
}
return message
}
func businessActionInternalError(err error) error {
var publicErr *contract.BusinessActionError
if errors.As(err, &publicErr) && publicErr.Cause != nil {
return publicErr.Cause
}
return err
}
func firstResumeValue(values map[string]string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
2026-07-29 17:02:40 +08:00
func parseConversationDecision(raw string) (*ConversationDecision, error) {
decision := &ConversationDecision{}
if err := json.Unmarshal([]byte(raw), decision); err != nil {
return nil, fmt.Errorf("invalid conversation decision: %w", err)
}
decision.Reason = strings.TrimSpace(decision.Reason)
decision.Reply = strings.TrimSpace(decision.Reply)
switch decision.Action {
case ConversationActionReply:
if decision.HandoffInitiator != HandoffInitiatorNone || decision.HandoffConfirmed {
return nil, fmt.Errorf("reply decision must not contain handoff state")
}
case ConversationActionHandoff:
if decision.HandoffInitiator == HandoffInitiatorNone || !decision.HandoffConfirmed {
return nil, fmt.Errorf("handoff decision requires a confirmed handoff initiator")
}
case ConversationActionAskHandoffConfirmation:
if decision.HandoffInitiator != HandoffInitiatorAgent || decision.HandoffConfirmed {
return nil, fmt.Errorf("handoff confirmation may only be requested for an unconfirmed agent recommendation")
}
default:
return nil, fmt.Errorf("invalid conversation decision action: %s", decision.Action)
}
if decision.Action != ConversationActionHandoff && decision.Reply == "" {
return nil, fmt.Errorf("conversation decision reply is required")
}
return decision, nil
}
func resolveAgentLoopToolCall(call ai.ToolCall) (string, map[string]any, error) {
if call.Name == "tool_search" {
var request agentLoopToolSearchRequest
if err := json.Unmarshal([]byte(call.Arguments), &request); err != nil {
return "", nil, fmt.Errorf("invalid tool_search arguments: %w", err)
}
return strings.TrimSpace(request.ToolCode), request.Arguments, nil
}
toolCode := strings.TrimSpace(call.Name)
if toolCode == "" {
return "", nil, fmt.Errorf("agent loop tool name is required")
}
arguments := map[string]any{}
if strings.TrimSpace(call.Arguments) == "" {
return toolCode, arguments, nil
}
if err := json.Unmarshal([]byte(call.Arguments), &arguments); err != nil {
return "", nil, fmt.Errorf("invalid direct capability arguments for %s: %w", toolCode, err)
}
return toolCode, arguments, nil
}
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 hostTool, ok := svc.BusinessReadToolService.ResolveForCustomerType(toolCode, conversation.CustomerType); ok {
definition := businessReadDefinition(hostTool)
if err := aitooling.DefaultRegistry.Authorize(definition, policy); err != nil {
return definition, "", err
}
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond)
defer cancel()
result, err := svc.BusinessReadToolService.Execute(ctx, hostTool, businessReadContext(ctx, conversation, ""), arguments)
if err != nil {
return definition, "", err
}
encoded, err := json.Marshal(result)
return definition, string(encoded), err
}
if toolCode != toolx.BuiltinConversationContext.Code && toolCode != toolx.BuiltinKnowledgeRetrieve.Code && toolCode != toolx.GraphTriageServiceRequest.Code && toolCode != toolx.GraphAnalyzeConversation.Code {
return aitooling.Definition{}, "", fmt.Errorf("tool is not a built-in read tool")
}
if toolCode == toolx.GraphTriageServiceRequest.Code || toolCode == toolx.GraphAnalyzeConversation.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), "result_count": count, "context": contextText})
return definition, string(result), err
}
result, err := json.Marshal(map[string]any{
"conversation_id": conversation.ID,
"customer_name": strings.TrimSpace(conversation.CustomerName),
"last_message_summary": strings.TrimSpace(conversation.LastMessageSummary),
"current_assignee_id": conversation.CurrentAssigneeID,
"recent_messages": 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 businessReadDefinition(tool contract.BusinessReadTool) aitooling.Definition {
return aitooling.Definition{
Code: tool.Code,
Name: tool.Code,
Description: tool.Description,
InputSchema: tool.InputSchema,
SourceType: enums.ToolSourceTypeBuiltin,
RiskLevel: aitooling.RiskLevelRead,
MaxCallsPerRun: 3,
TimeoutMS: 10000,
IdempotencyMode: "none",
}
}
func agentLoopExplicitToolCalls(records, prefetched []svc.AgentLoopToolCallInput) []svc.AgentLoopToolCallInput {
prefix := 0
for prefix < len(records) && prefix < len(prefetched) {
if toolx.NormalizeToolCodeAlias(strings.TrimSpace(records[prefix].ToolCode)) != toolx.NormalizeToolCodeAlias(strings.TrimSpace(prefetched[prefix].ToolCode)) ||
records[prefix].Status != prefetched[prefix].Status {
break
}
prefix++
}
return records[prefix:]
}
func findPrefetchedToolCall(records []svc.AgentLoopToolCallInput, toolCode string) (svc.AgentLoopToolCallInput, bool) {
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
for _, record := range records {
if toolx.NormalizeToolCodeAlias(strings.TrimSpace(record.ToolCode)) == toolCode {
return record, true
}
}
return svc.AgentLoopToolCallInput{}, false
}
func agentLoopToolResultCacheKey(toolCode string, arguments map[string]any) string {
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
if arguments == nil {
arguments = map[string]any{}
}
encoded, err := json.Marshal(arguments)
if err != nil {
return ""
}
return toolCode + "\x00" + string(encoded)
}
func prefetchedToolResult(results map[string]string, toolCode string, arguments map[string]any) (string, bool) {
key := agentLoopToolResultCacheKey(toolCode, arguments)
if key == "" {
return "", false
}
result, ok := results[key]
return result, ok && strings.TrimSpace(result) != ""
}
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."
}
prompt += "\n\nMaintain conversational continuity. If the immediately preceding assistant message already welcomed the customer and the current customer message is only a greeting, reply briefly without repeating the welcome wording, service capabilities, or service scope."
if retrieveErr != nil {
prompt += "\n\nKnowledge retrieval is temporarily unavailable for this message. Continue to answer ordinary questions, explain general concepts, interpret customer-provided photos, and give safe reversible troubleshooting from general knowledge. Clearly distinguish general guidance from verified account or product facts. For the customer's current account, device state, balance, order, price, policy, permission, or other host business facts, do not claim that any detail is verified without knowledge evidence or a successful business capability; ask one focused question, suggest retry, or offer human support when needed."
} else if hasKnowledgeBase && strings.TrimSpace(knowledgeContext) == "" {
prompt += "\n\nKnowledge retrieval found no supporting evidence for this message. Continue to answer ordinary questions, explain general concepts, interpret customer-provided photos, and give safe reversible troubleshooting from general knowledge. Do not invent account-specific or host-specific product facts, prices, policies, permissions, or business state. For those facts, state that they are not verified, ask one focused question, retry the appropriate capability, or offer human support when needed."
}
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 += "\nOffer human support when the customer requests it, when a high-risk matter cannot be verified, or when an account-specific issue remains unresolved. Missing knowledge alone does not require an automatic handoff for an ordinary question."
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, responsePolicy agentLoopResponsePolicy, toolCalls []svc.AgentLoopToolCallInput, cause error) (int64, error) {
endedAt := time.Now()
status := "completed"
errorMessage := ""
outputPreview := ""
promptTokens := 0
completionTokens := 0
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, "history_message_count": historyCount, "retriever_count": retrieverCount})
additionalSteps := agentLoopAdditionalSteps(req, retrieverCount, retrieveErr, responsePolicy)
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, 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 agentLoopAdditionalSteps(req RunInput, retrieverCount int, retrieveErr error, responsePolicy agentLoopResponsePolicy) []svc.AgentLoopStepInput {
steps := make([]svc.AgentLoopStepInput, 0, 3)
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
}