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
@@ -3,7 +3,6 @@ package services
import (
"context"
"fmt"
"strings"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
@@ -21,9 +20,6 @@ func (s *agentEvaluationService) Run(ctx context.Context, req request.RunAgentEv
if req.AIAgentID <= 0 {
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 {
return nil, errorsx.InvalidParam("evaluation cases are required")
}
@@ -14,9 +14,9 @@ func TestAgentEvaluationServiceValidatesAndCallsRunner(t *testing.T) {
called := false
AgentEvaluationRunHook = func(_ context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) {
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 {
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 {
Agent agentRevisionAgent `json:"agent"`
Model agentRevisionModel `json:"model"`
WorkflowBindings []agentRevisionWorkflowBinding `json:"workflowBindings"`
WorkflowBindings []AgentRevisionWorkflowBinding `json:"workflowBindings"`
}
type agentRevisionWorkflowBinding struct {
type AgentRevisionWorkflowBinding struct {
WorkflowID int64 `json:"workflowId"`
WorkflowVersionID int64 `json:"workflowVersionId"`
ToolName string `json:"toolName"`
@@ -69,7 +69,6 @@ type agentRevisionAgent struct {
Name string `json:"name"`
Description string `json:"description"`
AIConfigID int64 `json:"aiConfigId"`
RuntimeMode string `json:"runtimeMode"`
MaxSteps int `json:"maxSteps"`
ContextWindow int `json:"contextWindow"`
ToolPolicy string `json:"toolPolicy"`
@@ -94,33 +93,31 @@ type AgentRevisionSnapshot struct {
Revision models.AgentRevision
Agent models.AIAgent
AIConfig models.AIConfig
WorkflowBindings []agentRevisionWorkflowBinding
WorkflowBindings []AgentRevisionWorkflowBinding
}
// ResolvePublishedSnapshot restores a published Agent revision for runtime
// execution. Empty legacy definitions retain the current fields so historical
// records created before snapshot hydration remain executable.
// ResolvePublishedSnapshot restores an immutable published Agent revision.
func (s *agentRevisionService) ResolvePublishedSnapshot(agent models.AIAgent, config models.AIConfig) (*AgentRevisionSnapshot, error) {
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)
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}
if strings.TrimSpace(revision.Definition) == "" {
return snapshot, nil
return nil, errorsx.InvalidParam("published Agent revision definition is empty")
}
definition := agentRevisionDefinition{}
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 {
return nil, errorsx.InvalidParam("published agent model config no longer matches")
}
applyRevisionAgentSnapshot(&snapshot.Agent, definition.Agent)
snapshot.WorkflowBindings = append([]agentRevisionWorkflowBinding(nil), definition.WorkflowBindings...)
snapshot.WorkflowBindings = append([]AgentRevisionWorkflowBinding(nil), definition.WorkflowBindings...)
applyRevisionModelSnapshot(&snapshot.AIConfig, definition.Model)
return snapshot, nil
}
@@ -132,7 +129,6 @@ func applyRevisionAgentSnapshot(agent *models.AIAgent, definition agentRevisionA
agent.Name = definition.Name
agent.Description = definition.Description
agent.AIConfigID = definition.AIConfigID
agent.RuntimeMode = enums.AIAgentRuntimeMode(definition.RuntimeMode)
agent.MaxSteps = definition.MaxSteps
agent.ContextWindow = definition.ContextWindow
agent.ToolPolicy = definition.ToolPolicy
@@ -180,7 +176,7 @@ func (s *agentRevisionService) publishSnapshot(db *gorm.DB, agent *models.AIAgen
definition := agentRevisionDefinition{
Agent: agentRevisionAgent{
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,
WelcomeMessage: agent.WelcomeMessage, ReplyTimeoutSeconds: agent.ReplyTimeoutSeconds, TeamIDs: agent.TeamIDs, HandoffMode: int(agent.HandoffMode),
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,
}
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)
if err != nil {
@@ -24,7 +24,7 @@ func TestAgentRevisionServiceRestoresPublishedSnapshotAndKeepsAPIKey(t *testing.
sqls.SetDB(db)
definition := agentRevisionDefinition{
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,
},
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"`
}
type AgentRunEngineComparison struct {
EngineCode string `json:"engineCode"`
Metrics AgentRunMetrics `json:"metrics"`
}
const maxAgentAuditPreviewChars = 4000
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
}
// 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 {
metrics := AgentRunMetrics{TotalRuns: len(runs)}
if len(runs) == 0 {
@@ -258,29 +227,12 @@ func (s *agentRunService) aggregateMetrics(db *gorm.DB, runs []models.AgentRun)
return metrics
}
type WorkflowAgentRunInput 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 {
type AgentLoopRunInput struct {
ConversationID int64
AIAgentID int64
AgentRevisionID int64
SourceMessageID int64
EngineCode string
WorkflowRunID int64
Status string
PromptTokens int
CompletionTokens int
@@ -292,11 +244,11 @@ type EngineAgentRunInput struct {
StepCode string
StepInputPreview string
StepOutputPreview string
AdditionalSteps []EngineStepInput
ToolCalls []EngineToolCallInput
AdditionalSteps []AgentLoopStepInput
ToolCalls []AgentLoopToolCallInput
}
type EngineStepInput struct {
type AgentLoopStepInput struct {
StepType string
StepCode string
WorkflowRunID int64
@@ -306,7 +258,7 @@ type EngineStepInput struct {
ErrorMessage string
}
type EngineToolCallInput struct {
type AgentLoopToolCallInput struct {
ToolCode string
RiskLevel string
RequireConfirm bool
@@ -317,47 +269,47 @@ type EngineToolCallInput struct {
DurationMS int
}
// RecordHybridPlaybookResume closes or re-interrupts the Hybrid AgentRun that
// originally selected a Playbook. The detailed WorkflowRun remains separately
// auditable; this step preserves the parent AgentRun -> AgentStep -> WorkflowRun
// relationship across a human confirmation pause.
func (s *agentRunService) RecordHybridPlaybookResume(db *gorm.DB, agentRunID, workflowRunID int64, status, replyText string) error {
// RecordResume closes or re-interrupts the original Agent Loop parent run,
// appends a normalized resume step, and records an optional resumed tool call.
func (s *agentRunService) RecordResume(db *gorm.DB, agentRunID, workflowRunID int64, status, replyText string, toolCall *AgentLoopToolCallInput) error {
if agentRunID <= 0 {
return nil
}
run := repositories.AgentRunRepository.Get(db, agentRunID)
if run == nil || run.EngineCode != "hybrid" {
if run == nil {
return nil
}
status = strings.TrimSpace(status)
if status == "" {
status = "completed"
}
status = firstNonEmptyString(status, "completed")
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{
"status": status,
"ended_at": &now,
"error_message": "",
"updated_at": now,
"status": status, "ended_at": &now, "error_message": "", "updated_at": now,
}); err != nil {
return err
}
return repositories.AgentStepRepository.Create(db, &models.AgentStep{
step := &models.AgentStep{
AgentRunID: run.ID, WorkflowRunID: workflowRunID,
StepType: "playbook", StepCode: "playbook_resume", Status: status,
InputPreview: "human confirmation resume",
OutputPreview: sanitizeAgentAuditPreview(replyText),
StartedAt: now, EndedAt: &now, DurationMS: durationMS, CreatedAt: now,
StepType: "resume", StepCode: "confirmation_resume", Status: status,
InputPreview: "customer confirmation", OutputPreview: sanitizeAgentAuditPreview(replyText),
StartedAt: now, EndedAt: &now, 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.
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()
startedAt := input.StartedAt
if startedAt.IsZero() {
@@ -369,7 +321,7 @@ func (s *agentRunService) RecordEngineRun(db *gorm.DB, input EngineAgentRunInput
}
run := &models.AgentRun{
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,
ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage), TraceData: sanitizeAgentAuditPreview(input.TraceData), CreatedAt: now, UpdatedAt: now,
}
@@ -435,80 +387,3 @@ func firstNonEmptyString(items ...string) string {
}
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/enums"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/repositories"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
@@ -26,12 +25,12 @@ func TestAgentRunServiceFindsWorkflowAuditDetail(t *testing.T) {
ConversationID: 11,
AIAgentID: 12,
WorkflowRunID: 13,
EngineCode: "workflow",
Status: "completed",
StartedAt: now,
EndedAt: &endedAt,
CreatedAt: now,
UpdatedAt: now,
Status: "completed",
StartedAt: now,
EndedAt: &endedAt,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(run).Error; err != nil {
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)
now := time.Now()
if err := db.Create(&models.AgentRevision{AgentID: 12, Revision: 1, WorkflowVersionID: 14}).Error; err != nil {
t.Fatalf("create agent revision: %v", err)
}
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{{
runID, err := AgentRunService.RecordAgentLoopRun(db, AgentLoopRunInput{
ConversationID: 1, AIAgentID: 2, AgentRevisionID: 3, Status: "completed", StartedAt: now,
StepType: "model", StepCode: "chat_completion", StepInputPreview: "authorization=Bearer-secret", ToolCalls: []AgentLoopToolCallInput{{
ToolCode: "knowledge/search", RiskLevel: "read", Status: "completed", ArgumentsPreview: `{"token":"abc123","query":"refund"}`, ResultPreview: "policy text",
}},
})
if err != nil {
t.Fatalf("RecordEngineRun returned error: %v", err)
t.Fatalf("RecordAgentLoopRun returned error: %v", err)
}
_, steps, toolCalls := AgentRunService.GetDetail(runID)
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)
now := time.Now().Add(-time.Minute)
run := &models.AgentRun{EngineCode: "hybrid", Status: "interrupted", StartedAt: now, CreatedAt: now, UpdatedAt: now}
now := time.Now()
run := &models.AgentRun{Status: "interrupted", StartedAt: now, CreatedAt: now, UpdatedAt: now}
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 {
t.Fatalf("RecordHybridPlaybookResume returned error: %v", err)
err := AgentRunService.RecordResume(db, run.ID, 0, "completed", "操作已执行", &AgentLoopToolCallInput{
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)
if item == nil || item.Status != "completed" || item.EndedAt == nil {
t.Fatalf("expected completed hybrid run, got %#v", item)
item, steps, toolCalls := AgentRunService.GetDetail(run.ID)
if item == nil || item.Status != "completed" || len(steps) != 1 || steps[0].StepType != "resume" {
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 != "已完成工单登记。" {
t.Fatalf("unexpected playbook resume step: %#v", steps)
if len(toolCalls) != 1 || toolCalls[0].AgentStepID != steps[0].ID || !toolCalls[0].RequireConfirm || toolCalls[0].Status != "completed" {
t.Fatalf("unexpected resumed tool audit: %#v", toolCalls)
}
}
func TestAgentRunServiceSavesQualityFeedbackPerRun(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
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 {
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)
base := time.Now().Add(-time.Minute)
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, EngineCode: "workflow", 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: 8, Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(100 * time.Millisecond)), PromptTokens: 10, CompletionTokens: 5, 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, Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(900 * time.Millisecond)), CreatedAt: base, UpdatedAt: base},
}
for index := range runs {
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 {
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 }
+40 -68
View File
@@ -24,7 +24,7 @@ import (
var AIAgentService = newAIAgentService()
const defaultNewAutonomousRolloutPercent = 5
const defaultNewAgentRolloutPercent = 5
func newAIAgentService() *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 {
return err
}
bindings, err := s.replaceWorkflowBindings(ctx.Tx, item.ID, req.WorkflowBindings, operator)
if err != nil {
return err
}
return s.validateWorkflowBindingMode(ctx.Tx, item, bindings)
_, err := s.replaceWorkflowBindings(ctx.Tx, item.ID, req.WorkflowBindings, operator)
return err
}); err != nil {
return nil, err
}
@@ -110,7 +107,6 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato
"name": item.Name,
"description": item.Description,
"ai_config_id": item.AIConfigID,
"runtime_mode": item.RuntimeMode,
"max_steps": item.MaxSteps,
"context_window": item.ContextWindow,
"tool_policy": item.ToolPolicy,
@@ -127,6 +123,7 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato
"knowledge_ids": item.KnowledgeIDs,
"skill_ids": item.SkillIDs,
"allowed_mcp_tools": item.AllowedMCPTools,
"published_revision_id": 0,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
@@ -134,42 +131,15 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato
if item.RolloutPercent != 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 {
if err := repositories.AIAgentRepository.Updates(ctx.Tx, req.ID, columns); err != nil {
return err
}
bindings, err := s.replaceWorkflowBindings(ctx.Tx, req.ID, req.WorkflowBindings, operator)
if err != nil {
return err
}
return s.validateWorkflowBindingMode(ctx.Tx, item, bindings)
_, err := s.replaceWorkflowBindings(ctx.Tx, req.ID, req.WorkflowBindings, operator)
return err
})
}
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 {
current := s.Get(id)
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) {
if operator == nil {
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 {
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 {
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
revision, err = AgentRevisionService.PublishSnapshot(ctx.Tx, agent, operator)
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 {
return err
}
if strings.TrimSpace(agent.AllowedMCPTools) == "" {
return nil
}
var mcpTools []request.AIAgentMCPToolRequest
if err := json.Unmarshal([]byte(agent.AllowedMCPTools), &mcpTools); err != nil {
return errorsx.InvalidParam("ai agent MCP tools are invalid")
if raw := strings.TrimSpace(agent.AllowedMCPTools); raw != "" {
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 {
definition, err := aitooling.DefaultRegistry.Resolve(item.ToolCode)
if err != nil || definition.InputSchema == nil {
return errorsx.InvalidParam("ai agent MCP tool definition is unavailable")
}
if definition.RequireConfirmation {
return errorsx.InvalidParam("ai agent MCP tool requires confirmation and cannot be executed directly")
if item.RiskLevel != aitooling.RiskLevelRead && item.RiskLevel != aitooling.RiskLevelWrite {
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
@@ -328,15 +306,6 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe
if aiConfig.Status != enums.StatusOk {
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 {
req.MaxSteps = 6
}
@@ -374,11 +343,7 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe
return nil, errorsx.InvalidParamI18n("error.e0144")
}
if req.RolloutPercent == 0 {
if req.RuntimeMode == enums.AIAgentRuntimeModeAutonomous || req.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
req.RolloutPercent = defaultNewAutonomousRolloutPercent
} else {
req.RolloutPercent = 100
}
req.RolloutPercent = defaultNewAgentRolloutPercent
}
if req.RolloutPercent < 1 || req.RolloutPercent > 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,
Description: strings.TrimSpace(req.Description),
AIConfigID: req.AIConfigID,
RuntimeMode: req.RuntimeMode,
MaxSteps: req.MaxSteps,
ContextWindow: req.ContextWindow,
ToolPolicy: toolPolicy,
@@ -532,9 +496,9 @@ func (s *aIAgentService) normalizeSkillIDs(input []int64) ([]int64, error) {
if skill == nil || skill.Status == enums.StatusDeleted {
continue
}
// if skill.Status != enums.StatusOk {
// return nil, errorsx.InvalidParamI18n("error.e0056")
// }
if skill.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0056")
}
seen[id] = struct{}{}
ret = append(ret, id)
}
@@ -558,6 +522,14 @@ func (s *aIAgentService) normalizeMCPTools(input []request.AIAgentMCPToolRequest
if err := ToolCatalogService.ValidateToolCode(normalized.ToolCode); err != nil {
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)
if _, exists := seen[key]; exists {
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()
}
func (s *aiWorkflowService) ListPlaybookTemplates() []AIWorkflowTemplate {
func (s *aiWorkflowService) ListWorkflowTemplates() []AIWorkflowTemplate {
return []AIWorkflowTemplate{
{Code: "ticket-with-confirmation", Name: "创建工单", Description: "整理工单草稿,经客户确认后创建工单。", Definition: ticketWithConfirmationPlaybookDefinition()},
{Code: "identity-confirmation", Name: "身份确认", Description: "在执行后续业务前收集客户的明确确认。", Definition: identityConfirmationPlaybookDefinition()},
{Code: "complaint-escalation", Name: "投诉升级", Description: "投诉场景经客户确认后转入人工客服处理。", Definition: complaintEscalationPlaybookDefinition()},
{Code: "refund-request-preparation", Name: "退款申请准备", Description: "整理退款诉求,确认后转人工继续核验和处理。", Definition: refundRequestPreparationPlaybookDefinition()},
{Code: "ticket-with-confirmation", Name: "创建工单", Description: "整理工单草稿,经客户确认后创建工单。", Definition: ticketWithConfirmationWorkflowDefinition()},
{Code: "identity-confirmation", Name: "身份确认", Description: "在执行后续业务前收集客户的明确确认。", Definition: identityConfirmationWorkflowDefinition()},
{Code: "complaint-escalation", Name: "投诉升级", Description: "投诉场景经客户确认后转入人工客服处理。", Definition: complaintEscalationWorkflowDefinition()},
{Code: "refund-request-preparation", Name: "退款申请准备", Description: "整理退款诉求,确认后转人工继续核验和处理。", Definition: refundRequestPreparationWorkflowDefinition()},
}
}
func ticketWithConfirmationPlaybookDefinition() dsl.Definition {
func ticketWithConfirmationWorkflowDefinition() dsl.Definition {
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
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,
Nodes: []dsl.Node{
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
@@ -546,11 +546,11 @@ func identityConfirmationPlaybookDefinition() dsl.Definition {
}
}
func complaintEscalationPlaybookDefinition() dsl.Definition {
return confirmationHandoffPlaybookDefinition("投诉升级确认", "我们将把本次投诉升级给人工客服处理。请确认是否继续。", "已为你升级投诉,人工客服会尽快跟进。", "投诉升级已取消。")
func complaintEscalationWorkflowDefinition() dsl.Definition {
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,
Nodes: []dsl.Node{
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
@@ -574,8 +574,8 @@ func confirmationHandoffPlaybookDefinition(title, prompt, confirmedReply, cancel
}
}
func refundRequestPreparationPlaybookDefinition() dsl.Definition {
return confirmationHandoffPlaybookDefinition("退款申请确认", "我会先整理退款申请并转交人工客服核验。请确认是否继续。", "退款申请已准备完成,人工客服将继续核验订单和退款条件。", "退款申请准备已取消。")
func refundRequestPreparationWorkflowDefinition() dsl.Definition {
return confirmationHandoffWorkflowDefinition("退款申请确认", "我会先整理退款申请并转交人工客服核验。请确认是否继续。", "退款申请已准备完成,人工客服将继续核验订单和退款条件。", "退款申请准备已取消。")
}
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) {
templates := AIWorkflowService.ListPlaybookTemplates()
func TestAIWorkflowServiceWorkflowTemplatesAreValid(t *testing.T) {
templates := AIWorkflowService.ListWorkflowTemplates()
if len(templates) != 4 {
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.
// Autonomous mode deliberately does not expose it; deterministic Playbooks
// invoke it only after their human-confirm node has completed.
// AgentDesk services remain the write boundary. Workflow nodes invoke this
// executor only after their human-confirm node has completed.
var BusinessToolExecutor = newBusinessToolExecutor(aitooling.DefaultRegistry)
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 {
return nil, errorsx.InvalidParamI18n("error.e0004")
}
if aiAgent.RuntimeMode == "" || aiAgent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow {
if len(AIAgentService.ListEnabledWorkflowBindings(sqls.DB(), aiAgent.ID)) != 1 {
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")
if aiAgent.PublishedRevisionID <= 0 {
return nil, errorsx.InvalidParam("ai agent must be published before binding channel")
}
status := enums.Status(req.Status)
if req.Status == 0 {
+6 -75
View File
@@ -15,7 +15,7 @@ import (
"gorm.io/gorm/schema"
)
func TestChannelServiceRejectsAgentWithoutPublishedWorkflow(t *testing.T) {
func TestChannelServiceRejectsAgentWithoutPublishedRevision(t *testing.T) {
db := setupChannelServiceTestDB(t)
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)
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 {
t.Helper()
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
@@ -190,12 +121,12 @@ func setupChannelServiceTestDB(t *testing.T) *gorm.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()
item := models.AIAgent{
Name: "测试 AI",
Status: enums.StatusOk,
WorkflowVersionID: workflowVersionID,
Name: "测试 AI",
Status: enums.StatusOk,
PublishedRevisionID: publishedRevisionID,
}
if err := db.Create(&item).Error; err != nil {
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 {
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 {
return tx.Where("created_at >= ? AND error_message <> ''", todayStart)
agentRunFailCount := repositories.DashboardRepository.CountAgentRuns(db, func(tx *gorm.DB) *gorm.DB {
return tx.Where("created_at >= ? AND status = ?", todayStart, "failed")
})
aiHandoffCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB {
return tx.Where("handoff_at >= ?", todayStart)
@@ -108,7 +108,7 @@ func (s *dashboardService) GetOverview(rangeValue string, locale string) respons
TodayKnowledgeRetrieves: knowledgeRetrieveCount,
TodayKnowledgeRetrieveFailCount: knowledgeRetrieveFailCount,
TodayKnowledgeRetrieveFailRate: calcRate(knowledgeRetrieveFailCount, knowledgeRetrieveCount),
TodaySkillRunFailCount: skillRunFailCount,
TodayAgentRunFailCount: agentRunFailCount,
TodayAIHandoffCount: aiHandoffCount,
},
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)
}