feat: Implement AI Agent Workflow Binding functionality
- Added aiAgentWorkflowBindingRepository for managing workflow bindings associated with AI agents. - Enhanced agentRevisionService to include workflow bindings in agent revisions. - Updated aIAgentService to handle workflow bindings during agent creation and updates. - Introduced ai_agent_workflow_binding_service for managing workflow binding logic. - Created new API endpoints for fetching, creating, updating, and deleting AI workflows. - Developed a new dashboard page for managing AI workflows. - Updated frontend components to support workflow binding management in agent configuration. - Added necessary tests for workflow binding functionality and updated existing tests for compatibility. - Translated relevant UI texts and messages for workflow management.
This commit is contained in:
@@ -49,26 +49,42 @@ func (e *HybridEngine) Run(ctx context.Context, req RunInput) (*RunResult, error
|
||||
return nil, err
|
||||
}
|
||||
req.AIAgent, req.AIConfig = snapshot.Agent, snapshot.AIConfig
|
||||
if req.AIAgent.WorkflowVersionID <= 0 {
|
||||
return nil, errorsx.InvalidParam("hybrid agent requires a published playbook workflow")
|
||||
workflowVersionIDs := make([]int64, 0, len(snapshot.WorkflowBindings))
|
||||
workflowTools := make([]ai.ToolDefinition, 0, len(snapshot.WorkflowBindings))
|
||||
for _, binding := range snapshot.WorkflowBindings {
|
||||
if binding.WorkflowVersionID <= 0 {
|
||||
continue
|
||||
}
|
||||
versionAgent := req.AIAgent
|
||||
versionAgent.WorkflowVersionID = binding.WorkflowVersionID
|
||||
workflow, resolveErr := resolveAgentWorkflow(versionAgent)
|
||||
if resolveErr != nil || !workflowvalidator.ValidateDefinition(workflow.Definition, workflowregistry.DefaultRegistry()).Valid {
|
||||
return nil, errorsx.InvalidParam("hybrid agent workflow binding is invalid")
|
||||
}
|
||||
workflowVersionIDs = append(workflowVersionIDs, binding.WorkflowVersionID)
|
||||
workflowTools = append(workflowTools, hybridWorkflowToolDefinition(binding.WorkflowVersionID, binding.ToolName, binding.TriggerInstruction))
|
||||
}
|
||||
workflow, err := resolveAgentWorkflow(req.AIAgent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if len(workflowVersionIDs) == 0 && req.AIAgent.WorkflowVersionID > 0 {
|
||||
workflow, resolveErr := resolveAgentWorkflow(req.AIAgent)
|
||||
if resolveErr != nil || !workflowvalidator.ValidateDefinition(workflow.Definition, workflowregistry.DefaultRegistry()).Valid {
|
||||
return nil, errorsx.InvalidParam("hybrid agent workflow binding is invalid")
|
||||
}
|
||||
workflowVersionIDs = append(workflowVersionIDs, req.AIAgent.WorkflowVersionID)
|
||||
workflowTools = append(workflowTools, hybridWorkflowToolDefinition(req.AIAgent.WorkflowVersionID, "", ""))
|
||||
}
|
||||
if result := workflowvalidator.ValidateDefinition(workflow.Definition, workflowregistry.DefaultRegistry()); !result.Valid {
|
||||
return nil, errorsx.InvalidParam("hybrid agent playbook validation failed")
|
||||
if len(workflowVersionIDs) == 0 {
|
||||
return nil, errorsx.InvalidParam("hybrid agent requires a published workflow")
|
||||
}
|
||||
|
||||
turn := e.autonomous.prepareTurn(ctx, req)
|
||||
if turn.ResponsePolicy.Enforced {
|
||||
return writeHybridResult(req, startedAt, &ai.ChatCompletionResult{Content: turn.ResponsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}, "", 0, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, nil, turn.ResponsePolicy, nil)
|
||||
}
|
||||
turn.SystemPrompt += "\n\nWhen a deterministic process is required, use run_playbook. Do not call it for ordinary factual questions."
|
||||
turn.SystemPrompt += "\n\nWhen a deterministic business process is required, use the matching workflow tool. Do not call workflows for ordinary factual questions."
|
||||
|
||||
var playbookSummary *Summary
|
||||
toolCalls := make([]svc.EngineToolCallInput, 0, 1)
|
||||
loop, err := e.chatWithTools(ctx, req.AIConfig, turn.SystemPrompt, turn.UserPrompt, []ai.ToolDefinition{hybridPlaybookToolDefinition(req.AIAgent.WorkflowVersionID)}, req.AIAgent.MaxSteps, func(ctx context.Context, call ai.ToolCall) (string, error) {
|
||||
loop, err := e.chatWithTools(ctx, req.AIConfig, turn.SystemPrompt, turn.UserPrompt, workflowTools, req.AIAgent.MaxSteps, func(ctx context.Context, call ai.ToolCall) (string, error) {
|
||||
if call.Name != "run_playbook" {
|
||||
return "", fmt.Errorf("unsupported hybrid tool: %s", call.Name)
|
||||
}
|
||||
@@ -79,7 +95,7 @@ func (e *HybridEngine) Run(ctx context.Context, req RunInput) (*RunResult, error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if workflowVersionID != req.AIAgent.WorkflowVersionID {
|
||||
if !containsWorkflowVersion(workflowVersionIDs, workflowVersionID) {
|
||||
return "", fmt.Errorf("playbook is not allowed")
|
||||
}
|
||||
playbookDefinition := aitooling.Definition{Code: hybridPlaybookToolCode, Name: "run_playbook", RiskLevel: aitooling.RiskLevelWrite, RequireConfirmation: true, MaxCallsPerRun: 1}
|
||||
@@ -93,7 +109,9 @@ func (e *HybridEngine) Run(ctx context.Context, req RunInput) (*RunResult, error
|
||||
return "", err
|
||||
}
|
||||
callStartedAt := time.Now()
|
||||
playbookSummary, err = e.workflow.Run(ctx, req)
|
||||
workflowReq := req
|
||||
workflowReq.AIAgent.WorkflowVersionID = workflowVersionID
|
||||
playbookSummary, err = e.workflow.Run(ctx, workflowReq)
|
||||
toolRecord := svc.EngineToolCallInput{ToolCode: hybridPlaybookToolCode, RiskLevel: "write", RequireConfirm: true, ArgumentsPreview: call.Arguments, DurationMS: int(time.Since(callStartedAt).Milliseconds())}
|
||||
if err != nil {
|
||||
toolRecord.Status, toolRecord.ErrorMessage = "failed", err.Error()
|
||||
@@ -143,12 +161,28 @@ func (e *HybridEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult,
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func hybridPlaybookToolDefinition(workflowVersionID int64) ai.ToolDefinition {
|
||||
return ai.ToolDefinition{Name: "run_playbook", Description: "Run the Agent's published deterministic Playbook when the customer needs a controlled business action.", Parameters: map[string]any{
|
||||
"type": "object", "properties": map[string]any{"workflowVersionId": map[string]any{"type": "integer", "description": "The bound Playbook version."}}, "required": []string{"workflowVersionId"},
|
||||
func hybridWorkflowToolDefinition(workflowVersionID int64, toolName, instruction string) ai.ToolDefinition {
|
||||
description := "Run this Agent's published deterministic workflow when the customer needs the controlled business action."
|
||||
if strings.TrimSpace(toolName) != "" {
|
||||
description += " Workflow: " + toolName + "."
|
||||
}
|
||||
if strings.TrimSpace(instruction) != "" {
|
||||
description += " Use when: " + instruction
|
||||
}
|
||||
return ai.ToolDefinition{Name: "run_playbook", Description: description, Parameters: map[string]any{
|
||||
"type": "object", "properties": map[string]any{"workflowVersionId": map[string]any{"type": "integer", "description": fmt.Sprintf("The allowed workflow version (%d).", workflowVersionID)}}, "required": []string{"workflowVersionId"},
|
||||
}}
|
||||
}
|
||||
|
||||
func containsWorkflowVersion(items []int64, value int64) bool {
|
||||
for _, item := range items {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseHybridPlaybookCall(raw string) (int64, error) {
|
||||
var input struct {
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
|
||||
@@ -233,6 +233,11 @@ func registerDashboardAIAgentRoutes(group *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.AIWorkflowGetBy)
|
||||
group.POST("/create", dashboard.AIWorkflowPostCreate)
|
||||
group.POST("/update", dashboard.AIWorkflowPostUpdate)
|
||||
group.POST("/delete", dashboard.AIWorkflowPostDelete)
|
||||
group.Any("/list", dashboard.AIWorkflowAnyList)
|
||||
group.GET("/node-spec/list", dashboard.AIWorkflowGetNodeSpecList)
|
||||
group.GET("/default-definition", dashboard.AIWorkflowGetDefaultDefinition)
|
||||
group.GET("/template/list", dashboard.AIWorkflowGetTemplateList)
|
||||
|
||||
@@ -271,6 +271,7 @@ func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) respons
|
||||
Skills: make([]response.AIAgentSkillResponse, 0),
|
||||
Teams: make([]response.AIAgentTeamResponse, 0),
|
||||
DirectTools: make([]response.AIAgentMCPToolResponse, 0),
|
||||
WorkflowBindings: make([]response.AIAgentWorkflowBindingResponse, 0),
|
||||
WorkflowVersionID: item.WorkflowVersionID,
|
||||
PublishedRevisionID: item.PublishedRevisionID,
|
||||
WorkflowPublished: item.WorkflowVersionID > 0,
|
||||
@@ -348,6 +349,16 @@ func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) respons
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, binding := range services.AIAgentService.ListWorkflowBindings(item.ID) {
|
||||
if binding.Workflow == nil || binding.Version == nil {
|
||||
continue
|
||||
}
|
||||
ret.WorkflowBindings = append(ret.WorkflowBindings, response.AIAgentWorkflowBindingResponse{
|
||||
ID: binding.Binding.ID, WorkflowID: binding.Binding.WorkflowID, WorkflowVersionID: binding.Binding.WorkflowVersionID,
|
||||
WorkflowName: binding.Workflow.Name, WorkflowVersion: binding.Version.Version, ToolName: binding.Binding.ToolName,
|
||||
TriggerInstruction: binding.Binding.TriggerInstruction, Priority: binding.Binding.Priority, Enabled: binding.Binding.Enabled,
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ var Models = []any{
|
||||
&AgentRunQualityFeedback{},
|
||||
&AIWorkflow{},
|
||||
&AIWorkflowVersion{},
|
||||
&AIAgentWorkflowBinding{},
|
||||
&AIWorkflowRun{},
|
||||
&AIWorkflowNodeRun{},
|
||||
&ConversationInterrupt{},
|
||||
@@ -664,6 +665,21 @@ type AIWorkflowVersion struct {
|
||||
AuditFields
|
||||
}
|
||||
|
||||
// AIAgentWorkflowBinding binds an Agent to one immutable, published workflow
|
||||
// version. Workflows are maintained independently and can be reused by many
|
||||
// Agents; Agent publication snapshots the binding set for reproducible runs.
|
||||
type AIAgentWorkflowBinding struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
AIAgentID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_agent_workflow_binding"`
|
||||
WorkflowID int64 `gorm:"type:bigint;not null;index"`
|
||||
WorkflowVersionID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_agent_workflow_binding"`
|
||||
ToolName string `gorm:"type:varchar(100);not null;default:''"`
|
||||
TriggerInstruction string `gorm:"type:text"`
|
||||
Priority int `gorm:"type:int;not null;default:0;index"`
|
||||
Enabled bool `gorm:"not null;default:true;index"`
|
||||
AuditFields
|
||||
}
|
||||
|
||||
// AIWorkflowRun 表示一次会话 workflow 执行记录。
|
||||
type AIWorkflowRun struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
|
||||
@@ -11,6 +11,14 @@ type AIAgentMCPToolRequest struct {
|
||||
Arguments map[string]string `json:"arguments"`
|
||||
}
|
||||
|
||||
type AIAgentWorkflowBindingRequest struct {
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
ToolName string `json:"toolName"`
|
||||
TriggerInstruction string `json:"triggerInstruction"`
|
||||
Priority int `json:"priority"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type CreateAIConfigRequest struct {
|
||||
Name string `json:"name"`
|
||||
Provider enums.AIProvider `json:"provider"`
|
||||
@@ -63,6 +71,7 @@ type CreateAIAgentRequest struct {
|
||||
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
|
||||
SkillIDs []int64 `json:"skillIds"`
|
||||
DirectTools []AIAgentMCPToolRequest `json:"directTools"`
|
||||
WorkflowBindings []AIAgentWorkflowBindingRequest `json:"workflowBindings"`
|
||||
}
|
||||
|
||||
type UpdateAIAgentRequest struct {
|
||||
|
||||
@@ -24,6 +24,18 @@ type AIAgentMCPToolResponse struct {
|
||||
Arguments map[string]string `json:"arguments"`
|
||||
}
|
||||
|
||||
type AIAgentWorkflowBindingResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
WorkflowID int64 `json:"workflowId"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
WorkflowName string `json:"workflowName"`
|
||||
WorkflowVersion int `json:"workflowVersion"`
|
||||
ToolName string `json:"toolName"`
|
||||
TriggerInstruction string `json:"triggerInstruction"`
|
||||
Priority int `json:"priority"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type AgentRevisionResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
AgentID int64 `json:"agentId"`
|
||||
@@ -79,44 +91,45 @@ func BuildAIConfigResponse(item *models.AIConfig) AIConfigResponse {
|
||||
}
|
||||
|
||||
type AIAgentResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status enums.Status `json:"status"`
|
||||
StatusName string `json:"statusName"`
|
||||
AIConfigID int64 `json:"aiConfigId"`
|
||||
AIConfigName string `json:"aiConfigName"`
|
||||
RuntimeMode enums.AIAgentRuntimeMode `json:"runtimeMode"`
|
||||
RuntimeModeName string `json:"runtimeModeName"`
|
||||
MaxSteps int `json:"maxSteps"`
|
||||
ContextWindow int `json:"contextWindow"`
|
||||
ToolPolicy string `json:"toolPolicy"`
|
||||
KnowledgePolicy string `json:"knowledgePolicy"`
|
||||
ServiceMode enums.IMConversationServiceMode `json:"serviceMode"`
|
||||
ServiceModeName string `json:"serviceModeName"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
WelcomeMessage string `json:"welcomeMessage"`
|
||||
ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"`
|
||||
RolloutPercent int `json:"rolloutPercent"`
|
||||
PreviousRolloutPercent int `json:"previousRolloutPercent"`
|
||||
Teams []AIAgentTeamResponse `json:"teams"`
|
||||
HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"`
|
||||
HandoffModeName string `json:"handoffModeName"`
|
||||
FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"`
|
||||
FallbackModeName string `json:"fallbackModeName"`
|
||||
FallbackMessage string `json:"fallbackMessage"`
|
||||
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
|
||||
SkillIDs []int64 `json:"skillIds"`
|
||||
Skills []AIAgentSkillResponse `json:"skills"`
|
||||
DirectTools []AIAgentMCPToolResponse `json:"directTools"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
PublishedRevisionID int64 `json:"publishedRevisionId"`
|
||||
WorkflowPublished bool `json:"workflowPublished"`
|
||||
WorkflowState string `json:"workflowState"`
|
||||
WorkflowStateText string `json:"workflowStateText"`
|
||||
SortNo int `json:"sortNo"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreateUserName string `json:"createUserName"`
|
||||
UpdateUserName string `json:"updateUserName"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status enums.Status `json:"status"`
|
||||
StatusName string `json:"statusName"`
|
||||
AIConfigID int64 `json:"aiConfigId"`
|
||||
AIConfigName string `json:"aiConfigName"`
|
||||
RuntimeMode enums.AIAgentRuntimeMode `json:"runtimeMode"`
|
||||
RuntimeModeName string `json:"runtimeModeName"`
|
||||
MaxSteps int `json:"maxSteps"`
|
||||
ContextWindow int `json:"contextWindow"`
|
||||
ToolPolicy string `json:"toolPolicy"`
|
||||
KnowledgePolicy string `json:"knowledgePolicy"`
|
||||
ServiceMode enums.IMConversationServiceMode `json:"serviceMode"`
|
||||
ServiceModeName string `json:"serviceModeName"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
WelcomeMessage string `json:"welcomeMessage"`
|
||||
ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"`
|
||||
RolloutPercent int `json:"rolloutPercent"`
|
||||
PreviousRolloutPercent int `json:"previousRolloutPercent"`
|
||||
Teams []AIAgentTeamResponse `json:"teams"`
|
||||
HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"`
|
||||
HandoffModeName string `json:"handoffModeName"`
|
||||
FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"`
|
||||
FallbackModeName string `json:"fallbackModeName"`
|
||||
FallbackMessage string `json:"fallbackMessage"`
|
||||
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
|
||||
SkillIDs []int64 `json:"skillIds"`
|
||||
Skills []AIAgentSkillResponse `json:"skills"`
|
||||
DirectTools []AIAgentMCPToolResponse `json:"directTools"`
|
||||
WorkflowBindings []AIAgentWorkflowBindingResponse `json:"workflowBindings"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
PublishedRevisionID int64 `json:"publishedRevisionId"`
|
||||
WorkflowPublished bool `json:"workflowPublished"`
|
||||
WorkflowState string `json:"workflowState"`
|
||||
WorkflowStateText string `json:"workflowStateText"`
|
||||
SortNo int `json:"sortNo"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreateUserName string `json:"createUserName"`
|
||||
UpdateUserName string `json:"updateUserName"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var AIAgentWorkflowBindingRepository = newAIAgentWorkflowBindingRepository()
|
||||
|
||||
func newAIAgentWorkflowBindingRepository() *aiAgentWorkflowBindingRepository {
|
||||
return &aiAgentWorkflowBindingRepository{}
|
||||
}
|
||||
|
||||
type aiAgentWorkflowBindingRepository struct{}
|
||||
|
||||
func (r *aiAgentWorkflowBindingRepository) FindByAgentID(db *gorm.DB, agentID int64) []models.AIAgentWorkflowBinding {
|
||||
ret := make([]models.AIAgentWorkflowBinding, 0)
|
||||
if agentID > 0 {
|
||||
db.Where("ai_agent_id = ?", agentID).Order("priority ASC, id ASC").Find(&ret)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (r *aiAgentWorkflowBindingRepository) FindEnabledByAgentID(db *gorm.DB, agentID int64) []models.AIAgentWorkflowBinding {
|
||||
ret := make([]models.AIAgentWorkflowBinding, 0)
|
||||
if agentID > 0 {
|
||||
db.Where("ai_agent_id = ? AND enabled = ?", agentID, true).Order("priority ASC, id ASC").Find(&ret)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (r *aiAgentWorkflowBindingRepository) CountByWorkflowID(db *gorm.DB, workflowID int64) int64 {
|
||||
var count int64
|
||||
if workflowID > 0 {
|
||||
db.Model(&models.AIAgentWorkflowBinding{}).Where("workflow_id = ?", workflowID).Count(&count)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (r *aiAgentWorkflowBindingRepository) ReplaceByAgentID(db *gorm.DB, agentID int64, items []models.AIAgentWorkflowBinding) error {
|
||||
if err := db.Where("ai_agent_id = ?", agentID).Delete(&models.AIAgentWorkflowBinding{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return db.Create(&items).Error
|
||||
}
|
||||
@@ -38,10 +38,19 @@ func (s *agentRevisionService) FindByAgentID(agentID int64) []models.AgentRevisi
|
||||
}
|
||||
|
||||
type agentRevisionDefinition struct {
|
||||
Agent agentRevisionAgent `json:"agent"`
|
||||
Model agentRevisionModel `json:"model"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
WorkflowDefinition string `json:"workflowDefinition"`
|
||||
Agent agentRevisionAgent `json:"agent"`
|
||||
Model agentRevisionModel `json:"model"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
WorkflowDefinition string `json:"workflowDefinition"`
|
||||
WorkflowBindings []agentRevisionWorkflowBinding `json:"workflowBindings"`
|
||||
}
|
||||
|
||||
type agentRevisionWorkflowBinding struct {
|
||||
WorkflowID int64 `json:"workflowId"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
ToolName string `json:"toolName"`
|
||||
TriggerInstruction string `json:"triggerInstruction"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
// agentRevisionModel deliberately excludes APIKey. A revision must capture
|
||||
@@ -84,9 +93,10 @@ type agentRevisionAgent struct {
|
||||
// a published revision. Model credentials deliberately remain on the current
|
||||
// AIConfig so credential rotation does not require republishing every Agent.
|
||||
type AgentRevisionSnapshot struct {
|
||||
Revision models.AgentRevision
|
||||
Agent models.AIAgent
|
||||
AIConfig models.AIConfig
|
||||
Revision models.AgentRevision
|
||||
Agent models.AIAgent
|
||||
AIConfig models.AIConfig
|
||||
WorkflowBindings []agentRevisionWorkflowBinding
|
||||
}
|
||||
|
||||
// ResolvePublishedSnapshot restores a published Agent revision for runtime
|
||||
@@ -112,6 +122,7 @@ func (s *agentRevisionService) ResolvePublishedSnapshot(agent models.AIAgent, co
|
||||
return nil, errorsx.InvalidParam("published agent model config no longer matches")
|
||||
}
|
||||
applyRevisionAgentSnapshot(&snapshot.Agent, definition.Agent)
|
||||
snapshot.WorkflowBindings = append([]agentRevisionWorkflowBinding(nil), definition.WorkflowBindings...)
|
||||
if definition.WorkflowVersionID > 0 {
|
||||
snapshot.Agent.WorkflowVersionID = definition.WorkflowVersionID
|
||||
}
|
||||
@@ -196,6 +207,9 @@ func (s *agentRevisionService) publishSnapshot(db *gorm.DB, agent *models.AIAgen
|
||||
WorkflowVersionID: workflowVersionID,
|
||||
WorkflowDefinition: workflowDefinition,
|
||||
}
|
||||
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})
|
||||
}
|
||||
data, err := json.Marshal(definition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -83,11 +83,18 @@ func (s *aIAgentService) CreateAIAgent(req request.CreateAIAgentRequest, operato
|
||||
if err := repositories.AIAgentRepository.Create(ctx.Tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
if item.RuntimeMode == enums.AIAgentRuntimeModeWorkflow || item.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
|
||||
_, err := AIWorkflowService.createDefaultAgentWorkflow(ctx.Tx, item, operator)
|
||||
bindings, err := s.replaceWorkflowBindings(ctx.Tx, item.ID, req.WorkflowBindings, operator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
if len(bindings) == 0 && (item.RuntimeMode == enums.AIAgentRuntimeModeWorkflow || item.RuntimeMode == enums.AIAgentRuntimeModeHybrid) {
|
||||
_, createErr := AIWorkflowService.createDefaultAgentWorkflow(ctx.Tx, item, operator)
|
||||
if createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
return nil // Legacy create calls remain compatible until clients send explicit bindings.
|
||||
}
|
||||
return s.validateWorkflowBindingMode(ctx.Tx, item, bindings)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -139,7 +146,35 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato
|
||||
// behavior. The operator must explicitly publish the new revision.
|
||||
columns["published_revision_id"] = 0
|
||||
}
|
||||
return repositories.AIAgentRepository.Updates(sqls.DB(), req.ID, columns)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -169,12 +204,15 @@ 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 || agent.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
|
||||
return errorsx.InvalidParam("workflow and hybrid agents must publish a workflow version")
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AIAgentWorkflowBindingContext struct {
|
||||
Binding models.AIAgentWorkflowBinding
|
||||
Workflow *models.AIWorkflow
|
||||
Version *models.AIWorkflowVersion
|
||||
}
|
||||
|
||||
func (s *aIAgentService) ListWorkflowBindings(agentID int64) []AIAgentWorkflowBindingContext {
|
||||
bindings := repositories.AIAgentWorkflowBindingRepository.FindByAgentID(sqls.DB(), agentID)
|
||||
return s.buildWorkflowBindingContexts(sqls.DB(), bindings)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) ListEnabledWorkflowBindings(db *gorm.DB, agentID int64) []AIAgentWorkflowBindingContext {
|
||||
return s.buildWorkflowBindingContexts(db, repositories.AIAgentWorkflowBindingRepository.FindEnabledByAgentID(db, agentID))
|
||||
}
|
||||
|
||||
func (s *aIAgentService) buildWorkflowBindingContexts(db *gorm.DB, bindings []models.AIAgentWorkflowBinding) []AIAgentWorkflowBindingContext {
|
||||
ret := make([]AIAgentWorkflowBindingContext, 0, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
ret = append(ret, AIAgentWorkflowBindingContext{Binding: binding, Workflow: repositories.AIWorkflowRepository.Get(db, binding.WorkflowID), Version: repositories.AIWorkflowVersionRepository.Get(db, binding.WorkflowVersionID)})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *aIAgentService) replaceWorkflowBindings(db *gorm.DB, agentID int64, input []request.AIAgentWorkflowBindingRequest, operator *dto.AuthPrincipal) ([]models.AIAgentWorkflowBinding, error) {
|
||||
seen := make(map[int64]struct{}, len(input))
|
||||
items := make([]models.AIAgentWorkflowBinding, 0, len(input))
|
||||
for index, item := range input {
|
||||
if item.WorkflowVersionID <= 0 {
|
||||
return nil, errorsx.InvalidParam("workflow version is required")
|
||||
}
|
||||
if _, exists := seen[item.WorkflowVersionID]; exists {
|
||||
return nil, errorsx.InvalidParam("workflow version must not be bound more than once")
|
||||
}
|
||||
seen[item.WorkflowVersionID] = struct{}{}
|
||||
version := repositories.AIWorkflowVersionRepository.Get(db, item.WorkflowVersionID)
|
||||
if version == nil || version.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("workflow version is not published")
|
||||
}
|
||||
workflow := repositories.AIWorkflowRepository.Get(db, version.WorkflowID)
|
||||
if workflow == nil || workflow.Status == enums.StatusDeleted {
|
||||
return nil, errorsx.InvalidParam("workflow does not exist")
|
||||
}
|
||||
priority := item.Priority
|
||||
if priority == 0 {
|
||||
priority = index + 1
|
||||
}
|
||||
items = append(items, models.AIAgentWorkflowBinding{AIAgentID: agentID, WorkflowID: version.WorkflowID, WorkflowVersionID: version.ID, ToolName: strings.TrimSpace(item.ToolName), TriggerInstruction: strings.TrimSpace(item.TriggerInstruction), Priority: priority, Enabled: item.Enabled, AuditFields: utils.BuildAuditFields(operator)})
|
||||
}
|
||||
if err := repositories.AIAgentWorkflowBindingRepository.ReplaceByAgentID(db, agentID, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -463,13 +463,46 @@ func TestAIWorkflowServicePublishAgentWorkflowBindsAgentVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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.AgentRevision{}); err != nil {
|
||||
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)
|
||||
|
||||
@@ -246,7 +246,22 @@ func (s *aiWorkflowService) ValidateDefinition(def dsl.Definition) workflowvalid
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) CreateWorkflow(req request.CreateAIWorkflowRequest, operator *dto.AuthPrincipal) (*models.AIWorkflow, error) {
|
||||
return s.SaveAgentWorkflow(req, operator)
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParam("workflow name is required")
|
||||
}
|
||||
definition, err := marshalDefinition(req.Definition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := &models.AIWorkflow{Name: name, Description: strings.TrimSpace(req.Description), Status: enums.StatusOk, DraftDefinition: definition, AuditFields: utils.BuildAuditFields(operator)}
|
||||
if err := repositories.AIWorkflowRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) SaveAgentWorkflow(req request.SaveAIWorkflowRequest, operator *dto.AuthPrincipal) (*models.AIWorkflow, error) {
|
||||
@@ -305,9 +320,6 @@ func (s *aiWorkflowService) UpdateWorkflow(req request.UpdateAIWorkflowRequest,
|
||||
if name == "" {
|
||||
return errorsx.InvalidParam("workflow name is required")
|
||||
}
|
||||
if req.AgentID <= 0 {
|
||||
return errorsx.InvalidParam("agent id is required")
|
||||
}
|
||||
definition, err := marshalDefinition(req.Definition)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -315,7 +327,6 @@ func (s *aiWorkflowService) UpdateWorkflow(req request.UpdateAIWorkflowRequest,
|
||||
return repositories.AIWorkflowRepository.Updates(sqls.DB(), req.ID, map[string]interface{}{
|
||||
"name": name,
|
||||
"description": strings.TrimSpace(req.Description),
|
||||
"agent_id": req.AgentID,
|
||||
"draft_definition": definition,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
@@ -330,6 +341,9 @@ func (s *aiWorkflowService) DeleteWorkflow(id int64, operator *dto.AuthPrincipal
|
||||
if s.Get(id) == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
if repositories.AIAgentWorkflowBindingRepository.CountByWorkflowID(sqls.DB(), id) > 0 {
|
||||
return errorsx.InvalidParam("workflow is still associated with an agent")
|
||||
}
|
||||
return repositories.AIWorkflowRepository.Updates(sqls.DB(), id, map[string]interface{}{
|
||||
"status": enums.StatusDeleted,
|
||||
"update_user_id": operator.UserID,
|
||||
|
||||
@@ -283,7 +283,7 @@ func setupAIWorkflowTestDB(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AIAgent{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}, &models.AIWorkflowRun{}, &models.AIWorkflowNodeRun{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.AIAgent{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}, &models.AIAgentWorkflowBinding{}, &models.AIWorkflowRun{}, &models.AIWorkflowNodeRun{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
|
||||
@@ -183,7 +183,7 @@ func setupChannelServiceTestDB(t *testing.T) *gorm.DB {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&models.AIAgent{}, &models.AgentRevision{}, &models.Channel{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.AIAgent{}, &models.AgentRevision{}, &models.AIAgentWorkflowBinding{}, &models.Channel{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
|
||||
Reference in New Issue
Block a user