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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user