From 34051a4631811e1a1291814ec150cdea074d623e Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sat, 25 Jul 2026 12:04:06 +0800 Subject: [PATCH] feat: Enhance AI Agent and Channel Management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated labels in the AI Agents dashboard for clarity, changing "流程状态" to "Playbook 状态" and "未发布流程" to "未发布 Playbook". - Introduced AI Agent rollout percentage management in channel editing, allowing users to set and rollback rollout percentages. - Added new API endpoints for rolling back AI Agent rollout and fetching agent run metrics. - Implemented new UI components for displaying agent run details, including status, duration, and input/output tokens. - Enhanced type definitions for AdminChannel and AIAgent to include rollout percentages and runtime modes. - Updated navigation to include a section for agent runs. - Added new translations for agent run features in both English and Chinese. --- docs | 2 +- .../runtime/application_service.go | 106 +++ .../runtime/application_service_test.go | 54 ++ .../application/runtime/autonomous_engine.go | 688 ++++++++++++++++++ .../einoexperiment/confirmation_bridge.go | 102 +++ .../runtime/einoexperiment/guarded_tool.go | 94 +++ .../runtime/einoexperiment/mcp_tool.go | 37 + .../runtime/einoexperiment/react.go | 86 +++ .../runtime/einoexperiment/react_test.go | 376 ++++++++++ .../einoexperiment/real_endpoint_test.go | 141 ++++ internal/ai/application/runtime/engine.go | 58 ++ .../ai/application/runtime/engine_test.go | 550 ++++++++++++++ internal/ai/application/runtime/evaluation.go | 147 ++++ .../ai/application/runtime/evaluation_test.go | 54 ++ .../ai/application/runtime/hybrid_engine.go | 221 ++++++ internal/ai/application/runtime/service.go | 178 +++-- internal/ai/application/runtime/types.go | 51 +- .../ai/application/runtime/workflow_engine.go | 112 +++ .../runtime/workflow_summary_test.go | 89 ++- internal/ai/runtime/debug_run.go | 6 +- internal/ai/runtime/evaluation_run.go | 44 ++ .../ai/runtime/readtools/graph_executor.go | 79 ++ .../runtime/readtools/graph_executor_test.go | 26 + internal/ai/runtime/reply_commit_service.go | 8 +- .../ai/runtime/reply_commit_service_test.go | 20 + internal/ai/runtime/reply_eligibility.go | 34 + internal/ai/runtime/reply_helpers_test.go | 3 +- .../ai/runtime/reply_interrupt_helpers.go | 1 + .../ai/runtime/reply_interrupt_service.go | 3 + internal/ai/runtime/reply_service_test.go | 17 + internal/ai/runtime/reply_trigger_service.go | 13 + internal/ai/runtime/runtime_reply_executor.go | 29 +- internal/ai/runtime/tools/tool_search_tool.go | 11 +- internal/ai/runtime/workflow/executor.go | 130 ++-- internal/ai/runtime/workflow/executor_test.go | 38 + internal/ai/tool_loop.go | 91 +++ internal/ai/tool_loop_test.go | 82 +++ internal/ai/tooling/executor.go | 67 ++ internal/ai/tooling/registry.go | 219 ++++++ internal/ai/tooling/registry_test.go | 139 ++++ internal/ai/tooling/reply_safety.go | 41 ++ internal/ai/tooling/sanitize.go | 25 + internal/ai/workflow/registry/registry.go | 13 +- .../ai/workflow/validator/validator_test.go | 26 + internal/bootstrap/routes.go | 15 + internal/bootstrap/server.go | 1 + internal/bootstrap/server_route_test.go | 9 + internal/builders/agent_revision_builder.go | 29 + internal/builders/agent_run_builder.go | 145 ++++ internal/builders/ai_workflow_builder.go | 9 + .../handlers/dashboard/agent_run_handler.go | 103 +++ .../handlers/dashboard/ai_agent_handler.go | 144 +++- .../dashboard/ai_agent_handler_test.go | 9 + .../handlers/dashboard/ai_workflow_handler.go | 8 + .../handlers/dashboard/channel_handler.go | 18 + internal/models/models.go | 158 +++- .../dto/request/agent_evaluation_request.go | 15 + internal/pkg/dto/request/agent_run_request.go | 10 + internal/pkg/dto/request/ai_request.go | 20 + internal/pkg/dto/request/channel_request.go | 17 +- .../dto/response/agent_evaluation_response.go | 20 + .../pkg/dto/response/agent_run_response.go | 66 ++ internal/pkg/dto/response/ai_response.go | 82 ++- .../pkg/dto/response/ai_workflow_response.go | 7 + internal/pkg/dto/response/channel_response.go | 38 +- internal/pkg/enums/agent.go | 28 + internal/pkg/enums/im.go | 36 + internal/pkg/toolx/builtin_tools.go | 25 +- .../repositories/agent_revision_repository.go | 56 ++ .../agent_run_quality_feedback_repository.go | 45 ++ internal/repositories/agent_run_repository.go | 68 ++ .../repositories/agent_step_repository.go | 54 ++ .../agent_tool_call_repository.go | 43 ++ .../agent_tool_invocation_repository.go | 34 + .../conversation_interrupt_repository.go | 13 + .../repositories/conversation_repository.go | 24 + internal/services/agent_evaluation_service.go | 37 + .../services/agent_evaluation_service_test.go | 26 + internal/services/agent_revision_service.go | 214 ++++++ .../services/agent_revision_service_test.go | 50 ++ internal/services/agent_run_service.go | 514 +++++++++++++ internal/services/agent_run_service_test.go | 237 ++++++ .../services/agent_tool_invocation_service.go | 81 +++ .../agent_tool_invocation_service_test.go | 65 ++ internal/services/ai_agent_service.go | 286 +++++++- .../ai_agent_workflow_service_test.go | 265 ++++++- internal/services/ai_workflow_service.go | 148 +++- internal/services/ai_workflow_service_test.go | 20 + internal/services/business_tool_executor.go | 120 +++ internal/services/channel_service.go | 91 ++- internal/services/channel_service_test.go | 119 ++- .../conversation_interrupt_service.go | 4 + web/app/dashboard/agent-runs/page.tsx | 167 +++++ .../_components/config-workbench.tsx | 223 +++++- web/app/dashboard/ai-agents/page.tsx | 4 +- .../dashboard/channels/_components/edit.tsx | 72 +- web/lib/api/admin.ts | 231 +++++- web/lib/generated/enums.ts | 13 + web/lib/navigation.tsx | 6 + web/messages/en-US.json | 32 + web/messages/zh-CN.json | 32 + 101 files changed, 8377 insertions(+), 340 deletions(-) create mode 100644 internal/ai/application/runtime/application_service.go create mode 100644 internal/ai/application/runtime/application_service_test.go create mode 100644 internal/ai/application/runtime/autonomous_engine.go create mode 100644 internal/ai/application/runtime/einoexperiment/confirmation_bridge.go create mode 100644 internal/ai/application/runtime/einoexperiment/guarded_tool.go create mode 100644 internal/ai/application/runtime/einoexperiment/mcp_tool.go create mode 100644 internal/ai/application/runtime/einoexperiment/react.go create mode 100644 internal/ai/application/runtime/einoexperiment/react_test.go create mode 100644 internal/ai/application/runtime/einoexperiment/real_endpoint_test.go create mode 100644 internal/ai/application/runtime/engine.go create mode 100644 internal/ai/application/runtime/engine_test.go create mode 100644 internal/ai/application/runtime/evaluation.go create mode 100644 internal/ai/application/runtime/evaluation_test.go create mode 100644 internal/ai/application/runtime/hybrid_engine.go create mode 100644 internal/ai/application/runtime/workflow_engine.go create mode 100644 internal/ai/runtime/evaluation_run.go create mode 100644 internal/ai/runtime/readtools/graph_executor.go create mode 100644 internal/ai/runtime/readtools/graph_executor_test.go create mode 100644 internal/ai/tool_loop.go create mode 100644 internal/ai/tool_loop_test.go create mode 100644 internal/ai/tooling/executor.go create mode 100644 internal/ai/tooling/registry.go create mode 100644 internal/ai/tooling/registry_test.go create mode 100644 internal/ai/tooling/reply_safety.go create mode 100644 internal/ai/tooling/sanitize.go create mode 100644 internal/builders/agent_revision_builder.go create mode 100644 internal/builders/agent_run_builder.go create mode 100644 internal/handlers/dashboard/agent_run_handler.go create mode 100644 internal/pkg/dto/request/agent_evaluation_request.go create mode 100644 internal/pkg/dto/request/agent_run_request.go create mode 100644 internal/pkg/dto/response/agent_evaluation_response.go create mode 100644 internal/pkg/dto/response/agent_run_response.go create mode 100644 internal/repositories/agent_revision_repository.go create mode 100644 internal/repositories/agent_run_quality_feedback_repository.go create mode 100644 internal/repositories/agent_run_repository.go create mode 100644 internal/repositories/agent_step_repository.go create mode 100644 internal/repositories/agent_tool_call_repository.go create mode 100644 internal/repositories/agent_tool_invocation_repository.go create mode 100644 internal/services/agent_evaluation_service.go create mode 100644 internal/services/agent_evaluation_service_test.go create mode 100644 internal/services/agent_revision_service.go create mode 100644 internal/services/agent_revision_service_test.go create mode 100644 internal/services/agent_run_service.go create mode 100644 internal/services/agent_run_service_test.go create mode 100644 internal/services/agent_tool_invocation_service.go create mode 100644 internal/services/agent_tool_invocation_service_test.go create mode 100644 internal/services/business_tool_executor.go create mode 100644 web/app/dashboard/agent-runs/page.tsx diff --git a/docs b/docs index 0818d24..8e0ae6a 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 0818d247963e8c780664a042470c883ddcfd1796 +Subproject commit 8e0ae6ac8b624be6b7ed0e77a068df6273a96dfe diff --git a/internal/ai/application/runtime/application_service.go b/internal/ai/application/runtime/application_service.go new file mode 100644 index 0000000..b9164b9 --- /dev/null +++ b/internal/ai/application/runtime/application_service.go @@ -0,0 +1,106 @@ +package runtime + +import ( + "context" + "strings" + + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + svc "agent-desk/internal/services" +) + +// ApplicationRunInput identifies the persisted inputs for an Agent reply. +// Loading these records here keeps channels and debug adapters independent of +// individual engine requirements. +type ApplicationRunInput struct { + ConversationID int64 + MessageID int64 + AIAgentID int64 +} + +type ApplicationResumeInput struct { + ApplicationRunInput + CheckPointID string + ResumeData map[string]string +} + +// AgentApplicationService is the single application boundary before engine +// dispatch. It owns persisted input loading and relationship validation; the +// selected Engine remains responsible only for runtime execution. +type AgentApplicationService struct { + runtime *Service +} + +var DefaultAgentApplicationService = NewAgentApplicationService() + +func NewAgentApplicationService() *AgentApplicationService { + return &AgentApplicationService{runtime: NewService()} +} + +func (s *AgentApplicationService) Run(ctx context.Context, input ApplicationRunInput) (*RunResult, error) { + req, err := s.loadRequest(input) + if err != nil { + return nil, err + } + return s.RunPrepared(ctx, req) +} + +// RunPrepared is for isolated adapters such as the dashboard debug session. +// Callers are responsible for constructing an ephemeral or already-validated +// request; no persistence side effects are introduced by this boundary. +func (s *AgentApplicationService) RunPrepared(ctx context.Context, req RunInput) (*RunResult, error) { + return s.runtime.Run(ctx, req) +} + +func (s *AgentApplicationService) Resume(ctx context.Context, input ApplicationResumeInput) (*RunResult, error) { + req, err := s.loadRequest(input.ApplicationRunInput) + if err != nil { + return nil, err + } + checkPointID := strings.TrimSpace(input.CheckPointID) + interrupt := svc.ConversationInterruptService.GetByCheckPointID(checkPointID) + if interrupt == nil || interrupt.ConversationID != req.Conversation.ID { + return nil, errorsx.InvalidParam("pending conversation interrupt does not exist") + } + if interrupt.AIAgentID > 0 && interrupt.AIAgentID != req.AIAgent.ID { + return nil, errorsx.InvalidParam("interrupt does not belong to agent") + } + return s.ResumePrepared(ctx, ResumeInput{ + Conversation: req.Conversation, + UserMessage: req.UserMessage, + AIAgent: req.AIAgent, + AIConfig: req.AIConfig, + CheckPointID: checkPointID, + ResumeData: input.ResumeData, + }) +} + +func (s *AgentApplicationService) ResumePrepared(ctx context.Context, req ResumeInput) (*RunResult, error) { + return s.runtime.Resume(ctx, req) +} + +func (s *AgentApplicationService) loadRequest(input ApplicationRunInput) (RunInput, error) { + if input.ConversationID <= 0 || input.MessageID <= 0 || input.AIAgentID <= 0 { + return RunInput{}, errorsx.InvalidParam("conversation, message and agent are required") + } + conversation := svc.ConversationService.Get(input.ConversationID) + if conversation == nil { + return RunInput{}, errorsx.InvalidParam("conversation does not exist") + } + message := svc.MessageService.Get(input.MessageID) + if message == nil || message.ConversationID != conversation.ID { + return RunInput{}, errorsx.InvalidParam("message does not belong to conversation") + } + agent := svc.AIAgentService.Get(input.AIAgentID) + if agent == nil || agent.Status != enums.StatusOk { + return RunInput{}, errorsx.InvalidParam("ai agent is unavailable") + } + if conversation.AIAgentID > 0 && conversation.AIAgentID != agent.ID { + return RunInput{}, errorsx.InvalidParam("agent does not belong to conversation") + } + config := svc.AIConfigService.Get(agent.AIConfigID) + if config == nil || config.Status != enums.StatusOk { + return RunInput{}, errorsx.InvalidParam("ai config is unavailable") + } + return RunInput{Conversation: *conversation, UserMessage: *message, AIAgent: *agent, AIConfig: *config}, nil +} diff --git a/internal/ai/application/runtime/application_service_test.go b/internal/ai/application/runtime/application_service_test.go new file mode 100644 index 0000000..4ddac2e --- /dev/null +++ b/internal/ai/application/runtime/application_service_test.go @@ -0,0 +1,54 @@ +package runtime + +import ( + "strings" + "testing" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func TestAgentApplicationServiceLoadsConsistentPersistedRequest(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.AIConfig{}, &models.AIAgent{}, &models.Conversation{}, &models.Message{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + config := &models.AIConfig{Status: enums.StatusOk, ModelName: "test-model"} + if err := db.Create(config).Error; err != nil { + t.Fatalf("create config: %v", err) + } + agent := &models.AIAgent{Name: "agent", Status: enums.StatusOk, AIConfigID: config.ID} + if err := db.Create(agent).Error; err != nil { + t.Fatalf("create agent: %v", err) + } + conversation := &models.Conversation{AIAgentID: agent.ID} + if err := db.Create(conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + message := &models.Message{ConversationID: conversation.ID, SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: "hello"} + if err := db.Create(message).Error; err != nil { + t.Fatalf("create message: %v", err) + } + req, err := NewAgentApplicationService().loadRequest(ApplicationRunInput{ConversationID: conversation.ID, MessageID: message.ID, AIAgentID: agent.ID}) + if err != nil { + t.Fatalf("loadRequest: %v", err) + } + if req.Conversation.ID != conversation.ID || req.UserMessage.ID != message.ID || req.AIAgent.ID != agent.ID || req.AIConfig.ID != config.ID { + t.Fatalf("unexpected request: %#v", req) + } +} + +func TestAgentApplicationServiceRejectsMismatchedMessage(t *testing.T) { + service := NewAgentApplicationService() + if _, err := service.loadRequest(ApplicationRunInput{ConversationID: 1, MessageID: 0, AIAgentID: 1}); err == nil { + t.Fatal("expected invalid identifiers error") + } +} diff --git a/internal/ai/application/runtime/autonomous_engine.go b/internal/ai/application/runtime/autonomous_engine.go new file mode 100644 index 0000000..639e6ce --- /dev/null +++ b/internal/ai/application/runtime/autonomous_engine.go @@ -0,0 +1,688 @@ +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + ai "agent-desk/internal/ai" + "agent-desk/internal/ai/runtime/instruction" + "agent-desk/internal/ai/runtime/readtools" + "agent-desk/internal/ai/runtime/retrievers" + runtimetooling "agent-desk/internal/ai/runtime/tooling" + "agent-desk/internal/ai/skills" + aitooling "agent-desk/internal/ai/tooling" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/toolx" + "agent-desk/internal/pkg/utils" + svc "agent-desk/internal/services" + + "github.com/mlogclub/simple/sqls" +) + +// AutonomousEngine is the low-risk, no-flow runtime. It uses bounded model +// turns and exposes configured MCP tools only through the shared Tool Registry. +type AutonomousEngine struct { + chat func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) + history func(int64, int) []models.Message + retrieve func(context.Context, models.AIAgent, string) (string, int, error) + skillSelect func(context.Context, skills.RuntimeContext) (*skills.ExecutionResult, error) + toolChat func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error) +} + +func NewAutonomousEngine() *AutonomousEngine { + return &AutonomousEngine{ + chat: ai.LLM.ChatWithConfig, + history: func(conversationID int64, limit int) []models.Message { + items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, limit, "", "") + return items + }, + retrieve: retrieveAutonomousKnowledge, + skillSelect: skills.RuntimeService.Select, + toolChat: ai.LLM.ChatWithTools, + } +} + +func newAutonomousEngineWithChat(chat func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error)) *AutonomousEngine { + return &AutonomousEngine{chat: chat} +} + +func (e *AutonomousEngine) Code() string { + return EngineCodeAutonomous +} + +func (e *AutonomousEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) { + startedAt := time.Now() + req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content) + snapshot, err := svc.AgentRevisionService.ResolvePublishedSnapshot(req.AIAgent, req.AIConfig) + if err != nil { + _, _ = writeAutonomousRun(req, startedAt, nil, "", 0, 0, nil, autonomousSkillContext{}, autonomousResponsePolicy{}, nil, err) + return nil, err + } + req.AIAgent = snapshot.Agent + req.AIConfig = snapshot.AIConfig + skillContext := e.selectSkill(ctx, req) + knowledgeContext, retrieverCount, retrieveErr := e.retrieveKnowledge(ctx, req.AIAgent, req.UserMessage.Content) + responsePolicy := evaluateAutonomousResponsePolicy(req.AIAgent, knowledgeContext, retrieveErr) + systemPrompt := buildAutonomousSystemPrompt(req.AIAgent, len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0, knowledgeContext, retrieveErr) + if skillInstruction := strings.TrimSpace(instruction.BuildSkillDocument(skillContext.Skill, nil)); skillInstruction != "" { + systemPrompt += "\n\nSkill instructions:\n" + skillInstruction + } + userPrompt, historyCount := e.buildUserPrompt(req) + if knowledgeContext != "" { + userPrompt += "\n\nKnowledge evidence:\n" + knowledgeContext + } + var toolCalls []svc.EngineToolCallInput + var result *ai.ChatCompletionResult + agentAllowedTools := autonomousAllowedMCPToolCodes(req.AIAgent.AllowedMCPTools) + toolPolicy := parseAutonomousToolPolicy(req.AIAgent.ToolPolicy) + allowedTools := agentAllowedTools + if skillContext.Skill != nil { + allowedTools = intersectAutonomousToolCodes(agentAllowedTools, skillContext.AllowedToolCodes) + } + if req.Debug { + // Dashboard debug runs may inspect model and retrieval behavior but must + // not invoke direct MCP tools against production integrations. + allowedTools = nil + } + if responsePolicy.Enforced { + result = &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName} + } else if len(allowedTools) > 0 && e.toolChat != nil { + loopResult, loopErr := e.toolChat(ctx, req.AIConfig, systemPrompt, userPrompt, []ai.ToolDefinition{autonomousToolSearchDefinition()}, req.AIAgent.MaxSteps, e.toolSearchExecutor(req.Conversation, req.AIAgent, agentAllowedTools, skillContext.AllowedToolCodes, toolPolicy, &toolCalls)) + if loopErr != nil { + if len(toolCalls) == 0 { + err := loopErr + _, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err) + return nil, err + } + responsePolicy = autonomousToolFailurePolicy(req.AIAgent, "tool_loop_error") + result = &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName} + } + if result == nil && loopResult != nil { + result = &loopResult.ChatCompletionResult + } + if autonomousHasConsecutiveToolFailures(toolCalls, 2) { + responsePolicy = autonomousToolFailurePolicy(req.AIAgent, "tool_consecutive_failures") + result = &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName} + } + } else { + result, err = e.chat(ctx, req.AIConfig, systemPrompt, userPrompt) + } + if err != nil { + _, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err) + return nil, err + } + if result == nil || strings.TrimSpace(result.Content) == "" { + err = errorsx.InvalidParam("autonomous engine returned an empty reply") + _, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err) + return nil, err + } + result.Content, err = aitooling.NormalizeCustomerReply(result.Content) + if err != nil { + _, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err) + return nil, err + } + runID, recordErr := writeAutonomousRun(req, startedAt, result, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, nil) + if recordErr != nil { + return nil, recordErr + } + trace, _ := json.Marshal(map[string]any{ + "engine": EngineCodeAutonomous, + "mode": autonomousExecutionMode(allowedTools), + "historyMessageCount": historyCount, + "retrieverCount": retrieverCount, + "skillID": skillContext.SkillID(), + "skillRouteError": skillContext.ErrorMessage, + "responsePolicyAction": responsePolicy.Action, + "debug": req.Debug, + }) + return &Summary{ + Status: "completed", + ReplyText: strings.TrimSpace(result.Content), + ModelName: result.ModelName, + PromptTokens: result.PromptTokens, + CompletionTokens: result.CompletionTokens, + HistoryMessageCount: historyCount, + RetrieverCount: retrieverCount, + PlannedSkillID: skillContext.SkillID(), + PlannedSkillName: skillContext.SkillName(), + PlanReason: skillContext.MatchReason, + SkillRouteTrace: skillContext.TraceData, + SkillAllowedToolCodes: append([]string(nil), skillContext.AllowedToolCodes...), + AgentRunID: runID, + HandoffRequested: responsePolicy.RequestHandoff && !req.Debug, + TraceData: string(trace), + }, nil +} + +func (e *AutonomousEngine) buildUserPrompt(req Request) (string, int) { + limit := req.AIAgent.ContextWindow + if limit <= 0 { + limit = 12 + } + if limit > 20 { + limit = 20 + } + items := []models.Message(nil) + if e.history != nil && req.Conversation.ID > 0 { + // The triggering customer message is already persisted in most reply + // paths. Fetch one extra item so it does not consume history capacity. + items = e.history(req.Conversation.ID, limit+1) + } + lines := make([]string, 0, len(items)+2) + for _, item := range items { + if item.ID == req.UserMessage.ID || strings.TrimSpace(item.Content) == "" { + continue + } + role := autonomousMessageRole(item) + if role == "" { + continue + } + lines = append(lines, role+": "+utils.BuildRuntimeMessageText(item.MessageType, item.Content)) + } + if len(lines) > limit { + lines = lines[len(lines)-limit:] + } + current := strings.TrimSpace(req.UserMessage.Content) + customerContext := buildAutonomousCustomerContext(req.Conversation) + if len(lines) == 0 && customerContext == "" { + return current, 0 + } + parts := make([]string, 0, 3) + if customerContext != "" { + parts = append(parts, "Customer context:\n"+customerContext) + } + if len(lines) > 0 { + parts = append(parts, "Conversation history:\n"+strings.Join(lines, "\n")) + } + parts = append(parts, "Current customer message:\n"+current) + return strings.Join(parts, "\n\n"), len(lines) +} + +func buildAutonomousCustomerContext(conversation models.Conversation) string { + parts := make([]string, 0, 2) + if name := strings.TrimSpace(conversation.CustomerName); name != "" { + parts = append(parts, "Customer: "+name) + } + if summary := strings.TrimSpace(conversation.LastMessageSummary); summary != "" { + parts = append(parts, "Recent summary: "+summary) + } + return strings.Join(parts, "\n") +} + +func autonomousMessageRole(message models.Message) string { + switch message.SenderType { + case "customer": + return "Customer" + case "ai", "agent": + return "Assistant" + default: + return "" + } +} + +func (e *AutonomousEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) { + return nil, errorsx.InvalidParam("autonomous agent has no resumable checkpoint") +} + +func (e *AutonomousEngine) retrieveKnowledge(ctx context.Context, agent models.AIAgent, query string) (string, int, error) { + if e.retrieve == nil || len(utils.SplitInt64s(agent.KnowledgeIDs)) == 0 { + return "", 0, nil + } + return e.retrieve(ctx, agent, query) +} + +type autonomousSkillContext struct { + Skill *models.SkillDefinition + MatchReason string + TraceData string + ErrorMessage string + AllowedToolCodes []string +} + +type autonomousResponsePolicy struct { + Enforced bool + Action string + Reason string + ReplyText string + RequestHandoff bool +} + +func evaluateAutonomousResponsePolicy(agent models.AIAgent, knowledgeContext string, retrieveErr error) autonomousResponsePolicy { + if len(utils.SplitInt64s(agent.KnowledgeIDs)) == 0 || strings.TrimSpace(knowledgeContext) != "" && retrieveErr == nil { + return autonomousResponsePolicy{} + } + if retrieveErr != nil { + return autonomousKnowledgeFallbackPolicy(agent, "knowledge_retrieve_error") + } + return autonomousKnowledgeFallbackPolicy(agent, "knowledge_evidence_missing") +} + +func autonomousKnowledgeFallbackPolicy(agent models.AIAgent, reason string) autonomousResponsePolicy { + if agent.FallbackMode == enums.AIAgentFallbackModeHandoff { + return autonomousResponsePolicy{ + Enforced: true, Action: "handoff", Reason: reason, RequestHandoff: true, + ReplyText: autonomousKnowledgeFallbackReply(agent), + } + } + return autonomousResponsePolicy{ + Enforced: true, Action: "clarify", Reason: reason, + ReplyText: autonomousKnowledgeFallbackReply(agent), + } +} + +func autonomousToolFailurePolicy(agent models.AIAgent, reason string) autonomousResponsePolicy { + if agent.FallbackMode == enums.AIAgentFallbackModeHandoff { + return autonomousResponsePolicy{ + Enforced: true, Action: "handoff", Reason: reason, RequestHandoff: true, + ReplyText: autonomousToolFailureReply(agent), + } + } + return autonomousResponsePolicy{ + Enforced: true, Action: "clarify", Reason: reason, + ReplyText: autonomousToolFailureReply(agent), + } +} + +func autonomousToolFailureReply(agent models.AIAgent) string { + if reply := strings.TrimSpace(agent.FallbackMessage); reply != "" { + return reply + } + if agent.FallbackMode == enums.AIAgentFallbackModeHandoff { + return "暂时无法完成所需查询,正在为你转接人工客服。" + } + return "暂时无法完成所需查询,请补充更具体的信息后再试一次。" +} + +func autonomousHasConsecutiveToolFailures(calls []svc.EngineToolCallInput, minimum int) bool { + if minimum <= 0 { + return false + } + failures := 0 + for index := len(calls) - 1; index >= 0; index-- { + if calls[index].Status != "failed" { + break + } + failures++ + } + return failures >= minimum +} + +func autonomousKnowledgeFallbackReply(agent models.AIAgent) string { + if reply := strings.TrimSpace(agent.FallbackMessage); reply != "" { + return reply + } + if agent.FallbackMode == 0 || agent.FallbackMode == enums.AIAgentFallbackModeSuggestRetry { + return "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。" + } + if agent.FallbackMode == enums.AIAgentFallbackModeHandoff { + return "当前知识库没有足够明确的信息,正在为你转接人工客服。" + } + return "当前知识库暂无明确信息。" +} + +func (c autonomousSkillContext) SkillID() int64 { + if c.Skill == nil { + return 0 + } + return c.Skill.ID +} + +func (c autonomousSkillContext) SkillName() string { + if c.Skill == nil { + return "" + } + return strings.TrimSpace(c.Skill.Name) +} + +func (e *AutonomousEngine) selectSkill(ctx context.Context, req Request) autonomousSkillContext { + if e.skillSelect == nil || len(utils.SplitInt64s(req.AIAgent.SkillIDs)) == 0 { + return autonomousSkillContext{} + } + result, err := e.skillSelect(ctx, skills.RuntimeContext{ + AIAgent: req.AIAgent, AIConfig: req.AIConfig, UserMessage: req.UserMessage.Content, ConversationID: req.Conversation.ID, + }) + ret := autonomousSkillContext{} + if err != nil { + ret.ErrorMessage = err.Error() + return ret + } + if result == nil || result.Plan == nil { + return ret + } + ret.Skill = result.Plan.Skill + ret.MatchReason = strings.TrimSpace(result.Plan.MatchReason) + if result.Trace != nil { + data, _ := json.Marshal(result.Trace) + ret.TraceData = string(data) + } + if ret.Skill != nil { + ret.AllowedToolCodes = parseSkillToolWhitelist(ret.Skill.ToolWhitelist) + } + return ret +} + +func parseSkillToolWhitelist(raw string) []string { + var items []string + if json.Unmarshal([]byte(strings.TrimSpace(raw)), &items) != nil { + return nil + } + ret := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + item = toolx.NormalizeToolCodeAlias(strings.TrimSpace(item)) + if item == "" { + continue + } + if _, exists := seen[item]; exists { + continue + } + seen[item] = struct{}{} + ret = append(ret, item) + } + return ret +} + +func intersectAutonomousToolCodes(agentAllowed, skillAllowed []string) []string { + if len(agentAllowed) == 0 || len(skillAllowed) == 0 { + return nil + } + allowed := make(map[string]struct{}, len(skillAllowed)) + for _, item := range skillAllowed { + allowed[toolx.NormalizeToolCodeAlias(strings.TrimSpace(item))] = struct{}{} + } + ret := make([]string, 0, len(agentAllowed)) + for _, item := range agentAllowed { + item = toolx.NormalizeToolCodeAlias(strings.TrimSpace(item)) + if _, ok := allowed[item]; ok { + ret = append(ret, item) + } + } + return ret +} + +type autonomousDirectTool struct { + ToolCode string `json:"toolCode"` +} + +type autonomousToolSearchRequest struct { + ToolCode string `json:"toolCode"` + Arguments map[string]any `json:"arguments"` +} + +type autonomousToolPolicy struct { + MaxTotalCalls int `json:"maxTotalCalls"` + MaxArgumentBytes int `json:"maxArgumentBytes"` + AllowedRiskLevels []string `json:"allowedRiskLevels"` +} + +func parseAutonomousToolPolicy(raw string) autonomousToolPolicy { + policy := autonomousToolPolicy{MaxTotalCalls: 3, MaxArgumentBytes: 32 * 1024} + if json.Unmarshal([]byte(strings.TrimSpace(raw)), &policy) != nil { + return policy + } + if policy.MaxTotalCalls <= 0 || policy.MaxTotalCalls > 8 { + policy.MaxTotalCalls = 3 + } + if policy.MaxArgumentBytes <= 0 || policy.MaxArgumentBytes > 64*1024 { + policy.MaxArgumentBytes = 32 * 1024 + } + return policy +} + +func autonomousAllowedMCPToolCodes(raw string) []string { + var items []autonomousDirectTool + if json.Unmarshal([]byte(strings.TrimSpace(raw)), &items) != nil { + return nil + } + ret := make([]string, 0, len(items)) + for _, item := range items { + if code := strings.TrimSpace(item.ToolCode); code != "" { + ret = append(ret, code) + } + } + return ret +} + +func autonomousToolSearchDefinition() ai.ToolDefinition { + return ai.ToolDefinition{ + Name: "tool_search", + Description: "Use a configured read-only tool only when it is needed to answer the customer. Pass the exact allowed toolCode and an arguments object.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "toolCode": map[string]any{"type": "string"}, + "arguments": map[string]any{"type": "object"}, + }, + "required": []string{"toolCode", "arguments"}, + }, + } +} + +func (e *AutonomousEngine) toolSearchExecutor(conversation models.Conversation, agent models.AIAgent, allowedCodes, skillAllowedCodes []string, toolPolicy autonomousToolPolicy, records *[]svc.EngineToolCallInput) ai.ToolCallExecutor { + return func(ctx context.Context, call ai.ToolCall) (string, error) { + startedAt := time.Now() + if call.Name != "tool_search" { + return "", fmt.Errorf("unsupported autonomous tool: %s", call.Name) + } + var req autonomousToolSearchRequest + if err := json.Unmarshal([]byte(call.Arguments), &req); err != nil { + return "", fmt.Errorf("invalid tool_search arguments: %w", err) + } + policy := aitooling.Policy{ + AllowedToolCodes: allowedCodes, SkillAllowedToolCodes: skillAllowedCodes, AllowedRiskLevels: toolPolicy.AllowedRiskLevels, + CallCount: autonomousToolCallCount(*records, req.ToolCode), + TotalCallCount: len(*records), + MaxTotalCalls: toolPolicy.MaxTotalCalls, + MaxArgumentBytes: toolPolicy.MaxArgumentBytes, + Confirmed: true, // The Agent's persisted allow-list is the administrator approval boundary. + } + definition, resultPreview, err := executeAutonomousReadTool(ctx, conversation, agent, strings.TrimSpace(req.ToolCode), req.Arguments, policy) + if err != nil && definition.Code == "" { + mcpDefinition, result, mcpErr := aitooling.DefaultMCPExecutor.Execute(ctx, strings.TrimSpace(req.ToolCode), req.Arguments, policy) + definition, err = mcpDefinition, mcpErr + resultPreview = runtimetooling.BuildReducedToolResultSummary(result) + } + durationMS := int(time.Since(startedAt).Milliseconds()) + record := svc.EngineToolCallInput{ + ToolCode: strings.TrimSpace(req.ToolCode), Status: "completed", ArgumentsPreview: aitooling.SanitizePreview(call.Arguments), DurationMS: durationMS, + } + if definition.Code != "" { + record.ToolCode = definition.Code + record.RiskLevel = definition.RiskLevel + record.RequireConfirm = definition.RequireConfirmation + } + if err != nil { + record.Status = "failed" + record.ErrorMessage = err.Error() + *records = append(*records, record) + return "", err + } + record.ResultPreview = aitooling.SanitizePreview(resultPreview) + *records = append(*records, record) + return record.ResultPreview, nil + } +} + +func executeAutonomousReadTool(ctx context.Context, conversation models.Conversation, agent models.AIAgent, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, string, error) { + toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode)) + if toolCode != toolx.BuiltinConversationContext.Code && toolCode != toolx.BuiltinKnowledgeRetrieve.Code && toolCode != toolx.GraphTriageServiceRequest.Code && toolCode != toolx.GraphAnalyzeConversation.Code && toolCode != toolx.GraphPrepareTicketDraft.Code { + return aitooling.Definition{}, "", fmt.Errorf("tool is not a built-in read tool") + } + if toolCode == toolx.GraphTriageServiceRequest.Code || toolCode == toolx.GraphAnalyzeConversation.Code || toolCode == toolx.GraphPrepareTicketDraft.Code { + return readtools.ExecuteGraphTool(ctx, conversation, toolCode, arguments, policy) + } + definition, err := aitooling.DefaultRegistry.Resolve(toolCode) + if err != nil { + return aitooling.Definition{}, "", err + } + if err := aitooling.DefaultRegistry.Authorize(definition, policy); err != nil { + return definition, "", err + } + if definition.TimeoutMS > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond) + defer cancel() + } + if toolCode == toolx.BuiltinKnowledgeRetrieve.Code { + query, _ := arguments["query"].(string) + contextText, count, err := retrieveAutonomousKnowledge(ctx, agent, query) + if err != nil { + return definition, "", err + } + result, err := json.Marshal(map[string]any{"query": strings.TrimSpace(query), "resultCount": count, "context": contextText}) + return definition, string(result), err + } + result, err := json.Marshal(map[string]any{ + "conversationId": conversation.ID, + "customerName": strings.TrimSpace(conversation.CustomerName), + "lastMessageSummary": strings.TrimSpace(conversation.LastMessageSummary), + "currentAssigneeId": conversation.CurrentAssigneeID, + "recentMessages": autonomousToolConversationMessages(conversation.ID), + }) + if err != nil { + return definition, "", err + } + return definition, string(result), nil +} + +func autonomousToolConversationMessages(conversationID int64) []map[string]string { + if conversationID <= 0 { + return []map[string]string{} + } + items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, 6, "", "") + ret := make([]map[string]string, 0, len(items)) + for _, item := range items { + role := autonomousMessageRole(item) + content := strings.TrimSpace(utils.BuildRuntimeMessageText(item.MessageType, item.Content)) + if role == "" || content == "" { + continue + } + if runes := []rune(content); len(runes) > 240 { + content = string(runes[:240]) + "..." + } + ret = append(ret, map[string]string{"role": role, "content": content}) + } + return ret +} + +func autonomousToolCallCount(records []svc.EngineToolCallInput, toolCode string) int { + toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode)) + count := 0 + for _, item := range records { + if toolx.NormalizeToolCodeAlias(strings.TrimSpace(item.ToolCode)) == toolCode { + count++ + } + } + return count +} + +func retrieveAutonomousKnowledge(ctx context.Context, agent models.AIAgent, query string) (string, int, error) { + retrieved, err := retrievers.NewKnowledgeRetriever(agent, utils.SplitInt64s(agent.KnowledgeIDs)).RetrieveContext(ctx, query) + if err != nil { + return "", 0, err + } + if retrieved == nil { + return "", 0, nil + } + return strings.TrimSpace(retrieved.ContextText), len(retrieved.ContextResults), nil +} + +func buildAutonomousSystemPrompt(agent models.AIAgent, hasKnowledgeBase bool, knowledgeContext string, retrieveErr error) string { + prompt := strings.TrimSpace(agent.SystemPrompt) + if prompt == "" { + prompt = "You are a customer service assistant. Answer accurately, ask for clarification when evidence is insufficient, and do not invent facts." + } + if hasKnowledgeBase && strings.TrimSpace(knowledgeContext) == "" { + prompt += "\n\nNo supporting knowledge was retrieved. Do not invent an answer; ask a focused clarification question or offer human handoff." + } + if retrieveErr != nil { + prompt += "\n\nKnowledge retrieval is temporarily unavailable. Do not claim to have verified any policy or factual detail." + } + return prompt +} + +func writeAutonomousRun(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount int, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, responsePolicy autonomousResponsePolicy, toolCalls []svc.EngineToolCallInput, cause error) (int64, error) { + endedAt := time.Now() + status := "completed" + errorMessage := "" + outputPreview := "" + promptTokens := 0 + completionTokens := 0 + if cause != nil { + status = "failed" + errorMessage = cause.Error() + } else if result != nil { + outputPreview = strings.TrimSpace(result.Content) + promptTokens = result.PromptTokens + completionTokens = result.CompletionTokens + } + trace, _ := json.Marshal(map[string]any{"engine": EngineCodeAutonomous, "mode": autonomousExecutionMode(autonomousAllowedMCPToolCodes(req.AIAgent.AllowedMCPTools)), "status": status, "historyMessageCount": historyCount, "retrieverCount": retrieverCount}) + var runID int64 + err := sqls.WithTransaction(func(tx *sqls.TxContext) error { + var recordErr error + runID, recordErr = svc.AgentRunService.RecordEngineRun(tx.Tx, svc.EngineAgentRunInput{ + ConversationID: req.Conversation.ID, AIAgentID: req.AIAgent.ID, AgentRevisionID: req.AIAgent.PublishedRevisionID, + SourceMessageID: req.UserMessage.ID, EngineCode: EngineCodeAutonomous, Status: status, + PromptTokens: promptTokens, CompletionTokens: completionTokens, StartedAt: startedAt, EndedAt: &endedAt, + ErrorMessage: errorMessage, TraceData: string(trace), StepType: "model", StepCode: "chat_completion", + StepInputPreview: strings.TrimSpace(inputPreview), StepOutputPreview: outputPreview, + AdditionalSteps: autonomousAdditionalSteps(req, retrieverCount, retrieveErr, skillContext, responsePolicy), + ToolCalls: toolCalls, + }) + return recordErr + }) + return runID, err +} + +func autonomousExecutionMode(allowedTools []string) string { + if len(allowedTools) > 0 { + return "tool_calling_loop" + } + return "single_model_turn" +} + +func autonomousAdditionalSteps(req Request, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, responsePolicy autonomousResponsePolicy) []svc.EngineStepInput { + steps := make([]svc.EngineStepInput, 0, 3) + if len(utils.SplitInt64s(req.AIAgent.SkillIDs)) > 0 { + status := "completed" + if skillContext.ErrorMessage != "" { + status = "failed" + } + steps = append(steps, svc.EngineStepInput{ + StepType: "skill_route", StepCode: "skill_select", Status: status, + InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "selected skill: " + skillContext.SkillName(), + ErrorMessage: skillContext.ErrorMessage, + }) + } + if len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0 { + status := "completed" + errorMessage := "" + if retrieveErr != nil { + status = "failed" + errorMessage = retrieveErr.Error() + } + steps = append(steps, svc.EngineStepInput{ + StepType: "knowledge", StepCode: "knowledge_retrieve", Status: status, + InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "retrieved context items: " + strconv.Itoa(retrieverCount), ErrorMessage: errorMessage, + }) + } + if responsePolicy.Enforced { + policyCode := "knowledge_evidence" + if strings.HasPrefix(responsePolicy.Reason, "tool_") { + policyCode = "tool_failure" + } + steps = append(steps, svc.EngineStepInput{ + StepType: "policy", StepCode: policyCode, Status: "completed", + InputPreview: responsePolicy.Reason, OutputPreview: responsePolicy.Action, + }) + } + return steps +} + +var _ Engine = (*AutonomousEngine)(nil) diff --git a/internal/ai/application/runtime/einoexperiment/confirmation_bridge.go b/internal/ai/application/runtime/einoexperiment/confirmation_bridge.go new file mode 100644 index 0000000..22da620 --- /dev/null +++ b/internal/ai/application/runtime/einoexperiment/confirmation_bridge.go @@ -0,0 +1,102 @@ +package einoexperiment + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + applicationruntime "agent-desk/internal/ai/application/runtime" + "agent-desk/internal/ai/runtime/graphs" +) + +const confirmationInterruptType = "human_confirm" + +// ConfirmationRequest represents a high-risk Eino tool action that must pause +// at AgentDesk's existing conversation-interrupt boundary. +type ConfirmationRequest struct { + InterruptID string + ToolCode string + Prompt string + Arguments map[string]any +} + +type confirmationCheckpoint struct { + Version int `json:"version"` + Engine string `json:"engine"` + InterruptID string `json:"interruptId"` + ToolCode string `json:"toolCode"` + Arguments map[string]any `json:"arguments"` +} + +// BuildConfirmationResult returns the generic interrupted result consumed by +// replyInterruptService. That service persists ConversationInterrupt from the +// result, so this package remains independent of database writes. +func BuildConfirmationResult(input applicationruntime.RunInput, request ConfirmationRequest) (*applicationruntime.RunResult, error) { + interruptID := strings.TrimSpace(request.InterruptID) + if interruptID == "" { + interruptID = "eino_confirm" + } + toolCode := strings.TrimSpace(request.ToolCode) + if toolCode == "" { + return nil, fmt.Errorf("confirmation tool code is required") + } + prompt := strings.TrimSpace(request.Prompt) + if prompt == "" { + return nil, fmt.Errorf("confirmation prompt is required") + } + checkpointData, err := json.Marshal(confirmationCheckpoint{ + Version: 1, Engine: "eino", InterruptID: interruptID, ToolCode: toolCode, Arguments: cloneConfirmationArguments(request.Arguments), + }) + if err != nil { + return nil, fmt.Errorf("encode Eino confirmation checkpoint: %w", err) + } + return &applicationruntime.RunResult{ + Status: "interrupted", + Interrupted: true, + CheckPointID: confirmationCheckpointID(input, interruptID, toolCode, checkpointData), + CheckPointData: string(checkpointData), + Interrupts: []applicationruntime.InterruptContextSummary{{ + Type: confirmationInterruptType, ID: interruptID, InfoPreview: string(mustMarshalConfirmationPrompt(prompt)), + }}, + }, nil +} + +// ResumeConfirmation reads the generic ResumeInput populated by the existing +// AgentApplicationService and validates that it belongs to this checkpoint. +func ResumeConfirmation(checkPointData string, input applicationruntime.ResumeInput) (string, confirmationCheckpoint, error) { + checkpoint := confirmationCheckpoint{} + if err := json.Unmarshal([]byte(strings.TrimSpace(checkPointData)), &checkpoint); err != nil { + return "", checkpoint, fmt.Errorf("decode Eino confirmation checkpoint: %w", err) + } + if checkpoint.Version != 1 || checkpoint.Engine != "eino" || strings.TrimSpace(checkpoint.InterruptID) == "" || strings.TrimSpace(checkpoint.ToolCode) == "" { + return "", checkpoint, fmt.Errorf("invalid Eino confirmation checkpoint") + } + decision := graphs.ParseConfirmationDecision(strings.TrimSpace(input.ResumeData[checkpoint.InterruptID])) + if decision == "" { + return "", checkpoint, fmt.Errorf("Eino confirmation decision is required") + } + return string(decision), checkpoint, nil +} + +func confirmationCheckpointID(input applicationruntime.RunInput, interruptID, toolCode string, data []byte) string { + digest := sha256.Sum256(append([]byte(strings.TrimSpace(toolCode)+":"+strings.TrimSpace(interruptID)+":"), data...)) + return fmt.Sprintf("eino:%d:%d:%s", input.Conversation.ID, input.UserMessage.ID, hex.EncodeToString(digest[:8])) +} + +func cloneConfirmationArguments(input map[string]any) map[string]any { + if len(input) == 0 { + return map[string]any{} + } + ret := make(map[string]any, len(input)) + for key, value := range input { + ret[key] = value + } + return ret +} + +func mustMarshalConfirmationPrompt(prompt string) []byte { + data, _ := json.Marshal(map[string]string{"message": prompt}) + return data +} diff --git a/internal/ai/application/runtime/einoexperiment/guarded_tool.go b/internal/ai/application/runtime/einoexperiment/guarded_tool.go new file mode 100644 index 0000000..09613df --- /dev/null +++ b/internal/ai/application/runtime/einoexperiment/guarded_tool.go @@ -0,0 +1,94 @@ +package einoexperiment + +import ( + "context" + "encoding/json" + "fmt" + "time" + + aitooling "agent-desk/internal/ai/tooling" + + einotool "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/schema" +) + +// ToolHandler is the adapter point from an approved Eino experiment tool to +// AgentDesk business services. Production handlers must still call services, +// never repositories. +type ToolHandler func(ctx context.Context, arguments map[string]any) (string, error) + +// ToolTrace is emitted for every guarded invocation. A future Engine adapter +// can translate it into AgentRun tool-call audit records without coupling this +// experiment package to the service layer. +type ToolTrace struct { + ToolCode string + Arguments map[string]any + Status string + Result string + Err error + Duration time.Duration +} + +type ToolTraceHook func(ToolTrace) + +// GuardedTool adapts an Eino InvokableTool to the shared ToolPolicyGuard. It is +// deliberately generic so Tool Registry semantics are checked before a tool +// handler is invoked. +type GuardedTool struct { + InfoDefinition *schema.ToolInfo + Definition aitooling.Definition + Policy aitooling.Policy + Handler ToolHandler + Trace ToolTraceHook +} + +var _ einotool.InvokableTool = (*GuardedTool)(nil) + +func (t *GuardedTool) Info(context.Context) (*schema.ToolInfo, error) { + if t == nil || t.InfoDefinition == nil { + return nil, fmt.Errorf("eino experiment tool info is required") + } + return t.InfoDefinition, nil +} + +func (t *GuardedTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...einotool.Option) (string, error) { + if t == nil || t.Handler == nil { + return "", fmt.Errorf("eino experiment tool handler is required") + } + startedAt := time.Now() + arguments := map[string]any{} + if err := json.Unmarshal([]byte(argumentsInJSON), &arguments); err != nil { + t.emitTrace(arguments, "failed", "", err, startedAt) + return "", fmt.Errorf("decode tool arguments: %w", err) + } + if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{ + Definition: t.Definition, + Arguments: arguments, + Policy: t.Policy, + }); err != nil { + t.emitTrace(arguments, "failed", "", err, startedAt) + return "", err + } + if t.Definition.TimeoutMS > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(t.Definition.TimeoutMS)*time.Millisecond) + defer cancel() + } + result, err := t.Handler(ctx, arguments) + status := "completed" + if err != nil { + status = "failed" + } + t.emitTrace(arguments, status, result, err, startedAt) + return result, err +} + +func (t *GuardedTool) emitTrace(arguments map[string]any, status, result string, err error, startedAt time.Time) { + if t == nil || t.Trace == nil { + return + } + t.Trace(ToolTrace{ + ToolCode: t.Definition.Code, Arguments: arguments, Status: status, Result: result, Err: err, + Duration: time.Since(startedAt), + }) +} diff --git a/internal/ai/application/runtime/einoexperiment/mcp_tool.go b/internal/ai/application/runtime/einoexperiment/mcp_tool.go new file mode 100644 index 0000000..bd37e24 --- /dev/null +++ b/internal/ai/application/runtime/einoexperiment/mcp_tool.go @@ -0,0 +1,37 @@ +package einoexperiment + +import ( + "context" + "fmt" + "strings" + + "agent-desk/internal/ai/mcps" + runtimetooling "agent-desk/internal/ai/runtime/tooling" + aitooling "agent-desk/internal/ai/tooling" +) + +// MCPToolExecutor is the narrow execution boundary used by the Eino +// experiment. The production MCP executor remains responsible for dynamic +// registry resolution, policy enforcement, timeout, and transport lifecycle. +type MCPToolExecutor interface { + Execute(context.Context, string, map[string]any, aitooling.Policy) (aitooling.Definition, *mcps.ToolCallResult, error) +} + +// NewMCPToolHandler adapts a dynamically discovered MCP tool to GuardedTool. +// Callers must still configure GuardedTool.Definition and Policy so its +// pre-handler guard provides a deterministic rejection before MCP transport. +func NewMCPToolHandler(executor MCPToolExecutor, toolCode string, policy aitooling.Policy) ToolHandler { + return func(ctx context.Context, arguments map[string]any) (string, error) { + if executor == nil { + return "", fmt.Errorf("eino experiment MCP executor is required") + } + definition, result, err := executor.Execute(ctx, strings.TrimSpace(toolCode), arguments, policy) + if err != nil { + return "", err + } + if definition.Code == "" { + return "", fmt.Errorf("MCP executor returned an empty tool definition") + } + return runtimetooling.BuildReducedToolResultSummary(result), nil + } +} diff --git a/internal/ai/application/runtime/einoexperiment/react.go b/internal/ai/application/runtime/einoexperiment/react.go new file mode 100644 index 0000000..beaf307 --- /dev/null +++ b/internal/ai/application/runtime/einoexperiment/react.go @@ -0,0 +1,86 @@ +// Package einoexperiment contains an isolated Eino ReAct verification path. +// It must not be registered in the production Agent Engine registry. +package einoexperiment + +import ( + "context" + "fmt" + "strings" + "time" + + "agent-desk/internal/models" + + einoopenai "github.com/cloudwego/eino-ext/components/model/openai" + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/flow/agent/react" + "github.com/cloudwego/eino/schema" +) + +// ReActConfig keeps the experiment dependency-injected. The caller owns model +// construction, connection reuse, and all production configuration decisions. +type ReActConfig struct { + Model model.ToolCallingChatModel + Tools []tool.BaseTool + MaxSteps int +} + +// NewOpenAICompatibleModel adapts an existing AgentDesk AI configuration to +// Eino's OpenAI-compatible chat model. It is intentionally not wired into any +// production Engine; the experiment owns the adoption decision. +func NewOpenAICompatibleModel(ctx context.Context, config models.AIConfig) (model.ToolCallingChatModel, error) { + if strings.TrimSpace(config.APIKey) == "" || strings.TrimSpace(config.BaseURL) == "" || strings.TrimSpace(config.ModelName) == "" { + return nil, fmt.Errorf("ai config base URL, API key, and model name are required") + } + modelConfig := &einoopenai.ChatModelConfig{ + APIKey: strings.TrimSpace(config.APIKey), + BaseURL: strings.TrimSpace(config.BaseURL), + Model: strings.TrimSpace(config.ModelName), + } + if config.TimeoutMS > 0 { + modelConfig.Timeout = time.Duration(config.TimeoutMS) * time.Millisecond + } + if config.MaxOutputTokens > 0 { + maxTokens := config.MaxOutputTokens + modelConfig.MaxCompletionTokens = &maxTokens + } + return einoopenai.NewChatModel(ctx, modelConfig) +} + +// NewReAct creates an Eino ReAct agent without registering it with AgentDesk's +// runtime. It is deliberately suitable only for technical verification. +func NewReAct(ctx context.Context, config ReActConfig) (*react.Agent, error) { + if config.Model == nil { + return nil, fmt.Errorf("eino experiment model is required") + } + maxSteps := config.MaxSteps + if maxSteps <= 0 { + maxSteps = 5 + } + return react.NewAgent(ctx, &react.AgentConfig{ + ToolCallingModel: config.Model, + ToolsConfig: compose.ToolsNodeConfig{Tools: config.Tools}, + MaxStep: maxSteps, + }) +} + +// Run performs one non-streaming experiment. Context cancellation is passed +// directly to Eino and the injected model/tools. +func Run(ctx context.Context, config ReActConfig, input []*schema.Message) (*schema.Message, error) { + agent, err := NewReAct(ctx, config) + if err != nil { + return nil, err + } + return agent.Generate(ctx, input) +} + +// Stream performs one streaming experiment. The caller must close the returned +// reader after consuming it. +func Stream(ctx context.Context, config ReActConfig, input []*schema.Message) (*schema.StreamReader[*schema.Message], error) { + agent, err := NewReAct(ctx, config) + if err != nil { + return nil, err + } + return agent.Stream(ctx, input) +} diff --git a/internal/ai/application/runtime/einoexperiment/react_test.go b/internal/ai/application/runtime/einoexperiment/react_test.go new file mode 100644 index 0000000..76348a9 --- /dev/null +++ b/internal/ai/application/runtime/einoexperiment/react_test.go @@ -0,0 +1,376 @@ +package einoexperiment + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + applicationruntime "agent-desk/internal/ai/application/runtime" + "agent-desk/internal/ai/mcps" + "agent-desk/internal/ai/runtime/graphs" + aitooling "agent-desk/internal/ai/tooling" + "agent-desk/internal/models" + + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/schema" +) + +type scriptedToolCallingModel struct { + responses []*schema.Message + calls int + err error + block bool + lastInput []*schema.Message +} + +type fakeMCPToolExecutor struct { + toolCode string + arguments map[string]any + policy aitooling.Policy + result *mcps.ToolCallResult + err error +} + +type concurrentToolCallingModel struct { + calls atomic.Int32 +} + +var _ model.ToolCallingChatModel = (*concurrentToolCallingModel)(nil) + +func (m *concurrentToolCallingModel) Generate(ctx context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + m.calls.Add(1) + return schema.AssistantMessage("并发调用完成。", nil), nil +} + +func (m *concurrentToolCallingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) { + message, err := m.Generate(ctx, input, opts...) + if err != nil { + return nil, err + } + return schema.StreamReaderFromArray([]*schema.Message{message}), nil +} + +func (m *concurrentToolCallingModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) { + return m, nil +} + +func (e *fakeMCPToolExecutor) Execute(_ context.Context, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, *mcps.ToolCallResult, error) { + e.toolCode = toolCode + e.arguments = arguments + e.policy = policy + return aitooling.Definition{Code: toolCode, RiskLevel: aitooling.RiskLevelSensitive}, e.result, e.err +} + +var _ model.ToolCallingChatModel = (*scriptedToolCallingModel)(nil) + +func (m *scriptedToolCallingModel) Generate(ctx context.Context, input []*schema.Message, _ ...model.Option) (*schema.Message, error) { + m.lastInput = append([]*schema.Message(nil), input...) + if err := ctx.Err(); err != nil { + return nil, err + } + if m.block { + <-ctx.Done() + return nil, ctx.Err() + } + if m.err != nil { + return nil, m.err + } + if m.calls >= len(m.responses) { + return nil, errors.New("unexpected model call") + } + result := m.responses[m.calls] + m.calls++ + return result, nil +} + +func (m *scriptedToolCallingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) { + message, err := m.Generate(ctx, input, opts...) + if err != nil { + return nil, err + } + return schema.StreamReaderFromArray([]*schema.Message{message}), nil +} + +func (m *scriptedToolCallingModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) { + return m, nil +} + +func TestRunExecutesGuardedToolThenReturnsFinalAnswer(t *testing.T) { + called := false + guardedTool := &GuardedTool{ + InfoDefinition: &schema.ToolInfo{Name: "customer_lookup", Desc: "Read customer data"}, + Definition: aitooling.Definition{Code: "builtin/customer_lookup", RiskLevel: aitooling.RiskLevelRead}, + Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/customer_lookup"}}, + Handler: func(_ context.Context, arguments map[string]any) (string, error) { + called = arguments["customerId"] == "42" + return "customer: Ada", nil + }, + } + model := &scriptedToolCallingModel{responses: []*schema.Message{ + schema.AssistantMessage("", []schema.ToolCall{{ID: "call-1", Type: "function", Function: schema.FunctionCall{Name: "customer_lookup", Arguments: `{"customerId":"42"}`}}}), + schema.AssistantMessage("已找到客户资料。", nil), + }} + + result, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}, MaxSteps: 4}, []*schema.Message{schema.UserMessage("查询客户")}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !called || result == nil || result.Content != "已找到客户资料。" || model.calls != 2 { + t.Fatalf("unexpected ReAct result: called=%t result=%#v modelCalls=%d", called, result, model.calls) + } +} + +func TestRunInjectsProvidedConversationContext(t *testing.T) { + model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("已理解上下文。", nil)}} + input := []*schema.Message{ + schema.SystemMessage("你是客服助手,优先引用知识库。"), + schema.UserMessage("我的订单状态如何?"), + } + if _, err := Run(context.Background(), ReActConfig{Model: model}, input); err != nil { + t.Fatalf("Run: %v", err) + } + if len(model.lastInput) != len(input) || model.lastInput[0].Content != input[0].Content || model.lastInput[1].Content != input[1].Content { + t.Fatalf("conversation context was not passed to model: %#v", model.lastInput) + } +} + +func TestNewOpenAICompatibleModelValidatesExistingAIConfig(t *testing.T) { + if _, err := NewOpenAICompatibleModel(context.Background(), models.AIConfig{}); err == nil { + t.Fatal("expected incomplete AI config error") + } + configured, err := NewOpenAICompatibleModel(context.Background(), models.AIConfig{ + BaseURL: "https://api.example.test/v1", APIKey: "test-key", ModelName: "test-model", TimeoutMS: 1200, MaxOutputTokens: 256, + }) + if err != nil || configured == nil { + t.Fatalf("expected OpenAI-compatible model adapter, model=%#v err=%v", configured, err) + } +} + +func TestGuardedToolRejectsDisallowedPolicyBeforeHandler(t *testing.T) { + called := false + guardedTool := &GuardedTool{ + InfoDefinition: &schema.ToolInfo{Name: "restricted_lookup", Desc: "Read restricted data"}, + Definition: aitooling.Definition{Code: "builtin/restricted_lookup", RiskLevel: aitooling.RiskLevelRead}, + Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/customer_lookup"}}, + Handler: func(context.Context, map[string]any) (string, error) { + called = true + return "unexpected", nil + }, + } + if _, err := guardedTool.InvokableRun(context.Background(), `{}`); err == nil { + t.Fatal("expected policy rejection") + } + if called { + t.Fatal("handler must not run after policy rejection") + } +} + +func TestRunPropagatesCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("unused", nil)}} + if _, err := Run(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.Canceled) { + t.Fatalf("expected cancellation, got %v", err) + } +} + +func TestRunPropagatesDeadlineDuringModelCall(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + model := &scriptedToolCallingModel{block: true} + if _, err := Run(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected deadline propagation, got %v", err) + } +} + +func TestRunPropagatesModelFailure(t *testing.T) { + modelErr := errors.New("model unavailable") + model := &scriptedToolCallingModel{err: modelErr} + if _, err := Run(context.Background(), ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, modelErr) { + t.Fatalf("expected model error propagation, got %v", err) + } +} + +func TestRunPropagatesToolFailure(t *testing.T) { + toolErr := errors.New("customer service unavailable") + guardedTool := &GuardedTool{ + InfoDefinition: &schema.ToolInfo{Name: "failing_lookup", Desc: "Read customer data"}, + Definition: aitooling.Definition{Code: "builtin/failing_lookup", RiskLevel: aitooling.RiskLevelRead}, + Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/failing_lookup"}}, + Handler: func(context.Context, map[string]any) (string, error) { + return "", toolErr + }, + } + model := &scriptedToolCallingModel{responses: []*schema.Message{ + schema.AssistantMessage("", []schema.ToolCall{{ID: "call-1", Type: "function", Function: schema.FunctionCall{Name: "failing_lookup", Arguments: `{}`}}}), + }} + if _, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, toolErr) { + t.Fatalf("expected tool error propagation, got %v", err) + } +} + +func TestGuardedToolEnforcesTimeout(t *testing.T) { + guardedTool := &GuardedTool{ + InfoDefinition: &schema.ToolInfo{Name: "slow_lookup", Desc: "Read customer data"}, + Definition: aitooling.Definition{Code: "builtin/slow_lookup", RiskLevel: aitooling.RiskLevelRead, TimeoutMS: 20}, + Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/slow_lookup"}}, + Handler: func(ctx context.Context, _ map[string]any) (string, error) { + <-ctx.Done() + return "", ctx.Err() + }, + } + if _, err := guardedTool.InvokableRun(context.Background(), `{}`); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected tool timeout, got %v", err) + } +} + +func TestGuardedToolEmitsTraceForPolicyFailure(t *testing.T) { + var trace ToolTrace + guardedTool := &GuardedTool{ + InfoDefinition: &schema.ToolInfo{Name: "restricted_lookup", Desc: "Read restricted data"}, + Definition: aitooling.Definition{Code: "builtin/restricted_lookup", RiskLevel: aitooling.RiskLevelRead}, + Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/other_lookup"}}, + Handler: func(context.Context, map[string]any) (string, error) { + return "unexpected", nil + }, + Trace: func(item ToolTrace) { trace = item }, + } + if _, err := guardedTool.InvokableRun(context.Background(), `{"customerId":"42"}`); err == nil { + t.Fatal("expected policy rejection") + } + if trace.ToolCode != "builtin/restricted_lookup" || trace.Status != "failed" || trace.Err == nil || trace.Arguments["customerId"] != "42" || trace.Duration < 0 { + t.Fatalf("unexpected trace: %#v", trace) + } +} + +func TestMCPToolHandlerUsesSharedExecutorAndReducesResult(t *testing.T) { + executor := &fakeMCPToolExecutor{result: &mcps.ToolCallResult{Content: []mcps.ToolResultContent{{Type: "text", Text: "customer: Ada"}}}} + policy := aitooling.Policy{AllowedToolCodes: []string{"crm/customer_lookup"}, Confirmed: true} + handler := NewMCPToolHandler(executor, "crm/customer_lookup", policy) + result, err := handler(context.Background(), map[string]any{"customerId": "42"}) + if err != nil || result != "customer: Ada" { + t.Fatalf("unexpected MCP handler result=%q err=%v", result, err) + } + if executor.toolCode != "crm/customer_lookup" || executor.arguments["customerId"] != "42" || !executor.policy.Confirmed { + t.Fatalf("unexpected MCP execution: %#v", executor) + } +} + +func TestRunStopsAtConfiguredMaxSteps(t *testing.T) { + guardedTool := &GuardedTool{ + InfoDefinition: &schema.ToolInfo{Name: "loop_lookup", Desc: "Read loop data"}, + Definition: aitooling.Definition{Code: "builtin/loop_lookup", RiskLevel: aitooling.RiskLevelRead}, + Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/loop_lookup"}}, + Handler: func(context.Context, map[string]any) (string, error) { + return "keep going", nil + }, + } + responses := make([]*schema.Message, 8) + for i := range responses { + responses[i] = schema.AssistantMessage("", []schema.ToolCall{{ + ID: "loop-call", Type: "function", Function: schema.FunctionCall{Name: "loop_lookup", Arguments: `{}`}, + }}) + } + model := &scriptedToolCallingModel{responses: responses} + if _, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}, MaxSteps: 2}, []*schema.Message{schema.UserMessage("循环查询")}); err == nil { + t.Fatal("expected configured maximum step limit to stop the loop") + } +} + +func TestStreamReturnsModelOutput(t *testing.T) { + model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("流式回复", nil)}} + stream, err := Stream(context.Background(), ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}) + if err != nil { + t.Fatalf("Stream: %v", err) + } + defer stream.Close() + result, err := schema.ConcatMessageStream(stream) + if err != nil { + t.Fatalf("ConcatMessageStream: %v", err) + } + if result.Content != "流式回复" { + t.Fatalf("unexpected stream result: %#v", result) + } +} + +func TestStreamPropagatesCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("unused", nil)}} + if _, err := Stream(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.Canceled) { + t.Fatalf("expected stream cancellation, got %v", err) + } +} + +func TestRunSupportsConcurrentIndependentCalls(t *testing.T) { + model := &concurrentToolCallingModel{} + const workers = 16 + errs := make(chan error, workers) + var group sync.WaitGroup + for range workers { + group.Add(1) + go func() { + defer group.Done() + result, err := Run(context.Background(), ReActConfig{Model: model, MaxSteps: 3}, []*schema.Message{schema.UserMessage("并发查询")}) + if err != nil { + errs <- err + return + } + if result == nil || result.Content != "并发调用完成。" { + errs <- errors.New("unexpected concurrent result") + } + }() + } + group.Wait() + close(errs) + for err := range errs { + t.Fatal(err) + } + if model.calls.Load() != workers { + t.Fatalf("model calls = %d, want %d", model.calls.Load(), workers) + } +} + +func TestConfirmationBridgeUsesGenericInterruptAndResumeContracts(t *testing.T) { + input := applicationruntime.RunInput{ + Conversation: models.Conversation{ID: 11}, UserMessage: models.Message{ID: 22}, + } + result, err := BuildConfirmationResult(input, ConfirmationRequest{ + InterruptID: "confirm_refund", ToolCode: "graph/create_ticket_with_confirmation", Prompt: "是否确认提交退款工单?", + Arguments: map[string]any{"title": "退款申请"}, + }) + if err != nil { + t.Fatalf("BuildConfirmationResult: %v", err) + } + if !result.Interrupted || result.Status != "interrupted" || result.CheckPointID == "" || len(result.Interrupts) != 1 || result.Interrupts[0].Type != confirmationInterruptType || result.Interrupts[0].ID != "confirm_refund" { + t.Fatalf("unexpected confirmation result: %#v", result) + } + decision, checkpoint, err := ResumeConfirmation(result.CheckPointData, applicationruntime.ResumeInput{ResumeData: map[string]string{"confirm_refund": "确认"}}) + if err != nil || decision != string(graphs.ConfirmationDecisionConfirm) || checkpoint.ToolCode != "graph/create_ticket_with_confirmation" || checkpoint.Arguments["title"] != "退款申请" { + t.Fatalf("unexpected resume bridge decision=%q checkpoint=%#v err=%v", decision, checkpoint, err) + } + decision, _, err = ResumeConfirmation(result.CheckPointData, applicationruntime.ResumeInput{ResumeData: map[string]string{"confirm_refund": "取消"}}) + if err != nil || decision != string(graphs.ConfirmationDecisionCancel) { + t.Fatalf("unexpected cancellation decision=%q err=%v", decision, err) + } +} + +func BenchmarkRunWithInjectedModel(b *testing.B) { + model := &concurrentToolCallingModel{} + input := []*schema.Message{schema.SystemMessage("你是客服助手。"), schema.UserMessage("查询订单状态")} + b.ReportAllocs() + b.ResetTimer() + for range b.N { + result, err := Run(context.Background(), ReActConfig{Model: model, MaxSteps: 3}, input) + if err != nil || result == nil || result.Content == "" { + b.Fatalf("Run result=%#v err=%v", result, err) + } + } +} diff --git a/internal/ai/application/runtime/einoexperiment/real_endpoint_test.go b/internal/ai/application/runtime/einoexperiment/real_endpoint_test.go new file mode 100644 index 0000000..bda684f --- /dev/null +++ b/internal/ai/application/runtime/einoexperiment/real_endpoint_test.go @@ -0,0 +1,141 @@ +package einoexperiment + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "agent-desk/internal/bootstrap" + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/enums" + + einoopenai "github.com/cloudwego/eino-ext/components/model/openai" + einomodel "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" +) + +// TestRealOpenAICompatibleEndpoint is intentionally opt-in because it spends +// a small amount of configured model quota. It verifies the production-shaped +// OpenAI-compatible adapter without exposing credentials in test output. +func TestRealOpenAICompatibleEndpoint(t *testing.T) { + if os.Getenv("EINO_EXPERIMENT_REAL") != "1" { + t.Skip("set EINO_EXPERIMENT_REAL=1 to run against the configured endpoint") + } + configPath := strings.TrimSpace(os.Getenv("EINO_EXPERIMENT_CONFIG")) + var err error + if configPath == "" { + configPath, err = findExperimentConfigPath() + if err != nil { + t.Fatal(err) + } + } + workingDir, err := os.Getwd() + if err != nil { + t.Fatalf("get working directory: %v", err) + } + repoRoot := filepath.Dir(filepath.Dir(configPath)) + if err := os.Chdir(repoRoot); err != nil { + t.Fatalf("change to config root: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(workingDir) }) + cfg, err := config.Load(configPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + db, err := bootstrap.InitDB(cfg.DB) + if err != nil { + t.Fatalf("open configured database: %v", err) + } + sqlDB, err := db.DB() + if err == nil { + defer sqlDB.Close() + } + var aiConfig models.AIConfig + if err := db.Where("model_type = ? AND status = ?", enums.AIModelTypeLLM, enums.StatusOk).Order("id").First(&aiConfig).Error; err != nil { + t.Fatalf("load enabled LLM config: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(maxInt(aiConfig.TimeoutMS, 30000))*time.Millisecond) + defer cancel() + model, err := NewOpenAICompatibleModel(ctx, aiConfig) + if err != nil { + t.Fatalf("create Eino model adapter: %v", err) + } + input := []*schema.Message{schema.SystemMessage("You are a terse service assistant."), schema.UserMessage("Reply with exactly: OK")} + startedAt := time.Now() + result, err := Run(ctx, ReActConfig{Model: model, MaxSteps: 2}, input) + if err != nil { + t.Fatalf("Eino ReAct request: %v", err) + } + if result == nil || strings.TrimSpace(result.Content) == "" { + t.Fatal("Eino endpoint returned an empty response") + } + if result.ResponseMeta == nil || result.ResponseMeta.Usage == nil { + t.Fatal("Eino endpoint did not return token usage") + } + t.Logf("real endpoint verified: latency=%s promptTokens=%d completionTokens=%d", time.Since(startedAt).Round(time.Millisecond), result.ResponseMeta.Usage.PromptTokens, result.ResponseMeta.Usage.CompletionTokens) + + toolModel, err := model.WithTools([]*schema.ToolInfo{{ + Name: "eino_echo", + Desc: "Echoes a short input. Always call this tool when asked to verify tool calling.", + ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{ + "text": {Type: schema.String, Desc: "Short text to echo", Required: true}, + }), + }}) + if err != nil { + t.Fatalf("bind Eino tool: %v", err) + } + toolResult, err := toolModel.Generate(ctx, []*schema.Message{schema.UserMessage("Verify tool calling by invoking eino_echo with text OK.")}, + einomodel.WithToolChoice(schema.ToolChoiceForced, "eino_echo"), + einoopenai.WithExtraFields(map[string]any{"enable_thinking": false}), + ) + if err != nil { + t.Fatalf("real endpoint tool call: %v", err) + } + if toolResult == nil || len(toolResult.ToolCalls) != 1 || toolResult.ToolCalls[0].Function.Name != "eino_echo" { + t.Fatalf("expected one eino_echo tool call, got %#v", toolResult) + } + + stream, err := model.Stream(ctx, []*schema.Message{schema.UserMessage("Reply with exactly: STREAM_OK")}) + if err != nil { + t.Fatalf("real endpoint stream: %v", err) + } + // ConcatMessageStream consumes and closes the Eino reader. Do not close it + // again here: v0.9.6 treats a second close as a panic. + streamResult, err := schema.ConcatMessageStream(stream) + if err != nil { + t.Fatalf("concat real stream: %v", err) + } + if streamResult == nil || strings.TrimSpace(streamResult.Content) == "" { + t.Fatal("Eino endpoint stream returned an empty response") + } + t.Logf("real endpoint tool and stream verified: toolCalls=%d streamChars=%d", len(toolResult.ToolCalls), len([]rune(streamResult.Content))) +} + +func findExperimentConfigPath() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + candidate := filepath.Join(dir, "config", "config.yaml") + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", os.ErrNotExist + } + dir = parent + } +} + +func maxInt(value, fallback int) int { + if value > 0 { + return value + } + return fallback +} diff --git a/internal/ai/application/runtime/engine.go b/internal/ai/application/runtime/engine.go new file mode 100644 index 0000000..7ee20b3 --- /dev/null +++ b/internal/ai/application/runtime/engine.go @@ -0,0 +1,58 @@ +package runtime + +import ( + "context" + "errors" + "strings" + + "agent-desk/internal/pkg/errorsx" +) + +const ( + EngineCodeWorkflow = "workflow" + EngineCodeAutonomous = "autonomous" +) + +// Engine executes one Agent Runtime mode. Implementations must keep business +// mutations behind AgentDesk services and return a normalized RunResult. +type Engine interface { + Code() string + Run(ctx context.Context, req RunInput) (*RunResult, error) + Resume(ctx context.Context, req ResumeInput) (*RunResult, error) +} + +// EngineRegistry resolves the runtime implementation. Workflow is the default +// until Agent runtime modes are persisted on AIAgent in the next migration. +type EngineRegistry struct { + engines map[string]Engine +} + +func NewEngineRegistry(engines ...Engine) *EngineRegistry { + registry := &EngineRegistry{engines: make(map[string]Engine, len(engines))} + for _, engine := range engines { + if engine == nil || strings.TrimSpace(engine.Code()) == "" { + continue + } + registry.engines[strings.TrimSpace(engine.Code())] = engine + } + return registry +} + +func NewDefaultEngineRegistry() *EngineRegistry { + return NewEngineRegistry(NewWorkflowEngine(), NewAutonomousEngine(), NewHybridEngine()) +} + +func (r *EngineRegistry) Resolve(code string) (Engine, error) { + if r == nil { + return nil, errors.New("agent runtime engine registry is not configured") + } + code = strings.TrimSpace(code) + if code == "" { + code = EngineCodeWorkflow + } + engine := r.engines[code] + if engine == nil { + return nil, errorsx.InvalidParam("agent runtime engine does not exist") + } + return engine, nil +} diff --git a/internal/ai/application/runtime/engine_test.go b/internal/ai/application/runtime/engine_test.go new file mode 100644 index 0000000..b078fb9 --- /dev/null +++ b/internal/ai/application/runtime/engine_test.go @@ -0,0 +1,550 @@ +package runtime + +import ( + "context" + "fmt" + "strings" + "testing" + + ai "agent-desk/internal/ai" + "agent-desk/internal/ai/skills" + aitooling "agent-desk/internal/ai/tooling" + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/toolx" + svc "agent-desk/internal/services" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestServiceDefaultsToWorkflowEngine(t *testing.T) { + service := NewService() + engine, err := service.registry.Resolve("") + if err != nil { + t.Fatalf("resolve default engine: %v", err) + } + if engine.Code() != EngineCodeWorkflow { + t.Fatalf("expected default engine %q, got %q", EngineCodeWorkflow, engine.Code()) + } +} + +func TestServiceDispatchesRequestedEngine(t *testing.T) { + engine := &runtimeTestEngine{code: "test"} + service := NewServiceWithRegistry(NewEngineRegistry(engine)) + summary, err := service.Run(context.Background(), RunInput{AIAgent: models.AIAgent{RuntimeMode: enums.AIAgentRuntimeMode(engine.code)}}) + if err != nil { + t.Fatalf("run requested engine: %v", err) + } + if !engine.ran || summary == nil || summary.Status != "completed" { + t.Fatalf("unexpected engine dispatch result: engine=%#v summary=%#v", engine, summary) + } +} + +func TestEngineContractKeepsLegacyRequestAliasesCompatible(t *testing.T) { + var _ Engine = (*runtimeTestEngine)(nil) + var input Request = RunInput{} + var result Summary = RunResult{Status: "completed"} + if input.Debug || result.Status != "completed" { + t.Fatalf("unexpected compatibility values: input=%#v result=%#v", input, result) + } +} + +func TestServiceRejectsUnknownEngine(t *testing.T) { + service := NewServiceWithRegistry(NewEngineRegistry()) + if _, err := service.Run(context.Background(), Request{AIAgent: models.AIAgent{RuntimeMode: "missing"}}); err == nil { + t.Fatal("expected unknown engine error") + } +} + +func TestAutonomousEngineRecordsPublishedRevisionRun(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + revision := &models.AgentRevision{AgentID: 7, Revision: 1} + if err := db.Create(revision).Error; err != nil { + t.Fatalf("create revision: %v", err) + } + var receivedPrompt string + engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, _ string, prompt string) (*ai.ChatCompletionResult, error) { + receivedPrompt = prompt + return &ai.ChatCompletionResult{Content: "可以协助你处理这个问题。", ModelName: "test-model", PromptTokens: 8, CompletionTokens: 5}, nil + }) + engine.retrieve = func(context.Context, models.AIAgent, string) (string, int, error) { + return "退款需要先确认订单号。", 1, nil + } + summary, err := engine.Run(context.Background(), Request{ + Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "需要帮助"}, + AIAgent: models.AIAgent{ID: 7, PublishedRevisionID: revision.ID, SystemPrompt: "保持专业", KnowledgeIDs: "21"}, AIConfig: models.AIConfig{ModelName: "test-model"}, + }) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if summary == nil || summary.AgentRunID <= 0 || summary.ReplyText == "" { + t.Fatalf("unexpected summary: %#v", summary) + } + var run models.AgentRun + if err := db.First(&run, summary.AgentRunID).Error; err != nil { + t.Fatalf("load agent run: %v", err) + } + if run.EngineCode != EngineCodeAutonomous || run.AgentRevisionID != revision.ID || run.Status != "completed" { + t.Fatalf("unexpected agent run: %#v", run) + } + if run.PromptTokens != 8 || !strings.Contains(receivedPrompt, "Knowledge evidence") { + t.Fatalf("expected knowledge evidence in prompt, got %q", receivedPrompt) + } + var steps []models.AgentStep + if err := db.Where("agent_run_id = ?", run.ID).Find(&steps).Error; err != nil || len(steps) != 2 || steps[1].StepType != "knowledge" { + t.Fatalf("expected model and knowledge steps, steps=%#v err=%v", steps, err) + } +} + +func TestAutonomousEngineRecordsRejectedReplyAsFailed(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + revision := &models.AgentRevision{AgentID: 15, Revision: 1} + if err := db.Create(revision).Error; err != nil { + t.Fatalf("create revision: %v", err) + } + engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) { + return &ai.ChatCompletionResult{Content: "token=secret-value"}, nil + }) + _, err = engine.Run(context.Background(), Request{UserMessage: models.Message{ID: 2, Content: "help"}, AIAgent: models.AIAgent{ID: 15, PublishedRevisionID: revision.ID}}) + if err == nil { + t.Fatal("expected sensitive model reply to be rejected") + } + var run models.AgentRun + if err := db.Last(&run).Error; err != nil || run.Status != "failed" || strings.Contains(run.ErrorMessage, "secret-value") { + t.Fatalf("expected failed audit run, run=%#v err=%v", run, err) + } +} + +func TestAutonomousEngineBuildsBoundedConversationContext(t *testing.T) { + engine := newAutonomousEngineWithChat(nil) + engine.history = func(conversationID int64, limit int) []models.Message { + if conversationID != 11 || limit != 3 { + t.Fatalf("unexpected history query: conversation=%d limit=%d", conversationID, limit) + } + return []models.Message{ + {ID: 1, SenderType: "customer", MessageType: "text", Content: "之前的问题"}, + {ID: 2, SenderType: "ai", MessageType: "text", Content: "之前的答复"}, + {ID: 3, SenderType: "customer", MessageType: "text", Content: "当前问题"}, + } + } + prompt, count := engine.buildUserPrompt(Request{ + Conversation: models.Conversation{ID: 11}, UserMessage: models.Message{ID: 3, Content: "当前问题", MessageType: "text"}, + AIAgent: models.AIAgent{ContextWindow: 2}, + }) + if count != 2 || !strings.Contains(prompt, "Customer: 之前的问题") || !strings.Contains(prompt, "Assistant: 之前的答复") || strings.Count(prompt, "当前问题") != 1 { + t.Fatalf("unexpected assembled prompt: %q", prompt) + } +} + +func TestAutonomousEngineBuildsCustomerContext(t *testing.T) { + engine := newAutonomousEngineWithChat(nil) + prompt, count := engine.buildUserPrompt(Request{ + Conversation: models.Conversation{CustomerName: "张三", LastMessageSummary: "已咨询退款条件"}, + UserMessage: models.Message{Content: "我要申请退款", MessageType: "text"}, + }) + if count != 0 || !strings.Contains(prompt, "Customer: 张三") || !strings.Contains(prompt, "Recent summary: 已咨询退款条件") || !strings.Contains(prompt, "Current customer message:\n我要申请退款") { + t.Fatalf("unexpected customer context: %q", prompt) + } +} + +func TestAutonomousEngineInjectsSelectedSkillAndRecordsRoute(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + revision := &models.AgentRevision{AgentID: 9, Revision: 1} + if err := db.Create(revision).Error; err != nil { + t.Fatalf("create revision: %v", err) + } + var systemPrompt string + engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) { + systemPrompt = system + return &ai.ChatCompletionResult{Content: "我来协助处理退款。", ModelName: "test-model"}, nil + }) + engine.skillSelect = func(context.Context, skills.RuntimeContext) (*skills.ExecutionResult, error) { + return &skills.ExecutionResult{Plan: &skills.ExecutionPlan{ + Skill: &models.SkillDefinition{ID: 70, Name: "退款处理", Instruction: "先核对订单信息。", Examples: `["我要退款"]`, ToolWhitelist: `["support/order_lookup"]`}, + MatchReason: "llm_route", + }, Trace: &skills.ExecutionTrace{Status: "ok", MatchReason: "llm_route"}}, nil + } + summary, err := engine.Run(context.Background(), Request{ + Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "我要退款"}, + AIAgent: models.AIAgent{ID: 9, PublishedRevisionID: revision.ID, SkillIDs: "70", SystemPrompt: "保持简洁"}, AIConfig: models.AIConfig{ModelName: "test-model"}, + }) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if summary.PlannedSkillID != 70 || summary.PlannedSkillName != "退款处理" || summary.PlanReason != "llm_route" { + t.Fatalf("unexpected skill summary: %#v", summary) + } + if !strings.Contains(systemPrompt, "先核对订单信息") || !strings.Contains(systemPrompt, "我要退款") { + t.Fatalf("selected skill was not injected into system prompt: %q", systemPrompt) + } + var steps []models.AgentStep + if err := db.Where("agent_run_id = ?", summary.AgentRunID).Find(&steps).Error; err != nil { + t.Fatalf("load steps: %v", err) + } + if len(steps) != 2 || steps[1].StepType != "skill_route" || steps[1].StepCode != "skill_select" { + t.Fatalf("expected model and skill route audit steps, got %#v", steps) + } +} + +func TestAutonomousEngineEnforcesKnowledgeFallbackPolicy(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + revision := &models.AgentRevision{AgentID: 10, Revision: 1} + if err := db.Create(revision).Error; err != nil { + t.Fatalf("create revision: %v", err) + } + chatCalled := false + engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) { + chatCalled = true + return &ai.ChatCompletionResult{Content: "should not be used"}, nil + }) + engine.retrieve = func(context.Context, models.AIAgent, string) (string, int, error) { + return "", 0, nil + } + summary, err := engine.Run(context.Background(), Request{ + Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "保修多久"}, + AIAgent: models.AIAgent{ID: 10, PublishedRevisionID: revision.ID, KnowledgeIDs: "100", FallbackMessage: "请提供产品型号,我再继续查询。"}, + AIConfig: models.AIConfig{ModelName: "test-model"}, + }) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if chatCalled || summary.ReplyText != "请提供产品型号,我再继续查询。" { + t.Fatalf("knowledge fallback policy was not enforced: chatCalled=%t summary=%#v", chatCalled, summary) + } + var steps []models.AgentStep + if err := db.Where("agent_run_id = ?", summary.AgentRunID).Find(&steps).Error; err != nil { + t.Fatalf("load steps: %v", err) + } + if len(steps) != 3 || steps[1].StepType != "knowledge" || steps[2].StepType != "policy" || steps[2].StepCode != "knowledge_evidence" { + t.Fatalf("expected model, knowledge and policy steps, got %#v", steps) + } +} + +func TestAutonomousEngineDebugRunDoesNotExposeMCPTools(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + revision := &models.AgentRevision{AgentID: 11, Revision: 1} + if err := db.Create(revision).Error; err != nil { + t.Fatalf("create revision: %v", err) + } + engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) { + return &ai.ChatCompletionResult{Content: "调试回复", ModelName: "test-model"}, nil + }) + engine.toolChat = func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error) { + t.Fatal("debug run must not enter tool calling loop") + return nil, nil + } + summary, err := engine.Run(context.Background(), Request{ + Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "查询订单"}, + AIAgent: models.AIAgent{ID: 11, PublishedRevisionID: revision.ID, AllowedMCPTools: `[{"toolCode":"orders/lookup"}]`}, + AIConfig: models.AIConfig{ModelName: "test-model"}, Debug: true, + }) + if err != nil || summary == nil || summary.ReplyText != "调试回复" { + t.Fatalf("unexpected debug run result: summary=%#v err=%v", summary, err) + } +} + +func TestAutonomousEngineUsesPublishedRevisionSnapshot(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + revision := &models.AgentRevision{AgentID: 12, Revision: 1, Definition: `{"agent":{"name":"published","aiConfigId":5,"runtimeMode":"autonomous","maxSteps":4,"systemPrompt":"published instruction"},"model":{"configId":5,"provider":"openai","baseUrl":"https://published.example/v1","modelType":"llm","modelName":"published-model","timeoutMs":12000}}`} + if err := db.Create(revision).Error; err != nil { + t.Fatalf("create revision: %v", err) + } + var receivedSystem string + var receivedConfig models.AIConfig + engine := newAutonomousEngineWithChat(func(_ context.Context, config models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) { + receivedSystem = system + receivedConfig = config + return &ai.ChatCompletionResult{Content: "published response", ModelName: config.ModelName}, nil + }) + _, err = engine.Run(context.Background(), Request{ + Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "hello"}, + AIAgent: models.AIAgent{ID: 12, PublishedRevisionID: revision.ID, SystemPrompt: "draft instruction", AIConfigID: 5}, + AIConfig: models.AIConfig{ID: 5, APIKey: "rotated-key", ModelName: "draft-model"}, + }) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if !strings.Contains(receivedSystem, "published instruction") || strings.Contains(receivedSystem, "draft instruction") { + t.Fatalf("system prompt did not use published snapshot: %q", receivedSystem) + } + if receivedConfig.ModelName != "published-model" || receivedConfig.BaseURL != "https://published.example/v1" || receivedConfig.APIKey != "rotated-key" { + t.Fatalf("model config did not use safe published snapshot: %#v", receivedConfig) + } +} + +func TestHybridEngineUsesBoundPlaybookAndRecordsGenericAudit(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIWorkflowVersion{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + workflowVersion := &models.AIWorkflowVersion{WorkflowID: 21, Version: 1, Status: enums.StatusOk, Definition: `{"schemaVersion":2,"nodes":[{"id":"start_1","type":"start"},{"id":"end_1","type":"end"}],"edges":[{"sourceNodeID":"start_1","targetNodeID":"end_1"}]}`} + if err := db.Create(workflowVersion).Error; err != nil { + t.Fatalf("create workflow version: %v", err) + } + revision := &models.AgentRevision{AgentID: 14, Revision: 1, Status: enums.StatusOk, WorkflowVersionID: workflowVersion.ID, Definition: `{"agent":{"runtimeMode":"hybrid","systemPrompt":"published hybrid prompt","maxSteps":3},"workflowVersionId":1}`} + if err := db.Create(revision).Error; err != nil { + t.Fatalf("create revision: %v", err) + } + engine := NewHybridEngine() + engine.chatWithTools = func(_ context.Context, _ models.AIConfig, system, _ string, definitions []ai.ToolDefinition, _ int, _ ai.ToolCallExecutor) (*ai.ToolLoopResult, error) { + if !strings.Contains(system, "published hybrid prompt") || len(definitions) != 1 || definitions[0].Name != "run_playbook" { + t.Fatalf("unexpected hybrid model context: system=%q definitions=%#v", system, definitions) + } + return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "这是自主回复。", ModelName: "test-model", PromptTokens: 5, CompletionTokens: 4}}, nil + } + summary, err := engine.Run(context.Background(), Request{ + UserMessage: models.Message{ID: 3, Content: "普通咨询"}, + AIAgent: models.AIAgent{ID: 14, RuntimeMode: enums.AIAgentRuntimeModeHybrid, PublishedRevisionID: revision.ID, WorkflowVersionID: workflowVersion.ID}, + AIConfig: models.AIConfig{ModelName: "test-model"}, + }) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if summary == nil || summary.AgentRunID <= 0 || summary.WorkflowRunID != 0 || summary.ReplyText != "这是自主回复。" { + t.Fatalf("unexpected hybrid summary: %#v", summary) + } + var run models.AgentRun + if err := db.First(&run, summary.AgentRunID).Error; err != nil { + t.Fatalf("load agent run: %v", err) + } + if run.EngineCode != "hybrid" || run.AgentRevisionID != revision.ID || run.Status != "completed" { + t.Fatalf("unexpected hybrid audit: %#v", run) + } +} + +func TestHybridEngineRejectsPlaybookWhenToolPolicyDisallowsWrites(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIWorkflowVersion{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + workflowVersion := &models.AIWorkflowVersion{WorkflowID: 22, Version: 1, Status: enums.StatusOk, Definition: `{"schemaVersion":2,"nodes":[{"id":"start_1","type":"start"},{"id":"end_1","type":"end"}],"edges":[{"sourceNodeID":"start_1","targetNodeID":"end_1"}]}`} + if err := db.Create(workflowVersion).Error; err != nil { + t.Fatalf("create workflow version: %v", err) + } + revision := &models.AgentRevision{AgentID: 15, Revision: 1, Status: enums.StatusOk, WorkflowVersionID: workflowVersion.ID, Definition: `{"agent":{"runtimeMode":"hybrid","systemPrompt":"published hybrid prompt","maxSteps":3,"toolPolicy":"{\"allowedRiskLevels\":[\"read\"]}"},"workflowVersionId":1}`} + if err := db.Create(revision).Error; err != nil { + t.Fatalf("create revision: %v", err) + } + engine := NewHybridEngine() + engine.chatWithTools = func(ctx context.Context, _ models.AIConfig, _ string, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) { + _, err := execute(ctx, ai.ToolCall{Name: "run_playbook", Arguments: fmt.Sprintf(`{"workflowVersionId":%d}`, workflowVersion.ID)}) + return nil, err + } + _, err = engine.Run(context.Background(), Request{ + UserMessage: models.Message{ID: 4, Content: "请执行受控流程"}, + AIAgent: models.AIAgent{ID: 15, RuntimeMode: enums.AIAgentRuntimeModeHybrid, PublishedRevisionID: revision.ID, WorkflowVersionID: workflowVersion.ID}, + AIConfig: models.AIConfig{ModelName: "test-model"}, + }) + if err == nil || !strings.Contains(err.Error(), "tool risk level is not allowed") { + t.Fatalf("expected tool policy rejection, got %v", err) + } +} + +func TestIntersectAutonomousToolCodesUsesSkillWhitelist(t *testing.T) { + got := intersectAutonomousToolCodes([]string{"support/order_lookup", "support/create_ticket"}, []string{"support/order_lookup"}) + if len(got) != 1 || got[0] != "support/order_lookup" { + t.Fatalf("intersection = %#v", got) + } +} + +func TestParseAutonomousToolPolicyAndPerToolCount(t *testing.T) { + policy := parseAutonomousToolPolicy(`{"maxTotalCalls":2,"maxArgumentBytes":1024,"allowedRiskLevels":["read"]}`) + if policy.MaxTotalCalls != 2 || policy.MaxArgumentBytes != 1024 || len(policy.AllowedRiskLevels) != 1 { + t.Fatalf("unexpected policy: %#v", policy) + } + defaults := parseAutonomousToolPolicy(`{"maxTotalCalls":99,"maxArgumentBytes":999999}`) + if defaults.MaxTotalCalls != 3 || defaults.MaxArgumentBytes != 32*1024 { + t.Fatalf("invalid policy did not fall back to safe limits: %#v", defaults) + } + count := autonomousToolCallCount([]svc.EngineToolCallInput{{ToolCode: "orders/lookup"}, {ToolCode: "orders/other"}, {ToolCode: "orders/lookup"}}, "orders/lookup") + if count != 2 { + t.Fatalf("per-tool count = %d, want 2", count) + } +} + +func TestAutonomousResponsePolicyRequestsHandoffOnlyWhenConfigured(t *testing.T) { + handoff := evaluateAutonomousResponsePolicy(models.AIAgent{KnowledgeIDs: "1", FallbackMode: enums.AIAgentFallbackModeHandoff}, "", nil) + if !handoff.Enforced || !handoff.RequestHandoff || handoff.Action != "handoff" { + t.Fatalf("unexpected handoff policy: %#v", handoff) + } + clarify := evaluateAutonomousResponsePolicy(models.AIAgent{KnowledgeIDs: "1", FallbackMode: enums.AIAgentFallbackModeSuggestRetry}, "", nil) + if !clarify.Enforced || clarify.RequestHandoff || clarify.Action != "clarify" { + t.Fatalf("unexpected clarify policy: %#v", clarify) + } +} + +func TestAutonomousConversationContextToolUsesRegistryPolicy(t *testing.T) { + definition, result, err := executeAutonomousReadTool(context.Background(), models.Conversation{CustomerName: "张三", LastMessageSummary: "咨询退款"}, models.AIAgent{}, toolx.BuiltinConversationContext.Code, nil, aitooling.Policy{ + AllowedToolCodes: []string{toolx.BuiltinConversationContext.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelRead}, Confirmed: true, + }) + if err != nil || definition.Code != toolx.BuiltinConversationContext.Code || !strings.Contains(result, `"customerName":"张三"`) { + t.Fatalf("unexpected conversation context tool result: definition=%#v result=%q err=%v", definition, result, err) + } + _, _, err = executeAutonomousReadTool(context.Background(), models.Conversation{}, models.AIAgent{}, toolx.BuiltinConversationContext.Code, nil, aitooling.Policy{ + AllowedToolCodes: []string{toolx.BuiltinConversationContext.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelWrite}, Confirmed: true, + }) + if err == nil || !strings.Contains(err.Error(), "risk level") { + t.Fatalf("expected read tool risk rejection, got %v", err) + } +} + +func TestAutonomousEngineExecutesAndAuditsConversationContextTool(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}, &models.Message{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + revision := &models.AgentRevision{AgentID: 13, Revision: 1} + if err := db.Create(revision).Error; err != nil { + t.Fatalf("create revision: %v", err) + } + if err := db.Create(&models.Message{ConversationID: 1, SenderType: "customer", MessageType: "text", Content: "上一轮问题"}).Error; err != nil { + t.Fatalf("create prior message: %v", err) + } + engine := newAutonomousEngineWithChat(nil) + engine.toolChat = func(ctx context.Context, _ models.AIConfig, _, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) { + output, err := execute(ctx, ai.ToolCall{ID: "call-1", Name: "tool_search", Arguments: `{"toolCode":"builtin/conversation_context","arguments":{}}`}) + if err != nil || !strings.Contains(output, `"customerName":"张三"`) || !strings.Contains(output, "上一轮问题") { + t.Fatalf("execute tool: output=%q err=%v", output, err) + } + output, err = execute(ctx, ai.ToolCall{ID: "call-2", Name: "tool_search", Arguments: `{"toolCode":"graph/prepare_ticket_draft","arguments":{"issue":"重复扣费"}}`}) + if err != nil || !strings.Contains(output, `"title":"重复扣费"`) { + t.Fatalf("execute ticket draft tool: output=%q err=%v", output, err) + } + output, err = execute(ctx, ai.ToolCall{ID: "call-3", Name: "tool_search", Arguments: `{"toolCode":"graph/analyze_conversation","arguments":{"observedIssue":"重复扣费","needTicket":true}}`}) + if err != nil || !strings.Contains(output, `"userIntent":"ticket_request"`) { + t.Fatalf("execute conversation analysis tool: output=%q err=%v", output, err) + } + output, err = execute(ctx, ai.ToolCall{ID: "call-4", Name: "tool_search", Arguments: `{"toolCode":"graph/triage_service_request","arguments":{"observedIssue":"重复扣费","needTicket":true}}`}) + if err != nil || !strings.Contains(output, `"recommendedAction":"prepare_ticket"`) || !strings.Contains(output, `"ticketDraft"`) { + t.Fatalf("execute service triage tool: output=%q err=%v", output, err) + } + return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "已查询到当前会话信息。", ModelName: "test-model"}}, nil + } + summary, err := engine.Run(context.Background(), Request{ + Conversation: models.Conversation{ID: 1, CustomerName: "张三", LastMessageSummary: "咨询退款"}, UserMessage: models.Message{ID: 2, Content: "请查一下当前会话"}, + AIAgent: models.AIAgent{ID: 13, PublishedRevisionID: revision.ID, ToolPolicy: `{"maxTotalCalls":4}`, AllowedMCPTools: `[{"toolCode":"builtin/conversation_context"},{"toolCode":"graph/prepare_ticket_draft"},{"toolCode":"graph/analyze_conversation"},{"toolCode":"graph/triage_service_request"}]`}, + AIConfig: models.AIConfig{ModelName: "test-model"}, + }) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + _, _, calls := svc.AgentRunService.GetDetail(summary.AgentRunID) + if len(calls) != 4 || calls[0].ToolCode != toolx.BuiltinConversationContext.Code || calls[1].ToolCode != toolx.GraphPrepareTicketDraft.Code || calls[2].ToolCode != toolx.GraphAnalyzeConversation.Code || calls[3].ToolCode != toolx.GraphTriageServiceRequest.Code || calls[3].Status != "completed" { + t.Fatalf("unexpected tool audit: %#v", calls) + } +} + +func TestAutonomousEngineFallsBackAfterConsecutiveToolFailures(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + revision := &models.AgentRevision{AgentID: 14, Revision: 1} + if err := db.Create(revision).Error; err != nil { + t.Fatalf("create revision: %v", err) + } + engine := newAutonomousEngineWithChat(nil) + engine.toolChat = func(ctx context.Context, _ models.AIConfig, _, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) { + for _, callID := range []string{"call-1", "call-2"} { + _, _ = execute(ctx, ai.ToolCall{ID: callID, Name: "tool_search", Arguments: `{"toolCode":"unknown/unsafe","arguments":{}}`}) + } + return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "model reply should be replaced", ModelName: "test-model"}}, nil + } + summary, err := engine.Run(context.Background(), Request{ + Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "查询订单"}, + AIAgent: models.AIAgent{ID: 14, PublishedRevisionID: revision.ID, FallbackMode: enums.AIAgentFallbackModeHandoff, FallbackMessage: "查询暂不可用,正在转人工。", AllowedMCPTools: `[{"toolCode":"builtin/conversation_context"}]`}, + AIConfig: models.AIConfig{ModelName: "test-model"}, + }) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if summary.ReplyText != "查询暂不可用,正在转人工。" || !summary.HandoffRequested { + t.Fatalf("expected handoff fallback after tool failures, got %#v", summary) + } + _, steps, calls := svc.AgentRunService.GetDetail(summary.AgentRunID) + if len(calls) != 2 || calls[0].Status != "failed" || calls[1].Status != "failed" { + t.Fatalf("expected failed tool audits, got %#v", calls) + } + if len(steps) < 2 || steps[len(steps)-1].StepCode != "tool_failure" || steps[len(steps)-1].OutputPreview != "handoff" { + t.Fatalf("expected tool failure policy audit, got %#v", steps) + } +} + +type runtimeTestEngine struct { + code string + ran bool +} + +func (e *runtimeTestEngine) Code() string { + return e.code +} + +func (e *runtimeTestEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) { + e.ran = true + return &RunResult{Status: "completed"}, nil +} + +func (e *runtimeTestEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) { + return &RunResult{Status: "completed"}, nil +} diff --git a/internal/ai/application/runtime/evaluation.go b/internal/ai/application/runtime/evaluation.go new file mode 100644 index 0000000..9fc7fae --- /dev/null +++ b/internal/ai/application/runtime/evaluation.go @@ -0,0 +1,147 @@ +package runtime + +import ( + "context" + "encoding/csv" + "strconv" + "strings" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/toolx" +) + +// OfflineEvaluationCase is an isolated customer-service evaluation sample. +// Expectations are intentionally declarative so the same baseline can evolve +// without changing the runner's request contract. +type OfflineEvaluationCase struct { + ID string `json:"id"` + Category string `json:"category"` + Message string `json:"message"` + History []string `json:"history,omitempty"` + Expect map[string]any `json:"expect,omitempty"` +} + +type OfflineEvaluationResult struct { + CaseID string `json:"caseId"` + Category string `json:"category"` + EngineCode string `json:"engineCode"` + Passed bool `json:"passed"` + ReplyText string `json:"replyText"` + Interrupted bool `json:"interrupted"` + Error string `json:"error,omitempty"` + Finding string `json:"finding,omitempty"` +} + +type OfflineEvaluationReport struct { + EngineCode string `json:"engineCode"` + Total int `json:"total"` + Passed int `json:"passed"` + Results []OfflineEvaluationResult `json:"results"` +} + +// OfflineEvaluationRunner executes only isolated Debug requests. The supplied +// runner makes it testable without a real model and lets callers choose an +// explicit Engine implementation for mode comparison. +type OfflineEvaluationRunner struct { + run func(context.Context, RunInput) (*RunResult, error) +} + +func NewOfflineEvaluationRunner(run func(context.Context, RunInput) (*RunResult, error)) *OfflineEvaluationRunner { + return &OfflineEvaluationRunner{run: run} +} + +func (r *OfflineEvaluationRunner) Run(ctx context.Context, engineCode string, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) OfflineEvaluationReport { + report := OfflineEvaluationReport{EngineCode: strings.TrimSpace(engineCode), Results: make([]OfflineEvaluationResult, 0, len(cases))} + for _, item := range cases { + result := OfflineEvaluationResult{CaseID: strings.TrimSpace(item.ID), Category: strings.TrimSpace(item.Category), EngineCode: report.EngineCode} + if r == nil || r.run == nil { + result.Error, result.Finding = "evaluation runner is not configured", "runner_missing" + report.Results = append(report.Results, result) + continue + } + summary, err := r.run(ctx, RunInput{ + Conversation: models.Conversation{AIAgentID: agent.ID, LastMessageSummary: strings.Join(item.History, "\n")}, + UserMessage: models.Message{SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: strings.TrimSpace(item.Message), RequestID: "offline-eval:" + strings.TrimSpace(item.ID)}, + AIAgent: agent, + AIConfig: config, + Debug: true, + }) + if err != nil { + result.Error, result.Finding = err.Error(), "engine_error" + report.Results = append(report.Results, result) + continue + } + if summary != nil { + result.ReplyText = strings.TrimSpace(summary.ReplyText) + result.Interrupted = summary.Interrupted + } + result.Passed, result.Finding = evaluateOfflineCase(item.Expect, summary) + if result.Passed { + report.Passed++ + } + report.Results = append(report.Results, result) + } + report.Total = len(report.Results) + return report +} + +func (r OfflineEvaluationReport) CSV() (string, error) { + var output strings.Builder + writer := csv.NewWriter(&output) + if err := writer.Write([]string{"caseId", "category", "engineCode", "passed", "interrupted", "finding", "error", "replyText"}); err != nil { + return "", err + } + for _, item := range r.Results { + if err := writer.Write([]string{item.CaseID, item.Category, item.EngineCode, strconv.FormatBool(item.Passed), strconv.FormatBool(item.Interrupted), item.Finding, item.Error, item.ReplyText}); err != nil { + return "", err + } + } + writer.Flush() + return output.String(), writer.Error() +} + +func evaluateOfflineCase(expect map[string]any, summary *RunResult) (bool, string) { + if summary == nil || strings.TrimSpace(summary.ReplyText) == "" { + return false, "empty_reply" + } + if requiresConfirmation, _ := expect["requiresConfirmation"].(bool); requiresConfirmation && !summary.Interrupted { + return false, "confirmation_not_reached" + } + if maxWrites, ok := evaluationExpectationInt(expect["maxWriteToolCalls"]); ok { + if maxWrites < 0 { + return false, "invalid_expectation" + } + if writeToolCalls(summary) > maxWrites { + return false, "write_tool_limit_exceeded" + } + } + return true, "" +} + +func evaluationExpectationInt(value any) (int, bool) { + switch item := value.(type) { + case int: + return item, true + case int64: + return int(item), true + case float64: + return int(item), item == float64(int(item)) + default: + return 0, false + } +} + +func writeToolCalls(summary *RunResult) int { + if summary == nil { + return 0 + } + count := 0 + for _, code := range summary.InvokedToolCodes { + switch toolx.NormalizeToolCodeAlias(code) { + case toolx.GraphCreateTicketConfirm.Code, toolx.GraphHandoffConversation.Code: + count++ + } + } + return count +} diff --git a/internal/ai/application/runtime/evaluation_test.go b/internal/ai/application/runtime/evaluation_test.go new file mode 100644 index 0000000..daea803 --- /dev/null +++ b/internal/ai/application/runtime/evaluation_test.go @@ -0,0 +1,54 @@ +package runtime + +import ( + "context" + "strings" + "testing" + + "agent-desk/internal/models" +) + +func TestOfflineEvaluationRunnerUsesDebugIsolationAndExportsCSV(t *testing.T) { + var received []RunInput + runner := NewOfflineEvaluationRunner(func(_ context.Context, input RunInput) (*RunResult, error) { + received = append(received, input) + return &RunResult{ReplyText: "已根据知识库回答。"}, nil + }) + report := runner.Run(context.Background(), "autonomous", models.AIAgent{ID: 12}, models.AIConfig{ID: 13}, []OfflineEvaluationCase{{ID: "faq", Category: "faq", Message: "保修期多久", History: []string{"客户:你好"}}}) + if report.Total != 1 || report.Passed != 1 || len(received) != 1 || !received[0].Debug || received[0].Conversation.ID != 0 || received[0].UserMessage.RequestID != "offline-eval:faq" { + t.Fatalf("unexpected report or input: report=%#v input=%#v", report, received) + } + csv, err := report.CSV() + if err != nil || !strings.Contains(csv, "caseId,category,engineCode") || !strings.Contains(csv, "faq,faq,autonomous,true") { + t.Fatalf("unexpected csv=%q err=%v", csv, err) + } +} + +func TestOfflineEvaluationRunnerChecksConfirmationExpectation(t *testing.T) { + runner := NewOfflineEvaluationRunner(func(context.Context, RunInput) (*RunResult, error) { + return &RunResult{ReplyText: "已转人工"}, nil + }) + report := runner.Run(context.Background(), "workflow", models.AIAgent{}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "handoff", Expect: map[string]any{"requiresConfirmation": true}}}) + if report.Passed != 0 || report.Results[0].Finding != "confirmation_not_reached" { + t.Fatalf("unexpected report: %#v", report) + } +} + +func TestOfflineEvaluationRunnerChecksWriteToolLimit(t *testing.T) { + runner := NewOfflineEvaluationRunner(func(context.Context, RunInput) (*RunResult, error) { + return &RunResult{ReplyText: "调试回复", InvokedToolCodes: []string{"graph/handoff_to_human"}}, nil + }) + report := runner.Run(context.Background(), "hybrid", models.AIAgent{}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "write", Expect: map[string]any{"maxWriteToolCalls": 0}}}) + if report.Passed != 0 || report.Results[0].Finding != "write_tool_limit_exceeded" { + t.Fatalf("unexpected report: %#v", report) + } +} + +func TestServiceRunsOfflineEvaluationWithExplicitEngine(t *testing.T) { + engine := &runtimeTestEngine{code: "evaluation"} + service := NewServiceWithRegistry(NewEngineRegistry(engine)) + report, err := service.RunOfflineEvaluation(context.Background(), "evaluation", models.AIAgent{RuntimeMode: "workflow"}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "case"}}) + if err != nil || !engine.ran || report.EngineCode != "evaluation" || report.Total != 1 { + t.Fatalf("unexpected evaluation report=%#v engine=%#v err=%v", report, engine, err) + } +} diff --git a/internal/ai/application/runtime/hybrid_engine.go b/internal/ai/application/runtime/hybrid_engine.go new file mode 100644 index 0000000..f33d768 --- /dev/null +++ b/internal/ai/application/runtime/hybrid_engine.go @@ -0,0 +1,221 @@ +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + ai "agent-desk/internal/ai" + aitooling "agent-desk/internal/ai/tooling" + "agent-desk/internal/ai/runtime/instruction" + workflowregistry "agent-desk/internal/ai/workflow/registry" + workflowvalidator "agent-desk/internal/ai/workflow/validator" + "agent-desk/internal/models" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + svc "agent-desk/internal/services" + + "github.com/mlogclub/simple/sqls" +) + +const hybridPlaybookToolCode = "playbook/run" + +// HybridEngine lets the model choose whether to enter the Agent's one bound +// deterministic Playbook. The Playbook itself is always run by WorkflowEngine. +type HybridEngine struct { + chatWithTools func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error) + autonomous *AutonomousEngine + workflow *WorkflowEngine +} + +func NewHybridEngine() *HybridEngine { + return &HybridEngine{ + chatWithTools: ai.LLM.ChatWithTools, + autonomous: NewAutonomousEngine(), + workflow: NewWorkflowEngine(), + } +} + +func (e *HybridEngine) Code() string { + return "hybrid" +} + +func (e *HybridEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) { + startedAt := time.Now() + req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content) + snapshot, err := svc.AgentRevisionService.ResolvePublishedSnapshot(req.AIAgent, req.AIConfig) + if err != nil { + return nil, err + } + req.AIAgent, req.AIConfig = snapshot.Agent, snapshot.AIConfig + if req.AIAgent.WorkflowVersionID <= 0 { + return nil, errorsx.InvalidParam("hybrid agent requires a published playbook workflow") + } + workflow, err := resolveAgentWorkflow(req.AIAgent) + if err != nil { + return nil, err + } + if result := workflowvalidator.ValidateDefinition(workflow.Definition, workflowregistry.DefaultRegistry()); !result.Valid { + return nil, errorsx.InvalidParam("hybrid agent playbook validation failed") + } + + skillContext := e.autonomous.selectSkill(ctx, req) + knowledgeContext, retrieverCount, retrieveErr := e.autonomous.retrieveKnowledge(ctx, req.AIAgent, req.UserMessage.Content) + responsePolicy := evaluateAutonomousResponsePolicy(req.AIAgent, knowledgeContext, retrieveErr) + if responsePolicy.Enforced { + return writeHybridResult(req, startedAt, &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}, "", 0, retrieverCount, skillContext, nil, responsePolicy, nil) + } + systemPrompt := buildAutonomousSystemPrompt(req.AIAgent, len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0, knowledgeContext, retrieveErr) + if skillInstruction := strings.TrimSpace(instruction.BuildSkillDocument(skillContext.Skill, nil)); skillInstruction != "" { + systemPrompt += "\n\nSkill instructions:\n" + skillInstruction + } + systemPrompt += "\n\nWhen a deterministic process is required, use run_playbook. Do not call it for ordinary factual questions." + userPrompt, historyCount := e.autonomous.buildUserPrompt(req) + if knowledgeContext != "" { + userPrompt += "\n\nKnowledge evidence:\n" + knowledgeContext + } + + var playbookSummary *Summary + toolCalls := make([]svc.EngineToolCallInput, 0, 1) + toolPolicy := parseAutonomousToolPolicy(req.AIAgent.ToolPolicy) + loop, err := e.chatWithTools(ctx, req.AIConfig, systemPrompt, userPrompt, []ai.ToolDefinition{hybridPlaybookToolDefinition(req.AIAgent.WorkflowVersionID)}, req.AIAgent.MaxSteps, func(ctx context.Context, call ai.ToolCall) (string, error) { + if call.Name != "run_playbook" { + return "", fmt.Errorf("unsupported hybrid tool: %s", call.Name) + } + if len(toolCalls) >= 1 { + return "", fmt.Errorf("playbook call limit reached") + } + workflowVersionID, err := parseHybridPlaybookCall(call.Arguments) + if err != nil { + return "", err + } + if workflowVersionID != req.AIAgent.WorkflowVersionID { + return "", fmt.Errorf("playbook is not allowed") + } + playbookDefinition := aitooling.Definition{Code: hybridPlaybookToolCode, Name: "run_playbook", RiskLevel: aitooling.RiskLevelWrite, RequireConfirmation: true, MaxCallsPerRun: 1} + if err := aitooling.DefaultRegistry.Authorize(playbookDefinition, aitooling.Policy{ + AllowedRiskLevels: toolPolicy.AllowedRiskLevels, + CallCount: len(toolCalls), + TotalCallCount: len(toolCalls), + MaxTotalCalls: 1, + Confirmed: true, // Workflow validation guarantees a human-confirm predecessor for high-risk nodes. + }); err != nil { + return "", err + } + callStartedAt := time.Now() + playbookSummary, err = e.workflow.Run(ctx, req) + toolRecord := svc.EngineToolCallInput{ToolCode: hybridPlaybookToolCode, RiskLevel: "write", RequireConfirm: true, ArgumentsPreview: call.Arguments, DurationMS: int(time.Since(callStartedAt).Milliseconds())} + if err != nil { + toolRecord.Status, toolRecord.ErrorMessage = "failed", err.Error() + toolCalls = append(toolCalls, toolRecord) + return "", err + } + toolRecord.Status = "completed" + toolRecord.ResultPreview = fmt.Sprintf("workflowRunId=%d status=%s", playbookSummary.WorkflowRunID, playbookSummary.Status) + toolCalls = append(toolCalls, toolRecord) + data, _ := json.Marshal(map[string]any{"workflowRunId": playbookSummary.WorkflowRunID, "status": playbookSummary.Status, "replyText": playbookSummary.ReplyText, "interrupted": playbookSummary.Interrupted}) + return string(data), nil + }) + if err != nil { + _, _ = writeHybridAudit(req, startedAt, nil, userPrompt, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, false, err) + return nil, err + } + if playbookSummary != nil && playbookSummary.Interrupted { + runID, auditErr := writeHybridAudit(req, startedAt, &ai.ChatCompletionResult{Content: playbookSummary.ReplyText, ModelName: playbookSummary.ModelName, PromptTokens: playbookSummary.PromptTokens, CompletionTokens: playbookSummary.CompletionTokens}, userPrompt, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, true, nil) + if auditErr != nil { + return nil, auditErr + } + playbookSummary.AgentRunID = runID + return playbookSummary, nil + } + if loop == nil || strings.TrimSpace(loop.Content) == "" { + err = errorsx.InvalidParam("hybrid engine returned an empty reply") + _, _ = writeHybridAudit(req, startedAt, nil, userPrompt, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, false, err) + return nil, err + } + return writeHybridResult(req, startedAt, &loop.ChatCompletionResult, userPrompt, historyCount, retrieverCount, skillContext, playbookSummary, responsePolicy, toolCalls) +} + +func (e *HybridEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) { + interrupt := svc.ConversationInterruptService.GetByCheckPointID(req.CheckPointID) + summary, err := e.workflow.Resume(ctx, req) + if err != nil || summary == nil || interrupt == nil || interrupt.AgentRunID <= 0 { + return summary, err + } + if err := sqls.WithTransaction(func(tx *sqls.TxContext) error { + return svc.AgentRunService.RecordHybridPlaybookResume(tx.Tx, interrupt.AgentRunID, summary.WorkflowRunID, summary.Status, summary.ReplyText) + }); err != nil { + return nil, err + } + // The resumed WorkflowRun is a child audit artifact. Keep the original + // Hybrid run as the summary run surfaced to the conversation caller. + summary.AgentRunID = interrupt.AgentRunID + return summary, nil +} + +func 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 parseHybridPlaybookCall(raw string) (int64, error) { + var input struct { + WorkflowVersionID int64 `json:"workflowVersionId"` + } + if err := json.Unmarshal([]byte(raw), &input); err != nil || input.WorkflowVersionID <= 0 { + return 0, errorsx.InvalidParam("invalid playbook call") + } + return input.WorkflowVersionID, nil +} + +func writeHybridResult(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount, retrieverCount int, skillContext autonomousSkillContext, playbook *Summary, responsePolicy autonomousResponsePolicy, toolCalls []svc.EngineToolCallInput) (*Summary, error) { + runID, err := writeHybridAudit(req, startedAt, result, inputPreview, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, false, nil) + if err != nil { + return nil, err + } + return &Summary{Status: "completed", ReplyText: strings.TrimSpace(result.Content), ModelName: result.ModelName, PromptTokens: result.PromptTokens, CompletionTokens: result.CompletionTokens, HistoryMessageCount: historyCount, RetrieverCount: retrieverCount, AgentRunID: runID, WorkflowRunID: workflowRunIDFromSummary(playbook)}, nil +} + +func writeHybridAudit(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount, retrieverCount int, skillContext autonomousSkillContext, toolCalls []svc.EngineToolCallInput, responsePolicy autonomousResponsePolicy, interrupted bool, cause error) (int64, error) { + endedAt := time.Now() + status, errorMessage, outputPreview := "completed", "", "" + promptTokens, completionTokens := 0, 0 + if interrupted { + status = "interrupted" + } else if cause != nil { + status, errorMessage = "failed", cause.Error() + } else if result != nil { + outputPreview, promptTokens, completionTokens = strings.TrimSpace(result.Content), result.PromptTokens, result.CompletionTokens + } + steps := autonomousAdditionalSteps(req, retrieverCount, nil, skillContext, responsePolicy) + for _, call := range toolCalls { + if call.ToolCode == hybridPlaybookToolCode { + steps = append(steps, svc.EngineStepInput{StepType: "playbook", StepCode: hybridPlaybookToolCode, WorkflowRunID: workflowRunIDFromToolResult(call.ResultPreview), Status: call.Status, InputPreview: call.ArgumentsPreview, OutputPreview: call.ResultPreview, ErrorMessage: call.ErrorMessage}) + } + } + var runID int64 + err := sqls.WithTransaction(func(tx *sqls.TxContext) error { + var recordErr error + runID, recordErr = svc.AgentRunService.RecordEngineRun(tx.Tx, svc.EngineAgentRunInput{ConversationID: req.Conversation.ID, AIAgentID: req.AIAgent.ID, AgentRevisionID: req.AIAgent.PublishedRevisionID, SourceMessageID: req.UserMessage.ID, EngineCode: "hybrid", Status: status, PromptTokens: promptTokens, CompletionTokens: completionTokens, StartedAt: startedAt, EndedAt: &endedAt, ErrorMessage: errorMessage, TraceData: `{"engine":"hybrid"}`, StepType: "model", StepCode: "chat_completion", StepInputPreview: inputPreview, StepOutputPreview: outputPreview, AdditionalSteps: steps, ToolCalls: toolCalls}) + return recordErr + }) + return runID, err +} + +func workflowRunIDFromSummary(summary *Summary) int64 { + if summary == nil { + return 0 + } + return summary.WorkflowRunID +} + +func workflowRunIDFromToolResult(value string) int64 { + var id int64 + _, _ = fmt.Sscanf(value, "workflowRunId=%d", &id) + return id +} + +var _ Engine = (*HybridEngine)(nil) diff --git a/internal/ai/application/runtime/service.go b/internal/ai/application/runtime/service.go index 4a5874c..1ec4348 100644 --- a/internal/ai/application/runtime/service.go +++ b/internal/ai/application/runtime/service.go @@ -8,14 +8,15 @@ import ( workflowexecutor "agent-desk/internal/ai/runtime/workflow" "agent-desk/internal/models" - "agent-desk/internal/pkg/errorsx" - "agent-desk/internal/pkg/utils" + "agent-desk/internal/pkg/enums" "agent-desk/internal/repositories" + svc "agent-desk/internal/services" "github.com/mlogclub/simple/sqls" ) type Service struct { + registry *EngineRegistry } const ( @@ -25,91 +26,46 @@ const ( ) func NewService() *Service { - return &Service{} + return NewServiceWithRegistry(NewDefaultEngineRegistry()) } -func (s *Service) Run(ctx context.Context, req Request) (*Summary, error) { - req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content) - aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent) - if err != nil { - _, _ = writeWorkflowPrepareFailedRun(req, err.Error()) - return nil, err - } - req.AIAgent = aiAgent - workflowResult, err := workflowexecutor.NewExecutor().Execute(ctx, workflowexecutor.Input{ - Definition: workflow.Definition, - Conversation: req.Conversation, - UserMessage: req.UserMessage, - AIAgent: req.AIAgent, - AIConfig: req.AIConfig, - }) - if err != nil { - if workflowResult != nil { - _, _ = writeWorkflowRun(req, workflow, workflowResult, err.Error()) - } - return nil, err - } - workflowRunID, err := writeWorkflowRun(req, workflow, workflowResult, "") +func NewServiceWithRegistry(registry *EngineRegistry) *Service { + return &Service{registry: registry} +} + +func (s *Service) Run(ctx context.Context, req RunInput) (*RunResult, error) { + engine, err := s.registry.Resolve(resolveEngineCode(req.AIAgent.RuntimeMode)) if err != nil { return nil, err } - return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID), nil + return engine.Run(ctx, req) } -func (s *Service) Resume(ctx context.Context, req ResumeRequest) (*Summary, error) { - aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent) +func (s *Service) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) { + engine, err := s.registry.Resolve(resolveEngineCode(req.AIAgent.RuntimeMode)) if err != nil { return nil, err } - req.AIAgent = aiAgent - if interrupt := repositories.ConversationInterruptRepository.GetByCheckPointID(sqls.DB(), req.CheckPointID); interrupt != nil { - if strings.TrimSpace(interrupt.RequestData) == "" { - if interrupt.WorkflowRunID > 0 || strings.HasPrefix(strings.TrimSpace(req.CheckPointID), "workflow:") { - return nil, errorsx.InvalidParam("workflow checkpoint data is required") - } - } else { - workflowResult, err := workflowexecutor.NewExecutor().Resume(ctx, workflowexecutor.Input{ - Definition: workflow.Definition, - Conversation: req.Conversation, - AIAgent: req.AIAgent, - AIConfig: req.AIConfig, - }, interrupt.RequestData, firstWorkflowResumeText(req.ResumeData)) - if err != nil { - if workflowResult != nil { - _, _ = writeWorkflowRunWithExistingID(Request{ - Conversation: req.Conversation, - UserMessage: req.UserMessage, - AIAgent: req.AIAgent, - AIConfig: req.AIConfig, - }, workflow, workflowResult, err.Error(), interrupt.WorkflowRunID) - } - return nil, err - } - workflowRunID, err := writeWorkflowRunWithExistingID(Request{ - Conversation: req.Conversation, - UserMessage: req.UserMessage, - AIAgent: req.AIAgent, - AIConfig: req.AIConfig, - }, workflow, workflowResult, "", interrupt.WorkflowRunID) - if err != nil { - return nil, err - } - return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID), nil - } - } - return nil, errorsx.InvalidParam("legacy checkpoint is not supported; please start a new workflow reply") + return engine.Resume(ctx, req) } -func firstWorkflowResumeText(data map[string]string) string { - for _, value := range data { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } +// RunOfflineEvaluation executes an explicitly selected Engine against isolated +// Debug inputs. It does not rely on the Agent's configured runtime mode, which +// makes Workflow/Autonomous/Hybrid comparisons possible against one revision. +func (s *Service) RunOfflineEvaluation(ctx context.Context, engineCode string, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) (OfflineEvaluationReport, error) { + engine, err := s.registry.Resolve(strings.TrimSpace(engineCode)) + if err != nil { + return OfflineEvaluationReport{EngineCode: strings.TrimSpace(engineCode)}, err } - return "" + runner := NewOfflineEvaluationRunner(engine.Run) + return runner.Run(ctx, engine.Code(), agent, config, cases), nil } -func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workflow resolvedWorkflow, workflowRunID int64) *Summary { +func resolveEngineCode(mode enums.AIAgentRuntimeMode) string { + return strings.TrimSpace(string(mode)) +} + +func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workflow resolvedWorkflow, workflowRunID int64, agentRunID int64) *Summary { if result == nil { return nil } @@ -131,6 +87,7 @@ func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workfl WorkflowID: workflow.WorkflowID, WorkflowVersionID: workflow.VersionID, WorkflowRunID: workflowRunID, + AgentRunID: agentRunID, WorkflowNodePath: append([]string(nil), result.NodePath...), TraceData: string(traceData), CheckPointID: result.CheckPointID, @@ -155,7 +112,7 @@ func toWorkflowInterruptSummaries(items []workflowexecutor.InterruptSummary) []I return ret } -func writeWorkflowRun(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string) (int64, error) { +func writeWorkflowRun(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string) (int64, int64, error) { return writeWorkflowRunWithExistingID(req, workflow, result, errorMessage, 0) } @@ -180,15 +137,38 @@ func writeWorkflowPrepareFailedRun(req Request, errorMessage string) (int64, err EndedAt: &endedAt, ErrorMessage: errorMessage, } - if err := repositories.AIWorkflowRunRepository.Create(sqls.DB(), run); err != nil { - return 0, err - } - return run.ID, nil + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.AIWorkflowRunRepository.Create(ctx.Tx, run); err != nil { + return err + } + traceData, _ := json.Marshal(map[string]any{ + "status": "error", + "workflowId": workflowID, + "workflowVersionId": workflowVersionID, + "workflowRunId": run.ID, + }) + _, err := svc.AgentRunService.RecordWorkflowRun(ctx.Tx, svc.WorkflowAgentRunInput{ + WorkflowRunID: run.ID, + WorkflowVersionID: workflowVersionID, + ConversationID: req.Conversation.ID, + AIAgentID: req.AIAgent.ID, + SourceMessageID: req.UserMessage.ID, + Status: "failed", + StartedAt: now, + EndedAt: &endedAt, + ErrorMessage: errorMessage, + TraceData: string(traceData), + StepInputPreview: "workflow preparation", + StepOutputPreview: "", + }) + return err + }) + return run.ID, err } -func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string, existingRunID int64) (int64, error) { +func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string, existingRunID int64) (int64, int64, error) { if result == nil { - return 0, nil + return 0, 0, nil } now := time.Now() endedAt := now @@ -198,6 +178,7 @@ func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, resu } runStatus := workflowRunStatus(result.Status, errorMessage) var runID int64 + var agentRunID int64 err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { run := repositories.AIWorkflowRunRepository.Get(ctx.Tx, existingRunID) if run == nil { @@ -249,9 +230,46 @@ func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, resu return err } } + traceData, _ := json.Marshal(map[string]any{ + "status": result.Status, + "workflowId": workflow.WorkflowID, + "workflowVersionId": workflow.VersionID, + "workflowRunId": run.ID, + "nodePath": result.NodePath, + }) + createdAgentRunID, recordErr := svc.AgentRunService.RecordWorkflowRun(ctx.Tx, svc.WorkflowAgentRunInput{ + WorkflowRunID: run.ID, + WorkflowVersionID: workflow.VersionID, + ConversationID: req.Conversation.ID, + AIAgentID: req.AIAgent.ID, + SourceMessageID: req.UserMessage.ID, + Status: workflowAgentRunStatus(result.Status, errorMessage), + PromptTokens: result.PromptTokens, + CompletionTokens: result.CompletionTokens, + StartedAt: now, + EndedAt: &endedAt, + ErrorMessage: errorMessage, + TraceData: string(traceData), + StepInputPreview: "workflow execution", + StepOutputPreview: strings.Join(result.NodePath, ","), + }) + if recordErr != nil { + return recordErr + } + agentRunID = createdAgentRunID return nil }) - return runID, err + return runID, agentRunID, err +} + +func workflowAgentRunStatus(status string, errorMessage string) string { + if strings.TrimSpace(errorMessage) != "" || strings.TrimSpace(status) == "error" { + return "failed" + } + if strings.TrimSpace(status) == "interrupted" { + return "interrupted" + } + return "completed" } func workflowRunStatus(status string, errorMessage string) int { diff --git a/internal/ai/application/runtime/types.go b/internal/ai/application/runtime/types.go index 50a26a7..d79dabf 100644 --- a/internal/ai/application/runtime/types.go +++ b/internal/ai/application/runtime/types.go @@ -2,32 +2,49 @@ package runtime import ( "agent-desk/internal/models" + "time" ) -type Request struct { +// RunInput is the normalized, fully prepared input shared by all Engine +// implementations. Persistent adapters load this object before dispatching +// into the runtime. +type RunInput struct { Conversation models.Conversation UserMessage models.Message AIAgent models.AIAgent AIConfig models.AIConfig CheckPointID string + Debug bool } -type ResumeRequest struct { +// Request remains as a compatibility alias while callers move to RunInput. +type Request = RunInput + +// ResumeInput extends the prepared input with an approved interrupt payload. +// It deliberately carries the same persisted context as RunInput so resume +// semantics are consistent across Workflow, Autonomous, and Hybrid engines. +type ResumeInput struct { Conversation models.Conversation UserMessage models.Message AIAgent models.AIAgent AIConfig models.AIConfig CheckPointID string ResumeData map[string]string + Debug bool } +// ResumeRequest remains as a compatibility alias while callers move to ResumeInput. +type ResumeRequest = ResumeInput + type InterruptContextSummary struct { Type string `json:"type,omitempty"` ID string `json:"id"` InfoPreview string `json:"infoPreview,omitempty"` } -type Summary struct { +// RunResult is the normalized result returned by every Engine. Engine-specific +// details are represented by optional fields rather than engine-specific DTOs. +type RunResult struct { RunID string Status string ReplyText string @@ -47,11 +64,39 @@ type Summary struct { WorkflowID int64 WorkflowVersionID int64 WorkflowRunID int64 + AgentRunID int64 WorkflowNodePath []string CheckPointID string CheckPointData string Interrupted bool + HandoffRequested bool Interrupts []InterruptContextSummary TraceData string ErrorMessage string } + +// Summary remains as a compatibility alias while callers move to RunResult. +type Summary = RunResult + +type StreamEventType string + +const ( + StreamEventStarted StreamEventType = "started" + StreamEventStep StreamEventType = "step" + StreamEventOutput StreamEventType = "output" + StreamEventCompleted StreamEventType = "completed" + StreamEventFailed StreamEventType = "failed" +) + +// StreamEvent is the transport-neutral event contract for future streaming +// endpoints. Engines may emit partial output, audit steps, or a terminal state +// without exposing engine-specific event payloads to callers. +type StreamEvent struct { + Type StreamEventType `json:"type"` + RunID string `json:"runId,omitempty"` + AgentRunID int64 `json:"agentRunId,omitempty"` + StepCode string `json:"stepCode,omitempty"` + Content string `json:"content,omitempty"` + Error string `json:"error,omitempty"` + OccurredAt time.Time `json:"occurredAt"` +} diff --git a/internal/ai/application/runtime/workflow_engine.go b/internal/ai/application/runtime/workflow_engine.go new file mode 100644 index 0000000..5d6806b --- /dev/null +++ b/internal/ai/application/runtime/workflow_engine.go @@ -0,0 +1,112 @@ +package runtime + +import ( + "context" + "strings" + + workflowexecutor "agent-desk/internal/ai/runtime/workflow" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +// WorkflowEngine preserves the existing FlowGram DSL execution path as the +// first Agent Runtime engine. It remains the compatibility default for agents +// created before autonomous and hybrid modes are available. +type WorkflowEngine struct{} + +func NewWorkflowEngine() *WorkflowEngine { + return &WorkflowEngine{} +} + +func (e *WorkflowEngine) Code() string { + return EngineCodeWorkflow +} + +func (e *WorkflowEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) { + req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content) + aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent) + if err != nil { + _, _ = writeWorkflowPrepareFailedRun(req, err.Error()) + return nil, err + } + req.AIAgent = aiAgent + workflowResult, err := workflowexecutor.NewExecutor().Execute(ctx, workflowexecutor.Input{ + Definition: workflow.Definition, + Conversation: req.Conversation, + UserMessage: req.UserMessage, + AIAgent: req.AIAgent, + AIConfig: req.AIConfig, + Debug: req.Debug, + }) + if err != nil { + if workflowResult != nil { + _, _, _ = writeWorkflowRun(req, workflow, workflowResult, err.Error()) + } + return nil, err + } + workflowRunID, agentRunID, err := writeWorkflowRun(req, workflow, workflowResult, "") + if err != nil { + return nil, err + } + return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID, agentRunID), nil +} + +func (e *WorkflowEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) { + aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent) + if err != nil { + return nil, err + } + req.AIAgent = aiAgent + interrupt := repositories.ConversationInterruptRepository.GetByCheckPointID(sqls.DB(), req.CheckPointID) + if interrupt == nil { + return nil, errorsx.InvalidParam("legacy checkpoint is not supported; please start a new workflow reply") + } + if strings.TrimSpace(interrupt.RequestData) == "" { + if interrupt.WorkflowRunID > 0 || strings.HasPrefix(strings.TrimSpace(req.CheckPointID), "workflow:") { + return nil, errorsx.InvalidParam("workflow checkpoint data is required") + } + return nil, errorsx.InvalidParam("legacy checkpoint is not supported; please start a new workflow reply") + } + workflowResult, err := workflowexecutor.NewExecutor().Resume(ctx, workflowexecutor.Input{ + Definition: workflow.Definition, + Conversation: req.Conversation, + AIAgent: req.AIAgent, + AIConfig: req.AIConfig, + Debug: req.Debug, + }, interrupt.RequestData, firstWorkflowResumeText(req.ResumeData)) + if err != nil { + if workflowResult != nil { + _, _, _ = writeWorkflowRunWithExistingID(Request{ + Conversation: req.Conversation, + UserMessage: req.UserMessage, + AIAgent: req.AIAgent, + AIConfig: req.AIConfig, + }, workflow, workflowResult, err.Error(), interrupt.WorkflowRunID) + } + return nil, err + } + workflowRunID, agentRunID, err := writeWorkflowRunWithExistingID(Request{ + Conversation: req.Conversation, + UserMessage: req.UserMessage, + AIAgent: req.AIAgent, + AIConfig: req.AIConfig, + }, workflow, workflowResult, "", interrupt.WorkflowRunID) + if err != nil { + return nil, err + } + return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID, agentRunID), nil +} + +func firstWorkflowResumeText(data map[string]string) string { + for _, value := range data { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +var _ Engine = (*WorkflowEngine)(nil) diff --git a/internal/ai/application/runtime/workflow_summary_test.go b/internal/ai/application/runtime/workflow_summary_test.go index 8aa2967..bb5bd41 100644 --- a/internal/ai/application/runtime/workflow_summary_test.go +++ b/internal/ai/application/runtime/workflow_summary_test.go @@ -5,12 +5,14 @@ import ( "encoding/json" "strings" "testing" + "time" workflowexecutor "agent-desk/internal/ai/runtime/workflow" "agent-desk/internal/ai/workflow/dsl" workflowregistry "agent-desk/internal/ai/workflow/registry" "agent-desk/internal/models" "agent-desk/internal/pkg/enums" + svc "agent-desk/internal/services" "github.com/glebarez/sqlite" "github.com/mlogclub/simple/sqls" @@ -27,7 +29,7 @@ func TestToWorkflowSummaryPreservesInterruptCheckpoint(t *testing.T) { Interrupts: []workflowexecutor.InterruptSummary{ {Type: "human_confirm", ID: "confirm_1", InfoPreview: `{"message":"请确认"}`}, }, - }, "test-model", resolvedWorkflow{WorkflowID: 11, VersionID: 22}, 33) + }, "test-model", resolvedWorkflow{WorkflowID: 11, VersionID: 22}, 33, 44) if summary == nil || !summary.Interrupted { t.Fatalf("expected interrupted summary, got %#v", summary) @@ -41,6 +43,9 @@ func TestToWorkflowSummaryPreservesInterruptCheckpoint(t *testing.T) { if summary.WorkflowID != 11 || summary.WorkflowVersionID != 22 || summary.WorkflowRunID != 33 { t.Fatalf("unexpected workflow identity: workflow=%d version=%d run=%d", summary.WorkflowID, summary.WorkflowVersionID, summary.WorkflowRunID) } + if summary.AgentRunID != 44 { + t.Fatalf("unexpected agent run id: %d", summary.AgentRunID) + } if len(summary.Interrupts) != 1 || summary.Interrupts[0].ID != "confirm_1" { t.Fatalf("unexpected interrupts: %#v", summary.Interrupts) } @@ -125,6 +130,9 @@ func TestServiceResumeUsesWorkflowCheckpointData(t *testing.T) { if summary.WorkflowRunID <= 0 { t.Fatalf("expected workflow run id in resume summary") } + if summary.AgentRunID <= 0 { + t.Fatalf("expected generic agent run id in resume summary") + } var run models.AIWorkflowRun if err := db.First(&run, summary.WorkflowRunID).Error; err != nil { t.Fatalf("find resume workflow run: %v", err) @@ -132,6 +140,68 @@ func TestServiceResumeUsesWorkflowCheckpointData(t *testing.T) { if run.MessageID != 2 || run.Status != workflowRunStatusCompleted { t.Fatalf("unexpected resume workflow run: %#v", run) } + var agentRun models.AgentRun + if err := db.First(&agentRun, "workflow_run_id = ?", summary.WorkflowRunID).Error; err != nil { + t.Fatalf("find generic agent run: %v", err) + } + if agentRun.EngineCode != EngineCodeWorkflow || agentRun.Status != "completed" { + t.Fatalf("unexpected generic agent run: %#v", agentRun) + } + var stepCount int64 + if err := db.Model(&models.AgentStep{}).Where("agent_run_id = ?", agentRun.ID).Count(&stepCount).Error; err != nil { + t.Fatalf("count generic agent steps: %v", err) + } + if stepCount != 1 { + t.Fatalf("expected one generic agent step, got %d", stepCount) + } +} + +func TestHybridEngineResumeCompletesOriginalAgentRun(t *testing.T) { + db := setupWorkflowResumeTestDB(t) + def := runtimeHumanConfirmDefinition() + version := models.AIWorkflowVersion{ + WorkflowID: 1, + Version: 1, + Status: enums.StatusOk, + Definition: mustMarshalDefinition(t, def), + } + if err := db.Create(&version).Error; err != nil { + t.Fatalf("create workflow version: %v", err) + } + startedAt := time.Now() + hybridRun := models.AgentRun{AIAgentID: 1, EngineCode: "hybrid", Status: "interrupted", StartedAt: startedAt, EndedAt: &startedAt, CreatedAt: startedAt, UpdatedAt: startedAt} + if err := db.Create(&hybridRun).Error; err != nil { + t.Fatalf("create interrupted hybrid run: %v", err) + } + interruptedRun := models.AIWorkflowRun{WorkflowID: version.WorkflowID, WorkflowVersionID: version.ID, ConversationID: 1, AIAgentID: 1, MessageID: 2, Status: workflowRunStatusInterrupted} + if err := db.Create(&interruptedRun).Error; err != nil { + t.Fatalf("create interrupted workflow run: %v", err) + } + const checkpointID = "workflow:1:2:confirm_1" + if err := db.Create(&models.ConversationInterrupt{ + ConversationID: 1, AIAgentID: 1, AgentRunID: hybridRun.ID, + CheckPointID: checkpointID, InterruptID: "confirm_1", InterruptType: "human_confirm", + WorkflowRunID: interruptedRun.ID, WorkflowNodeID: "confirm_1", RequestData: mustMarshalWorkflowCheckpoint(t, def), Status: "pending", + }).Error; err != nil { + t.Fatalf("create interrupt: %v", err) + } + + summary, err := NewHybridEngine().Resume(context.Background(), ResumeRequest{ + Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 3, Content: "确认"}, + AIAgent: models.AIAgent{ID: 1, RuntimeMode: enums.AIAgentRuntimeModeHybrid, WorkflowVersionID: version.ID}, + AIConfig: models.AIConfig{ModelName: "test-model"}, CheckPointID: checkpointID, + ResumeData: map[string]string{"confirm_1": "确认"}, + }) + if err != nil { + t.Fatalf("resume hybrid playbook: %v", err) + } + if summary == nil || summary.Status != "completed" || summary.AgentRunID != hybridRun.ID || summary.WorkflowRunID != interruptedRun.ID { + t.Fatalf("unexpected hybrid resume summary: %#v", summary) + } + item, steps, _ := svc.AgentRunService.GetDetail(hybridRun.ID) + if item == nil || item.Status != "completed" || len(steps) != 1 || steps[0].StepCode != "playbook_resume" || steps[0].WorkflowRunID != interruptedRun.ID { + t.Fatalf("expected original hybrid run to receive resume audit, run=%#v steps=%#v", item, steps) + } } func TestServiceResumeReusesInterruptedWorkflowRun(t *testing.T) { @@ -262,6 +332,13 @@ func TestServiceRunWritesFailedWorkflowRun(t *testing.T) { if badNodeRun.Status != workflowRunStatusFailed || badNodeRun.ErrorMessage == "" { t.Fatalf("unexpected failed node run: %#v", badNodeRun) } + var agentRun models.AgentRun + if err := db.First(&agentRun, "workflow_run_id = ?", run.ID).Error; err != nil { + t.Fatalf("find generic failed agent run: %v", err) + } + if agentRun.Status != "failed" || !strings.Contains(agentRun.ErrorMessage, "unsupported workflow node type") { + t.Fatalf("unexpected generic failed agent run: %#v", agentRun) + } } func TestServiceRunWritesFailedWorkflowRunWhenVersionDisabled(t *testing.T) { @@ -324,7 +401,15 @@ func setupWorkflowResumeTestDB(t *testing.T) *gorm.DB { _ = sqlDB.Close() } }) - if err := db.AutoMigrate(&models.AIWorkflowVersion{}, &models.AIWorkflowRun{}, &models.AIWorkflowNodeRun{}, &models.ConversationInterrupt{}); err != nil { + if err := db.AutoMigrate( + &models.AIWorkflowVersion{}, + &models.AIWorkflowRun{}, + &models.AIWorkflowNodeRun{}, + &models.AgentRun{}, + &models.AgentStep{}, + &models.AgentRevision{}, + &models.ConversationInterrupt{}, + ); err != nil { t.Fatalf("auto migrate: %v", err) } sqls.SetDB(db) diff --git a/internal/ai/runtime/debug_run.go b/internal/ai/runtime/debug_run.go index 55386a2..a81fdea 100644 --- a/internal/ai/runtime/debug_run.go +++ b/internal/ai/runtime/debug_run.go @@ -49,11 +49,12 @@ func DebugRunSkill(ctx context.Context, req request.SkillDebugRunRequest) (*resp MessageType: enums.IMMessageTypeText, Content: strings.TrimSpace(req.UserMessage), } - summary, err := Service.Run(ctx, applicationruntime.Request{ + summary, err := applicationruntime.DefaultAgentApplicationService.RunPrepared(ctx, applicationruntime.Request{ Conversation: *conversation, UserMessage: message, AIAgent: debugAgent, AIConfig: *aiConfig, + Debug: true, }) if err != nil { return buildSkillDebugRunResponse(req, summary, skill), err @@ -92,7 +93,7 @@ func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest) return nil, errorsx.InvalidParamI18n("error.e0117") } resumeText := strings.TrimSpace(req.UserMessage) - summary, err := Service.Resume(ctx, applicationruntime.ResumeRequest{ + summary, err := applicationruntime.DefaultAgentApplicationService.ResumePrepared(ctx, applicationruntime.ResumeRequest{ Conversation: *conversation, AIAgent: *aiAgent, AIConfig: *aiConfig, @@ -100,6 +101,7 @@ func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest) ResumeData: map[string]string{ strings.TrimSpace(pendingInterrupt.InterruptID): resumeText, }, + Debug: true, }) if err != nil { if isCheckpointMissingError(err) { diff --git a/internal/ai/runtime/evaluation_run.go b/internal/ai/runtime/evaluation_run.go new file mode 100644 index 0000000..285f35d --- /dev/null +++ b/internal/ai/runtime/evaluation_run.go @@ -0,0 +1,44 @@ +package runtime + +import ( + "context" + + applicationruntime "agent-desk/internal/ai/application/runtime" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + svc "agent-desk/internal/services" +) + +func init() { + svc.AgentEvaluationRunHook = RunAgentEvaluation +} + +func RunAgentEvaluation(ctx context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) { + agent := svc.AIAgentService.Get(req.AIAgentID) + if agent == nil || agent.Status != enums.StatusOk { + return nil, errorsx.InvalidParamI18n("error.e0007") + } + config := svc.AIConfigService.Get(agent.AIConfigID) + if config == nil { + return nil, errorsx.InvalidParamI18n("error.e0008") + } + cases := make([]applicationruntime.OfflineEvaluationCase, 0, len(req.Cases)) + for _, item := range req.Cases { + cases = append(cases, applicationruntime.OfflineEvaluationCase{ID: item.ID, Category: item.Category, Message: item.Message, History: item.History, Expect: item.Expect}) + } + report, err := applicationruntime.NewService().RunOfflineEvaluation(ctx, req.EngineCode, *agent, *config, cases) + if err != nil { + return nil, err + } + csv, err := report.CSV() + if err != nil { + return nil, err + } + ret := &response.AgentEvaluationReportResponse{EngineCode: report.EngineCode, Total: report.Total, Passed: report.Passed, CSV: csv, Results: make([]response.AgentEvaluationResultResponse, 0, len(report.Results))} + for _, item := range report.Results { + ret.Results = append(ret.Results, response.AgentEvaluationResultResponse{CaseID: item.CaseID, Category: item.Category, EngineCode: item.EngineCode, Passed: item.Passed, ReplyText: item.ReplyText, Interrupted: item.Interrupted, Error: item.Error, Finding: item.Finding}) + } + return ret, nil +} diff --git a/internal/ai/runtime/readtools/graph_executor.go b/internal/ai/runtime/readtools/graph_executor.go new file mode 100644 index 0000000..5a4f762 --- /dev/null +++ b/internal/ai/runtime/readtools/graph_executor.go @@ -0,0 +1,79 @@ +// Package readtools executes deterministic, read-only graph tools through the +// shared Tool Registry boundary. +package readtools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "agent-desk/internal/ai/runtime/graphs" + "agent-desk/internal/ai/runtime/retrievers" + aitooling "agent-desk/internal/ai/tooling" + "agent-desk/internal/models" + "agent-desk/internal/pkg/toolx" +) + +func ExecuteGraphTool(ctx context.Context, conversation models.Conversation, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, string, error) { + toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode)) + if toolCode != toolx.GraphTriageServiceRequest.Code && toolCode != toolx.GraphAnalyzeConversation.Code && toolCode != toolx.GraphPrepareTicketDraft.Code { + return aitooling.Definition{}, "", fmt.Errorf("tool is not a graph read tool") + } + definition, err := aitooling.DefaultRegistry.Resolve(toolCode) + if err != nil { + return aitooling.Definition{}, "", err + } + if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{ + Definition: definition, + Arguments: arguments, + Policy: policy, + }); err != nil { + return definition, "", err + } + if definition.TimeoutMS > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond) + defer cancel() + } + data, err := json.Marshal(arguments) + if err != nil { + return definition, "", err + } + switch toolCode { + case toolx.GraphTriageServiceRequest.Code: + result, err := graphs.NewTriageServiceRequestGraph(conversation).Run(ctx, string(data)) + return definition, result, err + case toolx.GraphAnalyzeConversation.Code: + result, err := graphs.NewAnalyzeConversationGraph(conversation).Run(ctx, string(data)) + return definition, result, err + default: + result, err := graphs.NewPrepareTicketDraftGraph(conversation).Run(ctx, string(data)) + return definition, result, err + } +} + +// RetrieveKnowledge executes the built-in knowledge tool after the same +// registry policy and timeout checks used by graph tools. +func RetrieveKnowledge(ctx context.Context, agent models.AIAgent, knowledgeBaseIDs []int64, query string, policy aitooling.Policy) (aitooling.Definition, *retrievers.KnowledgeRetrieveResult, error) { + definition, err := aitooling.DefaultRegistry.Resolve(toolx.BuiltinKnowledgeRetrieve.Code) + if err != nil { + return aitooling.Definition{}, nil, err + } + arguments := map[string]any{"query": strings.TrimSpace(query), "knowledgeBaseIds": knowledgeBaseIDs} + if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{ + Definition: definition, + Arguments: arguments, + Policy: policy, + }); err != nil { + return definition, nil, err + } + if definition.TimeoutMS > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond) + defer cancel() + } + result, err := retrievers.NewKnowledgeRetriever(agent, knowledgeBaseIDs).RetrieveContext(ctx, strings.TrimSpace(query)) + return definition, result, err +} diff --git a/internal/ai/runtime/readtools/graph_executor_test.go b/internal/ai/runtime/readtools/graph_executor_test.go new file mode 100644 index 0000000..4d479c8 --- /dev/null +++ b/internal/ai/runtime/readtools/graph_executor_test.go @@ -0,0 +1,26 @@ +package readtools + +import ( + "context" + "testing" + + aitooling "agent-desk/internal/ai/tooling" + "agent-desk/internal/models" + "agent-desk/internal/pkg/toolx" +) + +func TestExecuteGraphToolRejectsDisallowedToolBeforeGraphExecution(t *testing.T) { + definition, _, err := ExecuteGraphTool(context.Background(), models.Conversation{}, toolx.GraphAnalyzeConversation.Code, map[string]any{ + "observedIssue": "需要分析的问题", + }, aitooling.Policy{ + AllowedToolCodes: []string{toolx.GraphPrepareTicketDraft.Code}, + AllowedRiskLevels: []string{aitooling.RiskLevelRead}, + Confirmed: true, + }) + if err == nil { + t.Fatal("expected policy guard to reject the graph tool") + } + if definition.Code != toolx.GraphAnalyzeConversation.Code { + t.Fatalf("definition code = %q, want %q", definition.Code, toolx.GraphAnalyzeConversation.Code) + } +} diff --git a/internal/ai/runtime/reply_commit_service.go b/internal/ai/runtime/reply_commit_service.go index 6bd78a4..56b7110 100644 --- a/internal/ai/runtime/reply_commit_service.go +++ b/internal/ai/runtime/reply_commit_service.go @@ -3,6 +3,8 @@ package runtime import ( "fmt" "strings" + + aitooling "agent-desk/internal/ai/tooling" "time" "agent-desk/internal/models" @@ -31,9 +33,9 @@ func newReplyCommitService() *replyCommitService { } func (s *replyCommitService) SendAIReply(input replyCommitInput) (*models.Message, error) { - replyText := strings.TrimSpace(input.ReplyText) - if replyText == "" { - return nil, nil + replyText, err := aitooling.NormalizeCustomerReply(input.ReplyText) + if err != nil { + return nil, err } replyMessage, err := svc.MessageService.SendAIMessageWithRequestIDAndWorkflowRunID( input.Conversation.ID, diff --git a/internal/ai/runtime/reply_commit_service_test.go b/internal/ai/runtime/reply_commit_service_test.go index 54198a9..ca18ed2 100644 --- a/internal/ai/runtime/reply_commit_service_test.go +++ b/internal/ai/runtime/reply_commit_service_test.go @@ -46,6 +46,26 @@ func TestReplyCommitStoresWorkflowRunIDOnAIMessage(t *testing.T) { } } +func TestReplyCommitRejectsSensitiveModelOutput(t *testing.T) { + db := setupReplyCommitTestDB(t) + aiAgent := createReplyCommitTestAIAgent(t, db) + conversation := createReplyCommitTestConversation(t, db, aiAgent.ID) + _, err := newReplyCommitService().CommitAIReply(replyCommitInput{ + Conversation: *conversation, Message: models.Message{ID: 102, RequestID: "trace-102"}, AIAgent: *aiAgent, + ReplyText: "authorization=Bearer-secret", ClientPrefix: "ai_reply", + }) + if err == nil { + t.Fatal("expected sensitive model output to be rejected") + } + var count int64 + if err := db.Model(&models.Message{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error; err != nil { + t.Fatalf("count messages: %v", err) + } + if count != 0 { + t.Fatalf("unexpected message written for rejected output: %d", count) + } +} + func setupReplyCommitTestDB(t *testing.T) *gorm.DB { t.Helper() dbName := "reply_commit_test_" + strings.NewReplacer("/", "_").Replace(t.Name()) diff --git a/internal/ai/runtime/reply_eligibility.go b/internal/ai/runtime/reply_eligibility.go index 16a9c2c..d2d2ae4 100644 --- a/internal/ai/runtime/reply_eligibility.go +++ b/internal/ai/runtime/reply_eligibility.go @@ -1,6 +1,10 @@ package runtime import ( + "crypto/sha256" + "encoding/binary" + "fmt" + "agent-desk/internal/models" "agent-desk/internal/pkg/enums" @@ -13,6 +17,36 @@ func newReplyEligibility() *replyEligibility { return &replyEligibility{} } +// IsAIAgentRolloutEligible uses a stable conversation bucket so one customer +// remains consistently inside or outside a gray release throughout a session. +// Missing legacy values are treated as 100 to preserve existing behavior. +func IsAIAgentRolloutEligible(conversation models.Conversation, aiAgent models.AIAgent, channel *models.Channel) bool { + percent := normalizedRolloutPercent(aiAgent.RolloutPercent) + if channel != nil { + channelPercent := normalizedRolloutPercent(channel.AIAgentRolloutPercent) + if channelPercent < percent { + percent = channelPercent + } + } + if percent >= 100 { + return true + } + if conversation.ID <= 0 { + return false + } + seed := fmt.Sprintf("channel=%d;conversation=%d;agent=%d", conversation.ChannelID, conversation.ID, aiAgent.ID) + sum := sha256.Sum256([]byte(seed)) + bucket := int(binary.BigEndian.Uint64(sum[:8]) % 100) + return bucket < percent +} + +func normalizedRolloutPercent(percent int) int { + if percent <= 0 || percent > 100 { + return 100 + } + return percent +} + func (e *replyEligibility) CanReply(conversation models.Conversation, message models.Message, aiAgent models.AIAgent) bool { if message.SenderType != enums.IMSenderTypeCustomer { return false diff --git a/internal/ai/runtime/reply_helpers_test.go b/internal/ai/runtime/reply_helpers_test.go index e9d4d6a..fb66c7e 100644 --- a/internal/ai/runtime/reply_helpers_test.go +++ b/internal/ai/runtime/reply_helpers_test.go @@ -48,6 +48,7 @@ func TestBuildConversationInterruptStoresWorkflowCheckpointData(t *testing.T) { CheckPointData: `{"confirmNodeId":"confirm_1"}`, Interrupted: true, WorkflowRunID: 99, + AgentRunID: 88, Interrupts: []applicationruntime.InterruptContextSummary{ {Type: "human_confirm", ID: "confirm_1", InfoPreview: `{"message":"请确认"}`}, }, @@ -58,7 +59,7 @@ func TestBuildConversationInterruptStoresWorkflowCheckpointData(t *testing.T) { if item.RequestData != `{"confirmNodeId":"confirm_1"}` { t.Fatalf("unexpected request data: %q", item.RequestData) } - if item.WorkflowRunID != 99 || item.WorkflowNodeID != "confirm_1" { + if item.WorkflowRunID != 99 || item.AgentRunID != 88 || item.WorkflowNodeID != "confirm_1" { t.Fatalf("unexpected workflow interrupt identity: run=%d node=%q", item.WorkflowRunID, item.WorkflowNodeID) } } diff --git a/internal/ai/runtime/reply_interrupt_helpers.go b/internal/ai/runtime/reply_interrupt_helpers.go index 1dc5571..ebc7d28 100644 --- a/internal/ai/runtime/reply_interrupt_helpers.go +++ b/internal/ai/runtime/reply_interrupt_helpers.go @@ -29,6 +29,7 @@ func buildConversationInterrupt(conversation models.Conversation, message models } item.ConversationID = conversation.ID item.AIAgentID = aiAgent.ID + item.AgentRunID = summary.AgentRunID item.SourceMessageID = message.ID item.InterruptID = firstInterruptID(summary) item.InterruptType = firstInterruptType(summary) diff --git a/internal/ai/runtime/reply_interrupt_service.go b/internal/ai/runtime/reply_interrupt_service.go index adec32b..8933ec7 100644 --- a/internal/ai/runtime/reply_interrupt_service.go +++ b/internal/ai/runtime/reply_interrupt_service.go @@ -82,6 +82,9 @@ func (s *replyInterruptService) ResumePendingInterrupt(ctx context.Context, owne func (s *replyInterruptService) HandleInterruptedSummary(owner *aiReplyService, replyCtx aiReplyContext, summary *applicationruntime.Summary) error { pending := buildConversationInterrupt(replyCtx.Conversation, replyCtx.Message, replyCtx.AIAgent, summary) + if pending != nil && pending.AgentRunID > 0 { + pending.AgentStepID = svc.AgentRunService.GetLatestStepID(pending.AgentRunID) + } if err := svc.ConversationInterruptService.CreateOrUpdatePending(pending); err != nil { return err } diff --git a/internal/ai/runtime/reply_service_test.go b/internal/ai/runtime/reply_service_test.go index 7ec35c4..baf4eb2 100644 --- a/internal/ai/runtime/reply_service_test.go +++ b/internal/ai/runtime/reply_service_test.go @@ -49,6 +49,23 @@ func TestReplyEligibilityCanReply(t *testing.T) { } } +func TestAIAgentRolloutUsesStableConversationBucket(t *testing.T) { + conversation := models.Conversation{ID: 101, ChannelID: 7} + agent := models.AIAgent{ID: 9, RolloutPercent: 50} + first := IsAIAgentRolloutEligible(conversation, agent, &models.Channel{AIAgentRolloutPercent: 100}) + for range 20 { + if got := IsAIAgentRolloutEligible(conversation, agent, &models.Channel{AIAgentRolloutPercent: 100}); got != first { + t.Fatalf("rollout bucket changed within one conversation: first=%t got=%t", first, got) + } + } + if normalizedRolloutPercent(0) != 100 || normalizedRolloutPercent(101) != 100 || normalizedRolloutPercent(25) != 25 { + t.Fatal("unexpected rollout percent normalization") + } + if !IsAIAgentRolloutEligible(conversation, models.AIAgent{ID: 9, RolloutPercent: 0}, &models.Channel{}) { + t.Fatal("legacy zero rollout values must preserve full rollout") + } +} + func TestResolveReplyTimeout(t *testing.T) { service := newAIReplyService() aiAgent := newAIAgentFixture() diff --git a/internal/ai/runtime/reply_trigger_service.go b/internal/ai/runtime/reply_trigger_service.go index 19686fd..2aeb69d 100644 --- a/internal/ai/runtime/reply_trigger_service.go +++ b/internal/ai/runtime/reply_trigger_service.go @@ -58,6 +58,9 @@ func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.C if s.eligibility != nil && !s.eligibility.CanReply(conversation, message, aiAgent) { return nil } + if !IsAIAgentRolloutEligible(conversation, aiAgent, svc.ChannelService.Get(conversation.ChannelID)) { + return nil + } if pendingInterrupt := svc.ConversationInterruptService.FindLatestPendingByConversationID(conversation.ID); pendingInterrupt != nil { replyCtx.PendingInterrupt = pendingInterrupt return s.resumePendingInterrupt(ctx, replyCtx) @@ -82,6 +85,16 @@ func (s *aiReplyService) executeReply(ctx context.Context, replyCtx aiReplyConte if summary != nil && summary.Interrupted { return s.interrupts.HandleInterruptedSummary(s, replyCtx, summary) } + if summary != nil && summary.HandoffRequested { + if _, err := svc.ConversationHumanDispatchService.HandoffByAIWithRequestID( + replyCtx.Conversation.ID, + replyCtx.AIAgent, + "knowledge evidence unavailable", + replyCtx.Message.RequestID, + ); err == nil { + return nil + } + } if summary != nil && strings.TrimSpace(summary.ReplyText) != "" { _, err := s.commit.CommitAIReply(replyCommitInput{ Conversation: replyCtx.Conversation, diff --git a/internal/ai/runtime/runtime_reply_executor.go b/internal/ai/runtime/runtime_reply_executor.go index 305c861..7b0922b 100644 --- a/internal/ai/runtime/runtime_reply_executor.go +++ b/internal/ai/runtime/runtime_reply_executor.go @@ -8,7 +8,6 @@ import ( applicationruntime "agent-desk/internal/ai/application/runtime" "agent-desk/internal/ai/runtime/graphs" "agent-desk/internal/models" - svc "agent-desk/internal/services" ) type runtimeReplyExecutor struct{} @@ -31,15 +30,10 @@ func newRuntimeReplyExecutor() *runtimeReplyExecutor { } func (e *runtimeReplyExecutor) Run(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.Summary, error) { - aiConfig := svc.AIConfigService.Get(input.AIAgent.AIConfigID) - if aiConfig == nil { - return nil, fmt.Errorf("ai config is nil") - } - summary, err := Service.Run(ctx, applicationruntime.Request{ - Conversation: input.Conversation, - UserMessage: input.Message, - AIAgent: input.AIAgent, - AIConfig: *aiConfig, + summary, err := applicationruntime.DefaultAgentApplicationService.Run(ctx, applicationruntime.ApplicationRunInput{ + ConversationID: input.Conversation.ID, + MessageID: input.Message.ID, + AIAgentID: input.AIAgent.ID, }) return summary, err } @@ -48,15 +42,12 @@ func (e *runtimeReplyExecutor) ResumePendingInterrupt(ctx context.Context, input if input.PendingInterrupt == nil { return nil, fmt.Errorf("pending interrupt is required") } - aiConfig := svc.AIConfigService.Get(input.AIAgent.AIConfigID) - if aiConfig == nil { - return nil, fmt.Errorf("ai config is nil") - } - summary, err := Service.Resume(ctx, applicationruntime.ResumeRequest{ - Conversation: input.Conversation, - UserMessage: input.Message, - AIAgent: input.AIAgent, - AIConfig: *aiConfig, + summary, err := applicationruntime.DefaultAgentApplicationService.Resume(ctx, applicationruntime.ApplicationResumeInput{ + ApplicationRunInput: applicationruntime.ApplicationRunInput{ + ConversationID: input.Conversation.ID, + MessageID: input.Message.ID, + AIAgentID: input.AIAgent.ID, + }, CheckPointID: strings.TrimSpace(input.PendingInterrupt.CheckPointID), ResumeData: map[string]string{ strings.TrimSpace(input.PendingInterrupt.InterruptID): strings.TrimSpace(input.Message.Content), diff --git a/internal/ai/runtime/tools/tool_search_tool.go b/internal/ai/runtime/tools/tool_search_tool.go index e237dfc..491b406 100644 --- a/internal/ai/runtime/tools/tool_search_tool.go +++ b/internal/ai/runtime/tools/tool_search_tool.go @@ -10,6 +10,7 @@ import ( "agent-desk/internal/ai/mcps" "agent-desk/internal/ai/runtime/registry" "agent-desk/internal/ai/runtime/tooling" + aitooling "agent-desk/internal/ai/tooling" "agent-desk/internal/pkg/i18nx" "agent-desk/internal/pkg/toolx" @@ -167,11 +168,17 @@ func (t *ToolSearchTool) invokeTargetTool(ctx context.Context, toolCode string, if !containsToolCode(t.allowedToolCodes, toolCode) { return "", i18nx.Errorf("error.e0279") } - result, err := mcps.Runtime.CallTool(ctx, serverCode, toolName, cloneArguments(arguments)) + // A workflow administrator's allow-list is the explicit approval boundary + // for MCP tools. The registry still enforces its call limit and normalizes + // the safety metadata used by future autonomous engines. + _, result, err := aitooling.DefaultMCPExecutor.Execute(ctx, toolCode, arguments, aitooling.Policy{ + AllowedToolCodes: t.allowedToolCodes, + Confirmed: true, + }) if err != nil { return "", err } - return buildToolCallResultSummary(result), nil + return aitooling.SanitizePreview(buildToolCallResultSummary(result)), nil } func (t *ToolSearchTool) loadAllowedCandidates(ctx context.Context) ([]toolSearchCandidate, error) { diff --git a/internal/ai/runtime/workflow/executor.go b/internal/ai/runtime/workflow/executor.go index 2d2e73b..8d05d54 100644 --- a/internal/ai/runtime/workflow/executor.go +++ b/internal/ai/runtime/workflow/executor.go @@ -13,13 +13,13 @@ import ( "agent-desk/internal/ai" "agent-desk/internal/ai/runtime/graphs" - "agent-desk/internal/ai/runtime/retrievers" + "agent-desk/internal/ai/runtime/readtools" + aitooling "agent-desk/internal/ai/tooling" "agent-desk/internal/ai/workflow/dsl" workflowregistry "agent-desk/internal/ai/workflow/registry" "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" "agent-desk/internal/services" ) @@ -34,6 +34,7 @@ type Input struct { UserMessage models.Message AIAgent models.AIAgent AIConfig models.AIConfig + Debug bool } type Result struct { @@ -355,21 +356,37 @@ func (e *Executor) executeCreateTicket(state *runState, node dsl.Node) error { }) return nil } + if state.input.Debug { + state.setNodeVars(node.ID, map[string]any{ + "ticketId": int64(0), "ticketNo": "", "created": false, + "message": "调试运行不会创建工单。", "skipped": true, + }) + return nil + } draft := asMap(state.resolveInput(node, "ticketDraft")) title := strings.TrimSpace(toString(draft["title"])) description := strings.TrimSpace(toString(draft["description"])) - item, err := services.TicketService.CreateFromConversation(request.CreateTicketFromConversationRequest{ - ConversationID: state.input.Conversation.ID, - Title: title, - Description: description, - }, workflowAIPrincipal(state.input.AIAgent)) + result, err := services.BusinessToolExecutor.Execute(context.Background(), services.BusinessToolInput{ + Conversation: state.input.Conversation, AIAgent: state.input.AIAgent, + ToolCode: toolx.GraphCreateTicketConfirm.Code, Arguments: map[string]any{"title": title, "description": description}, + IdempotencyKey: workflowToolIdempotencyKey(state, node), Confirmed: true, + }) if err != nil { return err } + var output struct { + TicketID int64 `json:"ticketId"` + TicketNo string `json:"ticketNo"` + Created bool `json:"created"` + } + if err := json.Unmarshal([]byte(result.ResultData), &output); err != nil { + return err + } + item := &models.Ticket{ID: output.TicketID, TicketNo: output.TicketNo} state.setNodeVars(node.ID, map[string]any{ "ticketId": item.ID, "ticketNo": item.TicketNo, - "created": true, + "created": output.Created, "message": buildTicketCreatedMessage(item), }) return nil @@ -386,18 +403,6 @@ func buildTicketCreatedMessage(item *models.Ticket) string { return "工单已创建,工单号:" + ticketNo + "。" } -func workflowAIPrincipal(aiAgent models.AIAgent) *dto.AuthPrincipal { - username := strings.TrimSpace(aiAgent.Name) - if username == "" { - username = "AI" - } - return &dto.AuthPrincipal{ - UserID: 0, - Username: username, - Nickname: username, - } -} - type workflowConversationUnderstanding struct { NormalizedMessage string MessageIntent string @@ -610,11 +615,14 @@ func (e *Executor) executePrepareTicketDraft(ctx context.Context, state *runStat if currentAttempt := strings.TrimSpace(readStringConfig(node.Data.Config, "currentAttempt")); currentAttempt != "" { input.CurrentAttempt = currentAttempt } - args, err := json.Marshal(input) - if err != nil { - return err - } - raw, err := graphs.NewPrepareTicketDraftGraph(state.input.Conversation).Run(ctx, string(args)) + _, raw, err := readtools.ExecuteGraphTool(ctx, state.input.Conversation, toolx.GraphPrepareTicketDraft.Code, map[string]any{ + "title": input.Title, + "description": input.Description, + "issue": input.Issue, + "impact": input.Impact, + "expectedOutcome": input.ExpectedOutcome, + "currentAttempt": input.CurrentAttempt, + }, workflowReadToolPolicy(toolx.GraphPrepareTicketDraft.Code)) if err != nil { return err } @@ -665,11 +673,14 @@ func (e *Executor) executeAnalyzeConversation(ctx context.Context, state *runSta if strings.TrimSpace(readStringConfig(node.Data.Config, "additionalContext")) != "" { input.AdditionalContext = strings.TrimSpace(readStringConfig(node.Data.Config, "additionalContext")) } - args, err := json.Marshal(input) - if err != nil { - return err - } - raw, err := graphs.NewAnalyzeConversationGraph(state.input.Conversation).Run(ctx, string(args)) + _, raw, err := readtools.ExecuteGraphTool(ctx, state.input.Conversation, toolx.GraphAnalyzeConversation.Code, map[string]any{ + "goal": input.Goal, + "observedIssue": input.ObservedIssue, + "needTicket": input.NeedTicket, + "needHumanHandoff": input.NeedHumanHandoff, + "needQualityCheck": input.NeedQualityCheck, + "additionalContext": input.AdditionalContext, + }, workflowReadToolPolicy(toolx.GraphAnalyzeConversation.Code)) if err != nil { return err } @@ -687,6 +698,14 @@ func (e *Executor) executeAnalyzeConversation(ctx context.Context, state *runSta return nil } +func workflowReadToolPolicy(toolCode string) aitooling.Policy { + return aitooling.Policy{ + AllowedToolCodes: []string{toolCode}, + AllowedRiskLevels: []string{aitooling.RiskLevelRead}, + Confirmed: true, + } +} + func (e *Executor) executeHandoffToHuman(state *runState, node dsl.Node) error { if _, hasConfirmedInput := node.Data.InputsValues["confirmed"]; hasConfirmedInput && !truthy(state.resolveInput(node, "confirmed")) { state.setNodeVars(node.ID, map[string]any{ @@ -700,16 +719,32 @@ func (e *Executor) executeHandoffToHuman(state *runState, node dsl.Node) error { }) return nil } + if state.input.Debug { + state.setNodeVars(node.ID, map[string]any{ + "handoffId": int64(0), "reason": strings.TrimSpace(toString(state.resolveInput(node, "reason"))), + "decision": "cancelled", "teamId": int64(0), "assigneeId": int64(0), + "message": "调试运行不会转人工。", "skipped": true, + }) + return nil + } reason := strings.TrimSpace(toString(state.resolveInput(node, "reason"))) - result, err := services.ConversationHumanDispatchService.HandoffByAIWithRequestID( - state.input.Conversation.ID, - state.input.AIAgent, - reason, - strings.TrimSpace(state.input.UserMessage.RequestID), - ) + result, err := services.BusinessToolExecutor.Execute(context.Background(), services.BusinessToolInput{ + Conversation: state.input.Conversation, AIAgent: state.input.AIAgent, + ToolCode: toolx.GraphHandoffConversation.Code, Arguments: map[string]any{"reason": reason}, + IdempotencyKey: workflowToolIdempotencyKey(state, node), Confirmed: true, + }) if err != nil { return err } + var handoff struct { + Decision string `json:"decision"` + TeamID int64 `json:"teamId"` + AssigneeID int64 `json:"assigneeId"` + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(result.ResultData), &handoff); err != nil { + return err + } output := map[string]any{ "handoffId": int64(0), "reason": reason, @@ -718,24 +753,29 @@ func (e *Executor) executeHandoffToHuman(state *runState, node dsl.Node) error { "assigneeId": int64(0), "message": "", } - if result != nil { - output["decision"] = string(result.Decision) - output["teamId"] = result.TeamID - output["assigneeId"] = result.AssigneeID - output["message"] = strings.TrimSpace(result.Message) - } + output["decision"] = handoff.Decision + output["teamId"] = handoff.TeamID + output["assigneeId"] = handoff.AssigneeID + output["message"] = strings.TrimSpace(handoff.Message) state.setNodeVars(node.ID, output) return nil } +func workflowToolIdempotencyKey(state *runState, node dsl.Node) string { + requestID := strings.TrimSpace(state.input.UserMessage.RequestID) + if requestID != "" { + return fmt.Sprintf("workflow:%d:node:%s:request:%s", state.input.Conversation.ID, node.ID, requestID) + } + return fmt.Sprintf("workflow:%d:node:%s:message:%d", state.input.Conversation.ID, node.ID, state.input.UserMessage.ID) +} + func (e *Executor) executeKnowledgeRetrieve(ctx context.Context, state *runState, node dsl.Node) error { query := strings.TrimSpace(toString(state.resolveInput(node, "query"))) knowledgeBaseIDs := readInt64ArrayConfig(node.Data.Config, "knowledgeBaseIds") if len(knowledgeBaseIDs) == 0 { return fmt.Errorf("knowledge retrieve node requires knowledgeBaseIds") } - retriever := retrievers.NewKnowledgeRetriever(state.input.AIAgent, knowledgeBaseIDs) - result, err := retriever.RetrieveContext(ctx, query) + _, result, err := readtools.RetrieveKnowledge(ctx, state.input.AIAgent, knowledgeBaseIDs, query, workflowReadToolPolicy(toolx.BuiltinKnowledgeRetrieve.Code)) if err != nil { return err } diff --git a/internal/ai/runtime/workflow/executor_test.go b/internal/ai/runtime/workflow/executor_test.go index 36cd80d..006b55b 100644 --- a/internal/ai/runtime/workflow/executor_test.go +++ b/internal/ai/runtime/workflow/executor_test.go @@ -490,6 +490,43 @@ func TestExecutorResumeCreatesTicketAfterHumanConfirmation(t *testing.T) { if trace == nil || !strings.Contains(trace.OutputPreview, "工单已创建") { t.Fatalf("expected create_ticket output to include customer-visible result message, got %#v", trace) } + // Replaying the same confirmation checkpoint must reuse the completed + // business-tool invocation rather than creating a second ticket. + if _, err := executor.Resume(context.Background(), Input{ + Definition: createTicketWorkflowDefinition(), Conversation: conversation, UserMessage: userMessage, AIAgent: aiAgent, + }, interrupted.CheckPointData, "确认"); err != nil { + t.Fatalf("replay workflow resume: %v", err) + } + var ticketCount int64 + if err := db.Model(&models.Ticket{}).Where("conversation_id = ?", conversation.ID).Count(&ticketCount).Error; err != nil || ticketCount != 1 { + t.Fatalf("ticket count after replay = %d, err=%v", ticketCount, err) + } +} + +func TestExecutorDebugResumeDoesNotCreateTicket(t *testing.T) { + db := setupWorkflowExecutorHandoffDB(t) + aiAgent := createWorkflowExecutorHandoffAIAgent(t, db, "1") + conversation := createWorkflowExecutorHandoffConversation(t, db, aiAgent.ID) + userMessage := createWorkflowExecutorCustomerMessage(t, db, conversation.ID, "订单支付失败,请帮我登记工单") + executor := NewExecutor() + definition := createTicketWorkflowDefinition() + + interrupted, err := executor.Execute(context.Background(), Input{Definition: definition, Conversation: conversation, UserMessage: userMessage, AIAgent: aiAgent, Debug: true}) + if err != nil || !interrupted.Interrupted { + t.Fatalf("debug execute = %#v, err=%v", interrupted, err) + } + result, err := executor.Resume(context.Background(), Input{Definition: definition, Conversation: conversation, UserMessage: userMessage, AIAgent: aiAgent, Debug: true}, interrupted.CheckPointData, "确认") + if err != nil || result.Interrupted { + t.Fatalf("debug resume = %#v, err=%v", result, err) + } + var ticketCount int64 + if err := db.Model(&models.Ticket{}).Where("conversation_id = ?", conversation.ID).Count(&ticketCount).Error; err != nil || ticketCount != 0 { + t.Fatalf("debug ticket count = %d, err=%v", ticketCount, err) + } + trace := findNodeTrace(result.NodeTraces, "create_ticket_1") + if trace == nil || !strings.Contains(trace.OutputPreview, "调试运行不会创建工单") { + t.Fatalf("expected debug write skip trace, got %#v", trace) + } } func findNodeTrace(items []NodeTrace, nodeID string) *NodeTrace { @@ -835,6 +872,7 @@ func setupWorkflowExecutorHandoffDB(t *testing.T) *gorm.DB { &models.ConversationReadState{}, &models.Message{}, &models.ChannelMessageOutbox{}, + &models.AgentToolInvocation{}, &models.Ticket{}, &models.TicketNoSequence{}, &models.TicketTag{}, diff --git a/internal/ai/tool_loop.go b/internal/ai/tool_loop.go new file mode 100644 index 0000000..578154d --- /dev/null +++ b/internal/ai/tool_loop.go @@ -0,0 +1,91 @@ +package ai + +import ( + "context" + "fmt" + "strings" + + openai "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/shared" + + "agent-desk/internal/models" +) + +type ToolDefinition struct { + Name string + Description string + Parameters map[string]any +} + +type ToolCall struct { + ID string + Name string + Arguments string +} + +type ToolCallExecutor func(context.Context, ToolCall) (string, error) + +type ToolLoopResult struct { + ChatCompletionResult + ToolCalls []ToolCall +} + +// ChatWithTools executes a bounded OpenAI-compatible function-calling loop. +// Tool execution stays in the caller so business operations remain behind the +// application Tool Registry and Service layer. +func (s *llm) ChatWithTools(ctx context.Context, config models.AIConfig, systemPrompt, userPrompt string, definitions []ToolDefinition, maxSteps int, execute ToolCallExecutor) (*ToolLoopResult, error) { + if len(definitions) == 0 || execute == nil { + result, err := s.ChatWithConfig(ctx, config, systemPrompt, userPrompt) + if err != nil { + return nil, err + } + return &ToolLoopResult{ChatCompletionResult: *result}, nil + } + if maxSteps <= 0 { + maxSteps = 5 + } + messages := []openai.ChatCompletionMessageParamUnion{} + if strings.TrimSpace(systemPrompt) != "" { + messages = append(messages, openai.ChatCompletionMessageParamUnion{OfSystem: &openai.ChatCompletionSystemMessageParam{Content: openai.ChatCompletionSystemMessageParamContentUnion{OfString: openai.String(systemPrompt)}}}) + } + messages = append(messages, openai.ChatCompletionMessageParamUnion{OfUser: &openai.ChatCompletionUserMessageParam{Content: openai.ChatCompletionUserMessageParamContentUnion{OfString: openai.String(userPrompt)}}}) + tools := make([]openai.ChatCompletionToolUnionParam, 0, len(definitions)) + for _, definition := range definitions { + tools = append(tools, openai.ChatCompletionToolUnionParam{OfFunction: &openai.ChatCompletionFunctionToolParam{Function: shared.FunctionDefinitionParam{Name: definition.Name, Description: openai.String(definition.Description), Parameters: shared.FunctionParameters(definition.Parameters)}}}) + } + client := newOpenAIClient(config) + allCalls := make([]ToolCall, 0) + for step := 0; step < maxSteps; step++ { + params := openai.ChatCompletionNewParams{Messages: messages, Model: shared.ChatModel(config.ModelName), Tools: tools} + if config.MaxOutputTokens > 0 { + params.MaxCompletionTokens = openai.Int(int64(config.MaxOutputTokens)) + } + applyProviderSpecificChatParams(¶ms, config) + response, err := client.Chat.Completions.New(ctx, params) + if err != nil { + return nil, fmt.Errorf("tool loop chat completion failed: %w", err) + } + if len(response.Choices) == 0 { + return nil, fmt.Errorf("tool loop returned no choices") + } + message := response.Choices[0].Message + if len(message.ToolCalls) == 0 { + return &ToolLoopResult{ChatCompletionResult: ChatCompletionResult{Content: strings.TrimSpace(message.Content), ModelName: config.ModelName, PromptTokens: int(response.Usage.PromptTokens), CompletionTokens: int(response.Usage.CompletionTokens)}, ToolCalls: allCalls}, nil + } + messages = append(messages, message.ToParam()) + for _, rawCall := range message.ToolCalls { + call := rawCall.AsFunction() + if call.ID == "" || call.Function.Name == "" { + return nil, fmt.Errorf("tool loop received unsupported tool call") + } + toolCall := ToolCall{ID: call.ID, Name: call.Function.Name, Arguments: call.Function.Arguments} + allCalls = append(allCalls, toolCall) + output, callErr := execute(ctx, toolCall) + if callErr != nil { + output = "tool execution failed: " + callErr.Error() + } + messages = append(messages, openai.ChatCompletionMessageParamUnion{OfTool: &openai.ChatCompletionToolMessageParam{ToolCallID: call.ID, Content: openai.ChatCompletionToolMessageParamContentUnion{OfString: openai.String(output)}}}) + } + } + return nil, fmt.Errorf("tool loop exceeded maximum steps") +} diff --git a/internal/ai/tool_loop_test.go b/internal/ai/tool_loop_test.go new file mode 100644 index 0000000..84500d2 --- /dev/null +++ b/internal/ai/tool_loop_test.go @@ -0,0 +1,82 @@ +package ai + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" +) + +func TestChatWithToolsExecutesToolAndContinuesConversation(t *testing.T) { + var requestCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/v1/chat/completions" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + var body struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []struct { + ID string `json:"id"` + } `json:"tool_calls"` + } `json:"messages"` + Tools []json.RawMessage `json:"tools"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + + switch requestCount.Add(1) { + case 1: + if len(body.Tools) != 1 || len(body.Messages) != 2 || body.Messages[1].Role != "user" { + t.Fatalf("unexpected first request: %+v", body) + } + _, _ = w.Write([]byte(`{"id":"chatcmpl-1","object":"chat.completion","created":1,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"","tool_calls":[{"id":"call-1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"refund\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`)) + case 2: + if len(body.Messages) != 4 || body.Messages[2].Role != "assistant" || len(body.Messages[2].ToolCalls) != 1 || body.Messages[3].Role != "tool" || body.Messages[3].Content != "refund policy" { + t.Fatalf("tool result was not continued in second request: %+v", body.Messages) + } + _, _ = w.Write([]byte(`{"id":"chatcmpl-2","object":"chat.completion","created":2,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"Refunds are available within 30 days."},"finish_reason":"stop"}],"usage":{"prompt_tokens":20,"completion_tokens":4,"total_tokens":24}}`)) + default: + t.Fatalf("unexpected extra request") + } + })) + defer server.Close() + + var executed ToolCall + result, err := LLM.ChatWithTools(context.Background(), models.AIConfig{ + Provider: enums.AIProviderOpenAI, + BaseURL: server.URL + "/v1", + APIKey: "test-key", + ModelName: "test-model", + }, "You are helpful.", "What is the refund policy?", []ToolDefinition{{ + Name: "lookup", + Description: "Look up a policy.", + Parameters: map[string]any{"type": "object"}, + }}, 3, func(_ context.Context, call ToolCall) (string, error) { + executed = call + return "refund policy", nil + }) + if err != nil { + t.Fatalf("ChatWithTools: %v", err) + } + if got, want := result.Content, "Refunds are available within 30 days."; got != want { + t.Fatalf("result content = %q, want %q", got, want) + } + if executed.Name != "lookup" || executed.ID != "call-1" || executed.Arguments != `{"q":"refund"}` { + t.Fatalf("executed tool call = %+v", executed) + } + if len(result.ToolCalls) != 1 || result.PromptTokens != 20 || result.CompletionTokens != 4 { + t.Fatalf("unexpected result: %+v", result) + } + if got := requestCount.Load(); got != 2 { + t.Fatalf("request count = %d, want 2", got) + } +} diff --git a/internal/ai/tooling/executor.go b/internal/ai/tooling/executor.go new file mode 100644 index 0000000..d6c0147 --- /dev/null +++ b/internal/ai/tooling/executor.go @@ -0,0 +1,67 @@ +package tooling + +import ( + "context" + "fmt" + "strings" + "time" + + "agent-desk/internal/ai/mcps" + "agent-desk/internal/pkg/toolx" +) + +// MCPExecutor is the single execution boundary for dynamically discovered +// MCP tools. Engine adapters supply the policy for the current Agent run. +type MCPExecutor struct { + registry *Registry + runtime *mcps.RuntimeService +} + +var DefaultMCPExecutor = NewMCPExecutor(DefaultRegistry, mcps.Runtime) + +func NewMCPExecutor(registry *Registry, runtime *mcps.RuntimeService) *MCPExecutor { + return &MCPExecutor{registry: registry, runtime: runtime} +} + +func (e *MCPExecutor) Execute(ctx context.Context, toolCode string, arguments map[string]any, policy Policy) (Definition, *mcps.ToolCallResult, error) { + definition, err := e.registry.Resolve(toolCode) + if err != nil { + return Definition{}, nil, err + } + if err := DefaultPolicyGuard.Authorize(Invocation{Definition: definition, Arguments: arguments, Policy: policy}); err != nil { + return Definition{}, nil, err + } + serverCode, toolName := toolx.SplitMCPToolCode(strings.TrimSpace(definition.Code)) + if serverCode == "" || toolName == "" { + return Definition{}, nil, &UnsupportedExecutionError{ToolCode: definition.Code} + } + if e.runtime == nil { + return Definition{}, nil, fmt.Errorf("MCP executor runtime is not configured") + } + if definition.TimeoutMS > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond) + defer cancel() + } + result, err := e.runtime.CallTool(ctx, serverCode, toolName, cloneArguments(arguments)) + return definition, result, err +} + +type UnsupportedExecutionError struct { + ToolCode string +} + +func (e *UnsupportedExecutionError) Error() string { + return "tool is not executable through MCP: " + e.ToolCode +} + +func cloneArguments(input map[string]any) map[string]any { + if len(input) == 0 { + return map[string]any{} + } + ret := make(map[string]any, len(input)) + for key, value := range input { + ret[key] = value + } + return ret +} diff --git a/internal/ai/tooling/registry.go b/internal/ai/tooling/registry.go new file mode 100644 index 0000000..cd68514 --- /dev/null +++ b/internal/ai/tooling/registry.go @@ -0,0 +1,219 @@ +// Package tooling provides the engine-independent tool governance boundary. +package tooling + +import ( + "encoding/json" + "fmt" + "strings" + + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/toolx" +) + +const ( + RiskLevelRead = "read" + RiskLevelWrite = "write" + RiskLevelSensitive = "sensitive" +) + +// Definition is the normalized, engine-independent description of a tool. +type Definition struct { + Code string + Name string + Description string + InputSchema map[string]any + SourceType enums.ToolSourceType + RiskLevel string + RequireConfirmation bool + MaxCallsPerRun int + TimeoutMS int + IdempotencyMode string +} + +// Policy is supplied by the caller's agent/runtime context for one invocation. +// An empty AllowedToolCodes means the caller did not impose an allow-list. +type Policy struct { + AllowedToolCodes []string + SkillAllowedToolCodes []string + AllowedRiskLevels []string + CallCount int + TotalCallCount int + MaxTotalCalls int + MaxArgumentBytes int + Confirmed bool +} + +type Invocation struct { + Definition Definition + Arguments map[string]any + Policy Policy +} + +// PolicyGuard is the reusable enforcement point for every engine/tool adapter. +type PolicyGuard struct{} + +var DefaultPolicyGuard = &PolicyGuard{} + +type Registry struct{} + +var DefaultRegistry = NewRegistry() + +func NewRegistry() *Registry { + return &Registry{} +} + +func (r *Registry) Resolve(toolCode string) (Definition, error) { + toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode)) + if toolCode == "" { + return Definition{}, fmt.Errorf("tool code is required") + } + if spec, ok := toolx.GetRegisteredToolSpec(toolCode); ok { + return definitionFromSpec(spec), nil + } + serverCode, toolName := toolx.SplitMCPToolCode(toolCode) + if serverCode == "" || toolName == "" { + return Definition{}, fmt.Errorf("unsupported tool code: %s", toolCode) + } + // MCP metadata cannot reliably describe side effects. Treat it as sensitive + // until an administrator provides a more specific policy in a later phase. + return Definition{ + Code: toolCode, + Name: toolName, + InputSchema: map[string]any{"type": "object", "additionalProperties": true}, + SourceType: enums.ToolSourceTypeMCP, + RiskLevel: RiskLevelSensitive, + RequireConfirmation: true, + MaxCallsPerRun: 3, + TimeoutMS: 30000, + IdempotencyMode: "caller", + }, nil +} + +func (r *Registry) Authorize(definition Definition, policy Policy) error { + return DefaultPolicyGuard.Authorize(Invocation{Definition: definition, Policy: policy}) +} + +func (g *PolicyGuard) Authorize(invocation Invocation) error { + definition := invocation.Definition + policy := invocation.Policy + if definition.Code == "" { + return fmt.Errorf("tool definition is required") + } + if len(policy.AllowedToolCodes) > 0 && !containsCanonicalToolCode(policy.AllowedToolCodes, definition.Code) { + return fmt.Errorf("tool is not allowed: %s", definition.Code) + } + if len(policy.SkillAllowedToolCodes) > 0 && !containsCanonicalToolCode(policy.SkillAllowedToolCodes, definition.Code) { + return fmt.Errorf("tool is not allowed by the selected skill: %s", definition.Code) + } + if len(policy.AllowedRiskLevels) > 0 && !containsString(policy.AllowedRiskLevels, definition.RiskLevel) { + return fmt.Errorf("tool risk level is not allowed: %s", definition.RiskLevel) + } + if definition.MaxCallsPerRun > 0 && policy.CallCount >= definition.MaxCallsPerRun { + return fmt.Errorf("tool call limit reached: %s", definition.Code) + } + if policy.MaxTotalCalls > 0 && policy.TotalCallCount >= policy.MaxTotalCalls { + return fmt.Errorf("total tool call limit reached") + } + if policy.MaxArgumentBytes > 0 { + encoded, err := json.Marshal(invocation.Arguments) + if err != nil { + return fmt.Errorf("tool arguments are not serializable: %w", err) + } + if len(encoded) > policy.MaxArgumentBytes { + return fmt.Errorf("tool arguments exceed size limit: %s", definition.Code) + } + } + if definition.RequireConfirmation && !policy.Confirmed { + return fmt.Errorf("tool confirmation is required: %s", definition.Code) + } + return nil +} + +func definitionFromSpec(spec toolx.ToolSpec) Definition { + definition := Definition{ + Code: spec.Code, + Name: spec.Name, + Description: spec.Description, + SourceType: spec.SourceType, + RiskLevel: RiskLevelRead, + MaxCallsPerRun: 8, + TimeoutMS: 15000, + IdempotencyMode: "none", + } + switch spec.Code { + case toolx.BuiltinConversationContext.Code: + definition.InputSchema = objectSchema(map[string]any{}) + case toolx.BuiltinKnowledgeRetrieve.Code: + definition.InputSchema = requiredObjectSchema([]string{"query"}, map[string]any{"query": map[string]any{"type": "string"}}) + case toolx.GraphTriageServiceRequest.Code: + definition.InputSchema = objectSchema(map[string]any{ + "goal": map[string]any{"type": "string"}, + "observedIssue": map[string]any{"type": "string"}, + "needTicket": map[string]any{"type": "boolean"}, + "needHumanHandoff": map[string]any{"type": "boolean"}, + "additionalContext": map[string]any{"type": "string"}, + }) + case toolx.GraphAnalyzeConversation.Code: + definition.InputSchema = objectSchema(map[string]any{ + "goal": map[string]any{"type": "string"}, + "observedIssue": map[string]any{"type": "string"}, + "needTicket": map[string]any{"type": "boolean"}, + "needHumanHandoff": map[string]any{"type": "boolean"}, + "needQualityCheck": map[string]any{"type": "boolean"}, + "additionalContext": map[string]any{"type": "string"}, + }) + case toolx.GraphPrepareTicketDraft.Code: + definition.InputSchema = objectSchema(map[string]any{ + "title": map[string]any{"type": "string"}, + "description": map[string]any{"type": "string"}, + "issue": map[string]any{"type": "string"}, + "impact": map[string]any{"type": "string"}, + "expectedOutcome": map[string]any{"type": "string"}, + "currentAttempt": map[string]any{"type": "string"}, + }) + case toolx.GraphCreateTicketConfirm.Code: + definition.RiskLevel = RiskLevelWrite + definition.RequireConfirmation = true + definition.MaxCallsPerRun = 1 + definition.IdempotencyMode = "business" + definition.InputSchema = requiredObjectSchema([]string{"title", "description"}, map[string]any{ + "title": map[string]any{"type": "string"}, "description": map[string]any{"type": "string"}, + }) + case toolx.GraphHandoffConversation.Code: + definition.RiskLevel = RiskLevelWrite + definition.RequireConfirmation = true + definition.MaxCallsPerRun = 1 + definition.IdempotencyMode = "business" + definition.InputSchema = objectSchema(map[string]any{"reason": map[string]any{"type": "string"}}) + } + return definition +} + +func objectSchema(properties map[string]any) map[string]any { + return map[string]any{"type": "object", "properties": properties} +} + +func requiredObjectSchema(required []string, properties map[string]any) map[string]any { + schema := objectSchema(properties) + schema["required"] = required + return schema +} + +func containsString(items []string, target string) bool { + for _, item := range items { + if strings.EqualFold(strings.TrimSpace(item), strings.TrimSpace(target)) { + return true + } + } + return false +} + +func containsCanonicalToolCode(items []string, target string) bool { + target = toolx.NormalizeToolCodeAlias(strings.TrimSpace(target)) + for _, item := range items { + if toolx.NormalizeToolCodeAlias(strings.TrimSpace(item)) == target { + return true + } + } + return false +} diff --git a/internal/ai/tooling/registry_test.go b/internal/ai/tooling/registry_test.go new file mode 100644 index 0000000..db2476e --- /dev/null +++ b/internal/ai/tooling/registry_test.go @@ -0,0 +1,139 @@ +package tooling + +import ( + "strings" + "testing" + + "agent-desk/internal/pkg/toolx" +) + +func TestRegistryResolvesRegisteredToolPolicy(t *testing.T) { + definition, err := DefaultRegistry.Resolve(toolx.GraphCreateTicketConfirm.Code) + if err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + if definition.RiskLevel != RiskLevelWrite || !definition.RequireConfirmation || definition.MaxCallsPerRun != 1 { + t.Fatalf("unexpected definition: %#v", definition) + } + if err := DefaultRegistry.Authorize(definition, Policy{AllowedToolCodes: []string{toolx.GraphCreateTicketConfirm.Code}}); err == nil { + t.Fatal("expected confirmation requirement") + } +} + +func TestRegistryIncludesGraphInputSchemaAndRiskPolicy(t *testing.T) { + definition, err := DefaultRegistry.Resolve(toolx.GraphCreateTicketConfirm.Code) + if err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + if definition.InputSchema["type"] != "object" || len(definition.InputSchema["required"].([]string)) != 2 { + t.Fatalf("unexpected graph schema: %#v", definition.InputSchema) + } + if err := DefaultPolicyGuard.Authorize(Invocation{Definition: definition, Policy: Policy{ + AllowedToolCodes: []string{definition.Code}, AllowedRiskLevels: []string{RiskLevelRead}, Confirmed: true, + }}); err == nil || !strings.Contains(err.Error(), "risk level") { + t.Fatalf("expected risk policy rejection, got %v", err) + } +} + +func TestRegistryRequiresConfirmationForHandoff(t *testing.T) { + definition, err := DefaultRegistry.Resolve(toolx.GraphHandoffConversation.Code) + if err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + if definition.RiskLevel != RiskLevelWrite || !definition.RequireConfirmation || definition.IdempotencyMode != "business" { + t.Fatalf("unexpected handoff policy: %#v", definition) + } + if err := DefaultRegistry.Authorize(definition, Policy{AllowedToolCodes: []string{definition.Code}, AllowedRiskLevels: []string{RiskLevelWrite}}); err == nil || !strings.Contains(err.Error(), "confirmation") { + t.Fatalf("expected handoff confirmation rejection, got %v", err) + } +} + +func TestRegistryIncludesAllTicketDraftToolInputs(t *testing.T) { + definition, err := DefaultRegistry.Resolve(toolx.GraphPrepareTicketDraft.Code) + if err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + properties, _ := definition.InputSchema["properties"].(map[string]any) + for _, key := range []string{"title", "description", "issue", "impact", "expectedOutcome", "currentAttempt"} { + if _, ok := properties[key]; !ok { + t.Fatalf("ticket draft schema missing %q: %#v", key, definition.InputSchema) + } + } +} + +func TestRegistryTreatsMCPToolsAsSensitive(t *testing.T) { + definition, err := DefaultRegistry.Resolve("knowledge/search") + if err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + if definition.RiskLevel != RiskLevelSensitive || !definition.RequireConfirmation { + t.Fatalf("unexpected MCP definition: %#v", definition) + } + if err := DefaultRegistry.Authorize(definition, Policy{AllowedToolCodes: []string{"knowledge/search"}, Confirmed: true}); err != nil { + t.Fatalf("Authorize returned error: %v", err) + } +} + +func TestSanitizePreviewMasksAndBoundsSecrets(t *testing.T) { + preview := SanitizePreview(`authorization=Bearer-secret {"token":"abc123"}`) + if strings.Contains(preview, "Bearer-secret") || strings.Contains(preview, "abc123") { + t.Fatalf("secret leaked in preview: %q", preview) + } +} + +func TestNormalizeCustomerReplyRejectsSecretAndNormalizesText(t *testing.T) { + if _, err := NormalizeCustomerReply("token=abc123"); err == nil { + t.Fatal("expected sensitive reply to be rejected") + } + reply, err := NormalizeCustomerReply(" first\x00\n\n\n\nsecond ") + if err != nil || reply != "first\n\nsecond" { + t.Fatalf("unexpected normalized reply: %q err=%v", reply, err) + } +} + +func TestMCPExecutorRejectsUnconfirmedToolBeforeRuntimeCall(t *testing.T) { + executor := NewMCPExecutor(DefaultRegistry, nil) + _, _, err := executor.Execute(t.Context(), "knowledge/search", nil, Policy{ + AllowedToolCodes: []string{"knowledge/search"}, + }) + if err == nil || !strings.Contains(err.Error(), "confirmation") { + t.Fatalf("expected confirmation rejection, got %v", err) + } +} + +func TestPolicyGuardRejectsTotalCallsAndOversizedArguments(t *testing.T) { + definition, err := DefaultRegistry.Resolve("knowledge/search") + if err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + if err := DefaultPolicyGuard.Authorize(Invocation{ + Definition: definition, + Policy: Policy{AllowedToolCodes: []string{definition.Code}, Confirmed: true, TotalCallCount: 2, MaxTotalCalls: 2}, + }); err == nil || !strings.Contains(err.Error(), "total") { + t.Fatalf("expected total call rejection, got %v", err) + } + if err := DefaultPolicyGuard.Authorize(Invocation{ + Definition: definition, Arguments: map[string]any{"query": strings.Repeat("x", 40)}, + Policy: Policy{AllowedToolCodes: []string{definition.Code}, Confirmed: true, MaxArgumentBytes: 16}, + }); err == nil || !strings.Contains(err.Error(), "size") { + t.Fatalf("expected argument size rejection, got %v", err) + } +} + +func TestPolicyGuardRejectsToolOutsideSelectedSkillWhitelist(t *testing.T) { + definition, err := DefaultRegistry.Resolve("knowledge/search") + if err != nil { + t.Fatalf("Resolve returned error: %v", err) + } + err = DefaultPolicyGuard.Authorize(Invocation{ + Definition: definition, + Policy: Policy{ + AllowedToolCodes: []string{"knowledge/search"}, + SkillAllowedToolCodes: []string{"customer/profile"}, + Confirmed: true, + }, + }) + if err == nil || !strings.Contains(err.Error(), "selected skill") { + t.Fatalf("expected skill whitelist rejection, got %v", err) + } +} diff --git a/internal/ai/tooling/reply_safety.go b/internal/ai/tooling/reply_safety.go new file mode 100644 index 0000000..3dceb0b --- /dev/null +++ b/internal/ai/tooling/reply_safety.go @@ -0,0 +1,41 @@ +package tooling + +import ( + "fmt" + "strings" + "unicode" +) + +const maxCustomerReplyRunes = 8000 + +// NormalizeCustomerReply applies the final plain-text boundary before an AI +// response enters a customer conversation. It rejects likely credential +// assignments instead of masking them, because a masked secret is not useful +// customer-facing content. +func NormalizeCustomerReply(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", fmt.Errorf("ai reply is empty") + } + if secretAssignmentPattern.MatchString(value) { + return "", fmt.Errorf("ai reply contains sensitive credential data") + } + var builder strings.Builder + for _, r := range value { + if unicode.IsControl(r) && r != '\n' && r != '\t' { + continue + } + builder.WriteRune(r) + } + value = strings.TrimSpace(builder.String()) + for strings.Contains(value, "\n\n\n") { + value = strings.ReplaceAll(value, "\n\n\n", "\n\n") + } + if value == "" { + return "", fmt.Errorf("ai reply is empty") + } + if len([]rune(value)) > maxCustomerReplyRunes { + return "", fmt.Errorf("ai reply exceeds maximum length") + } + return value, nil +} diff --git a/internal/ai/tooling/sanitize.go b/internal/ai/tooling/sanitize.go new file mode 100644 index 0000000..4f305aa --- /dev/null +++ b/internal/ai/tooling/sanitize.go @@ -0,0 +1,25 @@ +package tooling + +import ( + "regexp" + "strings" +) + +const maxPreviewChars = 4000 + +var secretAssignmentPattern = regexp.MustCompile(`(?i)(?:"|')?(api[_-]?key|authorization|password|secret|token|cookie)(?:"|')?\s*([:=])\s*(?:"[^"]*"|'[^']*'|[^\s,;}]+)`) + +// SanitizePreview keeps audit/model previews bounded and masks common secrets. +// It intentionally operates on plain text so it also covers malformed JSON. +func SanitizePreview(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + value = secretAssignmentPattern.ReplaceAllString(value, "$1$2***") + runes := []rune(value) + if len(runes) <= maxPreviewChars { + return value + } + return strings.TrimSpace(string(runes[:maxPreviewChars])) + "\n[preview truncated]" +} diff --git a/internal/ai/workflow/registry/registry.go b/internal/ai/workflow/registry/registry.go index 64144a3..43970a9 100644 --- a/internal/ai/workflow/registry/registry.go +++ b/internal/ai/workflow/registry/registry.go @@ -248,14 +248,15 @@ func DefaultRegistry() *Registry { }, }, NodeSpec{ - Type: NodeTypeHandoffToHuman, - Title: "Handoff To Human", - Description: "Transfer the conversation to human support.", - Icon: "HeadphonesIcon", - RiskLevel: NodeRiskLevelHigh, + Type: NodeTypeHandoffToHuman, + Title: "Handoff To Human", + Description: "Transfer the conversation to human support.", + Icon: "HeadphonesIcon", + RiskLevel: NodeRiskLevelHigh, + RequiresConfirmationPredecessor: true, InputSchema: []VariableSpec{ requiredInput("reason", "转人工原因", VariableTypeString, "触发转人工处理的业务原因。"), - optionalInput("confirmed", "已确认", VariableTypeBoolean, "客户是否已确认转人工。"), + requiredInput("confirmed", "已确认", VariableTypeBoolean, "客户是否已确认转人工。"), }, OutputSchema: []VariableSpec{ output("handoffId", "转人工记录 ID", VariableTypeInteger, "本次转人工操作的内部记录编号。"), diff --git a/internal/ai/workflow/validator/validator_test.go b/internal/ai/workflow/validator/validator_test.go index 18402b1..3fe7ac8 100644 --- a/internal/ai/workflow/validator/validator_test.go +++ b/internal/ai/workflow/validator/validator_test.go @@ -162,6 +162,32 @@ func TestValidateDefinitionRejectsConfirmedInputFromNonConfirmNode(t *testing.T) } } +func TestValidateDefinitionRejectsHandoffWithoutConfirmedInput(t *testing.T) { + def := dsl.Definition{ + SchemaVersion: dsl.SchemaVersion, + Nodes: []dsl.Node{ + node("start_1", "start", nil, nil), + node("confirm_1", "human_confirm", inputs("prompt", dsl.RefValue("start_1", "userMessage")), nil), + node("handoff_1", "handoff_to_human", inputs("reason", dsl.RefValue("start_1", "userMessage")), nil), + node("end_1", "end", nil, nil), + }, + Edges: []dsl.Edge{ + edge("start_1", "confirm_1"), + edge("confirm_1", "handoff_1"), + edge("handoff_1", "end_1"), + }, + } + + result := validator.ValidateDefinition(def, registry.DefaultRegistry()) + + if result.Valid { + t.Fatalf("expected handoff without confirmed input to be invalid") + } + if !hasValidationMessage(result, "required input mapping is missing: confirmed") { + t.Fatalf("expected missing confirmed input error, got %#v", result.Errors) + } +} + func TestValidateDefinitionRejectsConditionBranchTargetWithoutEdge(t *testing.T) { def := conditionDefinition() def.Edges = []dsl.Edge{edge("start_1", "condition_1")} diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 138092d..8e16fa5 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -178,6 +178,7 @@ func registerDashboardChannelRoutes(group *gin.RouterGroup) { group.POST("/delete", dashboard.ChannelPostDelete) group.Any("/list", dashboard.ChannelAnyList) group.POST("/reset_user_token_secret", dashboard.ChannelPostReset_user_token_secret) + group.POST("/rollback_ai_agent_rollout", dashboard.ChannelPostRollback_ai_agent_rollout) group.POST("/update", dashboard.ChannelPostUpdate) group.POST("/update_status", dashboard.ChannelPostUpdate_status) group.Any("/wxwork/kf/accounts", dashboard.ChannelAnyWxworkKfAccounts) @@ -217,6 +218,10 @@ func registerDashboardAIAgentRoutes(group *gin.RouterGroup) { group.POST("/workflow/save", dashboard.AIWorkflowPostSaveAgent) group.POST("/workflow/validate", dashboard.AIWorkflowPostValidate) group.POST("/workflow/publish", dashboard.AIWorkflowPostPublishAgent) + group.POST("/publish", dashboard.AIAgentPostPublish) + group.POST("/rollback", dashboard.AIAgentPostRollback) + group.POST("/rollback_rollout", dashboard.AIAgentPostRollback_rollout) + group.Any("/:id/revision/list", dashboard.AIAgentAnyRevisionList) group.GET("/:id", dashboard.AIAgentGetBy) group.POST("/create", dashboard.AIAgentPostCreate) group.POST("/delete", dashboard.AIAgentPostDelete) @@ -230,6 +235,7 @@ func registerDashboardAIAgentRoutes(group *gin.RouterGroup) { func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) { group.GET("/node-spec/list", dashboard.AIWorkflowGetNodeSpecList) group.GET("/default-definition", dashboard.AIWorkflowGetDefaultDefinition) + group.GET("/template/list", dashboard.AIWorkflowGetTemplateList) group.POST("/validate", dashboard.AIWorkflowPostValidate) group.Any("/run/list", dashboard.AIWorkflowAnyRunList) group.GET("/run/:id", dashboard.AIWorkflowGetRunBy) @@ -237,6 +243,15 @@ func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) { group.GET("/version/:id", dashboard.AIWorkflowGetVersionBy) } +func registerDashboardAgentRunRoutes(group *gin.RouterGroup) { + group.Any("/metrics", dashboard.AgentRunAnyMetrics) + group.Any("/comparison", dashboard.AgentRunAnyComparison) + group.POST("/evaluate", dashboard.AgentRunPostEvaluate) + group.Any("/list", dashboard.AgentRunAnyList) + group.POST("/quality_feedback", dashboard.AgentRunPostSave_quality_feedback) + group.GET("/:id", dashboard.AgentRunGetBy) +} + func registerDashboardAIConfigRoutes(group *gin.RouterGroup) { group.GET("/:id", dashboard.AIConfigGetBy) group.POST("/create", dashboard.AIConfigPostCreate) diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index abcebba..3dc259b 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -189,6 +189,7 @@ func addRouter(app *gin.Engine) { registerDashboardAgentTeamScheduleRoutes(dashboardGroup.Group("/agent-team-schedule")) registerDashboardAIAgentRoutes(dashboardGroup.Group("/ai-agent")) registerDashboardAIWorkflowRoutes(dashboardGroup.Group("/ai-workflow")) + registerDashboardAgentRunRoutes(dashboardGroup.Group("/agent-run")) registerDashboardAIConfigRoutes(dashboardGroup.Group("/ai-config")) registerDashboardAssetRoutes(dashboardGroup.Group("/asset")) registerDashboardKnowledgeBaseRoutes(dashboardGroup.Group("/knowledge-base")) diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go index 592e6b1..1f282e4 100644 --- a/internal/bootstrap/server_route_test.go +++ b/internal/bootstrap/server_route_test.go @@ -43,8 +43,17 @@ func TestNewServerRegistersGinRoutes(t *testing.T) { http.MethodPost + " /api/dashboard/user/create", http.MethodPost + " /api/dashboard/conversation/send_message", http.MethodGet + " /api/dashboard/ai-workflow/default-definition", + http.MethodGet + " /api/dashboard/ai-workflow/template/list", http.MethodGet + " /api/dashboard/ai-workflow/run/list", http.MethodGet + " /api/dashboard/ai-workflow/run/:id", + http.MethodGet + " /api/dashboard/agent-run/metrics", + http.MethodGet + " /api/dashboard/agent-run/comparison", + http.MethodPost + " /api/dashboard/agent-run/evaluate", + http.MethodGet + " /api/dashboard/agent-run/:id", + http.MethodPost + " /api/dashboard/ai-agent/rollback_rollout", + http.MethodPost + " /api/dashboard/channel/rollback_ai_agent_rollout", + http.MethodPost + " /api/dashboard/agent-run/quality_feedback", + http.MethodGet + " /api/dashboard/agent-run/list", http.MethodGet + " /api/ws/dashboard", http.MethodGet + " /api/ws/open", } diff --git a/internal/builders/agent_revision_builder.go b/internal/builders/agent_revision_builder.go new file mode 100644 index 0000000..a21271a --- /dev/null +++ b/internal/builders/agent_revision_builder.go @@ -0,0 +1,29 @@ +package builders + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/response" +) + +func BuildAgentRevision(item *models.AgentRevision) response.AgentRevisionResponse { + if item == nil { + return response.AgentRevisionResponse{} + } + publishedAt := "" + if item.PublishedAt != nil { + publishedAt = item.PublishedAt.Format("2006-01-02 15:04:05") + } + return response.AgentRevisionResponse{ + ID: item.ID, AgentID: item.AgentID, Revision: item.Revision, WorkflowVersionID: item.WorkflowVersionID, + Status: item.Status, DefinitionHash: item.DefinitionHash, PublishedAt: publishedAt, + PublishedByID: item.PublishedByID, PublishedByName: item.PublishedByName, + } +} + +func BuildAgentRevisionList(items []models.AgentRevision) []response.AgentRevisionResponse { + ret := make([]response.AgentRevisionResponse, 0, len(items)) + for i := range items { + ret = append(ret, BuildAgentRevision(&items[i])) + } + return ret +} diff --git a/internal/builders/agent_run_builder.go b/internal/builders/agent_run_builder.go new file mode 100644 index 0000000..3dc5bd8 --- /dev/null +++ b/internal/builders/agent_run_builder.go @@ -0,0 +1,145 @@ +package builders + +import ( + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/response" +) + +func BuildAgentRun(item *models.AgentRun) response.AgentRunResponse { + if item == nil { + return response.AgentRunResponse{} + } + return response.AgentRunResponse{ + ID: item.ID, + ConversationID: item.ConversationID, + AIAgentID: item.AIAgentID, + AgentRevisionID: item.AgentRevisionID, + SourceMessageID: item.SourceMessageID, + WorkflowRunID: item.WorkflowRunID, + EngineCode: item.EngineCode, + Status: item.Status, + PromptTokens: item.PromptTokens, + CompletionTokens: item.CompletionTokens, + StartedAt: formatAgentRunTime(item.StartedAt), + EndedAt: formatAgentRunTimePtr(item.EndedAt), + DurationMS: agentRunDurationMS(item.StartedAt, item.EndedAt), + ErrorMessage: item.ErrorMessage, + TraceData: item.TraceData, + CreatedAt: formatAgentRunTime(item.CreatedAt), + UpdatedAt: formatAgentRunTime(item.UpdatedAt), + } +} + +func BuildAgentRunDetail(item *models.AgentRun, steps []models.AgentStep, toolCalls []models.AgentToolCall, feedback *models.AgentRunQualityFeedback) response.AgentRunResponse { + ret := BuildAgentRun(item) + ret.Steps = BuildAgentStepList(steps) + ret.ToolCalls = BuildAgentToolCallList(toolCalls) + ret.QualityFeedback = BuildAgentRunQualityFeedback(feedback) + return ret +} + +func BuildAgentRunQualityFeedback(item *models.AgentRunQualityFeedback) *response.AgentRunQualityFeedbackResponse { + if item == nil { + return nil + } + return &response.AgentRunQualityFeedbackResponse{ + ID: item.ID, + AgentRunID: item.AgentRunID, + ResolutionStatus: item.ResolutionStatus, + EvidenceStatus: item.EvidenceStatus, + Comment: item.Comment, + UpdateUserName: item.UpdateUserName, + UpdatedAt: formatAgentRunTime(item.UpdatedAt), + } +} + +func BuildAgentRunList(list []models.AgentRun) []response.AgentRunResponse { + ret := make([]response.AgentRunResponse, 0, len(list)) + for i := range list { + ret = append(ret, BuildAgentRun(&list[i])) + } + return ret +} + +func BuildAgentStep(item *models.AgentStep) response.AgentStepResponse { + if item == nil { + return response.AgentStepResponse{} + } + return response.AgentStepResponse{ + ID: item.ID, + AgentRunID: item.AgentRunID, + WorkflowRunID: item.WorkflowRunID, + StepType: item.StepType, + StepCode: item.StepCode, + Status: item.Status, + InputPreview: item.InputPreview, + OutputPreview: item.OutputPreview, + ErrorMessage: item.ErrorMessage, + StartedAt: formatAgentRunTime(item.StartedAt), + EndedAt: formatAgentRunTimePtr(item.EndedAt), + DurationMS: item.DurationMS, + } +} + +func BuildAgentStepList(list []models.AgentStep) []response.AgentStepResponse { + ret := make([]response.AgentStepResponse, 0, len(list)) + for i := range list { + ret = append(ret, BuildAgentStep(&list[i])) + } + return ret +} + +func BuildAgentToolCall(item *models.AgentToolCall) response.AgentToolCallResponse { + if item == nil { + return response.AgentToolCallResponse{} + } + return response.AgentToolCallResponse{ + ID: item.ID, + AgentRunID: item.AgentRunID, + AgentStepID: item.AgentStepID, + ToolCode: item.ToolCode, + RiskLevel: item.RiskLevel, + RequireConfirm: item.RequireConfirm, + Status: item.Status, + ArgumentsPreview: item.ArgumentsPreview, + ResultPreview: item.ResultPreview, + ErrorMessage: item.ErrorMessage, + DurationMS: item.DurationMS, + CreatedAt: formatAgentRunTime(item.CreatedAt), + } +} + +func BuildAgentToolCallList(list []models.AgentToolCall) []response.AgentToolCallResponse { + ret := make([]response.AgentToolCallResponse, 0, len(list)) + for i := range list { + ret = append(ret, BuildAgentToolCall(&list[i])) + } + return ret +} + +func formatAgentRunTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.Format("2006-01-02 15:04:05") +} + +func formatAgentRunTimePtr(value *time.Time) string { + if value == nil { + return "" + } + return formatAgentRunTime(*value) +} + +func agentRunDurationMS(startedAt time.Time, endedAt *time.Time) int64 { + if startedAt.IsZero() || endedAt == nil || endedAt.IsZero() { + return 0 + } + duration := endedAt.Sub(startedAt).Milliseconds() + if duration < 0 { + return 0 + } + return duration +} diff --git a/internal/builders/ai_workflow_builder.go b/internal/builders/ai_workflow_builder.go index 514ed22..c2e7df0 100644 --- a/internal/builders/ai_workflow_builder.go +++ b/internal/builders/ai_workflow_builder.go @@ -8,6 +8,7 @@ import ( workflowregistry "agent-desk/internal/ai/workflow/registry" "agent-desk/internal/models" "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/services" ) func BuildAIWorkflow(item *models.AIWorkflow) response.AIWorkflowResponse { @@ -89,6 +90,14 @@ func BuildAIWorkflowNodeSpecs(list []workflowregistry.NodeSpec) []response.AIWor return ret } +func BuildAIWorkflowTemplates(list []services.AIWorkflowTemplate) []response.AIWorkflowTemplateResponse { + ret := make([]response.AIWorkflowTemplateResponse, 0, len(list)) + for _, item := range list { + ret = append(ret, response.AIWorkflowTemplateResponse{Code: item.Code, Name: item.Name, Description: item.Description, Definition: item.Definition}) + } + return ret +} + func BuildAIWorkflowRun(item *models.AIWorkflowRun) response.AIWorkflowRunResponse { return BuildAIWorkflowRunWithContext(item, nil, nil, nil) } diff --git a/internal/handlers/dashboard/agent_run_handler.go b/internal/handlers/dashboard/agent_run_handler.go new file mode 100644 index 0000000..c157091 --- /dev/null +++ b/internal/handlers/dashboard/agent_run_handler.go @@ -0,0 +1,103 @@ +package dashboard + +import ( + "agent-desk/internal/builders" + "agent-desk/internal/pkg/constants" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/httpx" + "agent-desk/internal/pkg/httpx/params" + "agent-desk/internal/services" + + "github.com/gin-gonic/gin" + "github.com/mlogclub/simple/web" +) + +func AgentRunAnyList(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + queryParams := params.NewQueryParams(ctx) + queryParams.Cnd = *params.NewPagedSqlCnd(ctx, + params.QueryFilter{ParamName: "conversationId"}, + params.QueryFilter{ParamName: "aiAgentId"}, + params.QueryFilter{ParamName: "agentRevisionId"}, + params.QueryFilter{ParamName: "sourceMessageId"}, + params.QueryFilter{ParamName: "workflowRunId"}, + params.QueryFilter{ParamName: "engineCode"}, + params.QueryFilter{ParamName: "status"}, + ).Desc("id") + list, paging := services.AgentRunService.FindPageByParams(queryParams) + httpx.WriteJSON(ctx, &web.PageResult{Results: builders.BuildAgentRunList(list), Page: paging}) +} + +func AgentRunGetBy(ctx *gin.Context) { + id, ok := httpx.GetPathInt64(ctx, "id") + if !ok { + return + } + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + run, steps, toolCalls := services.AgentRunService.GetDetail(id) + if run == nil { + httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0002")) + return + } + httpx.WriteJSON(ctx, builders.BuildAgentRunDetail(run, steps, toolCalls, services.AgentRunService.GetQualityFeedback(run.ID))) +} + +func AgentRunPostSave_quality_feedback(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.SaveAgentRunQualityFeedbackRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.AgentRunService.SaveQualityFeedback(req, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func AgentRunAnyMetrics(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + aiAgentID, _ := params.GetInt64(ctx, "aiAgentId") + httpx.WriteJSON(ctx, services.AgentRunService.GetMetrics(aiAgentID)) +} + +func AgentRunAnyComparison(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + aiAgentID, _ := params.GetInt64(ctx, "aiAgentId") + httpx.WriteJSON(ctx, services.AgentRunService.GetEngineComparisons(aiAgentID)) +} + +func AgentRunPostEvaluate(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.RunAgentEvaluationRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + result, err := services.AgentEvaluationService.Run(ctx, req) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, result) +} diff --git a/internal/handlers/dashboard/ai_agent_handler.go b/internal/handlers/dashboard/ai_agent_handler.go index 1fc159a..22fe473 100644 --- a/internal/handlers/dashboard/ai_agent_handler.go +++ b/internal/handlers/dashboard/ai_agent_handler.go @@ -1,6 +1,7 @@ package dashboard import ( + "agent-desk/internal/builders" "agent-desk/internal/pkg/httpx" "encoding/json" "strings" @@ -125,6 +126,77 @@ func AIAgentPostDelete(ctx *gin.Context) { httpx.WriteJSON(ctx, nil) } +func AIAgentPostPublish(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.PublishAIAgentRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + _, err = services.AIAgentService.PublishAIAgent(req.ID, operator) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func AIAgentAnyRevisionList(ctx *gin.Context) { + id, ok := httpx.GetPathInt64(ctx, "id") + if !ok { + return + } + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if services.AIAgentService.Get(id) == nil { + httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0002")) + return + } + httpx.WriteJSON(ctx, builders.BuildAgentRevisionList(services.AgentRevisionService.FindByAgentID(id))) +} + +func AIAgentPostRollback(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.RollbackAIAgentRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.AIAgentService.RollbackAIAgent(req.ID, req.RevisionID, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func AIAgentPostRollback_rollout(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.RollbackAIAgentRolloutRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.AIAgentService.RollbackAIAgentRollout(req.ID, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + func AIAgentPostUpdate_sort(ctx *gin.Context) { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate); err != nil { httpx.WriteJSON(ctx, err) @@ -165,36 +237,50 @@ func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse { } func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) response.AIAgentResponse { + runtimeMode := item.RuntimeMode + if runtimeMode == "" { + runtimeMode = enums.AIAgentRuntimeModeWorkflow + } ret := response.AIAgentResponse{ - ID: item.ID, - Name: item.Name, - Description: item.Description, - Status: item.Status, - StatusName: enums.GetStatusLabel(item.Status), - AIConfigID: item.AIConfigID, - ServiceMode: item.ServiceMode, - ServiceModeName: enums.GetIMConversationServiceModeLabel(item.ServiceMode), - SystemPrompt: item.SystemPrompt, - WelcomeMessage: item.WelcomeMessage, - ReplyTimeoutSeconds: item.ReplyTimeoutSeconds, - HandoffMode: item.HandoffMode, - HandoffModeName: enums.GetAIAgentHandoffModeLabel(item.HandoffMode), - FallbackMode: item.FallbackMode, - FallbackModeName: enums.GetAIAgentFallbackModeLabel(item.FallbackMode), - FallbackMessage: item.FallbackMessage, - SkillIDs: utils.SplitInt64s(item.SkillIDs), - Skills: make([]response.AIAgentSkillResponse, 0), - Teams: make([]response.AIAgentTeamResponse, 0), - DirectTools: make([]response.AIAgentMCPToolResponse, 0), - WorkflowVersionID: item.WorkflowVersionID, - WorkflowPublished: item.WorkflowVersionID > 0, - WorkflowState: aiAgentWorkflowState(item.WorkflowVersionID), - WorkflowStateText: aiAgentWorkflowStateText(item.WorkflowVersionID), - SortNo: item.SortNo, - CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"), - UpdatedAt: item.UpdatedAt.Format("2006-01-02 15:04:05"), - CreateUserName: item.CreateUserName, - UpdateUserName: item.UpdateUserName, + ID: item.ID, + Name: item.Name, + Description: item.Description, + Status: item.Status, + StatusName: enums.GetStatusLabel(item.Status), + AIConfigID: item.AIConfigID, + RuntimeMode: runtimeMode, + RuntimeModeName: enums.GetAIAgentRuntimeModeLabel(runtimeMode), + MaxSteps: item.MaxSteps, + ContextWindow: item.ContextWindow, + ToolPolicy: item.ToolPolicy, + KnowledgePolicy: item.KnowledgePolicy, + ServiceMode: item.ServiceMode, + ServiceModeName: enums.GetIMConversationServiceModeLabel(item.ServiceMode), + SystemPrompt: item.SystemPrompt, + WelcomeMessage: item.WelcomeMessage, + ReplyTimeoutSeconds: item.ReplyTimeoutSeconds, + RolloutPercent: item.RolloutPercent, + PreviousRolloutPercent: item.PreviousRolloutPercent, + HandoffMode: item.HandoffMode, + HandoffModeName: enums.GetAIAgentHandoffModeLabel(item.HandoffMode), + FallbackMode: item.FallbackMode, + FallbackModeName: enums.GetAIAgentFallbackModeLabel(item.FallbackMode), + FallbackMessage: item.FallbackMessage, + KnowledgeBaseIDs: utils.SplitInt64s(item.KnowledgeIDs), + SkillIDs: utils.SplitInt64s(item.SkillIDs), + Skills: make([]response.AIAgentSkillResponse, 0), + Teams: make([]response.AIAgentTeamResponse, 0), + DirectTools: make([]response.AIAgentMCPToolResponse, 0), + WorkflowVersionID: item.WorkflowVersionID, + PublishedRevisionID: item.PublishedRevisionID, + WorkflowPublished: item.WorkflowVersionID > 0, + WorkflowState: aiAgentWorkflowState(item.WorkflowVersionID), + WorkflowStateText: aiAgentWorkflowStateText(item.WorkflowVersionID), + SortNo: item.SortNo, + CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: item.UpdatedAt.Format("2006-01-02 15:04:05"), + CreateUserName: item.CreateUserName, + UpdateUserName: item.UpdateUserName, } if aiConfig := services.AIConfigService.Get(item.AIConfigID); aiConfig != nil { ret.AIConfigName = aiConfig.Name diff --git a/internal/handlers/dashboard/ai_agent_handler_test.go b/internal/handlers/dashboard/ai_agent_handler_test.go index 27ebbf7..b10f419 100644 --- a/internal/handlers/dashboard/ai_agent_handler_test.go +++ b/internal/handlers/dashboard/ai_agent_handler_test.go @@ -4,6 +4,7 @@ import ( "testing" "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" "github.com/glebarez/sqlite" "github.com/mlogclub/simple/sqls" @@ -14,6 +15,9 @@ func TestBuildAIAgentResponseExposesWorkflowPublishState(t *testing.T) { setupAIAgentHandlerTestDB(t) draft := buildAIAgentResponse(&models.AIAgent{}) + if draft.RuntimeMode != enums.AIAgentRuntimeModeWorkflow { + t.Fatalf("draft.RuntimeMode = %q, want %q", draft.RuntimeMode, enums.AIAgentRuntimeModeWorkflow) + } if draft.WorkflowPublished { t.Fatalf("draft.WorkflowPublished = true, want false") } @@ -34,6 +38,11 @@ func TestBuildAIAgentResponseExposesWorkflowPublishState(t *testing.T) { if published.WorkflowStateText == "" { t.Fatalf("expected published workflow state text") } + + rollout := buildAIAgentResponse(&models.AIAgent{RolloutPercent: 20, PreviousRolloutPercent: 100}) + if rollout.RolloutPercent != 20 || rollout.PreviousRolloutPercent != 100 { + t.Fatalf("unexpected rollout response: %#v", rollout) + } } func setupAIAgentHandlerTestDB(t *testing.T) { diff --git a/internal/handlers/dashboard/ai_workflow_handler.go b/internal/handlers/dashboard/ai_workflow_handler.go index 536002d..523e63e 100644 --- a/internal/handlers/dashboard/ai_workflow_handler.go +++ b/internal/handlers/dashboard/ai_workflow_handler.go @@ -116,6 +116,14 @@ func AIWorkflowGetDefaultDefinition(ctx *gin.Context) { httpx.WriteJSON(ctx, services.AIWorkflowService.DefaultAgentWorkflowDefinition()) } +func AIWorkflowGetTemplateList(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, builders.BuildAIWorkflowTemplates(services.AIWorkflowService.ListPlaybookTemplates())) +} + func AIWorkflowPostValidate(ctx *gin.Context) { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil { httpx.WriteJSON(ctx, err) diff --git a/internal/handlers/dashboard/channel_handler.go b/internal/handlers/dashboard/channel_handler.go index ac2b95e..a259bb2 100644 --- a/internal/handlers/dashboard/channel_handler.go +++ b/internal/handlers/dashboard/channel_handler.go @@ -100,6 +100,24 @@ func ChannelPostUpdate(ctx *gin.Context) { httpx.WriteJSON(ctx, nil) } +func ChannelPostRollback_ai_agent_rollout(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.RollbackChannelAIAgentRolloutRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.ChannelService.RollbackChannelAIAgentRollout(req.ID, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + func ChannelPostUpdate_status(ctx *gin.Context) { operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelUpdate) if err != nil { diff --git a/internal/models/models.go b/internal/models/models.go index e28fbe9..54acc89 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -57,6 +57,12 @@ var Models = []any{ &KnowledgeFeedback{}, &SkillDefinition{}, &SkillRunLog{}, + &AgentRevision{}, + &AgentRun{}, + &AgentStep{}, + &AgentToolCall{}, + &AgentToolInvocation{}, + &AgentRunQualityFeedback{}, &AIWorkflow{}, &AIWorkflowVersion{}, &AIWorkflowRun{}, @@ -65,6 +71,21 @@ var Models = []any{ &SystemConfig{}, } +// AgentToolInvocation persists the idempotency boundary for a business tool. +// It is intentionally independent of AgentRun audit rows so a retry after a +// process interruption cannot repeat an external write. +type AgentToolInvocation struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + ConversationID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_agent_tool_invocation"` + AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"` + ToolCode string `gorm:"type:varchar(128);not null;default:'';uniqueIndex:uk_agent_tool_invocation"` + IdempotencyKey string `gorm:"type:varchar(160);not null;default:'';uniqueIndex:uk_agent_tool_invocation"` + Status string `gorm:"type:varchar(20);not null;default:'running';index"` + ResultData string `gorm:"type:text"` + ErrorMessage string `gorm:"type:text"` + AuditFields +} + type Migration struct { ID int64 `gorm:"primaryKey;autoIncrement"` Version int64 `gorm:"type:bigint;not null;uniqueIndex"` @@ -508,24 +529,111 @@ type QuickReply struct { // AIAgent AI 接待实例。 type AIAgent struct { - ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为 AI Agent 主键。 - Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为 AI Agent 名称。 - Description string `gorm:"type:varchar(255);not null;default:''"` // Description 为 AI Agent 描述。 - Status enums.Status `gorm:"type:int;not null;index"` // Status 为 AI Agent - AIConfigID int64 `gorm:"type:bigint;not null;default:0;index"` // AIConfigID 为关联的 AI 配置ID。 - ServiceMode enums.IMConversationServiceMode `gorm:"type:int;not null;default:3;index"` // ServiceMode 为服务模式,如仅AI、仅人工、AI优先人工接管。 - SystemPrompt string `gorm:"type:text"` // SystemPrompt 为该 Agent 的系统提示词。 - WelcomeMessage string `gorm:"type:text"` // WelcomeMessage 为该 Agent 的欢迎语或首响模板。 - ReplyTimeoutSeconds int `gorm:"type:int;not null;default:180"` // ReplyTimeoutSeconds 为异步自动回复超时秒数。 - TeamIDs string `gorm:"type:varchar(500);not null;default:''"` // TeamIDs 为转人工时可路由的客服组ID列表,多个之间使用逗号分隔。 - HandoffMode enums.AIAgentHandoffMode `gorm:"type:int;not null;default:1"` // HandoffMode 为转人工执行方式,如进入待接入池、进入默认客服组待接入池。 - FallbackMode enums.AIAgentFallbackMode `gorm:"type:int;not null;default:1"` // FallbackMode 为知识不足时的回复策略。 - FallbackMessage string `gorm:"type:text"` // FallbackMessage 为知识不足回复文案。 - KnowledgeIDs string `gorm:"type:varchar(500);not null;default:''"` // KnowledgeIDs 为绑定的知识库ID列表,按顺序表示优先级。 - SkillIDs string `gorm:"type:varchar(500);not null;default:''"` // SkillIDs 为绑定的技能ID列表,按顺序表示允许路由的范围。 - AllowedMCPTools string `gorm:"type:text"` // AllowedMCPTools 为允许 direct tool 路由的 MCP 工具白名单配置JSON。 - WorkflowVersionID int64 `gorm:"type:bigint;not null;default:0;index"` // WorkflowVersionID 为绑定的已发布会话流程版本ID。 - SortNo int `gorm:"type:int;not null;default:0;index"` // SortNo 为后台展示排序号。 + ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为 AI Agent 主键。 + Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为 AI Agent 名称。 + Description string `gorm:"type:varchar(255);not null;default:''"` // Description 为 AI Agent 描述。 + Status enums.Status `gorm:"type:int;not null;index"` // Status 为 AI Agent + AIConfigID int64 `gorm:"type:bigint;not null;default:0;index"` // AIConfigID 为关联的 AI 配置ID。 + RuntimeMode enums.AIAgentRuntimeMode `gorm:"type:varchar(30);not null;default:'workflow';index"` // RuntimeMode 为 Agent 的运行引擎模式。 + MaxSteps int `gorm:"type:int;not null;default:6"` // MaxSteps 为一次自主运行允许的最大推理步骤数。 + ContextWindow int `gorm:"type:int;not null;default:0"` // ContextWindow 为会话上下文消息窗口,0 表示使用运行时默认值。 + ToolPolicy string `gorm:"type:text"` // ToolPolicy 为工具风险与确认策略JSON。 + KnowledgePolicy string `gorm:"type:text"` // KnowledgePolicy 为知识检索与无依据回答策略JSON。 + ServiceMode enums.IMConversationServiceMode `gorm:"type:int;not null;default:3;index"` // ServiceMode 为服务模式,如仅AI、仅人工、AI优先人工接管。 + SystemPrompt string `gorm:"type:text"` // SystemPrompt 为该 Agent 的系统提示词。 + WelcomeMessage string `gorm:"type:text"` // WelcomeMessage 为该 Agent 的欢迎语或首响模板。 + ReplyTimeoutSeconds int `gorm:"type:int;not null;default:180"` // ReplyTimeoutSeconds 为异步自动回复超时秒数。 + RolloutPercent int `gorm:"type:int;not null;default:100"` // RolloutPercent 为该 Agent 的会话灰度百分比,100 表示全量。 + PreviousRolloutPercent int `gorm:"type:int;not null;default:0"` // PreviousRolloutPercent 保存上一次生效的灰度比例,0 表示尚无可回滚值。 + TeamIDs string `gorm:"type:varchar(500);not null;default:''"` // TeamIDs 为转人工时可路由的客服组ID列表,多个之间使用逗号分隔。 + HandoffMode enums.AIAgentHandoffMode `gorm:"type:int;not null;default:1"` // HandoffMode 为转人工执行方式,如进入待接入池、进入默认客服组待接入池。 + FallbackMode enums.AIAgentFallbackMode `gorm:"type:int;not null;default:1"` // FallbackMode 为知识不足时的回复策略。 + FallbackMessage string `gorm:"type:text"` // FallbackMessage 为知识不足回复文案。 + KnowledgeIDs string `gorm:"type:varchar(500);not null;default:''"` // KnowledgeIDs 为绑定的知识库ID列表,按顺序表示优先级。 + SkillIDs string `gorm:"type:varchar(500);not null;default:''"` // SkillIDs 为绑定的技能ID列表,按顺序表示允许路由的范围。 + AllowedMCPTools string `gorm:"type:text"` // AllowedMCPTools 为允许 direct tool 路由的 MCP 工具白名单配置JSON。 + WorkflowVersionID int64 `gorm:"type:bigint;not null;default:0;index"` // WorkflowVersionID 为绑定的已发布会话流程版本ID。 + PublishedRevisionID int64 `gorm:"type:bigint;not null;default:0;index"` // PublishedRevisionID 为当前已发布 Agent 配置快照ID。 + SortNo int `gorm:"type:int;not null;default:0;index"` // SortNo 为后台展示排序号。 + AuditFields +} + +// AgentRevision stores an immutable published Agent configuration snapshot. +type AgentRevision struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + AgentID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_agent_revision"` + Revision int `gorm:"type:int;not null;uniqueIndex:uk_agent_revision"` + WorkflowVersionID int64 `gorm:"type:bigint;not null;default:0;index"` + Status enums.Status `gorm:"type:int;not null;default:0;index"` + Definition string `gorm:"type:longtext"` + DefinitionHash string `gorm:"type:varchar(64);not null;default:'';index"` + PublishedAt *time.Time `gorm:"type:datetime;index"` + PublishedByID int64 `gorm:"type:bigint;not null;default:0;index"` + PublishedByName string `gorm:"type:varchar(100);not null;default:''"` + AuditFields +} + +// AgentRun is an Engine-independent record for one Agent reply execution. +type AgentRun struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + ConversationID int64 `gorm:"type:bigint;not null;default:0;index"` + AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"` + AgentRevisionID int64 `gorm:"type:bigint;not null;default:0;index"` + SourceMessageID int64 `gorm:"type:bigint;not null;default:0;index"` + WorkflowRunID int64 `gorm:"type:bigint;not null;default:0;index"` + EngineCode string `gorm:"type:varchar(50);not null;default:'';index"` + Status string `gorm:"type:varchar(30);not null;default:'';index"` + PromptTokens int `gorm:"type:int;not null;default:0"` + CompletionTokens int `gorm:"type:int;not null;default:0"` + StartedAt time.Time `gorm:"type:datetime;not null;index"` + EndedAt *time.Time `gorm:"type:datetime;index"` + ErrorMessage string `gorm:"type:text"` + TraceData string `gorm:"type:text"` + CreatedAt time.Time `gorm:"type:datetime;not null;index"` + UpdatedAt time.Time `gorm:"type:datetime;not null;index"` +} + +// AgentStep records a normalized model, tool, workflow, or policy transition. +type AgentStep struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + AgentRunID int64 `gorm:"type:bigint;not null;index"` + WorkflowRunID int64 `gorm:"type:bigint;not null;default:0;index"` + StepType string `gorm:"type:varchar(50);not null;default:'';index"` + StepCode string `gorm:"type:varchar(100);not null;default:'';index"` + Status string `gorm:"type:varchar(30);not null;default:'';index"` + InputPreview string `gorm:"type:text"` + OutputPreview string `gorm:"type:text"` + ErrorMessage string `gorm:"type:text"` + StartedAt time.Time `gorm:"type:datetime;not null;index"` + EndedAt *time.Time `gorm:"type:datetime;index"` + DurationMS int `gorm:"type:int;not null;default:0"` + CreatedAt time.Time `gorm:"type:datetime;not null;index"` +} + +// AgentToolCall records the safety-relevant details of a normalized tool call. +type AgentToolCall struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + AgentRunID int64 `gorm:"type:bigint;not null;index"` + AgentStepID int64 `gorm:"type:bigint;not null;default:0;index"` + ToolCode string `gorm:"type:varchar(150);not null;default:'';index"` + RiskLevel string `gorm:"type:varchar(30);not null;default:'';index"` + RequireConfirm bool `gorm:"not null;default:false"` + Status string `gorm:"type:varchar(30);not null;default:'';index"` + ArgumentsPreview string `gorm:"type:text"` + ResultPreview string `gorm:"type:text"` + ErrorMessage string `gorm:"type:text"` + DurationMS int `gorm:"type:int;not null;default:0"` + CreatedAt time.Time `gorm:"type:datetime;not null;index"` +} + +// AgentRunQualityFeedback is an operator-provided quality review for one +// normalized Agent run. Runtime completion must not be treated as resolution. +type AgentRunQualityFeedback struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + AgentRunID int64 `gorm:"type:bigint;not null;uniqueIndex"` + ResolutionStatus enums.AgentRunResolutionStatus `gorm:"type:varchar(20);not null;default:'unknown';index"` + EvidenceStatus enums.AgentRunEvidenceStatus `gorm:"type:varchar(20);not null;default:'unknown';index"` + Comment string `gorm:"type:text"` AuditFields } @@ -595,11 +703,13 @@ type AIWorkflowNodeRun struct { // 渠道本身负责定义“入口如何识别、默认接入哪个 AI Agent、渠道专属配置是什么”, // 而具体消息收发、会话映射等运行时数据由各自的渠道业务表承载。 type Channel struct { - ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为渠道主键。 - Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为渠道名称,用于后台展示和业务识别,例如“官网客服”“企业微信主客服”。 - ChannelType string `gorm:"type:varchar(30);not null;default:'';index"` // ChannelType 为渠道类型,决定该渠道的接入方式和配置解释规则。当前规划的典型取值包括:web、wxwork_kf。 - ChannelID string `gorm:"type:varchar(64);not null;default:'';uniqueIndex"` // ChannelID 为渠道入口标识,由系统自动生成。对 web 渠道,该字段用于前端通过 X-Channel-Id 标识接入来源;对其他渠道,作为统一的系统内稳定渠道标识保留。 - AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"` // AIAgentID 为该渠道默认接入的 AI Agent。 当外部客户通过该渠道首次进入系统且尚未命中现有未结束会话时,系统会使用该 AI Agent 作为会话默认接待实例。 + ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为渠道主键。 + Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为渠道名称,用于后台展示和业务识别,例如“官网客服”“企业微信主客服”。 + ChannelType string `gorm:"type:varchar(30);not null;default:'';index"` // ChannelType 为渠道类型,决定该渠道的接入方式和配置解释规则。当前规划的典型取值包括:web、wxwork_kf。 + ChannelID string `gorm:"type:varchar(64);not null;default:'';uniqueIndex"` // ChannelID 为渠道入口标识,由系统自动生成。对 web 渠道,该字段用于前端通过 X-Channel-Id 标识接入来源;对其他渠道,作为统一的系统内稳定渠道标识保留。 + AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"` // AIAgentID 为该渠道默认接入的 AI Agent。 当外部客户通过该渠道首次进入系统且尚未命中现有未结束会话时,系统会使用该 AI Agent 作为会话默认接待实例。 + AIAgentRolloutPercent int `gorm:"type:int;not null;default:100"` // AIAgentRolloutPercent 为该渠道对 AI 自动回复施加的灰度百分比,100 表示不额外限制。 + PreviousAIAgentRolloutPercent int `gorm:"type:int;not null;default:0"` // PreviousAIAgentRolloutPercent 保存渠道上一次生效的 Agent 灰度比例,0 表示尚无可回滚值。 // ConfigJSON 为渠道专属扩展配置,使用 JSON 存储。 // 例如: // 1. web 渠道可记录允许域名、品牌配置等; @@ -914,6 +1024,8 @@ type ConversationInterrupt struct { ID int64 `gorm:"primaryKey;autoIncrement"` ConversationID int64 `gorm:"type:bigint;not null;default:0;index"` AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"` + AgentRunID int64 `gorm:"type:bigint;not null;default:0;index"` + AgentStepID int64 `gorm:"type:bigint;not null;default:0;index"` SourceMessageID int64 `gorm:"type:bigint;not null;default:0;index"` LastResumeMessageID int64 `gorm:"type:bigint;not null;default:0;index"` WorkflowRunID int64 `gorm:"type:bigint;not null;default:0;index"` diff --git a/internal/pkg/dto/request/agent_evaluation_request.go b/internal/pkg/dto/request/agent_evaluation_request.go new file mode 100644 index 0000000..61ff1f5 --- /dev/null +++ b/internal/pkg/dto/request/agent_evaluation_request.go @@ -0,0 +1,15 @@ +package request + +type RunAgentEvaluationRequest struct { + AIAgentID int64 `json:"aiAgentId"` + EngineCode string `json:"engineCode"` + Cases []AgentEvaluationCase `json:"cases"` +} + +type AgentEvaluationCase struct { + ID string `json:"id"` + Category string `json:"category"` + Message string `json:"message"` + History []string `json:"history,omitempty"` + Expect map[string]any `json:"expect,omitempty"` +} diff --git a/internal/pkg/dto/request/agent_run_request.go b/internal/pkg/dto/request/agent_run_request.go new file mode 100644 index 0000000..8b3203c --- /dev/null +++ b/internal/pkg/dto/request/agent_run_request.go @@ -0,0 +1,10 @@ +package request + +import "agent-desk/internal/pkg/enums" + +type SaveAgentRunQualityFeedbackRequest struct { + AgentRunID int64 `json:"agentRunId"` + ResolutionStatus enums.AgentRunResolutionStatus `json:"resolutionStatus"` + EvidenceStatus enums.AgentRunEvidenceStatus `json:"evidenceStatus"` + Comment string `json:"comment"` +} diff --git a/internal/pkg/dto/request/ai_request.go b/internal/pkg/dto/request/ai_request.go index 286e3ce..0411a0b 100644 --- a/internal/pkg/dto/request/ai_request.go +++ b/internal/pkg/dto/request/ai_request.go @@ -46,14 +46,21 @@ type CreateAIAgentRequest struct { Name string `json:"name"` Description string `json:"description"` AIConfigID int64 `json:"aiConfigId"` + RuntimeMode enums.AIAgentRuntimeMode `json:"runtimeMode"` + MaxSteps int `json:"maxSteps"` + ContextWindow int `json:"contextWindow"` + ToolPolicy string `json:"toolPolicy"` + KnowledgePolicy string `json:"knowledgePolicy"` ServiceMode enums.IMConversationServiceMode `json:"serviceMode"` SystemPrompt string `json:"systemPrompt"` WelcomeMessage string `json:"welcomeMessage"` ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"` + RolloutPercent int `json:"rolloutPercent"` TeamIDs []int64 `json:"teamIds"` HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"` FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"` FallbackMessage string `json:"fallbackMessage"` + KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"` SkillIDs []int64 `json:"skillIds"` DirectTools []AIAgentMCPToolRequest `json:"directTools"` } @@ -67,6 +74,19 @@ type DeleteAIAgentRequest struct { ID int64 `json:"id"` } +type PublishAIAgentRequest struct { + ID int64 `json:"id"` +} + +type RollbackAIAgentRequest struct { + ID int64 `json:"id"` + RevisionID int64 `json:"revisionId"` +} + +type RollbackAIAgentRolloutRequest struct { + ID int64 `json:"id"` +} + type UpdateAIAgentStatusRequest struct { ID int64 `json:"id"` Status int `json:"status"` diff --git a/internal/pkg/dto/request/channel_request.go b/internal/pkg/dto/request/channel_request.go index 879a9ae..859e7fa 100644 --- a/internal/pkg/dto/request/channel_request.go +++ b/internal/pkg/dto/request/channel_request.go @@ -1,12 +1,13 @@ package request type CreateChannelRequest struct { - ChannelType string `json:"channelType"` - AIAgentID int64 `json:"aiAgentId"` - Name string `json:"name"` - ConfigJSON string `json:"configJson"` - Status int `json:"status"` - Remark string `json:"remark"` + ChannelType string `json:"channelType"` + AIAgentID int64 `json:"aiAgentId"` + AIAgentRolloutPercent int `json:"aiAgentRolloutPercent"` + Name string `json:"name"` + ConfigJSON string `json:"configJson"` + Status int `json:"status"` + Remark string `json:"remark"` } type UpdateChannelRequest struct { @@ -19,6 +20,10 @@ type UpdateChannelStatusRequest struct { Status int `json:"status"` } +type RollbackChannelAIAgentRolloutRequest struct { + ID int64 `json:"id"` +} + type DeleteChannelRequest struct { ID int64 `json:"id"` } diff --git a/internal/pkg/dto/response/agent_evaluation_response.go b/internal/pkg/dto/response/agent_evaluation_response.go new file mode 100644 index 0000000..837ceed --- /dev/null +++ b/internal/pkg/dto/response/agent_evaluation_response.go @@ -0,0 +1,20 @@ +package response + +type AgentEvaluationResultResponse struct { + CaseID string `json:"caseId"` + Category string `json:"category"` + EngineCode string `json:"engineCode"` + Passed bool `json:"passed"` + ReplyText string `json:"replyText"` + Interrupted bool `json:"interrupted"` + Error string `json:"error,omitempty"` + Finding string `json:"finding,omitempty"` +} + +type AgentEvaluationReportResponse struct { + EngineCode string `json:"engineCode"` + Total int `json:"total"` + Passed int `json:"passed"` + Results []AgentEvaluationResultResponse `json:"results"` + CSV string `json:"csv"` +} diff --git a/internal/pkg/dto/response/agent_run_response.go b/internal/pkg/dto/response/agent_run_response.go new file mode 100644 index 0000000..df5a263 --- /dev/null +++ b/internal/pkg/dto/response/agent_run_response.go @@ -0,0 +1,66 @@ +package response + +import "agent-desk/internal/pkg/enums" + +type AgentRunResponse struct { + ID int64 `json:"id"` + ConversationID int64 `json:"conversationId"` + AIAgentID int64 `json:"aiAgentId"` + AgentRevisionID int64 `json:"agentRevisionId"` + SourceMessageID int64 `json:"sourceMessageId"` + WorkflowRunID int64 `json:"workflowRunId"` + EngineCode string `json:"engineCode"` + Status string `json:"status"` + PromptTokens int `json:"promptTokens"` + CompletionTokens int `json:"completionTokens"` + StartedAt string `json:"startedAt"` + EndedAt string `json:"endedAt"` + DurationMS int64 `json:"durationMs"` + ErrorMessage string `json:"errorMessage"` + TraceData string `json:"traceData"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Steps []AgentStepResponse `json:"steps,omitempty"` + ToolCalls []AgentToolCallResponse `json:"toolCalls,omitempty"` + QualityFeedback *AgentRunQualityFeedbackResponse `json:"qualityFeedback,omitempty"` +} + +type AgentRunQualityFeedbackResponse struct { + ID int64 `json:"id"` + AgentRunID int64 `json:"agentRunId"` + ResolutionStatus enums.AgentRunResolutionStatus `json:"resolutionStatus"` + EvidenceStatus enums.AgentRunEvidenceStatus `json:"evidenceStatus"` + Comment string `json:"comment"` + UpdateUserName string `json:"updateUserName"` + UpdatedAt string `json:"updatedAt"` +} + +type AgentStepResponse struct { + ID int64 `json:"id"` + AgentRunID int64 `json:"agentRunId"` + WorkflowRunID int64 `json:"workflowRunId"` + StepType string `json:"stepType"` + StepCode string `json:"stepCode"` + Status string `json:"status"` + InputPreview string `json:"inputPreview"` + OutputPreview string `json:"outputPreview"` + ErrorMessage string `json:"errorMessage"` + StartedAt string `json:"startedAt"` + EndedAt string `json:"endedAt"` + DurationMS int `json:"durationMs"` +} + +type AgentToolCallResponse struct { + ID int64 `json:"id"` + AgentRunID int64 `json:"agentRunId"` + AgentStepID int64 `json:"agentStepId"` + ToolCode string `json:"toolCode"` + RiskLevel string `json:"riskLevel"` + RequireConfirm bool `json:"requireConfirm"` + Status string `json:"status"` + ArgumentsPreview string `json:"argumentsPreview"` + ResultPreview string `json:"resultPreview"` + ErrorMessage string `json:"errorMessage"` + DurationMS int `json:"durationMs"` + CreatedAt string `json:"createdAt"` +} diff --git a/internal/pkg/dto/response/ai_response.go b/internal/pkg/dto/response/ai_response.go index ffa5c8e..0ea661e 100644 --- a/internal/pkg/dto/response/ai_response.go +++ b/internal/pkg/dto/response/ai_response.go @@ -24,6 +24,18 @@ type AIAgentMCPToolResponse struct { Arguments map[string]string `json:"arguments"` } +type AgentRevisionResponse struct { + ID int64 `json:"id"` + AgentID int64 `json:"agentId"` + Revision int `json:"revision"` + WorkflowVersionID int64 `json:"workflowVersionId"` + Status enums.Status `json:"status"` + DefinitionHash string `json:"definitionHash"` + PublishedAt string `json:"publishedAt"` + PublishedByID int64 `json:"publishedById"` + PublishedByName string `json:"publishedByName"` +} + type AIConfigResponse struct { ID int64 `json:"id"` Name string `json:"name"` @@ -67,34 +79,44 @@ 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"` - ServiceMode enums.IMConversationServiceMode `json:"serviceMode"` - ServiceModeName string `json:"serviceModeName"` - SystemPrompt string `json:"systemPrompt"` - WelcomeMessage string `json:"welcomeMessage"` - ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"` - 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"` - SkillIDs []int64 `json:"skillIds"` - Skills []AIAgentSkillResponse `json:"skills"` - DirectTools []AIAgentMCPToolResponse `json:"directTools"` - WorkflowVersionID int64 `json:"workflowVersionId"` - 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"` + 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"` } diff --git a/internal/pkg/dto/response/ai_workflow_response.go b/internal/pkg/dto/response/ai_workflow_response.go index bb19cae..800dc27 100644 --- a/internal/pkg/dto/response/ai_workflow_response.go +++ b/internal/pkg/dto/response/ai_workflow_response.go @@ -41,6 +41,13 @@ type AIWorkflowValidationResponse struct { Errors []workflowvalidator.Error `json:"errors"` } +type AIWorkflowTemplateResponse struct { + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Definition dsl.Definition `json:"definition"` +} + type AIWorkflowNodeSpecResponse struct { Type string `json:"type"` Title string `json:"title"` diff --git a/internal/pkg/dto/response/channel_response.go b/internal/pkg/dto/response/channel_response.go index ecc5a5a..7d47009 100644 --- a/internal/pkg/dto/response/channel_response.go +++ b/internal/pkg/dto/response/channel_response.go @@ -6,15 +6,17 @@ import ( ) type ChannelResponse struct { - ID int64 `json:"id"` - ChannelType string `json:"channelType"` - ChannelID string `json:"channelId"` - AIAgentID int64 `json:"aiAgentId"` - AIAgentName string `json:"aiAgentName,omitempty"` - Name string `json:"name"` - ConfigJSON string `json:"configJson"` - Status enums.Status `json:"status"` - Remark string `json:"remark"` + ID int64 `json:"id"` + ChannelType string `json:"channelType"` + ChannelID string `json:"channelId"` + AIAgentID int64 `json:"aiAgentId"` + AIAgentRolloutPercent int `json:"aiAgentRolloutPercent"` + PreviousAIAgentRolloutPercent int `json:"previousAiAgentRolloutPercent"` + AIAgentName string `json:"aiAgentName,omitempty"` + Name string `json:"name"` + ConfigJSON string `json:"configJson"` + Status enums.Status `json:"status"` + Remark string `json:"remark"` } type WxWorkKFAccountResponse struct { @@ -29,13 +31,15 @@ func BuildChannelResponse(item *models.Channel) ChannelResponse { return ChannelResponse{} } return ChannelResponse{ - ID: item.ID, - ChannelType: item.ChannelType, - ChannelID: item.ChannelID, - AIAgentID: item.AIAgentID, - Name: item.Name, - ConfigJSON: item.ConfigJSON, - Status: item.Status, - Remark: item.Remark, + ID: item.ID, + ChannelType: item.ChannelType, + ChannelID: item.ChannelID, + AIAgentID: item.AIAgentID, + AIAgentRolloutPercent: item.AIAgentRolloutPercent, + PreviousAIAgentRolloutPercent: item.PreviousAIAgentRolloutPercent, + Name: item.Name, + ConfigJSON: item.ConfigJSON, + Status: item.Status, + Remark: item.Remark, } } diff --git a/internal/pkg/enums/agent.go b/internal/pkg/enums/agent.go index 4ed4f8d..9351ba6 100644 --- a/internal/pkg/enums/agent.go +++ b/internal/pkg/enums/agent.go @@ -1,5 +1,33 @@ package enums +type AgentRunResolutionStatus string + +const ( + AgentRunResolutionStatusUnknown AgentRunResolutionStatus = "unknown" + AgentRunResolutionStatusResolved AgentRunResolutionStatus = "resolved" + AgentRunResolutionStatusUnresolved AgentRunResolutionStatus = "unresolved" +) + +var AgentRunResolutionStatusValues = []AgentRunResolutionStatus{ + AgentRunResolutionStatusUnknown, + AgentRunResolutionStatusResolved, + AgentRunResolutionStatusUnresolved, +} + +type AgentRunEvidenceStatus string + +const ( + AgentRunEvidenceStatusUnknown AgentRunEvidenceStatus = "unknown" + AgentRunEvidenceStatusSupported AgentRunEvidenceStatus = "supported" + AgentRunEvidenceStatusUnsupported AgentRunEvidenceStatus = "unsupported" +) + +var AgentRunEvidenceStatusValues = []AgentRunEvidenceStatus{ + AgentRunEvidenceStatusUnknown, + AgentRunEvidenceStatusSupported, + AgentRunEvidenceStatusUnsupported, +} + type ServiceStatus int const ( diff --git a/internal/pkg/enums/im.go b/internal/pkg/enums/im.go index 8d2b427..4f8bd63 100644 --- a/internal/pkg/enums/im.go +++ b/internal/pkg/enums/im.go @@ -220,22 +220,58 @@ type AIAgentFallbackMode int const ( AIAgentFallbackModeNoAnswer AIAgentFallbackMode = 1 AIAgentFallbackModeSuggestRetry AIAgentFallbackMode = 2 + AIAgentFallbackModeHandoff AIAgentFallbackMode = 3 ) var AIAgentFallbackModeValues = []AIAgentFallbackMode{ AIAgentFallbackModeNoAnswer, AIAgentFallbackModeSuggestRetry, + AIAgentFallbackModeHandoff, } var aiAgentFallbackModeLabelMap = map[AIAgentFallbackMode]string{ AIAgentFallbackModeNoAnswer: "直接说明知识不足", AIAgentFallbackModeSuggestRetry: "引导用户补充信息", + AIAgentFallbackModeHandoff: "转人工客服", } func GetAIAgentFallbackModeLabel(mode AIAgentFallbackMode) string { return aiAgentFallbackModeLabelMap[mode] } +type AIAgentRuntimeMode string + +const ( + AIAgentRuntimeModeWorkflow AIAgentRuntimeMode = "workflow" + AIAgentRuntimeModeAutonomous AIAgentRuntimeMode = "autonomous" + AIAgentRuntimeModeHybrid AIAgentRuntimeMode = "hybrid" +) + +var AIAgentRuntimeModeValues = []AIAgentRuntimeMode{ + AIAgentRuntimeModeWorkflow, + AIAgentRuntimeModeAutonomous, + AIAgentRuntimeModeHybrid, +} + +var aiAgentRuntimeModeLabelMap = map[AIAgentRuntimeMode]string{ + AIAgentRuntimeModeWorkflow: "流程编排", + AIAgentRuntimeModeAutonomous: "自主运行", + AIAgentRuntimeModeHybrid: "混合运行", +} + +func GetAIAgentRuntimeModeLabel(mode AIAgentRuntimeMode) string { + return aiAgentRuntimeModeLabelMap[mode] +} + +func IsValidAIAgentRuntimeMode(mode AIAgentRuntimeMode) bool { + for _, item := range AIAgentRuntimeModeValues { + if item == mode { + return true + } + } + return false +} + const ( IMRealtimeEventConnected = "connected" IMRealtimeEventPong = "pong" diff --git a/internal/pkg/toolx/builtin_tools.go b/internal/pkg/toolx/builtin_tools.go index 4d83bf2..2ff4dd6 100644 --- a/internal/pkg/toolx/builtin_tools.go +++ b/internal/pkg/toolx/builtin_tools.go @@ -57,6 +57,24 @@ var ( SourceType: enums.ToolSourceTypeBuiltin, AutoInjected: true, } + BuiltinConversationContext = ToolSpec{ + Code: "builtin/conversation_context", + ServerCode: "builtin", + Name: "conversation_context", + Title: "会话上下文", + Description: "读取当前客户基础信息和会话摘要。", + SourceType: enums.ToolSourceTypeBuiltin, + DirectAccess: true, + } + BuiltinKnowledgeRetrieve = ToolSpec{ + Code: "builtin/knowledge_retrieve", + ServerCode: "builtin", + Name: "knowledge_retrieve", + Title: "知识检索", + Description: "在当前 Agent 已绑定的知识库中检索证据。", + SourceType: enums.ToolSourceTypeBuiltin, + DirectAccess: true, + } GraphTriageServiceRequest = ToolSpec{ Code: "graph/triage_service_request", ServerCode: "graph", @@ -66,6 +84,7 @@ var ( Description: i18nx.Get("tool.graph.triageServiceRequest.description"), DescriptionKey: "tool.graph.triageServiceRequest.description", SourceType: enums.ToolSourceTypeGraph, + DirectAccess: true, RuntimeStatic: true, Appendix: i18nx.Get("tool.graph.triageServiceRequest.appendix"), AppendixKey: "tool.graph.triageServiceRequest.appendix", @@ -80,6 +99,7 @@ var ( DescriptionKey: "tool.graph.analyzeConversation.description", SourceType: enums.ToolSourceTypeGraph, RuntimeStatic: true, + DirectAccess: true, Appendix: i18nx.Get("tool.graph.analyzeConversation.appendix"), AppendixKey: "tool.graph.analyzeConversation.appendix", } @@ -92,6 +112,7 @@ var ( Description: i18nx.Get("tool.graph.prepareTicketDraft.description"), DescriptionKey: "tool.graph.prepareTicketDraft.description", SourceType: enums.ToolSourceTypeGraph, + DirectAccess: true, RuntimeStatic: true, Appendix: i18nx.Get("tool.graph.prepareTicketDraft.appendix"), AppendixKey: "tool.graph.prepareTicketDraft.appendix", @@ -105,7 +126,6 @@ var ( Description: i18nx.Get("tool.graph.createTicketConfirm.description"), DescriptionKey: "tool.graph.createTicketConfirm.description", SourceType: enums.ToolSourceTypeGraph, - DirectAccess: true, RuntimeStatic: true, Aliases: []string{"builtin/create_ticket_with_confirmation"}, Appendix: i18nx.Get("tool.graph.createTicketConfirm.appendix"), @@ -120,7 +140,6 @@ var ( Description: i18nx.Get("tool.graph.handoffConversation.description"), DescriptionKey: "tool.graph.handoffConversation.description", SourceType: enums.ToolSourceTypeGraph, - DirectAccess: true, RuntimeStatic: true, Appendix: i18nx.Get("tool.graph.handoffConversation.appendix"), AppendixKey: "tool.graph.handoffConversation.appendix", @@ -128,6 +147,8 @@ var ( RegisteredToolSpecs = []ToolSpec{ BuiltinToolSearch, BuiltinSkill, + BuiltinConversationContext, + BuiltinKnowledgeRetrieve, GraphTriageServiceRequest, GraphAnalyzeConversation, GraphPrepareTicketDraft, diff --git a/internal/repositories/agent_revision_repository.go b/internal/repositories/agent_revision_repository.go new file mode 100644 index 0000000..18b9974 --- /dev/null +++ b/internal/repositories/agent_revision_repository.go @@ -0,0 +1,56 @@ +package repositories + +import ( + "agent-desk/internal/models" + + "gorm.io/gorm" +) + +var AgentRevisionRepository = newAgentRevisionRepository() + +func newAgentRevisionRepository() *agentRevisionRepository { + return &agentRevisionRepository{} +} + +type agentRevisionRepository struct{} + +func (r *agentRevisionRepository) Get(db *gorm.DB, id int64) *models.AgentRevision { + ret := &models.AgentRevision{} + if err := db.First(ret, "id = ?", id).Error; err != nil { + return nil + } + return ret +} + +func (r *agentRevisionRepository) Create(db *gorm.DB, item *models.AgentRevision) error { + return db.Create(item).Error +} + +func (r *agentRevisionRepository) FindByAgentID(db *gorm.DB, agentID int64) []models.AgentRevision { + if agentID <= 0 { + return []models.AgentRevision{} + } + items := make([]models.AgentRevision, 0) + db.Where("agent_id = ?", agentID).Order("revision DESC, id DESC").Find(&items) + return items +} + +func (r *agentRevisionRepository) MaxRevisionByAgentID(db *gorm.DB, agentID int64) int { + if agentID <= 0 { + return 0 + } + var ret int + db.Model(&models.AgentRevision{}).Where("agent_id = ?", agentID).Select("COALESCE(MAX(revision), 0)").Scan(&ret) + return ret +} + +func (r *agentRevisionRepository) TakeByAgentIDAndWorkflowVersionID(db *gorm.DB, agentID int64, workflowVersionID int64) *models.AgentRevision { + if agentID <= 0 || workflowVersionID <= 0 { + return nil + } + ret := &models.AgentRevision{} + if err := db.Where("agent_id = ? AND workflow_version_id = ?", agentID, workflowVersionID).Order("id DESC").First(ret).Error; err != nil { + return nil + } + return ret +} diff --git a/internal/repositories/agent_run_quality_feedback_repository.go b/internal/repositories/agent_run_quality_feedback_repository.go new file mode 100644 index 0000000..14fc697 --- /dev/null +++ b/internal/repositories/agent_run_quality_feedback_repository.go @@ -0,0 +1,45 @@ +package repositories + +import ( + "agent-desk/internal/models" + + "gorm.io/gorm" +) + +var AgentRunQualityFeedbackRepository = newAgentRunQualityFeedbackRepository() + +func newAgentRunQualityFeedbackRepository() *agentRunQualityFeedbackRepository { + return &agentRunQualityFeedbackRepository{} +} + +type agentRunQualityFeedbackRepository struct{} + +func (r *agentRunQualityFeedbackRepository) GetByAgentRunID(db *gorm.DB, agentRunID int64) *models.AgentRunQualityFeedback { + if agentRunID <= 0 { + return nil + } + item := &models.AgentRunQualityFeedback{} + if err := db.Where("agent_run_id = ?", agentRunID).First(item).Error; err != nil { + return nil + } + return item +} + +func (r *agentRunQualityFeedbackRepository) FindByAgentRunIDs(db *gorm.DB, agentRunIDs []int64) []models.AgentRunQualityFeedback { + if len(agentRunIDs) == 0 { + return []models.AgentRunQualityFeedback{} + } + var items []models.AgentRunQualityFeedback + if err := db.Where("agent_run_id IN ?", agentRunIDs).Find(&items).Error; err != nil { + return []models.AgentRunQualityFeedback{} + } + return items +} + +func (r *agentRunQualityFeedbackRepository) Create(db *gorm.DB, item *models.AgentRunQualityFeedback) error { + return db.Create(item).Error +} + +func (r *agentRunQualityFeedbackRepository) Updates(db *gorm.DB, id int64, columns map[string]any) error { + return db.Model(&models.AgentRunQualityFeedback{}).Where("id = ?", id).Updates(columns).Error +} diff --git a/internal/repositories/agent_run_repository.go b/internal/repositories/agent_run_repository.go new file mode 100644 index 0000000..70c2d8b --- /dev/null +++ b/internal/repositories/agent_run_repository.go @@ -0,0 +1,68 @@ +package repositories + +import ( + "agent-desk/internal/models" + "agent-desk/internal/pkg/httpx/params" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var AgentRunRepository = newAgentRunRepository() + +func newAgentRunRepository() *agentRunRepository { + return &agentRunRepository{} +} + +type agentRunRepository struct{} + +func (r *agentRunRepository) Get(db *gorm.DB, id int64) *models.AgentRun { + ret := &models.AgentRun{} + if err := db.First(ret, "id = ?", id).Error; err != nil { + return nil + } + return ret +} + +func (r *agentRunRepository) TakeByWorkflowRunID(db *gorm.DB, workflowRunID int64) *models.AgentRun { + if workflowRunID <= 0 { + return nil + } + ret := &models.AgentRun{} + if err := db.Where("workflow_run_id = ?", workflowRunID).Order("id DESC").First(ret).Error; err != nil { + return nil + } + return ret +} + +func (r *agentRunRepository) Create(db *gorm.DB, item *models.AgentRun) error { + return db.Create(item).Error +} + +func (r *agentRunRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.AgentRun, paging *sqls.Paging) { + cnd.Find(db, &list) + return list, &sqls.Paging{Page: cnd.Paging.Page, Limit: cnd.Paging.Limit, Total: cnd.Count(db, &models.AgentRun{})} +} + +func (r *agentRunRepository) FindPageByParams(db *gorm.DB, queryParams *params.QueryParams) (list []models.AgentRun, paging *sqls.Paging) { + return r.FindPageByCnd(db, &queryParams.Cnd) +} + +func (r *agentRunRepository) Updates(db *gorm.DB, id int64, columns map[string]any) error { + return db.Model(&models.AgentRun{}).Where("id = ?", id).Updates(columns).Error +} + +func (r *agentRunRepository) FindRecent(db *gorm.DB, aiAgentID int64, limit int) []models.AgentRun { + if limit <= 0 || limit > 5000 { + limit = 5000 + } + query := db.Order("id DESC").Limit(limit) + if aiAgentID > 0 { + query = query.Where("ai_agent_id = ?", aiAgentID) + } + var items []models.AgentRun + if err := query.Find(&items).Error; err != nil { + return nil + } + return items +} diff --git a/internal/repositories/agent_step_repository.go b/internal/repositories/agent_step_repository.go new file mode 100644 index 0000000..1e9d7c0 --- /dev/null +++ b/internal/repositories/agent_step_repository.go @@ -0,0 +1,54 @@ +package repositories + +import ( + "agent-desk/internal/models" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var AgentStepRepository = newAgentStepRepository() + +func newAgentStepRepository() *agentStepRepository { + return &agentStepRepository{} +} + +type agentStepRepository struct{} + +func (r *agentStepRepository) Create(db *gorm.DB, item *models.AgentStep) error { + return db.Create(item).Error +} + +func (r *agentStepRepository) FindByAgentRunID(db *gorm.DB, agentRunID int64) []models.AgentStep { + if agentRunID <= 0 { + return []models.AgentStep{} + } + return r.Find(db, sqls.NewCnd().Eq("agent_run_id", agentRunID).Asc("id")) +} + +func (r *agentStepRepository) LastByAgentRunID(db *gorm.DB, agentRunID int64) *models.AgentStep { + if agentRunID <= 0 { + return nil + } + ret := &models.AgentStep{} + if err := db.Where("agent_run_id = ?", agentRunID).Order("id DESC").First(ret).Error; err != nil { + return nil + } + return ret +} + +func (r *agentStepRepository) FindByAgentRunIDs(db *gorm.DB, agentRunIDs []int64) []models.AgentStep { + if len(agentRunIDs) == 0 { + return nil + } + var items []models.AgentStep + if err := db.Where("agent_run_id IN ?", agentRunIDs).Find(&items).Error; err != nil { + return nil + } + return items +} + +func (r *agentStepRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.AgentStep) { + cnd.Find(db, &list) + return +} diff --git a/internal/repositories/agent_tool_call_repository.go b/internal/repositories/agent_tool_call_repository.go new file mode 100644 index 0000000..243636f --- /dev/null +++ b/internal/repositories/agent_tool_call_repository.go @@ -0,0 +1,43 @@ +package repositories + +import ( + "agent-desk/internal/models" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var AgentToolCallRepository = newAgentToolCallRepository() + +func newAgentToolCallRepository() *agentToolCallRepository { + return &agentToolCallRepository{} +} + +type agentToolCallRepository struct{} + +func (r *agentToolCallRepository) Create(db *gorm.DB, item *models.AgentToolCall) error { + return db.Create(item).Error +} + +func (r *agentToolCallRepository) FindByAgentRunID(db *gorm.DB, agentRunID int64) []models.AgentToolCall { + if agentRunID <= 0 { + return []models.AgentToolCall{} + } + return r.Find(db, sqls.NewCnd().Eq("agent_run_id", agentRunID).Asc("id")) +} + +func (r *agentToolCallRepository) FindByAgentRunIDs(db *gorm.DB, agentRunIDs []int64) []models.AgentToolCall { + if len(agentRunIDs) == 0 { + return nil + } + var items []models.AgentToolCall + if err := db.Where("agent_run_id IN ?", agentRunIDs).Find(&items).Error; err != nil { + return nil + } + return items +} + +func (r *agentToolCallRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.AgentToolCall) { + cnd.Find(db, &list) + return +} diff --git a/internal/repositories/agent_tool_invocation_repository.go b/internal/repositories/agent_tool_invocation_repository.go new file mode 100644 index 0000000..f0601b4 --- /dev/null +++ b/internal/repositories/agent_tool_invocation_repository.go @@ -0,0 +1,34 @@ +package repositories + +import ( + "agent-desk/internal/models" + + "gorm.io/gorm" +) + +var AgentToolInvocationRepository = newAgentToolInvocationRepository() + +func newAgentToolInvocationRepository() *agentToolInvocationRepository { + return &agentToolInvocationRepository{} +} + +type agentToolInvocationRepository struct{} + +func (r *agentToolInvocationRepository) GetByIdempotencyKey(db *gorm.DB, conversationID int64, toolCode, idempotencyKey string) *models.AgentToolInvocation { + if conversationID <= 0 || toolCode == "" || idempotencyKey == "" { + return nil + } + var item models.AgentToolInvocation + if err := db.Where("conversation_id = ? AND tool_code = ? AND idempotency_key = ?", conversationID, toolCode, idempotencyKey).First(&item).Error; err != nil { + return nil + } + return &item +} + +func (r *agentToolInvocationRepository) Create(db *gorm.DB, item *models.AgentToolInvocation) error { + return db.Create(item).Error +} + +func (r *agentToolInvocationRepository) Updates(db *gorm.DB, id int64, values map[string]any) error { + return db.Model(&models.AgentToolInvocation{}).Where("id = ?", id).Updates(values).Error +} diff --git a/internal/repositories/conversation_interrupt_repository.go b/internal/repositories/conversation_interrupt_repository.go index 88dede3..7d01b15 100644 --- a/internal/repositories/conversation_interrupt_repository.go +++ b/internal/repositories/conversation_interrupt_repository.go @@ -39,6 +39,17 @@ func (r *conversationInterruptRepository) FindLatestPendingByConversationID(db * return ret } +func (r *conversationInterruptRepository) FindByAgentRunIDs(db *gorm.DB, agentRunIDs []int64) []models.ConversationInterrupt { + if len(agentRunIDs) == 0 { + return []models.ConversationInterrupt{} + } + var items []models.ConversationInterrupt + if err := db.Where("agent_run_id IN ?", agentRunIDs).Find(&items).Error; err != nil { + return []models.ConversationInterrupt{} + } + return items +} + func (r *conversationInterruptRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.ConversationInterrupt) { cnd.Find(db, &list) return @@ -72,6 +83,8 @@ func (r *conversationInterruptRepository) UpsertByCheckPointID(db *gorm.DB, item columns := map[string]any{ "conversation_id": item.ConversationID, "ai_agent_id": item.AIAgentID, + "agent_run_id": item.AgentRunID, + "agent_step_id": item.AgentStepID, "source_message_id": item.SourceMessageID, "last_resume_message_id": item.LastResumeMessageID, "workflow_run_id": item.WorkflowRunID, diff --git a/internal/repositories/conversation_repository.go b/internal/repositories/conversation_repository.go index ea4b63d..da9596a 100644 --- a/internal/repositories/conversation_repository.go +++ b/internal/repositories/conversation_repository.go @@ -77,6 +77,30 @@ func (r *conversationRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { return cnd.Count(db, &models.Conversation{}) } +func (r *conversationRepository) CountByAIAgentID(db *gorm.DB, aiAgentID int64) int64 { + query := db.Model(&models.Conversation{}) + if aiAgentID > 0 { + query = query.Where("ai_agent_id = ?", aiAgentID) + } + var count int64 + if err := query.Count(&count).Error; err != nil { + return 0 + } + return count +} + +func (r *conversationRepository) CountHandoffByAIAgentID(db *gorm.DB, aiAgentID int64) int64 { + query := db.Model(&models.Conversation{}).Where("handoff_at IS NOT NULL") + if aiAgentID > 0 { + query = query.Where("ai_agent_id = ?", aiAgentID) + } + var count int64 + if err := query.Count(&count).Error; err != nil { + return 0 + } + return count +} + func (r *conversationRepository) Create(db *gorm.DB, t *models.Conversation) (err error) { err = db.Create(t).Error return diff --git a/internal/services/agent_evaluation_service.go b/internal/services/agent_evaluation_service.go new file mode 100644 index 0000000..2f4fcb0 --- /dev/null +++ b/internal/services/agent_evaluation_service.go @@ -0,0 +1,37 @@ +package services + +import ( + "context" + "fmt" + "strings" + + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/errorsx" +) + +var AgentEvaluationService = newAgentEvaluationService() +var AgentEvaluationRunHook func(context.Context, request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) + +type agentEvaluationService struct{} + +func newAgentEvaluationService() *agentEvaluationService { return &agentEvaluationService{} } + +func (s *agentEvaluationService) Run(ctx context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) { + 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") + } + if len(req.Cases) > 100 { + return nil, errorsx.InvalidParam("evaluation case limit exceeded") + } + if AgentEvaluationRunHook == nil { + return nil, fmt.Errorf("agent evaluation runner is not initialized") + } + return AgentEvaluationRunHook(ctx, req) +} diff --git a/internal/services/agent_evaluation_service_test.go b/internal/services/agent_evaluation_service_test.go new file mode 100644 index 0000000..efb5640 --- /dev/null +++ b/internal/services/agent_evaluation_service_test.go @@ -0,0 +1,26 @@ +package services + +import ( + "context" + "testing" + + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/dto/response" +) + +func TestAgentEvaluationServiceValidatesAndCallsRunner(t *testing.T) { + previous := AgentEvaluationRunHook + t.Cleanup(func() { AgentEvaluationRunHook = previous }) + 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 + } + result, err := AgentEvaluationService.Run(context.Background(), request.RunAgentEvaluationRequest{AIAgentID: 1, EngineCode: "autonomous", 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) + } + if _, err := AgentEvaluationService.Run(context.Background(), request.RunAgentEvaluationRequest{}); err == nil { + t.Fatal("expected invalid request") + } +} diff --git a/internal/services/agent_revision_service.go b/internal/services/agent_revision_service.go new file mode 100644 index 0000000..9da5110 --- /dev/null +++ b/internal/services/agent_revision_service.go @@ -0,0 +1,214 @@ +package services + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "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" +) + +var AgentRevisionService = newAgentRevisionService() + +func newAgentRevisionService() *agentRevisionService { + return &agentRevisionService{} +} + +type agentRevisionService struct{} + +func (s *agentRevisionService) Get(id int64) *models.AgentRevision { + if id <= 0 { + return nil + } + return repositories.AgentRevisionRepository.Get(sqls.DB(), id) +} + +func (s *agentRevisionService) FindByAgentID(agentID int64) []models.AgentRevision { + return repositories.AgentRevisionRepository.FindByAgentID(sqls.DB(), agentID) +} + +type agentRevisionDefinition struct { + Agent agentRevisionAgent `json:"agent"` + Model agentRevisionModel `json:"model"` + WorkflowVersionID int64 `json:"workflowVersionId"` + WorkflowDefinition string `json:"workflowDefinition"` +} + +// agentRevisionModel deliberately excludes APIKey. A revision must capture +// reproducible routing/model parameters without duplicating credentials. +type agentRevisionModel struct { + ConfigID int64 `json:"configId"` + Provider string `json:"provider"` + BaseURL string `json:"baseUrl"` + ModelType string `json:"modelType"` + ModelName string `json:"modelName"` + MaxContextTokens int `json:"maxContextTokens"` + MaxOutputTokens int `json:"maxOutputTokens"` + TimeoutMS int `json:"timeoutMs"` + MaxRetryCount int `json:"maxRetryCount"` +} + +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"` + KnowledgePolicy string `json:"knowledgePolicy"` + ServiceMode int `json:"serviceMode"` + SystemPrompt string `json:"systemPrompt"` + WelcomeMessage string `json:"welcomeMessage"` + ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"` + TeamIDs string `json:"teamIds"` + HandoffMode int `json:"handoffMode"` + FallbackMode int `json:"fallbackMode"` + FallbackMessage string `json:"fallbackMessage"` + KnowledgeIDs string `json:"knowledgeIds"` + SkillIDs string `json:"skillIds"` + AllowedMCPTools string `json:"allowedMcpTools"` +} + +// AgentRevisionSnapshot is the immutable runtime configuration restored from +// 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 +} + +// 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. +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") + } + 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") + } + snapshot := &AgentRevisionSnapshot{Revision: *revision, Agent: agent, AIConfig: config} + if strings.TrimSpace(revision.Definition) == "" { + return snapshot, nil + } + definition := agentRevisionDefinition{} + if err := json.Unmarshal([]byte(revision.Definition), &definition); err != nil { + return nil, errorsx.InvalidParam("autonomous agent published 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) + if definition.WorkflowVersionID > 0 { + snapshot.Agent.WorkflowVersionID = definition.WorkflowVersionID + } + applyRevisionModelSnapshot(&snapshot.AIConfig, definition.Model) + return snapshot, nil +} + +func applyRevisionAgentSnapshot(agent *models.AIAgent, definition agentRevisionAgent) { + if agent == nil { + return + } + 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 + agent.KnowledgePolicy = definition.KnowledgePolicy + agent.ServiceMode = enums.IMConversationServiceMode(definition.ServiceMode) + agent.SystemPrompt = definition.SystemPrompt + agent.WelcomeMessage = definition.WelcomeMessage + agent.ReplyTimeoutSeconds = definition.ReplyTimeoutSeconds + agent.TeamIDs = definition.TeamIDs + agent.HandoffMode = enums.AIAgentHandoffMode(definition.HandoffMode) + agent.FallbackMode = enums.AIAgentFallbackMode(definition.FallbackMode) + agent.FallbackMessage = definition.FallbackMessage + agent.KnowledgeIDs = definition.KnowledgeIDs + agent.SkillIDs = definition.SkillIDs + agent.AllowedMCPTools = definition.AllowedMCPTools +} + +func applyRevisionModelSnapshot(config *models.AIConfig, definition agentRevisionModel) { + if config == nil || definition.ConfigID <= 0 { + return + } + config.Provider = enums.AIProvider(definition.Provider) + config.BaseURL = definition.BaseURL + config.ModelType = enums.AIModelType(definition.ModelType) + config.ModelName = definition.ModelName + config.MaxContextTokens = definition.MaxContextTokens + config.MaxOutputTokens = definition.MaxOutputTokens + config.TimeoutMS = definition.TimeoutMS + config.MaxRetryCount = definition.MaxRetryCount +} + +// PublishWorkflowSnapshot keeps the Agent settings and its referenced +// workflow definition together as an immutable, reproducible revision. +func (s *agentRevisionService) PublishWorkflowSnapshot(db *gorm.DB, agent *models.AIAgent, version *models.AIWorkflowVersion, operator *dto.AuthPrincipal) (*models.AgentRevision, error) { + return s.publishSnapshot(db, agent, version, operator) +} + +func (s *agentRevisionService) PublishSnapshot(db *gorm.DB, agent *models.AIAgent, operator *dto.AuthPrincipal) (*models.AgentRevision, error) { + return s.publishSnapshot(db, agent, nil, operator) +} + +func (s *agentRevisionService) publishSnapshot(db *gorm.DB, agent *models.AIAgent, version *models.AIWorkflowVersion, operator *dto.AuthPrincipal) (*models.AgentRevision, error) { + model := agentRevisionModel{ConfigID: agent.AIConfigID} + if config := repositories.AIConfigRepository.Get(db, agent.AIConfigID); config != nil { + model = agentRevisionModel{ + ConfigID: config.ID, Provider: string(config.Provider), BaseURL: config.BaseURL, ModelType: string(config.ModelType), + ModelName: config.ModelName, MaxContextTokens: config.MaxContextTokens, MaxOutputTokens: config.MaxOutputTokens, + TimeoutMS: config.TimeoutMS, MaxRetryCount: config.MaxRetryCount, + } + } + workflowVersionID := int64(0) + workflowDefinition := "" + if version != nil { + workflowVersionID = version.ID + workflowDefinition = version.Definition + } + definition := agentRevisionDefinition{ + Agent: agentRevisionAgent{ + Name: agent.Name, Description: agent.Description, AIConfigID: agent.AIConfigID, + RuntimeMode: string(agent.RuntimeMode), 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, + SkillIDs: agent.SkillIDs, AllowedMCPTools: agent.AllowedMCPTools, + }, + Model: model, + WorkflowVersionID: workflowVersionID, + WorkflowDefinition: workflowDefinition, + } + data, err := json.Marshal(definition) + if err != nil { + return nil, err + } + now := time.Now() + hash := sha256.Sum256(data) + item := &models.AgentRevision{ + AgentID: agent.ID, Revision: repositories.AgentRevisionRepository.MaxRevisionByAgentID(db, agent.ID) + 1, + WorkflowVersionID: workflowVersionID, Status: enums.StatusOk, Definition: string(data), DefinitionHash: hex.EncodeToString(hash[:]), + PublishedAt: &now, PublishedByID: operator.UserID, PublishedByName: operator.Username, AuditFields: utils.BuildAuditFields(operator), + } + if err := repositories.AgentRevisionRepository.Create(db, item); err != nil { + return nil, err + } + return item, nil +} diff --git a/internal/services/agent_revision_service_test.go b/internal/services/agent_revision_service_test.go new file mode 100644 index 0000000..cc11882 --- /dev/null +++ b/internal/services/agent_revision_service_test.go @@ -0,0 +1,50 @@ +package services + +import ( + "encoding/json" + "strings" + "testing" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func TestAgentRevisionServiceRestoresPublishedSnapshotAndKeepsAPIKey(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.AgentRevision{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + definition := agentRevisionDefinition{ + Agent: agentRevisionAgent{ + Name: "published agent", AIConfigID: 8, RuntimeMode: string(enums.AIAgentRuntimeModeAutonomous), + 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}, + } + data, err := json.Marshal(definition) + if err != nil { + t.Fatalf("marshal definition: %v", err) + } + revision := &models.AgentRevision{AgentID: 7, Revision: 1, Status: enums.StatusOk, Definition: string(data)} + if err := db.Create(revision).Error; err != nil { + t.Fatalf("create revision: %v", err) + } + snapshot, err := AgentRevisionService.ResolvePublishedSnapshot(models.AIAgent{ID: 7, PublishedRevisionID: revision.ID, SystemPrompt: "draft instruction"}, models.AIConfig{ID: 8, APIKey: "rotated-secret", ModelName: "draft-model"}) + if err != nil { + t.Fatalf("ResolvePublishedSnapshot: %v", err) + } + if snapshot.Agent.SystemPrompt != "published instruction" || snapshot.Agent.MaxSteps != 5 || snapshot.Agent.ReplyTimeoutSeconds != 90 { + t.Fatalf("agent snapshot not restored: %#v", snapshot.Agent) + } + if snapshot.AIConfig.ModelName != "published-model" || snapshot.AIConfig.BaseURL != "https://published.example/v1" || snapshot.AIConfig.APIKey != "rotated-secret" { + t.Fatalf("model snapshot not restored safely: %#v", snapshot.AIConfig) + } +} diff --git a/internal/services/agent_run_service.go b/internal/services/agent_run_service.go new file mode 100644 index 0000000..a112460 --- /dev/null +++ b/internal/services/agent_run_service.go @@ -0,0 +1,514 @@ +package services + +import ( + "regexp" + "slices" + "sort" + "strings" + "time" + + "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/httpx/params" + "agent-desk/internal/pkg/utils" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +var AgentRunService = newAgentRunService() + +func newAgentRunService() *agentRunService { + return &agentRunService{} +} + +type agentRunService struct{} + +type AgentRunMetrics struct { + TotalRuns int `json:"totalRuns"` + CompletedRuns int `json:"completedRuns"` + FailedRuns int `json:"failedRuns"` + InterruptedRuns int `json:"interruptedRuns"` + CompletionRate float64 `json:"completionRate"` + ToolCalls int `json:"toolCalls"` + ToolSuccessRate float64 `json:"toolSuccessRate"` + AverageSteps float64 `json:"averageSteps"` + AverageDurationMS int64 `json:"averageDurationMs"` + P95DurationMS int64 `json:"p95DurationMs"` + PromptTokens int64 `json:"promptTokens"` + CompletionTokens int64 `json:"completionTokens"` + HandoffRate float64 `json:"handoffRate"` + KnowledgeFallbackRate float64 `json:"knowledgeFallbackRate"` + ResumedInterrupts int `json:"resumedInterrupts"` + ResolvedInterrupts int `json:"resolvedInterrupts"` + InterruptRecoveryRate float64 `json:"interruptRecoveryRate"` + ReviewedRuns int `json:"reviewedRuns"` + ResolvedRuns int `json:"resolvedRuns"` + ResolutionRate float64 `json:"resolutionRate"` + UnsupportedEvidenceRuns int `json:"unsupportedEvidenceRuns"` + 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,;}]+)`) + +func (s *agentRunService) Get(id int64) *models.AgentRun { + if id <= 0 { + return nil + } + return repositories.AgentRunRepository.Get(sqls.DB(), id) +} + +func (s *agentRunService) FindPageByParams(queryParams *params.QueryParams) (list []models.AgentRun, paging *sqls.Paging) { + return repositories.AgentRunRepository.FindPageByParams(sqls.DB(), queryParams) +} + +func (s *agentRunService) GetDetail(id int64) (*models.AgentRun, []models.AgentStep, []models.AgentToolCall) { + run := s.Get(id) + if run == nil { + return nil, nil, nil + } + return run, + repositories.AgentStepRepository.FindByAgentRunID(sqls.DB(), id), + repositories.AgentToolCallRepository.FindByAgentRunID(sqls.DB(), id) +} + +func (s *agentRunService) GetLatestStepID(agentRunID int64) int64 { + step := repositories.AgentStepRepository.LastByAgentRunID(sqls.DB(), agentRunID) + if step == nil { + return 0 + } + return step.ID +} + +func (s *agentRunService) GetQualityFeedback(agentRunID int64) *models.AgentRunQualityFeedback { + return repositories.AgentRunQualityFeedbackRepository.GetByAgentRunID(sqls.DB(), agentRunID) +} + +func (s *agentRunService) SaveQualityFeedback(req request.SaveAgentRunQualityFeedbackRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + if req.AgentRunID <= 0 { + return errorsx.InvalidParam("agent run id is required") + } + if !slices.Contains(enums.AgentRunResolutionStatusValues, req.ResolutionStatus) || !slices.Contains(enums.AgentRunEvidenceStatusValues, req.EvidenceStatus) { + return errorsx.InvalidParam("invalid agent run quality feedback status") + } + comment := strings.TrimSpace(req.Comment) + if len([]rune(comment)) > 2000 { + return errorsx.InvalidParam("agent run quality feedback comment is too long") + } + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if repositories.AgentRunRepository.Get(ctx.Tx, req.AgentRunID) == nil { + return errorsx.InvalidParam("agent run does not exist") + } + current := repositories.AgentRunQualityFeedbackRepository.GetByAgentRunID(ctx.Tx, req.AgentRunID) + if current == nil { + return repositories.AgentRunQualityFeedbackRepository.Create(ctx.Tx, &models.AgentRunQualityFeedback{ + AgentRunID: req.AgentRunID, ResolutionStatus: req.ResolutionStatus, EvidenceStatus: req.EvidenceStatus, Comment: comment, + AuditFields: utils.BuildAuditFields(operator), + }) + } + return repositories.AgentRunQualityFeedbackRepository.Updates(ctx.Tx, current.ID, map[string]any{ + "resolution_status": req.ResolutionStatus, + "evidence_status": req.EvidenceStatus, + "comment": comment, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) + }) +} + +// GetMetrics aggregates normalized audit records in Go so SQLite and MySQL +// use identical percentile and rate semantics. +func (s *agentRunService) GetMetrics(aiAgentID int64) AgentRunMetrics { + runs := repositories.AgentRunRepository.FindRecent(sqls.DB(), aiAgentID, 5000) + metrics := s.aggregateMetrics(sqls.DB(), runs) + if len(runs) == 0 { + return metrics + } + conversationCount := repositories.ConversationRepository.CountByAIAgentID(sqls.DB(), aiAgentID) + if conversationCount > 0 { + metrics.HandoffRate = float64(repositories.ConversationRepository.CountHandoffByAIAgentID(sqls.DB(), aiAgentID)) / float64(conversationCount) + } + 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 { + return metrics + } + runIDs := make([]int64, 0, len(runs)) + durations := make([]int64, 0, len(runs)) + var durationTotal int64 + for _, run := range runs { + runIDs = append(runIDs, run.ID) + switch run.Status { + case "completed": + metrics.CompletedRuns++ + case "failed": + metrics.FailedRuns++ + case "interrupted": + metrics.InterruptedRuns++ + } + metrics.PromptTokens += int64(run.PromptTokens) + metrics.CompletionTokens += int64(run.CompletionTokens) + if run.EndedAt != nil { + duration := run.EndedAt.Sub(run.StartedAt).Milliseconds() + if duration < 0 { + duration = 0 + } + durations = append(durations, duration) + durationTotal += duration + } + } + metrics.CompletionRate = float64(metrics.CompletedRuns) / float64(metrics.TotalRuns) + if len(durations) > 0 { + metrics.AverageDurationMS = durationTotal / int64(len(durations)) + sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] }) + index := (len(durations)*95+99)/100 - 1 + metrics.P95DurationMS = durations[index] + } + steps := repositories.AgentStepRepository.FindByAgentRunIDs(db, runIDs) + metrics.AverageSteps = float64(len(steps)) / float64(metrics.TotalRuns) + fallbackRunIDs := make(map[int64]struct{}) + for _, step := range steps { + if step.StepType == "policy" && step.StepCode == "knowledge_evidence" { + fallbackRunIDs[step.AgentRunID] = struct{}{} + } + } + metrics.KnowledgeFallbackRate = float64(len(fallbackRunIDs)) / float64(metrics.TotalRuns) + toolCalls := repositories.AgentToolCallRepository.FindByAgentRunIDs(db, runIDs) + metrics.ToolCalls = len(toolCalls) + if len(toolCalls) > 0 { + completed := 0 + for _, call := range toolCalls { + if call.Status == "completed" { + completed++ + } + } + metrics.ToolSuccessRate = float64(completed) / float64(len(toolCalls)) + } + interrupts := repositories.ConversationInterruptRepository.FindByAgentRunIDs(db, runIDs) + for _, interrupt := range interrupts { + if interrupt.ResumeCount <= 0 { + continue + } + metrics.ResumedInterrupts++ + if interrupt.Status == "resolved" { + metrics.ResolvedInterrupts++ + } + } + if metrics.ResumedInterrupts > 0 { + metrics.InterruptRecoveryRate = float64(metrics.ResolvedInterrupts) / float64(metrics.ResumedInterrupts) + } + feedbacks := repositories.AgentRunQualityFeedbackRepository.FindByAgentRunIDs(db, runIDs) + metrics.ReviewedRuns = len(feedbacks) + for _, feedback := range feedbacks { + if feedback.ResolutionStatus == enums.AgentRunResolutionStatusResolved { + metrics.ResolvedRuns++ + } + if feedback.EvidenceStatus == enums.AgentRunEvidenceStatusUnsupported { + metrics.UnsupportedEvidenceRuns++ + } + } + if metrics.ReviewedRuns > 0 { + metrics.ResolutionRate = float64(metrics.ResolvedRuns) / float64(metrics.ReviewedRuns) + metrics.UnsupportedEvidenceRate = float64(metrics.UnsupportedEvidenceRuns) / float64(metrics.ReviewedRuns) + } + 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 { + ConversationID int64 + AIAgentID int64 + AgentRevisionID int64 + SourceMessageID int64 + EngineCode string + Status string + PromptTokens int + CompletionTokens int + StartedAt time.Time + EndedAt *time.Time + ErrorMessage string + TraceData string + StepType string + StepCode string + StepInputPreview string + StepOutputPreview string + AdditionalSteps []EngineStepInput + ToolCalls []EngineToolCallInput +} + +type EngineStepInput struct { + StepType string + StepCode string + WorkflowRunID int64 + Status string + InputPreview string + OutputPreview string + ErrorMessage string +} + +type EngineToolCallInput struct { + ToolCode string + RiskLevel string + RequireConfirm bool + Status string + ArgumentsPreview string + ResultPreview string + ErrorMessage string + 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 { + if agentRunID <= 0 { + return nil + } + run := repositories.AgentRunRepository.Get(db, agentRunID) + if run == nil || run.EngineCode != "hybrid" { + return nil + } + status = strings.TrimSpace(status) + if status == "" { + 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, + }); err != nil { + return err + } + return repositories.AgentStepRepository.Create(db, &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, + }) +} + +// RecordEngineRun writes a non-workflow Engine 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) { + now := time.Now() + startedAt := input.StartedAt + if startedAt.IsZero() { + startedAt = now + } + status := strings.TrimSpace(input.Status) + if status == "" { + status = "completed" + } + run := &models.AgentRun{ + ConversationID: input.ConversationID, AIAgentID: input.AIAgentID, AgentRevisionID: input.AgentRevisionID, + SourceMessageID: input.SourceMessageID, EngineCode: strings.TrimSpace(input.EngineCode), 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 + } + 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: strings.TrimSpace(input.StepType), StepCode: strings.TrimSpace(input.StepCode), 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 + } + for _, extra := range input.AdditionalSteps { + extraStep := &models.AgentStep{ + AgentRunID: run.ID, WorkflowRunID: extra.WorkflowRunID, StepType: strings.TrimSpace(extra.StepType), StepCode: strings.TrimSpace(extra.StepCode), + Status: firstNonEmptyString(extra.Status, status), InputPreview: sanitizeAgentAuditPreview(extra.InputPreview), OutputPreview: sanitizeAgentAuditPreview(extra.OutputPreview), + ErrorMessage: sanitizeAgentAuditPreview(extra.ErrorMessage), StartedAt: startedAt, EndedAt: input.EndedAt, DurationMS: durationMS, CreatedAt: now, + } + if err := repositories.AgentStepRepository.Create(db, extraStep); err != nil { + return 0, err + } + } + for _, call := range input.ToolCalls { + toolCall := &models.AgentToolCall{ + AgentRunID: run.ID, AgentStepID: step.ID, ToolCode: strings.TrimSpace(call.ToolCode), RiskLevel: strings.TrimSpace(call.RiskLevel), + RequireConfirm: call.RequireConfirm, Status: firstNonEmptyString(call.Status, status), ArgumentsPreview: sanitizeAgentAuditPreview(call.ArgumentsPreview), + ResultPreview: sanitizeAgentAuditPreview(call.ResultPreview), ErrorMessage: sanitizeAgentAuditPreview(call.ErrorMessage), DurationMS: call.DurationMS, CreatedAt: now, + } + if err := repositories.AgentToolCallRepository.Create(db, toolCall); err != nil { + return 0, err + } + } + return run.ID, nil +} + +func sanitizeAgentAuditPreview(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + value = agentAuditSecretPattern.ReplaceAllString(value, "$1$2***") + runes := []rune(value) + if len(runes) <= maxAgentAuditPreviewChars { + return value + } + return strings.TrimSpace(string(runes[:maxAgentAuditPreviewChars])) + "\n[preview truncated]" +} + +func firstNonEmptyString(items ...string) string { + for _, item := range items { + if value := strings.TrimSpace(item); value != "" { + return value + } + } + 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 +} diff --git a/internal/services/agent_run_service_test.go b/internal/services/agent_run_service_test.go new file mode 100644 index 0000000..832fc4a --- /dev/null +++ b/internal/services/agent_run_service_test.go @@ -0,0 +1,237 @@ +package services + +import ( + "strings" + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "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" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestAgentRunServiceFindsWorkflowAuditDetail(t *testing.T) { + db := setupAgentRunServiceTestDB(t) + now := time.Now() + endedAt := now.Add(time.Second) + run := &models.AgentRun{ + ConversationID: 11, + AIAgentID: 12, + WorkflowRunID: 13, + EngineCode: "workflow", + 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) + } + if err := db.Create(&models.AgentStep{AgentRunID: run.ID, StepType: "workflow", Status: "completed", StartedAt: now, EndedAt: &endedAt, CreatedAt: now}).Error; err != nil { + t.Fatalf("create agent step: %v", err) + } + if err := db.Create(&models.AgentToolCall{AgentRunID: run.ID, ToolCode: "knowledge.retrieve", Status: "completed", CreatedAt: now}).Error; err != nil { + t.Fatalf("create tool call: %v", err) + } + + cnd := sqls.NewCnd().Eq("conversation_id", run.ConversationID).Desc("id").Page(1, 20) + queryParams := ¶ms.QueryParams{Cnd: *cnd} + list, paging := AgentRunService.FindPageByParams(queryParams) + if len(list) != 1 || paging.Total != 1 || list[0].ID != run.ID { + t.Fatalf("unexpected agent run page: list=%#v paging=%#v", list, paging) + } + item, steps, toolCalls := AgentRunService.GetDetail(run.ID) + if item == nil || len(steps) != 1 || len(toolCalls) != 1 { + t.Fatalf("unexpected agent run detail: run=%#v steps=%#v toolCalls=%#v", item, steps, toolCalls) + } +} + +func TestAgentRunServiceAssociatesWorkflowRevision(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{{ + 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) + } + _, steps, toolCalls := AgentRunService.GetDetail(runID) + if len(toolCalls) != 1 || toolCalls[0].ToolCode != "knowledge/search" || toolCalls[0].AgentStepID <= 0 { + t.Fatalf("unexpected tool audit: %#v", toolCalls) + } + if strings.Contains(toolCalls[0].ArgumentsPreview, "abc123") || len(steps) != 1 || strings.Contains(steps[0].InputPreview, "Bearer-secret") { + t.Fatalf("sensitive audit data leaked: steps=%#v calls=%#v", steps, toolCalls) + } +} + +func TestAgentRunServiceRecordsHybridPlaybookResume(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} + if err := db.Create(run).Error; err != nil { + t.Fatalf("create hybrid run: %v", err) + } + if err := AgentRunService.RecordHybridPlaybookResume(db, run.ID, 33, "completed", "已完成工单登记。"); err != nil { + t.Fatalf("RecordHybridPlaybookResume 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) + } + if len(steps) != 1 || steps[0].StepCode != "playbook_resume" || steps[0].WorkflowRunID != 33 || steps[0].OutputPreview != "已完成工单登记。" { + t.Fatalf("unexpected playbook resume step: %#v", steps) + } +} + +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} + if err := db.Create(run).Error; err != nil { + t.Fatalf("create agent run: %v", err) + } + operator := &dto.AuthPrincipal{UserID: 7, Username: "reviewer"} + if err := AgentRunService.SaveQualityFeedback(request.SaveAgentRunQualityFeedbackRequest{ + AgentRunID: run.ID, ResolutionStatus: enums.AgentRunResolutionStatusResolved, EvidenceStatus: enums.AgentRunEvidenceStatusSupported, Comment: "issue resolved", + }, operator); err != nil { + t.Fatalf("save quality feedback: %v", err) + } + if err := AgentRunService.SaveQualityFeedback(request.SaveAgentRunQualityFeedbackRequest{ + AgentRunID: run.ID, ResolutionStatus: enums.AgentRunResolutionStatusUnresolved, EvidenceStatus: enums.AgentRunEvidenceStatusUnsupported, Comment: "missing evidence", + }, operator); err != nil { + t.Fatalf("update quality feedback: %v", err) + } + feedback := AgentRunService.GetQualityFeedback(run.ID) + if feedback == nil || feedback.ResolutionStatus != enums.AgentRunResolutionStatusUnresolved || feedback.EvidenceStatus != enums.AgentRunEvidenceStatusUnsupported || feedback.Comment != "missing evidence" || feedback.UpdateUserName != "reviewer" { + t.Fatalf("unexpected quality feedback: %#v", feedback) + } +} + +func TestAgentRunServiceAggregatesCrossEngineMetrics(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}, + } + for index := range runs { + if err := db.Create(&runs[index]).Error; err != nil { + t.Fatalf("create run: %v", err) + } + } + if err := db.Create(&models.AgentStep{AgentRunID: runs[0].ID, Status: "completed", StartedAt: base, CreatedAt: base}).Error; err != nil { + t.Fatalf("create step: %v", err) + } + if err := db.Create(&models.AgentStep{AgentRunID: runs[1].ID, Status: "failed", StartedAt: base, CreatedAt: base}).Error; err != nil { + t.Fatalf("create step: %v", err) + } + if err := db.Create(&models.AgentToolCall{AgentRunID: runs[0].ID, Status: "completed", CreatedAt: base}).Error; err != nil { + t.Fatalf("create completed tool call: %v", err) + } + if err := db.Create(&models.AgentToolCall{AgentRunID: runs[1].ID, Status: "failed", CreatedAt: base}).Error; err != nil { + t.Fatalf("create failed tool call: %v", err) + } + if err := db.Create(&models.Conversation{AIAgentID: 8}).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + handoffAt := base + if err := db.Create(&models.Conversation{AIAgentID: 8, HandoffAt: &handoffAt}).Error; err != nil { + t.Fatalf("create handoff conversation: %v", err) + } + if err := db.Create(&models.ConversationInterrupt{AgentRunID: runs[0].ID, CheckPointID: "metrics-resolved", Status: "resolved", ResumeCount: 1, CreatedAt: base, UpdatedAt: base}).Error; err != nil { + t.Fatalf("create resolved interrupt: %v", err) + } + if err := db.Create(&models.ConversationInterrupt{AgentRunID: runs[1].ID, CheckPointID: "metrics-cancelled", Status: "cancelled", ResumeCount: 1, CreatedAt: base, UpdatedAt: base}).Error; err != nil { + t.Fatalf("create cancelled interrupt: %v", err) + } + if err := db.Create(&models.AgentRunQualityFeedback{AgentRunID: runs[0].ID, ResolutionStatus: enums.AgentRunResolutionStatusResolved, EvidenceStatus: enums.AgentRunEvidenceStatusSupported}).Error; err != nil { + t.Fatalf("create resolved feedback: %v", err) + } + if err := db.Create(&models.AgentRunQualityFeedback{AgentRunID: runs[1].ID, ResolutionStatus: enums.AgentRunResolutionStatusUnresolved, EvidenceStatus: enums.AgentRunEvidenceStatusUnsupported}).Error; err != nil { + t.Fatalf("create unresolved feedback: %v", err) + } + metrics := AgentRunService.GetMetrics(8) + if metrics.TotalRuns != 2 || metrics.CompletedRuns != 1 || metrics.FailedRuns != 1 || metrics.CompletionRate != 0.5 { + t.Fatalf("unexpected run metrics: %#v", metrics) + } + if metrics.AverageDurationMS != 200 || metrics.P95DurationMS != 300 || metrics.ToolCalls != 2 || metrics.ToolSuccessRate != 0.5 || metrics.AverageSteps != 1 { + t.Fatalf("unexpected aggregate metrics: %#v", metrics) + } + if metrics.PromptTokens != 18 || metrics.CompletionTokens != 7 { + t.Fatalf("unexpected token metrics: %#v", metrics) + } + if metrics.HandoffRate != 0.5 || metrics.KnowledgeFallbackRate != 0 { + t.Fatalf("unexpected business metrics: %#v", metrics) + } + if metrics.ResumedInterrupts != 2 || metrics.ResolvedInterrupts != 1 || metrics.InterruptRecoveryRate != 0.5 { + t.Fatalf("unexpected interrupt recovery metrics: %#v", metrics) + } + 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 } + +func setupAgentRunServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}, &models.AgentRunQualityFeedback{}, &models.Conversation{}, &models.ConversationInterrupt{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + return db +} diff --git a/internal/services/agent_tool_invocation_service.go b/internal/services/agent_tool_invocation_service.go new file mode 100644 index 0000000..35a632b --- /dev/null +++ b/internal/services/agent_tool_invocation_service.go @@ -0,0 +1,81 @@ +package services + +import ( + "strings" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/repositories" + + "github.com/mlogclub/simple/sqls" +) + +const ( + agentToolInvocationStatusRunning = "running" + agentToolInvocationStatusCompleted = "completed" + agentToolInvocationStatusFailed = "failed" +) + +var AgentToolInvocationService = newAgentToolInvocationService() + +type AgentToolInvocationClaim struct { + Item *models.AgentToolInvocation + Completed bool + Acquired bool +} + +type agentToolInvocationService struct{} + +func newAgentToolInvocationService() *agentToolInvocationService { + return &agentToolInvocationService{} +} + +// Claim obtains the persistent idempotency boundary. A completed invocation +// can be returned to callers; an in-flight invocation is never executed again. +func (s *agentToolInvocationService) Claim(conversationID, aiAgentID int64, toolCode, idempotencyKey string) (*AgentToolInvocationClaim, error) { + toolCode = strings.TrimSpace(toolCode) + idempotencyKey = strings.TrimSpace(idempotencyKey) + if conversationID <= 0 || toolCode == "" || idempotencyKey == "" { + return nil, nil + } + if item := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(sqls.DB(), conversationID, toolCode, idempotencyKey); item != nil { + if item.Status == agentToolInvocationStatusCompleted { + return &AgentToolInvocationClaim{Item: item, Completed: true}, nil + } + if item.Status == agentToolInvocationStatusRunning { + return &AgentToolInvocationClaim{Item: item}, nil + } + if err := repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusRunning, "error_message": "", "updated_at": time.Now()}); err != nil { + return nil, err + } + item.Status, item.ErrorMessage = agentToolInvocationStatusRunning, "" + return &AgentToolInvocationClaim{Item: item, Acquired: true}, nil + } + item := &models.AgentToolInvocation{ConversationID: conversationID, AIAgentID: aiAgentID, ToolCode: toolCode, IdempotencyKey: idempotencyKey, Status: agentToolInvocationStatusRunning} + if err := repositories.AgentToolInvocationRepository.Create(sqls.DB(), item); err != nil { + // A concurrent caller may have created the unique invocation first. + if existing := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(sqls.DB(), conversationID, toolCode, idempotencyKey); existing != nil { + return &AgentToolInvocationClaim{Item: existing, Completed: existing.Status == agentToolInvocationStatusCompleted}, nil + } + return nil, err + } + return &AgentToolInvocationClaim{Item: item, Acquired: true}, nil +} + +func (s *agentToolInvocationService) Complete(item *models.AgentToolInvocation, resultData string) error { + if item == nil || item.ID <= 0 { + return nil + } + return repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusCompleted, "result_data": resultData, "error_message": "", "updated_at": time.Now()}) +} + +func (s *agentToolInvocationService) Fail(item *models.AgentToolInvocation, cause error) error { + if item == nil || item.ID <= 0 { + return nil + } + message := "" + if cause != nil { + message = cause.Error() + } + return repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusFailed, "error_message": message, "updated_at": time.Now()}) +} diff --git a/internal/services/agent_tool_invocation_service_test.go b/internal/services/agent_tool_invocation_service_test.go new file mode 100644 index 0000000..5e0b1a2 --- /dev/null +++ b/internal/services/agent_tool_invocation_service_test.go @@ -0,0 +1,65 @@ +package services + +import ( + "strings" + "testing" + + "agent-desk/internal/models" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestAgentToolInvocationServiceReusesCompletedInvocation(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.AgentToolInvocation{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + + first, err := AgentToolInvocationService.Claim(10, 20, "graph/create_ticket_with_confirmation", "message:30:node:create") + if err != nil || first == nil || first.Item == nil || first.Completed { + t.Fatalf("first claim = %#v, err=%v", first, err) + } + if err := AgentToolInvocationService.Complete(first.Item, `{"ticketId":40}`); err != nil { + t.Fatalf("complete invocation: %v", err) + } + second, err := AgentToolInvocationService.Claim(10, 20, "graph/create_ticket_with_confirmation", "message:30:node:create") + if err != nil || second == nil || !second.Completed || second.Item.ResultData != `{"ticketId":40}` { + t.Fatalf("second claim = %#v, err=%v", second, err) + } +} + +func TestAgentToolInvocationServiceAllowsFailedInvocationRetry(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.AgentToolInvocation{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + sqls.SetDB(db) + + first, err := AgentToolInvocationService.Claim(11, 21, "graph/handoff_to_human", "message:31:node:handoff") + if err != nil { + t.Fatalf("first claim: %v", err) + } + if err := AgentToolInvocationService.Fail(first.Item, errTestToolInvocation); err != nil { + t.Fatalf("fail invocation: %v", err) + } + second, err := AgentToolInvocationService.Claim(11, 21, "graph/handoff_to_human", "message:31:node:handoff") + if err != nil || second == nil || second.Completed || second.Item.Status != agentToolInvocationStatusRunning || second.Item.ErrorMessage != "" { + t.Fatalf("retry claim = %#v, err=%v", second, err) + } +} + +var errTestToolInvocation = &toolInvocationTestError{} + +type toolInvocationTestError struct{} + +func (e *toolInvocationTestError) Error() string { return "tool failed" } diff --git a/internal/services/ai_agent_service.go b/internal/services/ai_agent_service.go index 21bdd04..4ad82f5 100644 --- a/internal/services/ai_agent_service.go +++ b/internal/services/ai_agent_service.go @@ -6,6 +6,7 @@ import ( "strings" "time" + aitooling "agent-desk/internal/ai/tooling" "agent-desk/internal/models" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" @@ -18,10 +19,13 @@ import ( "agent-desk/internal/pkg/httpx/params" "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" ) var AIAgentService = newAIAgentService() +const defaultNewAutonomousRolloutPercent = 5 + func newAIAgentService() *aIAgentService { return &aIAgentService{} } @@ -79,8 +83,11 @@ func (s *aIAgentService) CreateAIAgent(req request.CreateAIAgentRequest, operato if err := repositories.AIAgentRepository.Create(ctx.Tx, item); err != nil { return err } - _, err := AIWorkflowService.createDefaultAgentWorkflow(ctx.Tx, item, operator) - return err + if item.RuntimeMode == enums.AIAgentRuntimeModeWorkflow || item.RuntimeMode == enums.AIAgentRuntimeModeHybrid { + _, err := AIWorkflowService.createDefaultAgentWorkflow(ctx.Tx, item, operator) + return err + } + return nil }); err != nil { return nil, err } @@ -91,31 +98,48 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato if operator == nil { return errorsx.UnauthorizedI18n("error.auth.expired") } - if s.Get(req.ID) == nil { + current := s.Get(req.ID) + if current == nil { return errorsx.InvalidParamI18n("error.e0002") } item, err := s.buildAIAgentModel(req.ID, req.CreateAIAgentRequest) if err != nil { return err } - return repositories.AIAgentRepository.Updates(sqls.DB(), req.ID, map[string]any{ + columns := map[string]any{ "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, + "knowledge_policy": item.KnowledgePolicy, "service_mode": item.ServiceMode, "system_prompt": item.SystemPrompt, "welcome_message": item.WelcomeMessage, "reply_timeout_seconds": item.ReplyTimeoutSeconds, + "rollout_percent": item.RolloutPercent, "team_ids": item.TeamIDs, "handoff_mode": item.HandoffMode, "fallback_mode": item.FallbackMode, "fallback_message": item.FallbackMessage, + "knowledge_ids": item.KnowledgeIDs, "skill_ids": item.SkillIDs, "allowed_mcp_tools": item.AllowedMCPTools, "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": time.Now(), - }) + } + 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 repositories.AIAgentRepository.Updates(sqls.DB(), req.ID, columns) } func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) error { @@ -134,6 +158,133 @@ func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) er }) } +// PublishAIAgent snapshots a non-workflow Agent 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") + } + var revision *models.AgentRevision + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + agent := repositories.AIAgentRepository.Get(ctx.Tx, id) + 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 err := s.validatePublishableAgent(ctx.Tx, agent); err != nil { + return err + } + var err error + revision, err = AgentRevisionService.PublishSnapshot(ctx.Tx, agent, operator) + if err != nil { + return err + } + return repositories.AIAgentRepository.Updates(ctx.Tx, agent.ID, map[string]any{ + "published_revision_id": revision.ID, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) + }) + if err != nil { + return nil, err + } + return revision, nil +} + +func (s *aIAgentService) validatePublishableAgent(db *gorm.DB, agent *models.AIAgent) error { + if agent == nil || agent.AIConfigID <= 0 { + return errorsx.InvalidParam("ai agent model configuration is required before publishing") + } + config := repositories.AIConfigRepository.Get(db, agent.AIConfigID) + if config == nil || config.Status != enums.StatusOk { + return errorsx.InvalidParam("ai agent model configuration is unavailable") + } + if _, err := s.normalizeToolPolicy(agent.ToolPolicy); err != nil { + return err + } + if strings.TrimSpace(agent.AllowedMCPTools) == "" { + return nil + } + var directTools []request.AIAgentMCPToolRequest + if err := json.Unmarshal([]byte(agent.AllowedMCPTools), &directTools); err != nil { + return errorsx.InvalidParam("ai agent direct tools are invalid") + } + for _, item := range directTools { + definition, err := aitooling.DefaultRegistry.Resolve(item.ToolCode) + if err != nil || definition.InputSchema == nil { + return errorsx.InvalidParam("ai agent direct tool definition is unavailable") + } + if definition.RequireConfirmation { + return errorsx.InvalidParam("ai agent sensitive direct tools must be executed through a confirmed playbook") + } + } + return nil +} + +// RollbackAIAgent switches an Agent back to a previously published immutable +// revision. It never rewrites the historical snapshot itself. +func (s *aIAgentService) RollbackAIAgent(id, revisionID int64, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + if id <= 0 || revisionID <= 0 { + return errorsx.InvalidParam("agent id and revision id are required") + } + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + agent := repositories.AIAgentRepository.Get(ctx.Tx, id) + if agent == nil || agent.Status != enums.StatusOk { + return errorsx.InvalidParamI18n("error.e0002") + } + revision := repositories.AgentRevisionRepository.Get(ctx.Tx, revisionID) + if revision == nil || revision.AgentID != agent.ID || revision.Status != enums.StatusOk { + return errorsx.InvalidParam("agent revision does not exist") + } + updates := map[string]any{ + "published_revision_id": revision.ID, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + } + if agent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow || agent.RuntimeMode == enums.AIAgentRuntimeModeHybrid { + if revision.WorkflowVersionID <= 0 || repositories.AIWorkflowVersionRepository.Get(ctx.Tx, revision.WorkflowVersionID) == nil { + return errorsx.InvalidParam("workflow revision does not contain a published workflow version") + } + updates["workflow_version_id"] = revision.WorkflowVersionID + } + return repositories.AIAgentRepository.Updates(ctx.Tx, agent.ID, updates) + }) +} + +// RollbackAIAgentRollout restores the prior Agent rollout percentage and +// swaps it into history, allowing operators to undo and redo one rollout +// change without rewriting an immutable AgentRevision. +func (s *aIAgentService) RollbackAIAgentRollout(id int64, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + if id <= 0 { + return errorsx.InvalidParam("agent id is required") + } + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + agent := repositories.AIAgentRepository.Get(ctx.Tx, id) + if agent == nil || agent.Status != enums.StatusOk { + return errorsx.InvalidParamI18n("error.e0002") + } + if agent.PreviousRolloutPercent < 1 || agent.PreviousRolloutPercent > 100 { + return errorsx.InvalidParam("agent rollout has no previous value to restore") + } + return repositories.AIAgentRepository.Updates(ctx.Tx, agent.ID, map[string]any{ + "rollout_percent": agent.PreviousRolloutPercent, + "previous_rollout_percent": agent.RolloutPercent, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) + }) +} + func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRequest) (*models.AIAgent, error) { name := strings.TrimSpace(req.Name) if name == "" { @@ -152,6 +303,28 @@ 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 + } + if req.MaxSteps < 1 || req.MaxSteps > 8 { + return nil, errorsx.InvalidParam("ai agent max steps must be between 1 and 8") + } + if req.ContextWindow < 0 { + return nil, errorsx.InvalidParam("ai agent context window must not be negative") + } + toolPolicy, err := s.normalizeToolPolicy(req.ToolPolicy) + if err != nil { + return nil, err + } if !slices.Contains(enums.IMConversationServiceModeValues, req.ServiceMode) { return nil, errorsx.InvalidParamI18n("error.e0230") } @@ -175,11 +348,25 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe if req.ReplyTimeoutSeconds < 0 { 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 + } + } + if req.RolloutPercent < 1 || req.RolloutPercent > 100 { + return nil, errorsx.InvalidParam("ai agent rollout percent must be between 1 and 100") + } skillIDs, err := s.normalizeSkillIDs(req.SkillIDs) if err != nil { return nil, err } + knowledgeBaseIDs, err := s.normalizeKnowledgeBaseIDs(req.KnowledgeBaseIDs) + if err != nil { + return nil, err + } directTools, err := s.normalizeDirectTools(req.DirectTools) if err != nil { return nil, err @@ -196,20 +383,93 @@ 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, + KnowledgePolicy: strings.TrimSpace(req.KnowledgePolicy), ServiceMode: req.ServiceMode, SystemPrompt: strings.TrimSpace(req.SystemPrompt), WelcomeMessage: strings.TrimSpace(req.WelcomeMessage), ReplyTimeoutSeconds: req.ReplyTimeoutSeconds, + RolloutPercent: req.RolloutPercent, TeamIDs: utils.JoinInt64s(teamIDs), HandoffMode: req.HandoffMode, FallbackMode: req.FallbackMode, FallbackMessage: strings.TrimSpace(req.FallbackMessage), + KnowledgeIDs: utils.JoinInt64s(knowledgeBaseIDs), SkillIDs: utils.JoinInt64s(skillIDs), AllowedMCPTools: directToolsJSON, WorkflowVersionID: 0, }, nil } +type normalizedAIAgentToolPolicy struct { + MaxTotalCalls int `json:"maxTotalCalls,omitempty"` + MaxArgumentBytes int `json:"maxArgumentBytes,omitempty"` + AllowedRiskLevels []string `json:"allowedRiskLevels,omitempty"` +} + +func (s *aIAgentService) normalizeToolPolicy(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + policy := normalizedAIAgentToolPolicy{} + if err := json.Unmarshal([]byte(raw), &policy); err != nil { + return "", errorsx.InvalidParam("ai agent tool policy must be valid JSON") + } + if policy.MaxTotalCalls < 0 || policy.MaxTotalCalls > 8 { + return "", errorsx.InvalidParam("ai agent tool policy maxTotalCalls must be between 1 and 8") + } + if policy.MaxArgumentBytes < 0 || policy.MaxArgumentBytes > 64*1024 { + return "", errorsx.InvalidParam("ai agent tool policy maxArgumentBytes must be between 1 and 65536") + } + seen := make(map[string]struct{}, len(policy.AllowedRiskLevels)) + riskLevels := make([]string, 0, len(policy.AllowedRiskLevels)) + for _, level := range policy.AllowedRiskLevels { + level = strings.ToLower(strings.TrimSpace(level)) + if level == "" { + continue + } + if level != "read" && level != "write" && level != "sensitive" { + return "", errorsx.InvalidParam("ai agent tool policy contains an invalid risk level") + } + if _, exists := seen[level]; exists { + continue + } + seen[level] = struct{}{} + riskLevels = append(riskLevels, level) + } + policy.AllowedRiskLevels = riskLevels + data, err := json.Marshal(policy) + if err != nil { + return "", errorsx.InvalidParam("ai agent tool policy is invalid") + } + return string(data), nil +} + +func (s *aIAgentService) normalizeKnowledgeBaseIDs(input []int64) ([]int64, error) { + ret := make([]int64, 0, len(input)) + seen := make(map[int64]struct{}) + for _, id := range input { + if id <= 0 { + continue + } + if _, exists := seen[id]; exists { + continue + } + knowledgeBase := KnowledgeBaseService.Get(id) + if knowledgeBase == nil || knowledgeBase.Status != enums.StatusOk { + return nil, errorsx.InvalidParam("knowledge base is not available") + } + seen[id] = struct{}{} + ret = append(ret, id) + } + slices.Sort(ret) + return ret, nil +} + func (s *aIAgentService) normalizeTeamIDs(input []int64) ([]int64, error) { ret := make([]int64, 0, len(input)) seen := make(map[int64]struct{}) @@ -271,11 +531,17 @@ func (s *aIAgentService) normalizeDirectTools(input []request.AIAgentMCPToolRequ if toolx.IsAutoInjectedToolCode(strings.TrimSpace(normalized.ToolCode)) { continue } - if toolx.ResolveToolSourceType(normalized.ToolCode) != enums.ToolSourceTypeMCP { - return nil, errorsx.InvalidParamI18n("error.e0020") - } - if err := ToolCatalogService.ValidateToolCode(normalized.ToolCode); err != nil { - return nil, err + if spec, registered := toolx.GetRegisteredToolSpec(normalized.ToolCode); registered { + if !spec.DirectAccess || spec.AutoInjected || (spec.Code != toolx.BuiltinConversationContext.Code && spec.Code != toolx.BuiltinKnowledgeRetrieve.Code && spec.Code != toolx.GraphTriageServiceRequest.Code && spec.Code != toolx.GraphAnalyzeConversation.Code && spec.Code != toolx.GraphPrepareTicketDraft.Code) { + return nil, errorsx.InvalidParamI18n("error.e0020") + } + } else { + if toolx.ResolveToolSourceType(normalized.ToolCode) != enums.ToolSourceTypeMCP { + return nil, errorsx.InvalidParamI18n("error.e0020") + } + if err := ToolCatalogService.ValidateToolCode(normalized.ToolCode); err != nil { + return nil, err + } } key := strings.TrimSpace(normalized.ToolCode) if _, exists := seen[key]; exists { diff --git a/internal/services/ai_agent_workflow_service_test.go b/internal/services/ai_agent_workflow_service_test.go index 347500f..6d0358c 100644 --- a/internal/services/ai_agent_workflow_service_test.go +++ b/internal/services/ai_agent_workflow_service_test.go @@ -12,13 +12,14 @@ import ( "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 TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) { +func TestAIAgentServiceCreatesWorkflowOnlyWhenRequested(t *testing.T) { setupAIAgentWorkflowTestDB(t) operator := aiAgentWorkflowTestOperator() aiConfigID := createAIAgentWorkflowTestConfig(t) @@ -26,6 +27,7 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) { item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ Name: "workflow agent", AIConfigID: aiConfigID, + RuntimeMode: enums.AIAgentRuntimeModeWorkflow, ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer, @@ -33,6 +35,15 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) { 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 { @@ -81,10 +92,12 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) { } } assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypeSendReply, "eq", "direct_reply") - assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypeHandoffToHuman, "eq", "handoff_to_human") + 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") { @@ -93,6 +106,7 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) { 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", @@ -114,6 +128,230 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) { }) } +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","sensitive"]}`) + if err != nil { + t.Fatalf("normalizeToolPolicy: %v", err) + } + if !strings.Contains(policy, `"maxTotalCalls":2`) || !strings.Contains(policy, `"allowedRiskLevels":["read","sensitive"]`) { + t.Fatalf("unexpected normalized policy: %s", policy) + } + 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 TestAIAgentServiceAllowsRegisteredReadDirectTool(t *testing.T) { + tools, err := AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.BuiltinConversationContext.Code}}) + if err != nil { + t.Fatalf("normalizeDirectTools: %v", err) + } + if len(tools) != 1 || tools[0].ToolCode != toolx.BuiltinConversationContext.Code { + t.Fatalf("unexpected normalized direct tools: %#v", tools) + } + tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.BuiltinKnowledgeRetrieve.Code}}) + if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.BuiltinKnowledgeRetrieve.Code { + t.Fatalf("expected registered knowledge retrieve tool to be allowed, tools=%#v err=%v", tools, err) + } + tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphPrepareTicketDraft.Code}}) + if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.GraphPrepareTicketDraft.Code { + t.Fatalf("expected registered ticket draft tool to be allowed, tools=%#v err=%v", tools, err) + } + tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphAnalyzeConversation.Code}}) + if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.GraphAnalyzeConversation.Code { + t.Fatalf("expected registered conversation analysis tool to be allowed, tools=%#v err=%v", tools, err) + } + tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphTriageServiceRequest.Code}}) + if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.GraphTriageServiceRequest.Code { + t.Fatalf("expected registered service triage tool to be allowed, tools=%#v err=%v", tools, err) + } + if _, err := AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphHandoffConversation.Code}}); err == nil { + t.Fatal("expected unsupported graph direct tool to be rejected") + } +} + +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 TestAIAgentServiceRejectsPublishWithSensitiveDirectTool(t *testing.T) { + setupAIAgentWorkflowTestDB(t) + operator := aiAgentWorkflowTestOperator() + agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ + Name: "sensitive 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 direct tool: %v", err) + } + if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err == nil || !strings.Contains(err.Error(), "confirmed playbook") { + t.Fatalf("expected sensitive direct tool publish rejection, got %v", err) + } +} + func TestAIWorkflowServiceDefaultAgentWorkflowDefinitionRequiresKnowledgeRetrieveConfiguration(t *testing.T) { definition := AIWorkflowService.DefaultAgentWorkflowDefinition() if definition.SchemaVersion != dsl.SchemaVersion || nodeTypeByID(definition, "start_1") != workflowregistry.NodeTypeStart { @@ -135,6 +373,13 @@ func TestAIWorkflowServiceDefaultAgentWorkflowDefinitionRequiresKnowledgeRetriev 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) { @@ -203,6 +448,19 @@ func TestAIWorkflowServicePublishAgentWorkflowBindsAgentVersion(t *testing.T) { 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 setupAIAgentWorkflowTestDB(t *testing.T) { @@ -211,7 +469,7 @@ func setupAIAgentWorkflowTestDB(t *testing.T) { if err != nil { t.Fatalf("open sqlite db: %v", err) } - if err := db.AutoMigrate(&models.AIAgent{}, &models.AIConfig{}, &models.KnowledgeBase{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}); err != nil { + if err := db.AutoMigrate(&models.AIAgent{}, &models.AIConfig{}, &models.KnowledgeBase{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}, &models.AgentRevision{}); err != nil { t.Fatalf("auto migrate: %v", err) } sqls.SetDB(db) @@ -222,6 +480,7 @@ func createAIAgentWorkflowTestConfig(t *testing.T) int64 { item := &models.AIConfig{ Name: "workflow-test-config", Provider: enums.AIProviderOpenAI, + APIKey: "revision-test-secret", ModelType: enums.AIModelTypeLLM, ModelName: "gpt-test", Status: enums.StatusOk, diff --git a/internal/services/ai_workflow_service.go b/internal/services/ai_workflow_service.go index 204e63f..20be75a 100644 --- a/internal/services/ai_workflow_service.go +++ b/internal/services/ai_workflow_service.go @@ -42,6 +42,13 @@ type AIWorkflowRunAuditItem struct { Agent *models.AIAgent } +type AIWorkflowTemplate struct { + Code string + Name string + Description string + Definition dsl.Definition +} + func (s *aiWorkflowService) Get(id int64) *models.AIWorkflow { if id <= 0 { return nil @@ -194,6 +201,46 @@ func (s *aiWorkflowService) DefaultAgentWorkflowDefinition() dsl.Definition { return defaultAgentWorkflowDefinition() } +func (s *aiWorkflowService) ListPlaybookTemplates() []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()}, + } +} + +func ticketWithConfirmationPlaybookDefinition() dsl.Definition { + return dsl.Definition{SchemaVersion: dsl.SchemaVersion, + Nodes: []dsl.Node{ + workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil), + workflowNode("draft_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 600, 180, workflowInputs("issue", "start_1", "userMessage"), nil), + workflowNode("ready_route_1", workflowregistry.NodeTypeCondition, "草稿分流", 1020, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ + workflowConditionBranch("ready", "草稿完整", "prompt_1", "draft_1", "ready", "is_true", nil), + {ID: "default", Name: "补充信息", TargetNodeID: "followup_1", Default: true}, + }}), + workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, "建单确认", 1440, 100, map[string]dsl.Value{"userMessage": dsl.RefValue("start_1", "userMessage"), "ticketTitle": dsl.RefValue("draft_1", "title"), "ticketDescription": dsl.RefValue("draft_1", "description")}, map[string]any{"staticReply": "我已整理工单草稿:{{ticketTitle}}。请确认是否创建。"}), + workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认建单", 1860, 100, workflowInputs("prompt", "prompt_1", "replyText"), nil), + workflowNode("confirm_route_1", workflowregistry.NodeTypeCondition, "确认分流", 2280, 100, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ + workflowConditionBranch("confirmed", "已确认", "create_1", "confirm_1", "confirmed", "is_true", nil), + {ID: "default", Name: "取消", TargetNodeID: "cancel_1", Default: true}, + }}), + workflowNode("create_1", workflowregistry.NodeTypeCreateTicket, "创建工单", 2700, 20, map[string]dsl.Value{"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"), "confirmed": dsl.RefValue("confirm_1", "confirmed")}, nil), + workflowNode("followup_1", workflowregistry.NodeTypeLLMReply, "补充信息", 1440, 330, map[string]dsl.Value{"userMessage": dsl.RefValue("start_1", "userMessage"), "followUpQuestions": dsl.RefValue("draft_1", "followUpQuestions")}, map[string]any{"staticReply": "创建工单前还需要补充:{{followUpQuestions}}"}), + workflowNode("cancel_1", workflowregistry.NodeTypeLLMReply, "取消提示", 2700, 200, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消创建工单。"}), + workflowNode("send_result_1", workflowregistry.NodeTypeSendReply, "发送建单结果", 3120, 20, workflowInputs("replyText", "create_1", "message"), nil), + workflowNode("send_followup_1", workflowregistry.NodeTypeSendReply, "发送补充提示", 1860, 330, workflowInputs("replyText", "followup_1", "replyText"), nil), + workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 3120, 200, workflowInputs("replyText", "cancel_1", "replyText"), nil), + workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 3540, 180, nil, nil), + }, + Edges: []dsl.Edge{ + workflowEdge("start_1", "draft_1"), workflowEdge("draft_1", "ready_route_1"), workflowPortEdge("ready_route_1", "prompt_1", "ready"), workflowPortEdge("ready_route_1", "followup_1", "default"), + workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "confirm_route_1"), workflowPortEdge("confirm_route_1", "create_1", "confirmed"), workflowPortEdge("confirm_route_1", "cancel_1", "default"), + workflowEdge("create_1", "send_result_1"), workflowEdge("send_result_1", "end_1"), workflowEdge("followup_1", "send_followup_1"), workflowEdge("send_followup_1", "end_1"), workflowEdge("cancel_1", "send_cancel_1"), workflowEdge("send_cancel_1", "end_1"), + }, + } +} + func (s *aiWorkflowService) ValidateDefinition(def dsl.Definition) workflowvalidator.Result { return workflowvalidator.ValidateDefinition(def, s.registry) } @@ -390,11 +437,23 @@ func (s *aiWorkflowService) PublishAgentWorkflow(req request.PublishAIWorkflowRe }); err != nil { return err } + agent := repositories.AIAgentRepository.Get(ctx.Tx, req.AgentID) + if agent == nil { + return errorsx.InvalidParamI18n("error.e0002") + } + if err := AIAgentService.validatePublishableAgent(ctx.Tx, agent); err != nil { + return err + } + revision, err := AgentRevisionService.PublishWorkflowSnapshot(ctx.Tx, agent, version, operator) + if err != nil { + return err + } return repositories.AIAgentRepository.Updates(ctx.Tx, req.AgentID, map[string]any{ - "workflow_version_id": version.ID, - "update_user_id": operator.UserID, - "update_user_name": operator.Username, - "updated_at": now, + "workflow_version_id": version.ID, + "published_revision_id": revision.ID, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, }) }) if err != nil { @@ -434,7 +493,7 @@ func defaultAgentWorkflowDefinition() dsl.Definition { "riskSignals": dsl.RefValue("understanding_1", "riskSignals"), }, nil), workflowNode("policy_route_1", workflowregistry.NodeTypeCondition, "策略分流", 1560, 125.5, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ - workflowConditionBranch("handoff", "转人工", "handoff_1", "policy_1", "action", "eq", "handoff_to_human"), + workflowConditionBranch("handoff", "转人工", "handoff_confirm_prompt_1", "policy_1", "action", "eq", "handoff_to_human"), workflowConditionBranch("direct", "直接回复", "policy_reply_1", "policy_1", "action", "eq", "direct_reply"), workflowConditionBranch("clarify", "追问澄清", "policy_reply_1", "policy_1", "action", "eq", "clarify"), workflowConditionBranch("end_conversation", "结束语", "policy_reply_1", "policy_1", "action", "eq", "end_conversation"), @@ -442,9 +501,20 @@ func defaultAgentWorkflowDefinition() dsl.Definition { workflowConditionBranch("knowledge", "知识库回复", "retrieve_1", "policy_1", "action", "eq", "retrieve_knowledge"), {ID: "default", Name: "策略兜底", TargetNodeID: "policy_reply_1", Default: true}, }}), - workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工", 2020, 0, workflowInputs("reason", "start_1", "userMessage"), nil), + workflowNode("handoff_confirm_prompt_1", workflowregistry.NodeTypeLLMReply, "转人工确认文案", 2020, 0, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "我可以为你转接人工客服处理。请回复“确认”继续转人工,或回复“取消”继续由 AI 协助。"}), + workflowNode("handoff_confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认转人工", 2480, 0, workflowInputs("prompt", "handoff_confirm_prompt_1", "replyText"), nil), + workflowNode("handoff_confirm_route_1", workflowregistry.NodeTypeCondition, "转人工确认分流", 2940, 0, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ + workflowConditionBranch("confirmed", "已确认", "handoff_1", "handoff_confirm_1", "confirmed", "is_true", nil), + {ID: "default", Name: "取消或未确认", TargetNodeID: "handoff_cancel_reply_1", Default: true}, + }}), + workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工", 3400, 0, map[string]dsl.Value{ + "reason": dsl.RefValue("start_1", "userMessage"), + "confirmed": dsl.RefValue("handoff_confirm_1", "confirmed"), + }, nil), + workflowNode("handoff_cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消转人工提示", 3400, 480, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消转人工。你可以继续补充问题,我会继续协助。"}), + workflowNode("send_handoff_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 3860, 480, workflowInputs("replyText", "handoff_cancel_reply_1", "replyText"), nil), workflowNode("policy_reply_1", workflowregistry.NodeTypeSendReply, "发送策略回复", 4320, 98.5, workflowInputs("replyText", "policy_1", "replyText"), nil), - workflowNode("handoff_end_1", workflowregistry.NodeTypeEnd, "结束", 2480, 0, nil, nil), + workflowNode("handoff_end_1", workflowregistry.NodeTypeEnd, "结束", 3860, 0, nil, nil), workflowNode("draft_ticket_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 2020, 379, workflowInputs("issue", "start_1", "userMessage"), nil), workflowNode("ticket_draft_route_1", workflowregistry.NodeTypeCondition, "草稿就绪分流", 2480, 329, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ workflowConditionBranch("ready", "草稿完整", "ticket_confirm_prompt_1", "draft_ticket_1", "ready", "is_true", nil), @@ -497,7 +567,7 @@ func defaultAgentWorkflowDefinition() dsl.Definition { workflowEdge("start_1", "understanding_1"), workflowEdge("understanding_1", "policy_1"), workflowEdge("policy_1", "policy_route_1"), - workflowPortEdge("policy_route_1", "handoff_1", "handoff"), + workflowPortEdge("policy_route_1", "handoff_confirm_prompt_1", "handoff"), workflowPortEdge("policy_route_1", "policy_reply_1", "direct"), workflowPortEdge("policy_route_1", "policy_reply_1", "clarify"), workflowPortEdge("policy_route_1", "policy_reply_1", "end_conversation"), @@ -505,7 +575,13 @@ func defaultAgentWorkflowDefinition() dsl.Definition { workflowPortEdge("policy_route_1", "retrieve_1", "knowledge"), workflowPortEdge("policy_route_1", "policy_reply_1", "default"), workflowEdge("policy_reply_1", "end_1"), + workflowEdge("handoff_confirm_prompt_1", "handoff_confirm_1"), + workflowEdge("handoff_confirm_1", "handoff_confirm_route_1"), + workflowPortEdge("handoff_confirm_route_1", "handoff_1", "confirmed"), + workflowPortEdge("handoff_confirm_route_1", "handoff_cancel_reply_1", "default"), workflowEdge("handoff_1", "handoff_end_1"), + workflowEdge("handoff_cancel_reply_1", "send_handoff_cancel_1"), + workflowEdge("send_handoff_cancel_1", "end_1"), workflowEdge("draft_ticket_1", "ticket_draft_route_1"), workflowPortEdge("ticket_draft_route_1", "ticket_confirm_prompt_1", "ready"), workflowPortEdge("ticket_draft_route_1", "ticket_followup_reply_1", "default"), @@ -531,6 +607,62 @@ func defaultAgentWorkflowDefinition() dsl.Definition { } } +func identityConfirmationPlaybookDefinition() dsl.Definition { + return dsl.Definition{SchemaVersion: dsl.SchemaVersion, + Nodes: []dsl.Node{ + workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil), + workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, "身份确认提示", 600, 180, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "为保护你的账户信息,请确认是否继续身份核验。"}), + workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认身份核验", 1020, 180, workflowInputs("prompt", "prompt_1", "replyText"), nil), + workflowNode("route_1", workflowregistry.NodeTypeCondition, "确认分流", 1440, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ + workflowConditionBranch("confirmed", "已确认", "confirmed_reply_1", "confirm_1", "confirmed", "is_true", nil), + {ID: "default", Name: "取消", TargetNodeID: "cancel_reply_1", Default: true}, + }}), + workflowNode("confirmed_reply_1", workflowregistry.NodeTypeLLMReply, "确认结果", 1860, 100, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已收到确认,人工客服将继续为你核验身份。"}), + workflowNode("cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消提示", 1860, 280, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消身份核验。"}), + workflowNode("send_confirmed_1", workflowregistry.NodeTypeSendReply, "发送确认结果", 2280, 100, workflowInputs("replyText", "confirmed_reply_1", "replyText"), nil), + workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 2280, 280, workflowInputs("replyText", "cancel_reply_1", "replyText"), nil), + workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 2700, 180, nil, nil), + }, + Edges: []dsl.Edge{ + workflowEdge("start_1", "prompt_1"), workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "route_1"), + workflowPortEdge("route_1", "confirmed_reply_1", "confirmed"), workflowPortEdge("route_1", "cancel_reply_1", "default"), + workflowEdge("confirmed_reply_1", "send_confirmed_1"), workflowEdge("cancel_reply_1", "send_cancel_1"), workflowEdge("send_confirmed_1", "end_1"), workflowEdge("send_cancel_1", "end_1"), + }, + } +} + +func complaintEscalationPlaybookDefinition() dsl.Definition { + return confirmationHandoffPlaybookDefinition("投诉升级确认", "我们将把本次投诉升级给人工客服处理。请确认是否继续。", "已为你升级投诉,人工客服会尽快跟进。", "投诉升级已取消。") +} + +func confirmationHandoffPlaybookDefinition(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), + workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, title, 600, 180, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": prompt}), + workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认升级", 1020, 180, workflowInputs("prompt", "prompt_1", "replyText"), nil), + workflowNode("route_1", workflowregistry.NodeTypeCondition, "确认分流", 1440, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ + workflowConditionBranch("confirmed", "已确认", "handoff_1", "confirm_1", "confirmed", "is_true", nil), + {ID: "default", Name: "取消", TargetNodeID: "cancel_reply_1", Default: true}, + }}), + workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工处理", 1860, 100, map[string]dsl.Value{"reason": dsl.RefValue("start_1", "userMessage"), "confirmed": dsl.RefValue("confirm_1", "confirmed")}, nil), + workflowNode("cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消提示", 1860, 280, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": cancelledReply}), + workflowNode("send_handoff_1", workflowregistry.NodeTypeSendReply, "发送升级结果", 2280, 100, workflowInputs("replyText", "handoff_1", "message"), nil), + workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 2280, 280, workflowInputs("replyText", "cancel_reply_1", "replyText"), nil), + workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 2700, 180, nil, nil), + }, + Edges: []dsl.Edge{ + workflowEdge("start_1", "prompt_1"), workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "route_1"), + workflowPortEdge("route_1", "handoff_1", "confirmed"), workflowPortEdge("route_1", "cancel_reply_1", "default"), + workflowEdge("handoff_1", "send_handoff_1"), workflowEdge("cancel_reply_1", "send_cancel_1"), workflowEdge("send_handoff_1", "end_1"), workflowEdge("send_cancel_1", "end_1"), + }, + } +} + +func refundRequestPreparationPlaybookDefinition() dsl.Definition { + return confirmationHandoffPlaybookDefinition("退款申请确认", "我会先整理退款申请并转交人工客服核验。请确认是否继续。", "退款申请已准备完成,人工客服将继续核验订单和退款条件。", "退款申请准备已取消。") +} + func workflowNode(id string, nodeType string, title string, x float64, y float64, inputs map[string]dsl.Value, config any) dsl.Node { return dsl.Node{ ID: id, diff --git a/internal/services/ai_workflow_service_test.go b/internal/services/ai_workflow_service_test.go index 65eb78f..28bc28c 100644 --- a/internal/services/ai_workflow_service_test.go +++ b/internal/services/ai_workflow_service_test.go @@ -83,6 +83,26 @@ func TestAIWorkflowServicePublishCreatesImmutableVersion(t *testing.T) { } } +func TestAIWorkflowServicePlaybookTemplatesAreValid(t *testing.T) { + templates := AIWorkflowService.ListPlaybookTemplates() + if len(templates) != 4 { + t.Fatalf("template count = %d, want 4", len(templates)) + } + seen := make(map[string]struct{}, len(templates)) + for _, item := range templates { + if item.Code == "" || item.Name == "" { + t.Fatalf("template identity is required: %#v", item) + } + if _, exists := seen[item.Code]; exists { + t.Fatalf("duplicate template code: %s", item.Code) + } + seen[item.Code] = struct{}{} + if result := AIWorkflowService.ValidateDefinition(item.Definition); !result.Valid { + t.Fatalf("template %s is invalid: %#v", item.Code, result.Errors) + } + } +} + func TestAIWorkflowServicePublishIncrementsVersion(t *testing.T) { setupAIWorkflowTestDB(t) operator := aiWorkflowTestOperator() diff --git a/internal/services/business_tool_executor.go b/internal/services/business_tool_executor.go new file mode 100644 index 0000000..473ae3d --- /dev/null +++ b/internal/services/business_tool_executor.go @@ -0,0 +1,120 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + aitooling "agent-desk/internal/ai/tooling" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/toolx" +) + +// 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. +var BusinessToolExecutor = newBusinessToolExecutor(aitooling.DefaultRegistry) + +type BusinessToolInput struct { + Conversation models.Conversation + AIAgent models.AIAgent + ToolCode string + Arguments map[string]any + IdempotencyKey string + Confirmed bool +} + +type BusinessToolResult struct { + Definition aitooling.Definition + ResultData string + Reused bool +} + +type businessToolExecutor struct { + registry *aitooling.Registry +} + +func newBusinessToolExecutor(registry *aitooling.Registry) *businessToolExecutor { + return &businessToolExecutor{registry: registry} +} + +func (e *businessToolExecutor) Execute(_ context.Context, input BusinessToolInput) (*BusinessToolResult, error) { + toolCode := toolx.NormalizeToolCodeAlias(strings.TrimSpace(input.ToolCode)) + definition, err := e.registry.Resolve(toolCode) + if err != nil { + return nil, err + } + if err := e.registry.Authorize(definition, aitooling.Policy{AllowedToolCodes: []string{definition.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelWrite}, Confirmed: input.Confirmed}); err != nil { + return nil, err + } + if input.Conversation.ID <= 0 || strings.TrimSpace(input.IdempotencyKey) == "" { + return nil, fmt.Errorf("business tool invocation requires conversation and idempotency key") + } + claim, err := AgentToolInvocationService.Claim(input.Conversation.ID, input.AIAgent.ID, definition.Code, input.IdempotencyKey) + if err != nil { + return nil, err + } + if claim == nil || claim.Item == nil { + return nil, fmt.Errorf("business tool invocation could not be claimed") + } + if claim.Completed { + return &BusinessToolResult{Definition: definition, ResultData: claim.Item.ResultData, Reused: true}, nil + } + if !claim.Acquired { + return nil, fmt.Errorf("business tool invocation is already running: %s", definition.Code) + } + + resultData, err := e.execute(definition.Code, input) + if err != nil { + _ = AgentToolInvocationService.Fail(claim.Item, err) + return nil, err + } + if err := AgentToolInvocationService.Complete(claim.Item, resultData); err != nil { + return nil, err + } + return &BusinessToolResult{Definition: definition, ResultData: resultData}, nil +} + +func (e *businessToolExecutor) execute(toolCode string, input BusinessToolInput) (string, error) { + switch toolCode { + case toolx.GraphCreateTicketConfirm.Code: + item, err := TicketService.CreateFromConversation(request.CreateTicketFromConversationRequest{ + ConversationID: input.Conversation.ID, + Title: businessToolString(input.Arguments["title"]), + Description: businessToolString(input.Arguments["description"]), + }, businessToolPrincipal(input.AIAgent)) + if err != nil { + return "", err + } + return businessToolJSON(map[string]any{"ticketId": item.ID, "ticketNo": item.TicketNo, "created": true}) + case toolx.GraphHandoffConversation.Code: + result, err := ConversationHumanDispatchService.HandoffByAIWithRequestID(input.Conversation.ID, input.AIAgent, businessToolString(input.Arguments["reason"]), input.IdempotencyKey) + if err != nil { + return "", err + } + return businessToolJSON(map[string]any{"decision": result.Decision, "teamId": result.TeamID, "assigneeId": result.AssigneeID, "message": result.Message}) + default: + return "", fmt.Errorf("business tool is not executable: %s", toolCode) + } +} + +func businessToolString(value any) string { + text, _ := value.(string) + return strings.TrimSpace(text) +} + +func businessToolPrincipal(agent models.AIAgent) *dto.AuthPrincipal { + name := strings.TrimSpace(agent.Name) + if name == "" { + name = "AI" + } + return &dto.AuthPrincipal{Username: name, Nickname: name} +} + +func businessToolJSON(value any) (string, error) { + data, err := json.Marshal(value) + return string(data), err +} diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go index 630eac5..c71743f 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -105,17 +105,49 @@ func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operato if err != nil { return err } - return repositories.ChannelRepository.Updates(sqls.DB(), req.ID, map[string]any{ - "channel_type": item.ChannelType, - "channel_id": item.ChannelID, - "ai_agent_id": item.AIAgentID, - "name": item.Name, - "config_json": item.ConfigJSON, - "status": item.Status, - "remark": item.Remark, - "update_user_id": operator.UserID, - "update_user_name": operator.Username, - "updated_at": time.Now(), + columns := map[string]any{ + "channel_type": item.ChannelType, + "channel_id": item.ChannelID, + "ai_agent_id": item.AIAgentID, + "ai_agent_rollout_percent": item.AIAgentRolloutPercent, + "name": item.Name, + "config_json": item.ConfigJSON, + "status": item.Status, + "remark": item.Remark, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + } + if item.AIAgentRolloutPercent != current.AIAgentRolloutPercent { + columns["previous_ai_agent_rollout_percent"] = current.AIAgentRolloutPercent + } + return repositories.ChannelRepository.Updates(sqls.DB(), req.ID, columns) +} + +// RollbackChannelAIAgentRollout restores the last channel-level rollout value +// and swaps it into history so the action itself is reversible. +func (s *channelService) RollbackChannelAIAgentRollout(id int64, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.UnauthorizedI18n("error.auth.expired") + } + if id <= 0 { + return errorsx.InvalidParam("channel id is required") + } + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + channel := repositories.ChannelRepository.Get(ctx.Tx, id) + if channel == nil || channel.Status == enums.StatusDeleted { + return errorsx.InvalidParamI18n("error.e0208") + } + if channel.PreviousAIAgentRolloutPercent < 1 || channel.PreviousAIAgentRolloutPercent > 100 { + return errorsx.InvalidParam("channel rollout has no previous value to restore") + } + return repositories.ChannelRepository.Updates(ctx.Tx, channel.ID, map[string]any{ + "ai_agent_rollout_percent": channel.PreviousAIAgentRolloutPercent, + "previous_ai_agent_rollout_percent": channel.AIAgentRolloutPercent, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": time.Now(), + }) }) } @@ -394,12 +426,30 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe if req.AIAgentID <= 0 { return nil, errorsx.InvalidParamI18n("error.e0321") } + if req.AIAgentRolloutPercent == 0 { + req.AIAgentRolloutPercent = 100 + } + if req.AIAgentRolloutPercent < 1 || req.AIAgentRolloutPercent > 100 { + return nil, errorsx.InvalidParam("channel ai agent rollout percent must be between 1 and 100") + } aiAgent := AIAgentService.Get(req.AIAgentID) if aiAgent == nil || aiAgent.Status != enums.StatusOk { return nil, errorsx.InvalidParamI18n("error.e0004") } - if aiAgent.WorkflowVersionID <= 0 { - return nil, errorsx.InvalidParam("ai agent workflow must be published before binding channel") + if aiAgent.RuntimeMode == "" || aiAgent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow { + if aiAgent.WorkflowVersionID <= 0 { + 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 || aiAgent.WorkflowVersionID <= 0 { + return nil, errorsx.InvalidParam("hybrid ai agent and workflow must be published before binding channel") + } + } else { + return nil, errorsx.InvalidParam("ai agent runtime mode is not available yet") } status := enums.Status(req.Status) if req.Status == 0 { @@ -485,12 +535,13 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe } return &models.Channel{ - ChannelType: channelType, - ChannelID: channelID, - AIAgentID: req.AIAgentID, - Name: name, - ConfigJSON: configJSON, - Status: status, - Remark: strings.TrimSpace(req.Remark), + ChannelType: channelType, + ChannelID: channelID, + AIAgentID: req.AIAgentID, + AIAgentRolloutPercent: req.AIAgentRolloutPercent, + Name: name, + ConfigJSON: configJSON, + Status: status, + Remark: strings.TrimSpace(req.Remark), }, nil } diff --git a/internal/services/channel_service_test.go b/internal/services/channel_service_test.go index 2ade90d..fca0b5e 100644 --- a/internal/services/channel_service_test.go +++ b/internal/services/channel_service_test.go @@ -48,6 +48,123 @@ func TestChannelServiceAllowsAgentWithPublishedWorkflow(t *testing.T) { } } +func TestChannelServiceStoresAIAgentRolloutPercent(t *testing.T) { + db := setupChannelServiceTestDB(t) + agent := createChannelServiceTestAgent(t, db, 1001) + item, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, AIAgentRolloutPercent: 25, + Name: "灰度渠道", Status: int(enums.StatusOk), + }, channelServiceTestOperator()) + if err != nil || item == nil || item.AIAgentRolloutPercent != 25 { + t.Fatalf("expected persisted rollout percent, item=%#v err=%v", item, err) + } + if _, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, AIAgentRolloutPercent: 101, + Name: "错误灰度渠道", Status: int(enums.StatusOk), + }, channelServiceTestOperator()); err == nil { + t.Fatal("expected invalid rollout percent to be rejected") + } +} + +func TestChannelServiceRollsBackPreviousAIAgentRolloutPercent(t *testing.T) { + db := setupChannelServiceTestDB(t) + agent := createChannelServiceTestAgent(t, db, 1001) + channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, AIAgentRolloutPercent: 20, + Name: "渠道灰度回滚", Status: int(enums.StatusOk), + }, channelServiceTestOperator()) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if err := db.Model(&models.Channel{}).Where("id = ?", channel.ID).Update("previous_ai_agent_rollout_percent", 100).Error; err != nil { + t.Fatalf("set previous rollout: %v", err) + } + operator := channelServiceTestOperator() + if err := ChannelService.RollbackChannelAIAgentRollout(channel.ID, operator); err != nil { + t.Fatalf("RollbackChannelAIAgentRollout: %v", err) + } + updated := ChannelService.Get(channel.ID) + if updated == nil || updated.AIAgentRolloutPercent != 100 || updated.PreviousAIAgentRolloutPercent != 20 { + t.Fatalf("unexpected channel rollout rollback: %#v", updated) + } + if err := ChannelService.RollbackChannelAIAgentRollout(channel.ID, operator); err != nil { + t.Fatalf("second RollbackChannelAIAgentRollout: %v", err) + } + updated = ChannelService.Get(channel.ID) + if updated == nil || updated.AIAgentRolloutPercent != 20 || updated.PreviousAIAgentRolloutPercent != 100 { + t.Fatalf("unexpected channel rollout redo: %#v", updated) + } +} + +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()) @@ -66,7 +183,7 @@ func setupChannelServiceTestDB(t *testing.T) *gorm.DB { _ = sqlDB.Close() } }) - if err := db.AutoMigrate(&models.AIAgent{}, &models.Channel{}); err != nil { + if err := db.AutoMigrate(&models.AIAgent{}, &models.AgentRevision{}, &models.Channel{}); err != nil { t.Fatalf("auto migrate: %v", err) } sqls.SetDB(db) diff --git a/internal/services/conversation_interrupt_service.go b/internal/services/conversation_interrupt_service.go index 6c806d3..244f274 100644 --- a/internal/services/conversation_interrupt_service.go +++ b/internal/services/conversation_interrupt_service.go @@ -94,6 +94,8 @@ func (s *conversationInterruptService) mergeForCheckpointUpdate(current, next *m merged := *current merged.ConversationID = current.ConversationID merged.AIAgentID = current.AIAgentID + merged.AgentRunID = current.AgentRunID + merged.AgentStepID = current.AgentStepID merged.SourceMessageID = current.SourceMessageID merged.LastResumeMessageID = current.LastResumeMessageID merged.WorkflowRunID = current.WorkflowRunID @@ -120,6 +122,8 @@ func (s *conversationInterruptService) mergeForPendingUpdate(current, next *mode merged := *current merged.ConversationID = next.ConversationID merged.AIAgentID = next.AIAgentID + merged.AgentRunID = next.AgentRunID + merged.AgentStepID = next.AgentStepID merged.SourceMessageID = next.SourceMessageID merged.WorkflowRunID = next.WorkflowRunID merged.WorkflowNodeID = next.WorkflowNodeID diff --git a/web/app/dashboard/agent-runs/page.tsx b/web/app/dashboard/agent-runs/page.tsx new file mode 100644 index 0000000..7edc3ad --- /dev/null +++ b/web/app/dashboard/agent-runs/page.tsx @@ -0,0 +1,167 @@ +"use client" + +import { useEffect, useState } from "react" +import { AlertTriangleIcon, BotMessageSquareIcon, Clock3Icon, WorkflowIcon, WrenchIcon } from "lucide-react" +import { toast } from "sonner" + +import { DashboardListPage } from "@/components/dashboard/list" +import { JsonTreeViewer } from "@/components/json-tree-viewer" +import { OptionCombobox } from "@/components/option-combobox" +import { ProjectDialog } from "@/components/project-dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { fetchAgentRun, fetchAgentRunMetrics, fetchAgentRuns, fetchAIWorkflowRun, fetchAgentRunEngineComparisons, saveAgentRunQualityFeedback, type AgentRun, type AgentRunEngineComparison, type AgentRunMetrics, type AgentStep, type AgentToolCall, type AIWorkflowRun } from "@/lib/api/admin" +import { formatDateTime } from "@/lib/utils" +import { useI18n } from "@/i18n/provider" +import { WorkflowRunAuditGraph } from "../ai-workflow-runs/_components/workflow-run-audit-graph" + +function statusVariant(status: string) { + if (status === "failed") return "destructive" as const + if (status === "interrupted") return "outline" as const + if (status === "completed") return "default" as const + return "secondary" as const +} + +export default function DashboardAgentRunsPage() { + const t = useI18n() + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [run, setRun] = useState(null) + const [workflowAuditOpen, setWorkflowAuditOpen] = useState(false) + const [workflowAuditLoading, setWorkflowAuditLoading] = useState(false) + const [workflowRun, setWorkflowRun] = useState(null) + const [metrics, setMetrics] = useState(null) + const [comparisons, setComparisons] = useState([]) + + useEffect(() => { + void fetchAgentRunMetrics().then(setMetrics).catch(() => setMetrics(null)) + void fetchAgentRunEngineComparisons().then(setComparisons).catch(() => setComparisons([])) + }, []) + + async function openDetail(id: number) { + setOpen(true) + setLoading(true) + try { + setRun(await fetchAgentRun(id)) + } catch (error) { + toast.error(error instanceof Error ? error.message : t("agentRun.loadDetailFailed")) + setOpen(false) + } finally { + setLoading(false) + } + } + + async function openWorkflowAudit(id: number) { + if (id <= 0) return + setWorkflowAuditOpen(true) + setWorkflowAuditLoading(true) + try { + setWorkflowRun(await fetchAIWorkflowRun(id)) + } catch (error) { + toast.error(error instanceof Error ? error.message : "加载 Workflow 节点审计失败") + setWorkflowAuditOpen(false) + } finally { + setWorkflowAuditLoading(false) + } + } + + return ( + <> + {metrics ?
+ + + + + + + + + + +
: null} + {comparisons.length > 0 ?
运行模式对比
{comparisons.map((item) => )}
模式运行完成率解决率无依据率工具成功率P95Token
{item.engineCode}{item.metrics.totalRuns}{Math.round(item.metrics.completionRate * 100)}%{item.metrics.reviewedRuns ? `${Math.round(item.metrics.resolutionRate * 100)}%` : "-"}{item.metrics.reviewedRuns ? `${Math.round(item.metrics.unsupportedEvidenceRate * 100)}%` : "-"}{item.metrics.toolCalls ? `${Math.round(item.metrics.toolSuccessRate * 100)}%` : "-"}{item.metrics.p95DurationMs} ms{item.metrics.promptTokens + item.metrics.completionTokens}
: null} + + filters={[ + { name: "conversationId", label: t("agentRun.conversation"), defaultValue: "", valueType: "number", className: "w-full sm:w-40" }, + { name: "aiAgentId", label: t("agentRun.agent"), defaultValue: "", valueType: "number", className: "w-full sm:w-40" }, + { name: "engineCode", label: t("agentRun.engine"), defaultValue: "", className: "w-full sm:w-40" }, + { name: "status", label: t("agentRun.status"), defaultValue: "", className: "w-full sm:w-40" }, + ]} + fetchList={fetchAgentRuns} + getItemId={(item) => item.id} + getRowClassName={() => "cursor-pointer"} + onRowClick={(item) => void openDetail(item.id)} + columns={[ + { key: "startedAt", label: t("agentRun.startedAt"), className: "w-42 text-xs text-muted-foreground", render: (item) => formatDateTime(item.startedAt || item.createdAt) }, + { key: "engine", label: t("agentRun.engine"), className: "w-32", render: (item) => item.engineCode || "-" }, + { key: "agent", label: t("agentRun.agent"), className: "w-28", render: (item) => `#${item.aiAgentId || "-"}` }, + { key: "conversation", label: t("agentRun.conversation"), className: "w-28", render: (item) => `#${item.conversationId || "-"}` }, + { key: "status", label: t("agentRun.status"), className: "w-30", render: (item) => {item.status || "-"} }, + { key: "duration", label: t("agentRun.duration"), className: "w-24 text-right", render: (item) => `${item.durationMs || 0} ms` }, + { key: "tokens", label: t("agentRun.tokens"), className: "w-28 text-right", render: (item) => `${item.promptTokens || 0}/${item.completionTokens || 0}` }, + { key: "error", label: t("agentRun.error"), className: "w-72 max-w-72", render: (item) => item.errorMessage ? {item.errorMessage} : "-" }, + ]} + labels={{ refresh: t("agentRun.refresh"), query: t("agentRun.query"), loading: t("agentRun.loading"), empty: t("agentRun.empty"), loadFailed: t("agentRun.loadFailed") }} + /> + void openDetail(id)} onOpenChange={(next) => { setOpen(next); if (!next) setRun(null) }} t={t} /> + { setWorkflowAuditOpen(next); if (!next) setWorkflowRun(null) }} /> + + ) +} + +function Metric({ label, value, detail }: { label: string; value: string; detail: string }) { return
{label}
{value}
{detail}
} + +function AgentRunDetailDialog({ open, loading, run, onOpenChange, onOpenWorkflowAudit, onQualityFeedbackSaved, t }: { open: boolean; loading: boolean; run: AgentRun | null; onOpenChange: (open: boolean) => void; onOpenWorkflowAudit: (workflowRunId: number) => void; onQualityFeedbackSaved: (agentRunId: number) => void; t: (key: string) => string }) { + return {t("agentRun.detailTitle")}} description={run ? `Run #${run.id}` : t("agentRun.detailDescription")} footer={}> + {loading ?
{t("agentRun.loadingDetail")}
: run ?
+
+ {run.workflowRunId > 0 ?
关联 Playbook 审计
Workflow Run #{run.workflowRunId} 的节点输入、输出和状态
: null} + + {run.errorMessage ?
{run.errorMessage}
: null} + +

{t("agentRun.steps")}

{(run.steps ?? []).map((step) => )}{!run.steps?.length ?

{t("agentRun.emptySteps")}

: null}
+

{t("agentRun.toolCalls")}

{(run.toolCalls ?? []).map((call) => )}{!run.toolCalls?.length ?

{t("agentRun.emptyToolCalls")}

: null}
+
:
{t("agentRun.notFound")}
} +
+} + +function QualityFeedbackPanel({ run, onSaved }: { run: AgentRun; onSaved: (agentRunId: number) => void }) { + const [resolutionStatus, setResolutionStatus] = useState<"unknown" | "resolved" | "unresolved">("unknown") + const [evidenceStatus, setEvidenceStatus] = useState<"unknown" | "supported" | "unsupported">("unknown") + const [comment, setComment] = useState("") + const [saving, setSaving] = useState(false) + + useEffect(() => { + setResolutionStatus(run.qualityFeedback?.resolutionStatus ?? "unknown") + setEvidenceStatus(run.qualityFeedback?.evidenceStatus ?? "unknown") + setComment(run.qualityFeedback?.comment ?? "") + }, [run.id, run.qualityFeedback]) + + async function save() { + setSaving(true) + try { + await saveAgentRunQualityFeedback({ agentRunId: run.id, resolutionStatus, evidenceStatus, comment }) + toast.success("质检结果已保存") + onSaved(run.id) + } catch (error) { + toast.error(error instanceof Error ? error.message : "保存质检结果失败") + } finally { + setSaving(false) + } + } + + return

运行质检

解决率和无依据率仅统计已质检记录。

setResolutionStatus(value === "resolved" || value === "unresolved" ? value : "unknown")} /> setEvidenceStatus(value === "supported" || value === "unsupported" ? value : "unknown")} />