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:
mlogclub
2026-07-27 23:29:02 +08:00
parent 241f274927
commit 847f688398
97 changed files with 1666 additions and 5941 deletions
@@ -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
}
@@ -0,0 +1,184 @@
package runtime
import (
"context"
"encoding/json"
"strings"
"testing"
ai "agent-desk/internal/ai"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
svc "agent-desk/internal/services"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
func TestAgentLoopActivatesSkillInsideSameToolLoop(t *testing.T) {
skill := models.SkillDefinition{
ID: 7, Name: "退款说明", Instruction: "只根据退款政策回答。",
ToolWhitelist: `["builtin/knowledge_retrieve"]`, Status: enums.StatusOk,
}
turn := agentLoopTurn{
AllowedTools: []string{"skill/7"},
ToolPolicy: parseAgentLoopToolPolicy(""),
Skills: map[int64]models.SkillDefinition{skill.ID: skill},
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(RunInput{}, turn, &state, &calls)
result, err := execute(context.Background(), ai.ToolCall{
Name: "tool_search", Arguments: `{"toolCode":"skill/7","arguments":{}}`,
})
if err != nil {
t.Fatalf("activate Skill: %v", err)
}
if state.SkillContext.SkillID() != skill.ID || !strings.Contains(result, skill.Instruction) {
t.Fatalf("Skill was not activated in the Agent Loop: state=%#v result=%q", state, result)
}
if len(calls) != 1 || calls[0].ToolCode != "skill/7" || calls[0].Status != "completed" {
t.Fatalf("unexpected Skill audit: %#v", calls)
}
}
func TestAgentLoopInterruptsBeforeWriteMCPTool(t *testing.T) {
configured, err := json.Marshal([]request.AIAgentMCPToolRequest{{
ToolCode: "crm/update_customer", ServerCode: "crm", ToolName: "update_customer",
Title: "更新客户", RiskLevel: "write", RequireConfirmation: true,
}})
if err != nil {
t.Fatalf("marshal MCP configuration: %v", err)
}
runInput := RunInput{
Conversation: models.Conversation{ID: 9},
AIAgent: models.AIAgent{AllowedMCPTools: string(configured)},
}
turn := agentLoopTurn{
AllowedTools: []string{"crm/update_customer"},
ToolPolicy: parseAgentLoopToolPolicy(`{"allowedRiskLevels":["read","write"]}`),
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(runInput, turn, &state, &calls)
_, err = execute(context.Background(), ai.ToolCall{
Name: "tool_search", Arguments: `{"toolCode":"crm/update_customer","arguments":{"name":"Ada"}}`,
})
if err == nil {
t.Fatal("expected write MCP Tool to interrupt")
}
if state.Interrupted == nil || !state.Interrupted.Interrupted || !strings.HasPrefix(state.Interrupted.CheckPointID, "tool:9:") {
t.Fatalf("missing MCP confirmation checkpoint: %#v", state.Interrupted)
}
if len(calls) != 1 || calls[0].RiskLevel != "write" || !calls[0].RequireConfirm || calls[0].Status != "interrupted" {
t.Fatalf("unexpected MCP safety audit: %#v", calls)
}
}
func TestAgentLoopRejectsWriteMCPBeforeConfirmationWhenRiskIsNotAllowed(t *testing.T) {
configured, _ := json.Marshal([]request.AIAgentMCPToolRequest{{
ToolCode: "crm/update_customer", ServerCode: "crm", ToolName: "update_customer",
Title: "更新客户", RiskLevel: "write", RequireConfirmation: true,
}})
runInput := RunInput{
Conversation: models.Conversation{ID: 9},
AIAgent: models.AIAgent{AllowedMCPTools: string(configured)},
}
turn := agentLoopTurn{
AllowedTools: []string{"crm/update_customer"},
ToolPolicy: parseAgentLoopToolPolicy(`{"allowedRiskLevels":["read"]}`),
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(runInput, turn, &state, &calls)
_, err := execute(context.Background(), ai.ToolCall{
Name: "tool_search", Arguments: `{"toolCode":"crm/update_customer","arguments":{"name":"Ada"}}`,
})
if err == nil || !strings.Contains(err.Error(), "risk level") {
t.Fatalf("expected MCP risk policy rejection, got %v", err)
}
if state.Interrupted != nil || len(calls) != 1 || calls[0].Status != "failed" {
t.Fatalf("disallowed MCP call should fail without a checkpoint: state=%#v calls=%#v", state, calls)
}
}
func TestAgentLoopRejectsWorkflowWhenWriteRiskIsNotAllowed(t *testing.T) {
turn := agentLoopTurn{
AllowedTools: []string{"workflow/23"},
ToolPolicy: parseAgentLoopToolPolicy(`{"allowedRiskLevels":["read"]}`),
Workflows: map[int64]svc.AgentRevisionWorkflowBinding{
23: {WorkflowVersionID: 23, ToolName: "创建工单"},
},
}
state := agentLoopExecutionState{}
var calls []svc.AgentLoopToolCallInput
execute := NewAgentLoopEngine().toolSearchExecutor(RunInput{}, turn, &state, &calls)
_, err := execute(context.Background(), ai.ToolCall{
Name: "tool_search", Arguments: `{"toolCode":"workflow/23","arguments":{}}`,
})
if err == nil || !strings.Contains(err.Error(), "risk level") {
t.Fatalf("expected Workflow risk policy rejection, got %v", err)
}
if len(calls) != 1 || calls[0].Status != "failed" || calls[0].RiskLevel != "write" {
t.Fatalf("unexpected Workflow policy audit: %#v", calls)
}
}
func TestAgentLoopKnowledgeFallbackCanRequestHandoff(t *testing.T) {
agent := models.AIAgent{
KnowledgeIDs: "1", FallbackMode: enums.AIAgentFallbackModeHandoff,
FallbackMessage: "我暂时无法核实,马上为你转人工。",
}
policy := evaluateAgentLoopResponsePolicy(agent, "", nil)
prompt := buildAgentLoopSystemPrompt(agent, true, "", nil)
if !policy.RequestHandoff || !strings.Contains(prompt, agent.FallbackMessage) {
t.Fatalf("knowledge fallback was not applied: policy=%#v prompt=%q", policy, prompt)
}
}
func TestAgentTurnPublishesAllConfiguredCapabilityKinds(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.SkillDefinition{}); err != nil {
t.Fatalf("migrate Skill: %v", err)
}
sqls.SetDB(db)
skill := models.SkillDefinition{Name: "订单查询", Description: "查询订单状态", Status: enums.StatusOk}
if err := db.Create(&skill).Error; err != nil {
t.Fatalf("create Skill: %v", err)
}
mcp, _ := json.Marshal([]request.AIAgentMCPToolRequest{{
ToolCode: "crm/get_customer", ServerCode: "crm", ToolName: "get_customer",
RiskLevel: "read",
}})
agent := models.AIAgent{SkillIDs: jsonInt64List(skill.ID), AllowedMCPTools: string(mcp)}
snapshot := &svc.AgentRevisionSnapshot{
Agent: agent,
WorkflowBindings: []svc.AgentRevisionWorkflowBinding{{
WorkflowVersionID: 23, ToolName: "创建工单", TriggerInstruction: "用户要求创建工单",
}},
}
engine := NewAgentLoopEngine()
engine.retrieve = nil
engine.history = nil
turn := engine.prepareTurn(context.Background(), RunInput{AIAgent: agent}, snapshot)
for _, code := range []string{"skill/" + jsonInt64List(skill.ID), "workflow/23", "crm/get_customer"} {
if !strings.Contains(turn.SystemPrompt, code) {
t.Fatalf("capability %q missing from prompt:\n%s", code, turn.SystemPrompt)
}
}
}
func jsonInt64List(id int64) string {
data, _ := json.Marshal([]int64{id})
return strings.Trim(string(data), "[]")
}
@@ -0,0 +1,85 @@
package runtime
import (
"context"
"fmt"
"strings"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/toolx"
"agent-desk/internal/pkg/utils"
svc "agent-desk/internal/services"
)
type agentLoopTurn struct {
RetrieverCount int
RetrieveErr error
ResponsePolicy agentLoopResponsePolicy
SystemPrompt string
UserPrompt string
HistoryCount int
AllowedTools []string
ToolPolicy agentLoopToolPolicy
Skills map[int64]models.SkillDefinition
Workflows map[int64]svc.AgentRevisionWorkflowBinding
}
func (e *AgentLoopEngine) prepareTurn(ctx context.Context, req RunInput, snapshot *svc.AgentRevisionSnapshot) agentLoopTurn {
knowledgeContext, retrieverCount, retrieveErr := e.retrieveKnowledge(ctx, req.AIAgent, req.UserMessage.Content)
responsePolicy := evaluateAgentLoopResponsePolicy(req.AIAgent, knowledgeContext, retrieveErr)
systemPrompt := buildAgentLoopSystemPrompt(req.AIAgent, len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0, knowledgeContext, retrieveErr)
userPrompt, historyCount := e.buildUserPrompt(req)
if knowledgeContext != "" {
userPrompt += "\n\nKnowledge evidence:\n" + knowledgeContext
}
skills := svc.SkillDefinitionService.GetByIDs(utils.SplitInt64s(req.AIAgent.SkillIDs))
workflows := make(map[int64]svc.AgentRevisionWorkflowBinding, len(snapshot.WorkflowBindings))
allowedTools := agentLoopSafeBuiltinCodes()
catalog := []string{
"- " + toolx.BuiltinConversationContext.Code + " | Builtin | 读取当前会话和客户上下文",
"- " + toolx.BuiltinKnowledgeRetrieve.Code + " | Builtin | 按需再次检索已绑定知识库",
"- " + toolx.GraphTriageServiceRequest.Code + " | Builtin | 分析服务请求并生成处置建议",
"- " + toolx.GraphAnalyzeConversation.Code + " | Builtin | 分析会话意图和风险信号",
"- " + toolx.GraphPrepareTicketDraft.Code + " | Builtin | 只生成工单草稿,不执行写入",
}
for id, skill := range skills {
if skill.Status != enums.StatusOk {
continue
}
code := agentLoopSkillCode(id)
allowedTools = append(allowedTools, code)
catalog = append(catalog, fmt.Sprintf("- %s | Skill | %s | %s", code, strings.TrimSpace(skill.Name), strings.TrimSpace(skill.Description)))
}
for _, binding := range snapshot.WorkflowBindings {
if binding.WorkflowVersionID <= 0 {
continue
}
workflows[binding.WorkflowVersionID] = binding
code := agentLoopWorkflowCode(binding.WorkflowVersionID)
allowedTools = append(allowedTools, code)
catalog = append(catalog, fmt.Sprintf("- %s | Workflow | %s | %s", code, strings.TrimSpace(binding.ToolName), strings.TrimSpace(binding.TriggerInstruction)))
}
mcpTools, _ := toolx.ParseAgentMCPToolsJSON(req.AIAgent.AllowedMCPTools)
for _, tool := range mcpTools {
if strings.TrimSpace(tool.ToolCode) == "" {
continue
}
allowedTools = append(allowedTools, tool.ToolCode)
catalog = append(catalog, fmt.Sprintf("- %s | MCP | %s | %s", tool.ToolCode, tool.Title, tool.Description))
}
systemPrompt += "\n\nAvailable capabilities:\n" + strings.Join(catalog, "\n")
systemPrompt += "\n\nUse tool_search with an exact capability code only when needed. You decide whether to answer directly, activate a Skill, execute a Workflow, retrieve knowledge, or call MCP. A Skill activation returns instructions for this same run. Never invent a capability code."
return agentLoopTurn{
RetrieverCount: retrieverCount,
RetrieveErr: retrieveErr,
ResponsePolicy: responsePolicy,
SystemPrompt: systemPrompt,
UserPrompt: userPrompt,
HistoryCount: historyCount,
AllowedTools: allowedTools,
ToolPolicy: parseAgentLoopToolPolicy(req.AIAgent.ToolPolicy),
Skills: skills,
Workflows: workflows,
}
}
@@ -26,7 +26,7 @@ type ApplicationResumeInput struct {
// AgentApplicationService is the single application boundary before engine
// dispatch. It owns persisted input loading and relationship validation; the
// selected Engine remains responsible only for runtime execution.
// Agent Loop remains responsible only for runtime execution.
type AgentApplicationService struct {
runtime *Service
}
@@ -1,647 +0,0 @@
package runtime
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
ai "agent-desk/internal/ai"
"agent-desk/internal/ai/runtime/readtools"
"agent-desk/internal/ai/runtime/retrievers"
runtimetooling "agent-desk/internal/ai/runtime/tooling"
"agent-desk/internal/ai/skills"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"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"
)
// AutonomousEngine is the low-risk, no-flow runtime. It uses bounded model
// turns and exposes configured MCP tools only through the shared Tool Registry.
type AutonomousEngine struct {
chat func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error)
history func(int64, int) []models.Message
retrieve func(context.Context, models.AIAgent, string) (string, int, error)
skillSelect func(context.Context, skills.RuntimeContext) (*skills.ExecutionResult, error)
toolChat func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error)
}
func NewAutonomousEngine() *AutonomousEngine {
return &AutonomousEngine{
chat: ai.LLM.ChatWithConfig,
history: func(conversationID int64, limit int) []models.Message {
items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, limit, "", "")
return items
},
retrieve: retrieveAutonomousKnowledge,
skillSelect: skills.RuntimeService.Select,
toolChat: ai.LLM.ChatWithTools,
}
}
func newAutonomousEngineWithChat(chat func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error)) *AutonomousEngine {
return &AutonomousEngine{chat: chat}
}
func (e *AutonomousEngine) Code() string {
return EngineCodeAutonomous
}
func (e *AutonomousEngine) 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 {
_, _ = writeAutonomousRun(req, startedAt, nil, "", 0, 0, nil, autonomousSkillContext{}, autonomousResponsePolicy{}, nil, err)
return nil, err
}
req.AIAgent = snapshot.Agent
req.AIConfig = snapshot.AIConfig
turn := e.prepareTurn(ctx, req)
var toolCalls []svc.EngineToolCallInput
var result *ai.ChatCompletionResult
if turn.ResponsePolicy.Enforced {
result = &ai.ChatCompletionResult{Content: turn.ResponsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}
} else if len(turn.AllowedTools) > 0 && e.toolChat != nil {
loopResult, loopErr := e.toolChat(ctx, req.AIConfig, turn.SystemPrompt, turn.UserPrompt, []ai.ToolDefinition{autonomousToolSearchDefinition()}, req.AIAgent.MaxSteps, e.toolSearchExecutor(req.Conversation, req.AIAgent, turn.AgentAllowedTools, turn.SkillContext.AllowedToolCodes, turn.ToolPolicy, &toolCalls))
if loopErr != nil {
if len(toolCalls) == 0 {
err := loopErr
_, _ = writeAutonomousRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, turn.ResponsePolicy, toolCalls, err)
return nil, err
}
turn.ResponsePolicy = autonomousToolFailurePolicy(req.AIAgent, "tool_loop_error")
result = &ai.ChatCompletionResult{Content: turn.ResponsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}
}
if result == nil && loopResult != nil {
result = &loopResult.ChatCompletionResult
}
if autonomousHasConsecutiveToolFailures(toolCalls, 2) {
turn.ResponsePolicy = autonomousToolFailurePolicy(req.AIAgent, "tool_consecutive_failures")
result = &ai.ChatCompletionResult{Content: turn.ResponsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}
}
} else {
result, err = e.chat(ctx, req.AIConfig, turn.SystemPrompt, turn.UserPrompt)
}
if err != nil {
_, _ = writeAutonomousRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, turn.ResponsePolicy, toolCalls, err)
return nil, err
}
if result == nil || strings.TrimSpace(result.Content) == "" {
err = errorsx.InvalidParam("autonomous engine returned an empty reply")
_, _ = writeAutonomousRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, turn.ResponsePolicy, toolCalls, err)
return nil, err
}
result.Content, err = aitooling.NormalizeCustomerReply(result.Content)
if err != nil {
_, _ = writeAutonomousRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, turn.ResponsePolicy, toolCalls, err)
return nil, err
}
runID, recordErr := writeAutonomousRun(req, startedAt, result, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, turn.ResponsePolicy, toolCalls, nil)
if recordErr != nil {
return nil, recordErr
}
trace, _ := json.Marshal(map[string]any{
"engine": EngineCodeAutonomous,
"mode": autonomousExecutionMode(turn.AllowedTools),
"historyMessageCount": turn.HistoryCount,
"retrieverCount": turn.RetrieverCount,
"skillID": turn.SkillContext.SkillID(),
"skillRouteError": turn.SkillContext.ErrorMessage,
"responsePolicyAction": turn.ResponsePolicy.Action,
"responsePolicyReason": turn.ResponsePolicy.Reason,
"responsePolicyEnforced": turn.ResponsePolicy.Enforced,
"debug": req.Debug,
})
return &Summary{
Status: "completed",
ReplyText: strings.TrimSpace(result.Content),
ModelName: result.ModelName,
PromptTokens: result.PromptTokens,
CompletionTokens: result.CompletionTokens,
HistoryMessageCount: turn.HistoryCount,
RetrieverCount: turn.RetrieverCount,
PlannedSkillID: turn.SkillContext.SkillID(),
PlannedSkillName: turn.SkillContext.SkillName(),
PlanReason: turn.SkillContext.MatchReason,
SkillRouteTrace: turn.SkillContext.TraceData,
SkillAllowedToolCodes: append([]string(nil), turn.SkillContext.AllowedToolCodes...),
AgentRunID: runID,
HandoffRequested: turn.ResponsePolicy.RequestHandoff && !req.Debug,
TraceData: string(trace),
}, nil
}
func (e *AutonomousEngine) buildUserPrompt(req Request) (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 := autonomousMessageRole(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 := buildAutonomousCustomerContext(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 buildAutonomousCustomerContext(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 autonomousMessageRole(message models.Message) string {
switch message.SenderType {
case "customer":
return "Customer"
case "ai", "agent":
return "Assistant"
default:
return ""
}
}
func (e *AutonomousEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
return nil, errorsx.InvalidParam("autonomous agent has no resumable checkpoint")
}
func (e *AutonomousEngine) 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 autonomousSkillContext struct {
Skill *models.SkillDefinition
MatchReason string
TraceData string
ErrorMessage string
AllowedToolCodes []string
}
type autonomousResponsePolicy struct {
Enforced bool
Action string
Reason string
ReplyText string
RequestHandoff bool
}
func evaluateAutonomousResponsePolicy(agent models.AIAgent, knowledgeContext string, retrieveErr error) autonomousResponsePolicy {
if len(utils.SplitInt64s(agent.KnowledgeIDs)) == 0 || strings.TrimSpace(knowledgeContext) != "" && retrieveErr == nil {
return autonomousResponsePolicy{}
}
if retrieveErr != nil {
return autonomousResponsePolicy{Action: "retrieval_unavailable", Reason: "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 autonomousResponsePolicy{Action: "evidence_required", Reason: "knowledge_evidence_missing"}
}
func autonomousToolFailurePolicy(agent models.AIAgent, reason string) autonomousResponsePolicy {
if agent.FallbackMode == enums.AIAgentFallbackModeHandoff {
return autonomousResponsePolicy{
Enforced: true, Action: "handoff", Reason: reason, RequestHandoff: true,
ReplyText: autonomousToolFailureReply(agent),
}
}
return autonomousResponsePolicy{
Enforced: true, Action: "clarify", Reason: reason,
ReplyText: autonomousToolFailureReply(agent),
}
}
func autonomousToolFailureReply(agent models.AIAgent) string {
if reply := strings.TrimSpace(agent.FallbackMessage); reply != "" {
return reply
}
if agent.FallbackMode == enums.AIAgentFallbackModeHandoff {
return "暂时无法完成所需查询,正在为你转接人工客服。"
}
return "暂时无法完成所需查询,请补充更具体的信息后再试一次。"
}
func autonomousHasConsecutiveToolFailures(calls []svc.EngineToolCallInput, minimum int) bool {
if minimum <= 0 {
return false
}
failures := 0
for index := len(calls) - 1; index >= 0; index-- {
if calls[index].Status != "failed" {
break
}
failures++
}
return failures >= minimum
}
func (c autonomousSkillContext) SkillID() int64 {
if c.Skill == nil {
return 0
}
return c.Skill.ID
}
func (c autonomousSkillContext) SkillName() string {
if c.Skill == nil {
return ""
}
return strings.TrimSpace(c.Skill.Name)
}
func (e *AutonomousEngine) selectSkill(ctx context.Context, req Request) autonomousSkillContext {
if e.skillSelect == nil || len(utils.SplitInt64s(req.AIAgent.SkillIDs)) == 0 {
return autonomousSkillContext{}
}
result, err := e.skillSelect(ctx, skills.RuntimeContext{
AIAgent: req.AIAgent, AIConfig: req.AIConfig, UserMessage: req.UserMessage.Content, ConversationID: req.Conversation.ID,
})
ret := autonomousSkillContext{}
if err != nil {
ret.ErrorMessage = err.Error()
return ret
}
if result == nil || result.Plan == nil {
return ret
}
ret.Skill = result.Plan.Skill
ret.MatchReason = strings.TrimSpace(result.Plan.MatchReason)
if result.Trace != nil {
data, _ := json.Marshal(result.Trace)
ret.TraceData = string(data)
}
if ret.Skill != nil {
ret.AllowedToolCodes = parseSkillToolWhitelist(ret.Skill.ToolWhitelist)
}
return ret
}
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
}
func intersectAutonomousToolCodes(agentAllowed, skillAllowed []string) []string {
if len(agentAllowed) == 0 || len(skillAllowed) == 0 {
return nil
}
allowed := make(map[string]struct{}, len(skillAllowed))
for _, item := range skillAllowed {
allowed[toolx.NormalizeToolCodeAlias(strings.TrimSpace(item))] = struct{}{}
}
ret := make([]string, 0, len(agentAllowed))
for _, item := range agentAllowed {
item = toolx.NormalizeToolCodeAlias(strings.TrimSpace(item))
if _, ok := allowed[item]; ok {
ret = append(ret, item)
}
}
return ret
}
type autonomousDirectTool struct {
ToolCode string `json:"toolCode"`
}
type autonomousToolSearchRequest struct {
ToolCode string `json:"toolCode"`
Arguments map[string]any `json:"arguments"`
}
type autonomousToolPolicy struct {
MaxTotalCalls int `json:"maxTotalCalls"`
MaxArgumentBytes int `json:"maxArgumentBytes"`
AllowedRiskLevels []string `json:"allowedRiskLevels"`
}
func parseAutonomousToolPolicy(raw string) autonomousToolPolicy {
policy := autonomousToolPolicy{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 autonomousAllowedMCPToolCodes(raw string) []string {
var items []autonomousDirectTool
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &items) != nil {
return nil
}
ret := make([]string, 0, len(items))
for _, item := range items {
if code := strings.TrimSpace(item.ToolCode); code != "" {
ret = append(ret, code)
}
}
return ret
}
func autonomousToolSearchDefinition() ai.ToolDefinition {
return ai.ToolDefinition{
Name: "tool_search",
Description: "Use a configured read-only tool only when it is needed to answer the customer. Pass the exact allowed toolCode and an arguments object.",
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 (e *AutonomousEngine) toolSearchExecutor(conversation models.Conversation, agent models.AIAgent, allowedCodes, skillAllowedCodes []string, toolPolicy autonomousToolPolicy, records *[]svc.EngineToolCallInput) ai.ToolCallExecutor {
return func(ctx context.Context, call ai.ToolCall) (string, error) {
startedAt := time.Now()
if call.Name != "tool_search" {
return "", fmt.Errorf("unsupported autonomous tool: %s", call.Name)
}
var req autonomousToolSearchRequest
if err := json.Unmarshal([]byte(call.Arguments), &req); err != nil {
return "", fmt.Errorf("invalid tool_search arguments: %w", err)
}
policy := aitooling.Policy{
AllowedToolCodes: allowedCodes, SkillAllowedToolCodes: skillAllowedCodes, AllowedRiskLevels: toolPolicy.AllowedRiskLevels,
CallCount: autonomousToolCallCount(*records, req.ToolCode),
TotalCallCount: len(*records),
MaxTotalCalls: toolPolicy.MaxTotalCalls,
MaxArgumentBytes: toolPolicy.MaxArgumentBytes,
Confirmed: true, // The Agent's persisted allow-list is the administrator approval boundary.
}
definition, resultPreview, err := executeAutonomousReadTool(ctx, conversation, agent, strings.TrimSpace(req.ToolCode), req.Arguments, policy)
if err != nil && definition.Code == "" {
mcpDefinition, result, mcpErr := aitooling.DefaultMCPExecutor.Execute(ctx, strings.TrimSpace(req.ToolCode), req.Arguments, policy)
definition, err = mcpDefinition, mcpErr
resultPreview = runtimetooling.BuildReducedToolResultSummary(result)
}
durationMS := int(time.Since(startedAt).Milliseconds())
record := svc.EngineToolCallInput{
ToolCode: strings.TrimSpace(req.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"
record.ErrorMessage = err.Error()
*records = append(*records, record)
return "", err
}
record.ResultPreview = aitooling.SanitizePreview(resultPreview)
*records = append(*records, record)
return record.ResultPreview, nil
}
}
func executeAutonomousReadTool(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 := retrieveAutonomousKnowledge(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": autonomousToolConversationMessages(conversation.ID),
})
if err != nil {
return definition, "", err
}
return definition, string(result), nil
}
func autonomousToolConversationMessages(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 := autonomousMessageRole(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 autonomousToolCallCount(records []svc.EngineToolCallInput, 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 retrieveAutonomousKnowledge(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 buildAutonomousSystemPrompt(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."
}
return prompt
}
func writeAutonomousRun(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount int, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, responsePolicy autonomousResponsePolicy, toolCalls []svc.EngineToolCallInput, 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{"engine": EngineCodeAutonomous, "mode": autonomousExecutionMode(autonomousAllowedMCPToolCodes(req.AIAgent.AllowedMCPTools)), "status": status, "historyMessageCount": historyCount, "retrieverCount": retrieverCount})
var runID int64
err := sqls.WithTransaction(func(tx *sqls.TxContext) error {
var recordErr error
runID, recordErr = svc.AgentRunService.RecordEngineRun(tx.Tx, svc.EngineAgentRunInput{
ConversationID: req.Conversation.ID, AIAgentID: req.AIAgent.ID, AgentRevisionID: req.AIAgent.PublishedRevisionID,
SourceMessageID: req.UserMessage.ID, EngineCode: EngineCodeAutonomous, 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: autonomousAdditionalSteps(req, retrieverCount, retrieveErr, skillContext, responsePolicy),
ToolCalls: toolCalls,
})
return recordErr
})
return runID, err
}
func autonomousExecutionMode(allowedTools []string) string {
if len(allowedTools) > 0 {
return "tool_calling_loop"
}
return "single_model_turn"
}
func autonomousAdditionalSteps(req Request, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, responsePolicy autonomousResponsePolicy) []svc.EngineStepInput {
steps := make([]svc.EngineStepInput, 0, 3)
if len(utils.SplitInt64s(req.AIAgent.SkillIDs)) > 0 {
status := "completed"
if skillContext.ErrorMessage != "" {
status = "failed"
}
steps = append(steps, svc.EngineStepInput{
StepType: "skill_route", StepCode: "skill_select", Status: status,
InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "selected skill: " + skillContext.SkillName(),
ErrorMessage: skillContext.ErrorMessage,
})
}
if len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0 {
status := "completed"
errorMessage := ""
if retrieveErr != nil {
status = "failed"
errorMessage = retrieveErr.Error()
}
steps = append(steps, svc.EngineStepInput{
StepType: "knowledge", StepCode: "knowledge_retrieve", Status: status,
InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "retrieved context items: " + strconv.Itoa(retrieverCount), ErrorMessage: errorMessage,
})
}
if responsePolicy.Enforced || responsePolicy.Reason != "" {
policyCode := "knowledge_evidence"
if strings.HasPrefix(responsePolicy.Reason, "tool_") {
policyCode = "tool_failure"
}
status := "completed"
if !responsePolicy.Enforced {
status = "advisory"
}
steps = append(steps, svc.EngineStepInput{
StepType: "policy", StepCode: policyCode, Status: status,
InputPreview: responsePolicy.Reason, OutputPreview: responsePolicy.Action,
})
}
return steps
}
var _ Engine = (*AutonomousEngine)(nil)
@@ -1,61 +0,0 @@
package runtime
import (
"context"
"strings"
"agent-desk/internal/ai/runtime/instruction"
"agent-desk/internal/pkg/utils"
)
// autonomousTurn is the prepared context for an autonomous or hybrid model
// turn. It keeps context assembly separate from engine-specific execution and
// auditing.
type autonomousTurn struct {
SkillContext autonomousSkillContext
RetrieverCount int
RetrieveErr error
ResponsePolicy autonomousResponsePolicy
SystemPrompt string
UserPrompt string
HistoryCount int
AgentAllowedTools []string
AllowedTools []string
ToolPolicy autonomousToolPolicy
}
func (e *AutonomousEngine) prepareTurn(ctx context.Context, req Request) autonomousTurn {
skillContext := e.selectSkill(ctx, req)
knowledgeContext, retrieverCount, retrieveErr := e.retrieveKnowledge(ctx, req.AIAgent, req.UserMessage.Content)
responsePolicy := evaluateAutonomousResponsePolicy(req.AIAgent, knowledgeContext, retrieveErr)
systemPrompt := buildAutonomousSystemPrompt(req.AIAgent, len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0, knowledgeContext, retrieveErr)
if skillInstruction := strings.TrimSpace(instruction.BuildSkillDocument(skillContext.Skill, nil)); skillInstruction != "" {
systemPrompt += "\n\nSkill instructions:\n" + skillInstruction
}
userPrompt, historyCount := e.buildUserPrompt(req)
if knowledgeContext != "" {
userPrompt += "\n\nKnowledge evidence:\n" + knowledgeContext
}
agentAllowedTools := autonomousAllowedMCPToolCodes(req.AIAgent.AllowedMCPTools)
allowedTools := agentAllowedTools
if skillContext.Skill != nil {
allowedTools = intersectAutonomousToolCodes(agentAllowedTools, skillContext.AllowedToolCodes)
}
if req.Debug {
// Dashboard debug runs may inspect model and retrieval behavior but must
// not invoke direct MCP tools against production integrations.
allowedTools = nil
}
return autonomousTurn{
SkillContext: skillContext,
RetrieverCount: retrieverCount,
RetrieveErr: retrieveErr,
ResponsePolicy: responsePolicy,
SystemPrompt: systemPrompt,
UserPrompt: userPrompt,
HistoryCount: historyCount,
AgentAllowedTools: agentAllowedTools,
AllowedTools: allowedTools,
ToolPolicy: parseAutonomousToolPolicy(req.AIAgent.ToolPolicy),
}
}
@@ -0,0 +1,159 @@
package runtime
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
ai "agent-desk/internal/ai"
"agent-desk/internal/models"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
einomodel "github.com/cloudwego/eino/components/model"
einotool "github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/flow/agent/react"
"github.com/cloudwego/eino/schema"
einojsonschema "github.com/eino-contrib/jsonschema"
)
// einoAgentLoop is the production model/tool loop. AgentDesk still owns tool
// authorization, business execution, interrupts, idempotency, and auditing.
func einoAgentLoop(
ctx context.Context,
config models.AIConfig,
systemPrompt string,
userPrompt string,
definitions []ai.ToolDefinition,
maxSteps int,
execute ai.ToolCallExecutor,
) (*ai.ToolLoopResult, error) {
model, err := newEinoChatModel(ctx, config)
if err != nil {
return nil, err
}
if maxSteps <= 0 {
maxSteps = 6
}
tools := make([]einotool.BaseTool, 0, len(definitions))
for _, definition := range definitions {
tool, buildErr := newEinoFunctionTool(definition, execute)
if buildErr != nil {
return nil, buildErr
}
tools = append(tools, tool)
}
agent, err := react.NewAgent(ctx, &react.AgentConfig{
ToolCallingModel: model,
ToolsConfig: compose.ToolsNodeConfig{Tools: tools},
MaxStep: maxSteps,
})
if err != nil {
return nil, fmt.Errorf("create Eino agent loop: %w", err)
}
messages := make([]*schema.Message, 0, 2)
if value := strings.TrimSpace(systemPrompt); value != "" {
messages = append(messages, schema.SystemMessage(value))
}
messages = append(messages, schema.UserMessage(strings.TrimSpace(userPrompt)))
result, err := agent.Generate(ctx, messages)
if err != nil {
return nil, err
}
if result == nil {
return nil, fmt.Errorf("Eino agent loop returned no result")
}
ret := &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{
Content: strings.TrimSpace(result.Content),
ModelName: config.ModelName,
}}
if result.ResponseMeta != nil && result.ResponseMeta.Usage != nil {
ret.PromptTokens = result.ResponseMeta.Usage.PromptTokens
ret.CompletionTokens = result.ResponseMeta.Usage.CompletionTokens
}
return ret, nil
}
func newEinoChatModel(ctx context.Context, config models.AIConfig) (einomodel.ToolCallingChatModel, error) {
if strings.TrimSpace(config.APIKey) == "" || strings.TrimSpace(config.BaseURL) == "" || strings.TrimSpace(config.ModelName) == "" {
return nil, fmt.Errorf("ai config base URL, API key, and model name are required")
}
modelConfig := &einoopenai.ChatModelConfig{
APIKey: strings.TrimSpace(config.APIKey),
BaseURL: strings.TrimSpace(config.BaseURL),
Model: strings.TrimSpace(config.ModelName),
}
if config.TimeoutMS > 0 {
modelConfig.Timeout = time.Duration(config.TimeoutMS) * time.Millisecond
}
if config.MaxOutputTokens > 0 {
maxTokens := config.MaxOutputTokens
modelConfig.MaxCompletionTokens = &maxTokens
}
if isDashScopeQwenThinkingModel(config) {
modelConfig.ExtraFields = map[string]any{"enable_thinking": false}
}
model, err := einoopenai.NewChatModel(ctx, modelConfig)
if err != nil {
return nil, fmt.Errorf("create Eino OpenAI-compatible model: %w", err)
}
return model, nil
}
func isDashScopeQwenThinkingModel(config models.AIConfig) bool {
baseURL := strings.ToLower(strings.TrimSpace(config.BaseURL))
modelName := strings.ToLower(strings.TrimSpace(config.ModelName))
return strings.Contains(baseURL, "dashscope.aliyuncs.com") && strings.HasPrefix(modelName, "qwen3")
}
type einoFunctionTool struct {
info *schema.ToolInfo
execute ai.ToolCallExecutor
}
var _ einotool.InvokableTool = (*einoFunctionTool)(nil)
func newEinoFunctionTool(definition ai.ToolDefinition, execute ai.ToolCallExecutor) (*einoFunctionTool, error) {
if strings.TrimSpace(definition.Name) == "" || execute == nil {
return nil, fmt.Errorf("Eino tool name and executor are required")
}
info := &schema.ToolInfo{
Name: strings.TrimSpace(definition.Name),
Desc: strings.TrimSpace(definition.Description),
}
if len(definition.Parameters) > 0 {
data, err := json.Marshal(definition.Parameters)
if err != nil {
return nil, fmt.Errorf("encode Eino tool schema: %w", err)
}
var params einojsonschema.Schema
if err := json.Unmarshal(data, &params); err != nil {
return nil, fmt.Errorf("decode Eino tool schema: %w", err)
}
info.ParamsOneOf = schema.NewParamsOneOfByJSONSchema(&params)
}
return &einoFunctionTool{info: info, execute: execute}, nil
}
func (t *einoFunctionTool) Info(context.Context) (*schema.ToolInfo, error) {
return t.info, nil
}
func (t *einoFunctionTool) InvokableRun(ctx context.Context, arguments string, _ ...einotool.Option) (string, error) {
result, err := t.execute(ctx, ai.ToolCall{Name: t.info.Name, Arguments: arguments})
if err == nil {
return result, nil
}
var interrupt *agentLoopInterruptError
if errors.As(err, &interrupt) {
return "", err
}
observation, marshalErr := json.Marshal(map[string]string{"error": err.Error()})
if marshalErr != nil {
return "", err
}
return string(observation), nil
}
@@ -1,102 +0,0 @@
package einoexperiment
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
applicationruntime "agent-desk/internal/ai/application/runtime"
"agent-desk/internal/ai/runtime/graphs"
)
const confirmationInterruptType = "human_confirm"
// ConfirmationRequest represents a high-risk Eino tool action that must pause
// at AgentDesk's existing conversation-interrupt boundary.
type ConfirmationRequest struct {
InterruptID string
ToolCode string
Prompt string
Arguments map[string]any
}
type confirmationCheckpoint struct {
Version int `json:"version"`
Engine string `json:"engine"`
InterruptID string `json:"interruptId"`
ToolCode string `json:"toolCode"`
Arguments map[string]any `json:"arguments"`
}
// BuildConfirmationResult returns the generic interrupted result consumed by
// replyInterruptService. That service persists ConversationInterrupt from the
// result, so this package remains independent of database writes.
func BuildConfirmationResult(input applicationruntime.RunInput, request ConfirmationRequest) (*applicationruntime.RunResult, error) {
interruptID := strings.TrimSpace(request.InterruptID)
if interruptID == "" {
interruptID = "eino_confirm"
}
toolCode := strings.TrimSpace(request.ToolCode)
if toolCode == "" {
return nil, fmt.Errorf("confirmation tool code is required")
}
prompt := strings.TrimSpace(request.Prompt)
if prompt == "" {
return nil, fmt.Errorf("confirmation prompt is required")
}
checkpointData, err := json.Marshal(confirmationCheckpoint{
Version: 1, Engine: "eino", InterruptID: interruptID, ToolCode: toolCode, Arguments: cloneConfirmationArguments(request.Arguments),
})
if err != nil {
return nil, fmt.Errorf("encode Eino confirmation checkpoint: %w", err)
}
return &applicationruntime.RunResult{
Status: "interrupted",
Interrupted: true,
CheckPointID: confirmationCheckpointID(input, interruptID, toolCode, checkpointData),
CheckPointData: string(checkpointData),
Interrupts: []applicationruntime.InterruptContextSummary{{
Type: confirmationInterruptType, ID: interruptID, InfoPreview: string(mustMarshalConfirmationPrompt(prompt)),
}},
}, nil
}
// ResumeConfirmation reads the generic ResumeInput populated by the existing
// AgentApplicationService and validates that it belongs to this checkpoint.
func ResumeConfirmation(checkPointData string, input applicationruntime.ResumeInput) (string, confirmationCheckpoint, error) {
checkpoint := confirmationCheckpoint{}
if err := json.Unmarshal([]byte(strings.TrimSpace(checkPointData)), &checkpoint); err != nil {
return "", checkpoint, fmt.Errorf("decode Eino confirmation checkpoint: %w", err)
}
if checkpoint.Version != 1 || checkpoint.Engine != "eino" || strings.TrimSpace(checkpoint.InterruptID) == "" || strings.TrimSpace(checkpoint.ToolCode) == "" {
return "", checkpoint, fmt.Errorf("invalid Eino confirmation checkpoint")
}
decision := graphs.ParseConfirmationDecision(strings.TrimSpace(input.ResumeData[checkpoint.InterruptID]))
if decision == "" {
return "", checkpoint, fmt.Errorf("Eino confirmation decision is required")
}
return string(decision), checkpoint, nil
}
func confirmationCheckpointID(input applicationruntime.RunInput, interruptID, toolCode string, data []byte) string {
digest := sha256.Sum256(append([]byte(strings.TrimSpace(toolCode)+":"+strings.TrimSpace(interruptID)+":"), data...))
return fmt.Sprintf("eino:%d:%d:%s", input.Conversation.ID, input.UserMessage.ID, hex.EncodeToString(digest[:8]))
}
func cloneConfirmationArguments(input map[string]any) map[string]any {
if len(input) == 0 {
return map[string]any{}
}
ret := make(map[string]any, len(input))
for key, value := range input {
ret[key] = value
}
return ret
}
func mustMarshalConfirmationPrompt(prompt string) []byte {
data, _ := json.Marshal(map[string]string{"message": prompt})
return data
}
@@ -1,94 +0,0 @@
package einoexperiment
import (
"context"
"encoding/json"
"fmt"
"time"
aitooling "agent-desk/internal/ai/tooling"
einotool "github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
// ToolHandler is the adapter point from an approved Eino experiment tool to
// AgentDesk business services. Production handlers must still call services,
// never repositories.
type ToolHandler func(ctx context.Context, arguments map[string]any) (string, error)
// ToolTrace is emitted for every guarded invocation. A future Engine adapter
// can translate it into AgentRun tool-call audit records without coupling this
// experiment package to the service layer.
type ToolTrace struct {
ToolCode string
Arguments map[string]any
Status string
Result string
Err error
Duration time.Duration
}
type ToolTraceHook func(ToolTrace)
// GuardedTool adapts an Eino InvokableTool to the shared ToolPolicyGuard. It is
// deliberately generic so Tool Registry semantics are checked before a tool
// handler is invoked.
type GuardedTool struct {
InfoDefinition *schema.ToolInfo
Definition aitooling.Definition
Policy aitooling.Policy
Handler ToolHandler
Trace ToolTraceHook
}
var _ einotool.InvokableTool = (*GuardedTool)(nil)
func (t *GuardedTool) Info(context.Context) (*schema.ToolInfo, error) {
if t == nil || t.InfoDefinition == nil {
return nil, fmt.Errorf("eino experiment tool info is required")
}
return t.InfoDefinition, nil
}
func (t *GuardedTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...einotool.Option) (string, error) {
if t == nil || t.Handler == nil {
return "", fmt.Errorf("eino experiment tool handler is required")
}
startedAt := time.Now()
arguments := map[string]any{}
if err := json.Unmarshal([]byte(argumentsInJSON), &arguments); err != nil {
t.emitTrace(arguments, "failed", "", err, startedAt)
return "", fmt.Errorf("decode tool arguments: %w", err)
}
if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{
Definition: t.Definition,
Arguments: arguments,
Policy: t.Policy,
}); err != nil {
t.emitTrace(arguments, "failed", "", err, startedAt)
return "", err
}
if t.Definition.TimeoutMS > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(t.Definition.TimeoutMS)*time.Millisecond)
defer cancel()
}
result, err := t.Handler(ctx, arguments)
status := "completed"
if err != nil {
status = "failed"
}
t.emitTrace(arguments, status, result, err, startedAt)
return result, err
}
func (t *GuardedTool) emitTrace(arguments map[string]any, status, result string, err error, startedAt time.Time) {
if t == nil || t.Trace == nil {
return
}
t.Trace(ToolTrace{
ToolCode: t.Definition.Code, Arguments: arguments, Status: status, Result: result, Err: err,
Duration: time.Since(startedAt),
})
}
@@ -1,37 +0,0 @@
package einoexperiment
import (
"context"
"fmt"
"strings"
"agent-desk/internal/ai/mcps"
runtimetooling "agent-desk/internal/ai/runtime/tooling"
aitooling "agent-desk/internal/ai/tooling"
)
// MCPToolExecutor is the narrow execution boundary used by the Eino
// experiment. The production MCP executor remains responsible for dynamic
// registry resolution, policy enforcement, timeout, and transport lifecycle.
type MCPToolExecutor interface {
Execute(context.Context, string, map[string]any, aitooling.Policy) (aitooling.Definition, *mcps.ToolCallResult, error)
}
// NewMCPToolHandler adapts a dynamically discovered MCP tool to GuardedTool.
// Callers must still configure GuardedTool.Definition and Policy so its
// pre-handler guard provides a deterministic rejection before MCP transport.
func NewMCPToolHandler(executor MCPToolExecutor, toolCode string, policy aitooling.Policy) ToolHandler {
return func(ctx context.Context, arguments map[string]any) (string, error) {
if executor == nil {
return "", fmt.Errorf("eino experiment MCP executor is required")
}
definition, result, err := executor.Execute(ctx, strings.TrimSpace(toolCode), arguments, policy)
if err != nil {
return "", err
}
if definition.Code == "" {
return "", fmt.Errorf("MCP executor returned an empty tool definition")
}
return runtimetooling.BuildReducedToolResultSummary(result), nil
}
}
@@ -1,86 +0,0 @@
// Package einoexperiment contains an isolated Eino ReAct verification path.
// It must not be registered in the production Agent Engine registry.
package einoexperiment
import (
"context"
"fmt"
"strings"
"time"
"agent-desk/internal/models"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/flow/agent/react"
"github.com/cloudwego/eino/schema"
)
// ReActConfig keeps the experiment dependency-injected. The caller owns model
// construction, connection reuse, and all production configuration decisions.
type ReActConfig struct {
Model model.ToolCallingChatModel
Tools []tool.BaseTool
MaxSteps int
}
// NewOpenAICompatibleModel adapts an existing AgentDesk AI configuration to
// Eino's OpenAI-compatible chat model. It is intentionally not wired into any
// production Engine; the experiment owns the adoption decision.
func NewOpenAICompatibleModel(ctx context.Context, config models.AIConfig) (model.ToolCallingChatModel, error) {
if strings.TrimSpace(config.APIKey) == "" || strings.TrimSpace(config.BaseURL) == "" || strings.TrimSpace(config.ModelName) == "" {
return nil, fmt.Errorf("ai config base URL, API key, and model name are required")
}
modelConfig := &einoopenai.ChatModelConfig{
APIKey: strings.TrimSpace(config.APIKey),
BaseURL: strings.TrimSpace(config.BaseURL),
Model: strings.TrimSpace(config.ModelName),
}
if config.TimeoutMS > 0 {
modelConfig.Timeout = time.Duration(config.TimeoutMS) * time.Millisecond
}
if config.MaxOutputTokens > 0 {
maxTokens := config.MaxOutputTokens
modelConfig.MaxCompletionTokens = &maxTokens
}
return einoopenai.NewChatModel(ctx, modelConfig)
}
// NewReAct creates an Eino ReAct agent without registering it with AgentDesk's
// runtime. It is deliberately suitable only for technical verification.
func NewReAct(ctx context.Context, config ReActConfig) (*react.Agent, error) {
if config.Model == nil {
return nil, fmt.Errorf("eino experiment model is required")
}
maxSteps := config.MaxSteps
if maxSteps <= 0 {
maxSteps = 5
}
return react.NewAgent(ctx, &react.AgentConfig{
ToolCallingModel: config.Model,
ToolsConfig: compose.ToolsNodeConfig{Tools: config.Tools},
MaxStep: maxSteps,
})
}
// Run performs one non-streaming experiment. Context cancellation is passed
// directly to Eino and the injected model/tools.
func Run(ctx context.Context, config ReActConfig, input []*schema.Message) (*schema.Message, error) {
agent, err := NewReAct(ctx, config)
if err != nil {
return nil, err
}
return agent.Generate(ctx, input)
}
// Stream performs one streaming experiment. The caller must close the returned
// reader after consuming it.
func Stream(ctx context.Context, config ReActConfig, input []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
agent, err := NewReAct(ctx, config)
if err != nil {
return nil, err
}
return agent.Stream(ctx, input)
}
@@ -1,376 +0,0 @@
package einoexperiment
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
applicationruntime "agent-desk/internal/ai/application/runtime"
"agent-desk/internal/ai/mcps"
"agent-desk/internal/ai/runtime/graphs"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
type scriptedToolCallingModel struct {
responses []*schema.Message
calls int
err error
block bool
lastInput []*schema.Message
}
type fakeMCPToolExecutor struct {
toolCode string
arguments map[string]any
policy aitooling.Policy
result *mcps.ToolCallResult
err error
}
type concurrentToolCallingModel struct {
calls atomic.Int32
}
var _ model.ToolCallingChatModel = (*concurrentToolCallingModel)(nil)
func (m *concurrentToolCallingModel) Generate(ctx context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
m.calls.Add(1)
return schema.AssistantMessage("并发调用完成。", nil), nil
}
func (m *concurrentToolCallingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
message, err := m.Generate(ctx, input, opts...)
if err != nil {
return nil, err
}
return schema.StreamReaderFromArray([]*schema.Message{message}), nil
}
func (m *concurrentToolCallingModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
return m, nil
}
func (e *fakeMCPToolExecutor) Execute(_ context.Context, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, *mcps.ToolCallResult, error) {
e.toolCode = toolCode
e.arguments = arguments
e.policy = policy
return aitooling.Definition{Code: toolCode, RiskLevel: aitooling.RiskLevelRead}, e.result, e.err
}
var _ model.ToolCallingChatModel = (*scriptedToolCallingModel)(nil)
func (m *scriptedToolCallingModel) Generate(ctx context.Context, input []*schema.Message, _ ...model.Option) (*schema.Message, error) {
m.lastInput = append([]*schema.Message(nil), input...)
if err := ctx.Err(); err != nil {
return nil, err
}
if m.block {
<-ctx.Done()
return nil, ctx.Err()
}
if m.err != nil {
return nil, m.err
}
if m.calls >= len(m.responses) {
return nil, errors.New("unexpected model call")
}
result := m.responses[m.calls]
m.calls++
return result, nil
}
func (m *scriptedToolCallingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
message, err := m.Generate(ctx, input, opts...)
if err != nil {
return nil, err
}
return schema.StreamReaderFromArray([]*schema.Message{message}), nil
}
func (m *scriptedToolCallingModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
return m, nil
}
func TestRunExecutesGuardedToolThenReturnsFinalAnswer(t *testing.T) {
called := false
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "customer_lookup", Desc: "Read customer data"},
Definition: aitooling.Definition{Code: "builtin/customer_lookup", RiskLevel: aitooling.RiskLevelRead},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/customer_lookup"}},
Handler: func(_ context.Context, arguments map[string]any) (string, error) {
called = arguments["customerId"] == "42"
return "customer: Ada", nil
},
}
model := &scriptedToolCallingModel{responses: []*schema.Message{
schema.AssistantMessage("", []schema.ToolCall{{ID: "call-1", Type: "function", Function: schema.FunctionCall{Name: "customer_lookup", Arguments: `{"customerId":"42"}`}}}),
schema.AssistantMessage("已找到客户资料。", nil),
}}
result, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}, MaxSteps: 4}, []*schema.Message{schema.UserMessage("查询客户")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if !called || result == nil || result.Content != "已找到客户资料。" || model.calls != 2 {
t.Fatalf("unexpected ReAct result: called=%t result=%#v modelCalls=%d", called, result, model.calls)
}
}
func TestRunInjectsProvidedConversationContext(t *testing.T) {
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("已理解上下文。", nil)}}
input := []*schema.Message{
schema.SystemMessage("你是客服助手,优先引用知识库。"),
schema.UserMessage("我的订单状态如何?"),
}
if _, err := Run(context.Background(), ReActConfig{Model: model}, input); err != nil {
t.Fatalf("Run: %v", err)
}
if len(model.lastInput) != len(input) || model.lastInput[0].Content != input[0].Content || model.lastInput[1].Content != input[1].Content {
t.Fatalf("conversation context was not passed to model: %#v", model.lastInput)
}
}
func TestNewOpenAICompatibleModelValidatesExistingAIConfig(t *testing.T) {
if _, err := NewOpenAICompatibleModel(context.Background(), models.AIConfig{}); err == nil {
t.Fatal("expected incomplete AI config error")
}
configured, err := NewOpenAICompatibleModel(context.Background(), models.AIConfig{
BaseURL: "https://api.example.test/v1", APIKey: "test-key", ModelName: "test-model", TimeoutMS: 1200, MaxOutputTokens: 256,
})
if err != nil || configured == nil {
t.Fatalf("expected OpenAI-compatible model adapter, model=%#v err=%v", configured, err)
}
}
func TestGuardedToolRejectsDisallowedPolicyBeforeHandler(t *testing.T) {
called := false
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "restricted_lookup", Desc: "Read restricted data"},
Definition: aitooling.Definition{Code: "builtin/restricted_lookup", RiskLevel: aitooling.RiskLevelRead},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/customer_lookup"}},
Handler: func(context.Context, map[string]any) (string, error) {
called = true
return "unexpected", nil
},
}
if _, err := guardedTool.InvokableRun(context.Background(), `{}`); err == nil {
t.Fatal("expected policy rejection")
}
if called {
t.Fatal("handler must not run after policy rejection")
}
}
func TestRunPropagatesCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("unused", nil)}}
if _, err := Run(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.Canceled) {
t.Fatalf("expected cancellation, got %v", err)
}
}
func TestRunPropagatesDeadlineDuringModelCall(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
model := &scriptedToolCallingModel{block: true}
if _, err := Run(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected deadline propagation, got %v", err)
}
}
func TestRunPropagatesModelFailure(t *testing.T) {
modelErr := errors.New("model unavailable")
model := &scriptedToolCallingModel{err: modelErr}
if _, err := Run(context.Background(), ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, modelErr) {
t.Fatalf("expected model error propagation, got %v", err)
}
}
func TestRunPropagatesToolFailure(t *testing.T) {
toolErr := errors.New("customer service unavailable")
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "failing_lookup", Desc: "Read customer data"},
Definition: aitooling.Definition{Code: "builtin/failing_lookup", RiskLevel: aitooling.RiskLevelRead},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/failing_lookup"}},
Handler: func(context.Context, map[string]any) (string, error) {
return "", toolErr
},
}
model := &scriptedToolCallingModel{responses: []*schema.Message{
schema.AssistantMessage("", []schema.ToolCall{{ID: "call-1", Type: "function", Function: schema.FunctionCall{Name: "failing_lookup", Arguments: `{}`}}}),
}}
if _, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, toolErr) {
t.Fatalf("expected tool error propagation, got %v", err)
}
}
func TestGuardedToolEnforcesTimeout(t *testing.T) {
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "slow_lookup", Desc: "Read customer data"},
Definition: aitooling.Definition{Code: "builtin/slow_lookup", RiskLevel: aitooling.RiskLevelRead, TimeoutMS: 20},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/slow_lookup"}},
Handler: func(ctx context.Context, _ map[string]any) (string, error) {
<-ctx.Done()
return "", ctx.Err()
},
}
if _, err := guardedTool.InvokableRun(context.Background(), `{}`); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected tool timeout, got %v", err)
}
}
func TestGuardedToolEmitsTraceForPolicyFailure(t *testing.T) {
var trace ToolTrace
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "restricted_lookup", Desc: "Read restricted data"},
Definition: aitooling.Definition{Code: "builtin/restricted_lookup", RiskLevel: aitooling.RiskLevelRead},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/other_lookup"}},
Handler: func(context.Context, map[string]any) (string, error) {
return "unexpected", nil
},
Trace: func(item ToolTrace) { trace = item },
}
if _, err := guardedTool.InvokableRun(context.Background(), `{"customerId":"42"}`); err == nil {
t.Fatal("expected policy rejection")
}
if trace.ToolCode != "builtin/restricted_lookup" || trace.Status != "failed" || trace.Err == nil || trace.Arguments["customerId"] != "42" || trace.Duration < 0 {
t.Fatalf("unexpected trace: %#v", trace)
}
}
func TestMCPToolHandlerUsesSharedExecutorAndReducesResult(t *testing.T) {
executor := &fakeMCPToolExecutor{result: &mcps.ToolCallResult{Content: []mcps.ToolResultContent{{Type: "text", Text: "customer: Ada"}}}}
policy := aitooling.Policy{AllowedToolCodes: []string{"crm/customer_lookup"}, Confirmed: true}
handler := NewMCPToolHandler(executor, "crm/customer_lookup", policy)
result, err := handler(context.Background(), map[string]any{"customerId": "42"})
if err != nil || result != "customer: Ada" {
t.Fatalf("unexpected MCP handler result=%q err=%v", result, err)
}
if executor.toolCode != "crm/customer_lookup" || executor.arguments["customerId"] != "42" || !executor.policy.Confirmed {
t.Fatalf("unexpected MCP execution: %#v", executor)
}
}
func TestRunStopsAtConfiguredMaxSteps(t *testing.T) {
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "loop_lookup", Desc: "Read loop data"},
Definition: aitooling.Definition{Code: "builtin/loop_lookup", RiskLevel: aitooling.RiskLevelRead},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/loop_lookup"}},
Handler: func(context.Context, map[string]any) (string, error) {
return "keep going", nil
},
}
responses := make([]*schema.Message, 8)
for i := range responses {
responses[i] = schema.AssistantMessage("", []schema.ToolCall{{
ID: "loop-call", Type: "function", Function: schema.FunctionCall{Name: "loop_lookup", Arguments: `{}`},
}})
}
model := &scriptedToolCallingModel{responses: responses}
if _, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}, MaxSteps: 2}, []*schema.Message{schema.UserMessage("循环查询")}); err == nil {
t.Fatal("expected configured maximum step limit to stop the loop")
}
}
func TestStreamReturnsModelOutput(t *testing.T) {
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("流式回复", nil)}}
stream, err := Stream(context.Background(), ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")})
if err != nil {
t.Fatalf("Stream: %v", err)
}
defer stream.Close()
result, err := schema.ConcatMessageStream(stream)
if err != nil {
t.Fatalf("ConcatMessageStream: %v", err)
}
if result.Content != "流式回复" {
t.Fatalf("unexpected stream result: %#v", result)
}
}
func TestStreamPropagatesCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("unused", nil)}}
if _, err := Stream(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.Canceled) {
t.Fatalf("expected stream cancellation, got %v", err)
}
}
func TestRunSupportsConcurrentIndependentCalls(t *testing.T) {
model := &concurrentToolCallingModel{}
const workers = 16
errs := make(chan error, workers)
var group sync.WaitGroup
for range workers {
group.Add(1)
go func() {
defer group.Done()
result, err := Run(context.Background(), ReActConfig{Model: model, MaxSteps: 3}, []*schema.Message{schema.UserMessage("并发查询")})
if err != nil {
errs <- err
return
}
if result == nil || result.Content != "并发调用完成。" {
errs <- errors.New("unexpected concurrent result")
}
}()
}
group.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
if model.calls.Load() != workers {
t.Fatalf("model calls = %d, want %d", model.calls.Load(), workers)
}
}
func TestConfirmationBridgeUsesGenericInterruptAndResumeContracts(t *testing.T) {
input := applicationruntime.RunInput{
Conversation: models.Conversation{ID: 11}, UserMessage: models.Message{ID: 22},
}
result, err := BuildConfirmationResult(input, ConfirmationRequest{
InterruptID: "confirm_refund", ToolCode: "graph/create_ticket_with_confirmation", Prompt: "是否确认提交退款工单?",
Arguments: map[string]any{"title": "退款申请"},
})
if err != nil {
t.Fatalf("BuildConfirmationResult: %v", err)
}
if !result.Interrupted || result.Status != "interrupted" || result.CheckPointID == "" || len(result.Interrupts) != 1 || result.Interrupts[0].Type != confirmationInterruptType || result.Interrupts[0].ID != "confirm_refund" {
t.Fatalf("unexpected confirmation result: %#v", result)
}
decision, checkpoint, err := ResumeConfirmation(result.CheckPointData, applicationruntime.ResumeInput{ResumeData: map[string]string{"confirm_refund": "确认"}})
if err != nil || decision != string(graphs.ConfirmationDecisionConfirm) || checkpoint.ToolCode != "graph/create_ticket_with_confirmation" || checkpoint.Arguments["title"] != "退款申请" {
t.Fatalf("unexpected resume bridge decision=%q checkpoint=%#v err=%v", decision, checkpoint, err)
}
decision, _, err = ResumeConfirmation(result.CheckPointData, applicationruntime.ResumeInput{ResumeData: map[string]string{"confirm_refund": "取消"}})
if err != nil || decision != string(graphs.ConfirmationDecisionCancel) {
t.Fatalf("unexpected cancellation decision=%q err=%v", decision, err)
}
}
func BenchmarkRunWithInjectedModel(b *testing.B) {
model := &concurrentToolCallingModel{}
input := []*schema.Message{schema.SystemMessage("你是客服助手。"), schema.UserMessage("查询订单状态")}
b.ReportAllocs()
b.ResetTimer()
for range b.N {
result, err := Run(context.Background(), ReActConfig{Model: model, MaxSteps: 3}, input)
if err != nil || result == nil || result.Content == "" {
b.Fatalf("Run result=%#v err=%v", result, err)
}
}
}
@@ -1,141 +0,0 @@
package einoexperiment
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-desk/internal/bootstrap"
"agent-desk/internal/models"
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/enums"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
einomodel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
// TestRealOpenAICompatibleEndpoint is intentionally opt-in because it spends
// a small amount of configured model quota. It verifies the production-shaped
// OpenAI-compatible adapter without exposing credentials in test output.
func TestRealOpenAICompatibleEndpoint(t *testing.T) {
if os.Getenv("EINO_EXPERIMENT_REAL") != "1" {
t.Skip("set EINO_EXPERIMENT_REAL=1 to run against the configured endpoint")
}
configPath := strings.TrimSpace(os.Getenv("EINO_EXPERIMENT_CONFIG"))
var err error
if configPath == "" {
configPath, err = findExperimentConfigPath()
if err != nil {
t.Fatal(err)
}
}
workingDir, err := os.Getwd()
if err != nil {
t.Fatalf("get working directory: %v", err)
}
repoRoot := filepath.Dir(filepath.Dir(configPath))
if err := os.Chdir(repoRoot); err != nil {
t.Fatalf("change to config root: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(workingDir) })
cfg, err := config.Load(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
db, err := bootstrap.InitDB(cfg.DB)
if err != nil {
t.Fatalf("open configured database: %v", err)
}
sqlDB, err := db.DB()
if err == nil {
defer sqlDB.Close()
}
var aiConfig models.AIConfig
if err := db.Where("model_type = ? AND status = ?", enums.AIModelTypeLLM, enums.StatusOk).Order("id").First(&aiConfig).Error; err != nil {
t.Fatalf("load enabled LLM config: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(maxInt(aiConfig.TimeoutMS, 30000))*time.Millisecond)
defer cancel()
model, err := NewOpenAICompatibleModel(ctx, aiConfig)
if err != nil {
t.Fatalf("create Eino model adapter: %v", err)
}
input := []*schema.Message{schema.SystemMessage("You are a terse service assistant."), schema.UserMessage("Reply with exactly: OK")}
startedAt := time.Now()
result, err := Run(ctx, ReActConfig{Model: model, MaxSteps: 2}, input)
if err != nil {
t.Fatalf("Eino ReAct request: %v", err)
}
if result == nil || strings.TrimSpace(result.Content) == "" {
t.Fatal("Eino endpoint returned an empty response")
}
if result.ResponseMeta == nil || result.ResponseMeta.Usage == nil {
t.Fatal("Eino endpoint did not return token usage")
}
t.Logf("real endpoint verified: latency=%s promptTokens=%d completionTokens=%d", time.Since(startedAt).Round(time.Millisecond), result.ResponseMeta.Usage.PromptTokens, result.ResponseMeta.Usage.CompletionTokens)
toolModel, err := model.WithTools([]*schema.ToolInfo{{
Name: "eino_echo",
Desc: "Echoes a short input. Always call this tool when asked to verify tool calling.",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"text": {Type: schema.String, Desc: "Short text to echo", Required: true},
}),
}})
if err != nil {
t.Fatalf("bind Eino tool: %v", err)
}
toolResult, err := toolModel.Generate(ctx, []*schema.Message{schema.UserMessage("Verify tool calling by invoking eino_echo with text OK.")},
einomodel.WithToolChoice(schema.ToolChoiceForced, "eino_echo"),
einoopenai.WithExtraFields(map[string]any{"enable_thinking": false}),
)
if err != nil {
t.Fatalf("real endpoint tool call: %v", err)
}
if toolResult == nil || len(toolResult.ToolCalls) != 1 || toolResult.ToolCalls[0].Function.Name != "eino_echo" {
t.Fatalf("expected one eino_echo tool call, got %#v", toolResult)
}
stream, err := model.Stream(ctx, []*schema.Message{schema.UserMessage("Reply with exactly: STREAM_OK")})
if err != nil {
t.Fatalf("real endpoint stream: %v", err)
}
// ConcatMessageStream consumes and closes the Eino reader. Do not close it
// again here: v0.9.6 treats a second close as a panic.
streamResult, err := schema.ConcatMessageStream(stream)
if err != nil {
t.Fatalf("concat real stream: %v", err)
}
if streamResult == nil || strings.TrimSpace(streamResult.Content) == "" {
t.Fatal("Eino endpoint stream returned an empty response")
}
t.Logf("real endpoint tool and stream verified: toolCalls=%d streamChars=%d", len(toolResult.ToolCalls), len([]rune(streamResult.Content)))
}
func findExperimentConfigPath() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
candidate := filepath.Join(dir, "config", "config.yaml")
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", os.ErrNotExist
}
dir = parent
}
}
func maxInt(value, fallback int) int {
if value > 0 {
return value
}
return fallback
}
-58
View File
@@ -1,58 +0,0 @@
package runtime
import (
"context"
"errors"
"strings"
"agent-desk/internal/pkg/errorsx"
)
const (
EngineCodeWorkflow = "workflow"
EngineCodeAutonomous = "autonomous"
)
// Engine executes one Agent Runtime mode. Implementations must keep business
// mutations behind AgentDesk services and return a normalized RunResult.
type Engine interface {
Code() string
Run(ctx context.Context, req RunInput) (*RunResult, error)
Resume(ctx context.Context, req ResumeInput) (*RunResult, error)
}
// EngineRegistry resolves the runtime implementation. Workflow is the default
// until Agent runtime modes are persisted on AIAgent in the next migration.
type EngineRegistry struct {
engines map[string]Engine
}
func NewEngineRegistry(engines ...Engine) *EngineRegistry {
registry := &EngineRegistry{engines: make(map[string]Engine, len(engines))}
for _, engine := range engines {
if engine == nil || strings.TrimSpace(engine.Code()) == "" {
continue
}
registry.engines[strings.TrimSpace(engine.Code())] = engine
}
return registry
}
func NewDefaultEngineRegistry() *EngineRegistry {
return NewEngineRegistry(NewWorkflowEngine(), NewAutonomousEngine(), NewHybridEngine())
}
func (r *EngineRegistry) Resolve(code string) (Engine, error) {
if r == nil {
return nil, errors.New("agent runtime engine registry is not configured")
}
code = strings.TrimSpace(code)
if code == "" {
code = EngineCodeWorkflow
}
engine := r.engines[code]
if engine == nil {
return nil, errorsx.InvalidParam("agent runtime engine does not exist")
}
return engine, nil
}
@@ -1,597 +0,0 @@
package runtime
import (
"context"
"fmt"
"strings"
"testing"
ai "agent-desk/internal/ai"
"agent-desk/internal/ai/skills"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/toolx"
svc "agent-desk/internal/services"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestServiceDefaultsToWorkflowEngine(t *testing.T) {
service := NewService()
engine, err := service.registry.Resolve("")
if err != nil {
t.Fatalf("resolve default engine: %v", err)
}
if engine.Code() != EngineCodeWorkflow {
t.Fatalf("expected default engine %q, got %q", EngineCodeWorkflow, engine.Code())
}
}
func TestServiceDispatchesRequestedEngine(t *testing.T) {
engine := &runtimeTestEngine{code: "test"}
service := NewServiceWithRegistry(NewEngineRegistry(engine))
summary, err := service.Run(context.Background(), RunInput{AIAgent: models.AIAgent{RuntimeMode: enums.AIAgentRuntimeMode(engine.code)}})
if err != nil {
t.Fatalf("run requested engine: %v", err)
}
if !engine.ran || summary == nil || summary.Status != "completed" {
t.Fatalf("unexpected engine dispatch result: engine=%#v summary=%#v", engine, summary)
}
}
func TestEngineContractKeepsLegacyRequestAliasesCompatible(t *testing.T) {
var _ Engine = (*runtimeTestEngine)(nil)
var input Request = RunInput{}
var result Summary = RunResult{Status: "completed"}
if input.Debug || result.Status != "completed" {
t.Fatalf("unexpected compatibility values: input=%#v result=%#v", input, result)
}
}
func TestServiceRejectsUnknownEngine(t *testing.T) {
service := NewServiceWithRegistry(NewEngineRegistry())
if _, err := service.Run(context.Background(), Request{AIAgent: models.AIAgent{RuntimeMode: "missing"}}); err == nil {
t.Fatal("expected unknown engine error")
}
}
func TestAutonomousEngineRecordsPublishedRevisionRun(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 7, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
var receivedPrompt string
engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, _ string, prompt string) (*ai.ChatCompletionResult, error) {
receivedPrompt = prompt
return &ai.ChatCompletionResult{Content: "可以协助你处理这个问题。", ModelName: "test-model", PromptTokens: 8, CompletionTokens: 5}, nil
})
engine.retrieve = func(context.Context, models.AIAgent, string) (string, int, error) {
return "退款需要先确认订单号。", 1, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "需要帮助"},
AIAgent: models.AIAgent{ID: 7, PublishedRevisionID: revision.ID, SystemPrompt: "保持专业", KnowledgeIDs: "21"}, AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary == nil || summary.AgentRunID <= 0 || summary.ReplyText == "" {
t.Fatalf("unexpected summary: %#v", summary)
}
var run models.AgentRun
if err := db.First(&run, summary.AgentRunID).Error; err != nil {
t.Fatalf("load agent run: %v", err)
}
if run.EngineCode != EngineCodeAutonomous || run.AgentRevisionID != revision.ID || run.Status != "completed" {
t.Fatalf("unexpected agent run: %#v", run)
}
if run.PromptTokens != 8 || !strings.Contains(receivedPrompt, "Knowledge evidence") {
t.Fatalf("expected knowledge evidence in prompt, got %q", receivedPrompt)
}
var steps []models.AgentStep
if err := db.Where("agent_run_id = ?", run.ID).Find(&steps).Error; err != nil || len(steps) != 2 || steps[1].StepType != "knowledge" {
t.Fatalf("expected model and knowledge steps, steps=%#v err=%v", steps, err)
}
}
func TestAutonomousEngineRecordsRejectedReplyAsFailed(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 15, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) {
return &ai.ChatCompletionResult{Content: "token=secret-value"}, nil
})
_, err = engine.Run(context.Background(), Request{UserMessage: models.Message{ID: 2, Content: "help"}, AIAgent: models.AIAgent{ID: 15, PublishedRevisionID: revision.ID}})
if err == nil {
t.Fatal("expected sensitive model reply to be rejected")
}
var run models.AgentRun
if err := db.Last(&run).Error; err != nil || run.Status != "failed" || strings.Contains(run.ErrorMessage, "secret-value") {
t.Fatalf("expected failed audit run, run=%#v err=%v", run, err)
}
}
func TestAutonomousEngineBuildsBoundedConversationContext(t *testing.T) {
engine := newAutonomousEngineWithChat(nil)
engine.history = func(conversationID int64, limit int) []models.Message {
if conversationID != 11 || limit != 3 {
t.Fatalf("unexpected history query: conversation=%d limit=%d", conversationID, limit)
}
return []models.Message{
{ID: 1, SenderType: "customer", MessageType: "text", Content: "之前的问题"},
{ID: 2, SenderType: "ai", MessageType: "text", Content: "之前的答复"},
{ID: 3, SenderType: "customer", MessageType: "text", Content: "当前问题"},
}
}
prompt, count := engine.buildUserPrompt(Request{
Conversation: models.Conversation{ID: 11}, UserMessage: models.Message{ID: 3, Content: "当前问题", MessageType: "text"},
AIAgent: models.AIAgent{ContextWindow: 2},
})
if count != 2 || !strings.Contains(prompt, "Customer: 之前的问题") || !strings.Contains(prompt, "Assistant: 之前的答复") || strings.Count(prompt, "当前问题") != 1 {
t.Fatalf("unexpected assembled prompt: %q", prompt)
}
}
func TestAutonomousEngineBuildsCustomerContext(t *testing.T) {
engine := newAutonomousEngineWithChat(nil)
prompt, count := engine.buildUserPrompt(Request{
Conversation: models.Conversation{CustomerName: "张三", LastMessageSummary: "已咨询退款条件"},
UserMessage: models.Message{Content: "我要申请退款", MessageType: "text"},
})
if count != 0 || !strings.Contains(prompt, "Customer: 张三") || !strings.Contains(prompt, "Recent summary: 已咨询退款条件") || !strings.Contains(prompt, "Current customer message:\n我要申请退款") {
t.Fatalf("unexpected customer context: %q", prompt)
}
}
func TestAutonomousEngineInjectsSelectedSkillAndRecordsRoute(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 9, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
var systemPrompt string
engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) {
systemPrompt = system
return &ai.ChatCompletionResult{Content: "我来协助处理退款。", ModelName: "test-model"}, nil
})
engine.skillSelect = func(context.Context, skills.RuntimeContext) (*skills.ExecutionResult, error) {
return &skills.ExecutionResult{Plan: &skills.ExecutionPlan{
Skill: &models.SkillDefinition{ID: 70, Name: "退款处理", Instruction: "先核对订单信息。", Examples: `["我要退款"]`, ToolWhitelist: `["support/order_lookup"]`},
MatchReason: "llm_route",
}, Trace: &skills.ExecutionTrace{Status: "ok", MatchReason: "llm_route"}}, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "我要退款"},
AIAgent: models.AIAgent{ID: 9, PublishedRevisionID: revision.ID, SkillIDs: "70", SystemPrompt: "保持简洁"}, AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary.PlannedSkillID != 70 || summary.PlannedSkillName != "退款处理" || summary.PlanReason != "llm_route" {
t.Fatalf("unexpected skill summary: %#v", summary)
}
if !strings.Contains(systemPrompt, "先核对订单信息") || !strings.Contains(systemPrompt, "我要退款") {
t.Fatalf("selected skill was not injected into system prompt: %q", systemPrompt)
}
var steps []models.AgentStep
if err := db.Where("agent_run_id = ?", summary.AgentRunID).Find(&steps).Error; err != nil {
t.Fatalf("load steps: %v", err)
}
if len(steps) != 2 || steps[1].StepType != "skill_route" || steps[1].StepCode != "skill_select" {
t.Fatalf("expected model and skill route audit steps, got %#v", steps)
}
}
func TestAutonomousEngineLetsModelHandleGreetingWithoutKnowledgeEvidence(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 10, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
var systemPrompt string
engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) {
systemPrompt = system
return &ai.ChatCompletionResult{Content: "你好,有什么可以帮你?", ModelName: "test-model"}, nil
})
engine.retrieve = func(context.Context, models.AIAgent, string) (string, int, error) {
return "", 0, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "你好"},
AIAgent: models.AIAgent{ID: 10, PublishedRevisionID: revision.ID, KnowledgeIDs: "100"},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary.ReplyText != "你好,有什么可以帮你?" {
t.Fatalf("unexpected model reply: %#v", summary)
}
if !strings.Contains(systemPrompt, "answer greetings") || !strings.Contains(systemPrompt, "Knowledge retrieval found no supporting evidence") {
t.Fatalf("missing no-evidence greeting instructions: %q", systemPrompt)
}
var steps []models.AgentStep
if err := db.Where("agent_run_id = ?", summary.AgentRunID).Find(&steps).Error; err != nil {
t.Fatalf("load steps: %v", err)
}
if len(steps) != 3 || steps[1].StepType != "knowledge" || steps[2].StepType != "policy" || steps[2].StepCode != "knowledge_evidence" || steps[2].Status != "advisory" || steps[2].OutputPreview != "evidence_required" {
t.Fatalf("expected model, knowledge and policy steps, got %#v", steps)
}
}
func TestAutonomousEngineInstructsModelNotToInventFactsWithoutKnowledgeEvidence(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 13, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
var systemPrompt string
engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) {
systemPrompt = system
return &ai.ChatCompletionResult{Content: "我暂时没有查到保修期限的准确依据。请提供产品型号,我再继续查询。", ModelName: "test-model"}, nil
})
engine.retrieve = func(context.Context, models.AIAgent, string) (string, int, error) {
return "", 0, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "保修多久"},
AIAgent: models.AIAgent{ID: 13, PublishedRevisionID: revision.ID, KnowledgeIDs: "100"},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary.ReplyText != "我暂时没有查到保修期限的准确依据。请提供产品型号,我再继续查询。" {
t.Fatalf("unexpected model reply: %#v", summary)
}
if !strings.Contains(systemPrompt, "product facts, policies, pricing") || !strings.Contains(systemPrompt, "do not infer or invent an answer") {
t.Fatalf("missing factual-answer evidence constraints: %q", systemPrompt)
}
}
func TestAutonomousEngineDebugRunDoesNotExposeMCPTools(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 11, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) {
return &ai.ChatCompletionResult{Content: "调试回复", ModelName: "test-model"}, nil
})
engine.toolChat = func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
t.Fatal("debug run must not enter tool calling loop")
return nil, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "查询订单"},
AIAgent: models.AIAgent{ID: 11, PublishedRevisionID: revision.ID, AllowedMCPTools: `[{"toolCode":"orders/lookup"}]`},
AIConfig: models.AIConfig{ModelName: "test-model"}, Debug: true,
})
if err != nil || summary == nil || summary.ReplyText != "调试回复" {
t.Fatalf("unexpected debug run result: summary=%#v err=%v", summary, err)
}
}
func TestAutonomousEngineUsesPublishedRevisionSnapshot(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 12, Revision: 1, Definition: `{"agent":{"name":"published","aiConfigId":5,"runtimeMode":"autonomous","maxSteps":4,"systemPrompt":"published instruction"},"model":{"configId":5,"provider":"openai","baseUrl":"https://published.example/v1","modelType":"llm","modelName":"published-model","timeoutMs":12000}}`}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
var receivedSystem string
var receivedConfig models.AIConfig
engine := newAutonomousEngineWithChat(func(_ context.Context, config models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) {
receivedSystem = system
receivedConfig = config
return &ai.ChatCompletionResult{Content: "published response", ModelName: config.ModelName}, nil
})
_, err = engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "hello"},
AIAgent: models.AIAgent{ID: 12, PublishedRevisionID: revision.ID, SystemPrompt: "draft instruction", AIConfigID: 5},
AIConfig: models.AIConfig{ID: 5, APIKey: "rotated-key", ModelName: "draft-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if !strings.Contains(receivedSystem, "published instruction") || strings.Contains(receivedSystem, "draft instruction") {
t.Fatalf("system prompt did not use published snapshot: %q", receivedSystem)
}
if receivedConfig.ModelName != "published-model" || receivedConfig.BaseURL != "https://published.example/v1" || receivedConfig.APIKey != "rotated-key" {
t.Fatalf("model config did not use safe published snapshot: %#v", receivedConfig)
}
}
func TestHybridEngineUsesBoundPlaybookAndRecordsGenericAudit(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIWorkflowVersion{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
workflowVersion := &models.AIWorkflowVersion{WorkflowID: 21, Version: 1, Status: enums.StatusOk, Definition: `{"schemaVersion":2,"nodes":[{"id":"start_1","type":"start"},{"id":"end_1","type":"end"}],"edges":[{"sourceNodeID":"start_1","targetNodeID":"end_1"}]}`}
if err := db.Create(workflowVersion).Error; err != nil {
t.Fatalf("create workflow version: %v", err)
}
revision := &models.AgentRevision{AgentID: 14, Revision: 1, Status: enums.StatusOk, WorkflowVersionID: workflowVersion.ID, Definition: `{"agent":{"runtimeMode":"hybrid","systemPrompt":"published hybrid prompt","maxSteps":3},"workflowVersionId":1}`}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
engine := NewHybridEngine()
engine.chatWithTools = func(_ context.Context, _ models.AIConfig, system, _ string, definitions []ai.ToolDefinition, _ int, _ ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
if !strings.Contains(system, "published hybrid prompt") || len(definitions) != 1 || definitions[0].Name != "run_playbook" {
t.Fatalf("unexpected hybrid model context: system=%q definitions=%#v", system, definitions)
}
return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "这是自主回复。", ModelName: "test-model", PromptTokens: 5, CompletionTokens: 4}}, nil
}
summary, err := engine.Run(context.Background(), Request{
UserMessage: models.Message{ID: 3, Content: "普通咨询"},
AIAgent: models.AIAgent{ID: 14, RuntimeMode: enums.AIAgentRuntimeModeHybrid, PublishedRevisionID: revision.ID, WorkflowVersionID: workflowVersion.ID},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary == nil || summary.AgentRunID <= 0 || summary.WorkflowRunID != 0 || summary.ReplyText != "这是自主回复。" {
t.Fatalf("unexpected hybrid summary: %#v", summary)
}
var run models.AgentRun
if err := db.First(&run, summary.AgentRunID).Error; err != nil {
t.Fatalf("load agent run: %v", err)
}
if run.EngineCode != "hybrid" || run.AgentRevisionID != revision.ID || run.Status != "completed" {
t.Fatalf("unexpected hybrid audit: %#v", run)
}
}
func TestHybridEngineRejectsPlaybookWhenToolPolicyDisallowsWrites(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIWorkflowVersion{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
workflowVersion := &models.AIWorkflowVersion{WorkflowID: 22, Version: 1, Status: enums.StatusOk, Definition: `{"schemaVersion":2,"nodes":[{"id":"start_1","type":"start"},{"id":"end_1","type":"end"}],"edges":[{"sourceNodeID":"start_1","targetNodeID":"end_1"}]}`}
if err := db.Create(workflowVersion).Error; err != nil {
t.Fatalf("create workflow version: %v", err)
}
revision := &models.AgentRevision{AgentID: 15, Revision: 1, Status: enums.StatusOk, WorkflowVersionID: workflowVersion.ID, Definition: `{"agent":{"runtimeMode":"hybrid","systemPrompt":"published hybrid prompt","maxSteps":3,"toolPolicy":"{\"allowedRiskLevels\":[\"read\"]}"},"workflowVersionId":1}`}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
engine := NewHybridEngine()
engine.chatWithTools = func(ctx context.Context, _ models.AIConfig, _ string, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
_, err := execute(ctx, ai.ToolCall{Name: "run_playbook", Arguments: fmt.Sprintf(`{"workflowVersionId":%d}`, workflowVersion.ID)})
return nil, err
}
_, err = engine.Run(context.Background(), Request{
UserMessage: models.Message{ID: 4, Content: "请执行受控流程"},
AIAgent: models.AIAgent{ID: 15, RuntimeMode: enums.AIAgentRuntimeModeHybrid, PublishedRevisionID: revision.ID, WorkflowVersionID: workflowVersion.ID},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err == nil || !strings.Contains(err.Error(), "tool risk level is not allowed") {
t.Fatalf("expected tool policy rejection, got %v", err)
}
}
func TestIntersectAutonomousToolCodesUsesSkillWhitelist(t *testing.T) {
got := intersectAutonomousToolCodes([]string{"support/order_lookup", "support/create_ticket"}, []string{"support/order_lookup"})
if len(got) != 1 || got[0] != "support/order_lookup" {
t.Fatalf("intersection = %#v", got)
}
}
func TestParseAutonomousToolPolicyAndPerToolCount(t *testing.T) {
policy := parseAutonomousToolPolicy(`{"maxTotalCalls":2,"maxArgumentBytes":1024,"allowedRiskLevels":["read"]}`)
if policy.MaxTotalCalls != 2 || policy.MaxArgumentBytes != 1024 || len(policy.AllowedRiskLevels) != 1 {
t.Fatalf("unexpected policy: %#v", policy)
}
defaults := parseAutonomousToolPolicy(`{"maxTotalCalls":99,"maxArgumentBytes":999999}`)
if defaults.MaxTotalCalls != 3 || defaults.MaxArgumentBytes != 32*1024 {
t.Fatalf("invalid policy did not fall back to safe limits: %#v", defaults)
}
count := autonomousToolCallCount([]svc.EngineToolCallInput{{ToolCode: "orders/lookup"}, {ToolCode: "orders/other"}, {ToolCode: "orders/lookup"}}, "orders/lookup")
if count != 2 {
t.Fatalf("per-tool count = %d, want 2", count)
}
}
func TestAutonomousKnowledgeEvidencePolicyIsAdvisory(t *testing.T) {
policy := evaluateAutonomousResponsePolicy(models.AIAgent{KnowledgeIDs: "1", FallbackMode: enums.AIAgentFallbackModeHandoff}, "", nil)
if policy.Enforced || policy.RequestHandoff || policy.Action != "evidence_required" || policy.Reason != "knowledge_evidence_missing" {
t.Fatalf("unexpected knowledge evidence policy: %#v", policy)
}
}
func TestAutonomousToolFailurePolicyRequestsHandoffOnlyWhenConfigured(t *testing.T) {
handoff := autonomousToolFailurePolicy(models.AIAgent{FallbackMode: enums.AIAgentFallbackModeHandoff}, "tool_loop_error")
if !handoff.Enforced || !handoff.RequestHandoff || handoff.Action != "handoff" {
t.Fatalf("unexpected handoff policy: %#v", handoff)
}
clarify := autonomousToolFailurePolicy(models.AIAgent{FallbackMode: enums.AIAgentFallbackModeSuggestRetry}, "tool_loop_error")
if !clarify.Enforced || clarify.RequestHandoff || clarify.Action != "clarify" {
t.Fatalf("unexpected clarify policy: %#v", clarify)
}
}
func TestAutonomousConversationContextToolUsesRegistryPolicy(t *testing.T) {
definition, result, err := executeAutonomousReadTool(context.Background(), models.Conversation{CustomerName: "张三", LastMessageSummary: "咨询退款"}, models.AIAgent{}, toolx.BuiltinConversationContext.Code, nil, aitooling.Policy{
AllowedToolCodes: []string{toolx.BuiltinConversationContext.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelRead}, Confirmed: true,
})
if err != nil || definition.Code != toolx.BuiltinConversationContext.Code || !strings.Contains(result, `"customerName":"张三"`) {
t.Fatalf("unexpected conversation context tool result: definition=%#v result=%q err=%v", definition, result, err)
}
_, _, err = executeAutonomousReadTool(context.Background(), models.Conversation{}, models.AIAgent{}, toolx.BuiltinConversationContext.Code, nil, aitooling.Policy{
AllowedToolCodes: []string{toolx.BuiltinConversationContext.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelWrite}, Confirmed: true,
})
if err == nil || !strings.Contains(err.Error(), "risk level") {
t.Fatalf("expected read tool risk rejection, got %v", err)
}
}
func TestAutonomousEngineExecutesAndAuditsConversationContextTool(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}, &models.Message{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 13, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
if err := db.Create(&models.Message{ConversationID: 1, SenderType: "customer", MessageType: "text", Content: "上一轮问题"}).Error; err != nil {
t.Fatalf("create prior message: %v", err)
}
engine := newAutonomousEngineWithChat(nil)
engine.toolChat = func(ctx context.Context, _ models.AIConfig, _, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
output, err := execute(ctx, ai.ToolCall{ID: "call-1", Name: "tool_search", Arguments: `{"toolCode":"builtin/conversation_context","arguments":{}}`})
if err != nil || !strings.Contains(output, `"customerName":"张三"`) || !strings.Contains(output, "上一轮问题") {
t.Fatalf("execute tool: output=%q err=%v", output, err)
}
output, err = execute(ctx, ai.ToolCall{ID: "call-2", Name: "tool_search", Arguments: `{"toolCode":"graph/prepare_ticket_draft","arguments":{"issue":"重复扣费"}}`})
if err != nil || !strings.Contains(output, `"title":"重复扣费"`) {
t.Fatalf("execute ticket draft tool: output=%q err=%v", output, err)
}
output, err = execute(ctx, ai.ToolCall{ID: "call-3", Name: "tool_search", Arguments: `{"toolCode":"graph/analyze_conversation","arguments":{"observedIssue":"重复扣费","needTicket":true}}`})
if err != nil || !strings.Contains(output, `"userIntent":"ticket_request"`) {
t.Fatalf("execute conversation analysis tool: output=%q err=%v", output, err)
}
output, err = execute(ctx, ai.ToolCall{ID: "call-4", Name: "tool_search", Arguments: `{"toolCode":"graph/triage_service_request","arguments":{"observedIssue":"重复扣费","needTicket":true}}`})
if err != nil || !strings.Contains(output, `"recommendedAction":"prepare_ticket"`) || !strings.Contains(output, `"ticketDraft"`) {
t.Fatalf("execute service triage tool: output=%q err=%v", output, err)
}
return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "已查询到当前会话信息。", ModelName: "test-model"}}, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1, CustomerName: "张三", LastMessageSummary: "咨询退款"}, UserMessage: models.Message{ID: 2, Content: "请查一下当前会话"},
AIAgent: models.AIAgent{ID: 13, PublishedRevisionID: revision.ID, ToolPolicy: `{"maxTotalCalls":4}`, AllowedMCPTools: `[{"toolCode":"builtin/conversation_context"},{"toolCode":"graph/prepare_ticket_draft"},{"toolCode":"graph/analyze_conversation"},{"toolCode":"graph/triage_service_request"}]`},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
_, _, calls := svc.AgentRunService.GetDetail(summary.AgentRunID)
if len(calls) != 4 || calls[0].ToolCode != toolx.BuiltinConversationContext.Code || calls[1].ToolCode != toolx.GraphPrepareTicketDraft.Code || calls[2].ToolCode != toolx.GraphAnalyzeConversation.Code || calls[3].ToolCode != toolx.GraphTriageServiceRequest.Code || calls[3].Status != "completed" {
t.Fatalf("unexpected tool audit: %#v", calls)
}
}
func TestAutonomousEngineFallsBackAfterConsecutiveToolFailures(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 14, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
engine := newAutonomousEngineWithChat(nil)
engine.toolChat = func(ctx context.Context, _ models.AIConfig, _, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
for _, callID := range []string{"call-1", "call-2"} {
_, _ = execute(ctx, ai.ToolCall{ID: callID, Name: "tool_search", Arguments: `{"toolCode":"unknown/unsafe","arguments":{}}`})
}
return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "model reply should be replaced", ModelName: "test-model"}}, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "查询订单"},
AIAgent: models.AIAgent{ID: 14, PublishedRevisionID: revision.ID, FallbackMode: enums.AIAgentFallbackModeHandoff, FallbackMessage: "查询暂不可用,正在转人工。", AllowedMCPTools: `[{"toolCode":"builtin/conversation_context"}]`},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary.ReplyText != "查询暂不可用,正在转人工。" || !summary.HandoffRequested {
t.Fatalf("expected handoff fallback after tool failures, got %#v", summary)
}
_, steps, calls := svc.AgentRunService.GetDetail(summary.AgentRunID)
if len(calls) != 2 || calls[0].Status != "failed" || calls[1].Status != "failed" {
t.Fatalf("expected failed tool audits, got %#v", calls)
}
if len(steps) < 2 || steps[len(steps)-1].StepCode != "tool_failure" || steps[len(steps)-1].OutputPreview != "handoff" {
t.Fatalf("expected tool failure policy audit, got %#v", steps)
}
}
type runtimeTestEngine struct {
code string
ran bool
}
func (e *runtimeTestEngine) Code() string {
return e.code
}
func (e *runtimeTestEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) {
e.ran = true
return &RunResult{Status: "completed"}, nil
}
func (e *runtimeTestEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
return &RunResult{Status: "completed"}, nil
}
+8 -10
View File
@@ -25,7 +25,6 @@ type OfflineEvaluationCase struct {
type OfflineEvaluationResult struct {
CaseID string `json:"caseId"`
Category string `json:"category"`
EngineCode string `json:"engineCode"`
Passed bool `json:"passed"`
ReplyText string `json:"replyText"`
Interrupted bool `json:"interrupted"`
@@ -34,10 +33,9 @@ type OfflineEvaluationResult struct {
}
type OfflineEvaluationReport struct {
EngineCode string `json:"engineCode"`
Total int `json:"total"`
Passed int `json:"passed"`
Results []OfflineEvaluationResult `json:"results"`
Total int `json:"total"`
Passed int `json:"passed"`
Results []OfflineEvaluationResult `json:"results"`
}
// OfflineEvaluationRunner executes only isolated Debug requests. The supplied
@@ -51,10 +49,10 @@ func NewOfflineEvaluationRunner(run func(context.Context, RunInput) (*RunResult,
return &OfflineEvaluationRunner{run: run}
}
func (r *OfflineEvaluationRunner) Run(ctx context.Context, engineCode string, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) OfflineEvaluationReport {
report := OfflineEvaluationReport{EngineCode: strings.TrimSpace(engineCode), Results: make([]OfflineEvaluationResult, 0, len(cases))}
func (r *OfflineEvaluationRunner) Run(ctx context.Context, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) OfflineEvaluationReport {
report := OfflineEvaluationReport{Results: make([]OfflineEvaluationResult, 0, len(cases))}
for _, item := range cases {
result := OfflineEvaluationResult{CaseID: strings.TrimSpace(item.ID), Category: strings.TrimSpace(item.Category), EngineCode: report.EngineCode}
result := OfflineEvaluationResult{CaseID: strings.TrimSpace(item.ID), Category: strings.TrimSpace(item.Category)}
if r == nil || r.run == nil {
result.Error, result.Finding = "evaluation runner is not configured", "runner_missing"
report.Results = append(report.Results, result)
@@ -89,11 +87,11 @@ func (r *OfflineEvaluationRunner) Run(ctx context.Context, engineCode string, ag
func (r OfflineEvaluationReport) CSV() (string, error) {
var output strings.Builder
writer := csv.NewWriter(&output)
if err := writer.Write([]string{"caseId", "category", "engineCode", "passed", "interrupted", "finding", "error", "replyText"}); err != nil {
if err := writer.Write([]string{"caseId", "category", "passed", "interrupted", "finding", "error", "replyText"}); err != nil {
return "", err
}
for _, item := range r.Results {
if err := writer.Write([]string{item.CaseID, item.Category, item.EngineCode, strconv.FormatBool(item.Passed), strconv.FormatBool(item.Interrupted), item.Finding, item.Error, item.ReplyText}); err != nil {
if err := writer.Write([]string{item.CaseID, item.Category, strconv.FormatBool(item.Passed), strconv.FormatBool(item.Interrupted), item.Finding, item.Error, item.ReplyText}); err != nil {
return "", err
}
}
@@ -1,54 +0,0 @@
package runtime
import (
"context"
"strings"
"testing"
"agent-desk/internal/models"
)
func TestOfflineEvaluationRunnerUsesDebugIsolationAndExportsCSV(t *testing.T) {
var received []RunInput
runner := NewOfflineEvaluationRunner(func(_ context.Context, input RunInput) (*RunResult, error) {
received = append(received, input)
return &RunResult{ReplyText: "已根据知识库回答。"}, nil
})
report := runner.Run(context.Background(), "autonomous", models.AIAgent{ID: 12}, models.AIConfig{ID: 13}, []OfflineEvaluationCase{{ID: "faq", Category: "faq", Message: "保修期多久", History: []string{"客户:你好"}}})
if report.Total != 1 || report.Passed != 1 || len(received) != 1 || !received[0].Debug || received[0].Conversation.ID != 0 || received[0].UserMessage.RequestID != "offline-eval:faq" {
t.Fatalf("unexpected report or input: report=%#v input=%#v", report, received)
}
csv, err := report.CSV()
if err != nil || !strings.Contains(csv, "caseId,category,engineCode") || !strings.Contains(csv, "faq,faq,autonomous,true") {
t.Fatalf("unexpected csv=%q err=%v", csv, err)
}
}
func TestOfflineEvaluationRunnerChecksConfirmationExpectation(t *testing.T) {
runner := NewOfflineEvaluationRunner(func(context.Context, RunInput) (*RunResult, error) {
return &RunResult{ReplyText: "已转人工"}, nil
})
report := runner.Run(context.Background(), "workflow", models.AIAgent{}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "handoff", Expect: map[string]any{"requiresConfirmation": true}}})
if report.Passed != 0 || report.Results[0].Finding != "confirmation_not_reached" {
t.Fatalf("unexpected report: %#v", report)
}
}
func TestOfflineEvaluationRunnerChecksWriteToolLimit(t *testing.T) {
runner := NewOfflineEvaluationRunner(func(context.Context, RunInput) (*RunResult, error) {
return &RunResult{ReplyText: "调试回复", InvokedToolCodes: []string{"graph/handoff_to_human"}}, nil
})
report := runner.Run(context.Background(), "hybrid", models.AIAgent{}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "write", Expect: map[string]any{"maxWriteToolCalls": 0}}})
if report.Passed != 0 || report.Results[0].Finding != "write_tool_limit_exceeded" {
t.Fatalf("unexpected report: %#v", report)
}
}
func TestServiceRunsOfflineEvaluationWithExplicitEngine(t *testing.T) {
engine := &runtimeTestEngine{code: "evaluation"}
service := NewServiceWithRegistry(NewEngineRegistry(engine))
report, err := service.RunOfflineEvaluation(context.Background(), "evaluation", models.AIAgent{RuntimeMode: "workflow"}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "case"}})
if err != nil || !engine.ran || report.EngineCode != "evaluation" || report.Total != 1 {
t.Fatalf("unexpected evaluation report=%#v engine=%#v err=%v", report, engine, err)
}
}
@@ -1,243 +0,0 @@
package runtime
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
ai "agent-desk/internal/ai"
aitooling "agent-desk/internal/ai/tooling"
workflowregistry "agent-desk/internal/ai/workflow/registry"
workflowvalidator "agent-desk/internal/ai/workflow/validator"
"agent-desk/internal/models"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/utils"
svc "agent-desk/internal/services"
"github.com/mlogclub/simple/sqls"
)
const hybridPlaybookToolCode = "playbook/run"
// HybridEngine lets the model choose whether to enter the Agent's one bound
// deterministic Playbook. The Playbook itself is always run by WorkflowEngine.
type HybridEngine struct {
chatWithTools func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error)
autonomous *AutonomousEngine
workflow *WorkflowEngine
}
func NewHybridEngine() *HybridEngine {
return &HybridEngine{
chatWithTools: ai.LLM.ChatWithTools,
autonomous: NewAutonomousEngine(),
workflow: NewWorkflowEngine(),
}
}
func (e *HybridEngine) Code() string {
return "hybrid"
}
func (e *HybridEngine) 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 {
return nil, err
}
req.AIAgent, req.AIConfig = snapshot.Agent, snapshot.AIConfig
workflowVersionIDs := make([]int64, 0, len(snapshot.WorkflowBindings))
workflowTools := make([]ai.ToolDefinition, 0, len(snapshot.WorkflowBindings))
for _, binding := range snapshot.WorkflowBindings {
if binding.WorkflowVersionID <= 0 {
continue
}
versionAgent := req.AIAgent
versionAgent.WorkflowVersionID = binding.WorkflowVersionID
workflow, resolveErr := resolveAgentWorkflow(versionAgent)
if resolveErr != nil || !workflowvalidator.ValidateDefinition(workflow.Definition, workflowregistry.DefaultRegistry()).Valid {
return nil, errorsx.InvalidParam("hybrid agent workflow binding is invalid")
}
workflowVersionIDs = append(workflowVersionIDs, binding.WorkflowVersionID)
workflowTools = append(workflowTools, hybridWorkflowToolDefinition(binding.WorkflowVersionID, binding.ToolName, binding.TriggerInstruction))
}
if len(workflowVersionIDs) == 0 && req.AIAgent.WorkflowVersionID > 0 {
workflow, resolveErr := resolveAgentWorkflow(req.AIAgent)
if resolveErr != nil || !workflowvalidator.ValidateDefinition(workflow.Definition, workflowregistry.DefaultRegistry()).Valid {
return nil, errorsx.InvalidParam("hybrid agent workflow binding is invalid")
}
workflowVersionIDs = append(workflowVersionIDs, req.AIAgent.WorkflowVersionID)
workflowTools = append(workflowTools, hybridWorkflowToolDefinition(req.AIAgent.WorkflowVersionID, "", ""))
}
if len(workflowVersionIDs) == 0 {
return nil, errorsx.InvalidParam("hybrid agent requires a published workflow")
}
turn := e.autonomous.prepareTurn(ctx, req)
if turn.ResponsePolicy.Enforced {
return writeHybridResult(req, startedAt, &ai.ChatCompletionResult{Content: turn.ResponsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}, "", 0, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, nil, turn.ResponsePolicy, nil)
}
turn.SystemPrompt += "\n\nWhen a deterministic business process is required, use the matching workflow tool. Do not call workflows for ordinary factual questions."
var playbookSummary *Summary
toolCalls := make([]svc.EngineToolCallInput, 0, 1)
loop, err := e.chatWithTools(ctx, req.AIConfig, turn.SystemPrompt, turn.UserPrompt, workflowTools, req.AIAgent.MaxSteps, func(ctx context.Context, call ai.ToolCall) (string, error) {
if call.Name != "run_playbook" {
return "", fmt.Errorf("unsupported hybrid tool: %s", call.Name)
}
if len(toolCalls) >= 1 {
return "", fmt.Errorf("playbook call limit reached")
}
workflowVersionID, err := parseHybridPlaybookCall(call.Arguments)
if err != nil {
return "", err
}
if !containsWorkflowVersion(workflowVersionIDs, workflowVersionID) {
return "", fmt.Errorf("playbook is not allowed")
}
playbookDefinition := aitooling.Definition{Code: hybridPlaybookToolCode, Name: "run_playbook", RiskLevel: aitooling.RiskLevelWrite, RequireConfirmation: true, MaxCallsPerRun: 1}
if err := aitooling.DefaultRegistry.Authorize(playbookDefinition, aitooling.Policy{
AllowedRiskLevels: turn.ToolPolicy.AllowedRiskLevels,
CallCount: len(toolCalls),
TotalCallCount: len(toolCalls),
MaxTotalCalls: 1,
Confirmed: true, // Workflow validation guarantees a human-confirm predecessor for high-risk nodes.
}); err != nil {
return "", err
}
callStartedAt := time.Now()
workflowReq := req
workflowReq.AIAgent.WorkflowVersionID = workflowVersionID
playbookSummary, err = e.workflow.Run(ctx, workflowReq)
toolRecord := svc.EngineToolCallInput{ToolCode: hybridPlaybookToolCode, RiskLevel: "write", RequireConfirm: true, ArgumentsPreview: call.Arguments, DurationMS: int(time.Since(callStartedAt).Milliseconds())}
if err != nil {
toolRecord.Status, toolRecord.ErrorMessage = "failed", err.Error()
toolCalls = append(toolCalls, toolRecord)
return "", err
}
toolRecord.Status = "completed"
toolRecord.ResultPreview = fmt.Sprintf("workflowRunId=%d status=%s", playbookSummary.WorkflowRunID, playbookSummary.Status)
toolCalls = append(toolCalls, toolRecord)
data, _ := json.Marshal(map[string]any{"workflowRunId": playbookSummary.WorkflowRunID, "status": playbookSummary.Status, "replyText": playbookSummary.ReplyText, "interrupted": playbookSummary.Interrupted})
return string(data), nil
})
if err != nil {
_, _ = writeHybridAudit(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, toolCalls, turn.ResponsePolicy, false, err)
return nil, err
}
if playbookSummary != nil && playbookSummary.Interrupted {
runID, auditErr := writeHybridAudit(req, startedAt, &ai.ChatCompletionResult{Content: playbookSummary.ReplyText, ModelName: playbookSummary.ModelName, PromptTokens: playbookSummary.PromptTokens, CompletionTokens: playbookSummary.CompletionTokens}, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, toolCalls, turn.ResponsePolicy, true, nil)
if auditErr != nil {
return nil, auditErr
}
playbookSummary.AgentRunID = runID
return playbookSummary, nil
}
if loop == nil || strings.TrimSpace(loop.Content) == "" {
err = errorsx.InvalidParam("hybrid engine returned an empty reply")
_, _ = writeHybridAudit(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, toolCalls, turn.ResponsePolicy, false, err)
return nil, err
}
return writeHybridResult(req, startedAt, &loop.ChatCompletionResult, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, playbookSummary, turn.ResponsePolicy, toolCalls)
}
func (e *HybridEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
interrupt := svc.ConversationInterruptService.GetByCheckPointID(req.CheckPointID)
summary, err := e.workflow.Resume(ctx, req)
if err != nil || summary == nil || interrupt == nil || interrupt.AgentRunID <= 0 {
return summary, err
}
if err := sqls.WithTransaction(func(tx *sqls.TxContext) error {
return svc.AgentRunService.RecordHybridPlaybookResume(tx.Tx, interrupt.AgentRunID, summary.WorkflowRunID, summary.Status, summary.ReplyText)
}); err != nil {
return nil, err
}
// The resumed WorkflowRun is a child audit artifact. Keep the original
// Hybrid run as the summary run surfaced to the conversation caller.
summary.AgentRunID = interrupt.AgentRunID
return summary, nil
}
func hybridWorkflowToolDefinition(workflowVersionID int64, toolName, instruction string) ai.ToolDefinition {
description := "Run this Agent's published deterministic workflow when the customer needs the controlled business action."
if strings.TrimSpace(toolName) != "" {
description += " Workflow: " + toolName + "."
}
if strings.TrimSpace(instruction) != "" {
description += " Use when: " + instruction
}
return ai.ToolDefinition{Name: "run_playbook", Description: description, Parameters: map[string]any{
"type": "object", "properties": map[string]any{"workflowVersionId": map[string]any{"type": "integer", "description": fmt.Sprintf("The allowed workflow version (%d).", workflowVersionID)}}, "required": []string{"workflowVersionId"},
}}
}
func containsWorkflowVersion(items []int64, value int64) bool {
for _, item := range items {
if item == value {
return true
}
}
return false
}
func parseHybridPlaybookCall(raw string) (int64, error) {
var input struct {
WorkflowVersionID int64 `json:"workflowVersionId"`
}
if err := json.Unmarshal([]byte(raw), &input); err != nil || input.WorkflowVersionID <= 0 {
return 0, errorsx.InvalidParam("invalid playbook call")
}
return input.WorkflowVersionID, nil
}
func writeHybridResult(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, playbook *Summary, responsePolicy autonomousResponsePolicy, toolCalls []svc.EngineToolCallInput) (*Summary, error) {
runID, err := writeHybridAudit(req, startedAt, result, inputPreview, historyCount, retrieverCount, retrieveErr, skillContext, toolCalls, responsePolicy, false, nil)
if err != nil {
return nil, err
}
return &Summary{Status: "completed", ReplyText: strings.TrimSpace(result.Content), ModelName: result.ModelName, PromptTokens: result.PromptTokens, CompletionTokens: result.CompletionTokens, HistoryMessageCount: historyCount, RetrieverCount: retrieverCount, AgentRunID: runID, WorkflowRunID: workflowRunIDFromSummary(playbook)}, nil
}
func writeHybridAudit(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, toolCalls []svc.EngineToolCallInput, responsePolicy autonomousResponsePolicy, interrupted bool, cause error) (int64, error) {
endedAt := time.Now()
status, errorMessage, outputPreview := "completed", "", ""
promptTokens, completionTokens := 0, 0
if interrupted {
status = "interrupted"
} else if cause != nil {
status, errorMessage = "failed", cause.Error()
} else if result != nil {
outputPreview, promptTokens, completionTokens = strings.TrimSpace(result.Content), result.PromptTokens, result.CompletionTokens
}
steps := autonomousAdditionalSteps(req, retrieverCount, retrieveErr, skillContext, responsePolicy)
for _, call := range toolCalls {
if call.ToolCode == hybridPlaybookToolCode {
steps = append(steps, svc.EngineStepInput{StepType: "playbook", StepCode: hybridPlaybookToolCode, WorkflowRunID: workflowRunIDFromToolResult(call.ResultPreview), Status: call.Status, InputPreview: call.ArgumentsPreview, OutputPreview: call.ResultPreview, ErrorMessage: call.ErrorMessage})
}
}
var runID int64
err := sqls.WithTransaction(func(tx *sqls.TxContext) error {
var recordErr error
runID, recordErr = svc.AgentRunService.RecordEngineRun(tx.Tx, svc.EngineAgentRunInput{ConversationID: req.Conversation.ID, AIAgentID: req.AIAgent.ID, AgentRevisionID: req.AIAgent.PublishedRevisionID, SourceMessageID: req.UserMessage.ID, EngineCode: "hybrid", Status: status, PromptTokens: promptTokens, CompletionTokens: completionTokens, StartedAt: startedAt, EndedAt: &endedAt, ErrorMessage: errorMessage, TraceData: `{"engine":"hybrid"}`, StepType: "model", StepCode: "chat_completion", StepInputPreview: inputPreview, StepOutputPreview: outputPreview, AdditionalSteps: steps, ToolCalls: toolCalls})
return recordErr
})
return runID, err
}
func workflowRunIDFromSummary(summary *Summary) int64 {
if summary == nil {
return 0
}
return summary.WorkflowRunID
}
func workflowRunIDFromToolResult(value string) int64 {
var id int64
_, _ = fmt.Sscanf(value, "workflowRunId=%d", &id)
return id
}
var _ Engine = (*HybridEngine)(nil)
+15 -115
View File
@@ -8,15 +8,13 @@ import (
workflowexecutor "agent-desk/internal/ai/runtime/workflow"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/repositories"
svc "agent-desk/internal/services"
"github.com/mlogclub/simple/sqls"
)
type Service struct {
registry *EngineRegistry
engine *AgentLoopEngine
}
const (
@@ -26,46 +24,27 @@ const (
)
func NewService() *Service {
return NewServiceWithRegistry(NewDefaultEngineRegistry())
return NewServiceWithEngine(NewAgentLoopEngine())
}
func NewServiceWithRegistry(registry *EngineRegistry) *Service {
return &Service{registry: registry}
func NewServiceWithEngine(engine *AgentLoopEngine) *Service {
return &Service{engine: engine}
}
func (s *Service) Run(ctx context.Context, req RunInput) (*RunResult, error) {
engine, err := s.registry.Resolve(resolveEngineCode(req.AIAgent.RuntimeMode))
if err != nil {
return nil, err
}
return engine.Run(ctx, req)
return s.engine.Run(ctx, req)
}
func (s *Service) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
engine, err := s.registry.Resolve(resolveEngineCode(req.AIAgent.RuntimeMode))
if err != nil {
return nil, err
}
return engine.Resume(ctx, req)
return s.engine.Resume(ctx, req)
}
// RunOfflineEvaluation executes an explicitly selected Engine against isolated
// Debug inputs. It does not rely on the Agent's configured runtime mode, which
// makes Workflow/Autonomous/Hybrid comparisons possible against one revision.
func (s *Service) RunOfflineEvaluation(ctx context.Context, engineCode string, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) (OfflineEvaluationReport, error) {
engine, err := s.registry.Resolve(strings.TrimSpace(engineCode))
if err != nil {
return OfflineEvaluationReport{EngineCode: strings.TrimSpace(engineCode)}, err
}
runner := NewOfflineEvaluationRunner(engine.Run)
return runner.Run(ctx, engine.Code(), agent, config, cases), nil
func (s *Service) RunOfflineEvaluation(ctx context.Context, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) (OfflineEvaluationReport, error) {
runner := NewOfflineEvaluationRunner(s.engine.Run)
return runner.Run(ctx, agent, config, cases), nil
}
func resolveEngineCode(mode enums.AIAgentRuntimeMode) string {
return strings.TrimSpace(string(mode))
}
func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workflow resolvedWorkflow, workflowRunID int64, agentRunID int64) *Summary {
func toWorkflowResult(result *workflowexecutor.Result, modelName string, workflow resolvedWorkflow, workflowRunID int64) *RunResult {
if result == nil {
return nil
}
@@ -77,7 +56,7 @@ func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workfl
"nodePath": result.NodePath,
}
traceData, _ := json.Marshal(trace)
return &Summary{
return &RunResult{
Status: result.Status,
ReplyText: result.ReplyText,
ModelName: modelName,
@@ -87,7 +66,6 @@ func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workfl
WorkflowID: workflow.WorkflowID,
WorkflowVersionID: workflow.VersionID,
WorkflowRunID: workflowRunID,
AgentRunID: agentRunID,
WorkflowNodePath: append([]string(nil), result.NodePath...),
TraceData: string(traceData),
CheckPointID: result.CheckPointID,
@@ -112,63 +90,13 @@ func toWorkflowInterruptSummaries(items []workflowexecutor.InterruptSummary) []I
return ret
}
func writeWorkflowRun(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string) (int64, int64, error) {
func writeWorkflowRun(req RunInput, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string) (int64, error) {
return writeWorkflowRunWithExistingID(req, workflow, result, errorMessage, 0)
}
func writeWorkflowPrepareFailedRun(req Request, errorMessage string) (int64, error) {
now := time.Now()
endedAt := now
workflowID := int64(0)
workflowVersionID := req.AIAgent.WorkflowVersionID
if workflowVersionID > 0 {
if version := repositories.AIWorkflowVersionRepository.Get(sqls.DB(), workflowVersionID); version != nil {
workflowID = version.WorkflowID
}
}
run := &models.AIWorkflowRun{
WorkflowID: workflowID,
WorkflowVersionID: workflowVersionID,
ConversationID: req.Conversation.ID,
AIAgentID: req.AIAgent.ID,
MessageID: req.UserMessage.ID,
Status: workflowRunStatusFailed,
StartedAt: now,
EndedAt: &endedAt,
ErrorMessage: errorMessage,
}
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if err := repositories.AIWorkflowRunRepository.Create(ctx.Tx, run); err != nil {
return err
}
traceData, _ := json.Marshal(map[string]any{
"status": "error",
"workflowId": workflowID,
"workflowVersionId": workflowVersionID,
"workflowRunId": run.ID,
})
_, err := svc.AgentRunService.RecordWorkflowRun(ctx.Tx, svc.WorkflowAgentRunInput{
WorkflowRunID: run.ID,
WorkflowVersionID: workflowVersionID,
ConversationID: req.Conversation.ID,
AIAgentID: req.AIAgent.ID,
SourceMessageID: req.UserMessage.ID,
Status: "failed",
StartedAt: now,
EndedAt: &endedAt,
ErrorMessage: errorMessage,
TraceData: string(traceData),
StepInputPreview: "workflow preparation",
StepOutputPreview: "",
})
return err
})
return run.ID, err
}
func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string, existingRunID int64) (int64, int64, error) {
func writeWorkflowRunWithExistingID(req RunInput, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string, existingRunID int64) (int64, error) {
if result == nil {
return 0, 0, nil
return 0, nil
}
now := time.Now()
endedAt := now
@@ -178,7 +106,6 @@ func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, resu
}
runStatus := workflowRunStatus(result.Status, errorMessage)
var runID int64
var agentRunID int64
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
run := repositories.AIWorkflowRunRepository.Get(ctx.Tx, existingRunID)
if run == nil {
@@ -230,36 +157,9 @@ func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, resu
return err
}
}
traceData, _ := json.Marshal(map[string]any{
"status": result.Status,
"workflowId": workflow.WorkflowID,
"workflowVersionId": workflow.VersionID,
"workflowRunId": run.ID,
"nodePath": result.NodePath,
})
createdAgentRunID, recordErr := svc.AgentRunService.RecordWorkflowRun(ctx.Tx, svc.WorkflowAgentRunInput{
WorkflowRunID: run.ID,
WorkflowVersionID: workflow.VersionID,
ConversationID: req.Conversation.ID,
AIAgentID: req.AIAgent.ID,
SourceMessageID: req.UserMessage.ID,
Status: workflowAgentRunStatus(result.Status, errorMessage),
PromptTokens: result.PromptTokens,
CompletionTokens: result.CompletionTokens,
StartedAt: now,
EndedAt: &endedAt,
ErrorMessage: errorMessage,
TraceData: string(traceData),
StepInputPreview: "workflow execution",
StepOutputPreview: strings.Join(result.NodePath, ","),
})
if recordErr != nil {
return recordErr
}
agentRunID = createdAgentRunID
return nil
})
return runID, agentRunID, err
return runID, err
}
func workflowAgentRunStatus(status string, errorMessage string) string {
+3 -22
View File
@@ -5,9 +5,7 @@ import (
"time"
)
// RunInput is the normalized, fully prepared input shared by all Engine
// implementations. Persistent adapters load this object before dispatching
// into the runtime.
// RunInput is the normalized, fully prepared input for the Agent Loop.
type RunInput struct {
Conversation models.Conversation
UserMessage models.Message
@@ -17,12 +15,7 @@ type RunInput struct {
Debug bool
}
// Request remains as a compatibility alias while callers move to RunInput.
type Request = RunInput
// ResumeInput extends the prepared input with an approved interrupt payload.
// It deliberately carries the same persisted context as RunInput so resume
// semantics are consistent across Workflow, Autonomous, and Hybrid engines.
type ResumeInput struct {
Conversation models.Conversation
UserMessage models.Message
@@ -33,25 +26,19 @@ type ResumeInput struct {
Debug bool
}
// ResumeRequest remains as a compatibility alias while callers move to ResumeInput.
type ResumeRequest = ResumeInput
type InterruptContextSummary struct {
Type string `json:"type,omitempty"`
ID string `json:"id"`
InfoPreview string `json:"infoPreview,omitempty"`
}
// RunResult is the normalized result returned by every Engine. Engine-specific
// details are represented by optional fields rather than engine-specific DTOs.
// RunResult is the normalized Agent Loop result.
type RunResult struct {
RunID string
Status string
ReplyText string
PlannedSkillID int64
PlannedSkillName string
PlanReason string
SkillRouteTrace string
SkillAllowedToolCodes []string
ModelName string
PromptTokens int
@@ -59,7 +46,6 @@ type RunResult struct {
HistoryMessageCount int
RetrieverCount int
ToolCallCount int
ToolCodes []string
InvokedToolCodes []string
WorkflowID int64
WorkflowVersionID int64
@@ -75,9 +61,6 @@ type RunResult struct {
ErrorMessage string
}
// Summary remains as a compatibility alias while callers move to RunResult.
type Summary = RunResult
type StreamEventType string
const (
@@ -88,9 +71,7 @@ const (
StreamEventFailed StreamEventType = "failed"
)
// StreamEvent is the transport-neutral event contract for future streaming
// endpoints. Engines may emit partial output, audit steps, or a terminal state
// without exposing engine-specific event payloads to callers.
// StreamEvent is the transport-neutral event contract for future streaming.
type StreamEvent struct {
Type StreamEventType `json:"type"`
RunID string `json:"runId,omitempty"`
@@ -1,112 +0,0 @@
package runtime
import (
"context"
"strings"
workflowexecutor "agent-desk/internal/ai/runtime/workflow"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
)
// WorkflowEngine preserves the existing FlowGram DSL execution path as the
// first Agent Runtime engine. It remains the compatibility default for agents
// created before autonomous and hybrid modes are available.
type WorkflowEngine struct{}
func NewWorkflowEngine() *WorkflowEngine {
return &WorkflowEngine{}
}
func (e *WorkflowEngine) Code() string {
return EngineCodeWorkflow
}
func (e *WorkflowEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) {
req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content)
aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent)
if err != nil {
_, _ = writeWorkflowPrepareFailedRun(req, err.Error())
return nil, err
}
req.AIAgent = aiAgent
workflowResult, err := workflowexecutor.NewExecutor().Execute(ctx, workflowexecutor.Input{
Definition: workflow.Definition,
Conversation: req.Conversation,
UserMessage: req.UserMessage,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
Debug: req.Debug,
})
if err != nil {
if workflowResult != nil {
_, _, _ = writeWorkflowRun(req, workflow, workflowResult, err.Error())
}
return nil, err
}
workflowRunID, agentRunID, err := writeWorkflowRun(req, workflow, workflowResult, "")
if err != nil {
return nil, err
}
return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID, agentRunID), nil
}
func (e *WorkflowEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent)
if err != nil {
return nil, err
}
req.AIAgent = aiAgent
interrupt := repositories.ConversationInterruptRepository.GetByCheckPointID(sqls.DB(), req.CheckPointID)
if interrupt == nil {
return nil, errorsx.InvalidParam("legacy checkpoint is not supported; please start a new workflow reply")
}
if strings.TrimSpace(interrupt.RequestData) == "" {
if interrupt.WorkflowRunID > 0 || strings.HasPrefix(strings.TrimSpace(req.CheckPointID), "workflow:") {
return nil, errorsx.InvalidParam("workflow checkpoint data is required")
}
return nil, errorsx.InvalidParam("legacy checkpoint is not supported; please start a new workflow reply")
}
workflowResult, err := workflowexecutor.NewExecutor().Resume(ctx, workflowexecutor.Input{
Definition: workflow.Definition,
Conversation: req.Conversation,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
Debug: req.Debug,
}, interrupt.RequestData, firstWorkflowResumeText(req.ResumeData))
if err != nil {
if workflowResult != nil {
_, _, _ = writeWorkflowRunWithExistingID(Request{
Conversation: req.Conversation,
UserMessage: req.UserMessage,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
}, workflow, workflowResult, err.Error(), interrupt.WorkflowRunID)
}
return nil, err
}
workflowRunID, agentRunID, err := writeWorkflowRunWithExistingID(Request{
Conversation: req.Conversation,
UserMessage: req.UserMessage,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
}, workflow, workflowResult, "", interrupt.WorkflowRunID)
if err != nil {
return nil, err
}
return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID, agentRunID), nil
}
func firstWorkflowResumeText(data map[string]string) string {
for _, value := range data {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
var _ Engine = (*WorkflowEngine)(nil)
@@ -4,7 +4,6 @@ import (
"encoding/json"
"agent-desk/internal/ai/workflow/dsl"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/repositories"
@@ -18,11 +17,11 @@ type resolvedWorkflow struct {
VersionID int64
}
func resolveAgentWorkflow(aiAgent models.AIAgent) (resolvedWorkflow, error) {
if aiAgent.WorkflowVersionID <= 0 {
return resolvedWorkflow{}, errorsx.InvalidParam("AI Agent workflow is not published; publish a workflow version before enabling automatic replies")
func resolveWorkflowVersion(workflowVersionID int64) (resolvedWorkflow, error) {
if workflowVersionID <= 0 {
return resolvedWorkflow{}, errorsx.InvalidParam("workflow version is required")
}
version := repositories.AIWorkflowVersionRepository.Get(sqls.DB(), aiAgent.WorkflowVersionID)
version := repositories.AIWorkflowVersionRepository.Get(sqls.DB(), workflowVersionID)
if version == nil || version.Status != enums.StatusOk {
return resolvedWorkflow{}, errorsx.InvalidParam("workflow version does not exist")
}
@@ -36,11 +35,3 @@ func resolveAgentWorkflow(aiAgent models.AIAgent) (resolvedWorkflow, error) {
VersionID: version.ID,
}, nil
}
func prepareWorkflowAgent(aiAgent models.AIAgent) (models.AIAgent, resolvedWorkflow, error) {
workflow, err := resolveAgentWorkflow(aiAgent)
if err != nil {
return aiAgent, resolvedWorkflow{}, err
}
return aiAgent, workflow, nil
}
@@ -1,481 +0,0 @@
package runtime
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
workflowexecutor "agent-desk/internal/ai/runtime/workflow"
"agent-desk/internal/ai/workflow/dsl"
workflowregistry "agent-desk/internal/ai/workflow/registry"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
svc "agent-desk/internal/services"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestToWorkflowSummaryPreservesInterruptCheckpoint(t *testing.T) {
summary := toWorkflowSummary(&workflowexecutor.Result{
Status: "interrupted",
CheckPointID: "workflow:1:2:confirm_1",
CheckPointData: `{"confirmNodeId":"confirm_1"}`,
Interrupted: true,
Interrupts: []workflowexecutor.InterruptSummary{
{Type: "human_confirm", ID: "confirm_1", InfoPreview: `{"message":"请确认"}`},
},
}, "test-model", resolvedWorkflow{WorkflowID: 11, VersionID: 22}, 33, 44)
if summary == nil || !summary.Interrupted {
t.Fatalf("expected interrupted summary, got %#v", summary)
}
if summary.CheckPointID != "workflow:1:2:confirm_1" {
t.Fatalf("unexpected checkpoint id: %q", summary.CheckPointID)
}
if summary.CheckPointData == "" {
t.Fatalf("expected checkpoint data")
}
if summary.WorkflowID != 11 || summary.WorkflowVersionID != 22 || summary.WorkflowRunID != 33 {
t.Fatalf("unexpected workflow identity: workflow=%d version=%d run=%d", summary.WorkflowID, summary.WorkflowVersionID, summary.WorkflowRunID)
}
if summary.AgentRunID != 44 {
t.Fatalf("unexpected agent run id: %d", summary.AgentRunID)
}
if len(summary.Interrupts) != 1 || summary.Interrupts[0].ID != "confirm_1" {
t.Fatalf("unexpected interrupts: %#v", summary.Interrupts)
}
}
func TestPrepareWorkflowAgentDoesNotInjectWorkflowAppendix(t *testing.T) {
db := setupWorkflowResumeTestDB(t)
definitionJSON := mustMarshalDefinition(t, dsl.Definition{
SchemaVersion: 2,
Nodes: []dsl.Node{
runtimeTestNode("start", workflowregistry.NodeTypeStart, "Start", nil, nil),
runtimeTestNode("handoff", workflowregistry.NodeTypeHandoffToHuman, "Handoff", nil, nil),
},
Edges: []dsl.Edge{runtimeTestEdge("edge_start_handoff", "start", "handoff")},
})
version := models.AIWorkflowVersion{
WorkflowID: 1,
Version: 1,
Status: enums.StatusOk,
Definition: definitionJSON,
}
if err := db.Create(&version).Error; err != nil {
t.Fatalf("create workflow version: %v", err)
}
agent, _, err := prepareWorkflowAgent(models.AIAgent{
ID: 1,
SystemPrompt: "保持简洁回答。",
WorkflowVersionID: version.ID,
})
if err != nil {
t.Fatalf("prepareWorkflowAgent() error = %v", err)
}
if agent.SystemPrompt != "保持简洁回答。" {
t.Fatalf("expected system prompt to stay unchanged, got %q", agent.SystemPrompt)
}
}
func TestServiceResumeUsesWorkflowCheckpointData(t *testing.T) {
db := setupWorkflowResumeTestDB(t)
def := runtimeHumanConfirmDefinition()
definitionJSON := mustMarshalDefinition(t, def)
version := models.AIWorkflowVersion{
WorkflowID: 1,
Version: 1,
Status: enums.StatusOk,
Definition: definitionJSON,
}
if err := db.Create(&version).Error; err != nil {
t.Fatalf("create workflow version: %v", err)
}
checkpointData := mustMarshalWorkflowCheckpoint(t, def)
if err := db.Create(&models.ConversationInterrupt{
ConversationID: 1,
AIAgentID: 1,
CheckPointID: "workflow:1:2:confirm_1",
RequestData: checkpointData,
Status: "pending",
}).Error; err != nil {
t.Fatalf("create interrupt: %v", err)
}
summary, err := NewService().Resume(context.Background(), ResumeRequest{
Conversation: models.Conversation{ID: 1},
UserMessage: models.Message{ID: 2, Content: "确认"},
AIAgent: models.AIAgent{
ID: 1,
WorkflowVersionID: version.ID,
},
AIConfig: models.AIConfig{ModelName: "test-model"},
CheckPointID: "workflow:1:2:confirm_1",
ResumeData: map[string]string{
"confirm_1": "确认",
},
})
if err != nil {
t.Fatalf("resume workflow: %v", err)
}
if summary == nil || summary.Status != "completed" || summary.Interrupted {
t.Fatalf("unexpected summary: %#v", summary)
}
if summary.WorkflowRunID <= 0 {
t.Fatalf("expected workflow run id in resume summary")
}
if summary.AgentRunID <= 0 {
t.Fatalf("expected generic agent run id in resume summary")
}
var run models.AIWorkflowRun
if err := db.First(&run, summary.WorkflowRunID).Error; err != nil {
t.Fatalf("find resume workflow run: %v", err)
}
if run.MessageID != 2 || run.Status != workflowRunStatusCompleted {
t.Fatalf("unexpected resume workflow run: %#v", run)
}
var agentRun models.AgentRun
if err := db.First(&agentRun, "workflow_run_id = ?", summary.WorkflowRunID).Error; err != nil {
t.Fatalf("find generic agent run: %v", err)
}
if agentRun.EngineCode != EngineCodeWorkflow || agentRun.Status != "completed" {
t.Fatalf("unexpected generic agent run: %#v", agentRun)
}
var stepCount int64
if err := db.Model(&models.AgentStep{}).Where("agent_run_id = ?", agentRun.ID).Count(&stepCount).Error; err != nil {
t.Fatalf("count generic agent steps: %v", err)
}
if stepCount != 1 {
t.Fatalf("expected one generic agent step, got %d", stepCount)
}
}
func TestHybridEngineResumeCompletesOriginalAgentRun(t *testing.T) {
db := setupWorkflowResumeTestDB(t)
def := runtimeHumanConfirmDefinition()
version := models.AIWorkflowVersion{
WorkflowID: 1,
Version: 1,
Status: enums.StatusOk,
Definition: mustMarshalDefinition(t, def),
}
if err := db.Create(&version).Error; err != nil {
t.Fatalf("create workflow version: %v", err)
}
startedAt := time.Now()
hybridRun := models.AgentRun{AIAgentID: 1, EngineCode: "hybrid", Status: "interrupted", StartedAt: startedAt, EndedAt: &startedAt, CreatedAt: startedAt, UpdatedAt: startedAt}
if err := db.Create(&hybridRun).Error; err != nil {
t.Fatalf("create interrupted hybrid run: %v", err)
}
interruptedRun := models.AIWorkflowRun{WorkflowID: version.WorkflowID, WorkflowVersionID: version.ID, ConversationID: 1, AIAgentID: 1, MessageID: 2, Status: workflowRunStatusInterrupted}
if err := db.Create(&interruptedRun).Error; err != nil {
t.Fatalf("create interrupted workflow run: %v", err)
}
const checkpointID = "workflow:1:2:confirm_1"
if err := db.Create(&models.ConversationInterrupt{
ConversationID: 1, AIAgentID: 1, AgentRunID: hybridRun.ID,
CheckPointID: checkpointID, InterruptID: "confirm_1", InterruptType: "human_confirm",
WorkflowRunID: interruptedRun.ID, WorkflowNodeID: "confirm_1", RequestData: mustMarshalWorkflowCheckpoint(t, def), Status: "pending",
}).Error; err != nil {
t.Fatalf("create interrupt: %v", err)
}
summary, err := NewHybridEngine().Resume(context.Background(), ResumeRequest{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 3, Content: "确认"},
AIAgent: models.AIAgent{ID: 1, RuntimeMode: enums.AIAgentRuntimeModeHybrid, WorkflowVersionID: version.ID},
AIConfig: models.AIConfig{ModelName: "test-model"}, CheckPointID: checkpointID,
ResumeData: map[string]string{"confirm_1": "确认"},
})
if err != nil {
t.Fatalf("resume hybrid playbook: %v", err)
}
if summary == nil || summary.Status != "completed" || summary.AgentRunID != hybridRun.ID || summary.WorkflowRunID != interruptedRun.ID {
t.Fatalf("unexpected hybrid resume summary: %#v", summary)
}
item, steps, _ := svc.AgentRunService.GetDetail(hybridRun.ID)
if item == nil || item.Status != "completed" || len(steps) != 1 || steps[0].StepCode != "playbook_resume" || steps[0].WorkflowRunID != interruptedRun.ID {
t.Fatalf("expected original hybrid run to receive resume audit, run=%#v steps=%#v", item, steps)
}
}
func TestServiceResumeReusesInterruptedWorkflowRun(t *testing.T) {
db := setupWorkflowResumeTestDB(t)
def := runtimeHumanConfirmDefinition()
version := models.AIWorkflowVersion{
WorkflowID: 1,
Version: 1,
Status: enums.StatusOk,
Definition: mustMarshalDefinition(t, def),
}
if err := db.Create(&version).Error; err != nil {
t.Fatalf("create workflow version: %v", err)
}
interruptedRun := models.AIWorkflowRun{
WorkflowID: version.WorkflowID,
WorkflowVersionID: version.ID,
ConversationID: 1,
AIAgentID: 1,
MessageID: 2,
Status: workflowRunStatusInterrupted,
InterruptType: "human_confirm",
InterruptNodeID: "confirm_1",
}
if err := db.Create(&interruptedRun).Error; err != nil {
t.Fatalf("create interrupted workflow run: %v", err)
}
if err := db.Create(&models.ConversationInterrupt{
ConversationID: 1,
AIAgentID: 1,
CheckPointID: "workflow:1:2:confirm_1",
InterruptID: "confirm_1",
InterruptType: "human_confirm",
WorkflowRunID: interruptedRun.ID,
WorkflowNodeID: "confirm_1",
RequestData: mustMarshalWorkflowCheckpoint(t, def),
Status: "pending",
}).Error; err != nil {
t.Fatalf("create interrupt: %v", err)
}
summary, err := NewService().Resume(context.Background(), ResumeRequest{
Conversation: models.Conversation{ID: 1},
UserMessage: models.Message{ID: 3, Content: "确认"},
AIAgent: models.AIAgent{
ID: 1,
WorkflowVersionID: version.ID,
},
AIConfig: models.AIConfig{ModelName: "test-model"},
CheckPointID: "workflow:1:2:confirm_1",
ResumeData: map[string]string{
"confirm_1": "确认",
},
})
if err != nil {
t.Fatalf("resume workflow: %v", err)
}
if summary.WorkflowRunID != interruptedRun.ID {
t.Fatalf("expected resume to reuse workflow run %d, got %d", interruptedRun.ID, summary.WorkflowRunID)
}
var runCount int64
if err := db.Model(&models.AIWorkflowRun{}).Count(&runCount).Error; err != nil {
t.Fatalf("count workflow runs: %v", err)
}
if runCount != 1 {
t.Fatalf("expected one workflow run after resume, got %d", runCount)
}
var updated models.AIWorkflowRun
if err := db.First(&updated, interruptedRun.ID).Error; err != nil {
t.Fatalf("find updated workflow run: %v", err)
}
if updated.Status != workflowRunStatusCompleted || updated.ErrorMessage != "" {
t.Fatalf("unexpected updated workflow run: %#v", updated)
}
var nodeCount int64
if err := db.Model(&models.AIWorkflowNodeRun{}).Where("workflow_run_id = ?", interruptedRun.ID).Count(&nodeCount).Error; err != nil {
t.Fatalf("count node runs: %v", err)
}
if nodeCount == 0 {
t.Fatalf("expected resumed node traces to be appended to original workflow run")
}
}
func TestServiceRunWritesFailedWorkflowRun(t *testing.T) {
db := setupWorkflowResumeTestDB(t)
def := dsl.Definition{
SchemaVersion: 2,
Nodes: []dsl.Node{
runtimeTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
runtimeTestNode("bad_1", "unsupported_node", "Bad", nil, nil),
},
Edges: []dsl.Edge{
runtimeTestEdge("edge_start_bad", "start_1", "bad_1"),
},
}
version := models.AIWorkflowVersion{
WorkflowID: 9,
Version: 1,
Status: enums.StatusOk,
Definition: mustMarshalDefinition(t, def),
}
if err := db.Create(&version).Error; err != nil {
t.Fatalf("create workflow version: %v", err)
}
_, err := NewService().Run(context.Background(), Request{
Conversation: models.Conversation{ID: 10},
UserMessage: models.Message{ID: 20, Content: "hello"},
AIAgent: models.AIAgent{
ID: 30,
WorkflowVersionID: version.ID,
},
})
if err == nil {
t.Fatalf("expected workflow run error")
}
var run models.AIWorkflowRun
if err := db.First(&run, "workflow_version_id = ?", version.ID).Error; err != nil {
t.Fatalf("find failed workflow run: %v", err)
}
if run.Status != workflowRunStatusFailed || !strings.Contains(run.ErrorMessage, "unsupported workflow node type") {
t.Fatalf("unexpected failed workflow run: %#v", run)
}
var badNodeRun models.AIWorkflowNodeRun
if err := db.First(&badNodeRun, "workflow_run_id = ? AND node_id = ?", run.ID, "bad_1").Error; err != nil {
t.Fatalf("find failed node run: %v", err)
}
if badNodeRun.Status != workflowRunStatusFailed || badNodeRun.ErrorMessage == "" {
t.Fatalf("unexpected failed node run: %#v", badNodeRun)
}
var agentRun models.AgentRun
if err := db.First(&agentRun, "workflow_run_id = ?", run.ID).Error; err != nil {
t.Fatalf("find generic failed agent run: %v", err)
}
if agentRun.Status != "failed" || !strings.Contains(agentRun.ErrorMessage, "unsupported workflow node type") {
t.Fatalf("unexpected generic failed agent run: %#v", agentRun)
}
}
func TestServiceRunWritesFailedWorkflowRunWhenVersionDisabled(t *testing.T) {
db := setupWorkflowResumeTestDB(t)
version := models.AIWorkflowVersion{
WorkflowID: 9,
Version: 1,
Status: enums.StatusDisabled,
Definition: mustMarshalDefinition(t, runtimeHumanConfirmDefinition()),
}
if err := db.Create(&version).Error; err != nil {
t.Fatalf("create disabled workflow version: %v", err)
}
_, err := NewService().Run(context.Background(), Request{
Conversation: models.Conversation{ID: 10},
UserMessage: models.Message{ID: 20, Content: "hello"},
AIAgent: models.AIAgent{
ID: 30,
WorkflowVersionID: version.ID,
},
})
if err == nil {
t.Fatalf("expected disabled workflow version error")
}
var run models.AIWorkflowRun
if err := db.First(&run, "workflow_version_id = ?", version.ID).Error; err != nil {
t.Fatalf("find prepare-stage failed workflow run: %v", err)
}
if run.WorkflowID != version.WorkflowID || run.ConversationID != 10 || run.AIAgentID != 30 || run.MessageID != 20 {
t.Fatalf("unexpected prepare-stage failed workflow run identity: %#v", run)
}
if run.Status != workflowRunStatusFailed || !strings.Contains(run.ErrorMessage, "workflow version does not exist") {
t.Fatalf("unexpected prepare-stage failed workflow run: %#v", run)
}
var nodeCount int64
if err := db.Model(&models.AIWorkflowNodeRun{}).Where("workflow_run_id = ?", run.ID).Count(&nodeCount).Error; err != nil {
t.Fatalf("count node runs: %v", err)
}
if nodeCount != 0 {
t.Fatalf("expected no node runs for prepare-stage failure, got %d", nodeCount)
}
}
func setupWorkflowResumeTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: "t_",
SingularTable: true,
},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(
&models.AIWorkflowVersion{},
&models.AIWorkflowRun{},
&models.AIWorkflowNodeRun{},
&models.AgentRun{},
&models.AgentStep{},
&models.AgentRevision{},
&models.ConversationInterrupt{},
); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
return db
}
func runtimeHumanConfirmDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 2,
Nodes: []dsl.Node{
runtimeTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil),
runtimeTestNode("prompt_1", workflowregistry.NodeTypeLLMReply, "Prompt", []byte(`{"staticReply":"请确认"}`), nil),
runtimeTestNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "Confirm", nil, map[string]dsl.Value{
"prompt": dsl.RefValue("prompt_1", "replyText"),
}),
runtimeTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil),
},
Edges: []dsl.Edge{
runtimeTestEdge("edge_start_prompt", "start_1", "prompt_1"),
runtimeTestEdge("edge_prompt_confirm", "prompt_1", "confirm_1"),
runtimeTestEdge("edge_confirm_end", "confirm_1", "end_1"),
},
}
}
func runtimeTestNode(id string, nodeType string, title string, config []byte, inputs map[string]dsl.Value) dsl.Node {
return dsl.Node{
ID: id,
Type: nodeType,
Data: dsl.NodeData{
Title: title,
Config: config,
InputsValues: inputs,
},
}
}
func runtimeTestEdge(id string, source string, target string) dsl.Edge {
return dsl.Edge{SourceNodeID: source, TargetNodeID: target, SourcePortID: id}
}
func mustMarshalDefinition(t *testing.T, def dsl.Definition) string {
t.Helper()
buf, err := json.Marshal(def)
if err != nil {
t.Fatalf("marshal definition: %v", err)
}
return string(buf)
}
func mustMarshalWorkflowCheckpoint(t *testing.T, def dsl.Definition) string {
t.Helper()
buf, err := json.Marshal(struct {
Definition dsl.Definition `json:"definition"`
ConfirmNodeID string `json:"confirmNodeId"`
Vars map[string]map[string]any `json:"vars"`
}{
Definition: def,
ConfirmNodeID: "confirm_1",
Vars: map[string]map[string]any{
"start_1": {"userMessage": "创建工单"},
"prompt_1": {"replyText": "请确认"},
},
})
if err != nil {
t.Fatalf("marshal checkpoint: %v", err)
}
return string(buf)
}