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
-1
View File
@@ -57,7 +57,6 @@ func main() {
codegen.GetGenerateStruct(&models.AgentTeamSchedule{}), codegen.GetGenerateStruct(&models.AgentTeamSchedule{}),
codegen.GetGenerateStruct(&models.AIConfig{}), codegen.GetGenerateStruct(&models.AIConfig{}),
codegen.GetGenerateStruct(&models.SkillDefinition{}), codegen.GetGenerateStruct(&models.SkillDefinition{}),
codegen.GetGenerateStruct(&models.SkillRunLog{}),
codegen.GetGenerateStruct(&models.SystemConfig{}), codegen.GetGenerateStruct(&models.SystemConfig{}),
) )
+1 -1
Submodule docs updated: 50814016a3...d343eaf648
@@ -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 // AgentApplicationService is the single application boundary before engine
// dispatch. It owns persisted input loading and relationship validation; the // 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 { type AgentApplicationService struct {
runtime *Service 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 { type OfflineEvaluationResult struct {
CaseID string `json:"caseId"` CaseID string `json:"caseId"`
Category string `json:"category"` Category string `json:"category"`
EngineCode string `json:"engineCode"`
Passed bool `json:"passed"` Passed bool `json:"passed"`
ReplyText string `json:"replyText"` ReplyText string `json:"replyText"`
Interrupted bool `json:"interrupted"` Interrupted bool `json:"interrupted"`
@@ -34,10 +33,9 @@ type OfflineEvaluationResult struct {
} }
type OfflineEvaluationReport struct { type OfflineEvaluationReport struct {
EngineCode string `json:"engineCode"` Total int `json:"total"`
Total int `json:"total"` Passed int `json:"passed"`
Passed int `json:"passed"` Results []OfflineEvaluationResult `json:"results"`
Results []OfflineEvaluationResult `json:"results"`
} }
// OfflineEvaluationRunner executes only isolated Debug requests. The supplied // OfflineEvaluationRunner executes only isolated Debug requests. The supplied
@@ -51,10 +49,10 @@ func NewOfflineEvaluationRunner(run func(context.Context, RunInput) (*RunResult,
return &OfflineEvaluationRunner{run: run} return &OfflineEvaluationRunner{run: run}
} }
func (r *OfflineEvaluationRunner) Run(ctx context.Context, engineCode string, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) OfflineEvaluationReport { func (r *OfflineEvaluationRunner) Run(ctx context.Context, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) OfflineEvaluationReport {
report := OfflineEvaluationReport{EngineCode: strings.TrimSpace(engineCode), Results: make([]OfflineEvaluationResult, 0, len(cases))} report := OfflineEvaluationReport{Results: make([]OfflineEvaluationResult, 0, len(cases))}
for _, item := range 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 { if r == nil || r.run == nil {
result.Error, result.Finding = "evaluation runner is not configured", "runner_missing" result.Error, result.Finding = "evaluation runner is not configured", "runner_missing"
report.Results = append(report.Results, result) 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) { func (r OfflineEvaluationReport) CSV() (string, error) {
var output strings.Builder var output strings.Builder
writer := csv.NewWriter(&output) 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 return "", err
} }
for _, item := range r.Results { 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 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" workflowexecutor "agent-desk/internal/ai/runtime/workflow"
"agent-desk/internal/models" "agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/repositories" "agent-desk/internal/repositories"
svc "agent-desk/internal/services"
"github.com/mlogclub/simple/sqls" "github.com/mlogclub/simple/sqls"
) )
type Service struct { type Service struct {
registry *EngineRegistry engine *AgentLoopEngine
} }
const ( const (
@@ -26,46 +24,27 @@ const (
) )
func NewService() *Service { func NewService() *Service {
return NewServiceWithRegistry(NewDefaultEngineRegistry()) return NewServiceWithEngine(NewAgentLoopEngine())
} }
func NewServiceWithRegistry(registry *EngineRegistry) *Service { func NewServiceWithEngine(engine *AgentLoopEngine) *Service {
return &Service{registry: registry} return &Service{engine: engine}
} }
func (s *Service) Run(ctx context.Context, req RunInput) (*RunResult, error) { func (s *Service) Run(ctx context.Context, req RunInput) (*RunResult, error) {
engine, err := s.registry.Resolve(resolveEngineCode(req.AIAgent.RuntimeMode)) return s.engine.Run(ctx, req)
if err != nil {
return nil, err
}
return engine.Run(ctx, req)
} }
func (s *Service) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) { func (s *Service) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
engine, err := s.registry.Resolve(resolveEngineCode(req.AIAgent.RuntimeMode)) return s.engine.Resume(ctx, req)
if err != nil {
return nil, err
}
return engine.Resume(ctx, req)
} }
// RunOfflineEvaluation executes an explicitly selected Engine against isolated func (s *Service) RunOfflineEvaluation(ctx context.Context, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) (OfflineEvaluationReport, error) {
// Debug inputs. It does not rely on the Agent's configured runtime mode, which runner := NewOfflineEvaluationRunner(s.engine.Run)
// makes Workflow/Autonomous/Hybrid comparisons possible against one revision. return runner.Run(ctx, agent, config, cases), nil
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 resolveEngineCode(mode enums.AIAgentRuntimeMode) string { func toWorkflowResult(result *workflowexecutor.Result, modelName string, workflow resolvedWorkflow, workflowRunID int64) *RunResult {
return strings.TrimSpace(string(mode))
}
func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workflow resolvedWorkflow, workflowRunID int64, agentRunID int64) *Summary {
if result == nil { if result == nil {
return nil return nil
} }
@@ -77,7 +56,7 @@ func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workfl
"nodePath": result.NodePath, "nodePath": result.NodePath,
} }
traceData, _ := json.Marshal(trace) traceData, _ := json.Marshal(trace)
return &Summary{ return &RunResult{
Status: result.Status, Status: result.Status,
ReplyText: result.ReplyText, ReplyText: result.ReplyText,
ModelName: modelName, ModelName: modelName,
@@ -87,7 +66,6 @@ func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workfl
WorkflowID: workflow.WorkflowID, WorkflowID: workflow.WorkflowID,
WorkflowVersionID: workflow.VersionID, WorkflowVersionID: workflow.VersionID,
WorkflowRunID: workflowRunID, WorkflowRunID: workflowRunID,
AgentRunID: agentRunID,
WorkflowNodePath: append([]string(nil), result.NodePath...), WorkflowNodePath: append([]string(nil), result.NodePath...),
TraceData: string(traceData), TraceData: string(traceData),
CheckPointID: result.CheckPointID, CheckPointID: result.CheckPointID,
@@ -112,63 +90,13 @@ func toWorkflowInterruptSummaries(items []workflowexecutor.InterruptSummary) []I
return ret 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) return writeWorkflowRunWithExistingID(req, workflow, result, errorMessage, 0)
} }
func writeWorkflowPrepareFailedRun(req Request, errorMessage string) (int64, error) { func writeWorkflowRunWithExistingID(req RunInput, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string, existingRunID int64) (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) {
if result == nil { if result == nil {
return 0, 0, nil return 0, nil
} }
now := time.Now() now := time.Now()
endedAt := now endedAt := now
@@ -178,7 +106,6 @@ func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, resu
} }
runStatus := workflowRunStatus(result.Status, errorMessage) runStatus := workflowRunStatus(result.Status, errorMessage)
var runID int64 var runID int64
var agentRunID int64
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
run := repositories.AIWorkflowRunRepository.Get(ctx.Tx, existingRunID) run := repositories.AIWorkflowRunRepository.Get(ctx.Tx, existingRunID)
if run == nil { if run == nil {
@@ -230,36 +157,9 @@ func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, resu
return err 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 nil
}) })
return runID, agentRunID, err return runID, err
} }
func workflowAgentRunStatus(status string, errorMessage string) string { func workflowAgentRunStatus(status string, errorMessage string) string {
+3 -22
View File
@@ -5,9 +5,7 @@ import (
"time" "time"
) )
// RunInput is the normalized, fully prepared input shared by all Engine // RunInput is the normalized, fully prepared input for the Agent Loop.
// implementations. Persistent adapters load this object before dispatching
// into the runtime.
type RunInput struct { type RunInput struct {
Conversation models.Conversation Conversation models.Conversation
UserMessage models.Message UserMessage models.Message
@@ -17,12 +15,7 @@ type RunInput struct {
Debug bool 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. // 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 { type ResumeInput struct {
Conversation models.Conversation Conversation models.Conversation
UserMessage models.Message UserMessage models.Message
@@ -33,25 +26,19 @@ type ResumeInput struct {
Debug bool Debug bool
} }
// ResumeRequest remains as a compatibility alias while callers move to ResumeInput.
type ResumeRequest = ResumeInput
type InterruptContextSummary struct { type InterruptContextSummary struct {
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
ID string `json:"id"` ID string `json:"id"`
InfoPreview string `json:"infoPreview,omitempty"` InfoPreview string `json:"infoPreview,omitempty"`
} }
// RunResult is the normalized result returned by every Engine. Engine-specific // RunResult is the normalized Agent Loop result.
// details are represented by optional fields rather than engine-specific DTOs.
type RunResult struct { type RunResult struct {
RunID string RunID string
Status string Status string
ReplyText string ReplyText string
PlannedSkillID int64 PlannedSkillID int64
PlannedSkillName string PlannedSkillName string
PlanReason string
SkillRouteTrace string
SkillAllowedToolCodes []string SkillAllowedToolCodes []string
ModelName string ModelName string
PromptTokens int PromptTokens int
@@ -59,7 +46,6 @@ type RunResult struct {
HistoryMessageCount int HistoryMessageCount int
RetrieverCount int RetrieverCount int
ToolCallCount int ToolCallCount int
ToolCodes []string
InvokedToolCodes []string InvokedToolCodes []string
WorkflowID int64 WorkflowID int64
WorkflowVersionID int64 WorkflowVersionID int64
@@ -75,9 +61,6 @@ type RunResult struct {
ErrorMessage string ErrorMessage string
} }
// Summary remains as a compatibility alias while callers move to RunResult.
type Summary = RunResult
type StreamEventType string type StreamEventType string
const ( const (
@@ -88,9 +71,7 @@ const (
StreamEventFailed StreamEventType = "failed" StreamEventFailed StreamEventType = "failed"
) )
// StreamEvent is the transport-neutral event contract for future streaming // 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.
type StreamEvent struct { type StreamEvent struct {
Type StreamEventType `json:"type"` Type StreamEventType `json:"type"`
RunID string `json:"runId,omitempty"` 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" "encoding/json"
"agent-desk/internal/ai/workflow/dsl" "agent-desk/internal/ai/workflow/dsl"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/errorsx"
"agent-desk/internal/repositories" "agent-desk/internal/repositories"
@@ -18,11 +17,11 @@ type resolvedWorkflow struct {
VersionID int64 VersionID int64
} }
func resolveAgentWorkflow(aiAgent models.AIAgent) (resolvedWorkflow, error) { func resolveWorkflowVersion(workflowVersionID int64) (resolvedWorkflow, error) {
if aiAgent.WorkflowVersionID <= 0 { if workflowVersionID <= 0 {
return resolvedWorkflow{}, errorsx.InvalidParam("AI Agent workflow is not published; publish a workflow version before enabling automatic replies") 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 { if version == nil || version.Status != enums.StatusOk {
return resolvedWorkflow{}, errorsx.InvalidParam("workflow version does not exist") return resolvedWorkflow{}, errorsx.InvalidParam("workflow version does not exist")
} }
@@ -36,11 +35,3 @@ func resolveAgentWorkflow(aiAgent models.AIAgent) (resolvedWorkflow, error) {
VersionID: version.ID, VersionID: version.ID,
}, nil }, 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)
}
+5 -17
View File
@@ -49,7 +49,7 @@ func DebugRunSkill(ctx context.Context, req request.SkillDebugRunRequest) (*resp
MessageType: enums.IMMessageTypeText, MessageType: enums.IMMessageTypeText,
Content: strings.TrimSpace(req.UserMessage), Content: strings.TrimSpace(req.UserMessage),
} }
summary, err := applicationruntime.DefaultAgentApplicationService.RunPrepared(ctx, applicationruntime.Request{ summary, err := applicationruntime.DefaultAgentApplicationService.RunPrepared(ctx, applicationruntime.RunInput{
Conversation: *conversation, Conversation: *conversation,
UserMessage: message, UserMessage: message,
AIAgent: debugAgent, AIAgent: debugAgent,
@@ -93,7 +93,7 @@ func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest)
return nil, errorsx.InvalidParamI18n("error.e0117") return nil, errorsx.InvalidParamI18n("error.e0117")
} }
resumeText := strings.TrimSpace(req.UserMessage) resumeText := strings.TrimSpace(req.UserMessage)
summary, err := applicationruntime.DefaultAgentApplicationService.ResumePrepared(ctx, applicationruntime.ResumeRequest{ summary, err := applicationruntime.DefaultAgentApplicationService.ResumePrepared(ctx, applicationruntime.ResumeInput{
Conversation: *conversation, Conversation: *conversation,
AIAgent: *aiAgent, AIAgent: *aiAgent,
AIConfig: *aiConfig, AIConfig: *aiConfig,
@@ -105,7 +105,7 @@ func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest)
}) })
if err != nil { if err != nil {
if isCheckpointMissingError(err) { if isCheckpointMissingError(err) {
summary = &applicationruntime.Summary{ summary = &applicationruntime.RunResult{
Status: "expired", Status: "expired",
ReplyText: graphs.ConfirmationExpiredReply, ReplyText: graphs.ConfirmationExpiredReply,
} }
@@ -128,7 +128,7 @@ func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest)
return buildSkillDebugResumeResponse(req, summary, conversationID), nil return buildSkillDebugResumeResponse(req, summary, conversationID), nil
} }
func buildSkillDebugRunResponse(req request.SkillDebugRunRequest, summary *applicationruntime.Summary, skill *models.SkillDefinition) *response.SkillDebugRunResponse { func buildSkillDebugRunResponse(req request.SkillDebugRunRequest, summary *applicationruntime.RunResult, skill *models.SkillDefinition) *response.SkillDebugRunResponse {
resp := &response.SkillDebugRunResponse{ resp := &response.SkillDebugRunResponse{
ConversationID: req.ConversationID, ConversationID: req.ConversationID,
AIAgentID: req.AIAgentID, AIAgentID: req.AIAgentID,
@@ -144,14 +144,8 @@ func buildSkillDebugRunResponse(req request.SkillDebugRunRequest, summary *appli
resp.SkillDefinitionID = summary.PlannedSkillID resp.SkillDefinitionID = summary.PlannedSkillID
} }
resp.ReplyText = summary.ReplyText resp.ReplyText = summary.ReplyText
resp.PlanReason = summary.PlanReason
resp.SkillRouteTrace = summary.SkillRouteTrace
resp.ToolWhitelist = append([]string(nil), summary.SkillAllowedToolCodes...) resp.ToolWhitelist = append([]string(nil), summary.SkillAllowedToolCodes...)
resp.ExposedToolCodes = append([]string(nil), summary.ToolCodes...)
resp.InvokedToolCodes = append([]string(nil), summary.InvokedToolCodes...) resp.InvokedToolCodes = append([]string(nil), summary.InvokedToolCodes...)
resp.ToolSearchTrace = extractToolSearchTrace(summary)
resp.GraphToolTrace = extractGraphToolTrace(summary)
resp.GraphToolCode = firstGraphToolCode(summary)
resp.InterruptType = firstInterruptType(summary) resp.InterruptType = firstInterruptType(summary)
resp.CheckPointID = summary.CheckPointID resp.CheckPointID = summary.CheckPointID
resp.Interrupted = summary.Interrupted resp.Interrupted = summary.Interrupted
@@ -160,7 +154,7 @@ func buildSkillDebugRunResponse(req request.SkillDebugRunRequest, summary *appli
return resp return resp
} }
func buildSkillDebugResumeResponse(req request.SkillDebugResumeRequest, summary *applicationruntime.Summary, conversationID int64) *response.SkillDebugRunResponse { func buildSkillDebugResumeResponse(req request.SkillDebugResumeRequest, summary *applicationruntime.RunResult, conversationID int64) *response.SkillDebugRunResponse {
resp := &response.SkillDebugRunResponse{ resp := &response.SkillDebugRunResponse{
ConversationID: conversationID, ConversationID: conversationID,
AIAgentID: req.AIAgentID, AIAgentID: req.AIAgentID,
@@ -171,14 +165,8 @@ func buildSkillDebugResumeResponse(req request.SkillDebugResumeRequest, summary
resp.SkillDefinitionID = summary.PlannedSkillID resp.SkillDefinitionID = summary.PlannedSkillID
resp.SkillName = strings.TrimSpace(summary.PlannedSkillName) resp.SkillName = strings.TrimSpace(summary.PlannedSkillName)
resp.ReplyText = summary.ReplyText resp.ReplyText = summary.ReplyText
resp.PlanReason = summary.PlanReason
resp.SkillRouteTrace = summary.SkillRouteTrace
resp.ToolWhitelist = append([]string(nil), summary.SkillAllowedToolCodes...) resp.ToolWhitelist = append([]string(nil), summary.SkillAllowedToolCodes...)
resp.ExposedToolCodes = append([]string(nil), summary.ToolCodes...)
resp.InvokedToolCodes = append([]string(nil), summary.InvokedToolCodes...) resp.InvokedToolCodes = append([]string(nil), summary.InvokedToolCodes...)
resp.ToolSearchTrace = extractToolSearchTrace(summary)
resp.GraphToolTrace = extractGraphToolTrace(summary)
resp.GraphToolCode = firstGraphToolCode(summary)
resp.InterruptType = firstInterruptType(summary) resp.InterruptType = firstInterruptType(summary)
resp.CheckPointID = summary.CheckPointID resp.CheckPointID = summary.CheckPointID
resp.Interrupted = summary.Interrupted resp.Interrupted = summary.Interrupted
+3 -3
View File
@@ -28,7 +28,7 @@ func RunAgentEvaluation(ctx context.Context, req request.RunAgentEvaluationReque
for _, item := range req.Cases { for _, item := range req.Cases {
cases = append(cases, applicationruntime.OfflineEvaluationCase{ID: item.ID, Category: item.Category, Message: item.Message, History: item.History, Expect: item.Expect}) cases = append(cases, applicationruntime.OfflineEvaluationCase{ID: item.ID, Category: item.Category, Message: item.Message, History: item.History, Expect: item.Expect})
} }
report, err := applicationruntime.NewService().RunOfflineEvaluation(ctx, req.EngineCode, *agent, *config, cases) report, err := applicationruntime.NewService().RunOfflineEvaluation(ctx, *agent, *config, cases)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -36,9 +36,9 @@ func RunAgentEvaluation(ctx context.Context, req request.RunAgentEvaluationReque
if err != nil { if err != nil {
return nil, err return nil, err
} }
ret := &response.AgentEvaluationReportResponse{EngineCode: report.EngineCode, Total: report.Total, Passed: report.Passed, CSV: csv, Results: make([]response.AgentEvaluationResultResponse, 0, len(report.Results))} ret := &response.AgentEvaluationReportResponse{Total: report.Total, Passed: report.Passed, CSV: csv, Results: make([]response.AgentEvaluationResultResponse, 0, len(report.Results))}
for _, item := range report.Results { for _, item := range report.Results {
ret.Results = append(ret.Results, response.AgentEvaluationResultResponse{CaseID: item.CaseID, Category: item.Category, EngineCode: item.EngineCode, Passed: item.Passed, ReplyText: item.ReplyText, Interrupted: item.Interrupted, Error: item.Error, Finding: item.Finding}) ret.Results = append(ret.Results, response.AgentEvaluationResultResponse{CaseID: item.CaseID, Category: item.Category, Passed: item.Passed, ReplyText: item.ReplyText, Interrupted: item.Interrupted, Error: item.Error, Finding: item.Finding})
} }
return ret, nil return ret, nil
} }
+2 -2
View File
@@ -9,11 +9,11 @@ type aiReplyContext struct {
Conversation models.Conversation Conversation models.Conversation
Message models.Message Message models.Message
AIAgent models.AIAgent AIAgent models.AIAgent
SummaryRef **applicationruntime.Summary SummaryRef **applicationruntime.RunResult
PendingInterrupt *models.ConversationInterrupt PendingInterrupt *models.ConversationInterrupt
} }
func (c aiReplyContext) setSummary(summary *applicationruntime.Summary) { func (c aiReplyContext) setSummary(summary *applicationruntime.RunResult) {
if c.SummaryRef != nil { if c.SummaryRef != nil {
*c.SummaryRef = summary *c.SummaryRef = summary
} }
+1 -20
View File
@@ -5,27 +5,8 @@ import (
applicationruntime "agent-desk/internal/ai/application/runtime" applicationruntime "agent-desk/internal/ai/application/runtime"
"agent-desk/internal/models" "agent-desk/internal/models"
"agent-desk/internal/pkg/toolx"
) )
func TestExtractRuntimeToolTraces(t *testing.T) {
summary := &applicationruntime.Summary{
TraceData: `{
"toolSearch": {"items": [{"targetToolCode":"mcp/server/tool_a"}]},
"graphTools": {"items": [{"toolCode":"` + toolx.GraphAnalyzeConversation.Code + `"}]}
}`,
}
if got := extractToolSearchTrace(summary); got == "" {
t.Fatalf("expected tool search trace")
}
if got := extractGraphToolTrace(summary); got == "" {
t.Fatalf("expected graph tool trace")
}
if got := firstGraphToolCode(summary); got != toolx.GraphAnalyzeConversation.Code {
t.Fatalf("unexpected graph tool code: %q", got)
}
}
func TestExtractInterruptMessageAndCheckpointError(t *testing.T) { func TestExtractInterruptMessageAndCheckpointError(t *testing.T) {
if got := extractInterruptMessage(`{"message":"请补充订单号"}`); got != "请补充订单号" { if got := extractInterruptMessage(`{"message":"请补充订单号"}`); got != "请补充订单号" {
t.Fatalf("unexpected interrupt message: %q", got) t.Fatalf("unexpected interrupt message: %q", got)
@@ -44,7 +25,7 @@ func TestExtractInterruptMessageAndCheckpointError(t *testing.T) {
} }
func TestBuildConversationInterruptStoresWorkflowCheckpointData(t *testing.T) { func TestBuildConversationInterruptStoresWorkflowCheckpointData(t *testing.T) {
item := buildConversationInterrupt(testConversation(1), testMessage(2), testAIAgent(3), &applicationruntime.Summary{ item := buildConversationInterrupt(testConversation(1), testMessage(2), testAIAgent(3), &applicationruntime.RunResult{
CheckPointData: `{"confirmNodeId":"confirm_1"}`, CheckPointData: `{"confirmNodeId":"confirm_1"}`,
Interrupted: true, Interrupted: true,
WorkflowRunID: 99, WorkflowRunID: 99,
@@ -15,7 +15,7 @@ type interruptMessagePreview struct {
Message string `json:"message"` Message string `json:"message"`
} }
func buildConversationInterrupt(conversation models.Conversation, message models.Message, aiAgent models.AIAgent, summary *applicationruntime.Summary) *models.ConversationInterrupt { func buildConversationInterrupt(conversation models.Conversation, message models.Message, aiAgent models.AIAgent, summary *applicationruntime.RunResult) *models.ConversationInterrupt {
if summary == nil { if summary == nil {
return nil return nil
} }
@@ -42,7 +42,7 @@ func buildConversationInterrupt(conversation models.Conversation, message models
return item return item
} }
func resolveInterruptPrompt(summary *applicationruntime.Summary) string { func resolveInterruptPrompt(summary *applicationruntime.RunResult) string {
if summary == nil || len(summary.Interrupts) == 0 { if summary == nil || len(summary.Interrupts) == 0 {
return i18nx.Get("conversation.interrupt.defaultPrompt") return i18nx.Get("conversation.interrupt.defaultPrompt")
} }
@@ -67,14 +67,14 @@ func extractInterruptMessage(infoPreview string) string {
return strings.TrimSpace(payload.Message) return strings.TrimSpace(payload.Message)
} }
func firstInterruptID(summary *applicationruntime.Summary) string { func firstInterruptID(summary *applicationruntime.RunResult) string {
if summary == nil || len(summary.Interrupts) == 0 { if summary == nil || len(summary.Interrupts) == 0 {
return "" return ""
} }
return strings.TrimSpace(summary.Interrupts[0].ID) return strings.TrimSpace(summary.Interrupts[0].ID)
} }
func firstInterruptType(summary *applicationruntime.Summary) string { func firstInterruptType(summary *applicationruntime.RunResult) string {
if summary == nil || len(summary.Interrupts) == 0 { if summary == nil || len(summary.Interrupts) == 0 {
return "" return ""
} }
@@ -80,7 +80,7 @@ func (s *replyInterruptService) ResumePendingInterrupt(ctx context.Context, owne
return svc.ConversationInterruptService.MarkResolved(replyCtx.PendingInterrupt.ID, 0) return svc.ConversationInterruptService.MarkResolved(replyCtx.PendingInterrupt.ID, 0)
} }
func (s *replyInterruptService) HandleInterruptedSummary(owner *aiReplyService, replyCtx aiReplyContext, summary *applicationruntime.Summary) error { func (s *replyInterruptService) HandleInterruptedSummary(owner *aiReplyService, replyCtx aiReplyContext, summary *applicationruntime.RunResult) error {
pending := buildConversationInterrupt(replyCtx.Conversation, replyCtx.Message, replyCtx.AIAgent, summary) pending := buildConversationInterrupt(replyCtx.Conversation, replyCtx.Message, replyCtx.AIAgent, summary)
if pending != nil && pending.AgentRunID > 0 { if pending != nil && pending.AgentRunID > 0 {
pending.AgentStepID = svc.AgentRunService.GetLatestStepID(pending.AgentRunID) pending.AgentStepID = svc.AgentRunService.GetLatestStepID(pending.AgentRunID)
@@ -107,7 +107,7 @@ func (s *replyInterruptService) HandleInterruptedSummary(owner *aiReplyService,
return nil return nil
} }
func (s *replyInterruptService) HandleInterruptedResume(owner *aiReplyService, replyCtx aiReplyContext, summary *applicationruntime.Summary) error { func (s *replyInterruptService) HandleInterruptedResume(owner *aiReplyService, replyCtx aiReplyContext, summary *applicationruntime.RunResult) error {
if replyCtx.PendingInterrupt == nil { if replyCtx.PendingInterrupt == nil {
return fmt.Errorf("pending interrupt is required") return fmt.Errorf("pending interrupt is required")
} }
+1 -1
View File
@@ -29,7 +29,7 @@ type aiReplyService struct {
commit *replyCommitService commit *replyCommitService
} }
func firstInvokedToolCode(summary *applicationruntime.Summary) string { func firstInvokedToolCode(summary *applicationruntime.RunResult) string {
if summary == nil { if summary == nil {
return "" return ""
} }
+1 -1
View File
@@ -86,7 +86,7 @@ func TestResolveReplyTimeout(t *testing.T) {
} }
func TestResolveInterruptPrompt(t *testing.T) { func TestResolveInterruptPrompt(t *testing.T) {
summary := &applicationruntime.Summary{ summary := &applicationruntime.RunResult{
Interrupts: []applicationruntime.InterruptContextSummary{ Interrupts: []applicationruntime.InterruptContextSummary{
{ {
ID: "interrupt-1", ID: "interrupt-1",
+1 -1
View File
@@ -45,7 +45,7 @@ func (s *aiReplyService) TriggerReplyAsync(conversation models.Conversation, mes
} }
func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.Conversation, message models.Message, aiAgent models.AIAgent) (retErr error) { func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.Conversation, message models.Message, aiAgent models.AIAgent) (retErr error) {
var summary *applicationruntime.Summary var summary *applicationruntime.RunResult
replyCtx := aiReplyContext{ replyCtx := aiReplyContext{
Conversation: conversation, Conversation: conversation,
Message: message, Message: message,
@@ -29,7 +29,7 @@ func newRuntimeReplyExecutor() *runtimeReplyExecutor {
return &runtimeReplyExecutor{} return &runtimeReplyExecutor{}
} }
func (e *runtimeReplyExecutor) Run(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.Summary, error) { func (e *runtimeReplyExecutor) Run(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.RunResult, error) {
summary, err := applicationruntime.DefaultAgentApplicationService.Run(ctx, applicationruntime.ApplicationRunInput{ summary, err := applicationruntime.DefaultAgentApplicationService.Run(ctx, applicationruntime.ApplicationRunInput{
ConversationID: input.Conversation.ID, ConversationID: input.Conversation.ID,
MessageID: input.Message.ID, MessageID: input.Message.ID,
@@ -38,7 +38,7 @@ func (e *runtimeReplyExecutor) Run(ctx context.Context, input runtimeReplyRunInp
return summary, err return summary, err
} }
func (e *runtimeReplyExecutor) ResumePendingInterrupt(ctx context.Context, input runtimeReplyResumeInput) (*applicationruntime.Summary, error) { func (e *runtimeReplyExecutor) ResumePendingInterrupt(ctx context.Context, input runtimeReplyResumeInput) (*applicationruntime.RunResult, error) {
if input.PendingInterrupt == nil { if input.PendingInterrupt == nil {
return nil, fmt.Errorf("pending interrupt is required") return nil, fmt.Errorf("pending interrupt is required")
} }
@@ -56,8 +56,8 @@ func (e *runtimeReplyExecutor) ResumePendingInterrupt(ctx context.Context, input
return summary, err return summary, err
} }
func expiredInterruptSummary() *applicationruntime.Summary { func expiredInterruptSummary() *applicationruntime.RunResult {
return &applicationruntime.Summary{ return &applicationruntime.RunResult{
Status: "expired", Status: "expired",
ReplyText: graphs.ConfirmationExpiredReply, ReplyText: graphs.ConfirmationExpiredReply,
} }
@@ -1,82 +0,0 @@
package runtime
import (
"encoding/json"
"strings"
applicationruntime "agent-desk/internal/ai/application/runtime"
)
func extractToolSearchTrace(summary *applicationruntime.Summary) string {
if summary == nil {
return ""
}
trace := parseRuntimeTraceData(summary.TraceData)
if len(trace.ToolSearch.Items) == 0 {
return ""
}
buf, err := json.Marshal(trace.ToolSearch)
if err != nil {
return ""
}
return string(buf)
}
func extractGraphToolTrace(summary *applicationruntime.Summary) string {
if summary == nil {
return ""
}
trace := parseRuntimeTraceData(summary.TraceData)
if len(trace.GraphTools.Items) == 0 {
return ""
}
buf, err := json.Marshal(trace.GraphTools)
if err != nil {
return ""
}
return string(buf)
}
func firstGraphToolCode(summary *applicationruntime.Summary) string {
if summary == nil {
return ""
}
trace := parseRuntimeTraceData(summary.TraceData)
for _, item := range trace.GraphTools.Items {
toolCode := strings.TrimSpace(item.ToolCode)
if toolCode != "" {
return toolCode
}
}
return ""
}
type runtimeTraceProjection struct {
ToolSearch struct {
Items []struct {
TargetToolCode string `json:"targetToolCode"`
CandidateToolCodes []string `json:"candidateToolCodes"`
} `json:"items"`
} `json:"toolSearch"`
GraphTools struct {
Items []struct {
ToolCode string `json:"toolCode"`
Arguments json.RawMessage `json:"arguments"`
RecommendedAction string `json:"recommendedAction"`
RiskLevel string `json:"riskLevel"`
TicketDraftReady bool `json:"ticketDraftReady"`
} `json:"items"`
} `json:"graphTools"`
}
func parseRuntimeTraceData(raw string) runtimeTraceProjection {
raw = strings.TrimSpace(raw)
if raw == "" {
return runtimeTraceProjection{}
}
var trace runtimeTraceProjection
if err := json.Unmarshal([]byte(raw), &trace); err != nil {
return runtimeTraceProjection{}
}
return trace
}
+2 -2
View File
@@ -18,10 +18,10 @@ type service struct {
app *applicationruntime.Service app *applicationruntime.Service
} }
func (s *service) Run(ctx context.Context, req applicationruntime.Request) (*applicationruntime.Summary, error) { func (s *service) Run(ctx context.Context, req applicationruntime.RunInput) (*applicationruntime.RunResult, error) {
return s.app.Run(ctx, req) return s.app.Run(ctx, req)
} }
func (s *service) Resume(ctx context.Context, req applicationruntime.ResumeRequest) (*applicationruntime.Summary, error) { func (s *service) Resume(ctx context.Context, req applicationruntime.ResumeInput) (*applicationruntime.RunResult, error) {
return s.app.Resume(ctx, req) return s.app.Resume(ctx, req)
} }
@@ -168,9 +168,8 @@ func (t *ToolSearchTool) invokeTargetTool(ctx context.Context, toolCode string,
if !containsToolCode(t.allowedToolCodes, toolCode) { if !containsToolCode(t.allowedToolCodes, toolCode) {
return "", i18nx.Errorf("error.e0279") return "", i18nx.Errorf("error.e0279")
} }
// A workflow administrator's allow-list is the explicit approval boundary // The published Agent allow-list is the approval boundary for MCP tools.
// for MCP tools. The registry still enforces its call limit and normalizes // The registry still enforces call limits and safety metadata.
// the safety metadata used by future autonomous engines.
_, result, err := aitooling.DefaultMCPExecutor.Execute(ctx, toolCode, arguments, aitooling.Policy{ _, result, err := aitooling.DefaultMCPExecutor.Execute(ctx, toolCode, arguments, aitooling.Policy{
AllowedToolCodes: t.allowedToolCodes, AllowedToolCodes: t.allowedToolCodes,
Confirmed: true, Confirmed: true,
-142
View File
@@ -1,48 +1,5 @@
package traces package traces
type ToolTraceItem struct {
ToolCode string `json:"toolCode"`
ServerCode string `json:"serverCode"`
ToolName string `json:"toolName"`
Arguments map[string]any `json:"arguments,omitempty"`
ResultPreview string `json:"resultPreview,omitempty"`
ResultReduced bool `json:"resultReduced,omitempty"`
OriginalChars int `json:"originalChars,omitempty"`
KeptChars int `json:"keptChars,omitempty"`
LatencyMs int64 `json:"latencyMs,omitempty"`
Status string `json:"status,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
Blocked bool `json:"blocked,omitempty"`
BlockedReason string `json:"blockedReason,omitempty"`
}
type ToolSearchTraceItem struct {
Action string `json:"action,omitempty"`
Query string `json:"query,omitempty"`
TargetToolCode string `json:"targetToolCode,omitempty"`
TargetServerCode string `json:"targetServerCode,omitempty"`
TargetToolName string `json:"targetToolName,omitempty"`
CandidateToolCodes []string `json:"candidateToolCodes,omitempty"`
Status string `json:"status,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
type GraphToolTraceItem struct {
ToolCode string `json:"toolCode"`
ToolName string `json:"toolName"`
Arguments map[string]any `json:"arguments,omitempty"`
ResultPreview string `json:"resultPreview,omitempty"`
ResultReduced bool `json:"resultReduced,omitempty"`
OriginalChars int `json:"originalChars,omitempty"`
KeptChars int `json:"keptChars,omitempty"`
LatencyMs int64 `json:"latencyMs,omitempty"`
Status string `json:"status,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
RecommendedAction string `json:"recommendedAction,omitempty"`
RiskLevel string `json:"riskLevel,omitempty"`
TicketDraftReady bool `json:"ticketDraftReady,omitempty"`
}
type RetrieverTraceItem struct { type RetrieverTraceItem struct {
Query string `json:"query,omitempty"` Query string `json:"query,omitempty"`
KnowledgeBaseID int64 `json:"knowledgeBaseId,omitempty"` KnowledgeBaseID int64 `json:"knowledgeBaseId,omitempty"`
@@ -65,107 +22,8 @@ type RetrieverTraceSummary struct {
Policies []RetrieverPolicyTraceItem Policies []RetrieverPolicyTraceItem
} }
type AnswerabilityTraceData struct {
Status string `json:"status,omitempty"`
Reason string `json:"reason,omitempty"`
SupportingChunkIDs []string `json:"supportingChunkIds,omitempty"`
MissingInfo []string `json:"missingInfo,omitempty"`
LatencyMs int64 `json:"latencyMs,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
type RetrieverPolicyTraceItem struct { type RetrieverPolicyTraceItem struct {
KnowledgeBaseID int64 `json:"knowledgeBaseId,omitempty"` KnowledgeBaseID int64 `json:"knowledgeBaseId,omitempty"`
TopK int `json:"topK,omitempty"` TopK int `json:"topK,omitempty"`
ScoreThreshold float64 `json:"scoreThreshold,omitempty"` ScoreThreshold float64 `json:"scoreThreshold,omitempty"`
} }
type InstructionTraceSummary struct {
SectionTitles []string
HasAgentRule bool
HasSkillRule bool
HasToolRule bool
}
type RuntimeTraceData struct {
Version string `json:"version"`
Status string `json:"status"`
RunID string `json:"runId,omitempty"`
Skill SkillTraceData `json:"skill,omitempty"`
Interrupt struct {
CheckPointID string `json:"checkPointId,omitempty"`
Items []InterruptTraceContext `json:"items,omitempty"`
} `json:"interrupt"`
Model struct {
Provider string `json:"provider,omitempty"`
Name string `json:"name,omitempty"`
} `json:"model"`
Instruction struct {
SectionTitles []string `json:"sectionTitles,omitempty"`
HasAgentRule bool `json:"hasAgentRule,omitempty"`
HasSkillRule bool `json:"hasSkillRule,omitempty"`
HasToolRule bool `json:"hasToolRule,omitempty"`
} `json:"instruction"`
Input struct {
HistoryMessageCount int `json:"historyMessageCount,omitempty"`
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds,omitempty"`
ToolCodes []string `json:"toolCodes,omitempty"`
StaticToolCodes []string `json:"staticToolCodes,omitempty"`
DynamicToolCodes []string `json:"dynamicToolCodes,omitempty"`
ToolSearchEnabled bool `json:"toolSearchEnabled,omitempty"`
CurrentUserMessagePreview string `json:"currentUserMessagePreview,omitempty"`
} `json:"input"`
Retriever struct {
Count int `json:"count,omitempty"`
TopK int `json:"topK,omitempty"`
ScoreThreshold float64 `json:"scoreThreshold,omitempty"`
ContextMaxTokens int `json:"contextMaxTokens,omitempty"`
MaxContextItems int `json:"maxContextItems,omitempty"`
ContextCount int `json:"contextCount,omitempty"`
EmbeddingMs int64 `json:"embeddingMs,omitempty"`
VectorSearchMs int64 `json:"vectorSearchMs,omitempty"`
HydrateMs int64 `json:"hydrateMs,omitempty"`
Policies []RetrieverPolicyTraceItem `json:"policies,omitempty"`
Items []RetrieverTraceItem `json:"items,omitempty"`
} `json:"retriever"`
Answerability AnswerabilityTraceData `json:"answerability,omitempty"`
Tools struct {
Count int `json:"count,omitempty"`
Items []ToolTraceItem `json:"items,omitempty"`
} `json:"tools"`
ToolSearch struct {
Count int `json:"count,omitempty"`
Items []ToolSearchTraceItem `json:"items,omitempty"`
} `json:"toolSearch"`
GraphTools struct {
Count int `json:"count,omitempty"`
Items []GraphToolTraceItem `json:"items,omitempty"`
} `json:"graphTools"`
Output struct {
ReplyText string `json:"replyText,omitempty"`
FinishReason string `json:"finishReason,omitempty"`
} `json:"output"`
Error struct {
Message string `json:"message,omitempty"`
Stage string `json:"stage,omitempty"`
} `json:"error"`
}
type SkillTraceData struct {
ID int64 `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
RouteReason string `json:"routeReason,omitempty"`
RouteTrace string `json:"routeTrace,omitempty"`
AllowedToolCodes []string `json:"allowedToolCodes,omitempty"`
FilteredToolCodes []string `json:"filteredToolCodes,omitempty"`
MiddlewareEnabled bool `json:"middlewareEnabled,omitempty"`
MiddlewareToolName string `json:"middlewareToolName,omitempty"`
VisibleIDs []int64 `json:"visibleIds,omitempty"`
}
type InterruptTraceContext struct {
Type string `json:"type,omitempty"`
ID string `json:"id"`
InfoPreview string `json:"infoPreview,omitempty"`
}
-36
View File
@@ -1,36 +0,0 @@
package skills
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
)
var newCandidateLoader = func() *candidateLoader {
return &candidateLoader{}
}
type candidateLoader struct {
}
func (l *candidateLoader) findManualSkillDefinition(skillDefinitionID int64) *models.SkillDefinition {
if skillDefinitionID <= 0 {
return nil
}
return repositories.SkillDefinitionRepository.Get(sqls.DB(), skillDefinitionID)
}
func (l *candidateLoader) loadCandidateSkills(aiAgent models.AIAgent) []models.SkillDefinition {
skillIDs := utils.SplitInt64s(aiAgent.SkillIDs)
skills := repositories.SkillDefinitionRepository.GetByIDs(sqls.DB(), skillIDs)
ret := make([]models.SkillDefinition, 0, len(skillIDs))
for _, id := range skillIDs {
if skill, ok := skills[id]; ok && skill.Status == enums.StatusOk {
ret = append(ret, skill)
}
}
return ret
}
-13
View File
@@ -1,13 +0,0 @@
package skills
import "agent-desk/internal/models"
// BuildRunLog 根据执行计划与运行结果构建 Skill 运行日志。
func BuildRunLog(ctx RuntimeContext, plan *ExecutionPlan, trace *ExecutionTrace, err error) *models.SkillRunLog {
return RuntimeService.runlog.Build(ctx, plan, trace, err)
}
// WriteRunLog 写入 Skill 路由日志。
func WriteRunLog(log *models.SkillRunLog) error {
return RuntimeService.runlog.Write(log)
}
-87
View File
@@ -1,87 +0,0 @@
package skills
import (
"strings"
"testing"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
)
func TestBuildRunLogMatchedPlan(t *testing.T) {
log := BuildRunLog(
RuntimeContext{
AIAgent: models.AIAgent{ID: 22},
AIConfig: models.AIConfig{ID: 33},
ConversationID: 11,
ManualSkillDefinitionID: 44,
UserMessage: "我要退款",
},
&ExecutionPlan{
AIAgent: models.AIAgent{ID: 22},
AIConfig: models.AIConfig{
ID: 33,
ModelName: "gpt-test",
Provider: enums.AIProviderOpenAI,
},
Skill: &models.SkillDefinition{ID: 44},
MatchReason: "llm_route",
},
&ExecutionTrace{Status: "ok"},
nil,
)
if log == nil {
t.Fatalf("expected run log")
}
if log.ConversationID != 11 || log.AIAgentID != 22 || log.AIConfigID != 33 {
t.Fatalf("unexpected ids in run log: %#v", log)
}
if !log.Matched || !log.FinalSelected || log.SkillDefinitionID != 44 {
t.Fatalf("expected matched skill log, got %#v", log)
}
if log.MatchReason != "llm_route" {
t.Fatalf("unexpected match reason: %q", log.MatchReason)
}
if !strings.Contains(log.TraceData, `"status":"ok"`) {
t.Fatalf("expected trace data to contain status, got %q", log.TraceData)
}
}
func TestBuildRunLogNotMatchedAndError(t *testing.T) {
log := BuildRunLog(
RuntimeContext{
AIAgent: models.AIAgent{ID: 22},
UserMessage: "随便问问",
},
nil,
&ExecutionTrace{Status: "route_error"},
assertErr("route failed"),
)
if log == nil {
t.Fatalf("expected run log")
}
if log.Matched {
t.Fatalf("expected unmatched log")
}
if log.ErrorMessage != "route failed" {
t.Fatalf("unexpected error message: %q", log.ErrorMessage)
}
noMatchLog := BuildRunLog(
RuntimeContext{AIAgent: models.AIAgent{ID: 22}, UserMessage: "随便问问"},
&ExecutionPlan{MatchReason: ""},
&ExecutionTrace{Status: "not_matched"},
nil,
)
if noMatchLog.MatchReason != "not_matched" {
t.Fatalf("expected default not_matched reason, got %q", noMatchLog.MatchReason)
}
}
type assertErr string
func (e assertErr) Error() string {
return string(e)
}
-63
View File
@@ -1,63 +0,0 @@
package skills
import (
"context"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
)
type intentTriggerConfig struct {
Intents []string `json:"intents"`
}
// MatchSkill 对单个 SkillDefinition 执行命中判断。
func MatchSkill(execCtx context.Context, ctx RuntimeContext) (*models.SkillDefinition, string, *RouteTrace, error) {
loader := newCandidateLoader()
if ctx.ManualSkillDefinitionID > 0 {
skill := loader.findManualSkillDefinition(ctx.ManualSkillDefinitionID)
if skill == nil || skill.Status != enums.StatusOk {
return nil, "", nil, errorsx.InvalidParamI18n("error.e0054")
}
return skill, "manual_skill_id", &RouteTrace{
Status: "manual_selected",
SelectedSkillID: skill.ID,
}, nil
}
candidates := loader.loadCandidateSkills(ctx.AIAgent)
trace := &RouteTrace{
Status: "started",
CandidateSkillIDs: make([]int64, 0, len(candidates)),
}
for _, item := range candidates {
trace.CandidateSkillIDs = append(trace.CandidateSkillIDs, item.ID)
}
if len(candidates) == 0 {
trace.Status = "no_candidate"
return nil, "no_enabled_skill_bound", trace, nil
}
selected, routeTrace, err := routeSkillWithLLM(execCtx, ctx, candidates)
if routeTrace != nil {
trace.Status = routeTrace.Status
trace.SelectedSkillID = routeTrace.SelectedSkillID
trace.RawDecision = routeTrace.RawDecision
trace.LatencyMs = routeTrace.LatencyMs
trace.Error = routeTrace.Error
}
if err != nil {
if trace.Error == "" {
trace.Error = err.Error()
}
return nil, "route_error", trace, err
}
if selected == nil {
if trace.Status == "started" {
trace.Status = "not_matched"
}
return nil, "route_none", trace, nil
}
return selected, "llm_route", trace, nil
}
-28
View File
@@ -1,28 +0,0 @@
package skills
import (
"context"
"strings"
)
func newPlanService() *planService {
return &planService{}
}
type planService struct{}
// BuildExecutionPlan 构建当前请求的 Skill 执行计划。
func (s *planService) BuildExecutionPlan(execCtx context.Context, ctx RuntimeContext) (*ExecutionPlan, error) {
skill, matchReason, routeTrace, err := MatchSkill(execCtx, ctx)
if err != nil {
return nil, err
}
return &ExecutionPlan{
AIAgent: ctx.AIAgent,
AIConfig: ctx.AIConfig,
Skill: skill,
MatchReason: strings.TrimSpace(matchReason),
RouteTrace: routeTrace,
}, nil
}
-122
View File
@@ -1,122 +0,0 @@
package skills
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"agent-desk/internal/ai"
"agent-desk/internal/models"
"github.com/mlogclub/simple/common/strs"
)
const routeSkillSystemPrompt = `你是客服技能路由器你只能在候选 Skill 中选择一个最合适的 skillId或者返回 NONE
只有当用户问题与 Skill 的职责边界明确匹配时才选择
如果不明确信息不足多个 Skill 都不够确定就返回 NONE
输出只能是 skillId NONE不能输出其他内容`
func routeSkillWithLLM(ctx context.Context, runtimeCtx RuntimeContext, candidates []models.SkillDefinition) (*models.SkillDefinition, *RouteTrace, error) {
trace := &RouteTrace{Status: "started"}
if len(candidates) == 0 {
trace.Status = "no_candidate"
return nil, trace, nil
}
if strs.IsBlank(runtimeCtx.UserMessage) {
trace.Status = "empty_user_message"
return nil, trace, nil
}
userPrompt := buildSkillRoutePrompt(runtimeCtx.UserMessage, candidates)
startedAt := time.Now()
result, err := ai.LLM.ChatWithConfig(ctx, runtimeCtx.AIConfig, routeSkillSystemPrompt, userPrompt)
trace.LatencyMs = time.Since(startedAt).Milliseconds()
if err != nil {
trace.Status = "route_error"
trace.Error = err.Error()
return nil, trace, err
}
decision := normalizeRouteDecision(result.Content)
trace.RawDecision = strings.TrimSpace(result.Content)
if decision == "" || decision == "NONE" {
trace.Status = "not_matched"
return nil, trace, nil
}
selectedID, parseErr := strconv.ParseInt(decision, 10, 64)
if parseErr != nil || selectedID <= 0 {
trace.Status = "invalid_decision"
trace.Error = fmt.Sprintf("invalid route decision: %s", decision)
return nil, trace, nil
}
for _, item := range candidates {
if item.ID == selectedID {
trace.Status = "llm_selected"
trace.SelectedSkillID = item.ID
return &item, trace, nil
}
}
trace.Status = "invalid_decision"
trace.Error = fmt.Sprintf("invalid route decision: %s", decision)
return nil, trace, nil
}
func buildSkillRoutePrompt(userMessage string, candidates []models.SkillDefinition) string {
lines := make([]string, 0, len(candidates)+4)
lines = append(lines, "用户问题:")
lines = append(lines, strings.TrimSpace(userMessage))
lines = append(lines, "")
lines = append(lines, "候选 Skills")
for _, item := range candidates {
line := fmt.Sprintf("- skillId=%d; name=%s; description=%s", item.ID, strings.TrimSpace(item.Name), strings.TrimSpace(item.Description))
if examples := parseSkillExamples(item.Examples); len(examples) > 0 {
line += "; examples=" + strings.Join(examples, " | ")
}
lines = append(lines, line)
}
lines = append(lines, "")
lines = append(lines, "请只输出一个 skillId 或 NONE。")
return strings.Join(lines, "\n")
}
func parseSkillExamples(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil
}
ret := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
ret = append(ret, item)
if len(ret) >= 3 {
break
}
}
return ret
}
func normalizeRouteDecision(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if idx := strings.Index(raw, "\n"); idx >= 0 {
raw = raw[:idx]
}
raw = strings.TrimSpace(raw)
raw = strings.Trim(raw, "`")
raw = strings.TrimSpace(raw)
raw = strings.Trim(raw, "\"'")
if strings.EqualFold(raw, "NONE") {
return "NONE"
}
return raw
}
-48
View File
@@ -1,48 +0,0 @@
package skills
import (
"strings"
"testing"
"agent-desk/internal/models"
)
func TestParseSkillExamples(t *testing.T) {
examples := parseSkillExamples(`[" 退款进度 ","","发票补开","修改收货地址","多余示例"]`)
if len(examples) != 3 {
t.Fatalf("expected 3 examples, got %d", len(examples))
}
if examples[0] != "退款进度" || examples[1] != "发票补开" || examples[2] != "修改收货地址" {
t.Fatalf("unexpected examples: %#v", examples)
}
}
func TestNormalizeRouteDecision(t *testing.T) {
if got := normalizeRouteDecision("```44```\n补充说明"); got != "44" {
t.Fatalf("unexpected normalized decision: %q", got)
}
if got := normalizeRouteDecision(" none "); got != "NONE" {
t.Fatalf("expected NONE, got %q", got)
}
}
func TestBuildSkillRoutePrompt(t *testing.T) {
prompt := buildSkillRoutePrompt("我要申请退款", []models.SkillDefinition{
{
ID: 44,
Name: "退款处理",
Description: "负责退款和退货相关问题",
Examples: `["退款进度","退货运费"]`,
},
})
if !strings.Contains(prompt, "skillId=44") {
t.Fatalf("expected prompt to include skill id, got %q", prompt)
}
if !strings.Contains(prompt, "examples=退款进度 | 退货运费") {
t.Fatalf("expected prompt to include examples, got %q", prompt)
}
if !strings.Contains(prompt, "请只输出一个 skillId 或 NONE。") {
t.Fatalf("expected prompt to include output constraint, got %q", prompt)
}
}
-70
View File
@@ -1,70 +0,0 @@
package skills
import (
"encoding/json"
"time"
"agent-desk/internal/models"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
)
func newRunLogService() *RunLogService {
return &RunLogService{}
}
type RunLogService struct{}
// Build 根据执行计划与运行结果构建 Skill 运行日志。
func (s *RunLogService) Build(ctx RuntimeContext, plan *ExecutionPlan, trace *ExecutionTrace, err error) *models.SkillRunLog {
log := &models.SkillRunLog{
ConversationID: ctx.ConversationID,
AIAgentID: ctx.AIAgent.ID,
ManualSkillID: ctx.ManualSkillDefinitionID,
UserMessage: ctx.UserMessage,
TraceData: s.buildTraceData(trace),
CreatedAt: time.Now(),
}
if plan != nil {
log.AIConfigID = plan.AIConfig.ID
log.UsedModel = plan.AIConfig.ModelName
log.UsedProvider = plan.AIConfig.Provider
if plan.Skill != nil {
log.SkillDefinitionID = plan.Skill.ID
log.Matched = true
log.FinalSelected = true
log.MatchReason = plan.MatchReason
}
}
if err != nil {
log.ErrorMessage = err.Error()
} else if !log.Matched {
if plan != nil && plan.MatchReason != "" {
log.MatchReason = plan.MatchReason
} else {
log.MatchReason = "not_matched"
}
}
return log
}
// Write 写入 Skill 路由日志。
func (s *RunLogService) Write(log *models.SkillRunLog) error {
if log == nil {
return nil
}
return repositories.SkillRunLogRepository.Create(sqls.DB(), log)
}
func (s *RunLogService) buildTraceData(trace *ExecutionTrace) string {
if trace == nil {
return ""
}
data, err := json.Marshal(trace)
if err != nil {
return ""
}
return string(data)
}
-15
View File
@@ -1,15 +0,0 @@
package skills
import (
"context"
)
// BuildExecutionPlan 构建当前请求的 Skill 执行计划。
func BuildExecutionPlan(execCtx context.Context, ctx RuntimeContext) (*ExecutionPlan, error) {
return RuntimeService.BuildExecutionPlan(execCtx, ctx)
}
// Select 执行一次 Skill 路由并记录路由日志。
func Select(ctx context.Context, runtimeCtx RuntimeContext) (*ExecutionResult, error) {
return RuntimeService.Select(ctx, runtimeCtx)
}
-72
View File
@@ -1,72 +0,0 @@
package skills
import (
"context"
"strings"
"agent-desk/internal/models"
)
var RuntimeService = newService()
func newService() *Service {
return &Service{
plan: newPlanService(),
runlog: newRunLogService(),
}
}
type Service struct {
plan *planService
runlog *RunLogService
}
// BuildExecutionPlan 构建当前请求的 Skill 执行计划。
func (s *Service) BuildExecutionPlan(execCtx context.Context, ctx RuntimeContext) (*ExecutionPlan, error) {
return s.plan.BuildExecutionPlan(execCtx, ctx)
}
// WriteRunLog 写入 Skill 路由日志。
func (s *Service) WriteRunLog(log *models.SkillRunLog) error {
return s.runlog.Write(log)
}
// Select 执行一次 Skill 路由并记录路由日志。
func (s *Service) Select(ctx context.Context, runtimeCtx RuntimeContext) (*ExecutionResult, error) {
plan, err := s.BuildExecutionPlan(ctx, runtimeCtx)
if err != nil {
trace := &ExecutionTrace{Status: "route_error"}
log := s.runlog.Build(runtimeCtx, nil, trace, err)
_ = s.WriteRunLog(log)
return nil, err
}
trace := &ExecutionTrace{Status: "ok"}
if plan == nil || plan.Skill == nil {
if plan != nil {
trace.Status = "not_matched"
trace.MatchReason = strings.TrimSpace(plan.MatchReason)
trace.Route = plan.RouteTrace
}
log := s.runlog.Build(runtimeCtx, plan, trace, nil)
_ = s.WriteRunLog(log)
return &ExecutionResult{
Plan: plan,
RunLog: log,
Trace: trace,
}, nil
}
trace.MatchReason = strings.TrimSpace(plan.MatchReason)
trace.Route = plan.RouteTrace
log := s.runlog.Build(runtimeCtx, plan, trace, err)
if writeErr := s.WriteRunLog(log); writeErr != nil && err == nil {
err = writeErr
}
if err != nil {
return nil, err
}
return &ExecutionResult{
Plan: plan,
RunLog: log,
Trace: trace,
}, nil
}
-52
View File
@@ -1,52 +0,0 @@
package skills
import "agent-desk/internal/models"
// RuntimeContext 表示一次 Skill 运行的输入上下文。
type RuntimeContext struct {
AIAgent models.AIAgent // AIAgent 为当前请求所属的 AI Agent,必填。
AIConfig models.AIConfig // AIConfig 为当前请求实际使用的模型配置,必填。
UserMessage string // UserMessage 为当前用户输入。
ConversationID int64 // ConversationID 为当前会话 ID,无会话上下文时为 0。
ManualSkillDefinitionID int64 // ManualSkillDefinitionID 为显式指定的 Skill 定义ID。
}
// ExecutionPlan 表示 Skill Runtime 计算出的最终路由结果。
type ExecutionPlan struct {
AIAgent models.AIAgent // AIAgent 为本次请求所属的 AI Agent。
AIConfig models.AIConfig // AIConfig 为本次请求实际使用的模型配置。
Skill *models.SkillDefinition // Skill 为最终命中的 Skill,未命中时为空。
MatchReason string // MatchReason 为命中原因。
RouteTrace *RouteTrace // RouteTrace 为匹配阶段的路由追踪。
}
// ExecutionResult 表示一次 Skill 路由的最终结果。
type ExecutionResult struct {
Plan *ExecutionPlan
RunLog *models.SkillRunLog
Trace *ExecutionTrace
}
type ExecutionTrace struct {
Status string `json:"status"`
MatchReason string `json:"matchReason,omitempty"`
Route *RouteTrace `json:"route,omitempty"`
}
type RouteTrace struct {
Status string `json:"status"`
CandidateSkillIDs []int64 `json:"candidateSkillIds,omitempty"`
SelectedSkillID int64 `json:"selectedSkillId,omitempty"`
RawDecision string `json:"rawDecision,omitempty"`
LatencyMs int64 `json:"latencyMs,omitempty"`
Error string `json:"error,omitempty"`
}
type PromptTrace struct {
Status string `json:"status"`
LatencyMs int64 `json:"latencyMs,omitempty"`
ModelName string `json:"modelName,omitempty"`
PromptTokens int `json:"promptTokens,omitempty"`
CompletionTokens int `json:"completionTokens,omitempty"`
Error string `json:"error,omitempty"`
}
-1
View File
@@ -249,7 +249,6 @@ func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) {
func registerDashboardAgentRunRoutes(group *gin.RouterGroup) { func registerDashboardAgentRunRoutes(group *gin.RouterGroup) {
group.Any("/metrics", dashboard.AgentRunAnyMetrics) group.Any("/metrics", dashboard.AgentRunAnyMetrics)
group.Any("/comparison", dashboard.AgentRunAnyComparison)
group.POST("/evaluate", dashboard.AgentRunPostEvaluate) group.POST("/evaluate", dashboard.AgentRunPostEvaluate)
group.Any("/list", dashboard.AgentRunAnyList) group.Any("/list", dashboard.AgentRunAnyList)
group.POST("/quality_feedback", dashboard.AgentRunPostSave_quality_feedback) group.POST("/quality_feedback", dashboard.AgentRunPostSave_quality_feedback)
-1
View File
@@ -47,7 +47,6 @@ func TestNewServerRegistersGinRoutes(t *testing.T) {
http.MethodGet + " /api/dashboard/ai-workflow/run/list", http.MethodGet + " /api/dashboard/ai-workflow/run/list",
http.MethodGet + " /api/dashboard/ai-workflow/run/:id", http.MethodGet + " /api/dashboard/ai-workflow/run/:id",
http.MethodGet + " /api/dashboard/agent-run/metrics", http.MethodGet + " /api/dashboard/agent-run/metrics",
http.MethodGet + " /api/dashboard/agent-run/comparison",
http.MethodPost + " /api/dashboard/agent-run/evaluate", http.MethodPost + " /api/dashboard/agent-run/evaluate",
http.MethodGet + " /api/dashboard/agent-run/:id", http.MethodGet + " /api/dashboard/agent-run/:id",
http.MethodPost + " /api/dashboard/ai-agent/rollback_rollout", http.MethodPost + " /api/dashboard/ai-agent/rollback_rollout",
+1 -1
View File
@@ -14,7 +14,7 @@ func BuildAgentRevision(item *models.AgentRevision) response.AgentRevisionRespon
publishedAt = item.PublishedAt.Format("2006-01-02 15:04:05") publishedAt = item.PublishedAt.Format("2006-01-02 15:04:05")
} }
return response.AgentRevisionResponse{ return response.AgentRevisionResponse{
ID: item.ID, AgentID: item.AgentID, Revision: item.Revision, WorkflowVersionID: item.WorkflowVersionID, ID: item.ID, AgentID: item.AgentID, Revision: item.Revision,
Status: item.Status, DefinitionHash: item.DefinitionHash, PublishedAt: publishedAt, Status: item.Status, DefinitionHash: item.DefinitionHash, PublishedAt: publishedAt,
PublishedByID: item.PublishedByID, PublishedByName: item.PublishedByName, PublishedByID: item.PublishedByID, PublishedByName: item.PublishedByName,
} }
-1
View File
@@ -18,7 +18,6 @@ func BuildAgentRun(item *models.AgentRun) response.AgentRunResponse {
AgentRevisionID: item.AgentRevisionID, AgentRevisionID: item.AgentRevisionID,
SourceMessageID: item.SourceMessageID, SourceMessageID: item.SourceMessageID,
WorkflowRunID: item.WorkflowRunID, WorkflowRunID: item.WorkflowRunID,
EngineCode: item.EngineCode,
Status: item.Status, Status: item.Status,
PromptTokens: item.PromptTokens, PromptTokens: item.PromptTokens,
CompletionTokens: item.CompletionTokens, CompletionTokens: item.CompletionTokens,
@@ -24,7 +24,6 @@ func AgentRunAnyList(ctx *gin.Context) {
params.QueryFilter{ParamName: "agentRevisionId"}, params.QueryFilter{ParamName: "agentRevisionId"},
params.QueryFilter{ParamName: "sourceMessageId"}, params.QueryFilter{ParamName: "sourceMessageId"},
params.QueryFilter{ParamName: "workflowRunId"}, params.QueryFilter{ParamName: "workflowRunId"},
params.QueryFilter{ParamName: "engineCode"},
params.QueryFilter{ParamName: "status"}, params.QueryFilter{ParamName: "status"},
).Desc("id") ).Desc("id")
list, paging := services.AgentRunService.FindPageByParams(queryParams) list, paging := services.AgentRunService.FindPageByParams(queryParams)
@@ -75,15 +74,6 @@ func AgentRunAnyMetrics(ctx *gin.Context) {
httpx.WriteJSON(ctx, services.AgentRunService.GetMetrics(aiAgentID)) httpx.WriteJSON(ctx, services.AgentRunService.GetMetrics(aiAgentID))
} }
func AgentRunAnyComparison(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil {
httpx.WriteJSON(ctx, err)
return
}
aiAgentID, _ := params.GetInt64(ctx, "aiAgentId")
httpx.WriteJSON(ctx, services.AgentRunService.GetEngineComparisons(aiAgentID))
}
func AgentRunPostEvaluate(ctx *gin.Context) { func AgentRunPostEvaluate(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil {
httpx.WriteJSON(ctx, err) httpx.WriteJSON(ctx, err)
@@ -237,10 +237,6 @@ func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse {
} }
func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) response.AIAgentResponse { func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) response.AIAgentResponse {
runtimeMode := item.RuntimeMode
if runtimeMode == "" {
runtimeMode = enums.AIAgentRuntimeModeWorkflow
}
ret := response.AIAgentResponse{ ret := response.AIAgentResponse{
ID: item.ID, ID: item.ID,
Name: item.Name, Name: item.Name,
@@ -248,8 +244,6 @@ func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) respons
Status: item.Status, Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status), StatusName: enums.GetStatusLabel(item.Status),
AIConfigID: item.AIConfigID, AIConfigID: item.AIConfigID,
RuntimeMode: runtimeMode,
RuntimeModeName: enums.GetAIAgentRuntimeModeLabel(runtimeMode),
MaxSteps: item.MaxSteps, MaxSteps: item.MaxSteps,
ContextWindow: item.ContextWindow, ContextWindow: item.ContextWindow,
ToolPolicy: item.ToolPolicy, ToolPolicy: item.ToolPolicy,
@@ -272,11 +266,7 @@ func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) respons
Teams: make([]response.AIAgentTeamResponse, 0), Teams: make([]response.AIAgentTeamResponse, 0),
MCPTools: make([]response.AIAgentMCPToolResponse, 0), MCPTools: make([]response.AIAgentMCPToolResponse, 0),
WorkflowBindings: make([]response.AIAgentWorkflowBindingResponse, 0), WorkflowBindings: make([]response.AIAgentWorkflowBindingResponse, 0),
WorkflowVersionID: item.WorkflowVersionID,
PublishedRevisionID: item.PublishedRevisionID, PublishedRevisionID: item.PublishedRevisionID,
WorkflowPublished: item.WorkflowVersionID > 0,
WorkflowState: aiAgentWorkflowState(item.WorkflowVersionID),
WorkflowStateText: aiAgentWorkflowStateText(item.WorkflowVersionID),
SortNo: item.SortNo, SortNo: item.SortNo,
CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"), CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: item.UpdatedAt.Format("2006-01-02 15:04:05"), UpdatedAt: item.UpdatedAt.Format("2006-01-02 15:04:05"),
@@ -336,12 +326,14 @@ func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) respons
} }
} }
ret.MCPTools = append(ret.MCPTools, response.AIAgentMCPToolResponse{ ret.MCPTools = append(ret.MCPTools, response.AIAgentMCPToolResponse{
ToolCode: toolCode, ToolCode: toolCode,
ServerCode: serverCode, ServerCode: serverCode,
ToolName: toolName, ToolName: toolName,
Title: title, Title: title,
Description: description, Description: description,
Arguments: tool.Arguments, RiskLevel: tool.RiskLevel,
RequireConfirmation: tool.RequireConfirmation,
Arguments: tool.Arguments,
}) })
} }
} }
@@ -358,17 +350,3 @@ func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) respons
} }
return ret return ret
} }
func aiAgentWorkflowState(workflowVersionID int64) string {
if workflowVersionID > 0 {
return "published"
}
return "draft"
}
func aiAgentWorkflowStateText(workflowVersionID int64) string {
if workflowVersionID > 0 {
return "已发布"
}
return "未发布"
}
@@ -4,39 +4,18 @@ import (
"testing" "testing"
"agent-desk/internal/models" "agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls" "github.com/mlogclub/simple/sqls"
"gorm.io/gorm" "gorm.io/gorm"
) )
func TestBuildAIAgentResponseExposesWorkflowPublishState(t *testing.T) { func TestBuildAIAgentResponseExposesPublishedRevision(t *testing.T) {
setupAIAgentHandlerTestDB(t) setupAIAgentHandlerTestDB(t)
draft := buildAIAgentResponse(&models.AIAgent{}) published := buildAIAgentResponse(&models.AIAgent{PublishedRevisionID: 12})
if draft.RuntimeMode != enums.AIAgentRuntimeModeWorkflow { if published.PublishedRevisionID != 12 {
t.Fatalf("draft.RuntimeMode = %q, want %q", draft.RuntimeMode, enums.AIAgentRuntimeModeWorkflow) t.Fatalf("published revision = %d, want 12", published.PublishedRevisionID)
}
if draft.WorkflowPublished {
t.Fatalf("draft.WorkflowPublished = true, want false")
}
if draft.WorkflowState != "draft" {
t.Fatalf("draft.WorkflowState = %q, want draft", draft.WorkflowState)
}
if draft.WorkflowStateText == "" {
t.Fatalf("expected draft workflow state text")
}
published := buildAIAgentResponse(&models.AIAgent{WorkflowVersionID: 12})
if !published.WorkflowPublished {
t.Fatalf("published.WorkflowPublished = false, want true")
}
if published.WorkflowState != "published" {
t.Fatalf("published.WorkflowState = %q, want published", published.WorkflowState)
}
if published.WorkflowStateText == "" {
t.Fatalf("expected published workflow state text")
} }
rollout := buildAIAgentResponse(&models.AIAgent{RolloutPercent: 20, PreviousRolloutPercent: 100}) rollout := buildAIAgentResponse(&models.AIAgent{RolloutPercent: 20, PreviousRolloutPercent: 100})
@@ -158,7 +158,7 @@ func AIWorkflowGetTemplateList(ctx *gin.Context) {
httpx.WriteJSON(ctx, err) httpx.WriteJSON(ctx, err)
return return
} }
httpx.WriteJSON(ctx, builders.BuildAIWorkflowTemplates(services.AIWorkflowService.ListPlaybookTemplates())) httpx.WriteJSON(ctx, builders.BuildAIWorkflowTemplates(services.AIWorkflowService.ListWorkflowTemplates()))
} }
func AIWorkflowPostValidate(ctx *gin.Context) { func AIWorkflowPostValidate(ctx *gin.Context) {
+34 -58
View File
@@ -56,7 +56,6 @@ var Models = []any{
&KnowledgeRetrieveHit{}, &KnowledgeRetrieveHit{},
&KnowledgeFeedback{}, &KnowledgeFeedback{},
&SkillDefinition{}, &SkillDefinition{},
&SkillRunLog{},
&AgentRevision{}, &AgentRevision{},
&AgentRun{}, &AgentRun{},
&AgentStep{}, &AgentStep{},
@@ -530,51 +529,48 @@ type QuickReply struct {
// AIAgent AI 接待实例。 // AIAgent AI 接待实例。
type AIAgent struct { type AIAgent struct {
ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为 AI Agent 主键。 ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为 AI Agent 主键。
Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为 AI Agent 名称。 Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为 AI Agent 名称。
Description string `gorm:"type:varchar(255);not null;default:''"` // Description 为 AI Agent 描述。 Description string `gorm:"type:varchar(255);not null;default:''"` // Description 为 AI Agent 描述。
Status enums.Status `gorm:"type:int;not null;index"` // Status 为 AI Agent Status enums.Status `gorm:"type:int;not null;index"` // Status 为 AI Agent
AIConfigID int64 `gorm:"type:bigint;not null;default:0;index"` // AIConfigID 为关联的 AI 配置ID。 AIConfigID int64 `gorm:"type:bigint;not null;default:0;index"` // AIConfigID 为关联的 AI 配置ID。
RuntimeMode enums.AIAgentRuntimeMode `gorm:"type:varchar(30);not null;default:'workflow';index"` // RuntimeMode 为 Agent 的运行引擎模式 MaxSteps int `gorm:"type:int;not null;default:6"` // MaxSteps 为一次 Agent Loop 允许的最大推理步骤数
MaxSteps int `gorm:"type:int;not null;default:6"` // MaxSteps 为一次自主运行允许的最大推理步骤数 ContextWindow int `gorm:"type:int;not null;default:0"` // ContextWindow 为会话上下文消息窗口,0 表示使用运行时默认值
ContextWindow int `gorm:"type:int;not null;default:0"` // ContextWindow 为会话上下文消息窗口,0 表示使用运行时默认值 ToolPolicy string `gorm:"type:text"` // ToolPolicy 为工具风险与确认策略JSON
ToolPolicy string `gorm:"type:text"` // ToolPolicy 为工具风险与确认策略JSON。 KnowledgePolicy string `gorm:"type:text"` // KnowledgePolicy 为知识检索与无依据回答策略JSON。
KnowledgePolicy string `gorm:"type:text"` // KnowledgePolicy 为知识检索与无依据回答策略JSON ServiceMode enums.IMConversationServiceMode `gorm:"type:int;not null;default:3;index"` // ServiceMode 为服务模式,如仅AI、仅人工、AI优先人工接管
ServiceMode enums.IMConversationServiceMode `gorm:"type:int;not null;default:3;index"` // ServiceMode 为服务模式,如仅AI、仅人工、AI优先人工接管 SystemPrompt string `gorm:"type:text"` // SystemPrompt 为该 Agent 的系统提示词
SystemPrompt string `gorm:"type:text"` // SystemPrompt 为该 Agent 的系统提示词 WelcomeMessage string `gorm:"type:text"` // WelcomeMessage 为该 Agent 的欢迎语或首响模板
WelcomeMessage string `gorm:"type:text"` // WelcomeMessage 为该 Agent 的欢迎语或首响模板 ReplyTimeoutSeconds int `gorm:"type:int;not null;default:180"` // ReplyTimeoutSeconds 为异步自动回复超时秒数
ReplyTimeoutSeconds int `gorm:"type:int;not null;default:180"` // ReplyTimeoutSeconds 为异步自动回复超时秒数 RolloutPercent int `gorm:"type:int;not null;default:100"` // RolloutPercent 为该 Agent 的会话灰度百分比,100 表示全量
RolloutPercent int `gorm:"type:int;not null;default:100"` // RolloutPercent 为该 Agent 的会话灰度百分比,100 表示全量 PreviousRolloutPercent int `gorm:"type:int;not null;default:0"` // PreviousRolloutPercent 保存上一次生效的灰度比例,0 表示尚无可回滚值
PreviousRolloutPercent int `gorm:"type:int;not null;default:0"` // PreviousRolloutPercent 保存上一次生效的灰度比例,0 表示尚无可回滚值 TeamIDs string `gorm:"type:varchar(500);not null;default:''"` // TeamIDs 为转人工时可路由的客服组ID列表,多个之间使用逗号分隔
TeamIDs string `gorm:"type:varchar(500);not null;default:''"` // TeamIDs 为转人工时可路由的客服组ID列表,多个之间使用逗号分隔 HandoffMode enums.AIAgentHandoffMode `gorm:"type:int;not null;default:1"` // HandoffMode 为转人工执行方式,如进入待接入池、进入默认客服组待接入池
HandoffMode enums.AIAgentHandoffMode `gorm:"type:int;not null;default:1"` // HandoffMode 为转人工执行方式,如进入待接入池、进入默认客服组待接入池 FallbackMode enums.AIAgentFallbackMode `gorm:"type:int;not null;default:1"` // FallbackMode 为知识不足时的回复策略
FallbackMode enums.AIAgentFallbackMode `gorm:"type:int;not null;default:1"` // FallbackMode 为知识不足时的回复策略 FallbackMessage string `gorm:"type:text"` // FallbackMessage 为知识不足回复文案
FallbackMessage string `gorm:"type:text"` // FallbackMessage 为知识不足回复文案 KnowledgeIDs string `gorm:"type:varchar(500);not null;default:''"` // KnowledgeIDs 为绑定的知识库ID列表,按顺序表示优先级
KnowledgeIDs string `gorm:"type:varchar(500);not null;default:''"` // KnowledgeIDs 为绑定的知识库ID列表,按顺序表示优先级 SkillIDs string `gorm:"type:varchar(500);not null;default:''"` // SkillIDs 为绑定的技能ID列表,按顺序表示允许路由的范围
SkillIDs string `gorm:"type:varchar(500);not null;default:''"` // SkillIDs 为绑定的技能ID列表,按顺序表示允许路由的范围 AllowedMCPTools string `gorm:"type:text"` // AllowedMCPTools 为 Agent 允许调用的 MCP 工具白名单配置 JSON
AllowedMCPTools string `gorm:"type:text"` // AllowedMCPTools 为 Agent 允许调用的 MCP 工具白名单配置 JSON PublishedRevisionID int64 `gorm:"type:bigint;not null;default:0;index"` // PublishedRevisionID 为当前已发布 Agent 配置快照ID
WorkflowVersionID int64 `gorm:"type:bigint;not null;default:0;index"` // WorkflowVersionID is retained as the single-workflow runtime pointer. SortNo int `gorm:"type:int;not null;default:0;index"` // SortNo 为后台展示排序号。
PublishedRevisionID int64 `gorm:"type:bigint;not null;default:0;index"` // PublishedRevisionID 为当前已发布 Agent 配置快照ID。
SortNo int `gorm:"type:int;not null;default:0;index"` // SortNo 为后台展示排序号。
AuditFields AuditFields
} }
// AgentRevision stores an immutable published Agent configuration snapshot. // AgentRevision stores an immutable published Agent configuration snapshot.
type AgentRevision struct { type AgentRevision struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ID int64 `gorm:"primaryKey;autoIncrement"`
AgentID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_agent_revision"` AgentID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_agent_revision"`
Revision int `gorm:"type:int;not null;uniqueIndex:uk_agent_revision"` Revision int `gorm:"type:int;not null;uniqueIndex:uk_agent_revision"`
WorkflowVersionID int64 `gorm:"type:bigint;not null;default:0;index"` Status enums.Status `gorm:"type:int;not null;default:0;index"`
Status enums.Status `gorm:"type:int;not null;default:0;index"` Definition string `gorm:"type:longtext"`
Definition string `gorm:"type:longtext"` DefinitionHash string `gorm:"type:varchar(64);not null;default:'';index"`
DefinitionHash string `gorm:"type:varchar(64);not null;default:'';index"` PublishedAt *time.Time `gorm:"type:datetime;index"`
PublishedAt *time.Time `gorm:"type:datetime;index"` PublishedByID int64 `gorm:"type:bigint;not null;default:0;index"`
PublishedByID int64 `gorm:"type:bigint;not null;default:0;index"` PublishedByName string `gorm:"type:varchar(100);not null;default:''"`
PublishedByName string `gorm:"type:varchar(100);not null;default:''"`
AuditFields AuditFields
} }
// AgentRun is an Engine-independent record for one Agent reply execution. // AgentRun is the parent audit record for one Agent Loop execution.
type AgentRun struct { type AgentRun struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ID int64 `gorm:"primaryKey;autoIncrement"`
ConversationID int64 `gorm:"type:bigint;not null;default:0;index"` ConversationID int64 `gorm:"type:bigint;not null;default:0;index"`
@@ -582,7 +578,6 @@ type AgentRun struct {
AgentRevisionID int64 `gorm:"type:bigint;not null;default:0;index"` AgentRevisionID int64 `gorm:"type:bigint;not null;default:0;index"`
SourceMessageID int64 `gorm:"type:bigint;not null;default:0;index"` SourceMessageID int64 `gorm:"type:bigint;not null;default:0;index"`
WorkflowRunID int64 `gorm:"type:bigint;not null;default:0;index"` WorkflowRunID int64 `gorm:"type:bigint;not null;default:0;index"`
EngineCode string `gorm:"type:varchar(50);not null;default:'';index"`
Status string `gorm:"type:varchar(30);not null;default:'';index"` Status string `gorm:"type:varchar(30);not null;default:'';index"`
PromptTokens int `gorm:"type:int;not null;default:0"` PromptTokens int `gorm:"type:int;not null;default:0"`
CompletionTokens int `gorm:"type:int;not null;default:0"` CompletionTokens int `gorm:"type:int;not null;default:0"`
@@ -1015,25 +1010,6 @@ type SkillDefinition struct {
AuditFields AuditFields
} }
// SkillRunLog 表示一次 Skill 运行过程的审计日志。
type SkillRunLog struct {
ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为 Skill 运行日志主键。
ConversationID int64 `gorm:"type:bigint;not null;default:0;index"` // ConversationID 为关联会话ID,无会话上下文时为0。
AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"` // AIAgentID 为本次运行所属的 AI Agent ID。
AIConfigID int64 `gorm:"type:bigint;not null;default:0;index"` // AIConfigID 为本次运行实际使用的 AI 配置ID。
SkillDefinitionID int64 `gorm:"type:bigint;not null;default:0;index"` // SkillDefinitionID 为最终命中的 Skill 定义ID,未命中时为0。
ManualSkillID int64 `gorm:"type:bigint;not null;default:0;index"` // ManualSkillID 为本次请求显式指定的 Skill 定义ID。
UserMessage string `gorm:"type:longtext"` // UserMessage 为本次请求的用户输入内容。
Matched bool `gorm:"not null;default:false;index"` // Matched 表示本次请求是否命中了 Skill。
MatchReason string `gorm:"type:varchar(500);not null;default:''"` // MatchReason 为命中或未命中的原因说明。
FinalSelected bool `gorm:"not null;default:false;index"` // FinalSelected 表示该日志记录的 Skill 是否为最终选中的执行 Skill。
UsedModel string `gorm:"type:varchar(100);not null;default:''"` // UsedModel 为本次实际调用的模型名称。
UsedProvider enums.AIProvider `gorm:"type:varchar(50);not null;default:''"` // UsedProvider 为本次实际调用的模型供应商。
ErrorMessage string `gorm:"type:text"` // ErrorMessage 为运行过程中的错误信息。
TraceData string `gorm:"type:text"` // TraceData 为 Skill 执行链路追踪数据JSON。
CreatedAt time.Time `gorm:"type:datetime;not null;index"` // CreatedAt 为运行日志创建时间。
}
// ConversationInterrupt 表示会话级待恢复中断记录。 // ConversationInterrupt 表示会话级待恢复中断记录。
type ConversationInterrupt struct { type ConversationInterrupt struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ID int64 `gorm:"primaryKey;autoIncrement"`
@@ -1,9 +1,8 @@
package request package request
type RunAgentEvaluationRequest struct { type RunAgentEvaluationRequest struct {
AIAgentID int64 `json:"aiAgentId"` AIAgentID int64 `json:"aiAgentId"`
EngineCode string `json:"engineCode"` Cases []AgentEvaluationCase `json:"cases"`
Cases []AgentEvaluationCase `json:"cases"`
} }
type AgentEvaluationCase struct { type AgentEvaluationCase struct {
+8 -7
View File
@@ -3,12 +3,14 @@ package request
import "agent-desk/internal/pkg/enums" import "agent-desk/internal/pkg/enums"
type AIAgentMCPToolRequest struct { type AIAgentMCPToolRequest struct {
ToolCode string `json:"toolCode"` ToolCode string `json:"toolCode"`
ServerCode string `json:"serverCode"` ServerCode string `json:"serverCode"`
ToolName string `json:"toolName"` ToolName string `json:"toolName"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Arguments map[string]string `json:"arguments"` RiskLevel string `json:"riskLevel"`
RequireConfirmation bool `json:"requireConfirmation"`
Arguments map[string]string `json:"arguments"`
} }
type AIAgentWorkflowBindingRequest struct { type AIAgentWorkflowBindingRequest struct {
@@ -54,7 +56,6 @@ type CreateAIAgentRequest struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
AIConfigID int64 `json:"aiConfigId"` AIConfigID int64 `json:"aiConfigId"`
RuntimeMode enums.AIAgentRuntimeMode `json:"runtimeMode"`
MaxSteps int `json:"maxSteps"` MaxSteps int `json:"maxSteps"`
ContextWindow int `json:"contextWindow"` ContextWindow int `json:"contextWindow"`
ToolPolicy string `json:"toolPolicy"` ToolPolicy string `json:"toolPolicy"`
@@ -3,7 +3,6 @@ package response
type AgentEvaluationResultResponse struct { type AgentEvaluationResultResponse struct {
CaseID string `json:"caseId"` CaseID string `json:"caseId"`
Category string `json:"category"` Category string `json:"category"`
EngineCode string `json:"engineCode"`
Passed bool `json:"passed"` Passed bool `json:"passed"`
ReplyText string `json:"replyText"` ReplyText string `json:"replyText"`
Interrupted bool `json:"interrupted"` Interrupted bool `json:"interrupted"`
@@ -12,9 +11,8 @@ type AgentEvaluationResultResponse struct {
} }
type AgentEvaluationReportResponse struct { type AgentEvaluationReportResponse struct {
EngineCode string `json:"engineCode"` Total int `json:"total"`
Total int `json:"total"` Passed int `json:"passed"`
Passed int `json:"passed"` Results []AgentEvaluationResultResponse `json:"results"`
Results []AgentEvaluationResultResponse `json:"results"` CSV string `json:"csv"`
CSV string `json:"csv"`
} }
@@ -9,7 +9,6 @@ type AgentRunResponse struct {
AgentRevisionID int64 `json:"agentRevisionId"` AgentRevisionID int64 `json:"agentRevisionId"`
SourceMessageID int64 `json:"sourceMessageId"` SourceMessageID int64 `json:"sourceMessageId"`
WorkflowRunID int64 `json:"workflowRunId"` WorkflowRunID int64 `json:"workflowRunId"`
EngineCode string `json:"engineCode"`
Status string `json:"status"` Status string `json:"status"`
PromptTokens int `json:"promptTokens"` PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"` CompletionTokens int `json:"completionTokens"`
+16 -21
View File
@@ -16,12 +16,14 @@ type AIAgentSkillResponse struct {
} }
type AIAgentMCPToolResponse struct { type AIAgentMCPToolResponse struct {
ToolCode string `json:"toolCode"` ToolCode string `json:"toolCode"`
ServerCode string `json:"serverCode"` ServerCode string `json:"serverCode"`
ToolName string `json:"toolName"` ToolName string `json:"toolName"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Arguments map[string]string `json:"arguments"` RiskLevel string `json:"riskLevel"`
RequireConfirmation bool `json:"requireConfirmation"`
Arguments map[string]string `json:"arguments"`
} }
type AIAgentWorkflowBindingResponse struct { type AIAgentWorkflowBindingResponse struct {
@@ -37,15 +39,14 @@ type AIAgentWorkflowBindingResponse struct {
} }
type AgentRevisionResponse struct { type AgentRevisionResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
AgentID int64 `json:"agentId"` AgentID int64 `json:"agentId"`
Revision int `json:"revision"` Revision int `json:"revision"`
WorkflowVersionID int64 `json:"workflowVersionId"` Status enums.Status `json:"status"`
Status enums.Status `json:"status"` DefinitionHash string `json:"definitionHash"`
DefinitionHash string `json:"definitionHash"` PublishedAt string `json:"publishedAt"`
PublishedAt string `json:"publishedAt"` PublishedByID int64 `json:"publishedById"`
PublishedByID int64 `json:"publishedById"` PublishedByName string `json:"publishedByName"`
PublishedByName string `json:"publishedByName"`
} }
type AIConfigResponse struct { type AIConfigResponse struct {
@@ -98,8 +99,6 @@ type AIAgentResponse struct {
StatusName string `json:"statusName"` StatusName string `json:"statusName"`
AIConfigID int64 `json:"aiConfigId"` AIConfigID int64 `json:"aiConfigId"`
AIConfigName string `json:"aiConfigName"` AIConfigName string `json:"aiConfigName"`
RuntimeMode enums.AIAgentRuntimeMode `json:"runtimeMode"`
RuntimeModeName string `json:"runtimeModeName"`
MaxSteps int `json:"maxSteps"` MaxSteps int `json:"maxSteps"`
ContextWindow int `json:"contextWindow"` ContextWindow int `json:"contextWindow"`
ToolPolicy string `json:"toolPolicy"` ToolPolicy string `json:"toolPolicy"`
@@ -122,11 +121,7 @@ type AIAgentResponse struct {
Skills []AIAgentSkillResponse `json:"skills"` Skills []AIAgentSkillResponse `json:"skills"`
MCPTools []AIAgentMCPToolResponse `json:"mcpTools"` MCPTools []AIAgentMCPToolResponse `json:"mcpTools"`
WorkflowBindings []AIAgentWorkflowBindingResponse `json:"workflowBindings"` WorkflowBindings []AIAgentWorkflowBindingResponse `json:"workflowBindings"`
WorkflowVersionID int64 `json:"workflowVersionId"`
PublishedRevisionID int64 `json:"publishedRevisionId"` PublishedRevisionID int64 `json:"publishedRevisionId"`
WorkflowPublished bool `json:"workflowPublished"`
WorkflowState string `json:"workflowState"`
WorkflowStateText string `json:"workflowStateText"`
SortNo int `json:"sortNo"` SortNo int `json:"sortNo"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
@@ -63,7 +63,7 @@ type DashboardAIStatsResponse struct {
TodayKnowledgeRetrieves int64 `json:"todayKnowledgeRetrieves"` TodayKnowledgeRetrieves int64 `json:"todayKnowledgeRetrieves"`
TodayKnowledgeRetrieveFailCount int64 `json:"todayKnowledgeRetrieveFailCount"` TodayKnowledgeRetrieveFailCount int64 `json:"todayKnowledgeRetrieveFailCount"`
TodayKnowledgeRetrieveFailRate float64 `json:"todayKnowledgeRetrieveFailRate"` TodayKnowledgeRetrieveFailRate float64 `json:"todayKnowledgeRetrieveFailRate"`
TodaySkillRunFailCount int64 `json:"todaySkillRunFailCount"` TodayAgentRunFailCount int64 `json:"todayAgentRunFailCount"`
TodayAIHandoffCount int64 `json:"todayAiHandoffCount"` TodayAIHandoffCount int64 `json:"todayAiHandoffCount"`
} }
@@ -22,14 +22,8 @@ type SkillDebugRunResponse struct {
SkillDefinitionID int64 `json:"skillDefinitionId"` SkillDefinitionID int64 `json:"skillDefinitionId"`
SkillName string `json:"skillName"` SkillName string `json:"skillName"`
ReplyText string `json:"replyText"` ReplyText string `json:"replyText"`
PlanReason string `json:"planReason"`
SkillRouteTrace string `json:"skillRouteTrace"`
ToolWhitelist []string `json:"toolWhitelist"` ToolWhitelist []string `json:"toolWhitelist"`
ExposedToolCodes []string `json:"exposedToolCodes"`
InvokedToolCodes []string `json:"invokedToolCodes"` InvokedToolCodes []string `json:"invokedToolCodes"`
ToolSearchTrace string `json:"toolSearchTrace"`
GraphToolTrace string `json:"graphToolTrace"`
GraphToolCode string `json:"graphToolCode"`
InterruptType string `json:"interruptType"` InterruptType string `json:"interruptType"`
CheckPointID string `json:"checkPointId"` CheckPointID string `json:"checkPointId"`
Interrupted bool `json:"interrupted"` Interrupted bool `json:"interrupted"`
-33
View File
@@ -239,39 +239,6 @@ func GetAIAgentFallbackModeLabel(mode AIAgentFallbackMode) string {
return aiAgentFallbackModeLabelMap[mode] return aiAgentFallbackModeLabelMap[mode]
} }
type AIAgentRuntimeMode string
const (
AIAgentRuntimeModeWorkflow AIAgentRuntimeMode = "workflow"
AIAgentRuntimeModeAutonomous AIAgentRuntimeMode = "autonomous"
AIAgentRuntimeModeHybrid AIAgentRuntimeMode = "hybrid"
)
var AIAgentRuntimeModeValues = []AIAgentRuntimeMode{
AIAgentRuntimeModeWorkflow,
AIAgentRuntimeModeAutonomous,
AIAgentRuntimeModeHybrid,
}
var aiAgentRuntimeModeLabelMap = map[AIAgentRuntimeMode]string{
AIAgentRuntimeModeWorkflow: "流程编排",
AIAgentRuntimeModeAutonomous: "自主运行",
AIAgentRuntimeModeHybrid: "混合运行",
}
func GetAIAgentRuntimeModeLabel(mode AIAgentRuntimeMode) string {
return aiAgentRuntimeModeLabelMap[mode]
}
func IsValidAIAgentRuntimeMode(mode AIAgentRuntimeMode) bool {
for _, item := range AIAgentRuntimeModeValues {
if item == mode {
return true
}
}
return false
}
const ( const (
IMRealtimeEventConnected = "connected" IMRealtimeEventConnected = "connected"
IMRealtimeEventPong = "pong" IMRealtimeEventPong = "pong"
+7 -5
View File
@@ -63,11 +63,13 @@ func NormalizeMCPToolRequest(item request.AIAgentMCPToolRequest) (request.AIAgen
return request.AIAgentMCPToolRequest{}, errorsx.InvalidParamI18n("error.e0018") return request.AIAgentMCPToolRequest{}, errorsx.InvalidParamI18n("error.e0018")
} }
ret := request.AIAgentMCPToolRequest{ ret := request.AIAgentMCPToolRequest{
ToolCode: toolCode, ToolCode: toolCode,
ServerCode: serverCode, ServerCode: serverCode,
ToolName: toolName, ToolName: toolName,
Title: strings.TrimSpace(item.Title), Title: strings.TrimSpace(item.Title),
Description: strings.TrimSpace(item.Description), Description: strings.TrimSpace(item.Description),
RiskLevel: strings.ToLower(strings.TrimSpace(item.RiskLevel)),
RequireConfirmation: item.RequireConfirmation,
} }
if len(item.Arguments) > 0 { if len(item.Arguments) > 0 {
ret.Arguments = make(map[string]string, len(item.Arguments)) ret.Arguments = make(map[string]string, len(item.Arguments))
@@ -43,14 +43,3 @@ func (r *agentRevisionRepository) MaxRevisionByAgentID(db *gorm.DB, agentID int6
db.Model(&models.AgentRevision{}).Where("agent_id = ?", agentID).Select("COALESCE(MAX(revision), 0)").Scan(&ret) db.Model(&models.AgentRevision{}).Where("agent_id = ?", agentID).Select("COALESCE(MAX(revision), 0)").Scan(&ret)
return ret return ret
} }
func (r *agentRevisionRepository) TakeByAgentIDAndWorkflowVersionID(db *gorm.DB, agentID int64, workflowVersionID int64) *models.AgentRevision {
if agentID <= 0 || workflowVersionID <= 0 {
return nil
}
ret := &models.AgentRevision{}
if err := db.Where("agent_id = ? AND workflow_version_id = ?", agentID, workflowVersionID).Order("id DESC").First(ret).Error; err != nil {
return nil
}
return ret
}
@@ -97,9 +97,9 @@ func (r *dashboardRepository) CountKnowledgeRetrieveLogs(db *gorm.DB, query func
return count return count
} }
func (r *dashboardRepository) CountSkillRunLogs(db *gorm.DB, query func(tx *gorm.DB) *gorm.DB) int64 { func (r *dashboardRepository) CountAgentRuns(db *gorm.DB, query func(tx *gorm.DB) *gorm.DB) int64 {
var count int64 var count int64
tx := db.Model(&models.SkillRunLog{}) tx := db.Model(&models.AgentRun{})
if query != nil { if query != nil {
tx = query(tx) tx = query(tx)
} }
@@ -1,102 +0,0 @@
package repositories
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/httpx/params"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
var SkillRunLogRepository = newSkillRunLogRepository()
func newSkillRunLogRepository() *skillRunLogRepository {
return &skillRunLogRepository{}
}
type skillRunLogRepository struct {
}
func (r *skillRunLogRepository) Get(db *gorm.DB, id int64) *models.SkillRunLog {
ret := &models.SkillRunLog{}
if err := db.First(ret, "id = ?", id).Error; err != nil {
return nil
}
return ret
}
func (r *skillRunLogRepository) Take(db *gorm.DB, where ...interface{}) *models.SkillRunLog {
ret := &models.SkillRunLog{}
if err := db.Take(ret, where...).Error; err != nil {
return nil
}
return ret
}
func (r *skillRunLogRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.SkillRunLog) {
cnd.Find(db, &list)
return
}
func (r *skillRunLogRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.SkillRunLog {
ret := &models.SkillRunLog{}
if err := cnd.FindOne(db, &ret); err != nil {
return nil
}
return ret
}
func (r *skillRunLogRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.SkillRunLog, paging *sqls.Paging) {
return r.FindPageByCnd(db, &params.Cnd)
}
func (r *skillRunLogRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.SkillRunLog, paging *sqls.Paging) {
cnd.Find(db, &list)
count := cnd.Count(db, &models.SkillRunLog{})
paging = &sqls.Paging{
Page: cnd.Paging.Page,
Limit: cnd.Paging.Limit,
Total: count,
}
return
}
func (r *skillRunLogRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (list []models.SkillRunLog) {
db.Raw(sqlStr, paramArr...).Scan(&list)
return
}
func (r *skillRunLogRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (count int64) {
db.Raw(sqlStr, paramArr...).Count(&count)
return
}
func (r *skillRunLogRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
return cnd.Count(db, &models.SkillRunLog{})
}
func (r *skillRunLogRepository) Create(db *gorm.DB, t *models.SkillRunLog) (err error) {
err = db.Create(t).Error
return
}
func (r *skillRunLogRepository) Update(db *gorm.DB, t *models.SkillRunLog) (err error) {
err = db.Save(t).Error
return
}
func (r *skillRunLogRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) (err error) {
err = db.Model(&models.SkillRunLog{}).Where("id = ?", id).Updates(columns).Error
return
}
func (r *skillRunLogRepository) UpdateColumn(db *gorm.DB, id int64, name string, value interface{}) (err error) {
err = db.Model(&models.SkillRunLog{}).Where("id = ?", id).UpdateColumn(name, value).Error
return
}
func (r *skillRunLogRepository) Delete(db *gorm.DB, id int64) {
db.Delete(&models.SkillRunLog{}, "id = ?", id)
}
@@ -3,7 +3,6 @@ package services
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/dto/response"
@@ -21,9 +20,6 @@ func (s *agentEvaluationService) Run(ctx context.Context, req request.RunAgentEv
if req.AIAgentID <= 0 { if req.AIAgentID <= 0 {
return nil, errorsx.InvalidParam("ai agent id is required") return nil, errorsx.InvalidParam("ai agent id is required")
} }
if strings.TrimSpace(req.EngineCode) == "" {
return nil, errorsx.InvalidParam("engine code is required")
}
if len(req.Cases) == 0 { if len(req.Cases) == 0 {
return nil, errorsx.InvalidParam("evaluation cases are required") return nil, errorsx.InvalidParam("evaluation cases are required")
} }
@@ -14,9 +14,9 @@ func TestAgentEvaluationServiceValidatesAndCallsRunner(t *testing.T) {
called := false called := false
AgentEvaluationRunHook = func(_ context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) { AgentEvaluationRunHook = func(_ context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) {
called = true called = true
return &response.AgentEvaluationReportResponse{EngineCode: req.EngineCode, Total: len(req.Cases)}, nil return &response.AgentEvaluationReportResponse{Total: len(req.Cases)}, nil
} }
result, err := AgentEvaluationService.Run(context.Background(), request.RunAgentEvaluationRequest{AIAgentID: 1, EngineCode: "autonomous", Cases: []request.AgentEvaluationCase{{ID: "faq", Message: "hello"}}}) result, err := AgentEvaluationService.Run(context.Background(), request.RunAgentEvaluationRequest{AIAgentID: 1, Cases: []request.AgentEvaluationCase{{ID: "faq", Message: "hello"}}})
if err != nil || !called || result.Total != 1 { if err != nil || !called || result.Total != 1 {
t.Fatalf("result=%#v called=%t err=%v", result, called, err) t.Fatalf("result=%#v called=%t err=%v", result, called, err)
} }
+11 -15
View File
@@ -40,10 +40,10 @@ func (s *agentRevisionService) FindByAgentID(agentID int64) []models.AgentRevisi
type agentRevisionDefinition struct { type agentRevisionDefinition struct {
Agent agentRevisionAgent `json:"agent"` Agent agentRevisionAgent `json:"agent"`
Model agentRevisionModel `json:"model"` Model agentRevisionModel `json:"model"`
WorkflowBindings []agentRevisionWorkflowBinding `json:"workflowBindings"` WorkflowBindings []AgentRevisionWorkflowBinding `json:"workflowBindings"`
} }
type agentRevisionWorkflowBinding struct { type AgentRevisionWorkflowBinding struct {
WorkflowID int64 `json:"workflowId"` WorkflowID int64 `json:"workflowId"`
WorkflowVersionID int64 `json:"workflowVersionId"` WorkflowVersionID int64 `json:"workflowVersionId"`
ToolName string `json:"toolName"` ToolName string `json:"toolName"`
@@ -69,7 +69,6 @@ type agentRevisionAgent struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
AIConfigID int64 `json:"aiConfigId"` AIConfigID int64 `json:"aiConfigId"`
RuntimeMode string `json:"runtimeMode"`
MaxSteps int `json:"maxSteps"` MaxSteps int `json:"maxSteps"`
ContextWindow int `json:"contextWindow"` ContextWindow int `json:"contextWindow"`
ToolPolicy string `json:"toolPolicy"` ToolPolicy string `json:"toolPolicy"`
@@ -94,33 +93,31 @@ type AgentRevisionSnapshot struct {
Revision models.AgentRevision Revision models.AgentRevision
Agent models.AIAgent Agent models.AIAgent
AIConfig models.AIConfig AIConfig models.AIConfig
WorkflowBindings []agentRevisionWorkflowBinding WorkflowBindings []AgentRevisionWorkflowBinding
} }
// ResolvePublishedSnapshot restores a published Agent revision for runtime // ResolvePublishedSnapshot restores an immutable published Agent revision.
// execution. Empty legacy definitions retain the current fields so historical
// records created before snapshot hydration remain executable.
func (s *agentRevisionService) ResolvePublishedSnapshot(agent models.AIAgent, config models.AIConfig) (*AgentRevisionSnapshot, error) { func (s *agentRevisionService) ResolvePublishedSnapshot(agent models.AIAgent, config models.AIConfig) (*AgentRevisionSnapshot, error) {
if agent.PublishedRevisionID <= 0 { if agent.PublishedRevisionID <= 0 {
return nil, errorsx.InvalidParam("autonomous agent is not published") return nil, errorsx.InvalidParam("Agent is not published")
} }
revision := repositories.AgentRevisionRepository.Get(sqls.DB(), agent.PublishedRevisionID) revision := repositories.AgentRevisionRepository.Get(sqls.DB(), agent.PublishedRevisionID)
if revision == nil || revision.AgentID != agent.ID || revision.Status != enums.StatusOk { if revision == nil || revision.AgentID != agent.ID || revision.Status != enums.StatusOk {
return nil, errorsx.InvalidParam("autonomous agent published revision does not exist") return nil, errorsx.InvalidParam("published Agent revision does not exist")
} }
snapshot := &AgentRevisionSnapshot{Revision: *revision, Agent: agent, AIConfig: config} snapshot := &AgentRevisionSnapshot{Revision: *revision, Agent: agent, AIConfig: config}
if strings.TrimSpace(revision.Definition) == "" { if strings.TrimSpace(revision.Definition) == "" {
return snapshot, nil return nil, errorsx.InvalidParam("published Agent revision definition is empty")
} }
definition := agentRevisionDefinition{} definition := agentRevisionDefinition{}
if err := json.Unmarshal([]byte(revision.Definition), &definition); err != nil { if err := json.Unmarshal([]byte(revision.Definition), &definition); err != nil {
return nil, errorsx.InvalidParam("autonomous agent published revision is invalid") return nil, errorsx.InvalidParam("published Agent revision is invalid")
} }
if definition.Agent.AIConfigID > 0 && definition.Agent.AIConfigID != config.ID { if definition.Agent.AIConfigID > 0 && definition.Agent.AIConfigID != config.ID {
return nil, errorsx.InvalidParam("published agent model config no longer matches") return nil, errorsx.InvalidParam("published agent model config no longer matches")
} }
applyRevisionAgentSnapshot(&snapshot.Agent, definition.Agent) applyRevisionAgentSnapshot(&snapshot.Agent, definition.Agent)
snapshot.WorkflowBindings = append([]agentRevisionWorkflowBinding(nil), definition.WorkflowBindings...) snapshot.WorkflowBindings = append([]AgentRevisionWorkflowBinding(nil), definition.WorkflowBindings...)
applyRevisionModelSnapshot(&snapshot.AIConfig, definition.Model) applyRevisionModelSnapshot(&snapshot.AIConfig, definition.Model)
return snapshot, nil return snapshot, nil
} }
@@ -132,7 +129,6 @@ func applyRevisionAgentSnapshot(agent *models.AIAgent, definition agentRevisionA
agent.Name = definition.Name agent.Name = definition.Name
agent.Description = definition.Description agent.Description = definition.Description
agent.AIConfigID = definition.AIConfigID agent.AIConfigID = definition.AIConfigID
agent.RuntimeMode = enums.AIAgentRuntimeMode(definition.RuntimeMode)
agent.MaxSteps = definition.MaxSteps agent.MaxSteps = definition.MaxSteps
agent.ContextWindow = definition.ContextWindow agent.ContextWindow = definition.ContextWindow
agent.ToolPolicy = definition.ToolPolicy agent.ToolPolicy = definition.ToolPolicy
@@ -180,7 +176,7 @@ func (s *agentRevisionService) publishSnapshot(db *gorm.DB, agent *models.AIAgen
definition := agentRevisionDefinition{ definition := agentRevisionDefinition{
Agent: agentRevisionAgent{ Agent: agentRevisionAgent{
Name: agent.Name, Description: agent.Description, AIConfigID: agent.AIConfigID, Name: agent.Name, Description: agent.Description, AIConfigID: agent.AIConfigID,
RuntimeMode: string(agent.RuntimeMode), MaxSteps: agent.MaxSteps, ContextWindow: agent.ContextWindow, MaxSteps: agent.MaxSteps, ContextWindow: agent.ContextWindow,
ToolPolicy: agent.ToolPolicy, KnowledgePolicy: agent.KnowledgePolicy, ServiceMode: int(agent.ServiceMode), SystemPrompt: agent.SystemPrompt, ToolPolicy: agent.ToolPolicy, KnowledgePolicy: agent.KnowledgePolicy, ServiceMode: int(agent.ServiceMode), SystemPrompt: agent.SystemPrompt,
WelcomeMessage: agent.WelcomeMessage, ReplyTimeoutSeconds: agent.ReplyTimeoutSeconds, TeamIDs: agent.TeamIDs, HandoffMode: int(agent.HandoffMode), WelcomeMessage: agent.WelcomeMessage, ReplyTimeoutSeconds: agent.ReplyTimeoutSeconds, TeamIDs: agent.TeamIDs, HandoffMode: int(agent.HandoffMode),
FallbackMode: int(agent.FallbackMode), FallbackMessage: agent.FallbackMessage, KnowledgeIDs: agent.KnowledgeIDs, FallbackMode: int(agent.FallbackMode), FallbackMessage: agent.FallbackMessage, KnowledgeIDs: agent.KnowledgeIDs,
@@ -189,7 +185,7 @@ func (s *agentRevisionService) publishSnapshot(db *gorm.DB, agent *models.AIAgen
Model: model, Model: model,
} }
for _, binding := range repositories.AIAgentWorkflowBindingRepository.FindEnabledByAgentID(db, agent.ID) { for _, binding := range repositories.AIAgentWorkflowBindingRepository.FindEnabledByAgentID(db, agent.ID) {
definition.WorkflowBindings = append(definition.WorkflowBindings, agentRevisionWorkflowBinding{WorkflowID: binding.WorkflowID, WorkflowVersionID: binding.WorkflowVersionID, ToolName: binding.ToolName, TriggerInstruction: binding.TriggerInstruction, Priority: binding.Priority}) definition.WorkflowBindings = append(definition.WorkflowBindings, AgentRevisionWorkflowBinding{WorkflowID: binding.WorkflowID, WorkflowVersionID: binding.WorkflowVersionID, ToolName: binding.ToolName, TriggerInstruction: binding.TriggerInstruction, Priority: binding.Priority})
} }
data, err := json.Marshal(definition) data, err := json.Marshal(definition)
if err != nil { if err != nil {
@@ -24,7 +24,7 @@ func TestAgentRevisionServiceRestoresPublishedSnapshotAndKeepsAPIKey(t *testing.
sqls.SetDB(db) sqls.SetDB(db)
definition := agentRevisionDefinition{ definition := agentRevisionDefinition{
Agent: agentRevisionAgent{ Agent: agentRevisionAgent{
Name: "published agent", AIConfigID: 8, RuntimeMode: string(enums.AIAgentRuntimeModeAutonomous), Name: "published agent", AIConfigID: 8,
MaxSteps: 5, ContextWindow: 9, SystemPrompt: "published instruction", KnowledgeIDs: "4", ReplyTimeoutSeconds: 90, MaxSteps: 5, ContextWindow: 9, SystemPrompt: "published instruction", KnowledgeIDs: "4", ReplyTimeoutSeconds: 90,
}, },
Model: agentRevisionModel{ConfigID: 8, Provider: string(enums.AIProviderOpenAI), BaseURL: "https://published.example/v1", ModelType: string(enums.AIModelTypeLLM), ModelName: "published-model", TimeoutMS: 12000}, Model: agentRevisionModel{ConfigID: 8, Provider: string(enums.AIProviderOpenAI), BaseURL: "https://published.example/v1", ModelType: string(enums.AIModelTypeLLM), ModelName: "published-model", TimeoutMS: 12000},
+32 -157
View File
@@ -53,11 +53,6 @@ type AgentRunMetrics struct {
UnsupportedEvidenceRate float64 `json:"unsupportedEvidenceRate"` UnsupportedEvidenceRate float64 `json:"unsupportedEvidenceRate"`
} }
type AgentRunEngineComparison struct {
EngineCode string `json:"engineCode"`
Metrics AgentRunMetrics `json:"metrics"`
}
const maxAgentAuditPreviewChars = 4000 const maxAgentAuditPreviewChars = 4000
var agentAuditSecretPattern = regexp.MustCompile(`(?i)(?:"|')?(api[_-]?key|authorization|password|secret|token|cookie)(?:"|')?\s*([:=])\s*(?:"[^"]*"|'[^']*'|[^\s,;}]+)`) var agentAuditSecretPattern = regexp.MustCompile(`(?i)(?:"|')?(api[_-]?key|authorization|password|secret|token|cookie)(?:"|')?\s*([:=])\s*(?:"[^"]*"|'[^']*'|[^\s,;}]+)`)
@@ -146,32 +141,6 @@ func (s *agentRunService) GetMetrics(aiAgentID int64) AgentRunMetrics {
return metrics return metrics
} }
// GetEngineComparisons keeps Workflow, Autonomous, and Hybrid reports based on
// the same normalized audit and reviewed-quality records. Conversation-level
// handoff is deliberately excluded because it cannot be attributed to one
// Engine after a mode change.
func (s *agentRunService) GetEngineComparisons(aiAgentID int64) []AgentRunEngineComparison {
runs := repositories.AgentRunRepository.FindRecent(sqls.DB(), aiAgentID, 5000)
groups := make(map[string][]models.AgentRun)
for _, run := range runs {
engineCode := strings.TrimSpace(run.EngineCode)
if engineCode == "" {
engineCode = "unknown"
}
groups[engineCode] = append(groups[engineCode], run)
}
engineCodes := make([]string, 0, len(groups))
for engineCode := range groups {
engineCodes = append(engineCodes, engineCode)
}
sort.Strings(engineCodes)
ret := make([]AgentRunEngineComparison, 0, len(engineCodes))
for _, engineCode := range engineCodes {
ret = append(ret, AgentRunEngineComparison{EngineCode: engineCode, Metrics: s.aggregateMetrics(sqls.DB(), groups[engineCode])})
}
return ret
}
func (s *agentRunService) aggregateMetrics(db *gorm.DB, runs []models.AgentRun) AgentRunMetrics { func (s *agentRunService) aggregateMetrics(db *gorm.DB, runs []models.AgentRun) AgentRunMetrics {
metrics := AgentRunMetrics{TotalRuns: len(runs)} metrics := AgentRunMetrics{TotalRuns: len(runs)}
if len(runs) == 0 { if len(runs) == 0 {
@@ -258,29 +227,12 @@ func (s *agentRunService) aggregateMetrics(db *gorm.DB, runs []models.AgentRun)
return metrics return metrics
} }
type WorkflowAgentRunInput struct { type AgentLoopRunInput struct {
WorkflowRunID int64
WorkflowVersionID int64
ConversationID int64
AIAgentID int64
SourceMessageID int64
Status string
PromptTokens int
CompletionTokens int
StartedAt time.Time
EndedAt *time.Time
ErrorMessage string
TraceData string
StepInputPreview string
StepOutputPreview string
}
type EngineAgentRunInput struct {
ConversationID int64 ConversationID int64
AIAgentID int64 AIAgentID int64
AgentRevisionID int64 AgentRevisionID int64
SourceMessageID int64 SourceMessageID int64
EngineCode string WorkflowRunID int64
Status string Status string
PromptTokens int PromptTokens int
CompletionTokens int CompletionTokens int
@@ -292,11 +244,11 @@ type EngineAgentRunInput struct {
StepCode string StepCode string
StepInputPreview string StepInputPreview string
StepOutputPreview string StepOutputPreview string
AdditionalSteps []EngineStepInput AdditionalSteps []AgentLoopStepInput
ToolCalls []EngineToolCallInput ToolCalls []AgentLoopToolCallInput
} }
type EngineStepInput struct { type AgentLoopStepInput struct {
StepType string StepType string
StepCode string StepCode string
WorkflowRunID int64 WorkflowRunID int64
@@ -306,7 +258,7 @@ type EngineStepInput struct {
ErrorMessage string ErrorMessage string
} }
type EngineToolCallInput struct { type AgentLoopToolCallInput struct {
ToolCode string ToolCode string
RiskLevel string RiskLevel string
RequireConfirm bool RequireConfirm bool
@@ -317,47 +269,47 @@ type EngineToolCallInput struct {
DurationMS int DurationMS int
} }
// RecordHybridPlaybookResume closes or re-interrupts the Hybrid AgentRun that // RecordResume closes or re-interrupts the original Agent Loop parent run,
// originally selected a Playbook. The detailed WorkflowRun remains separately // appends a normalized resume step, and records an optional resumed tool call.
// auditable; this step preserves the parent AgentRun -> AgentStep -> WorkflowRun func (s *agentRunService) RecordResume(db *gorm.DB, agentRunID, workflowRunID int64, status, replyText string, toolCall *AgentLoopToolCallInput) error {
// relationship across a human confirmation pause.
func (s *agentRunService) RecordHybridPlaybookResume(db *gorm.DB, agentRunID, workflowRunID int64, status, replyText string) error {
if agentRunID <= 0 { if agentRunID <= 0 {
return nil return nil
} }
run := repositories.AgentRunRepository.Get(db, agentRunID) run := repositories.AgentRunRepository.Get(db, agentRunID)
if run == nil || run.EngineCode != "hybrid" { if run == nil {
return nil return nil
} }
status = strings.TrimSpace(status) status = firstNonEmptyString(status, "completed")
if status == "" {
status = "completed"
}
now := time.Now() now := time.Now()
durationMS := int(now.Sub(run.StartedAt).Milliseconds())
if durationMS < 0 {
durationMS = 0
}
if err := repositories.AgentRunRepository.Updates(db, run.ID, map[string]any{ if err := repositories.AgentRunRepository.Updates(db, run.ID, map[string]any{
"status": status, "status": status, "ended_at": &now, "error_message": "", "updated_at": now,
"ended_at": &now,
"error_message": "",
"updated_at": now,
}); err != nil { }); err != nil {
return err return err
} }
return repositories.AgentStepRepository.Create(db, &models.AgentStep{ step := &models.AgentStep{
AgentRunID: run.ID, WorkflowRunID: workflowRunID, AgentRunID: run.ID, WorkflowRunID: workflowRunID,
StepType: "playbook", StepCode: "playbook_resume", Status: status, StepType: "resume", StepCode: "confirmation_resume", Status: status,
InputPreview: "human confirmation resume", InputPreview: "customer confirmation", OutputPreview: sanitizeAgentAuditPreview(replyText),
OutputPreview: sanitizeAgentAuditPreview(replyText), StartedAt: now, EndedAt: &now, CreatedAt: now,
StartedAt: now, EndedAt: &now, DurationMS: durationMS, CreatedAt: now, }
if err := repositories.AgentStepRepository.Create(db, step); err != nil {
return err
}
if toolCall == nil {
return nil
}
return repositories.AgentToolCallRepository.Create(db, &models.AgentToolCall{
AgentRunID: run.ID, AgentStepID: step.ID, ToolCode: strings.TrimSpace(toolCall.ToolCode),
RiskLevel: strings.TrimSpace(toolCall.RiskLevel), RequireConfirm: toolCall.RequireConfirm,
Status: firstNonEmptyString(toolCall.Status, status), ArgumentsPreview: sanitizeAgentAuditPreview(toolCall.ArgumentsPreview),
ResultPreview: sanitizeAgentAuditPreview(toolCall.ResultPreview), ErrorMessage: sanitizeAgentAuditPreview(toolCall.ErrorMessage),
DurationMS: toolCall.DurationMS, CreatedAt: now,
}) })
} }
// RecordEngineRun writes a non-workflow Engine audit run and its normalized // RecordAgentLoopRun writes the Agent Loop parent audit run and its normalized
// root step in one transaction owned by the caller. // root step in one transaction owned by the caller.
func (s *agentRunService) RecordEngineRun(db *gorm.DB, input EngineAgentRunInput) (int64, error) { func (s *agentRunService) RecordAgentLoopRun(db *gorm.DB, input AgentLoopRunInput) (int64, error) {
now := time.Now() now := time.Now()
startedAt := input.StartedAt startedAt := input.StartedAt
if startedAt.IsZero() { if startedAt.IsZero() {
@@ -369,7 +321,7 @@ func (s *agentRunService) RecordEngineRun(db *gorm.DB, input EngineAgentRunInput
} }
run := &models.AgentRun{ run := &models.AgentRun{
ConversationID: input.ConversationID, AIAgentID: input.AIAgentID, AgentRevisionID: input.AgentRevisionID, ConversationID: input.ConversationID, AIAgentID: input.AIAgentID, AgentRevisionID: input.AgentRevisionID,
SourceMessageID: input.SourceMessageID, EngineCode: strings.TrimSpace(input.EngineCode), Status: status, SourceMessageID: input.SourceMessageID, WorkflowRunID: input.WorkflowRunID, Status: status,
PromptTokens: input.PromptTokens, CompletionTokens: input.CompletionTokens, StartedAt: startedAt, EndedAt: input.EndedAt, PromptTokens: input.PromptTokens, CompletionTokens: input.CompletionTokens, StartedAt: startedAt, EndedAt: input.EndedAt,
ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage), TraceData: sanitizeAgentAuditPreview(input.TraceData), CreatedAt: now, UpdatedAt: now, ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage), TraceData: sanitizeAgentAuditPreview(input.TraceData), CreatedAt: now, UpdatedAt: now,
} }
@@ -435,80 +387,3 @@ func firstNonEmptyString(items ...string) string {
} }
return "" return ""
} }
// RecordWorkflowRun writes the Engine-independent audit record inside the
// caller's transaction. Workflow-specific tables remain the detailed source
// for node-level diagnosis while AgentRun becomes the cross-engine summary.
func (s *agentRunService) RecordWorkflowRun(db *gorm.DB, input WorkflowAgentRunInput) (int64, error) {
now := time.Now()
status := strings.TrimSpace(input.Status)
if status == "" {
status = "completed"
}
startedAt := input.StartedAt
if startedAt.IsZero() {
startedAt = now
}
run := repositories.AgentRunRepository.TakeByWorkflowRunID(db, input.WorkflowRunID)
agentRevisionID := int64(0)
if revision := repositories.AgentRevisionRepository.TakeByAgentIDAndWorkflowVersionID(db, input.AIAgentID, input.WorkflowVersionID); revision != nil {
agentRevisionID = revision.ID
}
if run == nil {
run = &models.AgentRun{
ConversationID: input.ConversationID,
AIAgentID: input.AIAgentID,
AgentRevisionID: agentRevisionID,
SourceMessageID: input.SourceMessageID,
WorkflowRunID: input.WorkflowRunID,
EngineCode: "workflow",
Status: status,
PromptTokens: input.PromptTokens,
CompletionTokens: input.CompletionTokens,
StartedAt: startedAt,
EndedAt: input.EndedAt,
ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage),
TraceData: sanitizeAgentAuditPreview(input.TraceData),
CreatedAt: now,
UpdatedAt: now,
}
if err := repositories.AgentRunRepository.Create(db, run); err != nil {
return 0, err
}
} else if err := repositories.AgentRunRepository.Updates(db, run.ID, map[string]any{
"agent_revision_id": agentRevisionID,
"status": status,
"prompt_tokens": input.PromptTokens,
"completion_tokens": input.CompletionTokens,
"ended_at": input.EndedAt,
"error_message": sanitizeAgentAuditPreview(input.ErrorMessage),
"trace_data": sanitizeAgentAuditPreview(input.TraceData),
"updated_at": now,
}); err != nil {
return 0, err
}
durationMS := 0
if input.EndedAt != nil {
durationMS = int(input.EndedAt.Sub(startedAt).Milliseconds())
if durationMS < 0 {
durationMS = 0
}
}
step := &models.AgentStep{
AgentRunID: run.ID,
StepType: "workflow",
StepCode: "workflow",
Status: status,
InputPreview: sanitizeAgentAuditPreview(input.StepInputPreview),
OutputPreview: sanitizeAgentAuditPreview(input.StepOutputPreview),
ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage),
StartedAt: startedAt,
EndedAt: input.EndedAt,
DurationMS: durationMS,
CreatedAt: now,
}
if err := repositories.AgentStepRepository.Create(db, step); err != nil {
return 0, err
}
return run.ID, nil
}
+31 -56
View File
@@ -10,7 +10,6 @@ import (
"agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/httpx/params" "agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/repositories"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls" "github.com/mlogclub/simple/sqls"
@@ -26,12 +25,12 @@ func TestAgentRunServiceFindsWorkflowAuditDetail(t *testing.T) {
ConversationID: 11, ConversationID: 11,
AIAgentID: 12, AIAgentID: 12,
WorkflowRunID: 13, WorkflowRunID: 13,
EngineCode: "workflow",
Status: "completed", Status: "completed",
StartedAt: now, StartedAt: now,
EndedAt: &endedAt, EndedAt: &endedAt,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
if err := db.Create(run).Error; err != nil { if err := db.Create(run).Error; err != nil {
t.Fatalf("create agent run: %v", err) t.Fatalf("create agent run: %v", err)
@@ -55,38 +54,17 @@ func TestAgentRunServiceFindsWorkflowAuditDetail(t *testing.T) {
} }
} }
func TestAgentRunServiceAssociatesWorkflowRevision(t *testing.T) { func TestAgentRunServiceRecordsAgentLoopToolCall(t *testing.T) {
db := setupAgentRunServiceTestDB(t) db := setupAgentRunServiceTestDB(t)
now := time.Now() now := time.Now()
if err := db.Create(&models.AgentRevision{AgentID: 12, Revision: 1, WorkflowVersionID: 14}).Error; err != nil { runID, err := AgentRunService.RecordAgentLoopRun(db, AgentLoopRunInput{
t.Fatalf("create agent revision: %v", err) ConversationID: 1, AIAgentID: 2, AgentRevisionID: 3, Status: "completed", StartedAt: now,
} StepType: "model", StepCode: "chat_completion", StepInputPreview: "authorization=Bearer-secret", ToolCalls: []AgentLoopToolCallInput{{
if _, err := AgentRunService.RecordWorkflowRun(db, WorkflowAgentRunInput{
WorkflowRunID: 13, WorkflowVersionID: 14, ConversationID: 11, AIAgentID: 12,
Status: "completed", StartedAt: now,
}); err != nil {
t.Fatalf("RecordWorkflowRun returned error: %v", err)
}
run := repositories.AgentRunRepository.TakeByWorkflowRunID(db, 13)
if run == nil || run.AgentRevisionID <= 0 {
t.Fatalf("expected AgentRun to link revision, got %#v", run)
}
if stepID := AgentRunService.GetLatestStepID(run.ID); stepID <= 0 {
t.Fatalf("expected normalized agent step id, got %d", stepID)
}
}
func TestAgentRunServiceRecordsEngineToolCall(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
now := time.Now()
runID, err := AgentRunService.RecordEngineRun(db, EngineAgentRunInput{
ConversationID: 1, AIAgentID: 2, AgentRevisionID: 3, EngineCode: "autonomous", Status: "completed", StartedAt: now,
StepType: "model", StepCode: "chat_completion", StepInputPreview: "authorization=Bearer-secret", ToolCalls: []EngineToolCallInput{{
ToolCode: "knowledge/search", RiskLevel: "read", Status: "completed", ArgumentsPreview: `{"token":"abc123","query":"refund"}`, ResultPreview: "policy text", ToolCode: "knowledge/search", RiskLevel: "read", Status: "completed", ArgumentsPreview: `{"token":"abc123","query":"refund"}`, ResultPreview: "policy text",
}}, }},
}) })
if err != nil { if err != nil {
t.Fatalf("RecordEngineRun returned error: %v", err) t.Fatalf("RecordAgentLoopRun returned error: %v", err)
} }
_, steps, toolCalls := AgentRunService.GetDetail(runID) _, steps, toolCalls := AgentRunService.GetDetail(runID)
if len(toolCalls) != 1 || toolCalls[0].ToolCode != "knowledge/search" || toolCalls[0].AgentStepID <= 0 { if len(toolCalls) != 1 || toolCalls[0].ToolCode != "knowledge/search" || toolCalls[0].AgentStepID <= 0 {
@@ -97,29 +75,33 @@ func TestAgentRunServiceRecordsEngineToolCall(t *testing.T) {
} }
} }
func TestAgentRunServiceRecordsHybridPlaybookResume(t *testing.T) { func TestAgentRunServiceRecordsResumedToolCall(t *testing.T) {
db := setupAgentRunServiceTestDB(t) db := setupAgentRunServiceTestDB(t)
now := time.Now().Add(-time.Minute) now := time.Now()
run := &models.AgentRun{EngineCode: "hybrid", Status: "interrupted", StartedAt: now, CreatedAt: now, UpdatedAt: now} run := &models.AgentRun{Status: "interrupted", StartedAt: now, CreatedAt: now, UpdatedAt: now}
if err := db.Create(run).Error; err != nil { if err := db.Create(run).Error; err != nil {
t.Fatalf("create hybrid run: %v", err) t.Fatalf("create interrupted run: %v", err)
} }
if err := AgentRunService.RecordHybridPlaybookResume(db, run.ID, 33, "completed", "已完成工单登记。"); err != nil { err := AgentRunService.RecordResume(db, run.ID, 0, "completed", "操作已执行", &AgentLoopToolCallInput{
t.Fatalf("RecordHybridPlaybookResume returned error: %v", err) ToolCode: "crm/update_customer", RiskLevel: "write", RequireConfirm: true,
Status: "completed", ArgumentsPreview: `{"name":"Ada"}`, ResultPreview: "updated",
})
if err != nil {
t.Fatalf("RecordResume returned error: %v", err)
} }
item, steps, _ := AgentRunService.GetDetail(run.ID) item, steps, toolCalls := AgentRunService.GetDetail(run.ID)
if item == nil || item.Status != "completed" || item.EndedAt == nil { if item == nil || item.Status != "completed" || len(steps) != 1 || steps[0].StepType != "resume" {
t.Fatalf("expected completed hybrid run, got %#v", item) t.Fatalf("unexpected resumed run audit: item=%#v steps=%#v", item, steps)
} }
if len(steps) != 1 || steps[0].StepCode != "playbook_resume" || steps[0].WorkflowRunID != 33 || steps[0].OutputPreview != "已完成工单登记。" { if len(toolCalls) != 1 || toolCalls[0].AgentStepID != steps[0].ID || !toolCalls[0].RequireConfirm || toolCalls[0].Status != "completed" {
t.Fatalf("unexpected playbook resume step: %#v", steps) t.Fatalf("unexpected resumed tool audit: %#v", toolCalls)
} }
} }
func TestAgentRunServiceSavesQualityFeedbackPerRun(t *testing.T) { func TestAgentRunServiceSavesQualityFeedbackPerRun(t *testing.T) {
db := setupAgentRunServiceTestDB(t) db := setupAgentRunServiceTestDB(t)
now := time.Now() now := time.Now()
run := &models.AgentRun{AIAgentID: 4, EngineCode: "autonomous", Status: "completed", StartedAt: now, CreatedAt: now, UpdatedAt: now} run := &models.AgentRun{AIAgentID: 4, Status: "completed", StartedAt: now, CreatedAt: now, UpdatedAt: now}
if err := db.Create(run).Error; err != nil { if err := db.Create(run).Error; err != nil {
t.Fatalf("create agent run: %v", err) t.Fatalf("create agent run: %v", err)
} }
@@ -140,13 +122,13 @@ func TestAgentRunServiceSavesQualityFeedbackPerRun(t *testing.T) {
} }
} }
func TestAgentRunServiceAggregatesCrossEngineMetrics(t *testing.T) { func TestAgentRunServiceAggregatesMetrics(t *testing.T) {
db := setupAgentRunServiceTestDB(t) db := setupAgentRunServiceTestDB(t)
base := time.Now().Add(-time.Minute) base := time.Now().Add(-time.Minute)
runs := []models.AgentRun{ runs := []models.AgentRun{
{AIAgentID: 8, EngineCode: "autonomous", Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(100 * time.Millisecond)), PromptTokens: 10, CompletionTokens: 5, CreatedAt: base, UpdatedAt: base}, {AIAgentID: 8, Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(100 * time.Millisecond)), PromptTokens: 10, CompletionTokens: 5, CreatedAt: base, UpdatedAt: base},
{AIAgentID: 8, EngineCode: "workflow", Status: "failed", StartedAt: base, EndedAt: timePtr(base.Add(300 * time.Millisecond)), PromptTokens: 8, CompletionTokens: 2, CreatedAt: base, UpdatedAt: base}, {AIAgentID: 8, Status: "failed", StartedAt: base, EndedAt: timePtr(base.Add(300 * time.Millisecond)), PromptTokens: 8, CompletionTokens: 2, CreatedAt: base, UpdatedAt: base},
{AIAgentID: 9, EngineCode: "hybrid", Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(900 * time.Millisecond)), CreatedAt: base, UpdatedAt: base}, {AIAgentID: 9, Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(900 * time.Millisecond)), CreatedAt: base, UpdatedAt: base},
} }
for index := range runs { for index := range runs {
if err := db.Create(&runs[index]).Error; err != nil { if err := db.Create(&runs[index]).Error; err != nil {
@@ -203,13 +185,6 @@ func TestAgentRunServiceAggregatesCrossEngineMetrics(t *testing.T) {
if metrics.ReviewedRuns != 2 || metrics.ResolvedRuns != 1 || metrics.ResolutionRate != 0.5 || metrics.UnsupportedEvidenceRuns != 1 || metrics.UnsupportedEvidenceRate != 0.5 { if metrics.ReviewedRuns != 2 || metrics.ResolvedRuns != 1 || metrics.ResolutionRate != 0.5 || metrics.UnsupportedEvidenceRuns != 1 || metrics.UnsupportedEvidenceRate != 0.5 {
t.Fatalf("unexpected quality metrics: %#v", metrics) t.Fatalf("unexpected quality metrics: %#v", metrics)
} }
comparisons := AgentRunService.GetEngineComparisons(8)
if len(comparisons) != 2 || comparisons[0].EngineCode != "autonomous" || comparisons[1].EngineCode != "workflow" {
t.Fatalf("unexpected engine comparison groups: %#v", comparisons)
}
if comparisons[0].Metrics.TotalRuns != 1 || comparisons[0].Metrics.ResolutionRate != 1 || comparisons[1].Metrics.TotalRuns != 1 || comparisons[1].Metrics.UnsupportedEvidenceRate != 1 {
t.Fatalf("unexpected engine comparison metrics: %#v", comparisons)
}
} }
func timePtr(value time.Time) *time.Time { return &value } func timePtr(value time.Time) *time.Time { return &value }
+40 -68
View File
@@ -24,7 +24,7 @@ import (
var AIAgentService = newAIAgentService() var AIAgentService = newAIAgentService()
const defaultNewAutonomousRolloutPercent = 5 const defaultNewAgentRolloutPercent = 5
func newAIAgentService() *aIAgentService { func newAIAgentService() *aIAgentService {
return &aIAgentService{} return &aIAgentService{}
@@ -83,11 +83,8 @@ func (s *aIAgentService) CreateAIAgent(req request.CreateAIAgentRequest, operato
if err := repositories.AIAgentRepository.Create(ctx.Tx, item); err != nil { if err := repositories.AIAgentRepository.Create(ctx.Tx, item); err != nil {
return err return err
} }
bindings, err := s.replaceWorkflowBindings(ctx.Tx, item.ID, req.WorkflowBindings, operator) _, err := s.replaceWorkflowBindings(ctx.Tx, item.ID, req.WorkflowBindings, operator)
if err != nil { return err
return err
}
return s.validateWorkflowBindingMode(ctx.Tx, item, bindings)
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
@@ -110,7 +107,6 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato
"name": item.Name, "name": item.Name,
"description": item.Description, "description": item.Description,
"ai_config_id": item.AIConfigID, "ai_config_id": item.AIConfigID,
"runtime_mode": item.RuntimeMode,
"max_steps": item.MaxSteps, "max_steps": item.MaxSteps,
"context_window": item.ContextWindow, "context_window": item.ContextWindow,
"tool_policy": item.ToolPolicy, "tool_policy": item.ToolPolicy,
@@ -127,6 +123,7 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato
"knowledge_ids": item.KnowledgeIDs, "knowledge_ids": item.KnowledgeIDs,
"skill_ids": item.SkillIDs, "skill_ids": item.SkillIDs,
"allowed_mcp_tools": item.AllowedMCPTools, "allowed_mcp_tools": item.AllowedMCPTools,
"published_revision_id": 0,
"update_user_id": operator.UserID, "update_user_id": operator.UserID,
"update_user_name": operator.Username, "update_user_name": operator.Username,
"updated_at": time.Now(), "updated_at": time.Now(),
@@ -134,42 +131,15 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato
if item.RolloutPercent != current.RolloutPercent { if item.RolloutPercent != current.RolloutPercent {
columns["previous_rollout_percent"] = current.RolloutPercent columns["previous_rollout_percent"] = current.RolloutPercent
} }
if current.RuntimeMode == enums.AIAgentRuntimeModeAutonomous || current.RuntimeMode == enums.AIAgentRuntimeModeHybrid || item.RuntimeMode == enums.AIAgentRuntimeModeAutonomous || item.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
// Draft edits must not silently change the already published autonomous or hybrid
// behavior. The operator must explicitly publish the new revision.
columns["published_revision_id"] = 0
}
return sqls.WithTransaction(func(ctx *sqls.TxContext) error { return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if err := repositories.AIAgentRepository.Updates(ctx.Tx, req.ID, columns); err != nil { if err := repositories.AIAgentRepository.Updates(ctx.Tx, req.ID, columns); err != nil {
return err return err
} }
bindings, err := s.replaceWorkflowBindings(ctx.Tx, req.ID, req.WorkflowBindings, operator) _, err := s.replaceWorkflowBindings(ctx.Tx, req.ID, req.WorkflowBindings, operator)
if err != nil { return err
return err
}
return s.validateWorkflowBindingMode(ctx.Tx, item, bindings)
}) })
} }
func (s *aIAgentService) validateWorkflowBindingMode(db *gorm.DB, agent *models.AIAgent, bindings []models.AIAgentWorkflowBinding) error {
enabled := make([]models.AIAgentWorkflowBinding, 0, len(bindings))
for _, binding := range bindings {
if binding.Enabled {
enabled = append(enabled, binding)
}
}
if agent.RuntimeMode == enums.AIAgentRuntimeModeAutonomous {
return nil
}
if len(enabled) == 0 {
return errorsx.InvalidParam("workflow and hybrid agents require at least one enabled workflow")
}
if agent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow && len(enabled) != 1 {
return errorsx.InvalidParam("workflow agent requires exactly one enabled workflow")
}
return repositories.AIAgentRepository.Updates(db, agent.ID, map[string]any{"workflow_version_id": enabled[0].WorkflowVersionID})
}
func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) error { func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) error {
current := s.Get(id) current := s.Get(id)
if current == nil { if current == nil {
@@ -186,7 +156,8 @@ func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) er
}) })
} }
// PublishAIAgent snapshots a non-workflow Agent before it can receive traffic. // PublishAIAgent snapshots the complete Agent capability set before it can
// receive traffic.
func (s *aIAgentService) PublishAIAgent(id int64, operator *dto.AuthPrincipal) (*models.AgentRevision, error) { func (s *aIAgentService) PublishAIAgent(id int64, operator *dto.AuthPrincipal) (*models.AgentRevision, error) {
if operator == nil { if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired") return nil, errorsx.UnauthorizedI18n("error.auth.expired")
@@ -197,15 +168,9 @@ func (s *aIAgentService) PublishAIAgent(id int64, operator *dto.AuthPrincipal) (
if agent == nil || agent.Status != enums.StatusOk { if agent == nil || agent.Status != enums.StatusOk {
return errorsx.InvalidParamI18n("error.e0002") return errorsx.InvalidParamI18n("error.e0002")
} }
if agent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow {
return errorsx.InvalidParam("workflow agents publish through their selected workflow version")
}
if err := s.validatePublishableAgent(ctx.Tx, agent); err != nil { if err := s.validatePublishableAgent(ctx.Tx, agent); err != nil {
return err return err
} }
if agent.RuntimeMode == enums.AIAgentRuntimeModeHybrid && len(s.ListEnabledWorkflowBindings(ctx.Tx, agent.ID)) == 0 {
return errorsx.InvalidParam("hybrid agent requires at least one published workflow")
}
var err error var err error
revision, err = AgentRevisionService.PublishSnapshot(ctx.Tx, agent, operator) revision, err = AgentRevisionService.PublishSnapshot(ctx.Tx, agent, operator)
if err != nil { if err != nil {
@@ -235,20 +200,33 @@ func (s *aIAgentService) validatePublishableAgent(db *gorm.DB, agent *models.AIA
if _, err := s.normalizeToolPolicy(agent.ToolPolicy); err != nil { if _, err := s.normalizeToolPolicy(agent.ToolPolicy); err != nil {
return err return err
} }
if strings.TrimSpace(agent.AllowedMCPTools) == "" {
return nil
}
var mcpTools []request.AIAgentMCPToolRequest var mcpTools []request.AIAgentMCPToolRequest
if err := json.Unmarshal([]byte(agent.AllowedMCPTools), &mcpTools); err != nil { if raw := strings.TrimSpace(agent.AllowedMCPTools); raw != "" {
return errorsx.InvalidParam("ai agent MCP tools are invalid") if err := json.Unmarshal([]byte(raw), &mcpTools); err != nil {
return errorsx.InvalidParam("ai agent MCP tools are invalid")
}
}
for _, id := range utils.SplitInt64s(agent.SkillIDs) {
skill := repositories.SkillDefinitionRepository.Get(db, id)
if skill == nil || skill.Status != enums.StatusOk {
return errorsx.InvalidParam("bound Skill is unavailable")
}
} }
for _, item := range mcpTools { for _, item := range mcpTools {
definition, err := aitooling.DefaultRegistry.Resolve(item.ToolCode) definition, err := aitooling.DefaultRegistry.Resolve(item.ToolCode)
if err != nil || definition.InputSchema == nil { if err != nil || definition.InputSchema == nil {
return errorsx.InvalidParam("ai agent MCP tool definition is unavailable") return errorsx.InvalidParam("ai agent MCP tool definition is unavailable")
} }
if definition.RequireConfirmation { if item.RiskLevel != aitooling.RiskLevelRead && item.RiskLevel != aitooling.RiskLevelWrite {
return errorsx.InvalidParam("ai agent MCP tool requires confirmation and cannot be executed directly") return errorsx.InvalidParam("ai agent MCP tool risk level is invalid")
}
if item.RiskLevel == aitooling.RiskLevelWrite && !item.RequireConfirmation {
return errorsx.InvalidParam("write MCP tools must require confirmation")
}
}
for _, binding := range s.ListEnabledWorkflowBindings(db, agent.ID) {
if binding.Version == nil || binding.Version.Status != enums.StatusOk {
return errorsx.InvalidParam("bound workflow version is unavailable")
} }
} }
return nil return nil
@@ -328,15 +306,6 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe
if aiConfig.Status != enums.StatusOk { if aiConfig.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0011") return nil, errorsx.InvalidParamI18n("error.e0011")
} }
if req.RuntimeMode == "" {
req.RuntimeMode = enums.AIAgentRuntimeModeAutonomous
}
if !enums.IsValidAIAgentRuntimeMode(req.RuntimeMode) {
return nil, errorsx.InvalidParam("invalid ai agent runtime mode")
}
if req.RuntimeMode != enums.AIAgentRuntimeModeWorkflow && req.RuntimeMode != enums.AIAgentRuntimeModeAutonomous && req.RuntimeMode != enums.AIAgentRuntimeModeHybrid {
return nil, errorsx.InvalidParam("ai agent runtime mode is not available yet")
}
if req.MaxSteps == 0 { if req.MaxSteps == 0 {
req.MaxSteps = 6 req.MaxSteps = 6
} }
@@ -374,11 +343,7 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe
return nil, errorsx.InvalidParamI18n("error.e0144") return nil, errorsx.InvalidParamI18n("error.e0144")
} }
if req.RolloutPercent == 0 { if req.RolloutPercent == 0 {
if req.RuntimeMode == enums.AIAgentRuntimeModeAutonomous || req.RuntimeMode == enums.AIAgentRuntimeModeHybrid { req.RolloutPercent = defaultNewAgentRolloutPercent
req.RolloutPercent = defaultNewAutonomousRolloutPercent
} else {
req.RolloutPercent = 100
}
} }
if req.RolloutPercent < 1 || req.RolloutPercent > 100 { if req.RolloutPercent < 1 || req.RolloutPercent > 100 {
return nil, errorsx.InvalidParam("ai agent rollout percent must be between 1 and 100") return nil, errorsx.InvalidParam("ai agent rollout percent must be between 1 and 100")
@@ -408,7 +373,6 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe
Name: name, Name: name,
Description: strings.TrimSpace(req.Description), Description: strings.TrimSpace(req.Description),
AIConfigID: req.AIConfigID, AIConfigID: req.AIConfigID,
RuntimeMode: req.RuntimeMode,
MaxSteps: req.MaxSteps, MaxSteps: req.MaxSteps,
ContextWindow: req.ContextWindow, ContextWindow: req.ContextWindow,
ToolPolicy: toolPolicy, ToolPolicy: toolPolicy,
@@ -532,9 +496,9 @@ func (s *aIAgentService) normalizeSkillIDs(input []int64) ([]int64, error) {
if skill == nil || skill.Status == enums.StatusDeleted { if skill == nil || skill.Status == enums.StatusDeleted {
continue continue
} }
// if skill.Status != enums.StatusOk { if skill.Status != enums.StatusOk {
// return nil, errorsx.InvalidParamI18n("error.e0056") return nil, errorsx.InvalidParamI18n("error.e0056")
// } }
seen[id] = struct{}{} seen[id] = struct{}{}
ret = append(ret, id) ret = append(ret, id)
} }
@@ -558,6 +522,14 @@ func (s *aIAgentService) normalizeMCPTools(input []request.AIAgentMCPToolRequest
if err := ToolCatalogService.ValidateToolCode(normalized.ToolCode); err != nil { if err := ToolCatalogService.ValidateToolCode(normalized.ToolCode); err != nil {
return nil, err return nil, err
} }
normalized.RiskLevel = strings.ToLower(strings.TrimSpace(item.RiskLevel))
if normalized.RiskLevel != aitooling.RiskLevelRead && normalized.RiskLevel != aitooling.RiskLevelWrite {
return nil, errorsx.InvalidParam("MCP tool risk level must be read or write")
}
normalized.RequireConfirmation = item.RequireConfirmation
if normalized.RiskLevel == aitooling.RiskLevelWrite && !normalized.RequireConfirmation {
return nil, errorsx.InvalidParam("write MCP tools must require confirmation")
}
key := strings.TrimSpace(normalized.ToolCode) key := strings.TrimSpace(normalized.ToolCode)
if _, exists := seen[key]; exists { if _, exists := seen[key]; exists {
continue continue
@@ -1,757 +0,0 @@
//go:build legacy
package services
import (
"encoding/json"
"strings"
"testing"
"agent-desk/internal/ai/workflow/dsl"
workflowregistry "agent-desk/internal/ai/workflow/registry"
workflowvalidator "agent-desk/internal/ai/workflow/validator"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/toolx"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
func TestAIAgentServiceCreatesWorkflowOnlyWhenRequested(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
aiConfigID := createAIAgentWorkflowTestConfig(t)
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "workflow agent",
AIConfigID: aiConfigID,
RuntimeMode: enums.AIAgentRuntimeModeWorkflow,
ServiceMode: enums.IMConversationServiceModeAIOnly,
HandoffMode: enums.AIAgentHandoffModeWaitPool,
FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
if item.RuntimeMode != enums.AIAgentRuntimeModeWorkflow {
t.Fatalf("default runtime mode = %q, want %q", item.RuntimeMode, enums.AIAgentRuntimeModeWorkflow)
}
if item.MaxSteps != 6 {
t.Fatalf("default max steps = %d, want 6", item.MaxSteps)
}
if item.RolloutPercent != 100 {
t.Fatalf("workflow rollout default = %d, want 100", item.RolloutPercent)
}
workflow, err := AIWorkflowService.GetOrCreateAgentWorkflow(item.ID, operator)
if err != nil {
t.Fatalf("GetOrCreateAgentWorkflow() error = %v", err)
}
if workflow.AgentID != item.ID {
t.Fatalf("expected workflow agent id %d, got %d", item.ID, workflow.AgentID)
}
if workflow.Name != item.Name+" 会话流程" {
t.Fatalf("unexpected workflow name: %s", workflow.Name)
}
var stored dsl.Definition
if err := json.Unmarshal([]byte(workflow.DraftDefinition), &stored); err != nil {
t.Fatalf("unmarshal draft definition: %v", err)
}
if stored.SchemaVersion != dsl.SchemaVersion || nodeTypeByID(stored, "start_1") != workflowregistry.NodeTypeStart {
t.Fatalf("expected default draft definition")
}
validation := workflowvalidator.ValidateDefinition(stored, workflowregistry.DefaultRegistry())
if validation.Valid || !workflowValidationHasMessage(validation, "需要选择至少一个知识库") {
t.Fatalf("expected default workflow to require node knowledge bases, got %#v", validation.Errors)
}
if nodeTypeByID(stored, "understanding_1") != workflowregistry.NodeTypeConversationUnderstanding {
t.Fatalf("expected default workflow to include conversation understanding, got nodes: %#v", stored.Nodes)
}
if nodeTypeByID(stored, "policy_1") != workflowregistry.NodeTypeReplyPolicy {
t.Fatalf("expected default workflow to include reply policy, got nodes: %#v", stored.Nodes)
}
if !workflowEdgeExists(stored, "start_1", "understanding_1") || !workflowEdgeExists(stored, "understanding_1", "policy_1") {
t.Fatalf("expected default workflow to start with policy-first understanding flow, got edges: %#v", stored.Edges)
}
for _, nodeType := range []string{
workflowregistry.NodeTypeConversationUnderstanding,
workflowregistry.NodeTypeReplyPolicy,
workflowregistry.NodeTypeHandoffToHuman,
workflowregistry.NodeTypePrepareTicketDraft,
workflowregistry.NodeTypeHumanConfirm,
workflowregistry.NodeTypeCreateTicket,
workflowregistry.NodeTypeKnowledgeRetrieve,
workflowregistry.NodeTypeAnswerabilityGate,
workflowregistry.NodeTypeLLMReply,
workflowregistry.NodeTypeSendReply,
} {
if !workflowHasNodeType(stored, nodeType) {
t.Fatalf("expected default workflow to include %s node: %#v", nodeType, stored.Nodes)
}
}
assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypeSendReply, "eq", "direct_reply")
assertConditionBranchToNodeID(t, stored, "policy_route_1", "handoff_confirm_prompt_1", "eq", "handoff_to_human")
assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypePrepareTicketDraft, "eq", "prepare_ticket")
assertConditionBranchToNodeID(t, stored, "ticket_draft_route_1", "ticket_confirm_prompt_1", "is_true", nil)
assertDefaultBranchToNodeID(t, stored, "ticket_draft_route_1", "ticket_followup_reply_1")
assertConditionBranchToNodeID(t, stored, "handoff_confirm_route_1", "handoff_1", "is_true", nil)
assertDefaultBranchToNodeID(t, stored, "handoff_confirm_route_1", "handoff_cancel_reply_1")
assertConditionBranchToNodeID(t, stored, "answerability_route_1", "reply_1", "eq", "answerable")
assertDefaultBranchToNodeID(t, stored, "answerability_route_1", "fallback_reply_1")
if !workflowEdgeExists(stored, "create_ticket_1", "ticket_result_reply_1") {
t.Fatalf("expected create_ticket to flow into a customer-visible result reply")
}
assertConditionBranchesHavePortEdges(t, stored, "policy_route_1")
assertConditionBranchesHavePortEdges(t, stored, "ticket_draft_route_1")
assertConditionBranchesHavePortEdges(t, stored, "ticket_confirm_route_1")
assertConditionBranchesHavePortEdges(t, stored, "handoff_confirm_route_1")
assertConditionBranchesHavePortEdges(t, stored, "answerability_route_1")
assertConditionBranchOrder(t, stored, "policy_route_1", []string{
"handoff",
"direct",
"clarify",
"end_conversation",
"ticket",
"knowledge",
"default",
})
assertConditionPortEdgeOrder(t, stored, "policy_route_1", []string{
"handoff",
"direct",
"clarify",
"end_conversation",
"ticket",
"knowledge",
"default",
})
}
func TestAIAgentServiceDefaultsNewAutonomousAgentToSmallRollout(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "small-rollout autonomous agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, aiAgentWorkflowTestOperator())
if err != nil {
t.Fatalf("CreateAIAgent: %v", err)
}
if item.RolloutPercent != defaultNewAutonomousRolloutPercent {
t.Fatalf("autonomous rollout default = %d, want %d", item.RolloutPercent, defaultNewAutonomousRolloutPercent)
}
}
func TestAIAgentServiceDefaultsToAutonomousWithoutWorkflow(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "default autonomous agent", AIConfigID: createAIAgentWorkflowTestConfig(t),
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
if item.RuntimeMode != enums.AIAgentRuntimeModeAutonomous {
t.Fatalf("default runtime mode = %q, want %q", item.RuntimeMode, enums.AIAgentRuntimeModeAutonomous)
}
var workflowCount int64
if err := sqls.DB().Model(&models.AIWorkflow{}).Where("agent_id = ?", item.ID).Count(&workflowCount).Error; err != nil {
t.Fatalf("count workflows: %v", err)
}
if workflowCount != 0 {
t.Fatalf("default autonomous agent created %d workflows", workflowCount)
}
}
func TestAIAgentServiceCreatesWorkflowDraftForHybrid(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "hybrid agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeHybrid,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, aiAgentWorkflowTestOperator())
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
var workflowCount int64
if err := sqls.DB().Model(&models.AIWorkflow{}).Where("agent_id = ?", item.ID).Count(&workflowCount).Error; err != nil {
t.Fatalf("count workflows: %v", err)
}
if workflowCount != 1 {
t.Fatalf("hybrid agent created %d workflows, want 1", workflowCount)
}
}
func TestAIAgentServiceNormalizesToolPolicy(t *testing.T) {
policy, err := AIAgentService.normalizeToolPolicy(`{"maxTotalCalls":2,"maxArgumentBytes":1024,"allowedRiskLevels":["READ","read","write"]}`)
if err != nil {
t.Fatalf("normalizeToolPolicy: %v", err)
}
if !strings.Contains(policy, `"maxTotalCalls":2`) || !strings.Contains(policy, `"allowedRiskLevels":["read","write"]`) {
t.Fatalf("unexpected normalized policy: %s", policy)
}
if _, err := AIAgentService.normalizeToolPolicy(`{"allowedRiskLevels":["sensitive"]}`); err == nil {
t.Fatal("expected removed sensitive risk level to be rejected")
}
if _, err := AIAgentService.normalizeToolPolicy(`{"allowedRiskLevels":["admin"]}`); err == nil {
t.Fatal("expected invalid risk level error")
}
if _, err := AIAgentService.normalizeToolPolicy(`not-json`); err == nil {
t.Fatal("expected invalid JSON error")
}
}
func TestAIAgentServiceRejectsNonMCPToolSelection(t *testing.T) {
for _, toolCode := range []string{
toolx.BuiltinConversationContext.Code,
toolx.BuiltinKnowledgeRetrieve.Code,
toolx.GraphPrepareTicketDraft.Code,
toolx.GraphAnalyzeConversation.Code,
toolx.GraphTriageServiceRequest.Code,
toolx.GraphHandoffConversation.Code,
} {
if _, err := AIAgentService.normalizeMCPTools([]request.AIAgentMCPToolRequest{{ToolCode: toolCode}}); err == nil {
t.Fatalf("expected non-MCP tool %q to be rejected", toolCode)
}
}
}
func TestAIAgentServiceRollsBackToOwnPublishedRevision(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
db := sqls.DB()
agent := &models.AIAgent{Name: "rollback-agent", Status: enums.StatusOk, RuntimeMode: enums.AIAgentRuntimeModeAutonomous}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("create agent: %v", err)
}
revision := &models.AgentRevision{AgentID: agent.ID, Revision: 1, Status: enums.StatusOk}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
if err := AIAgentService.RollbackAIAgent(agent.ID, revision.ID, aiAgentWorkflowTestOperator()); err != nil {
t.Fatalf("RollbackAIAgent: %v", err)
}
if updated := AIAgentService.Get(agent.ID); updated == nil || updated.PublishedRevisionID != revision.ID {
t.Fatalf("rollback did not bind revision: %#v", updated)
}
otherRevision := &models.AgentRevision{AgentID: agent.ID + 1, Revision: 1, Status: enums.StatusOk}
if err := db.Create(otherRevision).Error; err != nil {
t.Fatalf("create other revision: %v", err)
}
if err := AIAgentService.RollbackAIAgent(agent.ID, otherRevision.ID, aiAgentWorkflowTestOperator()); err == nil {
t.Fatal("expected cross-agent revision rollback rejection")
}
}
func TestAIAgentServiceRollsBackPreviousRolloutPercent(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
agent := &models.AIAgent{
Name: "rollout-agent",
Status: enums.StatusOk,
RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
RolloutPercent: 20,
PreviousRolloutPercent: 100,
}
if err := sqls.DB().Create(agent).Error; err != nil {
t.Fatalf("create agent: %v", err)
}
operator := aiAgentWorkflowTestOperator()
if err := AIAgentService.RollbackAIAgentRollout(agent.ID, operator); err != nil {
t.Fatalf("RollbackAIAgentRollout: %v", err)
}
updated := AIAgentService.Get(agent.ID)
if updated == nil || updated.RolloutPercent != 100 || updated.PreviousRolloutPercent != 20 {
t.Fatalf("unexpected rollout rollback result: %#v", updated)
}
if err := AIAgentService.RollbackAIAgentRollout(agent.ID, operator); err != nil {
t.Fatalf("second RollbackAIAgentRollout: %v", err)
}
updated = AIAgentService.Get(agent.ID)
if updated == nil || updated.RolloutPercent != 20 || updated.PreviousRolloutPercent != 100 {
t.Fatalf("unexpected rollout redo result: %#v", updated)
}
if err := sqls.DB().Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("previous_rollout_percent", 0).Error; err != nil {
t.Fatalf("clear previous rollout: %v", err)
}
if err := AIAgentService.RollbackAIAgentRollout(agent.ID, operator); err == nil {
t.Fatal("expected missing previous rollout to be rejected")
}
}
func TestAIAgentServiceUpdateUnpublishesAutonomousAgent(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "autonomous agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err != nil {
t.Fatalf("PublishAIAgent() error = %v", err)
}
if published := AIAgentService.Get(agent.ID); published == nil || published.PublishedRevisionID <= 0 {
t.Fatalf("expected published autonomous agent, got %#v", published)
}
if err := AIAgentService.UpdateAIAgent(request.UpdateAIAgentRequest{ID: agent.ID, CreateAIAgentRequest: request.CreateAIAgentRequest{
Name: agent.Name, Description: "changed draft", AIConfigID: agent.AIConfigID, RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}}, operator); err != nil {
t.Fatalf("UpdateAIAgent() error = %v", err)
}
if updated := AIAgentService.Get(agent.ID); updated == nil || updated.PublishedRevisionID != 0 {
t.Fatalf("expected autonomous update to clear published revision, got %#v", updated)
}
}
func TestAIAgentServiceRejectsPublishWithUnavailableModelConfig(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
configID := createAIAgentWorkflowTestConfig(t)
agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "unavailable model agent", AIConfigID: configID, RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
if err := sqls.DB().Model(&models.AIConfig{}).Where("id = ?", configID).Update("status", enums.StatusDisabled).Error; err != nil {
t.Fatalf("disable model config: %v", err)
}
if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err == nil {
t.Fatal("expected unavailable model config to reject publishing")
}
}
func TestAIAgentServiceAllowsPublishWithAdministratorSelectedMCPTool(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "mcp tool agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
if err := sqls.DB().Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("allowed_mcp_tools", `[{"toolCode":"mcp/demo/write_order"}]`).Error; err != nil {
t.Fatalf("set MCP tool: %v", err)
}
if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err != nil {
t.Fatalf("expected administrator-selected MCP tool to be publishable, got %v", err)
}
}
func TestAIWorkflowServiceDefaultAgentWorkflowDefinitionRequiresKnowledgeRetrieveConfiguration(t *testing.T) {
definition := AIWorkflowService.DefaultAgentWorkflowDefinition()
if definition.SchemaVersion != dsl.SchemaVersion || nodeTypeByID(definition, "start_1") != workflowregistry.NodeTypeStart {
t.Fatalf("expected default workflow definition")
}
validation := workflowvalidator.ValidateDefinition(definition, workflowregistry.DefaultRegistry())
if validation.Valid || !workflowValidationHasMessage(validation, "需要选择至少一个知识库") {
t.Fatalf("expected default workflow definition to require node knowledge bases, got %#v", validation.Errors)
}
if nodeTypeByID(definition, "understanding_1") != workflowregistry.NodeTypeConversationUnderstanding {
t.Fatalf("expected default workflow to include conversation understanding, got nodes: %#v", definition.Nodes)
}
if nodeTypeByID(definition, "policy_1") != workflowregistry.NodeTypeReplyPolicy {
t.Fatalf("expected default workflow to include reply policy, got nodes: %#v", definition.Nodes)
}
if !workflowHasNodeType(definition, workflowregistry.NodeTypeHandoffToHuman) {
t.Fatalf("expected default workflow to include human handoff node")
}
if !workflowHasNodeType(definition, workflowregistry.NodeTypeCreateTicket) {
t.Fatalf("expected default workflow to include ticket creation node")
}
if nodeTypeByID(definition, "handoff_confirm_1") != workflowregistry.NodeTypeHumanConfirm {
t.Fatalf("expected default workflow handoff path to include human confirmation")
}
handoff := workflowNodeByID(t, definition, "handoff_1")
if nodeID, field, ok := handoff.Data.InputsValues["confirmed"].Ref(); !ok || nodeID != "handoff_confirm_1" || field != "confirmed" {
t.Fatalf("expected handoff to use confirmation result, got %#v", handoff.Data.InputsValues["confirmed"])
}
}
func TestAIWorkflowServiceDefaultAgentWorkflowTicketPromptIncludesDraftFields(t *testing.T) {
definition := AIWorkflowService.DefaultAgentWorkflowDefinition()
prompt := workflowNodeByID(t, definition, "ticket_confirm_prompt_1")
if _, ok := prompt.Data.InputsValues["ticketTitle"]; !ok {
t.Fatalf("expected ticket confirm prompt to map ticketTitle")
}
if _, ok := prompt.Data.InputsValues["ticketDescription"]; !ok {
t.Fatalf("expected ticket confirm prompt to map ticketDescription")
}
config := map[string]any{}
if err := json.Unmarshal(prompt.Data.Config, &config); err != nil {
t.Fatalf("unmarshal prompt config: %v", err)
}
staticReply, _ := config["staticReply"].(string)
if !strings.Contains(staticReply, "{{ticketTitle}}") || !strings.Contains(staticReply, "{{ticketDescription}}") {
t.Fatalf("expected prompt template to include ticket title and description, got %q", staticReply)
}
}
func TestAIWorkflowServiceDefaultAgentWorkflowLayoutDoesNotOverlap(t *testing.T) {
definition := AIWorkflowService.DefaultAgentWorkflowDefinition()
assertWorkflowLayoutDoesNotOverlap(t, definition)
}
func TestAIWorkflowServicePublishAgentWorkflowBindsAgentVersion(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
aiConfigID := createAIAgentWorkflowTestConfig(t)
agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "workflow agent without version",
AIConfigID: aiConfigID,
ServiceMode: enums.IMConversationServiceModeAIOnly,
HandoffMode: enums.AIAgentHandoffModeWaitPool,
FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
workflow, err := AIWorkflowService.SaveAgentWorkflow(request.SaveAIWorkflowRequest{
AgentID: agent.ID,
Name: "After sales flow",
Description: "Support workflow",
Definition: validAIWorkflowDefinition(),
}, operator)
if err != nil {
t.Fatalf("SaveAgentWorkflow() error = %v", err)
}
version, err := AIWorkflowService.PublishAgentWorkflow(request.PublishAIWorkflowRequest{
AgentID: agent.ID,
Definition: validAIWorkflowDefinition(),
}, operator)
if err != nil {
t.Fatalf("PublishAgentWorkflow() error = %v", err)
}
if version.WorkflowID != workflow.ID {
t.Fatalf("expected version workflow id %d, got %d", workflow.ID, version.WorkflowID)
}
storedAgent := AIAgentService.Get(agent.ID)
if storedAgent == nil {
t.Fatalf("expected stored agent")
}
if storedAgent.WorkflowVersionID != version.ID {
t.Fatalf("expected agent workflow version %d, got %d", version.ID, storedAgent.WorkflowVersionID)
}
if storedAgent.PublishedRevisionID <= 0 {
t.Fatalf("expected published agent revision id, got %d", storedAgent.PublishedRevisionID)
}
var revision models.AgentRevision
if err := sqls.DB().First(&revision, storedAgent.PublishedRevisionID).Error; err != nil {
t.Fatalf("load agent revision: %v", err)
}
if revision.AgentID != agent.ID || revision.WorkflowVersionID != version.ID || revision.Revision != 1 || revision.DefinitionHash == "" {
t.Fatalf("unexpected published agent revision: %#v", revision)
}
if !strings.Contains(revision.Definition, `"modelName":"gpt-test"`) || strings.Contains(revision.Definition, "revision-test-secret") {
t.Fatalf("unexpected revision definition: %s", revision.Definition)
}
}
func TestAIAgentServiceBindsPublishedWorkflowVersionIndependently(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
workflow, err := AIWorkflowService.CreateWorkflow(request.CreateAIWorkflowRequest{Name: "共享建单流程", Definition: validAIWorkflowDefinition()}, operator)
if err != nil {
t.Fatalf("CreateWorkflow() error = %v", err)
}
version, err := AIWorkflowService.PublishWorkflow(request.PublishAIWorkflowRequest{WorkflowID: workflow.ID, Definition: validAIWorkflowDefinition()}, operator)
if err != nil {
t.Fatalf("PublishWorkflow() error = %v", err)
}
agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "绑定共享工作流的 Agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeHybrid,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
WorkflowBindings: []request.AIAgentWorkflowBindingRequest{{WorkflowVersionID: version.ID, ToolName: "创建工单", TriggerInstruction: "用户要求创建工单", Enabled: true}},
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
bindings := AIAgentService.ListWorkflowBindings(agent.ID)
if len(bindings) != 1 || bindings[0].Binding.WorkflowVersionID != version.ID || bindings[0].Workflow == nil || bindings[0].Workflow.AgentID != 0 {
t.Fatalf("unexpected independent workflow binding: %#v", bindings)
}
if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err != nil {
t.Fatalf("PublishAIAgent() error = %v", err)
}
stored := AIAgentService.Get(agent.ID)
snapshot, err := AgentRevisionService.ResolvePublishedSnapshot(*stored, *AIConfigService.Get(stored.AIConfigID))
if err != nil || len(snapshot.WorkflowBindings) != 1 || snapshot.WorkflowBindings[0].WorkflowVersionID != version.ID {
t.Fatalf("expected published workflow binding snapshot, snapshot=%#v err=%v", snapshot, err)
}
}
func setupAIAgentWorkflowTestDB(t *testing.T) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite db: %v", err)
}
if err := db.AutoMigrate(&models.AIAgent{}, &models.AIConfig{}, &models.KnowledgeBase{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}, &models.AIAgentWorkflowBinding{}, &models.AgentRevision{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
}
func createAIAgentWorkflowTestConfig(t *testing.T) int64 {
t.Helper()
item := &models.AIConfig{
Name: "workflow-test-config",
Provider: enums.AIProviderOpenAI,
APIKey: "revision-test-secret",
ModelType: enums.AIModelTypeLLM,
ModelName: "gpt-test",
Status: enums.StatusOk,
}
if err := sqls.DB().Create(item).Error; err != nil {
t.Fatalf("create ai config: %v", err)
}
return item.ID
}
func createAIAgentWorkflowTestKnowledgeBase(t *testing.T) int64 {
t.Helper()
item := &models.KnowledgeBase{
Name: "workflow-test-kb",
KnowledgeType: string(enums.KnowledgeBaseTypeFAQ),
Status: enums.StatusOk,
}
if err := sqls.DB().Create(item).Error; err != nil {
t.Fatalf("create knowledge base: %v", err)
}
return item.ID
}
func createAIAgentWorkflowVersion(t *testing.T) int64 {
t.Helper()
workflow := &models.AIWorkflow{
Name: "workflow-test",
AgentID: 1,
Status: enums.StatusOk,
}
if err := sqls.DB().Create(workflow).Error; err != nil {
t.Fatalf("create workflow: %v", err)
}
version := &models.AIWorkflowVersion{
WorkflowID: workflow.ID,
Version: 1,
Status: enums.StatusOk,
}
if err := sqls.DB().Create(version).Error; err != nil {
t.Fatalf("create workflow version: %v", err)
}
return version.ID
}
func aiAgentWorkflowTestOperator() *dto.AuthPrincipal {
return &dto.AuthPrincipal{
UserID: 1,
Username: "agent-workflow-tester",
Nickname: "agent-workflow-tester",
}
}
func workflowHasNodeType(def dsl.Definition, nodeType string) bool {
for _, node := range def.Nodes {
if node.Type == nodeType {
return true
}
}
return false
}
func workflowValidationHasMessage(result workflowvalidator.Result, message string) bool {
for _, item := range result.Errors {
if strings.Contains(item.Message, message) {
return true
}
}
return false
}
func nodeTypeByID(def dsl.Definition, nodeID string) string {
for _, node := range def.Nodes {
if node.ID == nodeID {
return node.Type
}
}
return ""
}
func workflowNodeByID(t *testing.T, def dsl.Definition, nodeID string) dsl.Node {
t.Helper()
for _, node := range def.Nodes {
if node.ID == nodeID {
return node
}
}
t.Fatalf("workflow node not found: %s", nodeID)
return dsl.Node{}
}
func assertConditionBranchToNodeType(t *testing.T, def dsl.Definition, sourceID string, targetType string, operator string, right any) {
t.Helper()
nodeTypes := workflowNodeTypeMap(def)
for _, branch := range conditionBranches(t, def, sourceID) {
if nodeTypes[branch.TargetNodeID] != targetType || branch.Condition == nil {
continue
}
if branch.Condition.Operator == operator && branch.Condition.Right == right {
return
}
}
t.Fatalf("expected %s condition branch from %s to %s with right=%v", operator, sourceID, targetType, right)
}
func assertConditionBranchToNodeID(t *testing.T, def dsl.Definition, sourceID string, targetID string, operator string, right any) {
t.Helper()
for _, branch := range conditionBranches(t, def, sourceID) {
if branch.TargetNodeID != targetID || branch.Condition == nil {
continue
}
if branch.Condition.Operator == operator && branch.Condition.Right == right {
return
}
}
t.Fatalf("expected %s condition branch from %s to %s with right=%v", operator, sourceID, targetID, right)
}
func assertDefaultBranchToNodeID(t *testing.T, def dsl.Definition, sourceID string, targetID string) {
t.Helper()
for _, branch := range conditionBranches(t, def, sourceID) {
if branch.TargetNodeID == targetID && branch.Default {
return
}
}
t.Fatalf("expected default branch from %s to %s", sourceID, targetID)
}
func conditionBranches(t *testing.T, def dsl.Definition, nodeID string) []dsl.ConditionBranch {
t.Helper()
for _, node := range def.Nodes {
if node.ID != nodeID {
continue
}
var config dsl.ConditionConfig
if err := json.Unmarshal(node.Data.Config, &config); err != nil {
t.Fatalf("unmarshal condition config for %s: %v", nodeID, err)
}
return config.Branches
}
t.Fatalf("condition node not found: %s", nodeID)
return nil
}
func assertConditionBranchesHavePortEdges(t *testing.T, def dsl.Definition, nodeID string) {
t.Helper()
for _, branch := range conditionBranches(t, def, nodeID) {
if !workflowPortEdgeExists(def, nodeID, branch.TargetNodeID, branch.ID) {
t.Fatalf("expected condition branch %s.%s to have port edge to %s", nodeID, branch.ID, branch.TargetNodeID)
}
}
}
func assertConditionBranchOrder(t *testing.T, def dsl.Definition, nodeID string, want []string) {
t.Helper()
branches := conditionBranches(t, def, nodeID)
if len(branches) != len(want) {
t.Fatalf("expected %s branch order %v, got %#v", nodeID, want, branches)
}
for index, branch := range branches {
if branch.ID != want[index] {
t.Fatalf("expected %s branch order %v, got branch %d = %s", nodeID, want, index, branch.ID)
}
}
}
func assertConditionPortEdgeOrder(t *testing.T, def dsl.Definition, nodeID string, want []string) {
t.Helper()
got := make([]string, 0, len(want))
for _, edge := range def.Edges {
if edge.SourceNodeID == nodeID {
got = append(got, edge.SourcePortID)
}
}
if len(got) != len(want) {
t.Fatalf("expected %s port edge order %v, got %v", nodeID, want, got)
}
for index, sourcePortID := range got {
if sourcePortID != want[index] {
t.Fatalf("expected %s port edge order %v, got edge %d = %s", nodeID, want, index, sourcePortID)
}
}
}
func workflowPortEdgeExists(def dsl.Definition, sourceID string, targetID string, sourcePortID string) bool {
for _, edge := range def.Edges {
if edge.SourceNodeID == sourceID && edge.TargetNodeID == targetID && edge.SourcePortID == sourcePortID {
return true
}
}
return false
}
func workflowEdgeExists(def dsl.Definition, sourceID string, targetID string) bool {
for _, edge := range def.Edges {
if edge.SourceNodeID == sourceID && edge.TargetNodeID == targetID {
return true
}
}
return false
}
type workflowLayoutBox struct {
NodeID string
Left float64
Top float64
Right float64
Bottom float64
}
func assertWorkflowLayoutDoesNotOverlap(t *testing.T, def dsl.Definition) {
t.Helper()
boxes := make([]workflowLayoutBox, 0, len(def.Nodes))
for _, node := range def.Nodes {
width, height := defaultWorkflowNodeRenderSize(node.Type)
boxes = append(boxes, workflowLayoutBox{
NodeID: node.ID,
Left: node.Meta.Position.X,
Top: node.Meta.Position.Y,
Right: node.Meta.Position.X + width,
Bottom: node.Meta.Position.Y + height,
})
}
const minGap = 32.0
for i := range boxes {
for j := i + 1; j < len(boxes); j++ {
if workflowBoxesOverlapWithGap(boxes[i], boxes[j], minGap) {
t.Fatalf("default workflow nodes are too close or overlapping: %s=%+v %s=%+v", boxes[i].NodeID, boxes[i], boxes[j].NodeID, boxes[j])
}
}
}
}
func defaultWorkflowNodeRenderSize(nodeType string) (float64, float64) {
if nodeType == workflowregistry.NodeTypeCondition {
return 160, 160
}
return 220, 128
}
func workflowBoxesOverlapWithGap(a workflowLayoutBox, b workflowLayoutBox, gap float64) bool {
return a.Left < b.Right+gap && a.Right+gap > b.Left && a.Top < b.Bottom+gap && a.Bottom+gap > b.Top
}
func workflowNodeTypeMap(def dsl.Definition) map[string]string {
ret := make(map[string]string, len(def.Nodes))
for _, node := range def.Nodes {
ret[node.ID] = node.Type
}
return ret
}
+12 -12
View File
@@ -163,16 +163,16 @@ func (s *aiWorkflowService) DefaultAgentWorkflowDefinition() dsl.Definition {
return defaultAgentWorkflowDefinition() return defaultAgentWorkflowDefinition()
} }
func (s *aiWorkflowService) ListPlaybookTemplates() []AIWorkflowTemplate { func (s *aiWorkflowService) ListWorkflowTemplates() []AIWorkflowTemplate {
return []AIWorkflowTemplate{ return []AIWorkflowTemplate{
{Code: "ticket-with-confirmation", Name: "创建工单", Description: "整理工单草稿,经客户确认后创建工单。", Definition: ticketWithConfirmationPlaybookDefinition()}, {Code: "ticket-with-confirmation", Name: "创建工单", Description: "整理工单草稿,经客户确认后创建工单。", Definition: ticketWithConfirmationWorkflowDefinition()},
{Code: "identity-confirmation", Name: "身份确认", Description: "在执行后续业务前收集客户的明确确认。", Definition: identityConfirmationPlaybookDefinition()}, {Code: "identity-confirmation", Name: "身份确认", Description: "在执行后续业务前收集客户的明确确认。", Definition: identityConfirmationWorkflowDefinition()},
{Code: "complaint-escalation", Name: "投诉升级", Description: "投诉场景经客户确认后转入人工客服处理。", Definition: complaintEscalationPlaybookDefinition()}, {Code: "complaint-escalation", Name: "投诉升级", Description: "投诉场景经客户确认后转入人工客服处理。", Definition: complaintEscalationWorkflowDefinition()},
{Code: "refund-request-preparation", Name: "退款申请准备", Description: "整理退款诉求,确认后转人工继续核验和处理。", Definition: refundRequestPreparationPlaybookDefinition()}, {Code: "refund-request-preparation", Name: "退款申请准备", Description: "整理退款诉求,确认后转人工继续核验和处理。", Definition: refundRequestPreparationWorkflowDefinition()},
} }
} }
func ticketWithConfirmationPlaybookDefinition() dsl.Definition { func ticketWithConfirmationWorkflowDefinition() dsl.Definition {
return dsl.Definition{SchemaVersion: dsl.SchemaVersion, return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{ Nodes: []dsl.Node{
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil), workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
@@ -522,7 +522,7 @@ func legacyDefaultAgentWorkflowDefinition() dsl.Definition {
} }
} }
func identityConfirmationPlaybookDefinition() dsl.Definition { func identityConfirmationWorkflowDefinition() dsl.Definition {
return dsl.Definition{SchemaVersion: dsl.SchemaVersion, return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{ Nodes: []dsl.Node{
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil), workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
@@ -546,11 +546,11 @@ func identityConfirmationPlaybookDefinition() dsl.Definition {
} }
} }
func complaintEscalationPlaybookDefinition() dsl.Definition { func complaintEscalationWorkflowDefinition() dsl.Definition {
return confirmationHandoffPlaybookDefinition("投诉升级确认", "我们将把本次投诉升级给人工客服处理。请确认是否继续。", "已为你升级投诉,人工客服会尽快跟进。", "投诉升级已取消。") return confirmationHandoffWorkflowDefinition("投诉升级确认", "我们将把本次投诉升级给人工客服处理。请确认是否继续。", "已为你升级投诉,人工客服会尽快跟进。", "投诉升级已取消。")
} }
func confirmationHandoffPlaybookDefinition(title, prompt, confirmedReply, cancelledReply string) dsl.Definition { func confirmationHandoffWorkflowDefinition(title, prompt, confirmedReply, cancelledReply string) dsl.Definition {
return dsl.Definition{SchemaVersion: dsl.SchemaVersion, return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{ Nodes: []dsl.Node{
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil), workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
@@ -574,8 +574,8 @@ func confirmationHandoffPlaybookDefinition(title, prompt, confirmedReply, cancel
} }
} }
func refundRequestPreparationPlaybookDefinition() dsl.Definition { func refundRequestPreparationWorkflowDefinition() dsl.Definition {
return confirmationHandoffPlaybookDefinition("退款申请确认", "我会先整理退款申请并转交人工客服核验。请确认是否继续。", "退款申请已准备完成,人工客服将继续核验订单和退款条件。", "退款申请准备已取消。") return confirmationHandoffWorkflowDefinition("退款申请确认", "我会先整理退款申请并转交人工客服核验。请确认是否继续。", "退款申请已准备完成,人工客服将继续核验订单和退款条件。", "退款申请准备已取消。")
} }
func workflowNode(id string, nodeType string, title string, x float64, y float64, inputs map[string]dsl.Value, config any) dsl.Node { func workflowNode(id string, nodeType string, title string, x float64, y float64, inputs map[string]dsl.Value, config any) dsl.Node {
@@ -103,8 +103,8 @@ func TestAIWorkflowServicePublishCreatesImmutableVersion(t *testing.T) {
} }
} }
func TestAIWorkflowServicePlaybookTemplatesAreValid(t *testing.T) { func TestAIWorkflowServiceWorkflowTemplatesAreValid(t *testing.T) {
templates := AIWorkflowService.ListPlaybookTemplates() templates := AIWorkflowService.ListWorkflowTemplates()
if len(templates) != 4 { if len(templates) != 4 {
t.Fatalf("template count = %d, want 4", len(templates)) t.Fatalf("template count = %d, want 4", len(templates))
} }
+2 -2
View File
@@ -14,8 +14,8 @@ import (
) )
// BusinessToolExecutor is the write boundary for built-in business tools. // BusinessToolExecutor is the write boundary for built-in business tools.
// Autonomous mode deliberately does not expose it; deterministic Playbooks // AgentDesk services remain the write boundary. Workflow nodes invoke this
// invoke it only after their human-confirm node has completed. // executor only after their human-confirm node has completed.
var BusinessToolExecutor = newBusinessToolExecutor(aitooling.DefaultRegistry) var BusinessToolExecutor = newBusinessToolExecutor(aitooling.DefaultRegistry)
type BusinessToolInput struct { type BusinessToolInput struct {
+2 -14
View File
@@ -436,20 +436,8 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe
if aiAgent == nil || aiAgent.Status != enums.StatusOk { if aiAgent == nil || aiAgent.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0004") return nil, errorsx.InvalidParamI18n("error.e0004")
} }
if aiAgent.RuntimeMode == "" || aiAgent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow { if aiAgent.PublishedRevisionID <= 0 {
if len(AIAgentService.ListEnabledWorkflowBindings(sqls.DB(), aiAgent.ID)) != 1 { return nil, errorsx.InvalidParam("ai agent must be published before binding channel")
return nil, errorsx.InvalidParam("ai agent workflow must be published before binding channel")
}
} else if aiAgent.RuntimeMode == enums.AIAgentRuntimeModeAutonomous {
if aiAgent.PublishedRevisionID <= 0 {
return nil, errorsx.InvalidParam("autonomous ai agent must be published before binding channel")
}
} else if aiAgent.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
if aiAgent.PublishedRevisionID <= 0 {
return nil, errorsx.InvalidParam("hybrid ai agent and workflow must be published before binding channel")
}
} else {
return nil, errorsx.InvalidParam("ai agent runtime mode is not available yet")
} }
status := enums.Status(req.Status) status := enums.Status(req.Status)
if req.Status == 0 { if req.Status == 0 {
+6 -75
View File
@@ -15,7 +15,7 @@ import (
"gorm.io/gorm/schema" "gorm.io/gorm/schema"
) )
func TestChannelServiceRejectsAgentWithoutPublishedWorkflow(t *testing.T) { func TestChannelServiceRejectsAgentWithoutPublishedRevision(t *testing.T) {
db := setupChannelServiceTestDB(t) db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 0) agent := createChannelServiceTestAgent(t, db, 0)
@@ -30,7 +30,7 @@ func TestChannelServiceRejectsAgentWithoutPublishedWorkflow(t *testing.T) {
} }
} }
func TestChannelServiceAllowsAgentWithPublishedWorkflow(t *testing.T) { func TestChannelServiceAllowsAgentWithPublishedRevision(t *testing.T) {
db := setupChannelServiceTestDB(t) db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 1001) agent := createChannelServiceTestAgent(t, db, 1001)
@@ -96,75 +96,6 @@ func TestChannelServiceRollsBackPreviousAIAgentRolloutPercent(t *testing.T) {
} }
} }
func TestChannelServiceRejectsUnpublishedAutonomousRuntime(t *testing.T) {
db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 1001)
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("runtime_mode", enums.AIAgentRuntimeModeAutonomous).Error; err != nil {
t.Fatalf("set autonomous runtime mode: %v", err)
}
_, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb,
AIAgentID: agent.ID,
Name: "官网客服",
Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err == nil || !strings.Contains(err.Error(), "must be published") {
t.Fatalf("expected unpublished autonomous runtime error, got %v", err)
}
}
func TestChannelServiceAcceptsPublishedAutonomousRuntime(t *testing.T) {
db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 1001)
revision := &models.AgentRevision{AgentID: agent.ID, Revision: 1, Status: enums.StatusOk}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create agent revision: %v", err)
}
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Updates(map[string]any{
"runtime_mode": enums.AIAgentRuntimeModeAutonomous,
"published_revision_id": revision.ID,
}).Error; err != nil {
t.Fatalf("set autonomous runtime mode: %v", err)
}
item, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "自主客服", Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err != nil || item == nil {
t.Fatalf("create channel for autonomous runtime: item=%#v err=%v", item, err)
}
}
func TestChannelServiceRequiresBothHybridPublicationArtifacts(t *testing.T) {
db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 0)
revision := &models.AgentRevision{AgentID: agent.ID, Revision: 1, Status: enums.StatusOk}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create agent revision: %v", err)
}
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Updates(map[string]any{
"runtime_mode": enums.AIAgentRuntimeModeHybrid,
"published_revision_id": revision.ID,
}).Error; err != nil {
t.Fatalf("set hybrid runtime mode: %v", err)
}
_, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "混合客服", Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err == nil || !strings.Contains(err.Error(), "hybrid ai agent") {
t.Fatalf("expected hybrid publication error, got %v", err)
}
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("workflow_version_id", 1001).Error; err != nil {
t.Fatalf("set workflow version: %v", err)
}
item, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "混合客服已发布", Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err != nil || item == nil {
t.Fatalf("create channel for hybrid runtime: item=%#v err=%v", item, err)
}
}
func setupChannelServiceTestDB(t *testing.T) *gorm.DB { func setupChannelServiceTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
@@ -190,12 +121,12 @@ func setupChannelServiceTestDB(t *testing.T) *gorm.DB {
return db return db
} }
func createChannelServiceTestAgent(t *testing.T, db *gorm.DB, workflowVersionID int64) models.AIAgent { func createChannelServiceTestAgent(t *testing.T, db *gorm.DB, publishedRevisionID int64) models.AIAgent {
t.Helper() t.Helper()
item := models.AIAgent{ item := models.AIAgent{
Name: "测试 AI", Name: "测试 AI",
Status: enums.StatusOk, Status: enums.StatusOk,
WorkflowVersionID: workflowVersionID, PublishedRevisionID: publishedRevisionID,
} }
if err := db.Create(&item).Error; err != nil { if err := db.Create(&item).Error; err != nil {
t.Fatalf("create ai agent: %v", err) t.Fatalf("create ai agent: %v", err)
+3 -3
View File
@@ -70,8 +70,8 @@ func (s *dashboardService) GetOverview(rangeValue string, locale string) respons
knowledgeRetrieveFailCount := repositories.DashboardRepository.CountKnowledgeRetrieveLogs(db, func(tx *gorm.DB) *gorm.DB { knowledgeRetrieveFailCount := repositories.DashboardRepository.CountKnowledgeRetrieveLogs(db, func(tx *gorm.DB) *gorm.DB {
return tx.Where("created_at >= ? AND answer_status IN ?", todayStart, []int{2, 3, 4}) return tx.Where("created_at >= ? AND answer_status IN ?", todayStart, []int{2, 3, 4})
}) })
skillRunFailCount := repositories.DashboardRepository.CountSkillRunLogs(db, func(tx *gorm.DB) *gorm.DB { agentRunFailCount := repositories.DashboardRepository.CountAgentRuns(db, func(tx *gorm.DB) *gorm.DB {
return tx.Where("created_at >= ? AND error_message <> ''", todayStart) return tx.Where("created_at >= ? AND status = ?", todayStart, "failed")
}) })
aiHandoffCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { aiHandoffCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB {
return tx.Where("handoff_at >= ?", todayStart) return tx.Where("handoff_at >= ?", todayStart)
@@ -108,7 +108,7 @@ func (s *dashboardService) GetOverview(rangeValue string, locale string) respons
TodayKnowledgeRetrieves: knowledgeRetrieveCount, TodayKnowledgeRetrieves: knowledgeRetrieveCount,
TodayKnowledgeRetrieveFailCount: knowledgeRetrieveFailCount, TodayKnowledgeRetrieveFailCount: knowledgeRetrieveFailCount,
TodayKnowledgeRetrieveFailRate: calcRate(knowledgeRetrieveFailCount, knowledgeRetrieveCount), TodayKnowledgeRetrieveFailRate: calcRate(knowledgeRetrieveFailCount, knowledgeRetrieveCount),
TodaySkillRunFailCount: skillRunFailCount, TodayAgentRunFailCount: agentRunFailCount,
TodayAIHandoffCount: aiHandoffCount, TodayAIHandoffCount: aiHandoffCount,
}, },
Alerts: alerts, Alerts: alerts,
@@ -1,67 +0,0 @@
package services
import (
"agent-desk/internal/models"
"agent-desk/internal/repositories"
"agent-desk/internal/pkg/httpx/params"
"github.com/mlogclub/simple/sqls"
)
var SkillRunLogService = newSkillRunLogService()
func newSkillRunLogService() *skillRunLogService {
return &skillRunLogService{}
}
type skillRunLogService struct {
}
func (s *skillRunLogService) Get(id int64) *models.SkillRunLog {
return repositories.SkillRunLogRepository.Get(sqls.DB(), id)
}
func (s *skillRunLogService) Take(where ...interface{}) *models.SkillRunLog {
return repositories.SkillRunLogRepository.Take(sqls.DB(), where...)
}
func (s *skillRunLogService) Find(cnd *sqls.Cnd) []models.SkillRunLog {
return repositories.SkillRunLogRepository.Find(sqls.DB(), cnd)
}
func (s *skillRunLogService) FindOne(cnd *sqls.Cnd) *models.SkillRunLog {
return repositories.SkillRunLogRepository.FindOne(sqls.DB(), cnd)
}
func (s *skillRunLogService) FindPageByParams(params *params.QueryParams) (list []models.SkillRunLog, paging *sqls.Paging) {
return repositories.SkillRunLogRepository.FindPageByParams(sqls.DB(), params)
}
func (s *skillRunLogService) FindPageByCnd(cnd *sqls.Cnd) (list []models.SkillRunLog, paging *sqls.Paging) {
return repositories.SkillRunLogRepository.FindPageByCnd(sqls.DB(), cnd)
}
func (s *skillRunLogService) Count(cnd *sqls.Cnd) int64 {
return repositories.SkillRunLogRepository.Count(sqls.DB(), cnd)
}
func (s *skillRunLogService) Create(t *models.SkillRunLog) error {
return repositories.SkillRunLogRepository.Create(sqls.DB(), t)
}
func (s *skillRunLogService) Update(t *models.SkillRunLog) error {
return repositories.SkillRunLogRepository.Update(sqls.DB(), t)
}
func (s *skillRunLogService) Updates(id int64, columns map[string]interface{}) error {
return repositories.SkillRunLogRepository.Updates(sqls.DB(), id, columns)
}
func (s *skillRunLogService) UpdateColumn(id int64, name string, value interface{}) error {
return repositories.SkillRunLogRepository.UpdateColumn(sqls.DB(), id, name, value)
}
func (s *skillRunLogService) Delete(id int64) {
repositories.SkillRunLogRepository.Delete(sqls.DB(), id)
}
@@ -138,9 +138,9 @@ export function DashboardHome() {
</div> </div>
</div> </div>
<div className="rounded-md border bg-background px-3 py-2.5"> <div className="rounded-md border bg-background px-3 py-2.5">
<div className="text-sm text-muted-foreground">{t("dashboardHome.todaySkillRunFailCount")}</div> <div className="text-sm text-muted-foreground">{t("dashboardHome.todayAgentRunFailCount")}</div>
<div className="mt-1 text-2xl font-semibold"> <div className="mt-1 text-2xl font-semibold">
{data.aiStats.todaySkillRunFailCount} {data.aiStats.todayAgentRunFailCount}
</div> </div>
</div> </div>
<div className="rounded-md border bg-background px-3 py-2.5"> <div className="rounded-md border bg-background px-3 py-2.5">
+4 -9
View File
@@ -11,7 +11,7 @@ import { ProjectDialog } from "@/components/project-dialog"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { fetchAgentRun, fetchAgentRunMetrics, fetchAgentRuns, fetchAIWorkflowRun, fetchAgentRunEngineComparisons, saveAgentRunQualityFeedback, type AgentRun, type AgentRunEngineComparison, type AgentRunMetrics, type AgentStep, type AgentToolCall, type AIWorkflowRun } from "@/lib/api/admin" import { fetchAgentRun, fetchAgentRunMetrics, fetchAgentRuns, fetchAIWorkflowRun, saveAgentRunQualityFeedback, type AgentRun, type AgentRunMetrics, type AgentStep, type AgentToolCall, type AIWorkflowRun } from "@/lib/api/admin"
import { formatDateTime } from "@/lib/utils" import { formatDateTime } from "@/lib/utils"
import { useI18n } from "@/i18n/provider" import { useI18n } from "@/i18n/provider"
import { WorkflowRunAuditGraph } from "../ai-workflow-runs/_components/workflow-run-audit-graph" import { WorkflowRunAuditGraph } from "../ai-workflow-runs/_components/workflow-run-audit-graph"
@@ -32,11 +32,9 @@ export default function DashboardAgentRunsPage() {
const [workflowAuditLoading, setWorkflowAuditLoading] = useState(false) const [workflowAuditLoading, setWorkflowAuditLoading] = useState(false)
const [workflowRun, setWorkflowRun] = useState<AIWorkflowRun | null>(null) const [workflowRun, setWorkflowRun] = useState<AIWorkflowRun | null>(null)
const [metrics, setMetrics] = useState<AgentRunMetrics | null>(null) const [metrics, setMetrics] = useState<AgentRunMetrics | null>(null)
const [comparisons, setComparisons] = useState<AgentRunEngineComparison[]>([])
useEffect(() => { useEffect(() => {
void fetchAgentRunMetrics().then(setMetrics).catch(() => setMetrics(null)) void fetchAgentRunMetrics().then(setMetrics).catch(() => setMetrics(null))
void fetchAgentRunEngineComparisons().then(setComparisons).catch(() => setComparisons([]))
}, []) }, [])
async function openDetail(id: number) { async function openDetail(id: number) {
@@ -80,12 +78,10 @@ export default function DashboardAgentRunsPage() {
<Metric label="知识兜底率" value={`${Math.round(metrics.knowledgeFallbackRate * 100)}%`} detail="证据不足或检索失败" /> <Metric label="知识兜底率" value={`${Math.round(metrics.knowledgeFallbackRate * 100)}%`} detail="证据不足或检索失败" />
<Metric label="中断恢复率" value={metrics.resumedInterrupts ? `${Math.round(metrics.interruptRecoveryRate * 100)}%` : "-"} detail={`${metrics.resolvedInterrupts}/${metrics.resumedInterrupts}`} /> <Metric label="中断恢复率" value={metrics.resumedInterrupts ? `${Math.round(metrics.interruptRecoveryRate * 100)}%` : "-"} detail={`${metrics.resolvedInterrupts}/${metrics.resumedInterrupts}`} />
</div> : null} </div> : null}
{comparisons.length > 0 ? <section className="border-b"><div className="px-4 py-3 text-sm font-medium"></div><div className="overflow-x-auto"><table className="w-full min-w-[760px] text-sm"><thead className="border-y bg-muted/30 text-left text-xs text-muted-foreground"><tr><th className="px-4 py-2 font-medium"></th><th className="px-4 py-2 text-right font-medium"></th><th className="px-4 py-2 text-right font-medium"></th><th className="px-4 py-2 text-right font-medium"></th><th className="px-4 py-2 text-right font-medium"></th><th className="px-4 py-2 text-right font-medium"></th><th className="px-4 py-2 text-right font-medium">P95</th><th className="px-4 py-2 text-right font-medium">Token</th></tr></thead><tbody>{comparisons.map((item) => <tr key={item.engineCode} className="border-b last:border-0"><td className="px-4 py-2 font-medium">{item.engineCode}</td><td className="px-4 py-2 text-right">{item.metrics.totalRuns}</td><td className="px-4 py-2 text-right">{Math.round(item.metrics.completionRate * 100)}%</td><td className="px-4 py-2 text-right">{item.metrics.reviewedRuns ? `${Math.round(item.metrics.resolutionRate * 100)}%` : "-"}</td><td className="px-4 py-2 text-right">{item.metrics.reviewedRuns ? `${Math.round(item.metrics.unsupportedEvidenceRate * 100)}%` : "-"}</td><td className="px-4 py-2 text-right">{item.metrics.toolCalls ? `${Math.round(item.metrics.toolSuccessRate * 100)}%` : "-"}</td><td className="px-4 py-2 text-right">{item.metrics.p95DurationMs} ms</td><td className="px-4 py-2 text-right">{item.metrics.promptTokens + item.metrics.completionTokens}</td></tr>)}</tbody></table></div></section> : null}
<DashboardListPage<AgentRun> <DashboardListPage<AgentRun>
filters={[ filters={[
{ name: "conversationId", label: t("agentRun.conversation"), defaultValue: "", valueType: "number", className: "w-full sm:w-40" }, { name: "conversationId", label: t("agentRun.conversation"), defaultValue: "", valueType: "number", className: "w-full sm:w-40" },
{ name: "aiAgentId", label: t("agentRun.agent"), defaultValue: "", valueType: "number", className: "w-full sm:w-40" }, { name: "aiAgentId", label: t("agentRun.agent"), defaultValue: "", valueType: "number", className: "w-full sm:w-40" },
{ name: "engineCode", label: t("agentRun.engine"), defaultValue: "", className: "w-full sm:w-40" },
{ name: "status", label: t("agentRun.status"), defaultValue: "", className: "w-full sm:w-40" }, { name: "status", label: t("agentRun.status"), defaultValue: "", className: "w-full sm:w-40" },
]} ]}
fetchList={fetchAgentRuns} fetchList={fetchAgentRuns}
@@ -94,7 +90,6 @@ export default function DashboardAgentRunsPage() {
onRowClick={(item) => void openDetail(item.id)} onRowClick={(item) => void openDetail(item.id)}
columns={[ columns={[
{ key: "startedAt", label: t("agentRun.startedAt"), className: "w-42 text-xs text-muted-foreground", render: (item) => formatDateTime(item.startedAt || item.createdAt) }, { key: "startedAt", label: t("agentRun.startedAt"), className: "w-42 text-xs text-muted-foreground", render: (item) => formatDateTime(item.startedAt || item.createdAt) },
{ key: "engine", label: t("agentRun.engine"), className: "w-32", render: (item) => item.engineCode || "-" },
{ key: "agent", label: t("agentRun.agent"), className: "w-28", render: (item) => `#${item.aiAgentId || "-"}` }, { key: "agent", label: t("agentRun.agent"), className: "w-28", render: (item) => `#${item.aiAgentId || "-"}` },
{ key: "conversation", label: t("agentRun.conversation"), className: "w-28", render: (item) => `#${item.conversationId || "-"}` }, { key: "conversation", label: t("agentRun.conversation"), className: "w-28", render: (item) => `#${item.conversationId || "-"}` },
{ key: "status", label: t("agentRun.status"), className: "w-30", render: (item) => <Badge variant={statusVariant(item.status)}>{item.status || "-"}</Badge> }, { key: "status", label: t("agentRun.status"), className: "w-30", render: (item) => <Badge variant={statusVariant(item.status)}>{item.status || "-"}</Badge> },
@@ -115,8 +110,8 @@ function Metric({ label, value, detail }: { label: string; value: string; detail
function AgentRunDetailDialog({ open, loading, run, onOpenChange, onOpenWorkflowAudit, onQualityFeedbackSaved, t }: { open: boolean; loading: boolean; run: AgentRun | null; onOpenChange: (open: boolean) => void; onOpenWorkflowAudit: (workflowRunId: number) => void; onQualityFeedbackSaved: (agentRunId: number) => void; t: (key: string) => string }) { function AgentRunDetailDialog({ open, loading, run, onOpenChange, onOpenWorkflowAudit, onQualityFeedbackSaved, t }: { open: boolean; loading: boolean; run: AgentRun | null; onOpenChange: (open: boolean) => void; onOpenWorkflowAudit: (workflowRunId: number) => void; onQualityFeedbackSaved: (agentRunId: number) => void; t: (key: string) => string }) {
return <ProjectDialog open={open} onOpenChange={onOpenChange} size="xl" allowFullscreen defaultFullscreen title={<span className="flex items-center gap-2"><BotMessageSquareIcon className="size-4" />{t("agentRun.detailTitle")}</span>} description={run ? `Run #${run.id}` : t("agentRun.detailDescription")} footer={<Button variant="outline" onClick={() => onOpenChange(false)}>{t("agentRun.close")}</Button>}> return <ProjectDialog open={open} onOpenChange={onOpenChange} size="xl" allowFullscreen defaultFullscreen title={<span className="flex items-center gap-2"><BotMessageSquareIcon className="size-4" />{t("agentRun.detailTitle")}</span>} description={run ? `Run #${run.id}` : t("agentRun.detailDescription")} footer={<Button variant="outline" onClick={() => onOpenChange(false)}>{t("agentRun.close")}</Button>}>
{loading ? <div className="py-10 text-sm text-muted-foreground">{t("agentRun.loadingDetail")}</div> : run ? <div className="space-y-4"> {loading ? <div className="py-10 text-sm text-muted-foreground">{t("agentRun.loadingDetail")}</div> : run ? <div className="space-y-4">
<div className="flex flex-wrap gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs"><Meta label={t("agentRun.engine")} value={run.engineCode} /><Meta label={t("agentRun.status")} value={run.status} /><Meta label={t("agentRun.agent")} value={`#${run.aiAgentId}`} /><Meta label={t("agentRun.revision")} value={`#${run.agentRevisionId || "-"}`} /><Meta label={t("agentRun.duration")} value={`${run.durationMs || 0} ms`} /><Meta label={t("agentRun.tokens")} value={`${run.promptTokens || 0}/${run.completionTokens || 0}`} /></div> <div className="flex flex-wrap gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs"><Meta label={t("agentRun.status")} value={run.status} /><Meta label={t("agentRun.agent")} value={`#${run.aiAgentId}`} /><Meta label={t("agentRun.revision")} value={`#${run.agentRevisionId || "-"}`} /><Meta label={t("agentRun.duration")} value={`${run.durationMs || 0} ms`} /><Meta label={t("agentRun.tokens")} value={`${run.promptTokens || 0}/${run.completionTokens || 0}`} /></div>
{run.workflowRunId > 0 ? <section className="flex items-center justify-between gap-3 border px-3 py-2"><div><div className="text-sm font-medium"> Playbook </div><div className="text-xs text-muted-foreground">Workflow Run #{run.workflowRunId} </div></div><Button type="button" variant="outline" size="sm" onClick={() => onOpenWorkflowAudit(run.workflowRunId)}><WorkflowIcon /></Button></section> : null} {run.workflowRunId > 0 ? <section className="flex items-center justify-between gap-3 border px-3 py-2"><div><div className="text-sm font-medium"> Workflow </div><div className="text-xs text-muted-foreground">Workflow Run #{run.workflowRunId} </div></div><Button type="button" variant="outline" size="sm" onClick={() => onOpenWorkflowAudit(run.workflowRunId)}><WorkflowIcon /></Button></section> : null}
<QualityFeedbackPanel run={run} onSaved={onQualityFeedbackSaved} /> <QualityFeedbackPanel run={run} onSaved={onQualityFeedbackSaved} />
{run.errorMessage ? <div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangleIcon className="size-4 shrink-0" />{run.errorMessage}</div> : null} {run.errorMessage ? <div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangleIcon className="size-4 shrink-0" />{run.errorMessage}</div> : null}
<Preview title={t("agentRun.trace")} raw={run.traceData} /> <Preview title={t("agentRun.trace")} raw={run.traceData} />
@@ -155,7 +150,7 @@ function QualityFeedbackPanel({ run, onSaved }: { run: AgentRun; onSaved: (agent
} }
function WorkflowAuditDialog({ open, loading, run, onOpenChange }: { open: boolean; loading: boolean; run: AIWorkflowRun | null; onOpenChange: (open: boolean) => void }) { function WorkflowAuditDialog({ open, loading, run, onOpenChange }: { open: boolean; loading: boolean; run: AIWorkflowRun | null; onOpenChange: (open: boolean) => void }) {
return <ProjectDialog open={open} onOpenChange={onOpenChange} size="xl" allowFullscreen defaultFullscreen title={<span className="flex items-center gap-2"><WorkflowIcon className="size-4" />Workflow </span>} description={run ? `Workflow Run #${run.id}` : "加载关联 Playbook 的节点审计"} footer={<Button variant="outline" onClick={() => onOpenChange(false)}></Button>}> return <ProjectDialog open={open} onOpenChange={onOpenChange} size="xl" allowFullscreen defaultFullscreen title={<span className="flex items-center gap-2"><WorkflowIcon className="size-4" />Workflow </span>} description={run ? `Workflow Run #${run.id}` : "加载关联 Workflow 的节点审计"} footer={<Button variant="outline" onClick={() => onOpenChange(false)}></Button>}>
{loading ? <div className="py-10 text-sm text-muted-foreground">...</div> : run ? <div className="space-y-3"><div className="flex flex-wrap gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs"><Meta label="状态" value={run.statusName} /><Meta label="Workflow" value={run.workflowName || `#${run.workflowId}`} /><Meta label="版本" value={`v${run.workflowVersion || "-"}`} /><Meta label="时延" value={`${run.durationMs || 0} ms`} /></div>{run.errorMessage ? <div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangleIcon className="size-4 shrink-0" />{run.errorMessage}</div> : null}<WorkflowRunAuditGraph run={run} /></div> : <div className="py-10 text-sm text-muted-foreground"> Workflow Run</div>} {loading ? <div className="py-10 text-sm text-muted-foreground">...</div> : run ? <div className="space-y-3"><div className="flex flex-wrap gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs"><Meta label="状态" value={run.statusName} /><Meta label="Workflow" value={run.workflowName || `#${run.workflowId}`} /><Meta label="版本" value={`v${run.workflowVersion || "-"}`} /><Meta label="时延" value={`${run.durationMs || 0} ms`} /></div>{run.errorMessage ? <div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangleIcon className="size-4 shrink-0" />{run.errorMessage}</div> : null}<WorkflowRunAuditGraph run={run} /></div> : <div className="py-10 text-sm text-muted-foreground"> Workflow Run</div>}
</ProjectDialog> </ProjectDialog>
} }
@@ -7,7 +7,7 @@ const zhMessagesSource = await readFile(new URL("../../../../messages/zh-CN.json
const adminApiSource = await readFile(new URL("../../../../lib/api/admin.ts", import.meta.url), "utf8") const adminApiSource = await readFile(new URL("../../../../lib/api/admin.ts", import.meta.url), "utf8")
const zhMessages = JSON.parse(zhMessagesSource) const zhMessages = JSON.parse(zhMessagesSource)
test("AI Agent workflow-era policy copy separates handoff execution from knowledge fallback", () => { test("AI Agent policy copy separates handoff execution from knowledge fallback", () => {
const combinedSource = `${configWorkbenchSource}\n${zhMessagesSource}` const combinedSource = `${configWorkbenchSource}\n${zhMessagesSource}`
assert.match(combinedSource, /转人工执行方式/) assert.match(combinedSource, /转人工执行方式/)
@@ -31,3 +31,11 @@ test("AI Agent config no longer exposes legacy graph tool routing knobs", () =>
assert.doesNotMatch(aiAgentMessages, /Graph Tool/) assert.doesNotMatch(aiAgentMessages, /Graph Tool/)
assert.doesNotMatch(aiAgentMessages, /内置流程/) assert.doesNotMatch(aiAgentMessages, /内置流程/)
}) })
test("AI Agent config uses one Agent Loop without a runtime mode selector", () => {
assert.doesNotMatch(configWorkbenchSource, /runtimeMode/)
assert.doesNotMatch(adminApiSource, /runtimeMode/)
assert.doesNotMatch(configWorkbenchSource, /运行方式/)
assert.match(configWorkbenchSource, /Workflow 是 Agent 的可选能力/)
assert.match(configWorkbenchSource, /写操作(需确认)/)
})
@@ -61,7 +61,6 @@ import {
} from "@/lib/generated/enums" } from "@/lib/generated/enums"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type RuntimeMode = "workflow" | "autonomous" | "hybrid"
type SectionKey = "setup" | "persona" | "capability" | "service" type SectionKey = "setup" | "persona" | "capability" | "service"
type MCPToolItem = CreateAIAgentPayload["mcpTools"][number] type MCPToolItem = CreateAIAgentPayload["mcpTools"][number]
@@ -74,28 +73,6 @@ type MCPToolOption = {
meta: MCPToolItem meta: MCPToolItem
} }
const runtimeModes: {
value: RuntimeMode
title: string
description: string
}[] = [
{
value: "autonomous",
title: "自主接待",
description: "自主选择知识和工具处理请求,工作流可选。",
},
{
value: "hybrid",
title: "自主接待 + 工作流",
description: "自主处理咨询,按需调用一个或多个工作流。",
},
{
value: "workflow",
title: "仅工作流",
description: "严格按一个已发布工作流处理会话。",
},
]
function toText(value: string | number | undefined | null) { function toText(value: string | number | undefined | null) {
if (value === undefined || value === null || value === 0) return "" if (value === undefined || value === null || value === 0) return ""
return String(value) return String(value)
@@ -105,10 +82,6 @@ function uniqueNumbers(input: number[]) {
return Array.from(new Set(input.filter((id) => Number.isFinite(id) && id > 0))) return Array.from(new Set(input.filter((id) => Number.isFinite(id) && id > 0)))
} }
function isWorkflowPublished(agent: AIAgent | null) {
return Boolean(agent?.workflowPublished ?? (agent?.workflowVersionId ?? 0) > 0)
}
export function AIAgentConfigWorkbench({ export function AIAgentConfigWorkbench({
agentId, agentId,
onAgentSaved, onAgentSaved,
@@ -131,7 +104,6 @@ export function AIAgentConfigWorkbench({
const [name, setName] = useState("") const [name, setName] = useState("")
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [aiConfigId, setAIConfigId] = useState("") const [aiConfigId, setAIConfigId] = useState("")
const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>("autonomous")
const [serviceMode, setServiceMode] = useState(String(IMConversationServiceMode.AIFirst)) const [serviceMode, setServiceMode] = useState(String(IMConversationServiceMode.AIFirst))
const [systemPrompt, setSystemPrompt] = useState("") const [systemPrompt, setSystemPrompt] = useState("")
const [welcomeMessage, setWelcomeMessage] = useState("") const [welcomeMessage, setWelcomeMessage] = useState("")
@@ -186,7 +158,6 @@ export function AIAgentConfigWorkbench({
setName("") setName("")
setDescription("") setDescription("")
setAIConfigId("") setAIConfigId("")
setRuntimeMode("autonomous")
setServiceMode(String(IMConversationServiceMode.AIFirst)) setServiceMode(String(IMConversationServiceMode.AIFirst))
setSystemPrompt("") setSystemPrompt("")
setWelcomeMessage("") setWelcomeMessage("")
@@ -211,11 +182,6 @@ export function AIAgentConfigWorkbench({
setName(detail.name) setName(detail.name)
setDescription(detail.description || "") setDescription(detail.description || "")
setAIConfigId(toText(detail.aiConfigId)) setAIConfigId(toText(detail.aiConfigId))
setRuntimeMode(
detail.runtimeMode === "autonomous" || detail.runtimeMode === "hybrid"
? detail.runtimeMode
: "workflow",
)
setServiceMode(String(detail.serviceMode || IMConversationServiceMode.AIFirst)) setServiceMode(String(detail.serviceMode || IMConversationServiceMode.AIFirst))
setSystemPrompt(detail.systemPrompt || "") setSystemPrompt(detail.systemPrompt || "")
setWelcomeMessage(detail.welcomeMessage || "") setWelcomeMessage(detail.welcomeMessage || "")
@@ -315,6 +281,8 @@ export function AIAgentConfigWorkbench({
toolName: tool.toolName, toolName: tool.toolName,
title: tool.title || tool.toolName, title: tool.title || tool.toolName,
description: tool.description || "", description: tool.description || "",
riskLevel: "read",
requireConfirmation: false,
arguments: undefined, arguments: undefined,
}, },
})), })),
@@ -345,6 +313,8 @@ export function AIAgentConfigWorkbench({
function setMCPToolSelection(values: string[]) { function setMCPToolSelection(values: string[]) {
setMCPTools( setMCPTools(
values.flatMap((value) => { values.flatMap((value) => {
const current = mcpTools.find((item) => item.toolCode === value)
if (current) return [current]
const option = mcpToolOptions.find((item) => item.value === value) const option = mcpToolOptions.find((item) => item.value === value)
return option ? [option.meta] : [] return option ? [option.meta] : []
}), }),
@@ -352,12 +322,7 @@ export function AIAgentConfigWorkbench({
} }
function setWorkflowSelection(values: string[]) { function setWorkflowSelection(values: string[]) {
let selectedVersionIds = values.map(Number).filter((value) => value > 0) const selectedVersionIds = values.map(Number).filter((value) => value > 0)
if (runtimeMode === "workflow" && selectedVersionIds.length > 1) {
const currentIds = workflowBindings.map((binding) => binding.workflowVersionId)
const newlySelected = selectedVersionIds.find((id) => !currentIds.includes(id))
selectedVersionIds = [newlySelected ?? selectedVersionIds.at(-1)!]
}
setWorkflowBindings( setWorkflowBindings(
selectedVersionIds.flatMap((workflowVersionId, index) => { selectedVersionIds.flatMap((workflowVersionId, index) => {
const current = workflowBindings.find( const current = workflowBindings.find(
@@ -394,16 +359,6 @@ export function AIAgentConfigWorkbench({
toast.error("请选择 AI 配置") toast.error("请选择 AI 配置")
return false return false
} }
if (runtimeMode === "hybrid" && workflowBindings.length === 0) {
setActiveSection("capability")
toast.error("Hybrid 模式至少需要关联一个已发布工作流")
return false
}
if (runtimeMode === "workflow" && workflowBindings.length !== 1) {
setActiveSection("capability")
toast.error("仅工作流模式必须且只能关联一个已发布工作流")
return false
}
return true return true
} }
@@ -412,7 +367,6 @@ export function AIAgentConfigWorkbench({
name: name.trim(), name: name.trim(),
description: description.trim(), description: description.trim(),
aiConfigId: Number(aiConfigId), aiConfigId: Number(aiConfigId),
runtimeMode,
serviceMode: Number(serviceMode), serviceMode: Number(serviceMode),
systemPrompt: systemPrompt.trim(), systemPrompt: systemPrompt.trim(),
welcomeMessage: welcomeMessage.trim(), welcomeMessage: welcomeMessage.trim(),
@@ -454,7 +408,7 @@ export function AIAgentConfigWorkbench({
} }
async function publishAgent() { async function publishAgent() {
if (!agent || runtimeMode === "workflow") return if (!agent) return
if (!validateForm()) return if (!validateForm()) return
setSaving(true) setSaving(true)
try { try {
@@ -484,19 +438,7 @@ export function AIAgentConfigWorkbench({
} }
} }
const workflowPublished = isWorkflowPublished(agent) const agentPublished = (agent?.publishedRevisionId ?? 0) > 0
const autonomousPublished =
runtimeMode === "autonomous" && (agent?.publishedRevisionId ?? 0) > 0
const hybridPublished =
runtimeMode === "hybrid" &&
workflowPublished &&
(agent?.publishedRevisionId ?? 0) > 0
const runtimePublished =
runtimeMode === "workflow"
? workflowPublished
: runtimeMode === "hybrid"
? hybridPublished
: autonomousPublished
const sections: { const sections: {
key: SectionKey key: SectionKey
@@ -527,8 +469,8 @@ export function AIAgentConfigWorkbench({
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<h1 className="truncate text-base font-semibold">{agent?.name || "新建 AI Agent"}</h1> <h1 className="truncate text-base font-semibold">{agent?.name || "新建 AI Agent"}</h1>
{agent?.statusName ? <Badge variant="secondary">{agent.statusName}</Badge> : null} {agent?.statusName ? <Badge variant="secondary">{agent.statusName}</Badge> : null}
<Badge variant={runtimePublished ? "default" : "outline"}> <Badge variant={agentPublished ? "default" : "outline"}>
{runtimePublished ? "已发布" : agent ? "未发布" : "尚未创建"} {agentPublished ? "已发布" : agent ? "未发布" : "尚未创建"}
</Badge> </Badge>
</div> </div>
{agent ? ( {agent ? (
@@ -574,8 +516,8 @@ export function AIAgentConfigWorkbench({
</nav> </nav>
<div className="mt-auto flex items-center justify-between rounded-lg border bg-background p-3 text-xs"> <div className="mt-auto flex items-center justify-between rounded-lg border bg-background p-3 text-xs">
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span className={runtimePublished ? "font-medium text-emerald-600" : "font-medium text-amber-600"}> <span className={agentPublished ? "font-medium text-emerald-600" : "font-medium text-amber-600"}>
{runtimePublished ? "已发布" : agent ? "未发布" : "尚未创建"} {agentPublished ? "已发布" : agent ? "未发布" : "尚未创建"}
</span> </span>
</div> </div>
</aside> </aside>
@@ -610,39 +552,6 @@ export function AIAgentConfigWorkbench({
</div> </div>
</FormSection> </FormSection>
<FormSection
title="运行方式"
description="决定 Agent 是否自主处理请求,以及工作流的关联要求。"
>
<div className="grid gap-3 md:grid-cols-3">
{runtimeModes.map((mode) => (
<button
key={mode.value}
type="button"
onClick={() => setRuntimeMode(mode.value)}
className={cn(
"relative rounded-xl border p-4 text-left transition-colors hover:border-primary/40 hover:bg-primary/[0.02]",
runtimeMode === mode.value &&
"border-primary bg-primary/5 ring-1 ring-primary",
)}
>
<span
className={cn(
"absolute top-4 right-4 size-4 rounded-full border",
runtimeMode === mode.value
? "border-[5px] border-primary"
: "border-muted-foreground/40",
)}
/>
<strong className="block pr-6 text-sm">{mode.title}</strong>
<span className="mt-2 block pr-5 text-xs leading-5 text-muted-foreground">
{mode.description}
</span>
</button>
))}
</div>
</FormSection>
<FormSection <FormSection
title="模型与响应" title="模型与响应"
description="选择推理模型并设置单次回复的超时时间。" description="选择推理模型并设置单次回复的超时时间。"
@@ -750,11 +659,7 @@ export function AIAgentConfigWorkbench({
} }
> >
<div className="rounded-lg border border-blue-200 bg-blue-50 px-3.5 py-3 text-sm text-blue-900 dark:border-blue-900 dark:bg-blue-950/30 dark:text-blue-200"> <div className="rounded-lg border border-blue-200 bg-blue-50 px-3.5 py-3 text-sm text-blue-900 dark:border-blue-900 dark:bg-blue-950/30 dark:text-blue-200">
{runtimeMode === "autonomous" Workflow Agent
? "当前为自主接待模式,工作流是可选能力。"
: runtimeMode === "hybrid"
? "当前为 Hybrid 模式,至少关联一个已发布工作流后才能保存。"
: "当前为仅工作流模式,必须且只能关联一个已发布工作流。"}
</div> </div>
<OptionCombobox <OptionCombobox
multiple multiple
@@ -780,6 +685,67 @@ export function AIAgentConfigWorkbench({
emptyText="没有可用 MCP Tool" emptyText="没有可用 MCP Tool"
onValuesChange={setMCPToolSelection} onValuesChange={setMCPToolSelection}
/> />
{mcpTools.length > 0 ? (
<div className="space-y-2">
{mcpTools.map((tool) => (
<div
key={tool.toolCode}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{tool.title || tool.toolCode}
</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{tool.toolCode}
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant={tool.riskLevel === "read" ? "default" : "outline"}
onClick={() =>
setMCPTools((items) =>
items.map((item) =>
item.toolCode === tool.toolCode
? {
...item,
riskLevel: "read",
requireConfirmation: false,
}
: item,
),
)
}
>
</Button>
<Button
type="button"
size="sm"
variant={tool.riskLevel === "write" ? "destructive" : "outline"}
onClick={() =>
setMCPTools((items) =>
items.map((item) =>
item.toolCode === tool.toolCode
? {
...item,
riskLevel: "write",
requireConfirmation: true,
}
: item,
),
)
}
>
</Button>
</div>
</div>
))}
</div>
) : null}
</FormSection> </FormSection>
</div> </div>
) : null} ) : null}
@@ -868,10 +834,10 @@ export function AIAgentConfigWorkbench({
<span <span
className={cn( className={cn(
"size-2 rounded-full", "size-2 rounded-full",
runtimePublished ? "bg-emerald-500" : "bg-amber-500", agentPublished ? "bg-emerald-500" : "bg-amber-500",
)} )}
/> />
<span>{runtimePublished ? "当前配置已发布" : "保存配置后再发布 Agent"}</span> <span>{agentPublished ? "当前配置已发布" : "保存配置后再发布 Agent"}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button type="button" variant="outline" onClick={onCancel}> <Button type="button" variant="outline" onClick={onCancel}>
@@ -886,7 +852,7 @@ export function AIAgentConfigWorkbench({
<SaveIcon /> <SaveIcon />
</Button> </Button>
{agent && runtimeMode !== "workflow" ? ( {agent ? (
<Button type="button" disabled={saving} onClick={publishAgent}> <Button type="button" disabled={saving} onClick={publishAgent}>
Agent Agent
</Button> </Button>
@@ -1012,7 +978,7 @@ function VersionRecordsTable({
<TableHead className="w-28"></TableHead> <TableHead className="w-28"></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -1033,10 +999,8 @@ function VersionRecordsTable({
{revision.publishedAt || "-"} {revision.publishedAt || "-"}
</TableCell> </TableCell>
<TableCell>{revision.publishedByName || "-"}</TableCell> <TableCell>{revision.publishedByName || "-"}</TableCell>
<TableCell> <TableCell className="font-mono text-xs text-muted-foreground">
{revision.workflowVersionId > 0 {revision.definitionHash?.slice(0, 12) || "-"}
? `#${revision.workflowVersionId}`
: "-"}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <Button
+11 -18
View File
@@ -62,10 +62,6 @@ function getNextStatus(item: AIAgent) {
return item.status === Status.Ok ? Status.Disabled : Status.Ok; return item.status === Status.Ok ? Status.Disabled : Status.Ok;
} }
function isWorkflowPublished(item: AIAgent) {
return Boolean(item.workflowPublished ?? item.workflowVersionId > 0);
}
export default function DashboardAIAgentsPage() { export default function DashboardAIAgentsPage() {
const t = useI18n(); const t = useI18n();
const statusOptions = useMemo(() => getStatusOptions(t), [t]); const statusOptions = useMemo(() => getStatusOptions(t), [t]);
@@ -126,31 +122,28 @@ export default function DashboardAIAgentsPage() {
render: (item) => getServiceModeLabel(item.serviceMode, t), render: (item) => getServiceModeLabel(item.serviceMode, t),
}, },
{ {
key: "workflow", key: "publication",
label: "工作流状态", label: "发布状态",
render: (item) => { render: (item) => {
const published = isWorkflowPublished(item); const published = item.publishedRevisionId > 0;
const workflowCount = item.workflowBindings?.length ?? 0;
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<Badge variant={published ? "default" : "outline"}> <Badge variant={published ? "default" : "outline"}>
{item.workflowStateText || (published ? "已发布" : "未发布")} {published ? "已发布" : "未发布"}
</Badge> </Badge>
{published ? ( {published ? (
<span className="font-mono text-xs text-muted-foreground"> <span className="font-mono text-xs text-muted-foreground">
#{item.workflowVersionId} Revision #{item.publishedRevisionId}
</span> </span>
) : null} ) : null}
</div> </div>
{!published ? ( <div className="text-xs text-muted-foreground">
<div className="text-xs text-muted-foreground"> {workflowCount > 0
AI ? `已配置 ${workflowCount} 个 Workflow`
</div> : "由 Agent 自主判断并直接回复"}
) : ( </div>
<div className="text-xs text-muted-foreground">
#{item.workflowVersionId}
</div>
)}
</div> </div>
); );
}, },
@@ -254,14 +254,7 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin
} }
function isAgentChannelBindable(agent: AIAgent | undefined) { function isAgentChannelBindable(agent: AIAgent | undefined) {
if (!agent) return false return Boolean(agent && agent.publishedRevisionId > 0)
if (agent.runtimeMode === "autonomous") {
return agent.publishedRevisionId > 0
}
if (agent.runtimeMode === "hybrid") {
return agent.publishedRevisionId > 0 && agent.workflowVersionId > 0
}
return Boolean(agent.workflowPublished ?? agent.workflowVersionId > 0)
} }
type ChannelFormBodyProps = Omit<ChannelFormDialogProps, "open"> type ChannelFormBodyProps = Omit<ChannelFormDialogProps, "open">
@@ -431,7 +424,7 @@ function ChannelFormBody({
const aiAgentOptions = availableAIAgents.map((item) => ({ const aiAgentOptions = availableAIAgents.map((item) => ({
value: String(item.id), value: String(item.id),
label: isAgentChannelBindable(item) label: isAgentChannelBindable(item)
? `${item.name} · 当前生效 #${item.workflowVersionId}` ? `${item.name} · Revision #${item.publishedRevisionId}`
: `${item.name} · 未发布`, : `${item.name} · 未发布`,
})) }))
const wxWorkKFAccountOptions = wxWorkKFAccounts.map((item) => ({ const wxWorkKFAccountOptions = wxWorkKFAccounts.map((item) => ({
@@ -557,15 +550,15 @@ function ChannelFormBody({
/> />
{selectedAIAgent && !isAgentChannelBindable(selectedAIAgent) ? ( {selectedAIAgent && !isAgentChannelBindable(selectedAIAgent) ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900"> <div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Agent AI Agent Agent AI Agent Revision
</div> </div>
) : null} ) : null}
{selectedAIAgent && isAgentChannelBindable(selectedAIAgent) ? ( {selectedAIAgent && isAgentChannelBindable(selectedAIAgent) ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary"> <Badge variant="secondary">
{selectedAIAgent.runtimeMode === "autonomous" ? "已发布" : selectedAIAgent.workflowStateText || "已发布"}
</Badge> </Badge>
<span>{selectedAIAgent.runtimeMode === "autonomous" ? `当前版本 #${selectedAIAgent.publishedRevisionId}` : `当前生效版本 #${selectedAIAgent.workflowVersionId}`}</span> <span>Revision #{selectedAIAgent.publishedRevisionId}</span>
</div> </div>
) : null} ) : null}
<FieldError errors={[errors.aiAgentId]} /> <FieldError errors={[errors.aiAgentId]} />
@@ -360,9 +360,6 @@ function DebugDialogBody({
<CardContent className="space-y-3 text-sm"> <CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Badge variant="outline">{result?.skillName || skillName}</Badge> <Badge variant="outline">{result?.skillName || skillName}</Badge>
{result?.graphToolCode ? (
<Badge variant="secondary">{result.graphToolCode}</Badge>
) : null}
{result?.interruptType ? ( {result?.interruptType ? (
<Badge variant="secondary">{result.interruptType}</Badge> <Badge variant="secondary">{result.interruptType}</Badge>
) : null} ) : null}
@@ -376,12 +373,6 @@ function DebugDialogBody({
<div className="text-xs text-muted-foreground">{t("skillDefinition.skillName")}</div> <div className="text-xs text-muted-foreground">{t("skillDefinition.skillName")}</div>
<div className="mt-1 font-medium">{result?.skillName || skillName}</div> <div className="mt-1 font-medium">{result?.skillName || skillName}</div>
</div> </div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">Plan Reason</div>
<div className="mt-1 whitespace-pre-wrap break-words">
{result?.planReason || t("skillDefinition.none")}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">Reply</div> <div className="text-xs text-muted-foreground">Reply</div>
<div className="mt-1 whitespace-pre-wrap break-words"> <div className="mt-1 whitespace-pre-wrap break-words">
@@ -424,20 +415,6 @@ function DebugDialogBody({
)} )}
</div> </div>
</div> </div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">{t("skillDefinition.exposedTools")}</div>
<div className="mt-2 flex flex-wrap gap-2">
{(result?.exposedToolCodes ?? []).length > 0 ? (
result?.exposedToolCodes.map((toolCode) => (
<Badge key={toolCode} variant="outline">
{toolCode}
</Badge>
))
) : (
<span className="text-muted-foreground">{t("skillDefinition.none")}</span>
)}
</div>
</div>
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">{t("skillDefinition.invokedTools")}</div> <div className="text-xs text-muted-foreground">{t("skillDefinition.invokedTools")}</div>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
@@ -457,9 +434,6 @@ function DebugDialogBody({
</div> </div>
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<ResultBlock title={t("skillDefinition.skillRouteTrace")} value={result?.skillRouteTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title={t("skillDefinition.toolSearchTrace")} value={result?.toolSearchTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title={t("skillDefinition.graphToolTrace")} value={result?.graphToolTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title={t("skillDefinition.traceData")} value={result?.traceData} emptyText={t("skillDefinition.emptyData")} /> <ResultBlock title={t("skillDefinition.traceData")} value={result?.traceData} emptyText={t("skillDefinition.emptyData")} />
</div> </div>
@@ -523,9 +497,6 @@ function DebugDialogBody({
<CardContent className="space-y-3 text-sm"> <CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Badge variant="outline">{resumeResult.skillName || skillName}</Badge> <Badge variant="outline">{resumeResult.skillName || skillName}</Badge>
{resumeResult.graphToolCode ? (
<Badge variant="secondary">{resumeResult.graphToolCode}</Badge>
) : null}
{resumeResult.interruptType ? ( {resumeResult.interruptType ? (
<Badge variant="secondary">{resumeResult.interruptType}</Badge> <Badge variant="secondary">{resumeResult.interruptType}</Badge>
) : null} ) : null}
@@ -547,16 +518,8 @@ function DebugDialogBody({
{resumeResult.replyText || t("skillDefinition.none")} {resumeResult.replyText || t("skillDefinition.none")}
</div> </div>
</div> </div>
<div className="rounded-lg bg-muted/50 p-3">
<div className="text-xs text-muted-foreground">Resume Plan Reason</div>
<div className="mt-1 whitespace-pre-wrap break-words">
{resumeResult.planReason || t("skillDefinition.none")}
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
<ResultBlock title={t("skillDefinition.resumeToolSearchTrace")} value={resumeResult.toolSearchTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title={t("skillDefinition.resumeGraphToolTrace")} value={resumeResult.graphToolTrace} emptyText={t("skillDefinition.emptyData")} />
<ResultBlock title={t("skillDefinition.resumeTraceData")} value={resumeResult.traceData} emptyText={t("skillDefinition.emptyData")} /> <ResultBlock title={t("skillDefinition.resumeTraceData")} value={resumeResult.traceData} emptyText={t("skillDefinition.emptyData")} />
</div> </div>
) : null} ) : null}
+5 -27
View File
@@ -219,8 +219,6 @@ export type AIAgent = {
statusName: string statusName: string
aiConfigId: number aiConfigId: number
aiConfigName?: string aiConfigName?: string
runtimeMode: "workflow" | "autonomous" | "hybrid"
runtimeModeName: string
maxSteps: number maxSteps: number
contextWindow: number contextWindow: number
toolPolicy: string toolPolicy: string
@@ -247,14 +245,12 @@ export type AIAgent = {
toolName: string toolName: string
title: string title: string
description: string description: string
riskLevel: "read" | "write"
requireConfirmation: boolean
arguments?: Record<string, string> arguments?: Record<string, string>
}[] }[]
workflowBindings: AIAgentWorkflowBinding[] workflowBindings: AIAgentWorkflowBinding[]
workflowVersionId: number
publishedRevisionId: number publishedRevisionId: number
workflowPublished: boolean
workflowState: string
workflowStateText: string
sortNo: number sortNo: number
createdAt: string createdAt: string
updatedAt: string updatedAt: string
@@ -266,7 +262,6 @@ export type CreateAIAgentPayload = {
name: string name: string
description: string description: string
aiConfigId: number aiConfigId: number
runtimeMode?: "workflow" | "autonomous" | "hybrid"
maxSteps?: number maxSteps?: number
contextWindow?: number contextWindow?: number
toolPolicy?: string toolPolicy?: string
@@ -288,6 +283,8 @@ export type CreateAIAgentPayload = {
toolName: string toolName: string
title: string title: string
description: string description: string
riskLevel: "read" | "write"
requireConfirmation: boolean
arguments?: Record<string, string> arguments?: Record<string, string>
}[] }[]
workflowBindings: AIAgentWorkflowBindingInput[] workflowBindings: AIAgentWorkflowBindingInput[]
@@ -321,7 +318,6 @@ export type AgentRevision = {
id: number id: number
agentId: number agentId: number
revision: number revision: number
workflowVersionId: number
status: number status: number
definitionHash: string definitionHash: string
publishedAt: string publishedAt: string
@@ -536,14 +532,8 @@ export type SkillDebugRunResult = {
skillDefinitionId: number skillDefinitionId: number
skillName: string skillName: string
replyText: string replyText: string
planReason: string
skillRouteTrace: string
toolWhitelist: string[] toolWhitelist: string[]
exposedToolCodes: string[]
invokedToolCodes: string[] invokedToolCodes: string[]
toolSearchTrace: string
graphToolTrace: string
graphToolCode: string
interruptType: string interruptType: string
checkPointId: string checkPointId: string
interrupted: boolean interrupted: boolean
@@ -650,7 +640,6 @@ export type AgentRun = {
agentRevisionId: number agentRevisionId: number
sourceMessageId: number sourceMessageId: number
workflowRunId: number workflowRunId: number
engineCode: string
status: string status: string
promptTokens: number promptTokens: number
completionTokens: number completionTokens: number
@@ -701,11 +690,6 @@ export type AgentRunMetrics = {
unsupportedEvidenceRate: number unsupportedEvidenceRate: number
} }
export type AgentRunEngineComparison = {
engineCode: string
metrics: AgentRunMetrics
}
export type AgentEvaluationCase = { export type AgentEvaluationCase = {
id: string id: string
category?: string category?: string
@@ -715,13 +699,11 @@ export type AgentEvaluationCase = {
} }
export type AgentEvaluationReport = { export type AgentEvaluationReport = {
engineCode: string
total: number total: number
passed: number passed: number
results: { results: {
caseId: string caseId: string
category: string category: string
engineCode: string
passed: boolean passed: boolean
replyText: string replyText: string
interrupted: boolean interrupted: boolean
@@ -1394,11 +1376,7 @@ export function fetchAgentRunMetrics(aiAgentId?: number) {
return request<AgentRunMetrics>(`/api/dashboard/agent-run/metrics${toQueryString(aiAgentId ? { aiAgentId } : undefined)}`) return request<AgentRunMetrics>(`/api/dashboard/agent-run/metrics${toQueryString(aiAgentId ? { aiAgentId } : undefined)}`)
} }
export function fetchAgentRunEngineComparisons(aiAgentId?: number) { export function runAgentEvaluation(payload: { aiAgentId: number; cases: AgentEvaluationCase[] }) {
return request<AgentRunEngineComparison[]>(`/api/dashboard/agent-run/comparison${toQueryString(aiAgentId ? { aiAgentId } : undefined)}`)
}
export function runAgentEvaluation(payload: { aiAgentId: number; engineCode: string; cases: AgentEvaluationCase[] }) {
return request<AgentEvaluationReport>("/api/dashboard/agent-run/evaluate", { return request<AgentEvaluationReport>("/api/dashboard/agent-run/evaluate", {
method: "POST", method: "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
+1 -1
View File
@@ -69,7 +69,7 @@ export type DashboardOverview = {
todayKnowledgeRetrieves: number todayKnowledgeRetrieves: number
todayKnowledgeRetrieveFailCount: number todayKnowledgeRetrieveFailCount: number
todayKnowledgeRetrieveFailRate: number todayKnowledgeRetrieveFailRate: number
todaySkillRunFailCount: number todayAgentRunFailCount: number
todayAiHandoffCount: number todayAiHandoffCount: number
} }
alerts: DashboardAlert[] alerts: DashboardAlert[]
+2 -8
View File
@@ -419,7 +419,7 @@
"enabledChannels": "Active Channels", "enabledChannels": "Active Channels",
"todayKnowledgeRetrieves": "Knowledge Searches Today", "todayKnowledgeRetrieves": "Knowledge Searches Today",
"todayKnowledgeRetrieveFailRate": "Search Failure Rate Today", "todayKnowledgeRetrieveFailRate": "Search Failure Rate Today",
"todaySkillRunFailCount": "Skill Failures Today", "todayAgentRunFailCount": "Agent Run Failures Today",
"todayAiHandoffCount": "AI-to-Human Handoffs Today", "todayAiHandoffCount": "AI-to-Human Handoffs Today",
"empty": "No dashboard overview data yet.", "empty": "No dashboard overview data yet.",
"summaryTodayNewConversations": "New Conversations Today", "summaryTodayNewConversations": "New Conversations Today",
@@ -1644,12 +1644,7 @@
"agentRequired": "Select an AI agent.", "agentRequired": "Select an AI agent.",
"messageRequired": "Enter a user message.", "messageRequired": "Enter a user message.",
"emptyData": "No data", "emptyData": "No data",
"skillRouteTrace": "Skill Route Trace",
"toolSearchTrace": "Tool Search Trace",
"graphToolTrace": "Graph Tool Trace",
"traceData": "Trace Data", "traceData": "Trace Data",
"resumeToolSearchTrace": "Resume Tool Search Trace",
"resumeGraphToolTrace": "Resume Graph Tool Trace",
"resumeTraceData": "Resume Trace Data", "resumeTraceData": "Resume Trace Data",
"confirm": "Confirm", "confirm": "Confirm",
"reject": "Cancel", "reject": "Cancel",
@@ -1669,7 +1664,7 @@
"matchedAgent": "Matched Agent", "matchedAgent": "Matched Agent",
"noAgentSelected": "None selected", "noAgentSelected": "None selected",
"userMessage": "User Message", "userMessage": "User Message",
"userMessagePlaceholder": "Enter a user message to debug this skill's routing, tools, and reply.", "userMessagePlaceholder": "Enter a user message to debug this skill's capability calls and reply inside the Agent Loop.",
"debugSummary": "Debug Summary", "debugSummary": "Debug Summary",
"interrupted": "Interrupted", "interrupted": "Interrupted",
"notInterrupted": "Not interrupted", "notInterrupted": "Not interrupted",
@@ -1678,7 +1673,6 @@
"errorMessage": "Error Message", "errorMessage": "Error Message",
"toolView": "Tool View", "toolView": "Tool View",
"skillToolWhitelist": "Skill Tool Allowlist", "skillToolWhitelist": "Skill Tool Allowlist",
"exposedTools": "Tools Exposed This Run",
"invokedTools": "Tools Called This Run", "invokedTools": "Tools Called This Run",
"resumeDebug": "Resume Debug", "resumeDebug": "Resume Debug",
"currentCheckpoint": "Current Checkpoint", "currentCheckpoint": "Current Checkpoint",
+2 -8
View File
@@ -419,7 +419,7 @@
"enabledChannels": "启用中的接入渠道", "enabledChannels": "启用中的接入渠道",
"todayKnowledgeRetrieves": "今日知识检索次数", "todayKnowledgeRetrieves": "今日知识检索次数",
"todayKnowledgeRetrieveFailRate": "今日检索失败率", "todayKnowledgeRetrieveFailRate": "今日检索失败率",
"todaySkillRunFailCount": "今日 Skill 失败次数", "todayAgentRunFailCount": "今日 Agent 运行失败次数",
"todayAiHandoffCount": "今日 AI 转人工次数", "todayAiHandoffCount": "今日 AI 转人工次数",
"empty": "暂无首页概览数据", "empty": "暂无首页概览数据",
"summaryTodayNewConversations": "今日新增会话", "summaryTodayNewConversations": "今日新增会话",
@@ -1645,12 +1645,7 @@
"agentRequired": "请选择 AI Agent", "agentRequired": "请选择 AI Agent",
"messageRequired": "请输入用户消息", "messageRequired": "请输入用户消息",
"emptyData": "暂无数据", "emptyData": "暂无数据",
"skillRouteTrace": "Skill 路由追踪",
"toolSearchTrace": "工具搜索追踪",
"graphToolTrace": "Graph 工具追踪",
"traceData": "追踪数据", "traceData": "追踪数据",
"resumeToolSearchTrace": "恢复工具搜索追踪",
"resumeGraphToolTrace": "恢复 Graph 工具追踪",
"resumeTraceData": "恢复追踪数据", "resumeTraceData": "恢复追踪数据",
"confirm": "确认", "confirm": "确认",
"reject": "取消", "reject": "取消",
@@ -1670,7 +1665,7 @@
"matchedAgent": "命中 Agent", "matchedAgent": "命中 Agent",
"noAgentSelected": "未选择", "noAgentSelected": "未选择",
"userMessage": "用户消息", "userMessage": "用户消息",
"userMessagePlaceholder": "输入一段用户消息,调试当前 Skill 的路由、工具和回复。", "userMessagePlaceholder": "输入一段用户消息,调试当前 Skill 在 Agent Loop 中的能力调用和回复。",
"debugSummary": "调试摘要", "debugSummary": "调试摘要",
"interrupted": "已中断", "interrupted": "已中断",
"notInterrupted": "未中断", "notInterrupted": "未中断",
@@ -1679,7 +1674,6 @@
"errorMessage": "错误信息", "errorMessage": "错误信息",
"toolView": "工具视图", "toolView": "工具视图",
"skillToolWhitelist": "技能工具白名单", "skillToolWhitelist": "技能工具白名单",
"exposedTools": "本轮实际暴露工具",
"invokedTools": "本轮实际调用工具", "invokedTools": "本轮实际调用工具",
"resumeDebug": "恢复调试", "resumeDebug": "恢复调试",
"currentCheckpoint": "当前 Checkpoint", "currentCheckpoint": "当前 Checkpoint",