diff --git a/cmd/generator/generator.go b/cmd/generator/generator.go index 5abaedc..283ad19 100644 --- a/cmd/generator/generator.go +++ b/cmd/generator/generator.go @@ -57,7 +57,6 @@ func main() { codegen.GetGenerateStruct(&models.AgentTeamSchedule{}), codegen.GetGenerateStruct(&models.AIConfig{}), codegen.GetGenerateStruct(&models.SkillDefinition{}), - codegen.GetGenerateStruct(&models.SkillRunLog{}), codegen.GetGenerateStruct(&models.SystemConfig{}), ) diff --git a/docs b/docs index 5081401..d343eaf 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 50814016a3a840f8fa034d209015b0f26969d957 +Subproject commit d343eaf6482614b0a8d458ed85087c5a85c03832 diff --git a/internal/ai/application/runtime/agent_loop_engine.go b/internal/ai/application/runtime/agent_loop_engine.go new file mode 100644 index 0000000..00c5920 --- /dev/null +++ b/internal/ai/application/runtime/agent_loop_engine.go @@ -0,0 +1,826 @@ +package runtime + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "time" + + ai "agent-desk/internal/ai" + "agent-desk/internal/ai/runtime/instruction" + "agent-desk/internal/ai/runtime/readtools" + "agent-desk/internal/ai/runtime/retrievers" + runtimetooling "agent-desk/internal/ai/runtime/tooling" + workflowexecutor "agent-desk/internal/ai/runtime/workflow" + aitooling "agent-desk/internal/ai/tooling" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + "agent-desk/internal/pkg/toolx" + "agent-desk/internal/pkg/utils" + svc "agent-desk/internal/services" + + "github.com/mlogclub/simple/sqls" +) + +// AgentLoopEngine is the only Agent runtime. The model chooses among the +// Agent's published Skills, Workflows, knowledge capabilities, and MCP tools. +type AgentLoopEngine struct { + history func(int64, int) []models.Message + retrieve func(context.Context, models.AIAgent, string) (string, int, error) + loop func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error) +} + +func NewAgentLoopEngine() *AgentLoopEngine { + return &AgentLoopEngine{ + history: func(conversationID int64, limit int) []models.Message { + items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, limit, "", "") + return items + }, + retrieve: retrieveAgentLoopKnowledge, + loop: einoAgentLoop, + } +} + +func newAgentLoopEngineWithLoop(loop func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error)) *AgentLoopEngine { + engine := NewAgentLoopEngine() + engine.loop = loop + return engine +} + +func (e *AgentLoopEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) { + startedAt := time.Now() + req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content) + snapshot, err := svc.AgentRevisionService.ResolvePublishedSnapshot(req.AIAgent, req.AIConfig) + if err != nil { + _, _ = writeAgentLoopRun(req, startedAt, nil, "", 0, 0, nil, agentLoopSkillContext{}, agentLoopResponsePolicy{}, nil, err, false, nil) + return nil, err + } + req.AIAgent = snapshot.Agent + req.AIConfig = snapshot.AIConfig + turn := e.prepareTurn(ctx, req, snapshot) + var toolCalls []svc.AgentLoopToolCallInput + state := agentLoopExecutionState{} + loopResult, loopErr := e.loop(ctx, req.AIConfig, turn.SystemPrompt, turn.UserPrompt, []ai.ToolDefinition{agentLoopToolSearchDefinition()}, req.AIAgent.MaxSteps, + e.toolSearchExecutor(req, turn, &state, &toolCalls)) + if state.Interrupted != nil { + result := state.Interrupted + runID, recordErr := writeAgentLoopRun(req, startedAt, &ai.ChatCompletionResult{Content: result.ReplyText, ModelName: req.AIConfig.ModelName}, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, state.SkillContext, turn.ResponsePolicy, toolCalls, nil, true, state.WorkflowSteps) + if recordErr != nil { + return nil, recordErr + } + result.AgentRunID = runID + return result, nil + } + if loopErr != nil { + _, _ = writeAgentLoopRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, state.SkillContext, turn.ResponsePolicy, toolCalls, loopErr, false, state.WorkflowSteps) + return nil, loopErr + } + if loopResult == nil { + err = errorsx.InvalidParam("agent loop returned an empty result") + _, _ = writeAgentLoopRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, state.SkillContext, turn.ResponsePolicy, toolCalls, err, false, state.WorkflowSteps) + return nil, err + } + result := &loopResult.ChatCompletionResult + if strings.TrimSpace(result.Content) == "" { + err = errorsx.InvalidParam("Agent Loop returned an empty reply") + _, _ = writeAgentLoopRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, state.SkillContext, turn.ResponsePolicy, toolCalls, err, false, state.WorkflowSteps) + return nil, err + } + result.Content, err = aitooling.NormalizeCustomerReply(result.Content) + if err != nil { + _, _ = writeAgentLoopRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, state.SkillContext, turn.ResponsePolicy, toolCalls, err, false, state.WorkflowSteps) + return nil, err + } + runID, recordErr := writeAgentLoopRun(req, startedAt, result, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, state.SkillContext, turn.ResponsePolicy, toolCalls, nil, false, state.WorkflowSteps) + if recordErr != nil { + return nil, recordErr + } + trace, _ := json.Marshal(map[string]any{ + "runtime": "agent-loop", + "historyMessageCount": turn.HistoryCount, + "retrieverCount": turn.RetrieverCount, + "skillID": state.SkillContext.SkillID(), + "responsePolicyAction": turn.ResponsePolicy.Action, + "responsePolicyReason": turn.ResponsePolicy.Reason, + "debug": req.Debug, + }) + return &RunResult{ + Status: "completed", + ReplyText: strings.TrimSpace(result.Content), + ModelName: result.ModelName, + PromptTokens: result.PromptTokens, + CompletionTokens: result.CompletionTokens, + HistoryMessageCount: turn.HistoryCount, + RetrieverCount: turn.RetrieverCount, + PlannedSkillID: state.SkillContext.SkillID(), + PlannedSkillName: state.SkillContext.SkillName(), + SkillAllowedToolCodes: append([]string(nil), state.SkillContext.AllowedToolCodes...), + ToolCallCount: len(toolCalls), + InvokedToolCodes: agentLoopInvokedToolCodes(toolCalls), + WorkflowRunID: state.WorkflowRunID, + AgentRunID: runID, + HandoffRequested: turn.ResponsePolicy.RequestHandoff && !req.Debug, + TraceData: string(trace), + }, nil +} + +func (e *AgentLoopEngine) buildUserPrompt(req RunInput) (string, int) { + limit := req.AIAgent.ContextWindow + if limit <= 0 { + limit = 12 + } + if limit > 20 { + limit = 20 + } + items := []models.Message(nil) + if e.history != nil && req.Conversation.ID > 0 { + // The triggering customer message is already persisted in most reply + // paths. Fetch one extra item so it does not consume history capacity. + items = e.history(req.Conversation.ID, limit+1) + } + lines := make([]string, 0, len(items)+2) + for _, item := range items { + if item.ID == req.UserMessage.ID || strings.TrimSpace(item.Content) == "" { + continue + } + role := agentLoopMessageRole(item) + if role == "" { + continue + } + lines = append(lines, role+": "+utils.BuildRuntimeMessageText(item.MessageType, item.Content)) + } + if len(lines) > limit { + lines = lines[len(lines)-limit:] + } + current := strings.TrimSpace(req.UserMessage.Content) + customerContext := buildAgentLoopCustomerContext(req.Conversation) + if len(lines) == 0 && customerContext == "" { + return current, 0 + } + parts := make([]string, 0, 3) + if customerContext != "" { + parts = append(parts, "Customer context:\n"+customerContext) + } + if len(lines) > 0 { + parts = append(parts, "Conversation history:\n"+strings.Join(lines, "\n")) + } + parts = append(parts, "Current customer message:\n"+current) + return strings.Join(parts, "\n\n"), len(lines) +} + +func buildAgentLoopCustomerContext(conversation models.Conversation) string { + parts := make([]string, 0, 2) + if name := strings.TrimSpace(conversation.CustomerName); name != "" { + parts = append(parts, "Customer: "+name) + } + if summary := strings.TrimSpace(conversation.LastMessageSummary); summary != "" { + parts = append(parts, "Recent summary: "+summary) + } + return strings.Join(parts, "\n") +} + +func agentLoopMessageRole(message models.Message) string { + switch message.SenderType { + case "customer": + return "Customer" + case "ai", "agent": + return "Assistant" + default: + return "" + } +} + +func (e *AgentLoopEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) { + interrupt := svc.ConversationInterruptService.GetByCheckPointID(req.CheckPointID) + if interrupt == nil || strings.TrimSpace(interrupt.RequestData) == "" { + return nil, errorsx.InvalidParam("Agent Loop checkpoint does not exist") + } + snapshot, err := svc.AgentRevisionService.ResolvePublishedSnapshot(req.AIAgent, req.AIConfig) + if err != nil { + return nil, err + } + req.AIAgent, req.AIConfig = snapshot.Agent, snapshot.AIConfig + if interrupt.WorkflowRunID > 0 { + workflowRun, _ := svc.AIWorkflowService.GetRunDetail(interrupt.WorkflowRunID) + if workflowRun == nil { + return nil, errorsx.InvalidParam("Workflow run does not exist") + } + workflow, err := resolveWorkflowVersion(workflowRun.WorkflowVersionID) + if err != nil { + return nil, err + } + result, err := workflowexecutor.NewExecutor().Resume(ctx, workflowexecutor.Input{ + Definition: workflow.Definition, Conversation: req.Conversation, UserMessage: req.UserMessage, + AIAgent: req.AIAgent, AIConfig: req.AIConfig, Debug: req.Debug, + }, interrupt.RequestData, firstAgentLoopResumeText(req.ResumeData)) + if result != nil { + if _, persistErr := writeWorkflowRunWithExistingID(RunInput{ + Conversation: req.Conversation, UserMessage: req.UserMessage, AIAgent: req.AIAgent, AIConfig: req.AIConfig, Debug: req.Debug, + }, workflow, result, errorString(err), interrupt.WorkflowRunID); persistErr != nil { + return nil, persistErr + } + } + if err != nil { + return nil, err + } + ret := toWorkflowResult(result, req.AIConfig.ModelName, workflow, interrupt.WorkflowRunID) + ret.AgentRunID = interrupt.AgentRunID + if err := recordAgentLoopResume(interrupt.AgentRunID, interrupt.WorkflowRunID, ret.Status, ret.ReplyText, nil); err != nil { + return nil, err + } + return ret, nil + } + var checkpoint agentLoopMCPCheckpoint + if err := json.Unmarshal([]byte(interrupt.RequestData), &checkpoint); err != nil { + return nil, errorsx.InvalidParam("invalid MCP checkpoint data") + } + if !isAgentLoopConfirmation(firstAgentLoopResumeText(req.ResumeData)) { + ret := &RunResult{ + Status: "completed", ReplyText: "操作已取消。", ModelName: req.AIConfig.ModelName, + AgentRunID: interrupt.AgentRunID, + } + return ret, recordAgentLoopResume(interrupt.AgentRunID, 0, ret.Status, ret.ReplyText, nil) + } + tool, err := configuredMCPTool(req.AIAgent.AllowedMCPTools, checkpoint.ToolCode) + if err != nil { + return nil, err + } + policy := parseAgentLoopToolPolicy(req.AIAgent.ToolPolicy) + executionPolicy := aitooling.Policy{ + AllowedToolCodes: []string{checkpoint.ToolCode}, AllowedRiskLevels: policy.AllowedRiskLevels, + MaxTotalCalls: 1, MaxArgumentBytes: policy.MaxArgumentBytes, Confirmed: true, + } + definition := aitooling.Definition{ + Code: checkpoint.ToolCode, Name: tool.Title, RiskLevel: tool.RiskLevel, RequireConfirmation: tool.RequireConfirmation, + } + if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{ + Definition: definition, Arguments: checkpoint.Arguments, Policy: executionPolicy, + }); err != nil { + return nil, err + } + startedAt := time.Now() + _, result, err := aitooling.DefaultMCPExecutor.Execute(ctx, checkpoint.ToolCode, checkpoint.Arguments, executionPolicy) + if err != nil { + return nil, err + } + argumentsJSON, _ := json.Marshal(checkpoint.Arguments) + toolCall := &svc.AgentLoopToolCallInput{ + ToolCode: checkpoint.ToolCode, RiskLevel: aitooling.RiskLevelWrite, RequireConfirm: true, Status: "completed", + ArgumentsPreview: aitooling.SanitizePreview(string(argumentsJSON)), + ResultPreview: runtimetooling.BuildReducedToolResultSummary(result), + DurationMS: int(time.Since(startedAt).Milliseconds()), + } + ret := &RunResult{ + Status: "completed", ReplyText: "操作已执行:" + toolCall.ResultPreview, + ModelName: req.AIConfig.ModelName, AgentRunID: interrupt.AgentRunID, ToolCallCount: 1, + InvokedToolCodes: []string{tool.ToolCode}, + } + return ret, recordAgentLoopResume(interrupt.AgentRunID, 0, ret.Status, ret.ReplyText, toolCall) +} + +func recordAgentLoopResume(agentRunID, workflowRunID int64, status, replyText string, toolCall *svc.AgentLoopToolCallInput) error { + return sqls.WithTransaction(func(tx *sqls.TxContext) error { + return svc.AgentRunService.RecordResume(tx.Tx, agentRunID, workflowRunID, status, replyText, toolCall) + }) +} + +func firstAgentLoopResumeText(data map[string]string) string { + for _, value := range data { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} + +func isAgentLoopConfirmation(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "确认", "确认执行", "同意", "继续", "是", "yes", "y", "confirm", "approve", "approved": + return true + default: + return false + } +} + +func (e *AgentLoopEngine) retrieveKnowledge(ctx context.Context, agent models.AIAgent, query string) (string, int, error) { + if e.retrieve == nil || len(utils.SplitInt64s(agent.KnowledgeIDs)) == 0 { + return "", 0, nil + } + return e.retrieve(ctx, agent, query) +} + +type agentLoopSkillContext struct { + Skill *models.SkillDefinition + AllowedToolCodes []string +} + +type agentLoopResponsePolicy struct { + Action string + Reason string + RequestHandoff bool +} + +func evaluateAgentLoopResponsePolicy(agent models.AIAgent, knowledgeContext string, retrieveErr error) agentLoopResponsePolicy { + if len(utils.SplitInt64s(agent.KnowledgeIDs)) == 0 || strings.TrimSpace(knowledgeContext) != "" && retrieveErr == nil { + return agentLoopResponsePolicy{} + } + if retrieveErr != nil { + return agentLoopKnowledgeFallbackPolicy(agent, "retrieval_unavailable", "knowledge_retrieve_error") + } + // Knowledge retrieval is an evidence signal, not a replacement for the + // model's ability to handle greetings and other non-factual conversation. + return agentLoopKnowledgeFallbackPolicy(agent, "evidence_required", "knowledge_evidence_missing") +} + +func agentLoopKnowledgeFallbackPolicy(agent models.AIAgent, action, reason string) agentLoopResponsePolicy { + return agentLoopResponsePolicy{ + Action: action, Reason: reason, + RequestHandoff: agent.FallbackMode == enums.AIAgentFallbackModeHandoff, + } +} + +func (c agentLoopSkillContext) SkillID() int64 { + if c.Skill == nil { + return 0 + } + return c.Skill.ID +} + +func (c agentLoopSkillContext) SkillName() string { + if c.Skill == nil { + return "" + } + return strings.TrimSpace(c.Skill.Name) +} + +func parseSkillToolWhitelist(raw string) []string { + var items []string + if json.Unmarshal([]byte(strings.TrimSpace(raw)), &items) != nil { + return nil + } + ret := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + item = toolx.NormalizeToolCodeAlias(strings.TrimSpace(item)) + if item == "" { + continue + } + if _, exists := seen[item]; exists { + continue + } + seen[item] = struct{}{} + ret = append(ret, item) + } + return ret +} + +type agentLoopToolSearchRequest struct { + ToolCode string `json:"toolCode"` + Arguments map[string]any `json:"arguments"` +} + +type agentLoopToolPolicy struct { + MaxTotalCalls int `json:"maxTotalCalls"` + MaxArgumentBytes int `json:"maxArgumentBytes"` + AllowedRiskLevels []string `json:"allowedRiskLevels"` +} + +func parseAgentLoopToolPolicy(raw string) agentLoopToolPolicy { + policy := agentLoopToolPolicy{MaxTotalCalls: 3, MaxArgumentBytes: 32 * 1024} + if json.Unmarshal([]byte(strings.TrimSpace(raw)), &policy) != nil { + return policy + } + if policy.MaxTotalCalls <= 0 || policy.MaxTotalCalls > 8 { + policy.MaxTotalCalls = 3 + } + if policy.MaxArgumentBytes <= 0 || policy.MaxArgumentBytes > 64*1024 { + policy.MaxArgumentBytes = 32 * 1024 + } + return policy +} + +func agentLoopToolSearchDefinition() ai.ToolDefinition { + return ai.ToolDefinition{ + Name: "tool_search", + Description: "Activate a configured Skill or execute a configured Workflow, builtin capability, or MCP tool. Pass the exact capability code and arguments.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "toolCode": map[string]any{"type": "string"}, + "arguments": map[string]any{"type": "object"}, + }, + "required": []string{"toolCode", "arguments"}, + }, + } +} + +func agentLoopSafeBuiltinCodes() []string { + return []string{ + toolx.BuiltinConversationContext.Code, + toolx.BuiltinKnowledgeRetrieve.Code, + toolx.GraphTriageServiceRequest.Code, + toolx.GraphAnalyzeConversation.Code, + toolx.GraphPrepareTicketDraft.Code, + } +} + +func agentLoopSkillCode(id int64) string { + return "skill/" + strconv.FormatInt(id, 10) +} + +func agentLoopWorkflowCode(versionID int64) string { + return "workflow/" + strconv.FormatInt(versionID, 10) +} + +func agentLoopInvokedToolCodes(items []svc.AgentLoopToolCallInput) []string { + ret := make([]string, 0, len(items)) + for _, item := range items { + if code := strings.TrimSpace(item.ToolCode); code != "" { + ret = append(ret, code) + } + } + return ret +} + +func errorString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +type agentLoopExecutionState struct { + SkillContext agentLoopSkillContext + WorkflowRunID int64 + WorkflowSteps []svc.AgentLoopStepInput + Interrupted *RunResult +} + +type agentLoopInterruptError struct { + reason string +} + +func (e *agentLoopInterruptError) Error() string { + return e.reason +} + +func (e *AgentLoopEngine) toolSearchExecutor(runInput RunInput, turn agentLoopTurn, state *agentLoopExecutionState, records *[]svc.AgentLoopToolCallInput) ai.ToolCallExecutor { + return func(ctx context.Context, call ai.ToolCall) (string, error) { + startedAt := time.Now() + if call.Name != "tool_search" { + return "", fmt.Errorf("unsupported agent loop tool: %s", call.Name) + } + var toolRequest agentLoopToolSearchRequest + if err := json.Unmarshal([]byte(call.Arguments), &toolRequest); err != nil { + return "", fmt.Errorf("invalid tool_search arguments: %w", err) + } + toolCode := strings.TrimSpace(toolRequest.ToolCode) + if !slices.Contains(turn.AllowedTools, toolCode) { + return "", fmt.Errorf("capability is not configured for this Agent: %s", toolCode) + } + if state.SkillContext.Skill != nil && !strings.HasPrefix(toolCode, "skill/") && + !slices.Contains(state.SkillContext.AllowedToolCodes, toolx.NormalizeToolCodeAlias(toolCode)) { + return "", fmt.Errorf("capability is not allowed by the active Skill: %s", toolCode) + } + policy := aitooling.Policy{ + AllowedToolCodes: turn.AllowedTools, SkillAllowedToolCodes: state.SkillContext.AllowedToolCodes, AllowedRiskLevels: turn.ToolPolicy.AllowedRiskLevels, + CallCount: agentLoopToolCallCount(*records, toolCode), + TotalCallCount: len(*records), + MaxTotalCalls: turn.ToolPolicy.MaxTotalCalls, + MaxArgumentBytes: turn.ToolPolicy.MaxArgumentBytes, + Confirmed: false, + } + definition := aitooling.Definition{Code: toolCode, RiskLevel: aitooling.RiskLevelRead} + var resultPreview string + var err error + switch { + case strings.HasPrefix(toolCode, "skill/"): + resultPreview, err = activateAgentLoopSkill(toolCode, turn.Skills, state) + case strings.HasPrefix(toolCode, "workflow/"): + definition.RiskLevel = aitooling.RiskLevelWrite + definition.RequireConfirmation = true + workflowPolicy := policy + workflowPolicy.Confirmed = true + if err = aitooling.DefaultRegistry.Authorize(definition, workflowPolicy); err == nil { + resultPreview, err = executeAgentLoopWorkflow(ctx, runInput, toolCode, turn.Workflows, state) + } + default: + definition, resultPreview, err = executeAgentLoopReadTool(ctx, runInput.Conversation, runInput.AIAgent, toolCode, toolRequest.Arguments, policy) + if err != nil && definition.Code == "" { + definition, resultPreview, err = executeAgentLoopMCP(ctx, runInput, toolCode, toolRequest.Arguments, policy, state) + } + } + durationMS := int(time.Since(startedAt).Milliseconds()) + record := svc.AgentLoopToolCallInput{ + ToolCode: toolCode, Status: "completed", ArgumentsPreview: aitooling.SanitizePreview(call.Arguments), DurationMS: durationMS, + } + if definition.Code != "" { + record.ToolCode = definition.Code + record.RiskLevel = definition.RiskLevel + record.RequireConfirm = definition.RequireConfirmation + } + if err != nil { + record.Status = "failed" + var interruptErr *agentLoopInterruptError + if errors.As(err, &interruptErr) { + record.Status = "interrupted" + } + record.ErrorMessage = err.Error() + *records = append(*records, record) + return "", err + } + record.ResultPreview = aitooling.SanitizePreview(resultPreview) + *records = append(*records, record) + return record.ResultPreview, nil + } +} + +func activateAgentLoopSkill(code string, skills map[int64]models.SkillDefinition, state *agentLoopExecutionState) (string, error) { + id, err := strconv.ParseInt(strings.TrimPrefix(code, "skill/"), 10, 64) + if err != nil || id <= 0 { + return "", errorsx.InvalidParam("invalid Skill capability code") + } + skill, ok := skills[id] + if !ok { + return "", errorsx.InvalidParam("Skill is not configured for this Agent") + } + state.SkillContext = agentLoopSkillContext{Skill: &skill, AllowedToolCodes: parseSkillToolWhitelist(skill.ToolWhitelist)} + return instruction.BuildSkillDocument(&skill, nil), nil +} + +func executeAgentLoopWorkflow(ctx context.Context, runInput RunInput, code string, bindings map[int64]svc.AgentRevisionWorkflowBinding, state *agentLoopExecutionState) (string, error) { + versionID, err := strconv.ParseInt(strings.TrimPrefix(code, "workflow/"), 10, 64) + if err != nil || versionID <= 0 { + return "", errorsx.InvalidParam("invalid Workflow capability code") + } + if _, ok := bindings[versionID]; !ok { + return "", errorsx.InvalidParam("Workflow is not configured for this Agent") + } + workflow, err := resolveWorkflowVersion(versionID) + if err != nil { + return "", err + } + result, err := workflowexecutor.NewExecutor().Execute(ctx, workflowexecutor.Input{ + Definition: workflow.Definition, Conversation: runInput.Conversation, UserMessage: runInput.UserMessage, + AIAgent: runInput.AIAgent, AIConfig: runInput.AIConfig, Debug: runInput.Debug, + }) + if result == nil { + return "", err + } + runID, persistErr := writeWorkflowRun(runInput, workflow, result, errorString(err)) + if persistErr != nil { + return "", persistErr + } + state.WorkflowRunID = runID + state.WorkflowSteps = append(state.WorkflowSteps, svc.AgentLoopStepInput{ + StepType: "workflow", StepCode: code, WorkflowRunID: runID, Status: workflowAgentRunStatus(result.Status, errorString(err)), + InputPreview: strings.TrimSpace(runInput.UserMessage.Content), OutputPreview: strings.Join(result.NodePath, ","), ErrorMessage: errorString(err), + }) + if result.Interrupted { + state.Interrupted = toWorkflowResult(result, runInput.AIConfig.ModelName, workflow, runID) + return "", &agentLoopInterruptError{reason: "Agent Loop interrupted for Workflow confirmation"} + } + if err != nil { + return "", err + } + data, _ := json.Marshal(map[string]any{"workflowRunId": runID, "status": result.Status, "replyText": result.ReplyText}) + return string(data), nil +} + +func executeAgentLoopMCP(ctx context.Context, runInput RunInput, toolCode string, arguments map[string]any, policy aitooling.Policy, state *agentLoopExecutionState) (aitooling.Definition, string, error) { + configured, err := configuredMCPTool(runInput.AIAgent.AllowedMCPTools, toolCode) + if err != nil { + return aitooling.Definition{}, "", err + } + definition := aitooling.Definition{Code: toolCode, Name: configured.Title, RiskLevel: configured.RiskLevel, RequireConfirmation: configured.RequireConfirmation} + preflightPolicy := policy + preflightPolicy.Confirmed = true + if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{ + Definition: definition, Arguments: arguments, Policy: preflightPolicy, + }); err != nil { + return definition, "", err + } + if definition.RiskLevel == aitooling.RiskLevelWrite && definition.RequireConfirmation { + checkpoint := agentLoopMCPCheckpoint{ToolCode: toolCode, Arguments: arguments} + data, _ := json.Marshal(checkpoint) + checkPointID := fmt.Sprintf("tool:%d:%d", runInput.Conversation.ID, time.Now().UnixNano()) + state.Interrupted = &RunResult{ + Status: "interrupted", ReplyText: "请确认是否执行该操作。", CheckPointID: checkPointID, CheckPointData: string(data), Interrupted: true, + Interrupts: []InterruptContextSummary{{Type: "tool_confirmation", ID: toolCode, InfoPreview: configured.Title}}, + } + return definition, "", &agentLoopInterruptError{reason: "Agent Loop interrupted for MCP confirmation"} + } + policy.Confirmed = !definition.RequireConfirmation + _, result, err := aitooling.DefaultMCPExecutor.Execute(ctx, toolCode, arguments, policy) + return definition, runtimetooling.BuildReducedToolResultSummary(result), err +} + +type agentLoopMCPCheckpoint struct { + ToolCode string `json:"toolCode"` + Arguments map[string]any `json:"arguments"` +} + +func configuredMCPTool(raw, toolCode string) (request.AIAgentMCPToolRequest, error) { + items, err := toolx.ParseAgentMCPToolsJSON(raw) + if err != nil { + return request.AIAgentMCPToolRequest{}, err + } + for _, item := range items { + if item.ToolCode == toolCode { + return item, nil + } + } + return request.AIAgentMCPToolRequest{}, errorsx.InvalidParam("MCP tool is not configured for this Agent") +} + +func executeAgentLoopReadTool(ctx context.Context, conversation models.Conversation, agent models.AIAgent, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, string, error) { + toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode)) + if toolCode != toolx.BuiltinConversationContext.Code && toolCode != toolx.BuiltinKnowledgeRetrieve.Code && toolCode != toolx.GraphTriageServiceRequest.Code && toolCode != toolx.GraphAnalyzeConversation.Code && toolCode != toolx.GraphPrepareTicketDraft.Code { + return aitooling.Definition{}, "", fmt.Errorf("tool is not a built-in read tool") + } + if toolCode == toolx.GraphTriageServiceRequest.Code || toolCode == toolx.GraphAnalyzeConversation.Code || toolCode == toolx.GraphPrepareTicketDraft.Code { + return readtools.ExecuteGraphTool(ctx, conversation, toolCode, arguments, policy) + } + definition, err := aitooling.DefaultRegistry.Resolve(toolCode) + if err != nil { + return aitooling.Definition{}, "", err + } + if err := aitooling.DefaultRegistry.Authorize(definition, policy); err != nil { + return definition, "", err + } + if definition.TimeoutMS > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond) + defer cancel() + } + if toolCode == toolx.BuiltinKnowledgeRetrieve.Code { + query, _ := arguments["query"].(string) + contextText, count, err := retrieveAgentLoopKnowledge(ctx, agent, query) + if err != nil { + return definition, "", err + } + result, err := json.Marshal(map[string]any{"query": strings.TrimSpace(query), "resultCount": count, "context": contextText}) + return definition, string(result), err + } + result, err := json.Marshal(map[string]any{ + "conversationId": conversation.ID, + "customerName": strings.TrimSpace(conversation.CustomerName), + "lastMessageSummary": strings.TrimSpace(conversation.LastMessageSummary), + "currentAssigneeId": conversation.CurrentAssigneeID, + "recentMessages": agentLoopToolConversationMessages(conversation.ID), + }) + if err != nil { + return definition, "", err + } + return definition, string(result), nil +} + +func agentLoopToolConversationMessages(conversationID int64) []map[string]string { + if conversationID <= 0 { + return []map[string]string{} + } + items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, 6, "", "") + ret := make([]map[string]string, 0, len(items)) + for _, item := range items { + role := agentLoopMessageRole(item) + content := strings.TrimSpace(utils.BuildRuntimeMessageText(item.MessageType, item.Content)) + if role == "" || content == "" { + continue + } + if runes := []rune(content); len(runes) > 240 { + content = string(runes[:240]) + "..." + } + ret = append(ret, map[string]string{"role": role, "content": content}) + } + return ret +} + +func agentLoopToolCallCount(records []svc.AgentLoopToolCallInput, toolCode string) int { + toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode)) + count := 0 + for _, item := range records { + if toolx.NormalizeToolCodeAlias(strings.TrimSpace(item.ToolCode)) == toolCode { + count++ + } + } + return count +} + +func retrieveAgentLoopKnowledge(ctx context.Context, agent models.AIAgent, query string) (string, int, error) { + retrieved, err := retrievers.NewKnowledgeRetriever(agent, utils.SplitInt64s(agent.KnowledgeIDs)).RetrieveContext(ctx, query) + if err != nil { + return "", 0, err + } + if retrieved == nil { + return "", 0, nil + } + return strings.TrimSpace(retrieved.ContextText), len(retrieved.ContextResults), nil +} + +func buildAgentLoopSystemPrompt(agent models.AIAgent, hasKnowledgeBase bool, knowledgeContext string, retrieveErr error) string { + prompt := strings.TrimSpace(agent.SystemPrompt) + if prompt == "" { + prompt = "You are a customer service assistant. Answer accurately, ask for clarification when evidence is insufficient, and do not invent facts." + } + if retrieveErr != nil { + prompt += "\n\nKnowledge retrieval is temporarily unavailable for this message. You may answer greetings, acknowledgements, gratitude, farewells, and requests for clarification naturally. For product facts, policies, pricing, functions, procedures, timing, refunds, accounts, permissions, or after-sales questions, do not claim that any detail is verified. Explain that you cannot verify it now, ask one focused question when useful, or offer human handoff." + } else if hasKnowledgeBase && strings.TrimSpace(knowledgeContext) == "" { + prompt += "\n\nKnowledge retrieval found no supporting evidence for this message. You may answer greetings, acknowledgements, gratitude, farewells, and requests for clarification naturally. For product facts, policies, pricing, functions, procedures, timing, refunds, accounts, permissions, or after-sales questions, do not infer or invent an answer. State that the available information is insufficient, ask one focused question when useful, or offer human handoff." + } + if hasKnowledgeBase && (retrieveErr != nil || strings.TrimSpace(knowledgeContext) == "") { + if fallback := strings.TrimSpace(agent.FallbackMessage); fallback != "" { + prompt += "\nUse this configured fallback wording when knowledge evidence is insufficient: " + fallback + } + switch agent.FallbackMode { + case enums.AIAgentFallbackModeSuggestRetry: + prompt += "\nPrefer asking the customer for one specific missing detail." + case enums.AIAgentFallbackModeHandoff: + prompt += "\nTell the customer that a human handoff will be requested." + default: + prompt += "\nState plainly that the available knowledge is insufficient." + } + } + return prompt +} + +func writeAgentLoopRun(req RunInput, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount int, retrieverCount int, retrieveErr error, skillContext agentLoopSkillContext, responsePolicy agentLoopResponsePolicy, toolCalls []svc.AgentLoopToolCallInput, cause error, interrupted bool, workflowSteps []svc.AgentLoopStepInput) (int64, error) { + endedAt := time.Now() + status := "completed" + errorMessage := "" + outputPreview := "" + promptTokens := 0 + completionTokens := 0 + if interrupted { + status = "interrupted" + } else if cause != nil { + status = "failed" + errorMessage = cause.Error() + } else if result != nil { + outputPreview = strings.TrimSpace(result.Content) + promptTokens = result.PromptTokens + completionTokens = result.CompletionTokens + } + trace, _ := json.Marshal(map[string]any{"runtime": "agent-loop", "status": status, "historyMessageCount": historyCount, "retrieverCount": retrieverCount}) + additionalSteps := agentLoopAdditionalSteps(req, retrieverCount, retrieveErr, skillContext, responsePolicy) + additionalSteps = append(additionalSteps, workflowSteps...) + var runID int64 + err := sqls.WithTransaction(func(tx *sqls.TxContext) error { + var recordErr error + runID, recordErr = svc.AgentRunService.RecordAgentLoopRun(tx.Tx, svc.AgentLoopRunInput{ + ConversationID: req.Conversation.ID, AIAgentID: req.AIAgent.ID, AgentRevisionID: req.AIAgent.PublishedRevisionID, + SourceMessageID: req.UserMessage.ID, WorkflowRunID: firstWorkflowStepRunID(workflowSteps), Status: status, + PromptTokens: promptTokens, CompletionTokens: completionTokens, StartedAt: startedAt, EndedAt: &endedAt, + ErrorMessage: errorMessage, TraceData: string(trace), StepType: "model", StepCode: "chat_completion", + StepInputPreview: strings.TrimSpace(inputPreview), StepOutputPreview: outputPreview, + AdditionalSteps: additionalSteps, + ToolCalls: toolCalls, + }) + return recordErr + }) + return runID, err +} + +func firstWorkflowStepRunID(items []svc.AgentLoopStepInput) int64 { + for _, item := range items { + if item.WorkflowRunID > 0 { + return item.WorkflowRunID + } + } + return 0 +} + +func agentLoopAdditionalSteps(req RunInput, retrieverCount int, retrieveErr error, skillContext agentLoopSkillContext, responsePolicy agentLoopResponsePolicy) []svc.AgentLoopStepInput { + steps := make([]svc.AgentLoopStepInput, 0, 3) + if skillContext.Skill != nil { + steps = append(steps, svc.AgentLoopStepInput{ + StepType: "skill", StepCode: agentLoopSkillCode(skillContext.SkillID()), Status: "completed", + InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "activated Skill: " + skillContext.SkillName(), + }) + } + if len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0 { + status := "completed" + errorMessage := "" + if retrieveErr != nil { + status = "failed" + errorMessage = retrieveErr.Error() + } + steps = append(steps, svc.AgentLoopStepInput{ + StepType: "knowledge", StepCode: "knowledge_retrieve", Status: status, + InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "retrieved context items: " + strconv.Itoa(retrieverCount), ErrorMessage: errorMessage, + }) + } + if responsePolicy.Reason != "" { + policyCode := "knowledge_evidence" + steps = append(steps, svc.AgentLoopStepInput{ + StepType: "policy", StepCode: policyCode, Status: "completed", + InputPreview: responsePolicy.Reason, OutputPreview: responsePolicy.Action, + }) + } + return steps +} diff --git a/internal/ai/application/runtime/agent_loop_engine_test.go b/internal/ai/application/runtime/agent_loop_engine_test.go new file mode 100644 index 0000000..c20666b --- /dev/null +++ b/internal/ai/application/runtime/agent_loop_engine_test.go @@ -0,0 +1,184 @@ +package runtime + +import ( + "context" + "encoding/json" + "strings" + "testing" + + ai "agent-desk/internal/ai" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" + svc "agent-desk/internal/services" + + "github.com/glebarez/sqlite" + "github.com/mlogclub/simple/sqls" + "gorm.io/gorm" +) + +func TestAgentLoopActivatesSkillInsideSameToolLoop(t *testing.T) { + skill := models.SkillDefinition{ + ID: 7, Name: "退款说明", Instruction: "只根据退款政策回答。", + ToolWhitelist: `["builtin/knowledge_retrieve"]`, Status: enums.StatusOk, + } + turn := agentLoopTurn{ + AllowedTools: []string{"skill/7"}, + ToolPolicy: parseAgentLoopToolPolicy(""), + Skills: map[int64]models.SkillDefinition{skill.ID: skill}, + } + state := agentLoopExecutionState{} + var calls []svc.AgentLoopToolCallInput + execute := NewAgentLoopEngine().toolSearchExecutor(RunInput{}, turn, &state, &calls) + + result, err := execute(context.Background(), ai.ToolCall{ + Name: "tool_search", Arguments: `{"toolCode":"skill/7","arguments":{}}`, + }) + if err != nil { + t.Fatalf("activate Skill: %v", err) + } + if state.SkillContext.SkillID() != skill.ID || !strings.Contains(result, skill.Instruction) { + t.Fatalf("Skill was not activated in the Agent Loop: state=%#v result=%q", state, result) + } + if len(calls) != 1 || calls[0].ToolCode != "skill/7" || calls[0].Status != "completed" { + t.Fatalf("unexpected Skill audit: %#v", calls) + } +} + +func TestAgentLoopInterruptsBeforeWriteMCPTool(t *testing.T) { + configured, err := json.Marshal([]request.AIAgentMCPToolRequest{{ + ToolCode: "crm/update_customer", ServerCode: "crm", ToolName: "update_customer", + Title: "更新客户", RiskLevel: "write", RequireConfirmation: true, + }}) + if err != nil { + t.Fatalf("marshal MCP configuration: %v", err) + } + runInput := RunInput{ + Conversation: models.Conversation{ID: 9}, + AIAgent: models.AIAgent{AllowedMCPTools: string(configured)}, + } + turn := agentLoopTurn{ + AllowedTools: []string{"crm/update_customer"}, + ToolPolicy: parseAgentLoopToolPolicy(`{"allowedRiskLevels":["read","write"]}`), + } + state := agentLoopExecutionState{} + var calls []svc.AgentLoopToolCallInput + execute := NewAgentLoopEngine().toolSearchExecutor(runInput, turn, &state, &calls) + + _, err = execute(context.Background(), ai.ToolCall{ + Name: "tool_search", Arguments: `{"toolCode":"crm/update_customer","arguments":{"name":"Ada"}}`, + }) + if err == nil { + t.Fatal("expected write MCP Tool to interrupt") + } + if state.Interrupted == nil || !state.Interrupted.Interrupted || !strings.HasPrefix(state.Interrupted.CheckPointID, "tool:9:") { + t.Fatalf("missing MCP confirmation checkpoint: %#v", state.Interrupted) + } + if len(calls) != 1 || calls[0].RiskLevel != "write" || !calls[0].RequireConfirm || calls[0].Status != "interrupted" { + t.Fatalf("unexpected MCP safety audit: %#v", calls) + } +} + +func TestAgentLoopRejectsWriteMCPBeforeConfirmationWhenRiskIsNotAllowed(t *testing.T) { + configured, _ := json.Marshal([]request.AIAgentMCPToolRequest{{ + ToolCode: "crm/update_customer", ServerCode: "crm", ToolName: "update_customer", + Title: "更新客户", RiskLevel: "write", RequireConfirmation: true, + }}) + runInput := RunInput{ + Conversation: models.Conversation{ID: 9}, + AIAgent: models.AIAgent{AllowedMCPTools: string(configured)}, + } + turn := agentLoopTurn{ + AllowedTools: []string{"crm/update_customer"}, + ToolPolicy: parseAgentLoopToolPolicy(`{"allowedRiskLevels":["read"]}`), + } + state := agentLoopExecutionState{} + var calls []svc.AgentLoopToolCallInput + execute := NewAgentLoopEngine().toolSearchExecutor(runInput, turn, &state, &calls) + + _, err := execute(context.Background(), ai.ToolCall{ + Name: "tool_search", Arguments: `{"toolCode":"crm/update_customer","arguments":{"name":"Ada"}}`, + }) + if err == nil || !strings.Contains(err.Error(), "risk level") { + t.Fatalf("expected MCP risk policy rejection, got %v", err) + } + if state.Interrupted != nil || len(calls) != 1 || calls[0].Status != "failed" { + t.Fatalf("disallowed MCP call should fail without a checkpoint: state=%#v calls=%#v", state, calls) + } +} + +func TestAgentLoopRejectsWorkflowWhenWriteRiskIsNotAllowed(t *testing.T) { + turn := agentLoopTurn{ + AllowedTools: []string{"workflow/23"}, + ToolPolicy: parseAgentLoopToolPolicy(`{"allowedRiskLevels":["read"]}`), + Workflows: map[int64]svc.AgentRevisionWorkflowBinding{ + 23: {WorkflowVersionID: 23, ToolName: "创建工单"}, + }, + } + state := agentLoopExecutionState{} + var calls []svc.AgentLoopToolCallInput + execute := NewAgentLoopEngine().toolSearchExecutor(RunInput{}, turn, &state, &calls) + + _, err := execute(context.Background(), ai.ToolCall{ + Name: "tool_search", Arguments: `{"toolCode":"workflow/23","arguments":{}}`, + }) + if err == nil || !strings.Contains(err.Error(), "risk level") { + t.Fatalf("expected Workflow risk policy rejection, got %v", err) + } + if len(calls) != 1 || calls[0].Status != "failed" || calls[0].RiskLevel != "write" { + t.Fatalf("unexpected Workflow policy audit: %#v", calls) + } +} + +func TestAgentLoopKnowledgeFallbackCanRequestHandoff(t *testing.T) { + agent := models.AIAgent{ + KnowledgeIDs: "1", FallbackMode: enums.AIAgentFallbackModeHandoff, + FallbackMessage: "我暂时无法核实,马上为你转人工。", + } + policy := evaluateAgentLoopResponsePolicy(agent, "", nil) + prompt := buildAgentLoopSystemPrompt(agent, true, "", nil) + if !policy.RequestHandoff || !strings.Contains(prompt, agent.FallbackMessage) { + t.Fatalf("knowledge fallback was not applied: policy=%#v prompt=%q", policy, prompt) + } +} + +func TestAgentTurnPublishesAllConfiguredCapabilityKinds(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.SkillDefinition{}); err != nil { + t.Fatalf("migrate Skill: %v", err) + } + sqls.SetDB(db) + skill := models.SkillDefinition{Name: "订单查询", Description: "查询订单状态", Status: enums.StatusOk} + if err := db.Create(&skill).Error; err != nil { + t.Fatalf("create Skill: %v", err) + } + mcp, _ := json.Marshal([]request.AIAgentMCPToolRequest{{ + ToolCode: "crm/get_customer", ServerCode: "crm", ToolName: "get_customer", + RiskLevel: "read", + }}) + agent := models.AIAgent{SkillIDs: jsonInt64List(skill.ID), AllowedMCPTools: string(mcp)} + snapshot := &svc.AgentRevisionSnapshot{ + Agent: agent, + WorkflowBindings: []svc.AgentRevisionWorkflowBinding{{ + WorkflowVersionID: 23, ToolName: "创建工单", TriggerInstruction: "用户要求创建工单", + }}, + } + engine := NewAgentLoopEngine() + engine.retrieve = nil + engine.history = nil + turn := engine.prepareTurn(context.Background(), RunInput{AIAgent: agent}, snapshot) + + for _, code := range []string{"skill/" + jsonInt64List(skill.ID), "workflow/23", "crm/get_customer"} { + if !strings.Contains(turn.SystemPrompt, code) { + t.Fatalf("capability %q missing from prompt:\n%s", code, turn.SystemPrompt) + } + } +} + +func jsonInt64List(id int64) string { + data, _ := json.Marshal([]int64{id}) + return strings.Trim(string(data), "[]") +} diff --git a/internal/ai/application/runtime/agent_turn.go b/internal/ai/application/runtime/agent_turn.go new file mode 100644 index 0000000..38737c7 --- /dev/null +++ b/internal/ai/application/runtime/agent_turn.go @@ -0,0 +1,85 @@ +package runtime + +import ( + "context" + "fmt" + "strings" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/toolx" + "agent-desk/internal/pkg/utils" + svc "agent-desk/internal/services" +) + +type agentLoopTurn struct { + RetrieverCount int + RetrieveErr error + ResponsePolicy agentLoopResponsePolicy + SystemPrompt string + UserPrompt string + HistoryCount int + AllowedTools []string + ToolPolicy agentLoopToolPolicy + Skills map[int64]models.SkillDefinition + Workflows map[int64]svc.AgentRevisionWorkflowBinding +} + +func (e *AgentLoopEngine) prepareTurn(ctx context.Context, req RunInput, snapshot *svc.AgentRevisionSnapshot) agentLoopTurn { + knowledgeContext, retrieverCount, retrieveErr := e.retrieveKnowledge(ctx, req.AIAgent, req.UserMessage.Content) + responsePolicy := evaluateAgentLoopResponsePolicy(req.AIAgent, knowledgeContext, retrieveErr) + systemPrompt := buildAgentLoopSystemPrompt(req.AIAgent, len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0, knowledgeContext, retrieveErr) + userPrompt, historyCount := e.buildUserPrompt(req) + if knowledgeContext != "" { + userPrompt += "\n\nKnowledge evidence:\n" + knowledgeContext + } + skills := svc.SkillDefinitionService.GetByIDs(utils.SplitInt64s(req.AIAgent.SkillIDs)) + workflows := make(map[int64]svc.AgentRevisionWorkflowBinding, len(snapshot.WorkflowBindings)) + allowedTools := agentLoopSafeBuiltinCodes() + catalog := []string{ + "- " + toolx.BuiltinConversationContext.Code + " | Builtin | 读取当前会话和客户上下文", + "- " + toolx.BuiltinKnowledgeRetrieve.Code + " | Builtin | 按需再次检索已绑定知识库", + "- " + toolx.GraphTriageServiceRequest.Code + " | Builtin | 分析服务请求并生成处置建议", + "- " + toolx.GraphAnalyzeConversation.Code + " | Builtin | 分析会话意图和风险信号", + "- " + toolx.GraphPrepareTicketDraft.Code + " | Builtin | 只生成工单草稿,不执行写入", + } + for id, skill := range skills { + if skill.Status != enums.StatusOk { + continue + } + code := agentLoopSkillCode(id) + allowedTools = append(allowedTools, code) + catalog = append(catalog, fmt.Sprintf("- %s | Skill | %s | %s", code, strings.TrimSpace(skill.Name), strings.TrimSpace(skill.Description))) + } + for _, binding := range snapshot.WorkflowBindings { + if binding.WorkflowVersionID <= 0 { + continue + } + workflows[binding.WorkflowVersionID] = binding + code := agentLoopWorkflowCode(binding.WorkflowVersionID) + allowedTools = append(allowedTools, code) + catalog = append(catalog, fmt.Sprintf("- %s | Workflow | %s | %s", code, strings.TrimSpace(binding.ToolName), strings.TrimSpace(binding.TriggerInstruction))) + } + mcpTools, _ := toolx.ParseAgentMCPToolsJSON(req.AIAgent.AllowedMCPTools) + for _, tool := range mcpTools { + if strings.TrimSpace(tool.ToolCode) == "" { + continue + } + allowedTools = append(allowedTools, tool.ToolCode) + catalog = append(catalog, fmt.Sprintf("- %s | MCP | %s | %s", tool.ToolCode, tool.Title, tool.Description)) + } + systemPrompt += "\n\nAvailable capabilities:\n" + strings.Join(catalog, "\n") + systemPrompt += "\n\nUse tool_search with an exact capability code only when needed. You decide whether to answer directly, activate a Skill, execute a Workflow, retrieve knowledge, or call MCP. A Skill activation returns instructions for this same run. Never invent a capability code." + return agentLoopTurn{ + RetrieverCount: retrieverCount, + RetrieveErr: retrieveErr, + ResponsePolicy: responsePolicy, + SystemPrompt: systemPrompt, + UserPrompt: userPrompt, + HistoryCount: historyCount, + AllowedTools: allowedTools, + ToolPolicy: parseAgentLoopToolPolicy(req.AIAgent.ToolPolicy), + Skills: skills, + Workflows: workflows, + } +} diff --git a/internal/ai/application/runtime/application_service.go b/internal/ai/application/runtime/application_service.go index b9164b9..7419d19 100644 --- a/internal/ai/application/runtime/application_service.go +++ b/internal/ai/application/runtime/application_service.go @@ -26,7 +26,7 @@ type ApplicationResumeInput struct { // 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. +// Agent Loop remains responsible only for runtime execution. type AgentApplicationService struct { runtime *Service } diff --git a/internal/ai/application/runtime/autonomous_engine.go b/internal/ai/application/runtime/autonomous_engine.go deleted file mode 100644 index 51490f6..0000000 --- a/internal/ai/application/runtime/autonomous_engine.go +++ /dev/null @@ -1,647 +0,0 @@ -package runtime - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" - "time" - - ai "agent-desk/internal/ai" - "agent-desk/internal/ai/runtime/readtools" - "agent-desk/internal/ai/runtime/retrievers" - runtimetooling "agent-desk/internal/ai/runtime/tooling" - "agent-desk/internal/ai/skills" - aitooling "agent-desk/internal/ai/tooling" - "agent-desk/internal/models" - "agent-desk/internal/pkg/enums" - "agent-desk/internal/pkg/errorsx" - "agent-desk/internal/pkg/toolx" - "agent-desk/internal/pkg/utils" - svc "agent-desk/internal/services" - - "github.com/mlogclub/simple/sqls" -) - -// AutonomousEngine is the low-risk, no-flow runtime. It uses bounded model -// turns and exposes configured MCP tools only through the shared Tool Registry. -type AutonomousEngine struct { - chat func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) - history func(int64, int) []models.Message - retrieve func(context.Context, models.AIAgent, string) (string, int, error) - skillSelect func(context.Context, skills.RuntimeContext) (*skills.ExecutionResult, error) - toolChat func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error) -} - -func NewAutonomousEngine() *AutonomousEngine { - return &AutonomousEngine{ - chat: ai.LLM.ChatWithConfig, - history: func(conversationID int64, limit int) []models.Message { - items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, limit, "", "") - return items - }, - retrieve: retrieveAutonomousKnowledge, - skillSelect: skills.RuntimeService.Select, - toolChat: ai.LLM.ChatWithTools, - } -} - -func newAutonomousEngineWithChat(chat func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error)) *AutonomousEngine { - return &AutonomousEngine{chat: chat} -} - -func (e *AutonomousEngine) Code() string { - return EngineCodeAutonomous -} - -func (e *AutonomousEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) { - startedAt := time.Now() - req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content) - snapshot, err := svc.AgentRevisionService.ResolvePublishedSnapshot(req.AIAgent, req.AIConfig) - if err != nil { - _, _ = writeAutonomousRun(req, startedAt, nil, "", 0, 0, nil, autonomousSkillContext{}, autonomousResponsePolicy{}, nil, err) - return nil, err - } - req.AIAgent = snapshot.Agent - req.AIConfig = snapshot.AIConfig - turn := e.prepareTurn(ctx, req) - var toolCalls []svc.EngineToolCallInput - var result *ai.ChatCompletionResult - if turn.ResponsePolicy.Enforced { - result = &ai.ChatCompletionResult{Content: turn.ResponsePolicy.ReplyText, ModelName: req.AIConfig.ModelName} - } else if len(turn.AllowedTools) > 0 && e.toolChat != nil { - loopResult, loopErr := e.toolChat(ctx, req.AIConfig, turn.SystemPrompt, turn.UserPrompt, []ai.ToolDefinition{autonomousToolSearchDefinition()}, req.AIAgent.MaxSteps, e.toolSearchExecutor(req.Conversation, req.AIAgent, turn.AgentAllowedTools, turn.SkillContext.AllowedToolCodes, turn.ToolPolicy, &toolCalls)) - if loopErr != nil { - if len(toolCalls) == 0 { - err := loopErr - _, _ = writeAutonomousRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, turn.ResponsePolicy, toolCalls, err) - return nil, err - } - turn.ResponsePolicy = autonomousToolFailurePolicy(req.AIAgent, "tool_loop_error") - result = &ai.ChatCompletionResult{Content: turn.ResponsePolicy.ReplyText, ModelName: req.AIConfig.ModelName} - } - if result == nil && loopResult != nil { - result = &loopResult.ChatCompletionResult - } - if autonomousHasConsecutiveToolFailures(toolCalls, 2) { - turn.ResponsePolicy = autonomousToolFailurePolicy(req.AIAgent, "tool_consecutive_failures") - result = &ai.ChatCompletionResult{Content: turn.ResponsePolicy.ReplyText, ModelName: req.AIConfig.ModelName} - } - } else { - result, err = e.chat(ctx, req.AIConfig, turn.SystemPrompt, turn.UserPrompt) - } - if err != nil { - _, _ = writeAutonomousRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, turn.ResponsePolicy, toolCalls, err) - return nil, err - } - if result == nil || strings.TrimSpace(result.Content) == "" { - err = errorsx.InvalidParam("autonomous engine returned an empty reply") - _, _ = writeAutonomousRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, turn.ResponsePolicy, toolCalls, err) - return nil, err - } - result.Content, err = aitooling.NormalizeCustomerReply(result.Content) - if err != nil { - _, _ = writeAutonomousRun(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, turn.ResponsePolicy, toolCalls, err) - return nil, err - } - runID, recordErr := writeAutonomousRun(req, startedAt, result, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, turn.ResponsePolicy, toolCalls, nil) - if recordErr != nil { - return nil, recordErr - } - trace, _ := json.Marshal(map[string]any{ - "engine": EngineCodeAutonomous, - "mode": autonomousExecutionMode(turn.AllowedTools), - "historyMessageCount": turn.HistoryCount, - "retrieverCount": turn.RetrieverCount, - "skillID": turn.SkillContext.SkillID(), - "skillRouteError": turn.SkillContext.ErrorMessage, - "responsePolicyAction": turn.ResponsePolicy.Action, - "responsePolicyReason": turn.ResponsePolicy.Reason, - "responsePolicyEnforced": turn.ResponsePolicy.Enforced, - "debug": req.Debug, - }) - return &Summary{ - Status: "completed", - ReplyText: strings.TrimSpace(result.Content), - ModelName: result.ModelName, - PromptTokens: result.PromptTokens, - CompletionTokens: result.CompletionTokens, - HistoryMessageCount: turn.HistoryCount, - RetrieverCount: turn.RetrieverCount, - PlannedSkillID: turn.SkillContext.SkillID(), - PlannedSkillName: turn.SkillContext.SkillName(), - PlanReason: turn.SkillContext.MatchReason, - SkillRouteTrace: turn.SkillContext.TraceData, - SkillAllowedToolCodes: append([]string(nil), turn.SkillContext.AllowedToolCodes...), - AgentRunID: runID, - HandoffRequested: turn.ResponsePolicy.RequestHandoff && !req.Debug, - TraceData: string(trace), - }, nil -} - -func (e *AutonomousEngine) buildUserPrompt(req Request) (string, int) { - limit := req.AIAgent.ContextWindow - if limit <= 0 { - limit = 12 - } - if limit > 20 { - limit = 20 - } - items := []models.Message(nil) - if e.history != nil && req.Conversation.ID > 0 { - // The triggering customer message is already persisted in most reply - // paths. Fetch one extra item so it does not consume history capacity. - items = e.history(req.Conversation.ID, limit+1) - } - lines := make([]string, 0, len(items)+2) - for _, item := range items { - if item.ID == req.UserMessage.ID || strings.TrimSpace(item.Content) == "" { - continue - } - role := autonomousMessageRole(item) - if role == "" { - continue - } - lines = append(lines, role+": "+utils.BuildRuntimeMessageText(item.MessageType, item.Content)) - } - if len(lines) > limit { - lines = lines[len(lines)-limit:] - } - current := strings.TrimSpace(req.UserMessage.Content) - customerContext := buildAutonomousCustomerContext(req.Conversation) - if len(lines) == 0 && customerContext == "" { - return current, 0 - } - parts := make([]string, 0, 3) - if customerContext != "" { - parts = append(parts, "Customer context:\n"+customerContext) - } - if len(lines) > 0 { - parts = append(parts, "Conversation history:\n"+strings.Join(lines, "\n")) - } - parts = append(parts, "Current customer message:\n"+current) - return strings.Join(parts, "\n\n"), len(lines) -} - -func buildAutonomousCustomerContext(conversation models.Conversation) string { - parts := make([]string, 0, 2) - if name := strings.TrimSpace(conversation.CustomerName); name != "" { - parts = append(parts, "Customer: "+name) - } - if summary := strings.TrimSpace(conversation.LastMessageSummary); summary != "" { - parts = append(parts, "Recent summary: "+summary) - } - return strings.Join(parts, "\n") -} - -func autonomousMessageRole(message models.Message) string { - switch message.SenderType { - case "customer": - return "Customer" - case "ai", "agent": - return "Assistant" - default: - return "" - } -} - -func (e *AutonomousEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) { - return nil, errorsx.InvalidParam("autonomous agent has no resumable checkpoint") -} - -func (e *AutonomousEngine) retrieveKnowledge(ctx context.Context, agent models.AIAgent, query string) (string, int, error) { - if e.retrieve == nil || len(utils.SplitInt64s(agent.KnowledgeIDs)) == 0 { - return "", 0, nil - } - return e.retrieve(ctx, agent, query) -} - -type autonomousSkillContext struct { - Skill *models.SkillDefinition - MatchReason string - TraceData string - ErrorMessage string - AllowedToolCodes []string -} - -type autonomousResponsePolicy struct { - Enforced bool - Action string - Reason string - ReplyText string - RequestHandoff bool -} - -func evaluateAutonomousResponsePolicy(agent models.AIAgent, knowledgeContext string, retrieveErr error) autonomousResponsePolicy { - if len(utils.SplitInt64s(agent.KnowledgeIDs)) == 0 || strings.TrimSpace(knowledgeContext) != "" && retrieveErr == nil { - return autonomousResponsePolicy{} - } - if retrieveErr != nil { - return autonomousResponsePolicy{Action: "retrieval_unavailable", Reason: "knowledge_retrieve_error"} - } - // Knowledge retrieval is an evidence signal, not a replacement for the - // model's ability to handle greetings and other non-factual conversation. - return autonomousResponsePolicy{Action: "evidence_required", Reason: "knowledge_evidence_missing"} -} - -func autonomousToolFailurePolicy(agent models.AIAgent, reason string) autonomousResponsePolicy { - if agent.FallbackMode == enums.AIAgentFallbackModeHandoff { - return autonomousResponsePolicy{ - Enforced: true, Action: "handoff", Reason: reason, RequestHandoff: true, - ReplyText: autonomousToolFailureReply(agent), - } - } - return autonomousResponsePolicy{ - Enforced: true, Action: "clarify", Reason: reason, - ReplyText: autonomousToolFailureReply(agent), - } -} - -func autonomousToolFailureReply(agent models.AIAgent) string { - if reply := strings.TrimSpace(agent.FallbackMessage); reply != "" { - return reply - } - if agent.FallbackMode == enums.AIAgentFallbackModeHandoff { - return "暂时无法完成所需查询,正在为你转接人工客服。" - } - return "暂时无法完成所需查询,请补充更具体的信息后再试一次。" -} - -func autonomousHasConsecutiveToolFailures(calls []svc.EngineToolCallInput, minimum int) bool { - if minimum <= 0 { - return false - } - failures := 0 - for index := len(calls) - 1; index >= 0; index-- { - if calls[index].Status != "failed" { - break - } - failures++ - } - return failures >= minimum -} - -func (c autonomousSkillContext) SkillID() int64 { - if c.Skill == nil { - return 0 - } - return c.Skill.ID -} - -func (c autonomousSkillContext) SkillName() string { - if c.Skill == nil { - return "" - } - return strings.TrimSpace(c.Skill.Name) -} - -func (e *AutonomousEngine) selectSkill(ctx context.Context, req Request) autonomousSkillContext { - if e.skillSelect == nil || len(utils.SplitInt64s(req.AIAgent.SkillIDs)) == 0 { - return autonomousSkillContext{} - } - result, err := e.skillSelect(ctx, skills.RuntimeContext{ - AIAgent: req.AIAgent, AIConfig: req.AIConfig, UserMessage: req.UserMessage.Content, ConversationID: req.Conversation.ID, - }) - ret := autonomousSkillContext{} - if err != nil { - ret.ErrorMessage = err.Error() - return ret - } - if result == nil || result.Plan == nil { - return ret - } - ret.Skill = result.Plan.Skill - ret.MatchReason = strings.TrimSpace(result.Plan.MatchReason) - if result.Trace != nil { - data, _ := json.Marshal(result.Trace) - ret.TraceData = string(data) - } - if ret.Skill != nil { - ret.AllowedToolCodes = parseSkillToolWhitelist(ret.Skill.ToolWhitelist) - } - return ret -} - -func parseSkillToolWhitelist(raw string) []string { - var items []string - if json.Unmarshal([]byte(strings.TrimSpace(raw)), &items) != nil { - return nil - } - ret := make([]string, 0, len(items)) - seen := make(map[string]struct{}, len(items)) - for _, item := range items { - item = toolx.NormalizeToolCodeAlias(strings.TrimSpace(item)) - if item == "" { - continue - } - if _, exists := seen[item]; exists { - continue - } - seen[item] = struct{}{} - ret = append(ret, item) - } - return ret -} - -func intersectAutonomousToolCodes(agentAllowed, skillAllowed []string) []string { - if len(agentAllowed) == 0 || len(skillAllowed) == 0 { - return nil - } - allowed := make(map[string]struct{}, len(skillAllowed)) - for _, item := range skillAllowed { - allowed[toolx.NormalizeToolCodeAlias(strings.TrimSpace(item))] = struct{}{} - } - ret := make([]string, 0, len(agentAllowed)) - for _, item := range agentAllowed { - item = toolx.NormalizeToolCodeAlias(strings.TrimSpace(item)) - if _, ok := allowed[item]; ok { - ret = append(ret, item) - } - } - return ret -} - -type autonomousDirectTool struct { - ToolCode string `json:"toolCode"` -} - -type autonomousToolSearchRequest struct { - ToolCode string `json:"toolCode"` - Arguments map[string]any `json:"arguments"` -} - -type autonomousToolPolicy struct { - MaxTotalCalls int `json:"maxTotalCalls"` - MaxArgumentBytes int `json:"maxArgumentBytes"` - AllowedRiskLevels []string `json:"allowedRiskLevels"` -} - -func parseAutonomousToolPolicy(raw string) autonomousToolPolicy { - policy := autonomousToolPolicy{MaxTotalCalls: 3, MaxArgumentBytes: 32 * 1024} - if json.Unmarshal([]byte(strings.TrimSpace(raw)), &policy) != nil { - return policy - } - if policy.MaxTotalCalls <= 0 || policy.MaxTotalCalls > 8 { - policy.MaxTotalCalls = 3 - } - if policy.MaxArgumentBytes <= 0 || policy.MaxArgumentBytes > 64*1024 { - policy.MaxArgumentBytes = 32 * 1024 - } - return policy -} - -func autonomousAllowedMCPToolCodes(raw string) []string { - var items []autonomousDirectTool - if json.Unmarshal([]byte(strings.TrimSpace(raw)), &items) != nil { - return nil - } - ret := make([]string, 0, len(items)) - for _, item := range items { - if code := strings.TrimSpace(item.ToolCode); code != "" { - ret = append(ret, code) - } - } - return ret -} - -func autonomousToolSearchDefinition() ai.ToolDefinition { - return ai.ToolDefinition{ - Name: "tool_search", - Description: "Use a configured read-only tool only when it is needed to answer the customer. Pass the exact allowed toolCode and an arguments object.", - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "toolCode": map[string]any{"type": "string"}, - "arguments": map[string]any{"type": "object"}, - }, - "required": []string{"toolCode", "arguments"}, - }, - } -} - -func (e *AutonomousEngine) toolSearchExecutor(conversation models.Conversation, agent models.AIAgent, allowedCodes, skillAllowedCodes []string, toolPolicy autonomousToolPolicy, records *[]svc.EngineToolCallInput) ai.ToolCallExecutor { - return func(ctx context.Context, call ai.ToolCall) (string, error) { - startedAt := time.Now() - if call.Name != "tool_search" { - return "", fmt.Errorf("unsupported autonomous tool: %s", call.Name) - } - var req autonomousToolSearchRequest - if err := json.Unmarshal([]byte(call.Arguments), &req); err != nil { - return "", fmt.Errorf("invalid tool_search arguments: %w", err) - } - policy := aitooling.Policy{ - AllowedToolCodes: allowedCodes, SkillAllowedToolCodes: skillAllowedCodes, AllowedRiskLevels: toolPolicy.AllowedRiskLevels, - CallCount: autonomousToolCallCount(*records, req.ToolCode), - TotalCallCount: len(*records), - MaxTotalCalls: toolPolicy.MaxTotalCalls, - MaxArgumentBytes: toolPolicy.MaxArgumentBytes, - Confirmed: true, // The Agent's persisted allow-list is the administrator approval boundary. - } - definition, resultPreview, err := executeAutonomousReadTool(ctx, conversation, agent, strings.TrimSpace(req.ToolCode), req.Arguments, policy) - if err != nil && definition.Code == "" { - mcpDefinition, result, mcpErr := aitooling.DefaultMCPExecutor.Execute(ctx, strings.TrimSpace(req.ToolCode), req.Arguments, policy) - definition, err = mcpDefinition, mcpErr - resultPreview = runtimetooling.BuildReducedToolResultSummary(result) - } - durationMS := int(time.Since(startedAt).Milliseconds()) - record := svc.EngineToolCallInput{ - ToolCode: strings.TrimSpace(req.ToolCode), Status: "completed", ArgumentsPreview: aitooling.SanitizePreview(call.Arguments), DurationMS: durationMS, - } - if definition.Code != "" { - record.ToolCode = definition.Code - record.RiskLevel = definition.RiskLevel - record.RequireConfirm = definition.RequireConfirmation - } - if err != nil { - record.Status = "failed" - record.ErrorMessage = err.Error() - *records = append(*records, record) - return "", err - } - record.ResultPreview = aitooling.SanitizePreview(resultPreview) - *records = append(*records, record) - return record.ResultPreview, nil - } -} - -func executeAutonomousReadTool(ctx context.Context, conversation models.Conversation, agent models.AIAgent, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, string, error) { - toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode)) - if toolCode != toolx.BuiltinConversationContext.Code && toolCode != toolx.BuiltinKnowledgeRetrieve.Code && toolCode != toolx.GraphTriageServiceRequest.Code && toolCode != toolx.GraphAnalyzeConversation.Code && toolCode != toolx.GraphPrepareTicketDraft.Code { - return aitooling.Definition{}, "", fmt.Errorf("tool is not a built-in read tool") - } - if toolCode == toolx.GraphTriageServiceRequest.Code || toolCode == toolx.GraphAnalyzeConversation.Code || toolCode == toolx.GraphPrepareTicketDraft.Code { - return readtools.ExecuteGraphTool(ctx, conversation, toolCode, arguments, policy) - } - definition, err := aitooling.DefaultRegistry.Resolve(toolCode) - if err != nil { - return aitooling.Definition{}, "", err - } - if err := aitooling.DefaultRegistry.Authorize(definition, policy); err != nil { - return definition, "", err - } - if definition.TimeoutMS > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond) - defer cancel() - } - if toolCode == toolx.BuiltinKnowledgeRetrieve.Code { - query, _ := arguments["query"].(string) - contextText, count, err := retrieveAutonomousKnowledge(ctx, agent, query) - if err != nil { - return definition, "", err - } - result, err := json.Marshal(map[string]any{"query": strings.TrimSpace(query), "resultCount": count, "context": contextText}) - return definition, string(result), err - } - result, err := json.Marshal(map[string]any{ - "conversationId": conversation.ID, - "customerName": strings.TrimSpace(conversation.CustomerName), - "lastMessageSummary": strings.TrimSpace(conversation.LastMessageSummary), - "currentAssigneeId": conversation.CurrentAssigneeID, - "recentMessages": autonomousToolConversationMessages(conversation.ID), - }) - if err != nil { - return definition, "", err - } - return definition, string(result), nil -} - -func autonomousToolConversationMessages(conversationID int64) []map[string]string { - if conversationID <= 0 { - return []map[string]string{} - } - items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, 6, "", "") - ret := make([]map[string]string, 0, len(items)) - for _, item := range items { - role := autonomousMessageRole(item) - content := strings.TrimSpace(utils.BuildRuntimeMessageText(item.MessageType, item.Content)) - if role == "" || content == "" { - continue - } - if runes := []rune(content); len(runes) > 240 { - content = string(runes[:240]) + "..." - } - ret = append(ret, map[string]string{"role": role, "content": content}) - } - return ret -} - -func autonomousToolCallCount(records []svc.EngineToolCallInput, toolCode string) int { - toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode)) - count := 0 - for _, item := range records { - if toolx.NormalizeToolCodeAlias(strings.TrimSpace(item.ToolCode)) == toolCode { - count++ - } - } - return count -} - -func retrieveAutonomousKnowledge(ctx context.Context, agent models.AIAgent, query string) (string, int, error) { - retrieved, err := retrievers.NewKnowledgeRetriever(agent, utils.SplitInt64s(agent.KnowledgeIDs)).RetrieveContext(ctx, query) - if err != nil { - return "", 0, err - } - if retrieved == nil { - return "", 0, nil - } - return strings.TrimSpace(retrieved.ContextText), len(retrieved.ContextResults), nil -} - -func buildAutonomousSystemPrompt(agent models.AIAgent, hasKnowledgeBase bool, knowledgeContext string, retrieveErr error) string { - prompt := strings.TrimSpace(agent.SystemPrompt) - if prompt == "" { - prompt = "You are a customer service assistant. Answer accurately, ask for clarification when evidence is insufficient, and do not invent facts." - } - if retrieveErr != nil { - prompt += "\n\nKnowledge retrieval is temporarily unavailable for this message. You may answer greetings, acknowledgements, gratitude, farewells, and requests for clarification naturally. For product facts, policies, pricing, functions, procedures, timing, refunds, accounts, permissions, or after-sales questions, do not claim that any detail is verified. Explain that you cannot verify it now, ask one focused question when useful, or offer human handoff." - } else if hasKnowledgeBase && strings.TrimSpace(knowledgeContext) == "" { - prompt += "\n\nKnowledge retrieval found no supporting evidence for this message. You may answer greetings, acknowledgements, gratitude, farewells, and requests for clarification naturally. For product facts, policies, pricing, functions, procedures, timing, refunds, accounts, permissions, or after-sales questions, do not infer or invent an answer. State that the available information is insufficient, ask one focused question when useful, or offer human handoff." - } - return prompt -} - -func writeAutonomousRun(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount int, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, responsePolicy autonomousResponsePolicy, toolCalls []svc.EngineToolCallInput, cause error) (int64, error) { - endedAt := time.Now() - status := "completed" - errorMessage := "" - outputPreview := "" - promptTokens := 0 - completionTokens := 0 - if cause != nil { - status = "failed" - errorMessage = cause.Error() - } else if result != nil { - outputPreview = strings.TrimSpace(result.Content) - promptTokens = result.PromptTokens - completionTokens = result.CompletionTokens - } - trace, _ := json.Marshal(map[string]any{"engine": EngineCodeAutonomous, "mode": autonomousExecutionMode(autonomousAllowedMCPToolCodes(req.AIAgent.AllowedMCPTools)), "status": status, "historyMessageCount": historyCount, "retrieverCount": retrieverCount}) - var runID int64 - err := sqls.WithTransaction(func(tx *sqls.TxContext) error { - var recordErr error - runID, recordErr = svc.AgentRunService.RecordEngineRun(tx.Tx, svc.EngineAgentRunInput{ - ConversationID: req.Conversation.ID, AIAgentID: req.AIAgent.ID, AgentRevisionID: req.AIAgent.PublishedRevisionID, - SourceMessageID: req.UserMessage.ID, EngineCode: EngineCodeAutonomous, Status: status, - PromptTokens: promptTokens, CompletionTokens: completionTokens, StartedAt: startedAt, EndedAt: &endedAt, - ErrorMessage: errorMessage, TraceData: string(trace), StepType: "model", StepCode: "chat_completion", - StepInputPreview: strings.TrimSpace(inputPreview), StepOutputPreview: outputPreview, - AdditionalSteps: autonomousAdditionalSteps(req, retrieverCount, retrieveErr, skillContext, responsePolicy), - ToolCalls: toolCalls, - }) - return recordErr - }) - return runID, err -} - -func autonomousExecutionMode(allowedTools []string) string { - if len(allowedTools) > 0 { - return "tool_calling_loop" - } - return "single_model_turn" -} - -func autonomousAdditionalSteps(req Request, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, responsePolicy autonomousResponsePolicy) []svc.EngineStepInput { - steps := make([]svc.EngineStepInput, 0, 3) - if len(utils.SplitInt64s(req.AIAgent.SkillIDs)) > 0 { - status := "completed" - if skillContext.ErrorMessage != "" { - status = "failed" - } - steps = append(steps, svc.EngineStepInput{ - StepType: "skill_route", StepCode: "skill_select", Status: status, - InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "selected skill: " + skillContext.SkillName(), - ErrorMessage: skillContext.ErrorMessage, - }) - } - if len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0 { - status := "completed" - errorMessage := "" - if retrieveErr != nil { - status = "failed" - errorMessage = retrieveErr.Error() - } - steps = append(steps, svc.EngineStepInput{ - StepType: "knowledge", StepCode: "knowledge_retrieve", Status: status, - InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "retrieved context items: " + strconv.Itoa(retrieverCount), ErrorMessage: errorMessage, - }) - } - if responsePolicy.Enforced || responsePolicy.Reason != "" { - policyCode := "knowledge_evidence" - if strings.HasPrefix(responsePolicy.Reason, "tool_") { - policyCode = "tool_failure" - } - status := "completed" - if !responsePolicy.Enforced { - status = "advisory" - } - steps = append(steps, svc.EngineStepInput{ - StepType: "policy", StepCode: policyCode, Status: status, - InputPreview: responsePolicy.Reason, OutputPreview: responsePolicy.Action, - }) - } - return steps -} - -var _ Engine = (*AutonomousEngine)(nil) diff --git a/internal/ai/application/runtime/autonomous_turn.go b/internal/ai/application/runtime/autonomous_turn.go deleted file mode 100644 index f752827..0000000 --- a/internal/ai/application/runtime/autonomous_turn.go +++ /dev/null @@ -1,61 +0,0 @@ -package runtime - -import ( - "context" - "strings" - - "agent-desk/internal/ai/runtime/instruction" - "agent-desk/internal/pkg/utils" -) - -// autonomousTurn is the prepared context for an autonomous or hybrid model -// turn. It keeps context assembly separate from engine-specific execution and -// auditing. -type autonomousTurn struct { - SkillContext autonomousSkillContext - RetrieverCount int - RetrieveErr error - ResponsePolicy autonomousResponsePolicy - SystemPrompt string - UserPrompt string - HistoryCount int - AgentAllowedTools []string - AllowedTools []string - ToolPolicy autonomousToolPolicy -} - -func (e *AutonomousEngine) prepareTurn(ctx context.Context, req Request) autonomousTurn { - skillContext := e.selectSkill(ctx, req) - knowledgeContext, retrieverCount, retrieveErr := e.retrieveKnowledge(ctx, req.AIAgent, req.UserMessage.Content) - responsePolicy := evaluateAutonomousResponsePolicy(req.AIAgent, knowledgeContext, retrieveErr) - systemPrompt := buildAutonomousSystemPrompt(req.AIAgent, len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0, knowledgeContext, retrieveErr) - if skillInstruction := strings.TrimSpace(instruction.BuildSkillDocument(skillContext.Skill, nil)); skillInstruction != "" { - systemPrompt += "\n\nSkill instructions:\n" + skillInstruction - } - userPrompt, historyCount := e.buildUserPrompt(req) - if knowledgeContext != "" { - userPrompt += "\n\nKnowledge evidence:\n" + knowledgeContext - } - agentAllowedTools := autonomousAllowedMCPToolCodes(req.AIAgent.AllowedMCPTools) - allowedTools := agentAllowedTools - if skillContext.Skill != nil { - allowedTools = intersectAutonomousToolCodes(agentAllowedTools, skillContext.AllowedToolCodes) - } - if req.Debug { - // Dashboard debug runs may inspect model and retrieval behavior but must - // not invoke direct MCP tools against production integrations. - allowedTools = nil - } - return autonomousTurn{ - SkillContext: skillContext, - RetrieverCount: retrieverCount, - RetrieveErr: retrieveErr, - ResponsePolicy: responsePolicy, - SystemPrompt: systemPrompt, - UserPrompt: userPrompt, - HistoryCount: historyCount, - AgentAllowedTools: agentAllowedTools, - AllowedTools: allowedTools, - ToolPolicy: parseAutonomousToolPolicy(req.AIAgent.ToolPolicy), - } -} diff --git a/internal/ai/application/runtime/eino_agent_loop.go b/internal/ai/application/runtime/eino_agent_loop.go new file mode 100644 index 0000000..dad2c9f --- /dev/null +++ b/internal/ai/application/runtime/eino_agent_loop.go @@ -0,0 +1,159 @@ +package runtime + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + ai "agent-desk/internal/ai" + "agent-desk/internal/models" + + einoopenai "github.com/cloudwego/eino-ext/components/model/openai" + einomodel "github.com/cloudwego/eino/components/model" + einotool "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/flow/agent/react" + "github.com/cloudwego/eino/schema" + einojsonschema "github.com/eino-contrib/jsonschema" +) + +// einoAgentLoop is the production model/tool loop. AgentDesk still owns tool +// authorization, business execution, interrupts, idempotency, and auditing. +func einoAgentLoop( + ctx context.Context, + config models.AIConfig, + systemPrompt string, + userPrompt string, + definitions []ai.ToolDefinition, + maxSteps int, + execute ai.ToolCallExecutor, +) (*ai.ToolLoopResult, error) { + model, err := newEinoChatModel(ctx, config) + if err != nil { + return nil, err + } + if maxSteps <= 0 { + maxSteps = 6 + } + tools := make([]einotool.BaseTool, 0, len(definitions)) + for _, definition := range definitions { + tool, buildErr := newEinoFunctionTool(definition, execute) + if buildErr != nil { + return nil, buildErr + } + tools = append(tools, tool) + } + agent, err := react.NewAgent(ctx, &react.AgentConfig{ + ToolCallingModel: model, + ToolsConfig: compose.ToolsNodeConfig{Tools: tools}, + MaxStep: maxSteps, + }) + if err != nil { + return nil, fmt.Errorf("create Eino agent loop: %w", err) + } + messages := make([]*schema.Message, 0, 2) + if value := strings.TrimSpace(systemPrompt); value != "" { + messages = append(messages, schema.SystemMessage(value)) + } + messages = append(messages, schema.UserMessage(strings.TrimSpace(userPrompt))) + result, err := agent.Generate(ctx, messages) + if err != nil { + return nil, err + } + if result == nil { + return nil, fmt.Errorf("Eino agent loop returned no result") + } + ret := &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{ + Content: strings.TrimSpace(result.Content), + ModelName: config.ModelName, + }} + if result.ResponseMeta != nil && result.ResponseMeta.Usage != nil { + ret.PromptTokens = result.ResponseMeta.Usage.PromptTokens + ret.CompletionTokens = result.ResponseMeta.Usage.CompletionTokens + } + return ret, nil +} + +func newEinoChatModel(ctx context.Context, config models.AIConfig) (einomodel.ToolCallingChatModel, error) { + if strings.TrimSpace(config.APIKey) == "" || strings.TrimSpace(config.BaseURL) == "" || strings.TrimSpace(config.ModelName) == "" { + return nil, fmt.Errorf("ai config base URL, API key, and model name are required") + } + modelConfig := &einoopenai.ChatModelConfig{ + APIKey: strings.TrimSpace(config.APIKey), + BaseURL: strings.TrimSpace(config.BaseURL), + Model: strings.TrimSpace(config.ModelName), + } + if config.TimeoutMS > 0 { + modelConfig.Timeout = time.Duration(config.TimeoutMS) * time.Millisecond + } + if config.MaxOutputTokens > 0 { + maxTokens := config.MaxOutputTokens + modelConfig.MaxCompletionTokens = &maxTokens + } + if isDashScopeQwenThinkingModel(config) { + modelConfig.ExtraFields = map[string]any{"enable_thinking": false} + } + model, err := einoopenai.NewChatModel(ctx, modelConfig) + if err != nil { + return nil, fmt.Errorf("create Eino OpenAI-compatible model: %w", err) + } + return model, nil +} + +func isDashScopeQwenThinkingModel(config models.AIConfig) bool { + baseURL := strings.ToLower(strings.TrimSpace(config.BaseURL)) + modelName := strings.ToLower(strings.TrimSpace(config.ModelName)) + return strings.Contains(baseURL, "dashscope.aliyuncs.com") && strings.HasPrefix(modelName, "qwen3") +} + +type einoFunctionTool struct { + info *schema.ToolInfo + execute ai.ToolCallExecutor +} + +var _ einotool.InvokableTool = (*einoFunctionTool)(nil) + +func newEinoFunctionTool(definition ai.ToolDefinition, execute ai.ToolCallExecutor) (*einoFunctionTool, error) { + if strings.TrimSpace(definition.Name) == "" || execute == nil { + return nil, fmt.Errorf("Eino tool name and executor are required") + } + info := &schema.ToolInfo{ + Name: strings.TrimSpace(definition.Name), + Desc: strings.TrimSpace(definition.Description), + } + if len(definition.Parameters) > 0 { + data, err := json.Marshal(definition.Parameters) + if err != nil { + return nil, fmt.Errorf("encode Eino tool schema: %w", err) + } + var params einojsonschema.Schema + if err := json.Unmarshal(data, ¶ms); err != nil { + return nil, fmt.Errorf("decode Eino tool schema: %w", err) + } + info.ParamsOneOf = schema.NewParamsOneOfByJSONSchema(¶ms) + } + return &einoFunctionTool{info: info, execute: execute}, nil +} + +func (t *einoFunctionTool) Info(context.Context) (*schema.ToolInfo, error) { + return t.info, nil +} + +func (t *einoFunctionTool) InvokableRun(ctx context.Context, arguments string, _ ...einotool.Option) (string, error) { + result, err := t.execute(ctx, ai.ToolCall{Name: t.info.Name, Arguments: arguments}) + if err == nil { + return result, nil + } + var interrupt *agentLoopInterruptError + if errors.As(err, &interrupt) { + return "", err + } + observation, marshalErr := json.Marshal(map[string]string{"error": err.Error()}) + if marshalErr != nil { + return "", err + } + return string(observation), nil +} diff --git a/internal/ai/application/runtime/einoexperiment/confirmation_bridge.go b/internal/ai/application/runtime/einoexperiment/confirmation_bridge.go deleted file mode 100644 index 22da620..0000000 --- a/internal/ai/application/runtime/einoexperiment/confirmation_bridge.go +++ /dev/null @@ -1,102 +0,0 @@ -package einoexperiment - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "strings" - - applicationruntime "agent-desk/internal/ai/application/runtime" - "agent-desk/internal/ai/runtime/graphs" -) - -const confirmationInterruptType = "human_confirm" - -// ConfirmationRequest represents a high-risk Eino tool action that must pause -// at AgentDesk's existing conversation-interrupt boundary. -type ConfirmationRequest struct { - InterruptID string - ToolCode string - Prompt string - Arguments map[string]any -} - -type confirmationCheckpoint struct { - Version int `json:"version"` - Engine string `json:"engine"` - InterruptID string `json:"interruptId"` - ToolCode string `json:"toolCode"` - Arguments map[string]any `json:"arguments"` -} - -// BuildConfirmationResult returns the generic interrupted result consumed by -// replyInterruptService. That service persists ConversationInterrupt from the -// result, so this package remains independent of database writes. -func BuildConfirmationResult(input applicationruntime.RunInput, request ConfirmationRequest) (*applicationruntime.RunResult, error) { - interruptID := strings.TrimSpace(request.InterruptID) - if interruptID == "" { - interruptID = "eino_confirm" - } - toolCode := strings.TrimSpace(request.ToolCode) - if toolCode == "" { - return nil, fmt.Errorf("confirmation tool code is required") - } - prompt := strings.TrimSpace(request.Prompt) - if prompt == "" { - return nil, fmt.Errorf("confirmation prompt is required") - } - checkpointData, err := json.Marshal(confirmationCheckpoint{ - Version: 1, Engine: "eino", InterruptID: interruptID, ToolCode: toolCode, Arguments: cloneConfirmationArguments(request.Arguments), - }) - if err != nil { - return nil, fmt.Errorf("encode Eino confirmation checkpoint: %w", err) - } - return &applicationruntime.RunResult{ - Status: "interrupted", - Interrupted: true, - CheckPointID: confirmationCheckpointID(input, interruptID, toolCode, checkpointData), - CheckPointData: string(checkpointData), - Interrupts: []applicationruntime.InterruptContextSummary{{ - Type: confirmationInterruptType, ID: interruptID, InfoPreview: string(mustMarshalConfirmationPrompt(prompt)), - }}, - }, nil -} - -// ResumeConfirmation reads the generic ResumeInput populated by the existing -// AgentApplicationService and validates that it belongs to this checkpoint. -func ResumeConfirmation(checkPointData string, input applicationruntime.ResumeInput) (string, confirmationCheckpoint, error) { - checkpoint := confirmationCheckpoint{} - if err := json.Unmarshal([]byte(strings.TrimSpace(checkPointData)), &checkpoint); err != nil { - return "", checkpoint, fmt.Errorf("decode Eino confirmation checkpoint: %w", err) - } - if checkpoint.Version != 1 || checkpoint.Engine != "eino" || strings.TrimSpace(checkpoint.InterruptID) == "" || strings.TrimSpace(checkpoint.ToolCode) == "" { - return "", checkpoint, fmt.Errorf("invalid Eino confirmation checkpoint") - } - decision := graphs.ParseConfirmationDecision(strings.TrimSpace(input.ResumeData[checkpoint.InterruptID])) - if decision == "" { - return "", checkpoint, fmt.Errorf("Eino confirmation decision is required") - } - return string(decision), checkpoint, nil -} - -func confirmationCheckpointID(input applicationruntime.RunInput, interruptID, toolCode string, data []byte) string { - digest := sha256.Sum256(append([]byte(strings.TrimSpace(toolCode)+":"+strings.TrimSpace(interruptID)+":"), data...)) - return fmt.Sprintf("eino:%d:%d:%s", input.Conversation.ID, input.UserMessage.ID, hex.EncodeToString(digest[:8])) -} - -func cloneConfirmationArguments(input map[string]any) map[string]any { - if len(input) == 0 { - return map[string]any{} - } - ret := make(map[string]any, len(input)) - for key, value := range input { - ret[key] = value - } - return ret -} - -func mustMarshalConfirmationPrompt(prompt string) []byte { - data, _ := json.Marshal(map[string]string{"message": prompt}) - return data -} diff --git a/internal/ai/application/runtime/einoexperiment/guarded_tool.go b/internal/ai/application/runtime/einoexperiment/guarded_tool.go deleted file mode 100644 index 09613df..0000000 --- a/internal/ai/application/runtime/einoexperiment/guarded_tool.go +++ /dev/null @@ -1,94 +0,0 @@ -package einoexperiment - -import ( - "context" - "encoding/json" - "fmt" - "time" - - aitooling "agent-desk/internal/ai/tooling" - - einotool "github.com/cloudwego/eino/components/tool" - "github.com/cloudwego/eino/schema" -) - -// ToolHandler is the adapter point from an approved Eino experiment tool to -// AgentDesk business services. Production handlers must still call services, -// never repositories. -type ToolHandler func(ctx context.Context, arguments map[string]any) (string, error) - -// ToolTrace is emitted for every guarded invocation. A future Engine adapter -// can translate it into AgentRun tool-call audit records without coupling this -// experiment package to the service layer. -type ToolTrace struct { - ToolCode string - Arguments map[string]any - Status string - Result string - Err error - Duration time.Duration -} - -type ToolTraceHook func(ToolTrace) - -// GuardedTool adapts an Eino InvokableTool to the shared ToolPolicyGuard. It is -// deliberately generic so Tool Registry semantics are checked before a tool -// handler is invoked. -type GuardedTool struct { - InfoDefinition *schema.ToolInfo - Definition aitooling.Definition - Policy aitooling.Policy - Handler ToolHandler - Trace ToolTraceHook -} - -var _ einotool.InvokableTool = (*GuardedTool)(nil) - -func (t *GuardedTool) Info(context.Context) (*schema.ToolInfo, error) { - if t == nil || t.InfoDefinition == nil { - return nil, fmt.Errorf("eino experiment tool info is required") - } - return t.InfoDefinition, nil -} - -func (t *GuardedTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...einotool.Option) (string, error) { - if t == nil || t.Handler == nil { - return "", fmt.Errorf("eino experiment tool handler is required") - } - startedAt := time.Now() - arguments := map[string]any{} - if err := json.Unmarshal([]byte(argumentsInJSON), &arguments); err != nil { - t.emitTrace(arguments, "failed", "", err, startedAt) - return "", fmt.Errorf("decode tool arguments: %w", err) - } - if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{ - Definition: t.Definition, - Arguments: arguments, - Policy: t.Policy, - }); err != nil { - t.emitTrace(arguments, "failed", "", err, startedAt) - return "", err - } - if t.Definition.TimeoutMS > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, time.Duration(t.Definition.TimeoutMS)*time.Millisecond) - defer cancel() - } - result, err := t.Handler(ctx, arguments) - status := "completed" - if err != nil { - status = "failed" - } - t.emitTrace(arguments, status, result, err, startedAt) - return result, err -} - -func (t *GuardedTool) emitTrace(arguments map[string]any, status, result string, err error, startedAt time.Time) { - if t == nil || t.Trace == nil { - return - } - t.Trace(ToolTrace{ - ToolCode: t.Definition.Code, Arguments: arguments, Status: status, Result: result, Err: err, - Duration: time.Since(startedAt), - }) -} diff --git a/internal/ai/application/runtime/einoexperiment/mcp_tool.go b/internal/ai/application/runtime/einoexperiment/mcp_tool.go deleted file mode 100644 index bd37e24..0000000 --- a/internal/ai/application/runtime/einoexperiment/mcp_tool.go +++ /dev/null @@ -1,37 +0,0 @@ -package einoexperiment - -import ( - "context" - "fmt" - "strings" - - "agent-desk/internal/ai/mcps" - runtimetooling "agent-desk/internal/ai/runtime/tooling" - aitooling "agent-desk/internal/ai/tooling" -) - -// MCPToolExecutor is the narrow execution boundary used by the Eino -// experiment. The production MCP executor remains responsible for dynamic -// registry resolution, policy enforcement, timeout, and transport lifecycle. -type MCPToolExecutor interface { - Execute(context.Context, string, map[string]any, aitooling.Policy) (aitooling.Definition, *mcps.ToolCallResult, error) -} - -// NewMCPToolHandler adapts a dynamically discovered MCP tool to GuardedTool. -// Callers must still configure GuardedTool.Definition and Policy so its -// pre-handler guard provides a deterministic rejection before MCP transport. -func NewMCPToolHandler(executor MCPToolExecutor, toolCode string, policy aitooling.Policy) ToolHandler { - return func(ctx context.Context, arguments map[string]any) (string, error) { - if executor == nil { - return "", fmt.Errorf("eino experiment MCP executor is required") - } - definition, result, err := executor.Execute(ctx, strings.TrimSpace(toolCode), arguments, policy) - if err != nil { - return "", err - } - if definition.Code == "" { - return "", fmt.Errorf("MCP executor returned an empty tool definition") - } - return runtimetooling.BuildReducedToolResultSummary(result), nil - } -} diff --git a/internal/ai/application/runtime/einoexperiment/react.go b/internal/ai/application/runtime/einoexperiment/react.go deleted file mode 100644 index beaf307..0000000 --- a/internal/ai/application/runtime/einoexperiment/react.go +++ /dev/null @@ -1,86 +0,0 @@ -// Package einoexperiment contains an isolated Eino ReAct verification path. -// It must not be registered in the production Agent Engine registry. -package einoexperiment - -import ( - "context" - "fmt" - "strings" - "time" - - "agent-desk/internal/models" - - einoopenai "github.com/cloudwego/eino-ext/components/model/openai" - "github.com/cloudwego/eino/components/model" - "github.com/cloudwego/eino/components/tool" - "github.com/cloudwego/eino/compose" - "github.com/cloudwego/eino/flow/agent/react" - "github.com/cloudwego/eino/schema" -) - -// ReActConfig keeps the experiment dependency-injected. The caller owns model -// construction, connection reuse, and all production configuration decisions. -type ReActConfig struct { - Model model.ToolCallingChatModel - Tools []tool.BaseTool - MaxSteps int -} - -// NewOpenAICompatibleModel adapts an existing AgentDesk AI configuration to -// Eino's OpenAI-compatible chat model. It is intentionally not wired into any -// production Engine; the experiment owns the adoption decision. -func NewOpenAICompatibleModel(ctx context.Context, config models.AIConfig) (model.ToolCallingChatModel, error) { - if strings.TrimSpace(config.APIKey) == "" || strings.TrimSpace(config.BaseURL) == "" || strings.TrimSpace(config.ModelName) == "" { - return nil, fmt.Errorf("ai config base URL, API key, and model name are required") - } - modelConfig := &einoopenai.ChatModelConfig{ - APIKey: strings.TrimSpace(config.APIKey), - BaseURL: strings.TrimSpace(config.BaseURL), - Model: strings.TrimSpace(config.ModelName), - } - if config.TimeoutMS > 0 { - modelConfig.Timeout = time.Duration(config.TimeoutMS) * time.Millisecond - } - if config.MaxOutputTokens > 0 { - maxTokens := config.MaxOutputTokens - modelConfig.MaxCompletionTokens = &maxTokens - } - return einoopenai.NewChatModel(ctx, modelConfig) -} - -// NewReAct creates an Eino ReAct agent without registering it with AgentDesk's -// runtime. It is deliberately suitable only for technical verification. -func NewReAct(ctx context.Context, config ReActConfig) (*react.Agent, error) { - if config.Model == nil { - return nil, fmt.Errorf("eino experiment model is required") - } - maxSteps := config.MaxSteps - if maxSteps <= 0 { - maxSteps = 5 - } - return react.NewAgent(ctx, &react.AgentConfig{ - ToolCallingModel: config.Model, - ToolsConfig: compose.ToolsNodeConfig{Tools: config.Tools}, - MaxStep: maxSteps, - }) -} - -// Run performs one non-streaming experiment. Context cancellation is passed -// directly to Eino and the injected model/tools. -func Run(ctx context.Context, config ReActConfig, input []*schema.Message) (*schema.Message, error) { - agent, err := NewReAct(ctx, config) - if err != nil { - return nil, err - } - return agent.Generate(ctx, input) -} - -// Stream performs one streaming experiment. The caller must close the returned -// reader after consuming it. -func Stream(ctx context.Context, config ReActConfig, input []*schema.Message) (*schema.StreamReader[*schema.Message], error) { - agent, err := NewReAct(ctx, config) - if err != nil { - return nil, err - } - return agent.Stream(ctx, input) -} diff --git a/internal/ai/application/runtime/einoexperiment/react_test.go b/internal/ai/application/runtime/einoexperiment/react_test.go deleted file mode 100644 index 8335773..0000000 --- a/internal/ai/application/runtime/einoexperiment/react_test.go +++ /dev/null @@ -1,376 +0,0 @@ -package einoexperiment - -import ( - "context" - "errors" - "sync" - "sync/atomic" - "testing" - "time" - - applicationruntime "agent-desk/internal/ai/application/runtime" - "agent-desk/internal/ai/mcps" - "agent-desk/internal/ai/runtime/graphs" - aitooling "agent-desk/internal/ai/tooling" - "agent-desk/internal/models" - - "github.com/cloudwego/eino/components/model" - "github.com/cloudwego/eino/components/tool" - "github.com/cloudwego/eino/schema" -) - -type scriptedToolCallingModel struct { - responses []*schema.Message - calls int - err error - block bool - lastInput []*schema.Message -} - -type fakeMCPToolExecutor struct { - toolCode string - arguments map[string]any - policy aitooling.Policy - result *mcps.ToolCallResult - err error -} - -type concurrentToolCallingModel struct { - calls atomic.Int32 -} - -var _ model.ToolCallingChatModel = (*concurrentToolCallingModel)(nil) - -func (m *concurrentToolCallingModel) Generate(ctx context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - m.calls.Add(1) - return schema.AssistantMessage("并发调用完成。", nil), nil -} - -func (m *concurrentToolCallingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) { - message, err := m.Generate(ctx, input, opts...) - if err != nil { - return nil, err - } - return schema.StreamReaderFromArray([]*schema.Message{message}), nil -} - -func (m *concurrentToolCallingModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) { - return m, nil -} - -func (e *fakeMCPToolExecutor) Execute(_ context.Context, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, *mcps.ToolCallResult, error) { - e.toolCode = toolCode - e.arguments = arguments - e.policy = policy - return aitooling.Definition{Code: toolCode, RiskLevel: aitooling.RiskLevelRead}, e.result, e.err -} - -var _ model.ToolCallingChatModel = (*scriptedToolCallingModel)(nil) - -func (m *scriptedToolCallingModel) Generate(ctx context.Context, input []*schema.Message, _ ...model.Option) (*schema.Message, error) { - m.lastInput = append([]*schema.Message(nil), input...) - if err := ctx.Err(); err != nil { - return nil, err - } - if m.block { - <-ctx.Done() - return nil, ctx.Err() - } - if m.err != nil { - return nil, m.err - } - if m.calls >= len(m.responses) { - return nil, errors.New("unexpected model call") - } - result := m.responses[m.calls] - m.calls++ - return result, nil -} - -func (m *scriptedToolCallingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) { - message, err := m.Generate(ctx, input, opts...) - if err != nil { - return nil, err - } - return schema.StreamReaderFromArray([]*schema.Message{message}), nil -} - -func (m *scriptedToolCallingModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) { - return m, nil -} - -func TestRunExecutesGuardedToolThenReturnsFinalAnswer(t *testing.T) { - called := false - guardedTool := &GuardedTool{ - InfoDefinition: &schema.ToolInfo{Name: "customer_lookup", Desc: "Read customer data"}, - Definition: aitooling.Definition{Code: "builtin/customer_lookup", RiskLevel: aitooling.RiskLevelRead}, - Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/customer_lookup"}}, - Handler: func(_ context.Context, arguments map[string]any) (string, error) { - called = arguments["customerId"] == "42" - return "customer: Ada", nil - }, - } - model := &scriptedToolCallingModel{responses: []*schema.Message{ - schema.AssistantMessage("", []schema.ToolCall{{ID: "call-1", Type: "function", Function: schema.FunctionCall{Name: "customer_lookup", Arguments: `{"customerId":"42"}`}}}), - schema.AssistantMessage("已找到客户资料。", nil), - }} - - result, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}, MaxSteps: 4}, []*schema.Message{schema.UserMessage("查询客户")}) - if err != nil { - t.Fatalf("Run: %v", err) - } - if !called || result == nil || result.Content != "已找到客户资料。" || model.calls != 2 { - t.Fatalf("unexpected ReAct result: called=%t result=%#v modelCalls=%d", called, result, model.calls) - } -} - -func TestRunInjectsProvidedConversationContext(t *testing.T) { - model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("已理解上下文。", nil)}} - input := []*schema.Message{ - schema.SystemMessage("你是客服助手,优先引用知识库。"), - schema.UserMessage("我的订单状态如何?"), - } - if _, err := Run(context.Background(), ReActConfig{Model: model}, input); err != nil { - t.Fatalf("Run: %v", err) - } - if len(model.lastInput) != len(input) || model.lastInput[0].Content != input[0].Content || model.lastInput[1].Content != input[1].Content { - t.Fatalf("conversation context was not passed to model: %#v", model.lastInput) - } -} - -func TestNewOpenAICompatibleModelValidatesExistingAIConfig(t *testing.T) { - if _, err := NewOpenAICompatibleModel(context.Background(), models.AIConfig{}); err == nil { - t.Fatal("expected incomplete AI config error") - } - configured, err := NewOpenAICompatibleModel(context.Background(), models.AIConfig{ - BaseURL: "https://api.example.test/v1", APIKey: "test-key", ModelName: "test-model", TimeoutMS: 1200, MaxOutputTokens: 256, - }) - if err != nil || configured == nil { - t.Fatalf("expected OpenAI-compatible model adapter, model=%#v err=%v", configured, err) - } -} - -func TestGuardedToolRejectsDisallowedPolicyBeforeHandler(t *testing.T) { - called := false - guardedTool := &GuardedTool{ - InfoDefinition: &schema.ToolInfo{Name: "restricted_lookup", Desc: "Read restricted data"}, - Definition: aitooling.Definition{Code: "builtin/restricted_lookup", RiskLevel: aitooling.RiskLevelRead}, - Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/customer_lookup"}}, - Handler: func(context.Context, map[string]any) (string, error) { - called = true - return "unexpected", nil - }, - } - if _, err := guardedTool.InvokableRun(context.Background(), `{}`); err == nil { - t.Fatal("expected policy rejection") - } - if called { - t.Fatal("handler must not run after policy rejection") - } -} - -func TestRunPropagatesCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("unused", nil)}} - if _, err := Run(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.Canceled) { - t.Fatalf("expected cancellation, got %v", err) - } -} - -func TestRunPropagatesDeadlineDuringModelCall(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancel() - model := &scriptedToolCallingModel{block: true} - if _, err := Run(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("expected deadline propagation, got %v", err) - } -} - -func TestRunPropagatesModelFailure(t *testing.T) { - modelErr := errors.New("model unavailable") - model := &scriptedToolCallingModel{err: modelErr} - if _, err := Run(context.Background(), ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, modelErr) { - t.Fatalf("expected model error propagation, got %v", err) - } -} - -func TestRunPropagatesToolFailure(t *testing.T) { - toolErr := errors.New("customer service unavailable") - guardedTool := &GuardedTool{ - InfoDefinition: &schema.ToolInfo{Name: "failing_lookup", Desc: "Read customer data"}, - Definition: aitooling.Definition{Code: "builtin/failing_lookup", RiskLevel: aitooling.RiskLevelRead}, - Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/failing_lookup"}}, - Handler: func(context.Context, map[string]any) (string, error) { - return "", toolErr - }, - } - model := &scriptedToolCallingModel{responses: []*schema.Message{ - schema.AssistantMessage("", []schema.ToolCall{{ID: "call-1", Type: "function", Function: schema.FunctionCall{Name: "failing_lookup", Arguments: `{}`}}}), - }} - if _, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, toolErr) { - t.Fatalf("expected tool error propagation, got %v", err) - } -} - -func TestGuardedToolEnforcesTimeout(t *testing.T) { - guardedTool := &GuardedTool{ - InfoDefinition: &schema.ToolInfo{Name: "slow_lookup", Desc: "Read customer data"}, - Definition: aitooling.Definition{Code: "builtin/slow_lookup", RiskLevel: aitooling.RiskLevelRead, TimeoutMS: 20}, - Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/slow_lookup"}}, - Handler: func(ctx context.Context, _ map[string]any) (string, error) { - <-ctx.Done() - return "", ctx.Err() - }, - } - if _, err := guardedTool.InvokableRun(context.Background(), `{}`); !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("expected tool timeout, got %v", err) - } -} - -func TestGuardedToolEmitsTraceForPolicyFailure(t *testing.T) { - var trace ToolTrace - guardedTool := &GuardedTool{ - InfoDefinition: &schema.ToolInfo{Name: "restricted_lookup", Desc: "Read restricted data"}, - Definition: aitooling.Definition{Code: "builtin/restricted_lookup", RiskLevel: aitooling.RiskLevelRead}, - Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/other_lookup"}}, - Handler: func(context.Context, map[string]any) (string, error) { - return "unexpected", nil - }, - Trace: func(item ToolTrace) { trace = item }, - } - if _, err := guardedTool.InvokableRun(context.Background(), `{"customerId":"42"}`); err == nil { - t.Fatal("expected policy rejection") - } - if trace.ToolCode != "builtin/restricted_lookup" || trace.Status != "failed" || trace.Err == nil || trace.Arguments["customerId"] != "42" || trace.Duration < 0 { - t.Fatalf("unexpected trace: %#v", trace) - } -} - -func TestMCPToolHandlerUsesSharedExecutorAndReducesResult(t *testing.T) { - executor := &fakeMCPToolExecutor{result: &mcps.ToolCallResult{Content: []mcps.ToolResultContent{{Type: "text", Text: "customer: Ada"}}}} - policy := aitooling.Policy{AllowedToolCodes: []string{"crm/customer_lookup"}, Confirmed: true} - handler := NewMCPToolHandler(executor, "crm/customer_lookup", policy) - result, err := handler(context.Background(), map[string]any{"customerId": "42"}) - if err != nil || result != "customer: Ada" { - t.Fatalf("unexpected MCP handler result=%q err=%v", result, err) - } - if executor.toolCode != "crm/customer_lookup" || executor.arguments["customerId"] != "42" || !executor.policy.Confirmed { - t.Fatalf("unexpected MCP execution: %#v", executor) - } -} - -func TestRunStopsAtConfiguredMaxSteps(t *testing.T) { - guardedTool := &GuardedTool{ - InfoDefinition: &schema.ToolInfo{Name: "loop_lookup", Desc: "Read loop data"}, - Definition: aitooling.Definition{Code: "builtin/loop_lookup", RiskLevel: aitooling.RiskLevelRead}, - Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/loop_lookup"}}, - Handler: func(context.Context, map[string]any) (string, error) { - return "keep going", nil - }, - } - responses := make([]*schema.Message, 8) - for i := range responses { - responses[i] = schema.AssistantMessage("", []schema.ToolCall{{ - ID: "loop-call", Type: "function", Function: schema.FunctionCall{Name: "loop_lookup", Arguments: `{}`}, - }}) - } - model := &scriptedToolCallingModel{responses: responses} - if _, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}, MaxSteps: 2}, []*schema.Message{schema.UserMessage("循环查询")}); err == nil { - t.Fatal("expected configured maximum step limit to stop the loop") - } -} - -func TestStreamReturnsModelOutput(t *testing.T) { - model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("流式回复", nil)}} - stream, err := Stream(context.Background(), ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}) - if err != nil { - t.Fatalf("Stream: %v", err) - } - defer stream.Close() - result, err := schema.ConcatMessageStream(stream) - if err != nil { - t.Fatalf("ConcatMessageStream: %v", err) - } - if result.Content != "流式回复" { - t.Fatalf("unexpected stream result: %#v", result) - } -} - -func TestStreamPropagatesCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("unused", nil)}} - if _, err := Stream(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.Canceled) { - t.Fatalf("expected stream cancellation, got %v", err) - } -} - -func TestRunSupportsConcurrentIndependentCalls(t *testing.T) { - model := &concurrentToolCallingModel{} - const workers = 16 - errs := make(chan error, workers) - var group sync.WaitGroup - for range workers { - group.Add(1) - go func() { - defer group.Done() - result, err := Run(context.Background(), ReActConfig{Model: model, MaxSteps: 3}, []*schema.Message{schema.UserMessage("并发查询")}) - if err != nil { - errs <- err - return - } - if result == nil || result.Content != "并发调用完成。" { - errs <- errors.New("unexpected concurrent result") - } - }() - } - group.Wait() - close(errs) - for err := range errs { - t.Fatal(err) - } - if model.calls.Load() != workers { - t.Fatalf("model calls = %d, want %d", model.calls.Load(), workers) - } -} - -func TestConfirmationBridgeUsesGenericInterruptAndResumeContracts(t *testing.T) { - input := applicationruntime.RunInput{ - Conversation: models.Conversation{ID: 11}, UserMessage: models.Message{ID: 22}, - } - result, err := BuildConfirmationResult(input, ConfirmationRequest{ - InterruptID: "confirm_refund", ToolCode: "graph/create_ticket_with_confirmation", Prompt: "是否确认提交退款工单?", - Arguments: map[string]any{"title": "退款申请"}, - }) - if err != nil { - t.Fatalf("BuildConfirmationResult: %v", err) - } - if !result.Interrupted || result.Status != "interrupted" || result.CheckPointID == "" || len(result.Interrupts) != 1 || result.Interrupts[0].Type != confirmationInterruptType || result.Interrupts[0].ID != "confirm_refund" { - t.Fatalf("unexpected confirmation result: %#v", result) - } - decision, checkpoint, err := ResumeConfirmation(result.CheckPointData, applicationruntime.ResumeInput{ResumeData: map[string]string{"confirm_refund": "确认"}}) - if err != nil || decision != string(graphs.ConfirmationDecisionConfirm) || checkpoint.ToolCode != "graph/create_ticket_with_confirmation" || checkpoint.Arguments["title"] != "退款申请" { - t.Fatalf("unexpected resume bridge decision=%q checkpoint=%#v err=%v", decision, checkpoint, err) - } - decision, _, err = ResumeConfirmation(result.CheckPointData, applicationruntime.ResumeInput{ResumeData: map[string]string{"confirm_refund": "取消"}}) - if err != nil || decision != string(graphs.ConfirmationDecisionCancel) { - t.Fatalf("unexpected cancellation decision=%q err=%v", decision, err) - } -} - -func BenchmarkRunWithInjectedModel(b *testing.B) { - model := &concurrentToolCallingModel{} - input := []*schema.Message{schema.SystemMessage("你是客服助手。"), schema.UserMessage("查询订单状态")} - b.ReportAllocs() - b.ResetTimer() - for range b.N { - result, err := Run(context.Background(), ReActConfig{Model: model, MaxSteps: 3}, input) - if err != nil || result == nil || result.Content == "" { - b.Fatalf("Run result=%#v err=%v", result, err) - } - } -} diff --git a/internal/ai/application/runtime/einoexperiment/real_endpoint_test.go b/internal/ai/application/runtime/einoexperiment/real_endpoint_test.go deleted file mode 100644 index bda684f..0000000 --- a/internal/ai/application/runtime/einoexperiment/real_endpoint_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package einoexperiment - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "agent-desk/internal/bootstrap" - "agent-desk/internal/models" - "agent-desk/internal/pkg/config" - "agent-desk/internal/pkg/enums" - - einoopenai "github.com/cloudwego/eino-ext/components/model/openai" - einomodel "github.com/cloudwego/eino/components/model" - "github.com/cloudwego/eino/schema" -) - -// TestRealOpenAICompatibleEndpoint is intentionally opt-in because it spends -// a small amount of configured model quota. It verifies the production-shaped -// OpenAI-compatible adapter without exposing credentials in test output. -func TestRealOpenAICompatibleEndpoint(t *testing.T) { - if os.Getenv("EINO_EXPERIMENT_REAL") != "1" { - t.Skip("set EINO_EXPERIMENT_REAL=1 to run against the configured endpoint") - } - configPath := strings.TrimSpace(os.Getenv("EINO_EXPERIMENT_CONFIG")) - var err error - if configPath == "" { - configPath, err = findExperimentConfigPath() - if err != nil { - t.Fatal(err) - } - } - workingDir, err := os.Getwd() - if err != nil { - t.Fatalf("get working directory: %v", err) - } - repoRoot := filepath.Dir(filepath.Dir(configPath)) - if err := os.Chdir(repoRoot); err != nil { - t.Fatalf("change to config root: %v", err) - } - t.Cleanup(func() { _ = os.Chdir(workingDir) }) - cfg, err := config.Load(configPath) - if err != nil { - t.Fatalf("load config: %v", err) - } - db, err := bootstrap.InitDB(cfg.DB) - if err != nil { - t.Fatalf("open configured database: %v", err) - } - sqlDB, err := db.DB() - if err == nil { - defer sqlDB.Close() - } - var aiConfig models.AIConfig - if err := db.Where("model_type = ? AND status = ?", enums.AIModelTypeLLM, enums.StatusOk).Order("id").First(&aiConfig).Error; err != nil { - t.Fatalf("load enabled LLM config: %v", err) - } - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(maxInt(aiConfig.TimeoutMS, 30000))*time.Millisecond) - defer cancel() - model, err := NewOpenAICompatibleModel(ctx, aiConfig) - if err != nil { - t.Fatalf("create Eino model adapter: %v", err) - } - input := []*schema.Message{schema.SystemMessage("You are a terse service assistant."), schema.UserMessage("Reply with exactly: OK")} - startedAt := time.Now() - result, err := Run(ctx, ReActConfig{Model: model, MaxSteps: 2}, input) - if err != nil { - t.Fatalf("Eino ReAct request: %v", err) - } - if result == nil || strings.TrimSpace(result.Content) == "" { - t.Fatal("Eino endpoint returned an empty response") - } - if result.ResponseMeta == nil || result.ResponseMeta.Usage == nil { - t.Fatal("Eino endpoint did not return token usage") - } - t.Logf("real endpoint verified: latency=%s promptTokens=%d completionTokens=%d", time.Since(startedAt).Round(time.Millisecond), result.ResponseMeta.Usage.PromptTokens, result.ResponseMeta.Usage.CompletionTokens) - - toolModel, err := model.WithTools([]*schema.ToolInfo{{ - Name: "eino_echo", - Desc: "Echoes a short input. Always call this tool when asked to verify tool calling.", - ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{ - "text": {Type: schema.String, Desc: "Short text to echo", Required: true}, - }), - }}) - if err != nil { - t.Fatalf("bind Eino tool: %v", err) - } - toolResult, err := toolModel.Generate(ctx, []*schema.Message{schema.UserMessage("Verify tool calling by invoking eino_echo with text OK.")}, - einomodel.WithToolChoice(schema.ToolChoiceForced, "eino_echo"), - einoopenai.WithExtraFields(map[string]any{"enable_thinking": false}), - ) - if err != nil { - t.Fatalf("real endpoint tool call: %v", err) - } - if toolResult == nil || len(toolResult.ToolCalls) != 1 || toolResult.ToolCalls[0].Function.Name != "eino_echo" { - t.Fatalf("expected one eino_echo tool call, got %#v", toolResult) - } - - stream, err := model.Stream(ctx, []*schema.Message{schema.UserMessage("Reply with exactly: STREAM_OK")}) - if err != nil { - t.Fatalf("real endpoint stream: %v", err) - } - // ConcatMessageStream consumes and closes the Eino reader. Do not close it - // again here: v0.9.6 treats a second close as a panic. - streamResult, err := schema.ConcatMessageStream(stream) - if err != nil { - t.Fatalf("concat real stream: %v", err) - } - if streamResult == nil || strings.TrimSpace(streamResult.Content) == "" { - t.Fatal("Eino endpoint stream returned an empty response") - } - t.Logf("real endpoint tool and stream verified: toolCalls=%d streamChars=%d", len(toolResult.ToolCalls), len([]rune(streamResult.Content))) -} - -func findExperimentConfigPath() (string, error) { - dir, err := os.Getwd() - if err != nil { - return "", err - } - for { - candidate := filepath.Join(dir, "config", "config.yaml") - if _, err := os.Stat(candidate); err == nil { - return candidate, nil - } - parent := filepath.Dir(dir) - if parent == dir { - return "", os.ErrNotExist - } - dir = parent - } -} - -func maxInt(value, fallback int) int { - if value > 0 { - return value - } - return fallback -} diff --git a/internal/ai/application/runtime/engine.go b/internal/ai/application/runtime/engine.go deleted file mode 100644 index 7ee20b3..0000000 --- a/internal/ai/application/runtime/engine.go +++ /dev/null @@ -1,58 +0,0 @@ -package runtime - -import ( - "context" - "errors" - "strings" - - "agent-desk/internal/pkg/errorsx" -) - -const ( - EngineCodeWorkflow = "workflow" - EngineCodeAutonomous = "autonomous" -) - -// Engine executes one Agent Runtime mode. Implementations must keep business -// mutations behind AgentDesk services and return a normalized RunResult. -type Engine interface { - Code() string - Run(ctx context.Context, req RunInput) (*RunResult, error) - Resume(ctx context.Context, req ResumeInput) (*RunResult, error) -} - -// EngineRegistry resolves the runtime implementation. Workflow is the default -// until Agent runtime modes are persisted on AIAgent in the next migration. -type EngineRegistry struct { - engines map[string]Engine -} - -func NewEngineRegistry(engines ...Engine) *EngineRegistry { - registry := &EngineRegistry{engines: make(map[string]Engine, len(engines))} - for _, engine := range engines { - if engine == nil || strings.TrimSpace(engine.Code()) == "" { - continue - } - registry.engines[strings.TrimSpace(engine.Code())] = engine - } - return registry -} - -func NewDefaultEngineRegistry() *EngineRegistry { - return NewEngineRegistry(NewWorkflowEngine(), NewAutonomousEngine(), NewHybridEngine()) -} - -func (r *EngineRegistry) Resolve(code string) (Engine, error) { - if r == nil { - return nil, errors.New("agent runtime engine registry is not configured") - } - code = strings.TrimSpace(code) - if code == "" { - code = EngineCodeWorkflow - } - engine := r.engines[code] - if engine == nil { - return nil, errorsx.InvalidParam("agent runtime engine does not exist") - } - return engine, nil -} diff --git a/internal/ai/application/runtime/engine_test.go b/internal/ai/application/runtime/engine_test.go deleted file mode 100644 index ebbbc82..0000000 --- a/internal/ai/application/runtime/engine_test.go +++ /dev/null @@ -1,597 +0,0 @@ -package runtime - -import ( - "context" - "fmt" - "strings" - "testing" - - ai "agent-desk/internal/ai" - "agent-desk/internal/ai/skills" - aitooling "agent-desk/internal/ai/tooling" - "agent-desk/internal/models" - "agent-desk/internal/pkg/enums" - "agent-desk/internal/pkg/toolx" - svc "agent-desk/internal/services" - - "github.com/glebarez/sqlite" - "github.com/mlogclub/simple/sqls" - "gorm.io/gorm" - "gorm.io/gorm/schema" -) - -func TestServiceDefaultsToWorkflowEngine(t *testing.T) { - service := NewService() - engine, err := service.registry.Resolve("") - if err != nil { - t.Fatalf("resolve default engine: %v", err) - } - if engine.Code() != EngineCodeWorkflow { - t.Fatalf("expected default engine %q, got %q", EngineCodeWorkflow, engine.Code()) - } -} - -func TestServiceDispatchesRequestedEngine(t *testing.T) { - engine := &runtimeTestEngine{code: "test"} - service := NewServiceWithRegistry(NewEngineRegistry(engine)) - summary, err := service.Run(context.Background(), RunInput{AIAgent: models.AIAgent{RuntimeMode: enums.AIAgentRuntimeMode(engine.code)}}) - if err != nil { - t.Fatalf("run requested engine: %v", err) - } - if !engine.ran || summary == nil || summary.Status != "completed" { - t.Fatalf("unexpected engine dispatch result: engine=%#v summary=%#v", engine, summary) - } -} - -func TestEngineContractKeepsLegacyRequestAliasesCompatible(t *testing.T) { - var _ Engine = (*runtimeTestEngine)(nil) - var input Request = RunInput{} - var result Summary = RunResult{Status: "completed"} - if input.Debug || result.Status != "completed" { - t.Fatalf("unexpected compatibility values: input=%#v result=%#v", input, result) - } -} - -func TestServiceRejectsUnknownEngine(t *testing.T) { - service := NewServiceWithRegistry(NewEngineRegistry()) - if _, err := service.Run(context.Background(), Request{AIAgent: models.AIAgent{RuntimeMode: "missing"}}); err == nil { - t.Fatal("expected unknown engine error") - } -} - -func TestAutonomousEngineRecordsPublishedRevisionRun(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { - t.Fatalf("auto migrate: %v", err) - } - sqls.SetDB(db) - revision := &models.AgentRevision{AgentID: 7, Revision: 1} - if err := db.Create(revision).Error; err != nil { - t.Fatalf("create revision: %v", err) - } - var receivedPrompt string - engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, _ string, prompt string) (*ai.ChatCompletionResult, error) { - receivedPrompt = prompt - return &ai.ChatCompletionResult{Content: "可以协助你处理这个问题。", ModelName: "test-model", PromptTokens: 8, CompletionTokens: 5}, nil - }) - engine.retrieve = func(context.Context, models.AIAgent, string) (string, int, error) { - return "退款需要先确认订单号。", 1, nil - } - summary, err := engine.Run(context.Background(), Request{ - Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "需要帮助"}, - AIAgent: models.AIAgent{ID: 7, PublishedRevisionID: revision.ID, SystemPrompt: "保持专业", KnowledgeIDs: "21"}, AIConfig: models.AIConfig{ModelName: "test-model"}, - }) - if err != nil { - t.Fatalf("Run returned error: %v", err) - } - if summary == nil || summary.AgentRunID <= 0 || summary.ReplyText == "" { - t.Fatalf("unexpected summary: %#v", summary) - } - var run models.AgentRun - if err := db.First(&run, summary.AgentRunID).Error; err != nil { - t.Fatalf("load agent run: %v", err) - } - if run.EngineCode != EngineCodeAutonomous || run.AgentRevisionID != revision.ID || run.Status != "completed" { - t.Fatalf("unexpected agent run: %#v", run) - } - if run.PromptTokens != 8 || !strings.Contains(receivedPrompt, "Knowledge evidence") { - t.Fatalf("expected knowledge evidence in prompt, got %q", receivedPrompt) - } - var steps []models.AgentStep - if err := db.Where("agent_run_id = ?", run.ID).Find(&steps).Error; err != nil || len(steps) != 2 || steps[1].StepType != "knowledge" { - t.Fatalf("expected model and knowledge steps, steps=%#v err=%v", steps, err) - } -} - -func TestAutonomousEngineRecordsRejectedReplyAsFailed(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { - t.Fatalf("auto migrate: %v", err) - } - sqls.SetDB(db) - revision := &models.AgentRevision{AgentID: 15, Revision: 1} - if err := db.Create(revision).Error; err != nil { - t.Fatalf("create revision: %v", err) - } - engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) { - return &ai.ChatCompletionResult{Content: "token=secret-value"}, nil - }) - _, err = engine.Run(context.Background(), Request{UserMessage: models.Message{ID: 2, Content: "help"}, AIAgent: models.AIAgent{ID: 15, PublishedRevisionID: revision.ID}}) - if err == nil { - t.Fatal("expected sensitive model reply to be rejected") - } - var run models.AgentRun - if err := db.Last(&run).Error; err != nil || run.Status != "failed" || strings.Contains(run.ErrorMessage, "secret-value") { - t.Fatalf("expected failed audit run, run=%#v err=%v", run, err) - } -} - -func TestAutonomousEngineBuildsBoundedConversationContext(t *testing.T) { - engine := newAutonomousEngineWithChat(nil) - engine.history = func(conversationID int64, limit int) []models.Message { - if conversationID != 11 || limit != 3 { - t.Fatalf("unexpected history query: conversation=%d limit=%d", conversationID, limit) - } - return []models.Message{ - {ID: 1, SenderType: "customer", MessageType: "text", Content: "之前的问题"}, - {ID: 2, SenderType: "ai", MessageType: "text", Content: "之前的答复"}, - {ID: 3, SenderType: "customer", MessageType: "text", Content: "当前问题"}, - } - } - prompt, count := engine.buildUserPrompt(Request{ - Conversation: models.Conversation{ID: 11}, UserMessage: models.Message{ID: 3, Content: "当前问题", MessageType: "text"}, - AIAgent: models.AIAgent{ContextWindow: 2}, - }) - if count != 2 || !strings.Contains(prompt, "Customer: 之前的问题") || !strings.Contains(prompt, "Assistant: 之前的答复") || strings.Count(prompt, "当前问题") != 1 { - t.Fatalf("unexpected assembled prompt: %q", prompt) - } -} - -func TestAutonomousEngineBuildsCustomerContext(t *testing.T) { - engine := newAutonomousEngineWithChat(nil) - prompt, count := engine.buildUserPrompt(Request{ - Conversation: models.Conversation{CustomerName: "张三", LastMessageSummary: "已咨询退款条件"}, - UserMessage: models.Message{Content: "我要申请退款", MessageType: "text"}, - }) - if count != 0 || !strings.Contains(prompt, "Customer: 张三") || !strings.Contains(prompt, "Recent summary: 已咨询退款条件") || !strings.Contains(prompt, "Current customer message:\n我要申请退款") { - t.Fatalf("unexpected customer context: %q", prompt) - } -} - -func TestAutonomousEngineInjectsSelectedSkillAndRecordsRoute(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { - t.Fatalf("auto migrate: %v", err) - } - sqls.SetDB(db) - revision := &models.AgentRevision{AgentID: 9, Revision: 1} - if err := db.Create(revision).Error; err != nil { - t.Fatalf("create revision: %v", err) - } - var systemPrompt string - engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) { - systemPrompt = system - return &ai.ChatCompletionResult{Content: "我来协助处理退款。", ModelName: "test-model"}, nil - }) - engine.skillSelect = func(context.Context, skills.RuntimeContext) (*skills.ExecutionResult, error) { - return &skills.ExecutionResult{Plan: &skills.ExecutionPlan{ - Skill: &models.SkillDefinition{ID: 70, Name: "退款处理", Instruction: "先核对订单信息。", Examples: `["我要退款"]`, ToolWhitelist: `["support/order_lookup"]`}, - MatchReason: "llm_route", - }, Trace: &skills.ExecutionTrace{Status: "ok", MatchReason: "llm_route"}}, nil - } - summary, err := engine.Run(context.Background(), Request{ - Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "我要退款"}, - AIAgent: models.AIAgent{ID: 9, PublishedRevisionID: revision.ID, SkillIDs: "70", SystemPrompt: "保持简洁"}, AIConfig: models.AIConfig{ModelName: "test-model"}, - }) - if err != nil { - t.Fatalf("Run returned error: %v", err) - } - if summary.PlannedSkillID != 70 || summary.PlannedSkillName != "退款处理" || summary.PlanReason != "llm_route" { - t.Fatalf("unexpected skill summary: %#v", summary) - } - if !strings.Contains(systemPrompt, "先核对订单信息") || !strings.Contains(systemPrompt, "我要退款") { - t.Fatalf("selected skill was not injected into system prompt: %q", systemPrompt) - } - var steps []models.AgentStep - if err := db.Where("agent_run_id = ?", summary.AgentRunID).Find(&steps).Error; err != nil { - t.Fatalf("load steps: %v", err) - } - if len(steps) != 2 || steps[1].StepType != "skill_route" || steps[1].StepCode != "skill_select" { - t.Fatalf("expected model and skill route audit steps, got %#v", steps) - } -} - -func TestAutonomousEngineLetsModelHandleGreetingWithoutKnowledgeEvidence(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { - t.Fatalf("auto migrate: %v", err) - } - sqls.SetDB(db) - revision := &models.AgentRevision{AgentID: 10, Revision: 1} - if err := db.Create(revision).Error; err != nil { - t.Fatalf("create revision: %v", err) - } - var systemPrompt string - engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) { - systemPrompt = system - return &ai.ChatCompletionResult{Content: "你好,有什么可以帮你?", ModelName: "test-model"}, nil - }) - engine.retrieve = func(context.Context, models.AIAgent, string) (string, int, error) { - return "", 0, nil - } - summary, err := engine.Run(context.Background(), Request{ - Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "你好"}, - AIAgent: models.AIAgent{ID: 10, PublishedRevisionID: revision.ID, KnowledgeIDs: "100"}, - AIConfig: models.AIConfig{ModelName: "test-model"}, - }) - if err != nil { - t.Fatalf("Run returned error: %v", err) - } - if summary.ReplyText != "你好,有什么可以帮你?" { - t.Fatalf("unexpected model reply: %#v", summary) - } - if !strings.Contains(systemPrompt, "answer greetings") || !strings.Contains(systemPrompt, "Knowledge retrieval found no supporting evidence") { - t.Fatalf("missing no-evidence greeting instructions: %q", systemPrompt) - } - var steps []models.AgentStep - if err := db.Where("agent_run_id = ?", summary.AgentRunID).Find(&steps).Error; err != nil { - t.Fatalf("load steps: %v", err) - } - if len(steps) != 3 || steps[1].StepType != "knowledge" || steps[2].StepType != "policy" || steps[2].StepCode != "knowledge_evidence" || steps[2].Status != "advisory" || steps[2].OutputPreview != "evidence_required" { - t.Fatalf("expected model, knowledge and policy steps, got %#v", steps) - } -} - -func TestAutonomousEngineInstructsModelNotToInventFactsWithoutKnowledgeEvidence(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { - t.Fatalf("auto migrate: %v", err) - } - sqls.SetDB(db) - revision := &models.AgentRevision{AgentID: 13, Revision: 1} - if err := db.Create(revision).Error; err != nil { - t.Fatalf("create revision: %v", err) - } - var systemPrompt string - engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) { - systemPrompt = system - return &ai.ChatCompletionResult{Content: "我暂时没有查到保修期限的准确依据。请提供产品型号,我再继续查询。", ModelName: "test-model"}, nil - }) - engine.retrieve = func(context.Context, models.AIAgent, string) (string, int, error) { - return "", 0, nil - } - summary, err := engine.Run(context.Background(), Request{ - Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "保修多久"}, - AIAgent: models.AIAgent{ID: 13, PublishedRevisionID: revision.ID, KnowledgeIDs: "100"}, - AIConfig: models.AIConfig{ModelName: "test-model"}, - }) - if err != nil { - t.Fatalf("Run returned error: %v", err) - } - if summary.ReplyText != "我暂时没有查到保修期限的准确依据。请提供产品型号,我再继续查询。" { - t.Fatalf("unexpected model reply: %#v", summary) - } - if !strings.Contains(systemPrompt, "product facts, policies, pricing") || !strings.Contains(systemPrompt, "do not infer or invent an answer") { - t.Fatalf("missing factual-answer evidence constraints: %q", systemPrompt) - } -} - -func TestAutonomousEngineDebugRunDoesNotExposeMCPTools(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { - t.Fatalf("auto migrate: %v", err) - } - sqls.SetDB(db) - revision := &models.AgentRevision{AgentID: 11, Revision: 1} - if err := db.Create(revision).Error; err != nil { - t.Fatalf("create revision: %v", err) - } - engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) { - return &ai.ChatCompletionResult{Content: "调试回复", ModelName: "test-model"}, nil - }) - engine.toolChat = func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error) { - t.Fatal("debug run must not enter tool calling loop") - return nil, nil - } - summary, err := engine.Run(context.Background(), Request{ - Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "查询订单"}, - AIAgent: models.AIAgent{ID: 11, PublishedRevisionID: revision.ID, AllowedMCPTools: `[{"toolCode":"orders/lookup"}]`}, - AIConfig: models.AIConfig{ModelName: "test-model"}, Debug: true, - }) - if err != nil || summary == nil || summary.ReplyText != "调试回复" { - t.Fatalf("unexpected debug run result: summary=%#v err=%v", summary, err) - } -} - -func TestAutonomousEngineUsesPublishedRevisionSnapshot(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { - t.Fatalf("auto migrate: %v", err) - } - sqls.SetDB(db) - revision := &models.AgentRevision{AgentID: 12, Revision: 1, Definition: `{"agent":{"name":"published","aiConfigId":5,"runtimeMode":"autonomous","maxSteps":4,"systemPrompt":"published instruction"},"model":{"configId":5,"provider":"openai","baseUrl":"https://published.example/v1","modelType":"llm","modelName":"published-model","timeoutMs":12000}}`} - if err := db.Create(revision).Error; err != nil { - t.Fatalf("create revision: %v", err) - } - var receivedSystem string - var receivedConfig models.AIConfig - engine := newAutonomousEngineWithChat(func(_ context.Context, config models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) { - receivedSystem = system - receivedConfig = config - return &ai.ChatCompletionResult{Content: "published response", ModelName: config.ModelName}, nil - }) - _, err = engine.Run(context.Background(), Request{ - Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "hello"}, - AIAgent: models.AIAgent{ID: 12, PublishedRevisionID: revision.ID, SystemPrompt: "draft instruction", AIConfigID: 5}, - AIConfig: models.AIConfig{ID: 5, APIKey: "rotated-key", ModelName: "draft-model"}, - }) - if err != nil { - t.Fatalf("Run returned error: %v", err) - } - if !strings.Contains(receivedSystem, "published instruction") || strings.Contains(receivedSystem, "draft instruction") { - t.Fatalf("system prompt did not use published snapshot: %q", receivedSystem) - } - if receivedConfig.ModelName != "published-model" || receivedConfig.BaseURL != "https://published.example/v1" || receivedConfig.APIKey != "rotated-key" { - t.Fatalf("model config did not use safe published snapshot: %#v", receivedConfig) - } -} - -func TestHybridEngineUsesBoundPlaybookAndRecordsGenericAudit(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIWorkflowVersion{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { - t.Fatalf("auto migrate: %v", err) - } - sqls.SetDB(db) - workflowVersion := &models.AIWorkflowVersion{WorkflowID: 21, Version: 1, Status: enums.StatusOk, Definition: `{"schemaVersion":2,"nodes":[{"id":"start_1","type":"start"},{"id":"end_1","type":"end"}],"edges":[{"sourceNodeID":"start_1","targetNodeID":"end_1"}]}`} - if err := db.Create(workflowVersion).Error; err != nil { - t.Fatalf("create workflow version: %v", err) - } - revision := &models.AgentRevision{AgentID: 14, Revision: 1, Status: enums.StatusOk, WorkflowVersionID: workflowVersion.ID, Definition: `{"agent":{"runtimeMode":"hybrid","systemPrompt":"published hybrid prompt","maxSteps":3},"workflowVersionId":1}`} - if err := db.Create(revision).Error; err != nil { - t.Fatalf("create revision: %v", err) - } - engine := NewHybridEngine() - engine.chatWithTools = func(_ context.Context, _ models.AIConfig, system, _ string, definitions []ai.ToolDefinition, _ int, _ ai.ToolCallExecutor) (*ai.ToolLoopResult, error) { - if !strings.Contains(system, "published hybrid prompt") || len(definitions) != 1 || definitions[0].Name != "run_playbook" { - t.Fatalf("unexpected hybrid model context: system=%q definitions=%#v", system, definitions) - } - return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "这是自主回复。", ModelName: "test-model", PromptTokens: 5, CompletionTokens: 4}}, nil - } - summary, err := engine.Run(context.Background(), Request{ - UserMessage: models.Message{ID: 3, Content: "普通咨询"}, - AIAgent: models.AIAgent{ID: 14, RuntimeMode: enums.AIAgentRuntimeModeHybrid, PublishedRevisionID: revision.ID, WorkflowVersionID: workflowVersion.ID}, - AIConfig: models.AIConfig{ModelName: "test-model"}, - }) - if err != nil { - t.Fatalf("Run returned error: %v", err) - } - if summary == nil || summary.AgentRunID <= 0 || summary.WorkflowRunID != 0 || summary.ReplyText != "这是自主回复。" { - t.Fatalf("unexpected hybrid summary: %#v", summary) - } - var run models.AgentRun - if err := db.First(&run, summary.AgentRunID).Error; err != nil { - t.Fatalf("load agent run: %v", err) - } - if run.EngineCode != "hybrid" || run.AgentRevisionID != revision.ID || run.Status != "completed" { - t.Fatalf("unexpected hybrid audit: %#v", run) - } -} - -func TestHybridEngineRejectsPlaybookWhenToolPolicyDisallowsWrites(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIWorkflowVersion{}, &models.AgentRun{}, &models.AgentStep{}); err != nil { - t.Fatalf("auto migrate: %v", err) - } - sqls.SetDB(db) - workflowVersion := &models.AIWorkflowVersion{WorkflowID: 22, Version: 1, Status: enums.StatusOk, Definition: `{"schemaVersion":2,"nodes":[{"id":"start_1","type":"start"},{"id":"end_1","type":"end"}],"edges":[{"sourceNodeID":"start_1","targetNodeID":"end_1"}]}`} - if err := db.Create(workflowVersion).Error; err != nil { - t.Fatalf("create workflow version: %v", err) - } - revision := &models.AgentRevision{AgentID: 15, Revision: 1, Status: enums.StatusOk, WorkflowVersionID: workflowVersion.ID, Definition: `{"agent":{"runtimeMode":"hybrid","systemPrompt":"published hybrid prompt","maxSteps":3,"toolPolicy":"{\"allowedRiskLevels\":[\"read\"]}"},"workflowVersionId":1}`} - if err := db.Create(revision).Error; err != nil { - t.Fatalf("create revision: %v", err) - } - engine := NewHybridEngine() - engine.chatWithTools = func(ctx context.Context, _ models.AIConfig, _ string, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) { - _, err := execute(ctx, ai.ToolCall{Name: "run_playbook", Arguments: fmt.Sprintf(`{"workflowVersionId":%d}`, workflowVersion.ID)}) - return nil, err - } - _, err = engine.Run(context.Background(), Request{ - UserMessage: models.Message{ID: 4, Content: "请执行受控流程"}, - AIAgent: models.AIAgent{ID: 15, RuntimeMode: enums.AIAgentRuntimeModeHybrid, PublishedRevisionID: revision.ID, WorkflowVersionID: workflowVersion.ID}, - AIConfig: models.AIConfig{ModelName: "test-model"}, - }) - if err == nil || !strings.Contains(err.Error(), "tool risk level is not allowed") { - t.Fatalf("expected tool policy rejection, got %v", err) - } -} - -func TestIntersectAutonomousToolCodesUsesSkillWhitelist(t *testing.T) { - got := intersectAutonomousToolCodes([]string{"support/order_lookup", "support/create_ticket"}, []string{"support/order_lookup"}) - if len(got) != 1 || got[0] != "support/order_lookup" { - t.Fatalf("intersection = %#v", got) - } -} - -func TestParseAutonomousToolPolicyAndPerToolCount(t *testing.T) { - policy := parseAutonomousToolPolicy(`{"maxTotalCalls":2,"maxArgumentBytes":1024,"allowedRiskLevels":["read"]}`) - if policy.MaxTotalCalls != 2 || policy.MaxArgumentBytes != 1024 || len(policy.AllowedRiskLevels) != 1 { - t.Fatalf("unexpected policy: %#v", policy) - } - defaults := parseAutonomousToolPolicy(`{"maxTotalCalls":99,"maxArgumentBytes":999999}`) - if defaults.MaxTotalCalls != 3 || defaults.MaxArgumentBytes != 32*1024 { - t.Fatalf("invalid policy did not fall back to safe limits: %#v", defaults) - } - count := autonomousToolCallCount([]svc.EngineToolCallInput{{ToolCode: "orders/lookup"}, {ToolCode: "orders/other"}, {ToolCode: "orders/lookup"}}, "orders/lookup") - if count != 2 { - t.Fatalf("per-tool count = %d, want 2", count) - } -} - -func TestAutonomousKnowledgeEvidencePolicyIsAdvisory(t *testing.T) { - policy := evaluateAutonomousResponsePolicy(models.AIAgent{KnowledgeIDs: "1", FallbackMode: enums.AIAgentFallbackModeHandoff}, "", nil) - if policy.Enforced || policy.RequestHandoff || policy.Action != "evidence_required" || policy.Reason != "knowledge_evidence_missing" { - t.Fatalf("unexpected knowledge evidence policy: %#v", policy) - } -} - -func TestAutonomousToolFailurePolicyRequestsHandoffOnlyWhenConfigured(t *testing.T) { - handoff := autonomousToolFailurePolicy(models.AIAgent{FallbackMode: enums.AIAgentFallbackModeHandoff}, "tool_loop_error") - if !handoff.Enforced || !handoff.RequestHandoff || handoff.Action != "handoff" { - t.Fatalf("unexpected handoff policy: %#v", handoff) - } - clarify := autonomousToolFailurePolicy(models.AIAgent{FallbackMode: enums.AIAgentFallbackModeSuggestRetry}, "tool_loop_error") - if !clarify.Enforced || clarify.RequestHandoff || clarify.Action != "clarify" { - t.Fatalf("unexpected clarify policy: %#v", clarify) - } -} - -func TestAutonomousConversationContextToolUsesRegistryPolicy(t *testing.T) { - definition, result, err := executeAutonomousReadTool(context.Background(), models.Conversation{CustomerName: "张三", LastMessageSummary: "咨询退款"}, models.AIAgent{}, toolx.BuiltinConversationContext.Code, nil, aitooling.Policy{ - AllowedToolCodes: []string{toolx.BuiltinConversationContext.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelRead}, Confirmed: true, - }) - if err != nil || definition.Code != toolx.BuiltinConversationContext.Code || !strings.Contains(result, `"customerName":"张三"`) { - t.Fatalf("unexpected conversation context tool result: definition=%#v result=%q err=%v", definition, result, err) - } - _, _, err = executeAutonomousReadTool(context.Background(), models.Conversation{}, models.AIAgent{}, toolx.BuiltinConversationContext.Code, nil, aitooling.Policy{ - AllowedToolCodes: []string{toolx.BuiltinConversationContext.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelWrite}, Confirmed: true, - }) - if err == nil || !strings.Contains(err.Error(), "risk level") { - t.Fatalf("expected read tool risk rejection, got %v", err) - } -} - -func TestAutonomousEngineExecutesAndAuditsConversationContextTool(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}, &models.Message{}); err != nil { - t.Fatalf("auto migrate: %v", err) - } - sqls.SetDB(db) - revision := &models.AgentRevision{AgentID: 13, Revision: 1} - if err := db.Create(revision).Error; err != nil { - t.Fatalf("create revision: %v", err) - } - if err := db.Create(&models.Message{ConversationID: 1, SenderType: "customer", MessageType: "text", Content: "上一轮问题"}).Error; err != nil { - t.Fatalf("create prior message: %v", err) - } - engine := newAutonomousEngineWithChat(nil) - engine.toolChat = func(ctx context.Context, _ models.AIConfig, _, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) { - output, err := execute(ctx, ai.ToolCall{ID: "call-1", Name: "tool_search", Arguments: `{"toolCode":"builtin/conversation_context","arguments":{}}`}) - if err != nil || !strings.Contains(output, `"customerName":"张三"`) || !strings.Contains(output, "上一轮问题") { - t.Fatalf("execute tool: output=%q err=%v", output, err) - } - output, err = execute(ctx, ai.ToolCall{ID: "call-2", Name: "tool_search", Arguments: `{"toolCode":"graph/prepare_ticket_draft","arguments":{"issue":"重复扣费"}}`}) - if err != nil || !strings.Contains(output, `"title":"重复扣费"`) { - t.Fatalf("execute ticket draft tool: output=%q err=%v", output, err) - } - output, err = execute(ctx, ai.ToolCall{ID: "call-3", Name: "tool_search", Arguments: `{"toolCode":"graph/analyze_conversation","arguments":{"observedIssue":"重复扣费","needTicket":true}}`}) - if err != nil || !strings.Contains(output, `"userIntent":"ticket_request"`) { - t.Fatalf("execute conversation analysis tool: output=%q err=%v", output, err) - } - output, err = execute(ctx, ai.ToolCall{ID: "call-4", Name: "tool_search", Arguments: `{"toolCode":"graph/triage_service_request","arguments":{"observedIssue":"重复扣费","needTicket":true}}`}) - if err != nil || !strings.Contains(output, `"recommendedAction":"prepare_ticket"`) || !strings.Contains(output, `"ticketDraft"`) { - t.Fatalf("execute service triage tool: output=%q err=%v", output, err) - } - return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "已查询到当前会话信息。", ModelName: "test-model"}}, nil - } - summary, err := engine.Run(context.Background(), Request{ - Conversation: models.Conversation{ID: 1, CustomerName: "张三", LastMessageSummary: "咨询退款"}, UserMessage: models.Message{ID: 2, Content: "请查一下当前会话"}, - AIAgent: models.AIAgent{ID: 13, PublishedRevisionID: revision.ID, ToolPolicy: `{"maxTotalCalls":4}`, AllowedMCPTools: `[{"toolCode":"builtin/conversation_context"},{"toolCode":"graph/prepare_ticket_draft"},{"toolCode":"graph/analyze_conversation"},{"toolCode":"graph/triage_service_request"}]`}, - AIConfig: models.AIConfig{ModelName: "test-model"}, - }) - if err != nil { - t.Fatalf("Run returned error: %v", err) - } - _, _, calls := svc.AgentRunService.GetDetail(summary.AgentRunID) - if len(calls) != 4 || calls[0].ToolCode != toolx.BuiltinConversationContext.Code || calls[1].ToolCode != toolx.GraphPrepareTicketDraft.Code || calls[2].ToolCode != toolx.GraphAnalyzeConversation.Code || calls[3].ToolCode != toolx.GraphTriageServiceRequest.Code || calls[3].Status != "completed" { - t.Fatalf("unexpected tool audit: %#v", calls) - } -} - -func TestAutonomousEngineFallsBackAfterConsecutiveToolFailures(t *testing.T) { - db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}}) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}); err != nil { - t.Fatalf("auto migrate: %v", err) - } - sqls.SetDB(db) - revision := &models.AgentRevision{AgentID: 14, Revision: 1} - if err := db.Create(revision).Error; err != nil { - t.Fatalf("create revision: %v", err) - } - engine := newAutonomousEngineWithChat(nil) - engine.toolChat = func(ctx context.Context, _ models.AIConfig, _, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) { - for _, callID := range []string{"call-1", "call-2"} { - _, _ = execute(ctx, ai.ToolCall{ID: callID, Name: "tool_search", Arguments: `{"toolCode":"unknown/unsafe","arguments":{}}`}) - } - return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "model reply should be replaced", ModelName: "test-model"}}, nil - } - summary, err := engine.Run(context.Background(), Request{ - Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "查询订单"}, - AIAgent: models.AIAgent{ID: 14, PublishedRevisionID: revision.ID, FallbackMode: enums.AIAgentFallbackModeHandoff, FallbackMessage: "查询暂不可用,正在转人工。", AllowedMCPTools: `[{"toolCode":"builtin/conversation_context"}]`}, - AIConfig: models.AIConfig{ModelName: "test-model"}, - }) - if err != nil { - t.Fatalf("Run returned error: %v", err) - } - if summary.ReplyText != "查询暂不可用,正在转人工。" || !summary.HandoffRequested { - t.Fatalf("expected handoff fallback after tool failures, got %#v", summary) - } - _, steps, calls := svc.AgentRunService.GetDetail(summary.AgentRunID) - if len(calls) != 2 || calls[0].Status != "failed" || calls[1].Status != "failed" { - t.Fatalf("expected failed tool audits, got %#v", calls) - } - if len(steps) < 2 || steps[len(steps)-1].StepCode != "tool_failure" || steps[len(steps)-1].OutputPreview != "handoff" { - t.Fatalf("expected tool failure policy audit, got %#v", steps) - } -} - -type runtimeTestEngine struct { - code string - ran bool -} - -func (e *runtimeTestEngine) Code() string { - return e.code -} - -func (e *runtimeTestEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) { - e.ran = true - return &RunResult{Status: "completed"}, nil -} - -func (e *runtimeTestEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) { - return &RunResult{Status: "completed"}, nil -} diff --git a/internal/ai/application/runtime/evaluation.go b/internal/ai/application/runtime/evaluation.go index 9fc7fae..d8f3505 100644 --- a/internal/ai/application/runtime/evaluation.go +++ b/internal/ai/application/runtime/evaluation.go @@ -25,7 +25,6 @@ type OfflineEvaluationCase struct { 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"` @@ -34,10 +33,9 @@ type OfflineEvaluationResult struct { } type OfflineEvaluationReport struct { - EngineCode string `json:"engineCode"` - Total int `json:"total"` - Passed int `json:"passed"` - Results []OfflineEvaluationResult `json:"results"` + Total int `json:"total"` + Passed int `json:"passed"` + Results []OfflineEvaluationResult `json:"results"` } // OfflineEvaluationRunner executes only isolated Debug requests. The supplied @@ -51,10 +49,10 @@ func NewOfflineEvaluationRunner(run func(context.Context, RunInput) (*RunResult, 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))} +func (r *OfflineEvaluationRunner) Run(ctx context.Context, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) OfflineEvaluationReport { + report := OfflineEvaluationReport{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} + result := OfflineEvaluationResult{CaseID: strings.TrimSpace(item.ID), Category: strings.TrimSpace(item.Category)} if r == nil || r.run == nil { result.Error, result.Finding = "evaluation runner is not configured", "runner_missing" report.Results = append(report.Results, result) @@ -89,11 +87,11 @@ func (r *OfflineEvaluationRunner) Run(ctx context.Context, engineCode string, ag func (r OfflineEvaluationReport) CSV() (string, error) { var output strings.Builder writer := csv.NewWriter(&output) - if err := writer.Write([]string{"caseId", "category", "engineCode", "passed", "interrupted", "finding", "error", "replyText"}); err != nil { + if err := writer.Write([]string{"caseId", "category", "passed", "interrupted", "finding", "error", "replyText"}); err != nil { return "", err } for _, item := range r.Results { - if err := writer.Write([]string{item.CaseID, item.Category, item.EngineCode, strconv.FormatBool(item.Passed), strconv.FormatBool(item.Interrupted), item.Finding, item.Error, item.ReplyText}); err != nil { + if err := writer.Write([]string{item.CaseID, item.Category, strconv.FormatBool(item.Passed), strconv.FormatBool(item.Interrupted), item.Finding, item.Error, item.ReplyText}); err != nil { return "", err } } diff --git a/internal/ai/application/runtime/evaluation_test.go b/internal/ai/application/runtime/evaluation_test.go deleted file mode 100644 index daea803..0000000 --- a/internal/ai/application/runtime/evaluation_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package runtime - -import ( - "context" - "strings" - "testing" - - "agent-desk/internal/models" -) - -func TestOfflineEvaluationRunnerUsesDebugIsolationAndExportsCSV(t *testing.T) { - var received []RunInput - runner := NewOfflineEvaluationRunner(func(_ context.Context, input RunInput) (*RunResult, error) { - received = append(received, input) - return &RunResult{ReplyText: "已根据知识库回答。"}, nil - }) - report := runner.Run(context.Background(), "autonomous", models.AIAgent{ID: 12}, models.AIConfig{ID: 13}, []OfflineEvaluationCase{{ID: "faq", Category: "faq", Message: "保修期多久", History: []string{"客户:你好"}}}) - if report.Total != 1 || report.Passed != 1 || len(received) != 1 || !received[0].Debug || received[0].Conversation.ID != 0 || received[0].UserMessage.RequestID != "offline-eval:faq" { - t.Fatalf("unexpected report or input: report=%#v input=%#v", report, received) - } - csv, err := report.CSV() - if err != nil || !strings.Contains(csv, "caseId,category,engineCode") || !strings.Contains(csv, "faq,faq,autonomous,true") { - t.Fatalf("unexpected csv=%q err=%v", csv, err) - } -} - -func TestOfflineEvaluationRunnerChecksConfirmationExpectation(t *testing.T) { - runner := NewOfflineEvaluationRunner(func(context.Context, RunInput) (*RunResult, error) { - return &RunResult{ReplyText: "已转人工"}, nil - }) - report := runner.Run(context.Background(), "workflow", models.AIAgent{}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "handoff", Expect: map[string]any{"requiresConfirmation": true}}}) - if report.Passed != 0 || report.Results[0].Finding != "confirmation_not_reached" { - t.Fatalf("unexpected report: %#v", report) - } -} - -func TestOfflineEvaluationRunnerChecksWriteToolLimit(t *testing.T) { - runner := NewOfflineEvaluationRunner(func(context.Context, RunInput) (*RunResult, error) { - return &RunResult{ReplyText: "调试回复", InvokedToolCodes: []string{"graph/handoff_to_human"}}, nil - }) - report := runner.Run(context.Background(), "hybrid", models.AIAgent{}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "write", Expect: map[string]any{"maxWriteToolCalls": 0}}}) - if report.Passed != 0 || report.Results[0].Finding != "write_tool_limit_exceeded" { - t.Fatalf("unexpected report: %#v", report) - } -} - -func TestServiceRunsOfflineEvaluationWithExplicitEngine(t *testing.T) { - engine := &runtimeTestEngine{code: "evaluation"} - service := NewServiceWithRegistry(NewEngineRegistry(engine)) - report, err := service.RunOfflineEvaluation(context.Background(), "evaluation", models.AIAgent{RuntimeMode: "workflow"}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "case"}}) - if err != nil || !engine.ran || report.EngineCode != "evaluation" || report.Total != 1 { - t.Fatalf("unexpected evaluation report=%#v engine=%#v err=%v", report, engine, err) - } -} diff --git a/internal/ai/application/runtime/hybrid_engine.go b/internal/ai/application/runtime/hybrid_engine.go deleted file mode 100644 index a4d19af..0000000 --- a/internal/ai/application/runtime/hybrid_engine.go +++ /dev/null @@ -1,243 +0,0 @@ -package runtime - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - ai "agent-desk/internal/ai" - aitooling "agent-desk/internal/ai/tooling" - workflowregistry "agent-desk/internal/ai/workflow/registry" - workflowvalidator "agent-desk/internal/ai/workflow/validator" - "agent-desk/internal/models" - "agent-desk/internal/pkg/errorsx" - "agent-desk/internal/pkg/utils" - svc "agent-desk/internal/services" - - "github.com/mlogclub/simple/sqls" -) - -const hybridPlaybookToolCode = "playbook/run" - -// HybridEngine lets the model choose whether to enter the Agent's one bound -// deterministic Playbook. The Playbook itself is always run by WorkflowEngine. -type HybridEngine struct { - chatWithTools func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error) - autonomous *AutonomousEngine - workflow *WorkflowEngine -} - -func NewHybridEngine() *HybridEngine { - return &HybridEngine{ - chatWithTools: ai.LLM.ChatWithTools, - autonomous: NewAutonomousEngine(), - workflow: NewWorkflowEngine(), - } -} - -func (e *HybridEngine) Code() string { - return "hybrid" -} - -func (e *HybridEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) { - startedAt := time.Now() - req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content) - snapshot, err := svc.AgentRevisionService.ResolvePublishedSnapshot(req.AIAgent, req.AIConfig) - if err != nil { - return nil, err - } - req.AIAgent, req.AIConfig = snapshot.Agent, snapshot.AIConfig - workflowVersionIDs := make([]int64, 0, len(snapshot.WorkflowBindings)) - workflowTools := make([]ai.ToolDefinition, 0, len(snapshot.WorkflowBindings)) - for _, binding := range snapshot.WorkflowBindings { - if binding.WorkflowVersionID <= 0 { - continue - } - versionAgent := req.AIAgent - versionAgent.WorkflowVersionID = binding.WorkflowVersionID - workflow, resolveErr := resolveAgentWorkflow(versionAgent) - if resolveErr != nil || !workflowvalidator.ValidateDefinition(workflow.Definition, workflowregistry.DefaultRegistry()).Valid { - return nil, errorsx.InvalidParam("hybrid agent workflow binding is invalid") - } - workflowVersionIDs = append(workflowVersionIDs, binding.WorkflowVersionID) - workflowTools = append(workflowTools, hybridWorkflowToolDefinition(binding.WorkflowVersionID, binding.ToolName, binding.TriggerInstruction)) - } - if len(workflowVersionIDs) == 0 && req.AIAgent.WorkflowVersionID > 0 { - workflow, resolveErr := resolveAgentWorkflow(req.AIAgent) - if resolveErr != nil || !workflowvalidator.ValidateDefinition(workflow.Definition, workflowregistry.DefaultRegistry()).Valid { - return nil, errorsx.InvalidParam("hybrid agent workflow binding is invalid") - } - workflowVersionIDs = append(workflowVersionIDs, req.AIAgent.WorkflowVersionID) - workflowTools = append(workflowTools, hybridWorkflowToolDefinition(req.AIAgent.WorkflowVersionID, "", "")) - } - if len(workflowVersionIDs) == 0 { - return nil, errorsx.InvalidParam("hybrid agent requires a published workflow") - } - - turn := e.autonomous.prepareTurn(ctx, req) - if turn.ResponsePolicy.Enforced { - return writeHybridResult(req, startedAt, &ai.ChatCompletionResult{Content: turn.ResponsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}, "", 0, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, nil, turn.ResponsePolicy, nil) - } - turn.SystemPrompt += "\n\nWhen a deterministic business process is required, use the matching workflow tool. Do not call workflows for ordinary factual questions." - - var playbookSummary *Summary - toolCalls := make([]svc.EngineToolCallInput, 0, 1) - loop, err := e.chatWithTools(ctx, req.AIConfig, turn.SystemPrompt, turn.UserPrompt, workflowTools, req.AIAgent.MaxSteps, func(ctx context.Context, call ai.ToolCall) (string, error) { - if call.Name != "run_playbook" { - return "", fmt.Errorf("unsupported hybrid tool: %s", call.Name) - } - if len(toolCalls) >= 1 { - return "", fmt.Errorf("playbook call limit reached") - } - workflowVersionID, err := parseHybridPlaybookCall(call.Arguments) - if err != nil { - return "", err - } - if !containsWorkflowVersion(workflowVersionIDs, workflowVersionID) { - return "", fmt.Errorf("playbook is not allowed") - } - playbookDefinition := aitooling.Definition{Code: hybridPlaybookToolCode, Name: "run_playbook", RiskLevel: aitooling.RiskLevelWrite, RequireConfirmation: true, MaxCallsPerRun: 1} - if err := aitooling.DefaultRegistry.Authorize(playbookDefinition, aitooling.Policy{ - AllowedRiskLevels: turn.ToolPolicy.AllowedRiskLevels, - CallCount: len(toolCalls), - TotalCallCount: len(toolCalls), - MaxTotalCalls: 1, - Confirmed: true, // Workflow validation guarantees a human-confirm predecessor for high-risk nodes. - }); err != nil { - return "", err - } - callStartedAt := time.Now() - workflowReq := req - workflowReq.AIAgent.WorkflowVersionID = workflowVersionID - playbookSummary, err = e.workflow.Run(ctx, workflowReq) - toolRecord := svc.EngineToolCallInput{ToolCode: hybridPlaybookToolCode, RiskLevel: "write", RequireConfirm: true, ArgumentsPreview: call.Arguments, DurationMS: int(time.Since(callStartedAt).Milliseconds())} - if err != nil { - toolRecord.Status, toolRecord.ErrorMessage = "failed", err.Error() - toolCalls = append(toolCalls, toolRecord) - return "", err - } - toolRecord.Status = "completed" - toolRecord.ResultPreview = fmt.Sprintf("workflowRunId=%d status=%s", playbookSummary.WorkflowRunID, playbookSummary.Status) - toolCalls = append(toolCalls, toolRecord) - data, _ := json.Marshal(map[string]any{"workflowRunId": playbookSummary.WorkflowRunID, "status": playbookSummary.Status, "replyText": playbookSummary.ReplyText, "interrupted": playbookSummary.Interrupted}) - return string(data), nil - }) - if err != nil { - _, _ = writeHybridAudit(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, toolCalls, turn.ResponsePolicy, false, err) - return nil, err - } - if playbookSummary != nil && playbookSummary.Interrupted { - runID, auditErr := writeHybridAudit(req, startedAt, &ai.ChatCompletionResult{Content: playbookSummary.ReplyText, ModelName: playbookSummary.ModelName, PromptTokens: playbookSummary.PromptTokens, CompletionTokens: playbookSummary.CompletionTokens}, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, toolCalls, turn.ResponsePolicy, true, nil) - if auditErr != nil { - return nil, auditErr - } - playbookSummary.AgentRunID = runID - return playbookSummary, nil - } - if loop == nil || strings.TrimSpace(loop.Content) == "" { - err = errorsx.InvalidParam("hybrid engine returned an empty reply") - _, _ = writeHybridAudit(req, startedAt, nil, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, toolCalls, turn.ResponsePolicy, false, err) - return nil, err - } - return writeHybridResult(req, startedAt, &loop.ChatCompletionResult, turn.UserPrompt, turn.HistoryCount, turn.RetrieverCount, turn.RetrieveErr, turn.SkillContext, playbookSummary, turn.ResponsePolicy, toolCalls) -} - -func (e *HybridEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) { - interrupt := svc.ConversationInterruptService.GetByCheckPointID(req.CheckPointID) - summary, err := e.workflow.Resume(ctx, req) - if err != nil || summary == nil || interrupt == nil || interrupt.AgentRunID <= 0 { - return summary, err - } - if err := sqls.WithTransaction(func(tx *sqls.TxContext) error { - return svc.AgentRunService.RecordHybridPlaybookResume(tx.Tx, interrupt.AgentRunID, summary.WorkflowRunID, summary.Status, summary.ReplyText) - }); err != nil { - return nil, err - } - // The resumed WorkflowRun is a child audit artifact. Keep the original - // Hybrid run as the summary run surfaced to the conversation caller. - summary.AgentRunID = interrupt.AgentRunID - return summary, nil -} - -func hybridWorkflowToolDefinition(workflowVersionID int64, toolName, instruction string) ai.ToolDefinition { - description := "Run this Agent's published deterministic workflow when the customer needs the controlled business action." - if strings.TrimSpace(toolName) != "" { - description += " Workflow: " + toolName + "." - } - if strings.TrimSpace(instruction) != "" { - description += " Use when: " + instruction - } - return ai.ToolDefinition{Name: "run_playbook", Description: description, Parameters: map[string]any{ - "type": "object", "properties": map[string]any{"workflowVersionId": map[string]any{"type": "integer", "description": fmt.Sprintf("The allowed workflow version (%d).", workflowVersionID)}}, "required": []string{"workflowVersionId"}, - }} -} - -func containsWorkflowVersion(items []int64, value int64) bool { - for _, item := range items { - if item == value { - return true - } - } - return false -} - -func parseHybridPlaybookCall(raw string) (int64, error) { - var input struct { - WorkflowVersionID int64 `json:"workflowVersionId"` - } - if err := json.Unmarshal([]byte(raw), &input); err != nil || input.WorkflowVersionID <= 0 { - return 0, errorsx.InvalidParam("invalid playbook call") - } - return input.WorkflowVersionID, nil -} - -func writeHybridResult(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, playbook *Summary, responsePolicy autonomousResponsePolicy, toolCalls []svc.EngineToolCallInput) (*Summary, error) { - runID, err := writeHybridAudit(req, startedAt, result, inputPreview, historyCount, retrieverCount, retrieveErr, skillContext, toolCalls, responsePolicy, false, nil) - if err != nil { - return nil, err - } - return &Summary{Status: "completed", ReplyText: strings.TrimSpace(result.Content), ModelName: result.ModelName, PromptTokens: result.PromptTokens, CompletionTokens: result.CompletionTokens, HistoryMessageCount: historyCount, RetrieverCount: retrieverCount, AgentRunID: runID, WorkflowRunID: workflowRunIDFromSummary(playbook)}, nil -} - -func writeHybridAudit(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, toolCalls []svc.EngineToolCallInput, responsePolicy autonomousResponsePolicy, interrupted bool, cause error) (int64, error) { - endedAt := time.Now() - status, errorMessage, outputPreview := "completed", "", "" - promptTokens, completionTokens := 0, 0 - if interrupted { - status = "interrupted" - } else if cause != nil { - status, errorMessage = "failed", cause.Error() - } else if result != nil { - outputPreview, promptTokens, completionTokens = strings.TrimSpace(result.Content), result.PromptTokens, result.CompletionTokens - } - steps := autonomousAdditionalSteps(req, retrieverCount, retrieveErr, skillContext, responsePolicy) - for _, call := range toolCalls { - if call.ToolCode == hybridPlaybookToolCode { - steps = append(steps, svc.EngineStepInput{StepType: "playbook", StepCode: hybridPlaybookToolCode, WorkflowRunID: workflowRunIDFromToolResult(call.ResultPreview), Status: call.Status, InputPreview: call.ArgumentsPreview, OutputPreview: call.ResultPreview, ErrorMessage: call.ErrorMessage}) - } - } - var runID int64 - err := sqls.WithTransaction(func(tx *sqls.TxContext) error { - var recordErr error - runID, recordErr = svc.AgentRunService.RecordEngineRun(tx.Tx, svc.EngineAgentRunInput{ConversationID: req.Conversation.ID, AIAgentID: req.AIAgent.ID, AgentRevisionID: req.AIAgent.PublishedRevisionID, SourceMessageID: req.UserMessage.ID, EngineCode: "hybrid", Status: status, PromptTokens: promptTokens, CompletionTokens: completionTokens, StartedAt: startedAt, EndedAt: &endedAt, ErrorMessage: errorMessage, TraceData: `{"engine":"hybrid"}`, StepType: "model", StepCode: "chat_completion", StepInputPreview: inputPreview, StepOutputPreview: outputPreview, AdditionalSteps: steps, ToolCalls: toolCalls}) - return recordErr - }) - return runID, err -} - -func workflowRunIDFromSummary(summary *Summary) int64 { - if summary == nil { - return 0 - } - return summary.WorkflowRunID -} - -func workflowRunIDFromToolResult(value string) int64 { - var id int64 - _, _ = fmt.Sscanf(value, "workflowRunId=%d", &id) - return id -} - -var _ Engine = (*HybridEngine)(nil) diff --git a/internal/ai/application/runtime/service.go b/internal/ai/application/runtime/service.go index 1ec4348..20fb8ed 100644 --- a/internal/ai/application/runtime/service.go +++ b/internal/ai/application/runtime/service.go @@ -8,15 +8,13 @@ import ( workflowexecutor "agent-desk/internal/ai/runtime/workflow" "agent-desk/internal/models" - "agent-desk/internal/pkg/enums" "agent-desk/internal/repositories" - svc "agent-desk/internal/services" "github.com/mlogclub/simple/sqls" ) type Service struct { - registry *EngineRegistry + engine *AgentLoopEngine } const ( @@ -26,46 +24,27 @@ const ( ) func NewService() *Service { - return NewServiceWithRegistry(NewDefaultEngineRegistry()) + return NewServiceWithEngine(NewAgentLoopEngine()) } -func NewServiceWithRegistry(registry *EngineRegistry) *Service { - return &Service{registry: registry} +func NewServiceWithEngine(engine *AgentLoopEngine) *Service { + return &Service{engine: engine} } 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 engine.Run(ctx, req) + return s.engine.Run(ctx, req) } 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 - } - return engine.Resume(ctx, req) + return s.engine.Resume(ctx, req) } -// 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 - } - runner := NewOfflineEvaluationRunner(engine.Run) - return runner.Run(ctx, engine.Code(), agent, config, cases), nil +func (s *Service) RunOfflineEvaluation(ctx context.Context, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) (OfflineEvaluationReport, error) { + runner := NewOfflineEvaluationRunner(s.engine.Run) + return runner.Run(ctx, agent, config, cases), nil } -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 { +func toWorkflowResult(result *workflowexecutor.Result, modelName string, workflow resolvedWorkflow, workflowRunID int64) *RunResult { if result == nil { return nil } @@ -77,7 +56,7 @@ func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workfl "nodePath": result.NodePath, } traceData, _ := json.Marshal(trace) - return &Summary{ + return &RunResult{ Status: result.Status, ReplyText: result.ReplyText, ModelName: modelName, @@ -87,7 +66,6 @@ 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, @@ -112,63 +90,13 @@ func toWorkflowInterruptSummaries(items []workflowexecutor.InterruptSummary) []I return ret } -func writeWorkflowRun(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string) (int64, int64, error) { +func writeWorkflowRun(req RunInput, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string) (int64, error) { return writeWorkflowRunWithExistingID(req, workflow, result, errorMessage, 0) } -func writeWorkflowPrepareFailedRun(req Request, errorMessage string) (int64, error) { - now := time.Now() - endedAt := now - workflowID := int64(0) - workflowVersionID := req.AIAgent.WorkflowVersionID - if workflowVersionID > 0 { - if version := repositories.AIWorkflowVersionRepository.Get(sqls.DB(), workflowVersionID); version != nil { - workflowID = version.WorkflowID - } - } - run := &models.AIWorkflowRun{ - WorkflowID: workflowID, - WorkflowVersionID: workflowVersionID, - ConversationID: req.Conversation.ID, - AIAgentID: req.AIAgent.ID, - MessageID: req.UserMessage.ID, - Status: workflowRunStatusFailed, - StartedAt: now, - EndedAt: &endedAt, - ErrorMessage: errorMessage, - } - err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { - if err := repositories.AIWorkflowRunRepository.Create(ctx.Tx, run); err != nil { - return err - } - traceData, _ := json.Marshal(map[string]any{ - "status": "error", - "workflowId": workflowID, - "workflowVersionId": workflowVersionID, - "workflowRunId": run.ID, - }) - _, err := svc.AgentRunService.RecordWorkflowRun(ctx.Tx, svc.WorkflowAgentRunInput{ - WorkflowRunID: run.ID, - WorkflowVersionID: workflowVersionID, - ConversationID: req.Conversation.ID, - AIAgentID: req.AIAgent.ID, - SourceMessageID: req.UserMessage.ID, - Status: "failed", - StartedAt: now, - EndedAt: &endedAt, - ErrorMessage: errorMessage, - TraceData: string(traceData), - StepInputPreview: "workflow preparation", - StepOutputPreview: "", - }) - return err - }) - return run.ID, err -} - -func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string, existingRunID int64) (int64, int64, error) { +func writeWorkflowRunWithExistingID(req RunInput, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string, existingRunID int64) (int64, error) { if result == nil { - return 0, 0, nil + return 0, nil } now := time.Now() endedAt := now @@ -178,7 +106,6 @@ 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 { @@ -230,36 +157,9 @@ 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, agentRunID, err + return runID, err } func workflowAgentRunStatus(status string, errorMessage string) string { diff --git a/internal/ai/application/runtime/types.go b/internal/ai/application/runtime/types.go index d79dabf..bc7128b 100644 --- a/internal/ai/application/runtime/types.go +++ b/internal/ai/application/runtime/types.go @@ -5,9 +5,7 @@ import ( "time" ) -// RunInput is the normalized, fully prepared input shared by all Engine -// implementations. Persistent adapters load this object before dispatching -// into the runtime. +// RunInput is the normalized, fully prepared input for the Agent Loop. type RunInput struct { Conversation models.Conversation UserMessage models.Message @@ -17,12 +15,7 @@ type RunInput struct { Debug bool } -// Request remains as a compatibility alias while callers move to RunInput. -type Request = RunInput - // ResumeInput extends the prepared input with an approved interrupt payload. -// 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 @@ -33,25 +26,19 @@ type ResumeInput struct { 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"` } -// RunResult is the normalized result returned by every Engine. Engine-specific -// details are represented by optional fields rather than engine-specific DTOs. +// RunResult is the normalized Agent Loop result. type RunResult struct { RunID string Status string ReplyText string PlannedSkillID int64 PlannedSkillName string - PlanReason string - SkillRouteTrace string SkillAllowedToolCodes []string ModelName string PromptTokens int @@ -59,7 +46,6 @@ type RunResult struct { HistoryMessageCount int RetrieverCount int ToolCallCount int - ToolCodes []string InvokedToolCodes []string WorkflowID int64 WorkflowVersionID int64 @@ -75,9 +61,6 @@ type RunResult struct { ErrorMessage string } -// Summary remains as a compatibility alias while callers move to RunResult. -type Summary = RunResult - type StreamEventType string const ( @@ -88,9 +71,7 @@ const ( 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. +// StreamEvent is the transport-neutral event contract for future streaming. type StreamEvent struct { Type StreamEventType `json:"type"` RunID string `json:"runId,omitempty"` diff --git a/internal/ai/application/runtime/workflow_engine.go b/internal/ai/application/runtime/workflow_engine.go deleted file mode 100644 index 5d6806b..0000000 --- a/internal/ai/application/runtime/workflow_engine.go +++ /dev/null @@ -1,112 +0,0 @@ -package runtime - -import ( - "context" - "strings" - - workflowexecutor "agent-desk/internal/ai/runtime/workflow" - "agent-desk/internal/pkg/errorsx" - "agent-desk/internal/pkg/utils" - "agent-desk/internal/repositories" - - "github.com/mlogclub/simple/sqls" -) - -// WorkflowEngine preserves the existing FlowGram DSL execution path as the -// first Agent Runtime engine. It remains the compatibility default for agents -// created before autonomous and hybrid modes are available. -type WorkflowEngine struct{} - -func NewWorkflowEngine() *WorkflowEngine { - return &WorkflowEngine{} -} - -func (e *WorkflowEngine) Code() string { - return EngineCodeWorkflow -} - -func (e *WorkflowEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) { - req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content) - aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent) - if err != nil { - _, _ = writeWorkflowPrepareFailedRun(req, err.Error()) - return nil, err - } - req.AIAgent = aiAgent - workflowResult, err := workflowexecutor.NewExecutor().Execute(ctx, workflowexecutor.Input{ - Definition: workflow.Definition, - Conversation: req.Conversation, - UserMessage: req.UserMessage, - AIAgent: req.AIAgent, - AIConfig: req.AIConfig, - Debug: req.Debug, - }) - if err != nil { - if workflowResult != nil { - _, _, _ = writeWorkflowRun(req, workflow, workflowResult, err.Error()) - } - return nil, err - } - workflowRunID, agentRunID, err := writeWorkflowRun(req, workflow, workflowResult, "") - if err != nil { - return nil, err - } - return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID, agentRunID), nil -} - -func (e *WorkflowEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) { - aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent) - if err != nil { - return nil, err - } - req.AIAgent = aiAgent - interrupt := repositories.ConversationInterruptRepository.GetByCheckPointID(sqls.DB(), req.CheckPointID) - if interrupt == nil { - return nil, errorsx.InvalidParam("legacy checkpoint is not supported; please start a new workflow reply") - } - if strings.TrimSpace(interrupt.RequestData) == "" { - if interrupt.WorkflowRunID > 0 || strings.HasPrefix(strings.TrimSpace(req.CheckPointID), "workflow:") { - return nil, errorsx.InvalidParam("workflow checkpoint data is required") - } - return nil, errorsx.InvalidParam("legacy checkpoint is not supported; please start a new workflow reply") - } - workflowResult, err := workflowexecutor.NewExecutor().Resume(ctx, workflowexecutor.Input{ - Definition: workflow.Definition, - Conversation: req.Conversation, - AIAgent: req.AIAgent, - AIConfig: req.AIConfig, - Debug: req.Debug, - }, interrupt.RequestData, firstWorkflowResumeText(req.ResumeData)) - if err != nil { - if workflowResult != nil { - _, _, _ = writeWorkflowRunWithExistingID(Request{ - Conversation: req.Conversation, - UserMessage: req.UserMessage, - AIAgent: req.AIAgent, - AIConfig: req.AIConfig, - }, workflow, workflowResult, err.Error(), interrupt.WorkflowRunID) - } - return nil, err - } - workflowRunID, agentRunID, err := writeWorkflowRunWithExistingID(Request{ - Conversation: req.Conversation, - UserMessage: req.UserMessage, - AIAgent: req.AIAgent, - AIConfig: req.AIConfig, - }, workflow, workflowResult, "", interrupt.WorkflowRunID) - if err != nil { - return nil, err - } - return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID, agentRunID), nil -} - -func firstWorkflowResumeText(data map[string]string) string { - for _, value := range data { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -var _ Engine = (*WorkflowEngine)(nil) diff --git a/internal/ai/application/runtime/workflow_runtime.go b/internal/ai/application/runtime/workflow_runtime.go index 4d3510d..e50a954 100644 --- a/internal/ai/application/runtime/workflow_runtime.go +++ b/internal/ai/application/runtime/workflow_runtime.go @@ -4,7 +4,6 @@ import ( "encoding/json" "agent-desk/internal/ai/workflow/dsl" - "agent-desk/internal/models" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/repositories" @@ -18,11 +17,11 @@ type resolvedWorkflow struct { VersionID int64 } -func resolveAgentWorkflow(aiAgent models.AIAgent) (resolvedWorkflow, error) { - if aiAgent.WorkflowVersionID <= 0 { - return resolvedWorkflow{}, errorsx.InvalidParam("AI Agent workflow is not published; publish a workflow version before enabling automatic replies") +func resolveWorkflowVersion(workflowVersionID int64) (resolvedWorkflow, error) { + if workflowVersionID <= 0 { + return resolvedWorkflow{}, errorsx.InvalidParam("workflow version is required") } - version := repositories.AIWorkflowVersionRepository.Get(sqls.DB(), aiAgent.WorkflowVersionID) + version := repositories.AIWorkflowVersionRepository.Get(sqls.DB(), workflowVersionID) if version == nil || version.Status != enums.StatusOk { return resolvedWorkflow{}, errorsx.InvalidParam("workflow version does not exist") } @@ -36,11 +35,3 @@ func resolveAgentWorkflow(aiAgent models.AIAgent) (resolvedWorkflow, error) { VersionID: version.ID, }, nil } - -func prepareWorkflowAgent(aiAgent models.AIAgent) (models.AIAgent, resolvedWorkflow, error) { - workflow, err := resolveAgentWorkflow(aiAgent) - if err != nil { - return aiAgent, resolvedWorkflow{}, err - } - return aiAgent, workflow, nil -} diff --git a/internal/ai/application/runtime/workflow_summary_test.go b/internal/ai/application/runtime/workflow_summary_test.go deleted file mode 100644 index bb5bd41..0000000 --- a/internal/ai/application/runtime/workflow_summary_test.go +++ /dev/null @@ -1,481 +0,0 @@ -package runtime - -import ( - "context" - "encoding/json" - "strings" - "testing" - "time" - - workflowexecutor "agent-desk/internal/ai/runtime/workflow" - "agent-desk/internal/ai/workflow/dsl" - workflowregistry "agent-desk/internal/ai/workflow/registry" - "agent-desk/internal/models" - "agent-desk/internal/pkg/enums" - svc "agent-desk/internal/services" - - "github.com/glebarez/sqlite" - "github.com/mlogclub/simple/sqls" - "gorm.io/gorm" - "gorm.io/gorm/schema" -) - -func TestToWorkflowSummaryPreservesInterruptCheckpoint(t *testing.T) { - summary := toWorkflowSummary(&workflowexecutor.Result{ - Status: "interrupted", - CheckPointID: "workflow:1:2:confirm_1", - CheckPointData: `{"confirmNodeId":"confirm_1"}`, - Interrupted: true, - Interrupts: []workflowexecutor.InterruptSummary{ - {Type: "human_confirm", ID: "confirm_1", InfoPreview: `{"message":"请确认"}`}, - }, - }, "test-model", resolvedWorkflow{WorkflowID: 11, VersionID: 22}, 33, 44) - - if summary == nil || !summary.Interrupted { - t.Fatalf("expected interrupted summary, got %#v", summary) - } - if summary.CheckPointID != "workflow:1:2:confirm_1" { - t.Fatalf("unexpected checkpoint id: %q", summary.CheckPointID) - } - if summary.CheckPointData == "" { - t.Fatalf("expected checkpoint data") - } - if summary.WorkflowID != 11 || summary.WorkflowVersionID != 22 || summary.WorkflowRunID != 33 { - t.Fatalf("unexpected workflow identity: workflow=%d version=%d run=%d", summary.WorkflowID, summary.WorkflowVersionID, summary.WorkflowRunID) - } - if summary.AgentRunID != 44 { - t.Fatalf("unexpected agent run id: %d", summary.AgentRunID) - } - if len(summary.Interrupts) != 1 || summary.Interrupts[0].ID != "confirm_1" { - t.Fatalf("unexpected interrupts: %#v", summary.Interrupts) - } -} - -func TestPrepareWorkflowAgentDoesNotInjectWorkflowAppendix(t *testing.T) { - db := setupWorkflowResumeTestDB(t) - definitionJSON := mustMarshalDefinition(t, dsl.Definition{ - SchemaVersion: 2, - Nodes: []dsl.Node{ - runtimeTestNode("start", workflowregistry.NodeTypeStart, "Start", nil, nil), - runtimeTestNode("handoff", workflowregistry.NodeTypeHandoffToHuman, "Handoff", nil, nil), - }, - Edges: []dsl.Edge{runtimeTestEdge("edge_start_handoff", "start", "handoff")}, - }) - version := models.AIWorkflowVersion{ - WorkflowID: 1, - Version: 1, - Status: enums.StatusOk, - Definition: definitionJSON, - } - if err := db.Create(&version).Error; err != nil { - t.Fatalf("create workflow version: %v", err) - } - - agent, _, err := prepareWorkflowAgent(models.AIAgent{ - ID: 1, - SystemPrompt: "保持简洁回答。", - WorkflowVersionID: version.ID, - }) - if err != nil { - t.Fatalf("prepareWorkflowAgent() error = %v", err) - } - if agent.SystemPrompt != "保持简洁回答。" { - t.Fatalf("expected system prompt to stay unchanged, got %q", agent.SystemPrompt) - } -} - -func TestServiceResumeUsesWorkflowCheckpointData(t *testing.T) { - db := setupWorkflowResumeTestDB(t) - def := runtimeHumanConfirmDefinition() - definitionJSON := mustMarshalDefinition(t, def) - version := models.AIWorkflowVersion{ - WorkflowID: 1, - Version: 1, - Status: enums.StatusOk, - Definition: definitionJSON, - } - if err := db.Create(&version).Error; err != nil { - t.Fatalf("create workflow version: %v", err) - } - checkpointData := mustMarshalWorkflowCheckpoint(t, def) - if err := db.Create(&models.ConversationInterrupt{ - ConversationID: 1, - AIAgentID: 1, - CheckPointID: "workflow:1:2:confirm_1", - RequestData: checkpointData, - Status: "pending", - }).Error; err != nil { - t.Fatalf("create interrupt: %v", err) - } - - summary, err := NewService().Resume(context.Background(), ResumeRequest{ - Conversation: models.Conversation{ID: 1}, - UserMessage: models.Message{ID: 2, Content: "确认"}, - AIAgent: models.AIAgent{ - ID: 1, - WorkflowVersionID: version.ID, - }, - AIConfig: models.AIConfig{ModelName: "test-model"}, - CheckPointID: "workflow:1:2:confirm_1", - ResumeData: map[string]string{ - "confirm_1": "确认", - }, - }) - if err != nil { - t.Fatalf("resume workflow: %v", err) - } - if summary == nil || summary.Status != "completed" || summary.Interrupted { - t.Fatalf("unexpected summary: %#v", summary) - } - if summary.WorkflowRunID <= 0 { - t.Fatalf("expected workflow run id in resume summary") - } - if summary.AgentRunID <= 0 { - t.Fatalf("expected generic agent run id in resume summary") - } - var run models.AIWorkflowRun - if err := db.First(&run, summary.WorkflowRunID).Error; err != nil { - t.Fatalf("find resume workflow run: %v", err) - } - if run.MessageID != 2 || run.Status != workflowRunStatusCompleted { - t.Fatalf("unexpected resume workflow run: %#v", run) - } - var agentRun models.AgentRun - if err := db.First(&agentRun, "workflow_run_id = ?", summary.WorkflowRunID).Error; err != nil { - t.Fatalf("find generic agent run: %v", err) - } - if agentRun.EngineCode != EngineCodeWorkflow || agentRun.Status != "completed" { - t.Fatalf("unexpected generic agent run: %#v", agentRun) - } - var stepCount int64 - if err := db.Model(&models.AgentStep{}).Where("agent_run_id = ?", agentRun.ID).Count(&stepCount).Error; err != nil { - t.Fatalf("count generic agent steps: %v", err) - } - if stepCount != 1 { - t.Fatalf("expected one generic agent step, got %d", stepCount) - } -} - -func TestHybridEngineResumeCompletesOriginalAgentRun(t *testing.T) { - db := setupWorkflowResumeTestDB(t) - def := runtimeHumanConfirmDefinition() - version := models.AIWorkflowVersion{ - WorkflowID: 1, - Version: 1, - Status: enums.StatusOk, - Definition: mustMarshalDefinition(t, def), - } - if err := db.Create(&version).Error; err != nil { - t.Fatalf("create workflow version: %v", err) - } - startedAt := time.Now() - hybridRun := models.AgentRun{AIAgentID: 1, EngineCode: "hybrid", Status: "interrupted", StartedAt: startedAt, EndedAt: &startedAt, CreatedAt: startedAt, UpdatedAt: startedAt} - if err := db.Create(&hybridRun).Error; err != nil { - t.Fatalf("create interrupted hybrid run: %v", err) - } - interruptedRun := models.AIWorkflowRun{WorkflowID: version.WorkflowID, WorkflowVersionID: version.ID, ConversationID: 1, AIAgentID: 1, MessageID: 2, Status: workflowRunStatusInterrupted} - if err := db.Create(&interruptedRun).Error; err != nil { - t.Fatalf("create interrupted workflow run: %v", err) - } - const checkpointID = "workflow:1:2:confirm_1" - if err := db.Create(&models.ConversationInterrupt{ - ConversationID: 1, AIAgentID: 1, AgentRunID: hybridRun.ID, - CheckPointID: checkpointID, InterruptID: "confirm_1", InterruptType: "human_confirm", - WorkflowRunID: interruptedRun.ID, WorkflowNodeID: "confirm_1", RequestData: mustMarshalWorkflowCheckpoint(t, def), Status: "pending", - }).Error; err != nil { - t.Fatalf("create interrupt: %v", err) - } - - summary, err := NewHybridEngine().Resume(context.Background(), ResumeRequest{ - Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 3, Content: "确认"}, - AIAgent: models.AIAgent{ID: 1, RuntimeMode: enums.AIAgentRuntimeModeHybrid, WorkflowVersionID: version.ID}, - AIConfig: models.AIConfig{ModelName: "test-model"}, CheckPointID: checkpointID, - ResumeData: map[string]string{"confirm_1": "确认"}, - }) - if err != nil { - t.Fatalf("resume hybrid playbook: %v", err) - } - if summary == nil || summary.Status != "completed" || summary.AgentRunID != hybridRun.ID || summary.WorkflowRunID != interruptedRun.ID { - t.Fatalf("unexpected hybrid resume summary: %#v", summary) - } - item, steps, _ := svc.AgentRunService.GetDetail(hybridRun.ID) - if item == nil || item.Status != "completed" || len(steps) != 1 || steps[0].StepCode != "playbook_resume" || steps[0].WorkflowRunID != interruptedRun.ID { - t.Fatalf("expected original hybrid run to receive resume audit, run=%#v steps=%#v", item, steps) - } -} - -func TestServiceResumeReusesInterruptedWorkflowRun(t *testing.T) { - db := setupWorkflowResumeTestDB(t) - def := runtimeHumanConfirmDefinition() - version := models.AIWorkflowVersion{ - WorkflowID: 1, - Version: 1, - Status: enums.StatusOk, - Definition: mustMarshalDefinition(t, def), - } - if err := db.Create(&version).Error; err != nil { - t.Fatalf("create workflow version: %v", err) - } - interruptedRun := models.AIWorkflowRun{ - WorkflowID: version.WorkflowID, - WorkflowVersionID: version.ID, - ConversationID: 1, - AIAgentID: 1, - MessageID: 2, - Status: workflowRunStatusInterrupted, - InterruptType: "human_confirm", - InterruptNodeID: "confirm_1", - } - if err := db.Create(&interruptedRun).Error; err != nil { - t.Fatalf("create interrupted workflow run: %v", err) - } - if err := db.Create(&models.ConversationInterrupt{ - ConversationID: 1, - AIAgentID: 1, - CheckPointID: "workflow:1:2:confirm_1", - InterruptID: "confirm_1", - InterruptType: "human_confirm", - WorkflowRunID: interruptedRun.ID, - WorkflowNodeID: "confirm_1", - RequestData: mustMarshalWorkflowCheckpoint(t, def), - Status: "pending", - }).Error; err != nil { - t.Fatalf("create interrupt: %v", err) - } - - summary, err := NewService().Resume(context.Background(), ResumeRequest{ - Conversation: models.Conversation{ID: 1}, - UserMessage: models.Message{ID: 3, Content: "确认"}, - AIAgent: models.AIAgent{ - ID: 1, - WorkflowVersionID: version.ID, - }, - AIConfig: models.AIConfig{ModelName: "test-model"}, - CheckPointID: "workflow:1:2:confirm_1", - ResumeData: map[string]string{ - "confirm_1": "确认", - }, - }) - if err != nil { - t.Fatalf("resume workflow: %v", err) - } - if summary.WorkflowRunID != interruptedRun.ID { - t.Fatalf("expected resume to reuse workflow run %d, got %d", interruptedRun.ID, summary.WorkflowRunID) - } - var runCount int64 - if err := db.Model(&models.AIWorkflowRun{}).Count(&runCount).Error; err != nil { - t.Fatalf("count workflow runs: %v", err) - } - if runCount != 1 { - t.Fatalf("expected one workflow run after resume, got %d", runCount) - } - var updated models.AIWorkflowRun - if err := db.First(&updated, interruptedRun.ID).Error; err != nil { - t.Fatalf("find updated workflow run: %v", err) - } - if updated.Status != workflowRunStatusCompleted || updated.ErrorMessage != "" { - t.Fatalf("unexpected updated workflow run: %#v", updated) - } - var nodeCount int64 - if err := db.Model(&models.AIWorkflowNodeRun{}).Where("workflow_run_id = ?", interruptedRun.ID).Count(&nodeCount).Error; err != nil { - t.Fatalf("count node runs: %v", err) - } - if nodeCount == 0 { - t.Fatalf("expected resumed node traces to be appended to original workflow run") - } -} - -func TestServiceRunWritesFailedWorkflowRun(t *testing.T) { - db := setupWorkflowResumeTestDB(t) - def := dsl.Definition{ - SchemaVersion: 2, - Nodes: []dsl.Node{ - runtimeTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil), - runtimeTestNode("bad_1", "unsupported_node", "Bad", nil, nil), - }, - Edges: []dsl.Edge{ - runtimeTestEdge("edge_start_bad", "start_1", "bad_1"), - }, - } - version := models.AIWorkflowVersion{ - WorkflowID: 9, - Version: 1, - Status: enums.StatusOk, - Definition: mustMarshalDefinition(t, def), - } - if err := db.Create(&version).Error; err != nil { - t.Fatalf("create workflow version: %v", err) - } - - _, err := NewService().Run(context.Background(), Request{ - Conversation: models.Conversation{ID: 10}, - UserMessage: models.Message{ID: 20, Content: "hello"}, - AIAgent: models.AIAgent{ - ID: 30, - WorkflowVersionID: version.ID, - }, - }) - if err == nil { - t.Fatalf("expected workflow run error") - } - var run models.AIWorkflowRun - if err := db.First(&run, "workflow_version_id = ?", version.ID).Error; err != nil { - t.Fatalf("find failed workflow run: %v", err) - } - if run.Status != workflowRunStatusFailed || !strings.Contains(run.ErrorMessage, "unsupported workflow node type") { - t.Fatalf("unexpected failed workflow run: %#v", run) - } - var badNodeRun models.AIWorkflowNodeRun - if err := db.First(&badNodeRun, "workflow_run_id = ? AND node_id = ?", run.ID, "bad_1").Error; err != nil { - t.Fatalf("find failed node run: %v", err) - } - if badNodeRun.Status != workflowRunStatusFailed || badNodeRun.ErrorMessage == "" { - t.Fatalf("unexpected failed node run: %#v", badNodeRun) - } - var agentRun models.AgentRun - if err := db.First(&agentRun, "workflow_run_id = ?", run.ID).Error; err != nil { - t.Fatalf("find generic failed agent run: %v", err) - } - if agentRun.Status != "failed" || !strings.Contains(agentRun.ErrorMessage, "unsupported workflow node type") { - t.Fatalf("unexpected generic failed agent run: %#v", agentRun) - } -} - -func TestServiceRunWritesFailedWorkflowRunWhenVersionDisabled(t *testing.T) { - db := setupWorkflowResumeTestDB(t) - version := models.AIWorkflowVersion{ - WorkflowID: 9, - Version: 1, - Status: enums.StatusDisabled, - Definition: mustMarshalDefinition(t, runtimeHumanConfirmDefinition()), - } - if err := db.Create(&version).Error; err != nil { - t.Fatalf("create disabled workflow version: %v", err) - } - - _, err := NewService().Run(context.Background(), Request{ - Conversation: models.Conversation{ID: 10}, - UserMessage: models.Message{ID: 20, Content: "hello"}, - AIAgent: models.AIAgent{ - ID: 30, - WorkflowVersionID: version.ID, - }, - }) - if err == nil { - t.Fatalf("expected disabled workflow version error") - } - var run models.AIWorkflowRun - if err := db.First(&run, "workflow_version_id = ?", version.ID).Error; err != nil { - t.Fatalf("find prepare-stage failed workflow run: %v", err) - } - if run.WorkflowID != version.WorkflowID || run.ConversationID != 10 || run.AIAgentID != 30 || run.MessageID != 20 { - t.Fatalf("unexpected prepare-stage failed workflow run identity: %#v", run) - } - if run.Status != workflowRunStatusFailed || !strings.Contains(run.ErrorMessage, "workflow version does not exist") { - t.Fatalf("unexpected prepare-stage failed workflow run: %#v", run) - } - var nodeCount int64 - if err := db.Model(&models.AIWorkflowNodeRun{}).Where("workflow_run_id = ?", run.ID).Count(&nodeCount).Error; err != nil { - t.Fatalf("count node runs: %v", err) - } - if nodeCount != 0 { - t.Fatalf("expected no node runs for prepare-stage failure, got %d", nodeCount) - } -} - -func setupWorkflowResumeTestDB(t *testing.T) *gorm.DB { - t.Helper() - dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) - db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{ - NamingStrategy: schema.NamingStrategy{ - TablePrefix: "t_", - SingularTable: true, - }, - }) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - t.Cleanup(func() { - sqlDB, err := db.DB() - if err == nil { - _ = sqlDB.Close() - } - }) - if err := db.AutoMigrate( - &models.AIWorkflowVersion{}, - &models.AIWorkflowRun{}, - &models.AIWorkflowNodeRun{}, - &models.AgentRun{}, - &models.AgentStep{}, - &models.AgentRevision{}, - &models.ConversationInterrupt{}, - ); err != nil { - t.Fatalf("auto migrate: %v", err) - } - sqls.SetDB(db) - return db -} - -func runtimeHumanConfirmDefinition() dsl.Definition { - return dsl.Definition{ - SchemaVersion: 2, - Nodes: []dsl.Node{ - runtimeTestNode("start_1", workflowregistry.NodeTypeStart, "Start", nil, nil), - runtimeTestNode("prompt_1", workflowregistry.NodeTypeLLMReply, "Prompt", []byte(`{"staticReply":"请确认"}`), nil), - runtimeTestNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "Confirm", nil, map[string]dsl.Value{ - "prompt": dsl.RefValue("prompt_1", "replyText"), - }), - runtimeTestNode("end_1", workflowregistry.NodeTypeEnd, "End", nil, nil), - }, - Edges: []dsl.Edge{ - runtimeTestEdge("edge_start_prompt", "start_1", "prompt_1"), - runtimeTestEdge("edge_prompt_confirm", "prompt_1", "confirm_1"), - runtimeTestEdge("edge_confirm_end", "confirm_1", "end_1"), - }, - } -} - -func runtimeTestNode(id string, nodeType string, title string, config []byte, inputs map[string]dsl.Value) dsl.Node { - return dsl.Node{ - ID: id, - Type: nodeType, - Data: dsl.NodeData{ - Title: title, - Config: config, - InputsValues: inputs, - }, - } -} - -func runtimeTestEdge(id string, source string, target string) dsl.Edge { - return dsl.Edge{SourceNodeID: source, TargetNodeID: target, SourcePortID: id} -} - -func mustMarshalDefinition(t *testing.T, def dsl.Definition) string { - t.Helper() - buf, err := json.Marshal(def) - if err != nil { - t.Fatalf("marshal definition: %v", err) - } - return string(buf) -} - -func mustMarshalWorkflowCheckpoint(t *testing.T, def dsl.Definition) string { - t.Helper() - buf, err := json.Marshal(struct { - Definition dsl.Definition `json:"definition"` - ConfirmNodeID string `json:"confirmNodeId"` - Vars map[string]map[string]any `json:"vars"` - }{ - Definition: def, - ConfirmNodeID: "confirm_1", - Vars: map[string]map[string]any{ - "start_1": {"userMessage": "创建工单"}, - "prompt_1": {"replyText": "请确认"}, - }, - }) - if err != nil { - t.Fatalf("marshal checkpoint: %v", err) - } - return string(buf) -} diff --git a/internal/ai/runtime/debug_run.go b/internal/ai/runtime/debug_run.go index a81fdea..7741645 100644 --- a/internal/ai/runtime/debug_run.go +++ b/internal/ai/runtime/debug_run.go @@ -49,7 +49,7 @@ func DebugRunSkill(ctx context.Context, req request.SkillDebugRunRequest) (*resp MessageType: enums.IMMessageTypeText, Content: strings.TrimSpace(req.UserMessage), } - summary, err := applicationruntime.DefaultAgentApplicationService.RunPrepared(ctx, applicationruntime.Request{ + summary, err := applicationruntime.DefaultAgentApplicationService.RunPrepared(ctx, applicationruntime.RunInput{ Conversation: *conversation, UserMessage: message, AIAgent: debugAgent, @@ -93,7 +93,7 @@ func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest) return nil, errorsx.InvalidParamI18n("error.e0117") } resumeText := strings.TrimSpace(req.UserMessage) - summary, err := applicationruntime.DefaultAgentApplicationService.ResumePrepared(ctx, applicationruntime.ResumeRequest{ + summary, err := applicationruntime.DefaultAgentApplicationService.ResumePrepared(ctx, applicationruntime.ResumeInput{ Conversation: *conversation, AIAgent: *aiAgent, AIConfig: *aiConfig, @@ -105,7 +105,7 @@ func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest) }) if err != nil { if isCheckpointMissingError(err) { - summary = &applicationruntime.Summary{ + summary = &applicationruntime.RunResult{ Status: "expired", ReplyText: graphs.ConfirmationExpiredReply, } @@ -128,7 +128,7 @@ func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest) return buildSkillDebugResumeResponse(req, summary, conversationID), nil } -func buildSkillDebugRunResponse(req request.SkillDebugRunRequest, summary *applicationruntime.Summary, skill *models.SkillDefinition) *response.SkillDebugRunResponse { +func buildSkillDebugRunResponse(req request.SkillDebugRunRequest, summary *applicationruntime.RunResult, skill *models.SkillDefinition) *response.SkillDebugRunResponse { resp := &response.SkillDebugRunResponse{ ConversationID: req.ConversationID, AIAgentID: req.AIAgentID, @@ -144,14 +144,8 @@ func buildSkillDebugRunResponse(req request.SkillDebugRunRequest, summary *appli resp.SkillDefinitionID = summary.PlannedSkillID } resp.ReplyText = summary.ReplyText - resp.PlanReason = summary.PlanReason - resp.SkillRouteTrace = summary.SkillRouteTrace resp.ToolWhitelist = append([]string(nil), summary.SkillAllowedToolCodes...) - resp.ExposedToolCodes = append([]string(nil), summary.ToolCodes...) resp.InvokedToolCodes = append([]string(nil), summary.InvokedToolCodes...) - resp.ToolSearchTrace = extractToolSearchTrace(summary) - resp.GraphToolTrace = extractGraphToolTrace(summary) - resp.GraphToolCode = firstGraphToolCode(summary) resp.InterruptType = firstInterruptType(summary) resp.CheckPointID = summary.CheckPointID resp.Interrupted = summary.Interrupted @@ -160,7 +154,7 @@ func buildSkillDebugRunResponse(req request.SkillDebugRunRequest, summary *appli return resp } -func buildSkillDebugResumeResponse(req request.SkillDebugResumeRequest, summary *applicationruntime.Summary, conversationID int64) *response.SkillDebugRunResponse { +func buildSkillDebugResumeResponse(req request.SkillDebugResumeRequest, summary *applicationruntime.RunResult, conversationID int64) *response.SkillDebugRunResponse { resp := &response.SkillDebugRunResponse{ ConversationID: conversationID, AIAgentID: req.AIAgentID, @@ -171,14 +165,8 @@ func buildSkillDebugResumeResponse(req request.SkillDebugResumeRequest, summary resp.SkillDefinitionID = summary.PlannedSkillID resp.SkillName = strings.TrimSpace(summary.PlannedSkillName) resp.ReplyText = summary.ReplyText - resp.PlanReason = summary.PlanReason - resp.SkillRouteTrace = summary.SkillRouteTrace resp.ToolWhitelist = append([]string(nil), summary.SkillAllowedToolCodes...) - resp.ExposedToolCodes = append([]string(nil), summary.ToolCodes...) resp.InvokedToolCodes = append([]string(nil), summary.InvokedToolCodes...) - resp.ToolSearchTrace = extractToolSearchTrace(summary) - resp.GraphToolTrace = extractGraphToolTrace(summary) - resp.GraphToolCode = firstGraphToolCode(summary) resp.InterruptType = firstInterruptType(summary) resp.CheckPointID = summary.CheckPointID resp.Interrupted = summary.Interrupted diff --git a/internal/ai/runtime/evaluation_run.go b/internal/ai/runtime/evaluation_run.go index 285f35d..1d0cf50 100644 --- a/internal/ai/runtime/evaluation_run.go +++ b/internal/ai/runtime/evaluation_run.go @@ -28,7 +28,7 @@ func RunAgentEvaluation(ctx context.Context, req request.RunAgentEvaluationReque 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) + report, err := applicationruntime.NewService().RunOfflineEvaluation(ctx, *agent, *config, cases) if err != nil { return nil, err } @@ -36,9 +36,9 @@ func RunAgentEvaluation(ctx context.Context, req request.RunAgentEvaluationReque 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))} + ret := &response.AgentEvaluationReportResponse{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}) + ret.Results = append(ret.Results, response.AgentEvaluationResultResponse{CaseID: item.CaseID, Category: item.Category, Passed: item.Passed, ReplyText: item.ReplyText, Interrupted: item.Interrupted, Error: item.Error, Finding: item.Finding}) } return ret, nil } diff --git a/internal/ai/runtime/reply_context.go b/internal/ai/runtime/reply_context.go index 369fc1b..29151a6 100644 --- a/internal/ai/runtime/reply_context.go +++ b/internal/ai/runtime/reply_context.go @@ -9,11 +9,11 @@ type aiReplyContext struct { Conversation models.Conversation Message models.Message AIAgent models.AIAgent - SummaryRef **applicationruntime.Summary + SummaryRef **applicationruntime.RunResult PendingInterrupt *models.ConversationInterrupt } -func (c aiReplyContext) setSummary(summary *applicationruntime.Summary) { +func (c aiReplyContext) setSummary(summary *applicationruntime.RunResult) { if c.SummaryRef != nil { *c.SummaryRef = summary } diff --git a/internal/ai/runtime/reply_helpers_test.go b/internal/ai/runtime/reply_helpers_test.go index fb66c7e..cd22ed1 100644 --- a/internal/ai/runtime/reply_helpers_test.go +++ b/internal/ai/runtime/reply_helpers_test.go @@ -5,27 +5,8 @@ import ( applicationruntime "agent-desk/internal/ai/application/runtime" "agent-desk/internal/models" - "agent-desk/internal/pkg/toolx" ) -func TestExtractRuntimeToolTraces(t *testing.T) { - summary := &applicationruntime.Summary{ - TraceData: `{ - "toolSearch": {"items": [{"targetToolCode":"mcp/server/tool_a"}]}, - "graphTools": {"items": [{"toolCode":"` + toolx.GraphAnalyzeConversation.Code + `"}]} - }`, - } - if got := extractToolSearchTrace(summary); got == "" { - t.Fatalf("expected tool search trace") - } - if got := extractGraphToolTrace(summary); got == "" { - t.Fatalf("expected graph tool trace") - } - if got := firstGraphToolCode(summary); got != toolx.GraphAnalyzeConversation.Code { - t.Fatalf("unexpected graph tool code: %q", got) - } -} - func TestExtractInterruptMessageAndCheckpointError(t *testing.T) { if got := extractInterruptMessage(`{"message":"请补充订单号"}`); got != "请补充订单号" { t.Fatalf("unexpected interrupt message: %q", got) @@ -44,7 +25,7 @@ func TestExtractInterruptMessageAndCheckpointError(t *testing.T) { } func TestBuildConversationInterruptStoresWorkflowCheckpointData(t *testing.T) { - item := buildConversationInterrupt(testConversation(1), testMessage(2), testAIAgent(3), &applicationruntime.Summary{ + item := buildConversationInterrupt(testConversation(1), testMessage(2), testAIAgent(3), &applicationruntime.RunResult{ CheckPointData: `{"confirmNodeId":"confirm_1"}`, Interrupted: true, WorkflowRunID: 99, diff --git a/internal/ai/runtime/reply_interrupt_helpers.go b/internal/ai/runtime/reply_interrupt_helpers.go index ebc7d28..bc67b1a 100644 --- a/internal/ai/runtime/reply_interrupt_helpers.go +++ b/internal/ai/runtime/reply_interrupt_helpers.go @@ -15,7 +15,7 @@ type interruptMessagePreview struct { Message string `json:"message"` } -func buildConversationInterrupt(conversation models.Conversation, message models.Message, aiAgent models.AIAgent, summary *applicationruntime.Summary) *models.ConversationInterrupt { +func buildConversationInterrupt(conversation models.Conversation, message models.Message, aiAgent models.AIAgent, summary *applicationruntime.RunResult) *models.ConversationInterrupt { if summary == nil { return nil } @@ -42,7 +42,7 @@ func buildConversationInterrupt(conversation models.Conversation, message models return item } -func resolveInterruptPrompt(summary *applicationruntime.Summary) string { +func resolveInterruptPrompt(summary *applicationruntime.RunResult) string { if summary == nil || len(summary.Interrupts) == 0 { return i18nx.Get("conversation.interrupt.defaultPrompt") } @@ -67,14 +67,14 @@ func extractInterruptMessage(infoPreview string) string { return strings.TrimSpace(payload.Message) } -func firstInterruptID(summary *applicationruntime.Summary) string { +func firstInterruptID(summary *applicationruntime.RunResult) string { if summary == nil || len(summary.Interrupts) == 0 { return "" } return strings.TrimSpace(summary.Interrupts[0].ID) } -func firstInterruptType(summary *applicationruntime.Summary) string { +func firstInterruptType(summary *applicationruntime.RunResult) string { if summary == nil || len(summary.Interrupts) == 0 { return "" } diff --git a/internal/ai/runtime/reply_interrupt_service.go b/internal/ai/runtime/reply_interrupt_service.go index 8933ec7..43c47e5 100644 --- a/internal/ai/runtime/reply_interrupt_service.go +++ b/internal/ai/runtime/reply_interrupt_service.go @@ -80,7 +80,7 @@ func (s *replyInterruptService) ResumePendingInterrupt(ctx context.Context, owne return svc.ConversationInterruptService.MarkResolved(replyCtx.PendingInterrupt.ID, 0) } -func (s *replyInterruptService) HandleInterruptedSummary(owner *aiReplyService, replyCtx aiReplyContext, summary *applicationruntime.Summary) error { +func (s *replyInterruptService) HandleInterruptedSummary(owner *aiReplyService, replyCtx aiReplyContext, summary *applicationruntime.RunResult) error { pending := buildConversationInterrupt(replyCtx.Conversation, replyCtx.Message, replyCtx.AIAgent, summary) if pending != nil && pending.AgentRunID > 0 { pending.AgentStepID = svc.AgentRunService.GetLatestStepID(pending.AgentRunID) @@ -107,7 +107,7 @@ func (s *replyInterruptService) HandleInterruptedSummary(owner *aiReplyService, return nil } -func (s *replyInterruptService) HandleInterruptedResume(owner *aiReplyService, replyCtx aiReplyContext, summary *applicationruntime.Summary) error { +func (s *replyInterruptService) HandleInterruptedResume(owner *aiReplyService, replyCtx aiReplyContext, summary *applicationruntime.RunResult) error { if replyCtx.PendingInterrupt == nil { return fmt.Errorf("pending interrupt is required") } diff --git a/internal/ai/runtime/reply_service.go b/internal/ai/runtime/reply_service.go index 41fe3c7..5e11ebd 100644 --- a/internal/ai/runtime/reply_service.go +++ b/internal/ai/runtime/reply_service.go @@ -29,7 +29,7 @@ type aiReplyService struct { commit *replyCommitService } -func firstInvokedToolCode(summary *applicationruntime.Summary) string { +func firstInvokedToolCode(summary *applicationruntime.RunResult) string { if summary == nil { return "" } diff --git a/internal/ai/runtime/reply_service_test.go b/internal/ai/runtime/reply_service_test.go index baf4eb2..e2dcf39 100644 --- a/internal/ai/runtime/reply_service_test.go +++ b/internal/ai/runtime/reply_service_test.go @@ -86,7 +86,7 @@ func TestResolveReplyTimeout(t *testing.T) { } func TestResolveInterruptPrompt(t *testing.T) { - summary := &applicationruntime.Summary{ + summary := &applicationruntime.RunResult{ Interrupts: []applicationruntime.InterruptContextSummary{ { ID: "interrupt-1", diff --git a/internal/ai/runtime/reply_trigger_service.go b/internal/ai/runtime/reply_trigger_service.go index 2aeb69d..2f1b703 100644 --- a/internal/ai/runtime/reply_trigger_service.go +++ b/internal/ai/runtime/reply_trigger_service.go @@ -45,7 +45,7 @@ func (s *aiReplyService) TriggerReplyAsync(conversation models.Conversation, mes } func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.Conversation, message models.Message, aiAgent models.AIAgent) (retErr error) { - var summary *applicationruntime.Summary + var summary *applicationruntime.RunResult replyCtx := aiReplyContext{ Conversation: conversation, Message: message, diff --git a/internal/ai/runtime/runtime_reply_executor.go b/internal/ai/runtime/runtime_reply_executor.go index 7b0922b..9e670ed 100644 --- a/internal/ai/runtime/runtime_reply_executor.go +++ b/internal/ai/runtime/runtime_reply_executor.go @@ -29,7 +29,7 @@ func newRuntimeReplyExecutor() *runtimeReplyExecutor { return &runtimeReplyExecutor{} } -func (e *runtimeReplyExecutor) Run(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.Summary, error) { +func (e *runtimeReplyExecutor) Run(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.RunResult, error) { summary, err := applicationruntime.DefaultAgentApplicationService.Run(ctx, applicationruntime.ApplicationRunInput{ ConversationID: input.Conversation.ID, MessageID: input.Message.ID, @@ -38,7 +38,7 @@ func (e *runtimeReplyExecutor) Run(ctx context.Context, input runtimeReplyRunInp return summary, err } -func (e *runtimeReplyExecutor) ResumePendingInterrupt(ctx context.Context, input runtimeReplyResumeInput) (*applicationruntime.Summary, error) { +func (e *runtimeReplyExecutor) ResumePendingInterrupt(ctx context.Context, input runtimeReplyResumeInput) (*applicationruntime.RunResult, error) { if input.PendingInterrupt == nil { return nil, fmt.Errorf("pending interrupt is required") } @@ -56,8 +56,8 @@ func (e *runtimeReplyExecutor) ResumePendingInterrupt(ctx context.Context, input return summary, err } -func expiredInterruptSummary() *applicationruntime.Summary { - return &applicationruntime.Summary{ +func expiredInterruptSummary() *applicationruntime.RunResult { + return &applicationruntime.RunResult{ Status: "expired", ReplyText: graphs.ConfirmationExpiredReply, } diff --git a/internal/ai/runtime/runtime_trace_extractors.go b/internal/ai/runtime/runtime_trace_extractors.go deleted file mode 100644 index e16ade2..0000000 --- a/internal/ai/runtime/runtime_trace_extractors.go +++ /dev/null @@ -1,82 +0,0 @@ -package runtime - -import ( - "encoding/json" - "strings" - - applicationruntime "agent-desk/internal/ai/application/runtime" -) - -func extractToolSearchTrace(summary *applicationruntime.Summary) string { - if summary == nil { - return "" - } - trace := parseRuntimeTraceData(summary.TraceData) - if len(trace.ToolSearch.Items) == 0 { - return "" - } - buf, err := json.Marshal(trace.ToolSearch) - if err != nil { - return "" - } - return string(buf) -} - -func extractGraphToolTrace(summary *applicationruntime.Summary) string { - if summary == nil { - return "" - } - trace := parseRuntimeTraceData(summary.TraceData) - if len(trace.GraphTools.Items) == 0 { - return "" - } - buf, err := json.Marshal(trace.GraphTools) - if err != nil { - return "" - } - return string(buf) -} - -func firstGraphToolCode(summary *applicationruntime.Summary) string { - if summary == nil { - return "" - } - trace := parseRuntimeTraceData(summary.TraceData) - for _, item := range trace.GraphTools.Items { - toolCode := strings.TrimSpace(item.ToolCode) - if toolCode != "" { - return toolCode - } - } - return "" -} - -type runtimeTraceProjection struct { - ToolSearch struct { - Items []struct { - TargetToolCode string `json:"targetToolCode"` - CandidateToolCodes []string `json:"candidateToolCodes"` - } `json:"items"` - } `json:"toolSearch"` - GraphTools struct { - Items []struct { - ToolCode string `json:"toolCode"` - Arguments json.RawMessage `json:"arguments"` - RecommendedAction string `json:"recommendedAction"` - RiskLevel string `json:"riskLevel"` - TicketDraftReady bool `json:"ticketDraftReady"` - } `json:"items"` - } `json:"graphTools"` -} - -func parseRuntimeTraceData(raw string) runtimeTraceProjection { - raw = strings.TrimSpace(raw) - if raw == "" { - return runtimeTraceProjection{} - } - var trace runtimeTraceProjection - if err := json.Unmarshal([]byte(raw), &trace); err != nil { - return runtimeTraceProjection{} - } - return trace -} diff --git a/internal/ai/runtime/service.go b/internal/ai/runtime/service.go index 3c27b19..49abfd9 100644 --- a/internal/ai/runtime/service.go +++ b/internal/ai/runtime/service.go @@ -18,10 +18,10 @@ type service struct { app *applicationruntime.Service } -func (s *service) Run(ctx context.Context, req applicationruntime.Request) (*applicationruntime.Summary, error) { +func (s *service) Run(ctx context.Context, req applicationruntime.RunInput) (*applicationruntime.RunResult, error) { return s.app.Run(ctx, req) } -func (s *service) Resume(ctx context.Context, req applicationruntime.ResumeRequest) (*applicationruntime.Summary, error) { +func (s *service) Resume(ctx context.Context, req applicationruntime.ResumeInput) (*applicationruntime.RunResult, error) { return s.app.Resume(ctx, req) } diff --git a/internal/ai/runtime/tools/tool_search_tool.go b/internal/ai/runtime/tools/tool_search_tool.go index 491b406..ce56cac 100644 --- a/internal/ai/runtime/tools/tool_search_tool.go +++ b/internal/ai/runtime/tools/tool_search_tool.go @@ -168,9 +168,8 @@ func (t *ToolSearchTool) invokeTargetTool(ctx context.Context, toolCode string, if !containsToolCode(t.allowedToolCodes, toolCode) { return "", i18nx.Errorf("error.e0279") } - // 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. + // The published Agent allow-list is the approval boundary for MCP tools. + // The registry still enforces call limits and safety metadata. _, result, err := aitooling.DefaultMCPExecutor.Execute(ctx, toolCode, arguments, aitooling.Policy{ AllowedToolCodes: t.allowedToolCodes, Confirmed: true, diff --git a/internal/ai/runtime/traces/types.go b/internal/ai/runtime/traces/types.go index 48721eb..4f17a76 100644 --- a/internal/ai/runtime/traces/types.go +++ b/internal/ai/runtime/traces/types.go @@ -1,48 +1,5 @@ package traces -type ToolTraceItem struct { - ToolCode string `json:"toolCode"` - ServerCode string `json:"serverCode"` - ToolName string `json:"toolName"` - Arguments map[string]any `json:"arguments,omitempty"` - ResultPreview string `json:"resultPreview,omitempty"` - ResultReduced bool `json:"resultReduced,omitempty"` - OriginalChars int `json:"originalChars,omitempty"` - KeptChars int `json:"keptChars,omitempty"` - LatencyMs int64 `json:"latencyMs,omitempty"` - Status string `json:"status,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` - Blocked bool `json:"blocked,omitempty"` - BlockedReason string `json:"blockedReason,omitempty"` -} - -type ToolSearchTraceItem struct { - Action string `json:"action,omitempty"` - Query string `json:"query,omitempty"` - TargetToolCode string `json:"targetToolCode,omitempty"` - TargetServerCode string `json:"targetServerCode,omitempty"` - TargetToolName string `json:"targetToolName,omitempty"` - CandidateToolCodes []string `json:"candidateToolCodes,omitempty"` - Status string `json:"status,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` -} - -type GraphToolTraceItem struct { - ToolCode string `json:"toolCode"` - ToolName string `json:"toolName"` - Arguments map[string]any `json:"arguments,omitempty"` - ResultPreview string `json:"resultPreview,omitempty"` - ResultReduced bool `json:"resultReduced,omitempty"` - OriginalChars int `json:"originalChars,omitempty"` - KeptChars int `json:"keptChars,omitempty"` - LatencyMs int64 `json:"latencyMs,omitempty"` - Status string `json:"status,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` - RecommendedAction string `json:"recommendedAction,omitempty"` - RiskLevel string `json:"riskLevel,omitempty"` - TicketDraftReady bool `json:"ticketDraftReady,omitempty"` -} - type RetrieverTraceItem struct { Query string `json:"query,omitempty"` KnowledgeBaseID int64 `json:"knowledgeBaseId,omitempty"` @@ -65,107 +22,8 @@ type RetrieverTraceSummary struct { Policies []RetrieverPolicyTraceItem } -type AnswerabilityTraceData struct { - Status string `json:"status,omitempty"` - Reason string `json:"reason,omitempty"` - SupportingChunkIDs []string `json:"supportingChunkIds,omitempty"` - MissingInfo []string `json:"missingInfo,omitempty"` - LatencyMs int64 `json:"latencyMs,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` -} - type RetrieverPolicyTraceItem struct { KnowledgeBaseID int64 `json:"knowledgeBaseId,omitempty"` TopK int `json:"topK,omitempty"` ScoreThreshold float64 `json:"scoreThreshold,omitempty"` } - -type InstructionTraceSummary struct { - SectionTitles []string - HasAgentRule bool - HasSkillRule bool - HasToolRule bool -} - -type RuntimeTraceData struct { - Version string `json:"version"` - Status string `json:"status"` - RunID string `json:"runId,omitempty"` - Skill SkillTraceData `json:"skill,omitempty"` - Interrupt struct { - CheckPointID string `json:"checkPointId,omitempty"` - Items []InterruptTraceContext `json:"items,omitempty"` - } `json:"interrupt"` - Model struct { - Provider string `json:"provider,omitempty"` - Name string `json:"name,omitempty"` - } `json:"model"` - Instruction struct { - SectionTitles []string `json:"sectionTitles,omitempty"` - HasAgentRule bool `json:"hasAgentRule,omitempty"` - HasSkillRule bool `json:"hasSkillRule,omitempty"` - HasToolRule bool `json:"hasToolRule,omitempty"` - } `json:"instruction"` - Input struct { - HistoryMessageCount int `json:"historyMessageCount,omitempty"` - KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds,omitempty"` - ToolCodes []string `json:"toolCodes,omitempty"` - StaticToolCodes []string `json:"staticToolCodes,omitempty"` - DynamicToolCodes []string `json:"dynamicToolCodes,omitempty"` - ToolSearchEnabled bool `json:"toolSearchEnabled,omitempty"` - CurrentUserMessagePreview string `json:"currentUserMessagePreview,omitempty"` - } `json:"input"` - Retriever struct { - Count int `json:"count,omitempty"` - TopK int `json:"topK,omitempty"` - ScoreThreshold float64 `json:"scoreThreshold,omitempty"` - ContextMaxTokens int `json:"contextMaxTokens,omitempty"` - MaxContextItems int `json:"maxContextItems,omitempty"` - ContextCount int `json:"contextCount,omitempty"` - EmbeddingMs int64 `json:"embeddingMs,omitempty"` - VectorSearchMs int64 `json:"vectorSearchMs,omitempty"` - HydrateMs int64 `json:"hydrateMs,omitempty"` - Policies []RetrieverPolicyTraceItem `json:"policies,omitempty"` - Items []RetrieverTraceItem `json:"items,omitempty"` - } `json:"retriever"` - Answerability AnswerabilityTraceData `json:"answerability,omitempty"` - Tools struct { - Count int `json:"count,omitempty"` - Items []ToolTraceItem `json:"items,omitempty"` - } `json:"tools"` - ToolSearch struct { - Count int `json:"count,omitempty"` - Items []ToolSearchTraceItem `json:"items,omitempty"` - } `json:"toolSearch"` - GraphTools struct { - Count int `json:"count,omitempty"` - Items []GraphToolTraceItem `json:"items,omitempty"` - } `json:"graphTools"` - Output struct { - ReplyText string `json:"replyText,omitempty"` - FinishReason string `json:"finishReason,omitempty"` - } `json:"output"` - Error struct { - Message string `json:"message,omitempty"` - Stage string `json:"stage,omitempty"` - } `json:"error"` -} - -type SkillTraceData struct { - ID int64 `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - RouteReason string `json:"routeReason,omitempty"` - RouteTrace string `json:"routeTrace,omitempty"` - AllowedToolCodes []string `json:"allowedToolCodes,omitempty"` - FilteredToolCodes []string `json:"filteredToolCodes,omitempty"` - MiddlewareEnabled bool `json:"middlewareEnabled,omitempty"` - MiddlewareToolName string `json:"middlewareToolName,omitempty"` - VisibleIDs []int64 `json:"visibleIds,omitempty"` -} - -type InterruptTraceContext struct { - Type string `json:"type,omitempty"` - ID string `json:"id"` - InfoPreview string `json:"infoPreview,omitempty"` -} diff --git a/internal/ai/skills/candidate_loader.go b/internal/ai/skills/candidate_loader.go deleted file mode 100644 index 3bde7ca..0000000 --- a/internal/ai/skills/candidate_loader.go +++ /dev/null @@ -1,36 +0,0 @@ -package skills - -import ( - "agent-desk/internal/models" - "agent-desk/internal/pkg/enums" - "agent-desk/internal/pkg/utils" - "agent-desk/internal/repositories" - - "github.com/mlogclub/simple/sqls" -) - -var newCandidateLoader = func() *candidateLoader { - return &candidateLoader{} -} - -type candidateLoader struct { -} - -func (l *candidateLoader) findManualSkillDefinition(skillDefinitionID int64) *models.SkillDefinition { - if skillDefinitionID <= 0 { - return nil - } - return repositories.SkillDefinitionRepository.Get(sqls.DB(), skillDefinitionID) -} - -func (l *candidateLoader) loadCandidateSkills(aiAgent models.AIAgent) []models.SkillDefinition { - skillIDs := utils.SplitInt64s(aiAgent.SkillIDs) - skills := repositories.SkillDefinitionRepository.GetByIDs(sqls.DB(), skillIDs) - ret := make([]models.SkillDefinition, 0, len(skillIDs)) - for _, id := range skillIDs { - if skill, ok := skills[id]; ok && skill.Status == enums.StatusOk { - ret = append(ret, skill) - } - } - return ret -} diff --git a/internal/ai/skills/log.go b/internal/ai/skills/log.go deleted file mode 100644 index 60582b7..0000000 --- a/internal/ai/skills/log.go +++ /dev/null @@ -1,13 +0,0 @@ -package skills - -import "agent-desk/internal/models" - -// BuildRunLog 根据执行计划与运行结果构建 Skill 运行日志。 -func BuildRunLog(ctx RuntimeContext, plan *ExecutionPlan, trace *ExecutionTrace, err error) *models.SkillRunLog { - return RuntimeService.runlog.Build(ctx, plan, trace, err) -} - -// WriteRunLog 写入 Skill 路由日志。 -func WriteRunLog(log *models.SkillRunLog) error { - return RuntimeService.runlog.Write(log) -} diff --git a/internal/ai/skills/log_test.go b/internal/ai/skills/log_test.go deleted file mode 100644 index eef2e96..0000000 --- a/internal/ai/skills/log_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package skills - -import ( - "strings" - "testing" - - "agent-desk/internal/models" - "agent-desk/internal/pkg/enums" -) - -func TestBuildRunLogMatchedPlan(t *testing.T) { - log := BuildRunLog( - RuntimeContext{ - AIAgent: models.AIAgent{ID: 22}, - AIConfig: models.AIConfig{ID: 33}, - ConversationID: 11, - ManualSkillDefinitionID: 44, - UserMessage: "我要退款", - }, - &ExecutionPlan{ - AIAgent: models.AIAgent{ID: 22}, - AIConfig: models.AIConfig{ - ID: 33, - ModelName: "gpt-test", - Provider: enums.AIProviderOpenAI, - }, - Skill: &models.SkillDefinition{ID: 44}, - MatchReason: "llm_route", - }, - &ExecutionTrace{Status: "ok"}, - nil, - ) - - if log == nil { - t.Fatalf("expected run log") - } - if log.ConversationID != 11 || log.AIAgentID != 22 || log.AIConfigID != 33 { - t.Fatalf("unexpected ids in run log: %#v", log) - } - if !log.Matched || !log.FinalSelected || log.SkillDefinitionID != 44 { - t.Fatalf("expected matched skill log, got %#v", log) - } - if log.MatchReason != "llm_route" { - t.Fatalf("unexpected match reason: %q", log.MatchReason) - } - if !strings.Contains(log.TraceData, `"status":"ok"`) { - t.Fatalf("expected trace data to contain status, got %q", log.TraceData) - } -} - -func TestBuildRunLogNotMatchedAndError(t *testing.T) { - log := BuildRunLog( - RuntimeContext{ - AIAgent: models.AIAgent{ID: 22}, - UserMessage: "随便问问", - }, - nil, - &ExecutionTrace{Status: "route_error"}, - assertErr("route failed"), - ) - - if log == nil { - t.Fatalf("expected run log") - } - if log.Matched { - t.Fatalf("expected unmatched log") - } - if log.ErrorMessage != "route failed" { - t.Fatalf("unexpected error message: %q", log.ErrorMessage) - } - - noMatchLog := BuildRunLog( - RuntimeContext{AIAgent: models.AIAgent{ID: 22}, UserMessage: "随便问问"}, - &ExecutionPlan{MatchReason: ""}, - &ExecutionTrace{Status: "not_matched"}, - nil, - ) - if noMatchLog.MatchReason != "not_matched" { - t.Fatalf("expected default not_matched reason, got %q", noMatchLog.MatchReason) - } -} - -type assertErr string - -func (e assertErr) Error() string { - return string(e) -} diff --git a/internal/ai/skills/matcher.go b/internal/ai/skills/matcher.go deleted file mode 100644 index 2f31515..0000000 --- a/internal/ai/skills/matcher.go +++ /dev/null @@ -1,63 +0,0 @@ -package skills - -import ( - "context" - - "agent-desk/internal/models" - "agent-desk/internal/pkg/enums" - "agent-desk/internal/pkg/errorsx" -) - -type intentTriggerConfig struct { - Intents []string `json:"intents"` -} - -// MatchSkill 对单个 SkillDefinition 执行命中判断。 -func MatchSkill(execCtx context.Context, ctx RuntimeContext) (*models.SkillDefinition, string, *RouteTrace, error) { - loader := newCandidateLoader() - if ctx.ManualSkillDefinitionID > 0 { - skill := loader.findManualSkillDefinition(ctx.ManualSkillDefinitionID) - if skill == nil || skill.Status != enums.StatusOk { - return nil, "", nil, errorsx.InvalidParamI18n("error.e0054") - } - return skill, "manual_skill_id", &RouteTrace{ - Status: "manual_selected", - SelectedSkillID: skill.ID, - }, nil - } - - candidates := loader.loadCandidateSkills(ctx.AIAgent) - trace := &RouteTrace{ - Status: "started", - CandidateSkillIDs: make([]int64, 0, len(candidates)), - } - for _, item := range candidates { - trace.CandidateSkillIDs = append(trace.CandidateSkillIDs, item.ID) - } - if len(candidates) == 0 { - trace.Status = "no_candidate" - return nil, "no_enabled_skill_bound", trace, nil - } - - selected, routeTrace, err := routeSkillWithLLM(execCtx, ctx, candidates) - if routeTrace != nil { - trace.Status = routeTrace.Status - trace.SelectedSkillID = routeTrace.SelectedSkillID - trace.RawDecision = routeTrace.RawDecision - trace.LatencyMs = routeTrace.LatencyMs - trace.Error = routeTrace.Error - } - if err != nil { - if trace.Error == "" { - trace.Error = err.Error() - } - return nil, "route_error", trace, err - } - if selected == nil { - if trace.Status == "started" { - trace.Status = "not_matched" - } - return nil, "route_none", trace, nil - } - return selected, "llm_route", trace, nil -} diff --git a/internal/ai/skills/plan_service.go b/internal/ai/skills/plan_service.go deleted file mode 100644 index 1cd6c52..0000000 --- a/internal/ai/skills/plan_service.go +++ /dev/null @@ -1,28 +0,0 @@ -package skills - -import ( - "context" - "strings" -) - -func newPlanService() *planService { - return &planService{} -} - -type planService struct{} - -// BuildExecutionPlan 构建当前请求的 Skill 执行计划。 -func (s *planService) BuildExecutionPlan(execCtx context.Context, ctx RuntimeContext) (*ExecutionPlan, error) { - skill, matchReason, routeTrace, err := MatchSkill(execCtx, ctx) - if err != nil { - return nil, err - } - - return &ExecutionPlan{ - AIAgent: ctx.AIAgent, - AIConfig: ctx.AIConfig, - Skill: skill, - MatchReason: strings.TrimSpace(matchReason), - RouteTrace: routeTrace, - }, nil -} diff --git a/internal/ai/skills/router.go b/internal/ai/skills/router.go deleted file mode 100644 index 85a7156..0000000 --- a/internal/ai/skills/router.go +++ /dev/null @@ -1,122 +0,0 @@ -package skills - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" - "time" - - "agent-desk/internal/ai" - "agent-desk/internal/models" - - "github.com/mlogclub/simple/common/strs" -) - -const routeSkillSystemPrompt = `你是客服技能路由器。你只能在候选 Skill 中选择一个最合适的 skillId,或者返回 NONE。 -只有当用户问题与 Skill 的职责边界明确匹配时才选择; -如果不明确、信息不足、多个 Skill 都不够确定,就返回 NONE。 -输出只能是 skillId 或 NONE,不能输出其他内容。` - -func routeSkillWithLLM(ctx context.Context, runtimeCtx RuntimeContext, candidates []models.SkillDefinition) (*models.SkillDefinition, *RouteTrace, error) { - trace := &RouteTrace{Status: "started"} - if len(candidates) == 0 { - trace.Status = "no_candidate" - return nil, trace, nil - } - if strs.IsBlank(runtimeCtx.UserMessage) { - trace.Status = "empty_user_message" - return nil, trace, nil - } - userPrompt := buildSkillRoutePrompt(runtimeCtx.UserMessage, candidates) - startedAt := time.Now() - result, err := ai.LLM.ChatWithConfig(ctx, runtimeCtx.AIConfig, routeSkillSystemPrompt, userPrompt) - trace.LatencyMs = time.Since(startedAt).Milliseconds() - if err != nil { - trace.Status = "route_error" - trace.Error = err.Error() - return nil, trace, err - } - decision := normalizeRouteDecision(result.Content) - trace.RawDecision = strings.TrimSpace(result.Content) - if decision == "" || decision == "NONE" { - trace.Status = "not_matched" - return nil, trace, nil - } - selectedID, parseErr := strconv.ParseInt(decision, 10, 64) - if parseErr != nil || selectedID <= 0 { - trace.Status = "invalid_decision" - trace.Error = fmt.Sprintf("invalid route decision: %s", decision) - return nil, trace, nil - } - for _, item := range candidates { - if item.ID == selectedID { - trace.Status = "llm_selected" - trace.SelectedSkillID = item.ID - return &item, trace, nil - } - } - trace.Status = "invalid_decision" - trace.Error = fmt.Sprintf("invalid route decision: %s", decision) - return nil, trace, nil -} - -func buildSkillRoutePrompt(userMessage string, candidates []models.SkillDefinition) string { - lines := make([]string, 0, len(candidates)+4) - lines = append(lines, "用户问题:") - lines = append(lines, strings.TrimSpace(userMessage)) - lines = append(lines, "") - lines = append(lines, "候选 Skills:") - for _, item := range candidates { - line := fmt.Sprintf("- skillId=%d; name=%s; description=%s", item.ID, strings.TrimSpace(item.Name), strings.TrimSpace(item.Description)) - if examples := parseSkillExamples(item.Examples); len(examples) > 0 { - line += "; examples=" + strings.Join(examples, " | ") - } - lines = append(lines, line) - } - lines = append(lines, "") - lines = append(lines, "请只输出一个 skillId 或 NONE。") - return strings.Join(lines, "\n") -} - -func parseSkillExamples(raw string) []string { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil - } - var items []string - if err := json.Unmarshal([]byte(raw), &items); err != nil { - return nil - } - ret := make([]string, 0, len(items)) - for _, item := range items { - item = strings.TrimSpace(item) - if item == "" { - continue - } - ret = append(ret, item) - if len(ret) >= 3 { - break - } - } - return ret -} - -func normalizeRouteDecision(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if idx := strings.Index(raw, "\n"); idx >= 0 { - raw = raw[:idx] - } - raw = strings.TrimSpace(raw) - raw = strings.Trim(raw, "`") - raw = strings.TrimSpace(raw) - raw = strings.Trim(raw, "\"'") - if strings.EqualFold(raw, "NONE") { - return "NONE" - } - return raw -} diff --git a/internal/ai/skills/router_test.go b/internal/ai/skills/router_test.go deleted file mode 100644 index d3ad22c..0000000 --- a/internal/ai/skills/router_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package skills - -import ( - "strings" - "testing" - - "agent-desk/internal/models" -) - -func TestParseSkillExamples(t *testing.T) { - examples := parseSkillExamples(`[" 退款进度 ","","发票补开","修改收货地址","多余示例"]`) - if len(examples) != 3 { - t.Fatalf("expected 3 examples, got %d", len(examples)) - } - if examples[0] != "退款进度" || examples[1] != "发票补开" || examples[2] != "修改收货地址" { - t.Fatalf("unexpected examples: %#v", examples) - } -} - -func TestNormalizeRouteDecision(t *testing.T) { - if got := normalizeRouteDecision("```44```\n补充说明"); got != "44" { - t.Fatalf("unexpected normalized decision: %q", got) - } - if got := normalizeRouteDecision(" none "); got != "NONE" { - t.Fatalf("expected NONE, got %q", got) - } -} - -func TestBuildSkillRoutePrompt(t *testing.T) { - prompt := buildSkillRoutePrompt("我要申请退款", []models.SkillDefinition{ - { - ID: 44, - Name: "退款处理", - Description: "负责退款和退货相关问题", - Examples: `["退款进度","退货运费"]`, - }, - }) - - if !strings.Contains(prompt, "skillId=44") { - t.Fatalf("expected prompt to include skill id, got %q", prompt) - } - if !strings.Contains(prompt, "examples=退款进度 | 退货运费") { - t.Fatalf("expected prompt to include examples, got %q", prompt) - } - if !strings.Contains(prompt, "请只输出一个 skillId 或 NONE。") { - t.Fatalf("expected prompt to include output constraint, got %q", prompt) - } -} diff --git a/internal/ai/skills/runlog_service.go b/internal/ai/skills/runlog_service.go deleted file mode 100644 index 8e503d4..0000000 --- a/internal/ai/skills/runlog_service.go +++ /dev/null @@ -1,70 +0,0 @@ -package skills - -import ( - "encoding/json" - "time" - - "agent-desk/internal/models" - "agent-desk/internal/repositories" - - "github.com/mlogclub/simple/sqls" -) - -func newRunLogService() *RunLogService { - return &RunLogService{} -} - -type RunLogService struct{} - -// Build 根据执行计划与运行结果构建 Skill 运行日志。 -func (s *RunLogService) Build(ctx RuntimeContext, plan *ExecutionPlan, trace *ExecutionTrace, err error) *models.SkillRunLog { - log := &models.SkillRunLog{ - ConversationID: ctx.ConversationID, - AIAgentID: ctx.AIAgent.ID, - ManualSkillID: ctx.ManualSkillDefinitionID, - UserMessage: ctx.UserMessage, - TraceData: s.buildTraceData(trace), - CreatedAt: time.Now(), - } - if plan != nil { - log.AIConfigID = plan.AIConfig.ID - log.UsedModel = plan.AIConfig.ModelName - log.UsedProvider = plan.AIConfig.Provider - - if plan.Skill != nil { - log.SkillDefinitionID = plan.Skill.ID - log.Matched = true - log.FinalSelected = true - log.MatchReason = plan.MatchReason - } - } - if err != nil { - log.ErrorMessage = err.Error() - } else if !log.Matched { - if plan != nil && plan.MatchReason != "" { - log.MatchReason = plan.MatchReason - } else { - log.MatchReason = "not_matched" - } - } - return log -} - -// Write 写入 Skill 路由日志。 -func (s *RunLogService) Write(log *models.SkillRunLog) error { - if log == nil { - return nil - } - return repositories.SkillRunLogRepository.Create(sqls.DB(), log) -} - -func (s *RunLogService) buildTraceData(trace *ExecutionTrace) string { - if trace == nil { - return "" - } - data, err := json.Marshal(trace) - if err != nil { - return "" - } - return string(data) -} diff --git a/internal/ai/skills/runtime.go b/internal/ai/skills/runtime.go deleted file mode 100644 index 3200155..0000000 --- a/internal/ai/skills/runtime.go +++ /dev/null @@ -1,15 +0,0 @@ -package skills - -import ( - "context" -) - -// BuildExecutionPlan 构建当前请求的 Skill 执行计划。 -func BuildExecutionPlan(execCtx context.Context, ctx RuntimeContext) (*ExecutionPlan, error) { - return RuntimeService.BuildExecutionPlan(execCtx, ctx) -} - -// Select 执行一次 Skill 路由并记录路由日志。 -func Select(ctx context.Context, runtimeCtx RuntimeContext) (*ExecutionResult, error) { - return RuntimeService.Select(ctx, runtimeCtx) -} diff --git a/internal/ai/skills/service.go b/internal/ai/skills/service.go deleted file mode 100644 index a443bc6..0000000 --- a/internal/ai/skills/service.go +++ /dev/null @@ -1,72 +0,0 @@ -package skills - -import ( - "context" - "strings" - - "agent-desk/internal/models" -) - -var RuntimeService = newService() - -func newService() *Service { - return &Service{ - plan: newPlanService(), - runlog: newRunLogService(), - } -} - -type Service struct { - plan *planService - runlog *RunLogService -} - -// BuildExecutionPlan 构建当前请求的 Skill 执行计划。 -func (s *Service) BuildExecutionPlan(execCtx context.Context, ctx RuntimeContext) (*ExecutionPlan, error) { - return s.plan.BuildExecutionPlan(execCtx, ctx) -} - -// WriteRunLog 写入 Skill 路由日志。 -func (s *Service) WriteRunLog(log *models.SkillRunLog) error { - return s.runlog.Write(log) -} - -// Select 执行一次 Skill 路由并记录路由日志。 -func (s *Service) Select(ctx context.Context, runtimeCtx RuntimeContext) (*ExecutionResult, error) { - plan, err := s.BuildExecutionPlan(ctx, runtimeCtx) - if err != nil { - trace := &ExecutionTrace{Status: "route_error"} - log := s.runlog.Build(runtimeCtx, nil, trace, err) - _ = s.WriteRunLog(log) - return nil, err - } - trace := &ExecutionTrace{Status: "ok"} - if plan == nil || plan.Skill == nil { - if plan != nil { - trace.Status = "not_matched" - trace.MatchReason = strings.TrimSpace(plan.MatchReason) - trace.Route = plan.RouteTrace - } - log := s.runlog.Build(runtimeCtx, plan, trace, nil) - _ = s.WriteRunLog(log) - return &ExecutionResult{ - Plan: plan, - RunLog: log, - Trace: trace, - }, nil - } - trace.MatchReason = strings.TrimSpace(plan.MatchReason) - trace.Route = plan.RouteTrace - log := s.runlog.Build(runtimeCtx, plan, trace, err) - if writeErr := s.WriteRunLog(log); writeErr != nil && err == nil { - err = writeErr - } - if err != nil { - return nil, err - } - return &ExecutionResult{ - Plan: plan, - RunLog: log, - Trace: trace, - }, nil -} diff --git a/internal/ai/skills/types.go b/internal/ai/skills/types.go deleted file mode 100644 index 27127ea..0000000 --- a/internal/ai/skills/types.go +++ /dev/null @@ -1,52 +0,0 @@ -package skills - -import "agent-desk/internal/models" - -// RuntimeContext 表示一次 Skill 运行的输入上下文。 -type RuntimeContext struct { - AIAgent models.AIAgent // AIAgent 为当前请求所属的 AI Agent,必填。 - AIConfig models.AIConfig // AIConfig 为当前请求实际使用的模型配置,必填。 - UserMessage string // UserMessage 为当前用户输入。 - ConversationID int64 // ConversationID 为当前会话 ID,无会话上下文时为 0。 - ManualSkillDefinitionID int64 // ManualSkillDefinitionID 为显式指定的 Skill 定义ID。 -} - -// ExecutionPlan 表示 Skill Runtime 计算出的最终路由结果。 -type ExecutionPlan struct { - AIAgent models.AIAgent // AIAgent 为本次请求所属的 AI Agent。 - AIConfig models.AIConfig // AIConfig 为本次请求实际使用的模型配置。 - Skill *models.SkillDefinition // Skill 为最终命中的 Skill,未命中时为空。 - MatchReason string // MatchReason 为命中原因。 - RouteTrace *RouteTrace // RouteTrace 为匹配阶段的路由追踪。 -} - -// ExecutionResult 表示一次 Skill 路由的最终结果。 -type ExecutionResult struct { - Plan *ExecutionPlan - RunLog *models.SkillRunLog - Trace *ExecutionTrace -} - -type ExecutionTrace struct { - Status string `json:"status"` - MatchReason string `json:"matchReason,omitempty"` - Route *RouteTrace `json:"route,omitempty"` -} - -type RouteTrace struct { - Status string `json:"status"` - CandidateSkillIDs []int64 `json:"candidateSkillIds,omitempty"` - SelectedSkillID int64 `json:"selectedSkillId,omitempty"` - RawDecision string `json:"rawDecision,omitempty"` - LatencyMs int64 `json:"latencyMs,omitempty"` - Error string `json:"error,omitempty"` -} - -type PromptTrace struct { - Status string `json:"status"` - LatencyMs int64 `json:"latencyMs,omitempty"` - ModelName string `json:"modelName,omitempty"` - PromptTokens int `json:"promptTokens,omitempty"` - CompletionTokens int `json:"completionTokens,omitempty"` - Error string `json:"error,omitempty"` -} diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 923748f..333c37b 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -249,7 +249,6 @@ func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) { 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) diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go index 1f282e4..f20cf31 100644 --- a/internal/bootstrap/server_route_test.go +++ b/internal/bootstrap/server_route_test.go @@ -47,7 +47,6 @@ func TestNewServerRegistersGinRoutes(t *testing.T) { 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", diff --git a/internal/builders/agent_revision_builder.go b/internal/builders/agent_revision_builder.go index a21271a..eb24cdc 100644 --- a/internal/builders/agent_revision_builder.go +++ b/internal/builders/agent_revision_builder.go @@ -14,7 +14,7 @@ func BuildAgentRevision(item *models.AgentRevision) response.AgentRevisionRespon 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, + ID: item.ID, AgentID: item.AgentID, Revision: item.Revision, Status: item.Status, DefinitionHash: item.DefinitionHash, PublishedAt: publishedAt, PublishedByID: item.PublishedByID, PublishedByName: item.PublishedByName, } diff --git a/internal/builders/agent_run_builder.go b/internal/builders/agent_run_builder.go index 3dc5bd8..d7554ba 100644 --- a/internal/builders/agent_run_builder.go +++ b/internal/builders/agent_run_builder.go @@ -18,7 +18,6 @@ func BuildAgentRun(item *models.AgentRun) response.AgentRunResponse { AgentRevisionID: item.AgentRevisionID, SourceMessageID: item.SourceMessageID, WorkflowRunID: item.WorkflowRunID, - EngineCode: item.EngineCode, Status: item.Status, PromptTokens: item.PromptTokens, CompletionTokens: item.CompletionTokens, diff --git a/internal/handlers/dashboard/agent_run_handler.go b/internal/handlers/dashboard/agent_run_handler.go index c157091..9e35dbf 100644 --- a/internal/handlers/dashboard/agent_run_handler.go +++ b/internal/handlers/dashboard/agent_run_handler.go @@ -24,7 +24,6 @@ func AgentRunAnyList(ctx *gin.Context) { 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) @@ -75,15 +74,6 @@ func AgentRunAnyMetrics(ctx *gin.Context) { 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) diff --git a/internal/handlers/dashboard/ai_agent_handler.go b/internal/handlers/dashboard/ai_agent_handler.go index 39ee45a..6d7e8e7 100644 --- a/internal/handlers/dashboard/ai_agent_handler.go +++ b/internal/handlers/dashboard/ai_agent_handler.go @@ -237,10 +237,6 @@ 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, @@ -248,8 +244,6 @@ func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) respons 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, @@ -272,11 +266,7 @@ func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) respons Teams: make([]response.AIAgentTeamResponse, 0), MCPTools: make([]response.AIAgentMCPToolResponse, 0), WorkflowBindings: make([]response.AIAgentWorkflowBindingResponse, 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"), @@ -336,12 +326,14 @@ func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) respons } } ret.MCPTools = append(ret.MCPTools, response.AIAgentMCPToolResponse{ - ToolCode: toolCode, - ServerCode: serverCode, - ToolName: toolName, - Title: title, - Description: description, - Arguments: tool.Arguments, + ToolCode: toolCode, + ServerCode: serverCode, + ToolName: toolName, + Title: title, + Description: description, + RiskLevel: tool.RiskLevel, + RequireConfirmation: tool.RequireConfirmation, + Arguments: tool.Arguments, }) } } @@ -358,17 +350,3 @@ func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) respons } return ret } - -func aiAgentWorkflowState(workflowVersionID int64) string { - if workflowVersionID > 0 { - return "published" - } - return "draft" -} - -func aiAgentWorkflowStateText(workflowVersionID int64) string { - if workflowVersionID > 0 { - return "已发布" - } - return "未发布" -} diff --git a/internal/handlers/dashboard/ai_agent_handler_test.go b/internal/handlers/dashboard/ai_agent_handler_test.go index b10f419..cb5c09e 100644 --- a/internal/handlers/dashboard/ai_agent_handler_test.go +++ b/internal/handlers/dashboard/ai_agent_handler_test.go @@ -4,39 +4,18 @@ import ( "testing" "agent-desk/internal/models" - "agent-desk/internal/pkg/enums" "github.com/glebarez/sqlite" "github.com/mlogclub/simple/sqls" "gorm.io/gorm" ) -func TestBuildAIAgentResponseExposesWorkflowPublishState(t *testing.T) { +func TestBuildAIAgentResponseExposesPublishedRevision(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") - } - if draft.WorkflowState != "draft" { - t.Fatalf("draft.WorkflowState = %q, want draft", draft.WorkflowState) - } - if draft.WorkflowStateText == "" { - t.Fatalf("expected draft workflow state text") - } - - published := buildAIAgentResponse(&models.AIAgent{WorkflowVersionID: 12}) - if !published.WorkflowPublished { - t.Fatalf("published.WorkflowPublished = false, want true") - } - if published.WorkflowState != "published" { - t.Fatalf("published.WorkflowState = %q, want published", published.WorkflowState) - } - if published.WorkflowStateText == "" { - t.Fatalf("expected published workflow state text") + published := buildAIAgentResponse(&models.AIAgent{PublishedRevisionID: 12}) + if published.PublishedRevisionID != 12 { + t.Fatalf("published revision = %d, want 12", published.PublishedRevisionID) } rollout := buildAIAgentResponse(&models.AIAgent{RolloutPercent: 20, PreviousRolloutPercent: 100}) diff --git a/internal/handlers/dashboard/ai_workflow_handler.go b/internal/handlers/dashboard/ai_workflow_handler.go index a5cdacf..b4c6327 100644 --- a/internal/handlers/dashboard/ai_workflow_handler.go +++ b/internal/handlers/dashboard/ai_workflow_handler.go @@ -158,7 +158,7 @@ func AIWorkflowGetTemplateList(ctx *gin.Context) { httpx.WriteJSON(ctx, err) return } - httpx.WriteJSON(ctx, builders.BuildAIWorkflowTemplates(services.AIWorkflowService.ListPlaybookTemplates())) + httpx.WriteJSON(ctx, builders.BuildAIWorkflowTemplates(services.AIWorkflowService.ListWorkflowTemplates())) } func AIWorkflowPostValidate(ctx *gin.Context) { diff --git a/internal/models/models.go b/internal/models/models.go index b296d87..63462de 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -56,7 +56,6 @@ var Models = []any{ &KnowledgeRetrieveHit{}, &KnowledgeFeedback{}, &SkillDefinition{}, - &SkillRunLog{}, &AgentRevision{}, &AgentRun{}, &AgentStep{}, @@ -530,51 +529,48 @@ 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。 - 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 为 Agent 允许调用的 MCP 工具白名单配置 JSON。 - WorkflowVersionID int64 `gorm:"type:bigint;not null;default:0;index"` // WorkflowVersionID is retained as the single-workflow runtime pointer. - PublishedRevisionID int64 `gorm:"type:bigint;not null;default:0;index"` // PublishedRevisionID 为当前已发布 Agent 配置快照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。 + MaxSteps int `gorm:"type:int;not null;default:6"` // MaxSteps 为一次 Agent Loop 允许的最大推理步骤数。 + 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 为 Agent 允许调用的 MCP 工具白名单配置 JSON。 + 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:''"` + 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"` + 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. +// AgentRun is the parent audit record for one Agent Loop execution. type AgentRun struct { ID int64 `gorm:"primaryKey;autoIncrement"` ConversationID int64 `gorm:"type:bigint;not null;default:0;index"` @@ -582,7 +578,6 @@ type AgentRun struct { AgentRevisionID int64 `gorm:"type:bigint;not null;default:0;index"` 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"` @@ -1015,25 +1010,6 @@ type SkillDefinition struct { AuditFields } -// SkillRunLog 表示一次 Skill 运行过程的审计日志。 -type SkillRunLog struct { - ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为 Skill 运行日志主键。 - ConversationID int64 `gorm:"type:bigint;not null;default:0;index"` // ConversationID 为关联会话ID,无会话上下文时为0。 - AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"` // AIAgentID 为本次运行所属的 AI Agent ID。 - AIConfigID int64 `gorm:"type:bigint;not null;default:0;index"` // AIConfigID 为本次运行实际使用的 AI 配置ID。 - SkillDefinitionID int64 `gorm:"type:bigint;not null;default:0;index"` // SkillDefinitionID 为最终命中的 Skill 定义ID,未命中时为0。 - ManualSkillID int64 `gorm:"type:bigint;not null;default:0;index"` // ManualSkillID 为本次请求显式指定的 Skill 定义ID。 - UserMessage string `gorm:"type:longtext"` // UserMessage 为本次请求的用户输入内容。 - Matched bool `gorm:"not null;default:false;index"` // Matched 表示本次请求是否命中了 Skill。 - MatchReason string `gorm:"type:varchar(500);not null;default:''"` // MatchReason 为命中或未命中的原因说明。 - FinalSelected bool `gorm:"not null;default:false;index"` // FinalSelected 表示该日志记录的 Skill 是否为最终选中的执行 Skill。 - UsedModel string `gorm:"type:varchar(100);not null;default:''"` // UsedModel 为本次实际调用的模型名称。 - UsedProvider enums.AIProvider `gorm:"type:varchar(50);not null;default:''"` // UsedProvider 为本次实际调用的模型供应商。 - ErrorMessage string `gorm:"type:text"` // ErrorMessage 为运行过程中的错误信息。 - TraceData string `gorm:"type:text"` // TraceData 为 Skill 执行链路追踪数据JSON。 - CreatedAt time.Time `gorm:"type:datetime;not null;index"` // CreatedAt 为运行日志创建时间。 -} - // ConversationInterrupt 表示会话级待恢复中断记录。 type ConversationInterrupt struct { ID int64 `gorm:"primaryKey;autoIncrement"` diff --git a/internal/pkg/dto/request/agent_evaluation_request.go b/internal/pkg/dto/request/agent_evaluation_request.go index 61ff1f5..50379b5 100644 --- a/internal/pkg/dto/request/agent_evaluation_request.go +++ b/internal/pkg/dto/request/agent_evaluation_request.go @@ -1,9 +1,8 @@ package request type RunAgentEvaluationRequest struct { - AIAgentID int64 `json:"aiAgentId"` - EngineCode string `json:"engineCode"` - Cases []AgentEvaluationCase `json:"cases"` + AIAgentID int64 `json:"aiAgentId"` + Cases []AgentEvaluationCase `json:"cases"` } type AgentEvaluationCase struct { diff --git a/internal/pkg/dto/request/ai_request.go b/internal/pkg/dto/request/ai_request.go index 4a9d7f5..299547e 100644 --- a/internal/pkg/dto/request/ai_request.go +++ b/internal/pkg/dto/request/ai_request.go @@ -3,12 +3,14 @@ package request import "agent-desk/internal/pkg/enums" type AIAgentMCPToolRequest struct { - ToolCode string `json:"toolCode"` - ServerCode string `json:"serverCode"` - ToolName string `json:"toolName"` - Title string `json:"title"` - Description string `json:"description"` - Arguments map[string]string `json:"arguments"` + ToolCode string `json:"toolCode"` + ServerCode string `json:"serverCode"` + ToolName string `json:"toolName"` + Title string `json:"title"` + Description string `json:"description"` + RiskLevel string `json:"riskLevel"` + RequireConfirmation bool `json:"requireConfirmation"` + Arguments map[string]string `json:"arguments"` } type AIAgentWorkflowBindingRequest struct { @@ -54,7 +56,6 @@ 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"` diff --git a/internal/pkg/dto/response/agent_evaluation_response.go b/internal/pkg/dto/response/agent_evaluation_response.go index 837ceed..5af5ecf 100644 --- a/internal/pkg/dto/response/agent_evaluation_response.go +++ b/internal/pkg/dto/response/agent_evaluation_response.go @@ -3,7 +3,6 @@ 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"` @@ -12,9 +11,8 @@ type AgentEvaluationResultResponse struct { } type AgentEvaluationReportResponse struct { - EngineCode string `json:"engineCode"` - Total int `json:"total"` - Passed int `json:"passed"` - Results []AgentEvaluationResultResponse `json:"results"` - CSV string `json:"csv"` + 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 index df5a263..d9aaa3e 100644 --- a/internal/pkg/dto/response/agent_run_response.go +++ b/internal/pkg/dto/response/agent_run_response.go @@ -9,7 +9,6 @@ type AgentRunResponse struct { 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"` diff --git a/internal/pkg/dto/response/ai_response.go b/internal/pkg/dto/response/ai_response.go index 8497f27..1c73be6 100644 --- a/internal/pkg/dto/response/ai_response.go +++ b/internal/pkg/dto/response/ai_response.go @@ -16,12 +16,14 @@ type AIAgentSkillResponse struct { } type AIAgentMCPToolResponse struct { - ToolCode string `json:"toolCode"` - ServerCode string `json:"serverCode"` - ToolName string `json:"toolName"` - Title string `json:"title"` - Description string `json:"description"` - Arguments map[string]string `json:"arguments"` + ToolCode string `json:"toolCode"` + ServerCode string `json:"serverCode"` + ToolName string `json:"toolName"` + Title string `json:"title"` + Description string `json:"description"` + RiskLevel string `json:"riskLevel"` + RequireConfirmation bool `json:"requireConfirmation"` + Arguments map[string]string `json:"arguments"` } type AIAgentWorkflowBindingResponse struct { @@ -37,15 +39,14 @@ type AIAgentWorkflowBindingResponse struct { } 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"` + ID int64 `json:"id"` + AgentID int64 `json:"agentId"` + Revision int `json:"revision"` + Status enums.Status `json:"status"` + DefinitionHash string `json:"definitionHash"` + PublishedAt string `json:"publishedAt"` + PublishedByID int64 `json:"publishedById"` + PublishedByName string `json:"publishedByName"` } type AIConfigResponse struct { @@ -98,8 +99,6 @@ type AIAgentResponse struct { 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"` @@ -122,11 +121,7 @@ type AIAgentResponse struct { Skills []AIAgentSkillResponse `json:"skills"` MCPTools []AIAgentMCPToolResponse `json:"mcpTools"` WorkflowBindings []AIAgentWorkflowBindingResponse `json:"workflowBindings"` - WorkflowVersionID int64 `json:"workflowVersionId"` PublishedRevisionID int64 `json:"publishedRevisionId"` - WorkflowPublished bool `json:"workflowPublished"` - WorkflowState string `json:"workflowState"` - WorkflowStateText string `json:"workflowStateText"` SortNo int `json:"sortNo"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` diff --git a/internal/pkg/dto/response/dashboard_response.go b/internal/pkg/dto/response/dashboard_response.go index 9bba059..16e7b65 100644 --- a/internal/pkg/dto/response/dashboard_response.go +++ b/internal/pkg/dto/response/dashboard_response.go @@ -63,7 +63,7 @@ type DashboardAIStatsResponse struct { TodayKnowledgeRetrieves int64 `json:"todayKnowledgeRetrieves"` TodayKnowledgeRetrieveFailCount int64 `json:"todayKnowledgeRetrieveFailCount"` TodayKnowledgeRetrieveFailRate float64 `json:"todayKnowledgeRetrieveFailRate"` - TodaySkillRunFailCount int64 `json:"todaySkillRunFailCount"` + TodayAgentRunFailCount int64 `json:"todayAgentRunFailCount"` TodayAIHandoffCount int64 `json:"todayAiHandoffCount"` } diff --git a/internal/pkg/dto/response/skill_response.go b/internal/pkg/dto/response/skill_response.go index 494ceb9..f5e55f7 100644 --- a/internal/pkg/dto/response/skill_response.go +++ b/internal/pkg/dto/response/skill_response.go @@ -22,14 +22,8 @@ type SkillDebugRunResponse struct { SkillDefinitionID int64 `json:"skillDefinitionId"` SkillName string `json:"skillName"` ReplyText string `json:"replyText"` - PlanReason string `json:"planReason"` - SkillRouteTrace string `json:"skillRouteTrace"` ToolWhitelist []string `json:"toolWhitelist"` - ExposedToolCodes []string `json:"exposedToolCodes"` InvokedToolCodes []string `json:"invokedToolCodes"` - ToolSearchTrace string `json:"toolSearchTrace"` - GraphToolTrace string `json:"graphToolTrace"` - GraphToolCode string `json:"graphToolCode"` InterruptType string `json:"interruptType"` CheckPointID string `json:"checkPointId"` Interrupted bool `json:"interrupted"` diff --git a/internal/pkg/enums/im.go b/internal/pkg/enums/im.go index 4f8bd63..da23f4c 100644 --- a/internal/pkg/enums/im.go +++ b/internal/pkg/enums/im.go @@ -239,39 +239,6 @@ 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/mcp_tool.go b/internal/pkg/toolx/mcp_tool.go index df6311a..858dbe3 100644 --- a/internal/pkg/toolx/mcp_tool.go +++ b/internal/pkg/toolx/mcp_tool.go @@ -63,11 +63,13 @@ func NormalizeMCPToolRequest(item request.AIAgentMCPToolRequest) (request.AIAgen return request.AIAgentMCPToolRequest{}, errorsx.InvalidParamI18n("error.e0018") } ret := request.AIAgentMCPToolRequest{ - ToolCode: toolCode, - ServerCode: serverCode, - ToolName: toolName, - Title: strings.TrimSpace(item.Title), - Description: strings.TrimSpace(item.Description), + ToolCode: toolCode, + ServerCode: serverCode, + ToolName: toolName, + Title: strings.TrimSpace(item.Title), + Description: strings.TrimSpace(item.Description), + RiskLevel: strings.ToLower(strings.TrimSpace(item.RiskLevel)), + RequireConfirmation: item.RequireConfirmation, } if len(item.Arguments) > 0 { ret.Arguments = make(map[string]string, len(item.Arguments)) diff --git a/internal/repositories/agent_revision_repository.go b/internal/repositories/agent_revision_repository.go index 18b9974..6296382 100644 --- a/internal/repositories/agent_revision_repository.go +++ b/internal/repositories/agent_revision_repository.go @@ -43,14 +43,3 @@ func (r *agentRevisionRepository) MaxRevisionByAgentID(db *gorm.DB, agentID int6 db.Model(&models.AgentRevision{}).Where("agent_id = ?", agentID).Select("COALESCE(MAX(revision), 0)").Scan(&ret) 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/dashboard_repository.go b/internal/repositories/dashboard_repository.go index d542e89..49dc6c9 100644 --- a/internal/repositories/dashboard_repository.go +++ b/internal/repositories/dashboard_repository.go @@ -97,9 +97,9 @@ func (r *dashboardRepository) CountKnowledgeRetrieveLogs(db *gorm.DB, query func return count } -func (r *dashboardRepository) CountSkillRunLogs(db *gorm.DB, query func(tx *gorm.DB) *gorm.DB) int64 { +func (r *dashboardRepository) CountAgentRuns(db *gorm.DB, query func(tx *gorm.DB) *gorm.DB) int64 { var count int64 - tx := db.Model(&models.SkillRunLog{}) + tx := db.Model(&models.AgentRun{}) if query != nil { tx = query(tx) } diff --git a/internal/repositories/skill_run_log_repository.go b/internal/repositories/skill_run_log_repository.go deleted file mode 100644 index a139676..0000000 --- a/internal/repositories/skill_run_log_repository.go +++ /dev/null @@ -1,102 +0,0 @@ -package repositories - -import ( - "agent-desk/internal/models" - - "agent-desk/internal/pkg/httpx/params" - - "github.com/mlogclub/simple/sqls" - "gorm.io/gorm" -) - -var SkillRunLogRepository = newSkillRunLogRepository() - -func newSkillRunLogRepository() *skillRunLogRepository { - return &skillRunLogRepository{} -} - -type skillRunLogRepository struct { -} - -func (r *skillRunLogRepository) Get(db *gorm.DB, id int64) *models.SkillRunLog { - ret := &models.SkillRunLog{} - if err := db.First(ret, "id = ?", id).Error; err != nil { - return nil - } - return ret -} - -func (r *skillRunLogRepository) Take(db *gorm.DB, where ...interface{}) *models.SkillRunLog { - ret := &models.SkillRunLog{} - if err := db.Take(ret, where...).Error; err != nil { - return nil - } - return ret -} - -func (r *skillRunLogRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.SkillRunLog) { - cnd.Find(db, &list) - return -} - -func (r *skillRunLogRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.SkillRunLog { - ret := &models.SkillRunLog{} - if err := cnd.FindOne(db, &ret); err != nil { - return nil - } - return ret -} - -func (r *skillRunLogRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.SkillRunLog, paging *sqls.Paging) { - return r.FindPageByCnd(db, ¶ms.Cnd) -} - -func (r *skillRunLogRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.SkillRunLog, paging *sqls.Paging) { - cnd.Find(db, &list) - count := cnd.Count(db, &models.SkillRunLog{}) - - paging = &sqls.Paging{ - Page: cnd.Paging.Page, - Limit: cnd.Paging.Limit, - Total: count, - } - return -} - -func (r *skillRunLogRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (list []models.SkillRunLog) { - db.Raw(sqlStr, paramArr...).Scan(&list) - return -} - -func (r *skillRunLogRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (count int64) { - db.Raw(sqlStr, paramArr...).Count(&count) - return -} - -func (r *skillRunLogRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { - return cnd.Count(db, &models.SkillRunLog{}) -} - -func (r *skillRunLogRepository) Create(db *gorm.DB, t *models.SkillRunLog) (err error) { - err = db.Create(t).Error - return -} - -func (r *skillRunLogRepository) Update(db *gorm.DB, t *models.SkillRunLog) (err error) { - err = db.Save(t).Error - return -} - -func (r *skillRunLogRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) (err error) { - err = db.Model(&models.SkillRunLog{}).Where("id = ?", id).Updates(columns).Error - return -} - -func (r *skillRunLogRepository) UpdateColumn(db *gorm.DB, id int64, name string, value interface{}) (err error) { - err = db.Model(&models.SkillRunLog{}).Where("id = ?", id).UpdateColumn(name, value).Error - return -} - -func (r *skillRunLogRepository) Delete(db *gorm.DB, id int64) { - db.Delete(&models.SkillRunLog{}, "id = ?", id) -} diff --git a/internal/services/agent_evaluation_service.go b/internal/services/agent_evaluation_service.go index 2f4fcb0..d9adcf0 100644 --- a/internal/services/agent_evaluation_service.go +++ b/internal/services/agent_evaluation_service.go @@ -3,7 +3,6 @@ package services import ( "context" "fmt" - "strings" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" @@ -21,9 +20,6 @@ func (s *agentEvaluationService) Run(ctx context.Context, req request.RunAgentEv if req.AIAgentID <= 0 { return nil, errorsx.InvalidParam("ai agent id is required") } - if strings.TrimSpace(req.EngineCode) == "" { - return nil, errorsx.InvalidParam("engine code is required") - } if len(req.Cases) == 0 { return nil, errorsx.InvalidParam("evaluation cases are required") } diff --git a/internal/services/agent_evaluation_service_test.go b/internal/services/agent_evaluation_service_test.go index efb5640..548e30a 100644 --- a/internal/services/agent_evaluation_service_test.go +++ b/internal/services/agent_evaluation_service_test.go @@ -14,9 +14,9 @@ func TestAgentEvaluationServiceValidatesAndCallsRunner(t *testing.T) { called := false AgentEvaluationRunHook = func(_ context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) { called = true - return &response.AgentEvaluationReportResponse{EngineCode: req.EngineCode, Total: len(req.Cases)}, nil + return &response.AgentEvaluationReportResponse{Total: len(req.Cases)}, nil } - result, err := AgentEvaluationService.Run(context.Background(), request.RunAgentEvaluationRequest{AIAgentID: 1, EngineCode: "autonomous", Cases: []request.AgentEvaluationCase{{ID: "faq", Message: "hello"}}}) + result, err := AgentEvaluationService.Run(context.Background(), request.RunAgentEvaluationRequest{AIAgentID: 1, Cases: []request.AgentEvaluationCase{{ID: "faq", Message: "hello"}}}) if err != nil || !called || result.Total != 1 { t.Fatalf("result=%#v called=%t err=%v", result, called, err) } diff --git a/internal/services/agent_revision_service.go b/internal/services/agent_revision_service.go index dea2af3..bee8e9e 100644 --- a/internal/services/agent_revision_service.go +++ b/internal/services/agent_revision_service.go @@ -40,10 +40,10 @@ func (s *agentRevisionService) FindByAgentID(agentID int64) []models.AgentRevisi type agentRevisionDefinition struct { Agent agentRevisionAgent `json:"agent"` Model agentRevisionModel `json:"model"` - WorkflowBindings []agentRevisionWorkflowBinding `json:"workflowBindings"` + WorkflowBindings []AgentRevisionWorkflowBinding `json:"workflowBindings"` } -type agentRevisionWorkflowBinding struct { +type AgentRevisionWorkflowBinding struct { WorkflowID int64 `json:"workflowId"` WorkflowVersionID int64 `json:"workflowVersionId"` ToolName string `json:"toolName"` @@ -69,7 +69,6 @@ type agentRevisionAgent struct { Name string `json:"name"` Description string `json:"description"` AIConfigID int64 `json:"aiConfigId"` - RuntimeMode string `json:"runtimeMode"` MaxSteps int `json:"maxSteps"` ContextWindow int `json:"contextWindow"` ToolPolicy string `json:"toolPolicy"` @@ -94,33 +93,31 @@ type AgentRevisionSnapshot struct { Revision models.AgentRevision Agent models.AIAgent AIConfig models.AIConfig - WorkflowBindings []agentRevisionWorkflowBinding + WorkflowBindings []AgentRevisionWorkflowBinding } -// ResolvePublishedSnapshot restores a published Agent revision for runtime -// execution. Empty legacy definitions retain the current fields so historical -// records created before snapshot hydration remain executable. +// ResolvePublishedSnapshot restores an immutable published Agent revision. func (s *agentRevisionService) ResolvePublishedSnapshot(agent models.AIAgent, config models.AIConfig) (*AgentRevisionSnapshot, error) { if agent.PublishedRevisionID <= 0 { - return nil, errorsx.InvalidParam("autonomous agent is not published") + return nil, errorsx.InvalidParam("Agent is not published") } revision := repositories.AgentRevisionRepository.Get(sqls.DB(), agent.PublishedRevisionID) if revision == nil || revision.AgentID != agent.ID || revision.Status != enums.StatusOk { - return nil, errorsx.InvalidParam("autonomous agent published revision does not exist") + return nil, errorsx.InvalidParam("published Agent revision does not exist") } snapshot := &AgentRevisionSnapshot{Revision: *revision, Agent: agent, AIConfig: config} if strings.TrimSpace(revision.Definition) == "" { - return snapshot, nil + return nil, errorsx.InvalidParam("published Agent revision definition is empty") } definition := agentRevisionDefinition{} if err := json.Unmarshal([]byte(revision.Definition), &definition); err != nil { - return nil, errorsx.InvalidParam("autonomous agent published revision is invalid") + return nil, errorsx.InvalidParam("published Agent revision is invalid") } if definition.Agent.AIConfigID > 0 && definition.Agent.AIConfigID != config.ID { return nil, errorsx.InvalidParam("published agent model config no longer matches") } applyRevisionAgentSnapshot(&snapshot.Agent, definition.Agent) - snapshot.WorkflowBindings = append([]agentRevisionWorkflowBinding(nil), definition.WorkflowBindings...) + snapshot.WorkflowBindings = append([]AgentRevisionWorkflowBinding(nil), definition.WorkflowBindings...) applyRevisionModelSnapshot(&snapshot.AIConfig, definition.Model) return snapshot, nil } @@ -132,7 +129,6 @@ func applyRevisionAgentSnapshot(agent *models.AIAgent, definition agentRevisionA agent.Name = definition.Name agent.Description = definition.Description agent.AIConfigID = definition.AIConfigID - agent.RuntimeMode = enums.AIAgentRuntimeMode(definition.RuntimeMode) agent.MaxSteps = definition.MaxSteps agent.ContextWindow = definition.ContextWindow agent.ToolPolicy = definition.ToolPolicy @@ -180,7 +176,7 @@ func (s *agentRevisionService) publishSnapshot(db *gorm.DB, agent *models.AIAgen definition := agentRevisionDefinition{ Agent: agentRevisionAgent{ Name: agent.Name, Description: agent.Description, AIConfigID: agent.AIConfigID, - RuntimeMode: string(agent.RuntimeMode), MaxSteps: agent.MaxSteps, ContextWindow: agent.ContextWindow, + MaxSteps: agent.MaxSteps, ContextWindow: agent.ContextWindow, ToolPolicy: agent.ToolPolicy, KnowledgePolicy: agent.KnowledgePolicy, ServiceMode: int(agent.ServiceMode), SystemPrompt: agent.SystemPrompt, WelcomeMessage: agent.WelcomeMessage, ReplyTimeoutSeconds: agent.ReplyTimeoutSeconds, TeamIDs: agent.TeamIDs, HandoffMode: int(agent.HandoffMode), FallbackMode: int(agent.FallbackMode), FallbackMessage: agent.FallbackMessage, KnowledgeIDs: agent.KnowledgeIDs, @@ -189,7 +185,7 @@ func (s *agentRevisionService) publishSnapshot(db *gorm.DB, agent *models.AIAgen Model: model, } for _, binding := range repositories.AIAgentWorkflowBindingRepository.FindEnabledByAgentID(db, agent.ID) { - definition.WorkflowBindings = append(definition.WorkflowBindings, agentRevisionWorkflowBinding{WorkflowID: binding.WorkflowID, WorkflowVersionID: binding.WorkflowVersionID, ToolName: binding.ToolName, TriggerInstruction: binding.TriggerInstruction, Priority: binding.Priority}) + definition.WorkflowBindings = append(definition.WorkflowBindings, AgentRevisionWorkflowBinding{WorkflowID: binding.WorkflowID, WorkflowVersionID: binding.WorkflowVersionID, ToolName: binding.ToolName, TriggerInstruction: binding.TriggerInstruction, Priority: binding.Priority}) } data, err := json.Marshal(definition) if err != nil { diff --git a/internal/services/agent_revision_service_test.go b/internal/services/agent_revision_service_test.go index cc11882..335fe04 100644 --- a/internal/services/agent_revision_service_test.go +++ b/internal/services/agent_revision_service_test.go @@ -24,7 +24,7 @@ func TestAgentRevisionServiceRestoresPublishedSnapshotAndKeepsAPIKey(t *testing. sqls.SetDB(db) definition := agentRevisionDefinition{ Agent: agentRevisionAgent{ - Name: "published agent", AIConfigID: 8, RuntimeMode: string(enums.AIAgentRuntimeModeAutonomous), + Name: "published agent", AIConfigID: 8, MaxSteps: 5, ContextWindow: 9, SystemPrompt: "published instruction", KnowledgeIDs: "4", ReplyTimeoutSeconds: 90, }, Model: agentRevisionModel{ConfigID: 8, Provider: string(enums.AIProviderOpenAI), BaseURL: "https://published.example/v1", ModelType: string(enums.AIModelTypeLLM), ModelName: "published-model", TimeoutMS: 12000}, diff --git a/internal/services/agent_run_service.go b/internal/services/agent_run_service.go index a112460..e7c6c1f 100644 --- a/internal/services/agent_run_service.go +++ b/internal/services/agent_run_service.go @@ -53,11 +53,6 @@ type AgentRunMetrics struct { UnsupportedEvidenceRate float64 `json:"unsupportedEvidenceRate"` } -type AgentRunEngineComparison struct { - EngineCode string `json:"engineCode"` - Metrics AgentRunMetrics `json:"metrics"` -} - const maxAgentAuditPreviewChars = 4000 var agentAuditSecretPattern = regexp.MustCompile(`(?i)(?:"|')?(api[_-]?key|authorization|password|secret|token|cookie)(?:"|')?\s*([:=])\s*(?:"[^"]*"|'[^']*'|[^\s,;}]+)`) @@ -146,32 +141,6 @@ func (s *agentRunService) GetMetrics(aiAgentID int64) AgentRunMetrics { return metrics } -// GetEngineComparisons keeps Workflow, Autonomous, and Hybrid reports based on -// the same normalized audit and reviewed-quality records. Conversation-level -// handoff is deliberately excluded because it cannot be attributed to one -// Engine after a mode change. -func (s *agentRunService) GetEngineComparisons(aiAgentID int64) []AgentRunEngineComparison { - runs := repositories.AgentRunRepository.FindRecent(sqls.DB(), aiAgentID, 5000) - groups := make(map[string][]models.AgentRun) - for _, run := range runs { - engineCode := strings.TrimSpace(run.EngineCode) - if engineCode == "" { - engineCode = "unknown" - } - groups[engineCode] = append(groups[engineCode], run) - } - engineCodes := make([]string, 0, len(groups)) - for engineCode := range groups { - engineCodes = append(engineCodes, engineCode) - } - sort.Strings(engineCodes) - ret := make([]AgentRunEngineComparison, 0, len(engineCodes)) - for _, engineCode := range engineCodes { - ret = append(ret, AgentRunEngineComparison{EngineCode: engineCode, Metrics: s.aggregateMetrics(sqls.DB(), groups[engineCode])}) - } - return ret -} - func (s *agentRunService) aggregateMetrics(db *gorm.DB, runs []models.AgentRun) AgentRunMetrics { metrics := AgentRunMetrics{TotalRuns: len(runs)} if len(runs) == 0 { @@ -258,29 +227,12 @@ func (s *agentRunService) aggregateMetrics(db *gorm.DB, runs []models.AgentRun) return metrics } -type WorkflowAgentRunInput struct { - WorkflowRunID int64 - WorkflowVersionID int64 - ConversationID int64 - AIAgentID int64 - SourceMessageID int64 - Status string - PromptTokens int - CompletionTokens int - StartedAt time.Time - EndedAt *time.Time - ErrorMessage string - TraceData string - StepInputPreview string - StepOutputPreview string -} - -type EngineAgentRunInput struct { +type AgentLoopRunInput struct { ConversationID int64 AIAgentID int64 AgentRevisionID int64 SourceMessageID int64 - EngineCode string + WorkflowRunID int64 Status string PromptTokens int CompletionTokens int @@ -292,11 +244,11 @@ type EngineAgentRunInput struct { StepCode string StepInputPreview string StepOutputPreview string - AdditionalSteps []EngineStepInput - ToolCalls []EngineToolCallInput + AdditionalSteps []AgentLoopStepInput + ToolCalls []AgentLoopToolCallInput } -type EngineStepInput struct { +type AgentLoopStepInput struct { StepType string StepCode string WorkflowRunID int64 @@ -306,7 +258,7 @@ type EngineStepInput struct { ErrorMessage string } -type EngineToolCallInput struct { +type AgentLoopToolCallInput struct { ToolCode string RiskLevel string RequireConfirm bool @@ -317,47 +269,47 @@ type EngineToolCallInput struct { DurationMS int } -// RecordHybridPlaybookResume closes or re-interrupts the Hybrid AgentRun that -// originally selected a Playbook. The detailed WorkflowRun remains separately -// auditable; this step preserves the parent AgentRun -> AgentStep -> WorkflowRun -// relationship across a human confirmation pause. -func (s *agentRunService) RecordHybridPlaybookResume(db *gorm.DB, agentRunID, workflowRunID int64, status, replyText string) error { +// RecordResume closes or re-interrupts the original Agent Loop parent run, +// appends a normalized resume step, and records an optional resumed tool call. +func (s *agentRunService) RecordResume(db *gorm.DB, agentRunID, workflowRunID int64, status, replyText string, toolCall *AgentLoopToolCallInput) error { if agentRunID <= 0 { return nil } run := repositories.AgentRunRepository.Get(db, agentRunID) - if run == nil || run.EngineCode != "hybrid" { + if run == nil { return nil } - status = strings.TrimSpace(status) - if status == "" { - status = "completed" - } + status = firstNonEmptyString(status, "completed") now := time.Now() - durationMS := int(now.Sub(run.StartedAt).Milliseconds()) - if durationMS < 0 { - durationMS = 0 - } if err := repositories.AgentRunRepository.Updates(db, run.ID, map[string]any{ - "status": status, - "ended_at": &now, - "error_message": "", - "updated_at": now, + "status": status, "ended_at": &now, "error_message": "", "updated_at": now, }); err != nil { return err } - return repositories.AgentStepRepository.Create(db, &models.AgentStep{ + step := &models.AgentStep{ AgentRunID: run.ID, WorkflowRunID: workflowRunID, - StepType: "playbook", StepCode: "playbook_resume", Status: status, - InputPreview: "human confirmation resume", - OutputPreview: sanitizeAgentAuditPreview(replyText), - StartedAt: now, EndedAt: &now, DurationMS: durationMS, CreatedAt: now, + StepType: "resume", StepCode: "confirmation_resume", Status: status, + InputPreview: "customer confirmation", OutputPreview: sanitizeAgentAuditPreview(replyText), + StartedAt: now, EndedAt: &now, CreatedAt: now, + } + if err := repositories.AgentStepRepository.Create(db, step); err != nil { + return err + } + if toolCall == nil { + return nil + } + return repositories.AgentToolCallRepository.Create(db, &models.AgentToolCall{ + AgentRunID: run.ID, AgentStepID: step.ID, ToolCode: strings.TrimSpace(toolCall.ToolCode), + RiskLevel: strings.TrimSpace(toolCall.RiskLevel), RequireConfirm: toolCall.RequireConfirm, + Status: firstNonEmptyString(toolCall.Status, status), ArgumentsPreview: sanitizeAgentAuditPreview(toolCall.ArgumentsPreview), + ResultPreview: sanitizeAgentAuditPreview(toolCall.ResultPreview), ErrorMessage: sanitizeAgentAuditPreview(toolCall.ErrorMessage), + DurationMS: toolCall.DurationMS, CreatedAt: now, }) } -// RecordEngineRun writes a non-workflow Engine audit run and its normalized +// RecordAgentLoopRun writes the Agent Loop parent audit run and its normalized // root step in one transaction owned by the caller. -func (s *agentRunService) RecordEngineRun(db *gorm.DB, input EngineAgentRunInput) (int64, error) { +func (s *agentRunService) RecordAgentLoopRun(db *gorm.DB, input AgentLoopRunInput) (int64, error) { now := time.Now() startedAt := input.StartedAt if startedAt.IsZero() { @@ -369,7 +321,7 @@ func (s *agentRunService) RecordEngineRun(db *gorm.DB, input EngineAgentRunInput } run := &models.AgentRun{ ConversationID: input.ConversationID, AIAgentID: input.AIAgentID, AgentRevisionID: input.AgentRevisionID, - SourceMessageID: input.SourceMessageID, EngineCode: strings.TrimSpace(input.EngineCode), Status: status, + SourceMessageID: input.SourceMessageID, WorkflowRunID: input.WorkflowRunID, Status: status, PromptTokens: input.PromptTokens, CompletionTokens: input.CompletionTokens, StartedAt: startedAt, EndedAt: input.EndedAt, ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage), TraceData: sanitizeAgentAuditPreview(input.TraceData), CreatedAt: now, UpdatedAt: now, } @@ -435,80 +387,3 @@ func firstNonEmptyString(items ...string) string { } return "" } - -// RecordWorkflowRun writes the Engine-independent audit record inside the -// caller's transaction. Workflow-specific tables remain the detailed source -// for node-level diagnosis while AgentRun becomes the cross-engine summary. -func (s *agentRunService) RecordWorkflowRun(db *gorm.DB, input WorkflowAgentRunInput) (int64, error) { - now := time.Now() - status := strings.TrimSpace(input.Status) - if status == "" { - status = "completed" - } - startedAt := input.StartedAt - if startedAt.IsZero() { - startedAt = now - } - run := repositories.AgentRunRepository.TakeByWorkflowRunID(db, input.WorkflowRunID) - agentRevisionID := int64(0) - if revision := repositories.AgentRevisionRepository.TakeByAgentIDAndWorkflowVersionID(db, input.AIAgentID, input.WorkflowVersionID); revision != nil { - agentRevisionID = revision.ID - } - if run == nil { - run = &models.AgentRun{ - ConversationID: input.ConversationID, - AIAgentID: input.AIAgentID, - AgentRevisionID: agentRevisionID, - SourceMessageID: input.SourceMessageID, - WorkflowRunID: input.WorkflowRunID, - EngineCode: "workflow", - Status: status, - PromptTokens: input.PromptTokens, - CompletionTokens: input.CompletionTokens, - StartedAt: startedAt, - EndedAt: input.EndedAt, - ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage), - TraceData: sanitizeAgentAuditPreview(input.TraceData), - CreatedAt: now, - UpdatedAt: now, - } - if err := repositories.AgentRunRepository.Create(db, run); err != nil { - return 0, err - } - } else if err := repositories.AgentRunRepository.Updates(db, run.ID, map[string]any{ - "agent_revision_id": agentRevisionID, - "status": status, - "prompt_tokens": input.PromptTokens, - "completion_tokens": input.CompletionTokens, - "ended_at": input.EndedAt, - "error_message": sanitizeAgentAuditPreview(input.ErrorMessage), - "trace_data": sanitizeAgentAuditPreview(input.TraceData), - "updated_at": now, - }); err != nil { - return 0, err - } - durationMS := 0 - if input.EndedAt != nil { - durationMS = int(input.EndedAt.Sub(startedAt).Milliseconds()) - if durationMS < 0 { - durationMS = 0 - } - } - step := &models.AgentStep{ - AgentRunID: run.ID, - StepType: "workflow", - StepCode: "workflow", - Status: status, - InputPreview: sanitizeAgentAuditPreview(input.StepInputPreview), - OutputPreview: sanitizeAgentAuditPreview(input.StepOutputPreview), - ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage), - StartedAt: startedAt, - EndedAt: input.EndedAt, - DurationMS: durationMS, - CreatedAt: now, - } - if err := repositories.AgentStepRepository.Create(db, step); err != nil { - return 0, err - } - return run.ID, nil -} diff --git a/internal/services/agent_run_service_test.go b/internal/services/agent_run_service_test.go index 832fc4a..43b7647 100644 --- a/internal/services/agent_run_service_test.go +++ b/internal/services/agent_run_service_test.go @@ -10,7 +10,6 @@ import ( "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/httpx/params" - "agent-desk/internal/repositories" "github.com/glebarez/sqlite" "github.com/mlogclub/simple/sqls" @@ -26,12 +25,12 @@ func TestAgentRunServiceFindsWorkflowAuditDetail(t *testing.T) { ConversationID: 11, AIAgentID: 12, WorkflowRunID: 13, - EngineCode: "workflow", - Status: "completed", - StartedAt: now, - EndedAt: &endedAt, - CreatedAt: now, - UpdatedAt: now, + + Status: "completed", + StartedAt: now, + EndedAt: &endedAt, + CreatedAt: now, + UpdatedAt: now, } if err := db.Create(run).Error; err != nil { t.Fatalf("create agent run: %v", err) @@ -55,38 +54,17 @@ func TestAgentRunServiceFindsWorkflowAuditDetail(t *testing.T) { } } -func TestAgentRunServiceAssociatesWorkflowRevision(t *testing.T) { +func TestAgentRunServiceRecordsAgentLoopToolCall(t *testing.T) { db := setupAgentRunServiceTestDB(t) now := time.Now() - if err := db.Create(&models.AgentRevision{AgentID: 12, Revision: 1, WorkflowVersionID: 14}).Error; err != nil { - t.Fatalf("create agent revision: %v", err) - } - if _, err := AgentRunService.RecordWorkflowRun(db, WorkflowAgentRunInput{ - WorkflowRunID: 13, WorkflowVersionID: 14, ConversationID: 11, AIAgentID: 12, - Status: "completed", StartedAt: now, - }); err != nil { - t.Fatalf("RecordWorkflowRun returned error: %v", err) - } - run := repositories.AgentRunRepository.TakeByWorkflowRunID(db, 13) - if run == nil || run.AgentRevisionID <= 0 { - t.Fatalf("expected AgentRun to link revision, got %#v", run) - } - if stepID := AgentRunService.GetLatestStepID(run.ID); stepID <= 0 { - t.Fatalf("expected normalized agent step id, got %d", stepID) - } -} - -func TestAgentRunServiceRecordsEngineToolCall(t *testing.T) { - db := setupAgentRunServiceTestDB(t) - now := time.Now() - runID, err := AgentRunService.RecordEngineRun(db, EngineAgentRunInput{ - ConversationID: 1, AIAgentID: 2, AgentRevisionID: 3, EngineCode: "autonomous", Status: "completed", StartedAt: now, - StepType: "model", StepCode: "chat_completion", StepInputPreview: "authorization=Bearer-secret", ToolCalls: []EngineToolCallInput{{ + runID, err := AgentRunService.RecordAgentLoopRun(db, AgentLoopRunInput{ + ConversationID: 1, AIAgentID: 2, AgentRevisionID: 3, Status: "completed", StartedAt: now, + StepType: "model", StepCode: "chat_completion", StepInputPreview: "authorization=Bearer-secret", ToolCalls: []AgentLoopToolCallInput{{ ToolCode: "knowledge/search", RiskLevel: "read", Status: "completed", ArgumentsPreview: `{"token":"abc123","query":"refund"}`, ResultPreview: "policy text", }}, }) if err != nil { - t.Fatalf("RecordEngineRun returned error: %v", err) + t.Fatalf("RecordAgentLoopRun returned error: %v", err) } _, steps, toolCalls := AgentRunService.GetDetail(runID) if len(toolCalls) != 1 || toolCalls[0].ToolCode != "knowledge/search" || toolCalls[0].AgentStepID <= 0 { @@ -97,29 +75,33 @@ func TestAgentRunServiceRecordsEngineToolCall(t *testing.T) { } } -func TestAgentRunServiceRecordsHybridPlaybookResume(t *testing.T) { +func TestAgentRunServiceRecordsResumedToolCall(t *testing.T) { db := setupAgentRunServiceTestDB(t) - now := time.Now().Add(-time.Minute) - run := &models.AgentRun{EngineCode: "hybrid", Status: "interrupted", StartedAt: now, CreatedAt: now, UpdatedAt: now} + now := time.Now() + run := &models.AgentRun{Status: "interrupted", StartedAt: now, CreatedAt: now, UpdatedAt: now} if err := db.Create(run).Error; err != nil { - t.Fatalf("create hybrid run: %v", err) + t.Fatalf("create interrupted run: %v", err) } - if err := AgentRunService.RecordHybridPlaybookResume(db, run.ID, 33, "completed", "已完成工单登记。"); err != nil { - t.Fatalf("RecordHybridPlaybookResume returned error: %v", err) + err := AgentRunService.RecordResume(db, run.ID, 0, "completed", "操作已执行", &AgentLoopToolCallInput{ + ToolCode: "crm/update_customer", RiskLevel: "write", RequireConfirm: true, + Status: "completed", ArgumentsPreview: `{"name":"Ada"}`, ResultPreview: "updated", + }) + if err != nil { + t.Fatalf("RecordResume returned error: %v", err) } - item, steps, _ := AgentRunService.GetDetail(run.ID) - if item == nil || item.Status != "completed" || item.EndedAt == nil { - t.Fatalf("expected completed hybrid run, got %#v", item) + item, steps, toolCalls := AgentRunService.GetDetail(run.ID) + if item == nil || item.Status != "completed" || len(steps) != 1 || steps[0].StepType != "resume" { + t.Fatalf("unexpected resumed run audit: item=%#v steps=%#v", item, steps) } - if len(steps) != 1 || steps[0].StepCode != "playbook_resume" || steps[0].WorkflowRunID != 33 || steps[0].OutputPreview != "已完成工单登记。" { - t.Fatalf("unexpected playbook resume step: %#v", steps) + if len(toolCalls) != 1 || toolCalls[0].AgentStepID != steps[0].ID || !toolCalls[0].RequireConfirm || toolCalls[0].Status != "completed" { + t.Fatalf("unexpected resumed tool audit: %#v", toolCalls) } } func TestAgentRunServiceSavesQualityFeedbackPerRun(t *testing.T) { db := setupAgentRunServiceTestDB(t) now := time.Now() - run := &models.AgentRun{AIAgentID: 4, EngineCode: "autonomous", Status: "completed", StartedAt: now, CreatedAt: now, UpdatedAt: now} + run := &models.AgentRun{AIAgentID: 4, Status: "completed", StartedAt: now, CreatedAt: now, UpdatedAt: now} if err := db.Create(run).Error; err != nil { t.Fatalf("create agent run: %v", err) } @@ -140,13 +122,13 @@ func TestAgentRunServiceSavesQualityFeedbackPerRun(t *testing.T) { } } -func TestAgentRunServiceAggregatesCrossEngineMetrics(t *testing.T) { +func TestAgentRunServiceAggregatesMetrics(t *testing.T) { db := setupAgentRunServiceTestDB(t) base := time.Now().Add(-time.Minute) runs := []models.AgentRun{ - {AIAgentID: 8, EngineCode: "autonomous", Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(100 * time.Millisecond)), PromptTokens: 10, CompletionTokens: 5, CreatedAt: base, UpdatedAt: base}, - {AIAgentID: 8, EngineCode: "workflow", Status: "failed", StartedAt: base, EndedAt: timePtr(base.Add(300 * time.Millisecond)), PromptTokens: 8, CompletionTokens: 2, CreatedAt: base, UpdatedAt: base}, - {AIAgentID: 9, EngineCode: "hybrid", Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(900 * time.Millisecond)), CreatedAt: base, UpdatedAt: base}, + {AIAgentID: 8, Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(100 * time.Millisecond)), PromptTokens: 10, CompletionTokens: 5, CreatedAt: base, UpdatedAt: base}, + {AIAgentID: 8, Status: "failed", StartedAt: base, EndedAt: timePtr(base.Add(300 * time.Millisecond)), PromptTokens: 8, CompletionTokens: 2, CreatedAt: base, UpdatedAt: base}, + {AIAgentID: 9, Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(900 * time.Millisecond)), CreatedAt: base, UpdatedAt: base}, } for index := range runs { if err := db.Create(&runs[index]).Error; err != nil { @@ -203,13 +185,6 @@ func TestAgentRunServiceAggregatesCrossEngineMetrics(t *testing.T) { if metrics.ReviewedRuns != 2 || metrics.ResolvedRuns != 1 || metrics.ResolutionRate != 0.5 || metrics.UnsupportedEvidenceRuns != 1 || metrics.UnsupportedEvidenceRate != 0.5 { t.Fatalf("unexpected quality metrics: %#v", metrics) } - comparisons := AgentRunService.GetEngineComparisons(8) - if len(comparisons) != 2 || comparisons[0].EngineCode != "autonomous" || comparisons[1].EngineCode != "workflow" { - t.Fatalf("unexpected engine comparison groups: %#v", comparisons) - } - if comparisons[0].Metrics.TotalRuns != 1 || comparisons[0].Metrics.ResolutionRate != 1 || comparisons[1].Metrics.TotalRuns != 1 || comparisons[1].Metrics.UnsupportedEvidenceRate != 1 { - t.Fatalf("unexpected engine comparison metrics: %#v", comparisons) - } } func timePtr(value time.Time) *time.Time { return &value } diff --git a/internal/services/ai_agent_service.go b/internal/services/ai_agent_service.go index 771ffac..bfe3fc8 100644 --- a/internal/services/ai_agent_service.go +++ b/internal/services/ai_agent_service.go @@ -24,7 +24,7 @@ import ( var AIAgentService = newAIAgentService() -const defaultNewAutonomousRolloutPercent = 5 +const defaultNewAgentRolloutPercent = 5 func newAIAgentService() *aIAgentService { return &aIAgentService{} @@ -83,11 +83,8 @@ func (s *aIAgentService) CreateAIAgent(req request.CreateAIAgentRequest, operato if err := repositories.AIAgentRepository.Create(ctx.Tx, item); err != nil { return err } - bindings, err := s.replaceWorkflowBindings(ctx.Tx, item.ID, req.WorkflowBindings, operator) - if err != nil { - return err - } - return s.validateWorkflowBindingMode(ctx.Tx, item, bindings) + _, err := s.replaceWorkflowBindings(ctx.Tx, item.ID, req.WorkflowBindings, operator) + return err }); err != nil { return nil, err } @@ -110,7 +107,6 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato "name": item.Name, "description": item.Description, "ai_config_id": item.AIConfigID, - "runtime_mode": item.RuntimeMode, "max_steps": item.MaxSteps, "context_window": item.ContextWindow, "tool_policy": item.ToolPolicy, @@ -127,6 +123,7 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato "knowledge_ids": item.KnowledgeIDs, "skill_ids": item.SkillIDs, "allowed_mcp_tools": item.AllowedMCPTools, + "published_revision_id": 0, "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": time.Now(), @@ -134,42 +131,15 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato if item.RolloutPercent != current.RolloutPercent { columns["previous_rollout_percent"] = current.RolloutPercent } - if current.RuntimeMode == enums.AIAgentRuntimeModeAutonomous || current.RuntimeMode == enums.AIAgentRuntimeModeHybrid || item.RuntimeMode == enums.AIAgentRuntimeModeAutonomous || item.RuntimeMode == enums.AIAgentRuntimeModeHybrid { - // Draft edits must not silently change the already published autonomous or hybrid - // behavior. The operator must explicitly publish the new revision. - columns["published_revision_id"] = 0 - } return sqls.WithTransaction(func(ctx *sqls.TxContext) error { if err := repositories.AIAgentRepository.Updates(ctx.Tx, req.ID, columns); err != nil { return err } - bindings, err := s.replaceWorkflowBindings(ctx.Tx, req.ID, req.WorkflowBindings, operator) - if err != nil { - return err - } - return s.validateWorkflowBindingMode(ctx.Tx, item, bindings) + _, err := s.replaceWorkflowBindings(ctx.Tx, req.ID, req.WorkflowBindings, operator) + return err }) } -func (s *aIAgentService) validateWorkflowBindingMode(db *gorm.DB, agent *models.AIAgent, bindings []models.AIAgentWorkflowBinding) error { - enabled := make([]models.AIAgentWorkflowBinding, 0, len(bindings)) - for _, binding := range bindings { - if binding.Enabled { - enabled = append(enabled, binding) - } - } - if agent.RuntimeMode == enums.AIAgentRuntimeModeAutonomous { - return nil - } - if len(enabled) == 0 { - return errorsx.InvalidParam("workflow and hybrid agents require at least one enabled workflow") - } - if agent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow && len(enabled) != 1 { - return errorsx.InvalidParam("workflow agent requires exactly one enabled workflow") - } - return repositories.AIAgentRepository.Updates(db, agent.ID, map[string]any{"workflow_version_id": enabled[0].WorkflowVersionID}) -} - func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) error { current := s.Get(id) if current == nil { @@ -186,7 +156,8 @@ func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) er }) } -// PublishAIAgent snapshots a non-workflow Agent before it can receive traffic. +// PublishAIAgent snapshots the complete Agent capability set before it can +// receive traffic. func (s *aIAgentService) PublishAIAgent(id int64, operator *dto.AuthPrincipal) (*models.AgentRevision, error) { if operator == nil { return nil, errorsx.UnauthorizedI18n("error.auth.expired") @@ -197,15 +168,9 @@ func (s *aIAgentService) PublishAIAgent(id int64, operator *dto.AuthPrincipal) ( if agent == nil || agent.Status != enums.StatusOk { return errorsx.InvalidParamI18n("error.e0002") } - if agent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow { - return errorsx.InvalidParam("workflow agents publish through their selected workflow version") - } if err := s.validatePublishableAgent(ctx.Tx, agent); err != nil { return err } - if agent.RuntimeMode == enums.AIAgentRuntimeModeHybrid && len(s.ListEnabledWorkflowBindings(ctx.Tx, agent.ID)) == 0 { - return errorsx.InvalidParam("hybrid agent requires at least one published workflow") - } var err error revision, err = AgentRevisionService.PublishSnapshot(ctx.Tx, agent, operator) if err != nil { @@ -235,20 +200,33 @@ func (s *aIAgentService) validatePublishableAgent(db *gorm.DB, agent *models.AIA if _, err := s.normalizeToolPolicy(agent.ToolPolicy); err != nil { return err } - if strings.TrimSpace(agent.AllowedMCPTools) == "" { - return nil - } var mcpTools []request.AIAgentMCPToolRequest - if err := json.Unmarshal([]byte(agent.AllowedMCPTools), &mcpTools); err != nil { - return errorsx.InvalidParam("ai agent MCP tools are invalid") + if raw := strings.TrimSpace(agent.AllowedMCPTools); raw != "" { + if err := json.Unmarshal([]byte(raw), &mcpTools); err != nil { + return errorsx.InvalidParam("ai agent MCP tools are invalid") + } + } + for _, id := range utils.SplitInt64s(agent.SkillIDs) { + skill := repositories.SkillDefinitionRepository.Get(db, id) + if skill == nil || skill.Status != enums.StatusOk { + return errorsx.InvalidParam("bound Skill is unavailable") + } } for _, item := range mcpTools { definition, err := aitooling.DefaultRegistry.Resolve(item.ToolCode) if err != nil || definition.InputSchema == nil { return errorsx.InvalidParam("ai agent MCP tool definition is unavailable") } - if definition.RequireConfirmation { - return errorsx.InvalidParam("ai agent MCP tool requires confirmation and cannot be executed directly") + if item.RiskLevel != aitooling.RiskLevelRead && item.RiskLevel != aitooling.RiskLevelWrite { + return errorsx.InvalidParam("ai agent MCP tool risk level is invalid") + } + if item.RiskLevel == aitooling.RiskLevelWrite && !item.RequireConfirmation { + return errorsx.InvalidParam("write MCP tools must require confirmation") + } + } + for _, binding := range s.ListEnabledWorkflowBindings(db, agent.ID) { + if binding.Version == nil || binding.Version.Status != enums.StatusOk { + return errorsx.InvalidParam("bound workflow version is unavailable") } } return nil @@ -328,15 +306,6 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe if aiConfig.Status != enums.StatusOk { return nil, errorsx.InvalidParamI18n("error.e0011") } - if req.RuntimeMode == "" { - req.RuntimeMode = enums.AIAgentRuntimeModeAutonomous - } - if !enums.IsValidAIAgentRuntimeMode(req.RuntimeMode) { - return nil, errorsx.InvalidParam("invalid ai agent runtime mode") - } - if req.RuntimeMode != enums.AIAgentRuntimeModeWorkflow && req.RuntimeMode != enums.AIAgentRuntimeModeAutonomous && req.RuntimeMode != enums.AIAgentRuntimeModeHybrid { - return nil, errorsx.InvalidParam("ai agent runtime mode is not available yet") - } if req.MaxSteps == 0 { req.MaxSteps = 6 } @@ -374,11 +343,7 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe return nil, errorsx.InvalidParamI18n("error.e0144") } if req.RolloutPercent == 0 { - if req.RuntimeMode == enums.AIAgentRuntimeModeAutonomous || req.RuntimeMode == enums.AIAgentRuntimeModeHybrid { - req.RolloutPercent = defaultNewAutonomousRolloutPercent - } else { - req.RolloutPercent = 100 - } + req.RolloutPercent = defaultNewAgentRolloutPercent } if req.RolloutPercent < 1 || req.RolloutPercent > 100 { return nil, errorsx.InvalidParam("ai agent rollout percent must be between 1 and 100") @@ -408,7 +373,6 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe Name: name, Description: strings.TrimSpace(req.Description), AIConfigID: req.AIConfigID, - RuntimeMode: req.RuntimeMode, MaxSteps: req.MaxSteps, ContextWindow: req.ContextWindow, ToolPolicy: toolPolicy, @@ -532,9 +496,9 @@ func (s *aIAgentService) normalizeSkillIDs(input []int64) ([]int64, error) { if skill == nil || skill.Status == enums.StatusDeleted { continue } - // if skill.Status != enums.StatusOk { - // return nil, errorsx.InvalidParamI18n("error.e0056") - // } + if skill.Status != enums.StatusOk { + return nil, errorsx.InvalidParamI18n("error.e0056") + } seen[id] = struct{}{} ret = append(ret, id) } @@ -558,6 +522,14 @@ func (s *aIAgentService) normalizeMCPTools(input []request.AIAgentMCPToolRequest if err := ToolCatalogService.ValidateToolCode(normalized.ToolCode); err != nil { return nil, err } + normalized.RiskLevel = strings.ToLower(strings.TrimSpace(item.RiskLevel)) + if normalized.RiskLevel != aitooling.RiskLevelRead && normalized.RiskLevel != aitooling.RiskLevelWrite { + return nil, errorsx.InvalidParam("MCP tool risk level must be read or write") + } + normalized.RequireConfirmation = item.RequireConfirmation + if normalized.RiskLevel == aitooling.RiskLevelWrite && !normalized.RequireConfirmation { + return nil, errorsx.InvalidParam("write MCP tools must require confirmation") + } key := strings.TrimSpace(normalized.ToolCode) if _, exists := seen[key]; exists { continue diff --git a/internal/services/ai_agent_workflow_service_test.go b/internal/services/ai_agent_workflow_service_test.go deleted file mode 100644 index a20c783..0000000 --- a/internal/services/ai_agent_workflow_service_test.go +++ /dev/null @@ -1,757 +0,0 @@ -//go:build legacy - -package services - -import ( - "encoding/json" - "strings" - "testing" - - "agent-desk/internal/ai/workflow/dsl" - workflowregistry "agent-desk/internal/ai/workflow/registry" - workflowvalidator "agent-desk/internal/ai/workflow/validator" - "agent-desk/internal/models" - "agent-desk/internal/pkg/dto" - "agent-desk/internal/pkg/dto/request" - "agent-desk/internal/pkg/enums" - "agent-desk/internal/pkg/toolx" - - "github.com/glebarez/sqlite" - "github.com/mlogclub/simple/sqls" - "gorm.io/gorm" -) - -func TestAIAgentServiceCreatesWorkflowOnlyWhenRequested(t *testing.T) { - setupAIAgentWorkflowTestDB(t) - operator := aiAgentWorkflowTestOperator() - aiConfigID := createAIAgentWorkflowTestConfig(t) - - item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ - Name: "workflow agent", - AIConfigID: aiConfigID, - RuntimeMode: enums.AIAgentRuntimeModeWorkflow, - ServiceMode: enums.IMConversationServiceModeAIOnly, - HandoffMode: enums.AIAgentHandoffModeWaitPool, - FallbackMode: enums.AIAgentFallbackModeNoAnswer, - }, operator) - if err != nil { - t.Fatalf("CreateAIAgent() error = %v", err) - } - if item.RuntimeMode != enums.AIAgentRuntimeModeWorkflow { - t.Fatalf("default runtime mode = %q, want %q", item.RuntimeMode, enums.AIAgentRuntimeModeWorkflow) - } - if item.MaxSteps != 6 { - t.Fatalf("default max steps = %d, want 6", item.MaxSteps) - } - if item.RolloutPercent != 100 { - t.Fatalf("workflow rollout default = %d, want 100", item.RolloutPercent) - } - - workflow, err := AIWorkflowService.GetOrCreateAgentWorkflow(item.ID, operator) - if err != nil { - t.Fatalf("GetOrCreateAgentWorkflow() error = %v", err) - } - if workflow.AgentID != item.ID { - t.Fatalf("expected workflow agent id %d, got %d", item.ID, workflow.AgentID) - } - if workflow.Name != item.Name+" 会话流程" { - t.Fatalf("unexpected workflow name: %s", workflow.Name) - } - var stored dsl.Definition - if err := json.Unmarshal([]byte(workflow.DraftDefinition), &stored); err != nil { - t.Fatalf("unmarshal draft definition: %v", err) - } - if stored.SchemaVersion != dsl.SchemaVersion || nodeTypeByID(stored, "start_1") != workflowregistry.NodeTypeStart { - t.Fatalf("expected default draft definition") - } - validation := workflowvalidator.ValidateDefinition(stored, workflowregistry.DefaultRegistry()) - if validation.Valid || !workflowValidationHasMessage(validation, "需要选择至少一个知识库") { - t.Fatalf("expected default workflow to require node knowledge bases, got %#v", validation.Errors) - } - if nodeTypeByID(stored, "understanding_1") != workflowregistry.NodeTypeConversationUnderstanding { - t.Fatalf("expected default workflow to include conversation understanding, got nodes: %#v", stored.Nodes) - } - if nodeTypeByID(stored, "policy_1") != workflowregistry.NodeTypeReplyPolicy { - t.Fatalf("expected default workflow to include reply policy, got nodes: %#v", stored.Nodes) - } - if !workflowEdgeExists(stored, "start_1", "understanding_1") || !workflowEdgeExists(stored, "understanding_1", "policy_1") { - t.Fatalf("expected default workflow to start with policy-first understanding flow, got edges: %#v", stored.Edges) - } - for _, nodeType := range []string{ - workflowregistry.NodeTypeConversationUnderstanding, - workflowregistry.NodeTypeReplyPolicy, - workflowregistry.NodeTypeHandoffToHuman, - workflowregistry.NodeTypePrepareTicketDraft, - workflowregistry.NodeTypeHumanConfirm, - workflowregistry.NodeTypeCreateTicket, - workflowregistry.NodeTypeKnowledgeRetrieve, - workflowregistry.NodeTypeAnswerabilityGate, - workflowregistry.NodeTypeLLMReply, - workflowregistry.NodeTypeSendReply, - } { - if !workflowHasNodeType(stored, nodeType) { - t.Fatalf("expected default workflow to include %s node: %#v", nodeType, stored.Nodes) - } - } - assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypeSendReply, "eq", "direct_reply") - assertConditionBranchToNodeID(t, stored, "policy_route_1", "handoff_confirm_prompt_1", "eq", "handoff_to_human") - assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypePrepareTicketDraft, "eq", "prepare_ticket") - assertConditionBranchToNodeID(t, stored, "ticket_draft_route_1", "ticket_confirm_prompt_1", "is_true", nil) - assertDefaultBranchToNodeID(t, stored, "ticket_draft_route_1", "ticket_followup_reply_1") - assertConditionBranchToNodeID(t, stored, "handoff_confirm_route_1", "handoff_1", "is_true", nil) - assertDefaultBranchToNodeID(t, stored, "handoff_confirm_route_1", "handoff_cancel_reply_1") - assertConditionBranchToNodeID(t, stored, "answerability_route_1", "reply_1", "eq", "answerable") - assertDefaultBranchToNodeID(t, stored, "answerability_route_1", "fallback_reply_1") - if !workflowEdgeExists(stored, "create_ticket_1", "ticket_result_reply_1") { - t.Fatalf("expected create_ticket to flow into a customer-visible result reply") - } - assertConditionBranchesHavePortEdges(t, stored, "policy_route_1") - assertConditionBranchesHavePortEdges(t, stored, "ticket_draft_route_1") - assertConditionBranchesHavePortEdges(t, stored, "ticket_confirm_route_1") - assertConditionBranchesHavePortEdges(t, stored, "handoff_confirm_route_1") - assertConditionBranchesHavePortEdges(t, stored, "answerability_route_1") - assertConditionBranchOrder(t, stored, "policy_route_1", []string{ - "handoff", - "direct", - "clarify", - "end_conversation", - "ticket", - "knowledge", - "default", - }) - assertConditionPortEdgeOrder(t, stored, "policy_route_1", []string{ - "handoff", - "direct", - "clarify", - "end_conversation", - "ticket", - "knowledge", - "default", - }) -} - -func TestAIAgentServiceDefaultsNewAutonomousAgentToSmallRollout(t *testing.T) { - setupAIAgentWorkflowTestDB(t) - item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ - Name: "small-rollout autonomous agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeAutonomous, - ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer, - }, aiAgentWorkflowTestOperator()) - if err != nil { - t.Fatalf("CreateAIAgent: %v", err) - } - if item.RolloutPercent != defaultNewAutonomousRolloutPercent { - t.Fatalf("autonomous rollout default = %d, want %d", item.RolloutPercent, defaultNewAutonomousRolloutPercent) - } -} - -func TestAIAgentServiceDefaultsToAutonomousWithoutWorkflow(t *testing.T) { - setupAIAgentWorkflowTestDB(t) - operator := aiAgentWorkflowTestOperator() - item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ - Name: "default autonomous agent", AIConfigID: createAIAgentWorkflowTestConfig(t), - ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer, - }, operator) - if err != nil { - t.Fatalf("CreateAIAgent() error = %v", err) - } - if item.RuntimeMode != enums.AIAgentRuntimeModeAutonomous { - t.Fatalf("default runtime mode = %q, want %q", item.RuntimeMode, enums.AIAgentRuntimeModeAutonomous) - } - var workflowCount int64 - if err := sqls.DB().Model(&models.AIWorkflow{}).Where("agent_id = ?", item.ID).Count(&workflowCount).Error; err != nil { - t.Fatalf("count workflows: %v", err) - } - if workflowCount != 0 { - t.Fatalf("default autonomous agent created %d workflows", workflowCount) - } -} - -func TestAIAgentServiceCreatesWorkflowDraftForHybrid(t *testing.T) { - setupAIAgentWorkflowTestDB(t) - item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ - Name: "hybrid agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeHybrid, - ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer, - }, aiAgentWorkflowTestOperator()) - if err != nil { - t.Fatalf("CreateAIAgent() error = %v", err) - } - var workflowCount int64 - if err := sqls.DB().Model(&models.AIWorkflow{}).Where("agent_id = ?", item.ID).Count(&workflowCount).Error; err != nil { - t.Fatalf("count workflows: %v", err) - } - if workflowCount != 1 { - t.Fatalf("hybrid agent created %d workflows, want 1", workflowCount) - } -} - -func TestAIAgentServiceNormalizesToolPolicy(t *testing.T) { - policy, err := AIAgentService.normalizeToolPolicy(`{"maxTotalCalls":2,"maxArgumentBytes":1024,"allowedRiskLevels":["READ","read","write"]}`) - if err != nil { - t.Fatalf("normalizeToolPolicy: %v", err) - } - if !strings.Contains(policy, `"maxTotalCalls":2`) || !strings.Contains(policy, `"allowedRiskLevels":["read","write"]`) { - t.Fatalf("unexpected normalized policy: %s", policy) - } - if _, err := AIAgentService.normalizeToolPolicy(`{"allowedRiskLevels":["sensitive"]}`); err == nil { - t.Fatal("expected removed sensitive risk level to be rejected") - } - if _, err := AIAgentService.normalizeToolPolicy(`{"allowedRiskLevels":["admin"]}`); err == nil { - t.Fatal("expected invalid risk level error") - } - if _, err := AIAgentService.normalizeToolPolicy(`not-json`); err == nil { - t.Fatal("expected invalid JSON error") - } -} - -func TestAIAgentServiceRejectsNonMCPToolSelection(t *testing.T) { - for _, toolCode := range []string{ - toolx.BuiltinConversationContext.Code, - toolx.BuiltinKnowledgeRetrieve.Code, - toolx.GraphPrepareTicketDraft.Code, - toolx.GraphAnalyzeConversation.Code, - toolx.GraphTriageServiceRequest.Code, - toolx.GraphHandoffConversation.Code, - } { - if _, err := AIAgentService.normalizeMCPTools([]request.AIAgentMCPToolRequest{{ToolCode: toolCode}}); err == nil { - t.Fatalf("expected non-MCP tool %q to be rejected", toolCode) - } - } -} - -func TestAIAgentServiceRollsBackToOwnPublishedRevision(t *testing.T) { - setupAIAgentWorkflowTestDB(t) - db := sqls.DB() - agent := &models.AIAgent{Name: "rollback-agent", Status: enums.StatusOk, RuntimeMode: enums.AIAgentRuntimeModeAutonomous} - if err := db.Create(agent).Error; err != nil { - t.Fatalf("create agent: %v", err) - } - revision := &models.AgentRevision{AgentID: agent.ID, Revision: 1, Status: enums.StatusOk} - if err := db.Create(revision).Error; err != nil { - t.Fatalf("create revision: %v", err) - } - if err := AIAgentService.RollbackAIAgent(agent.ID, revision.ID, aiAgentWorkflowTestOperator()); err != nil { - t.Fatalf("RollbackAIAgent: %v", err) - } - if updated := AIAgentService.Get(agent.ID); updated == nil || updated.PublishedRevisionID != revision.ID { - t.Fatalf("rollback did not bind revision: %#v", updated) - } - otherRevision := &models.AgentRevision{AgentID: agent.ID + 1, Revision: 1, Status: enums.StatusOk} - if err := db.Create(otherRevision).Error; err != nil { - t.Fatalf("create other revision: %v", err) - } - if err := AIAgentService.RollbackAIAgent(agent.ID, otherRevision.ID, aiAgentWorkflowTestOperator()); err == nil { - t.Fatal("expected cross-agent revision rollback rejection") - } -} - -func TestAIAgentServiceRollsBackPreviousRolloutPercent(t *testing.T) { - setupAIAgentWorkflowTestDB(t) - agent := &models.AIAgent{ - Name: "rollout-agent", - Status: enums.StatusOk, - RuntimeMode: enums.AIAgentRuntimeModeAutonomous, - RolloutPercent: 20, - PreviousRolloutPercent: 100, - } - if err := sqls.DB().Create(agent).Error; err != nil { - t.Fatalf("create agent: %v", err) - } - operator := aiAgentWorkflowTestOperator() - if err := AIAgentService.RollbackAIAgentRollout(agent.ID, operator); err != nil { - t.Fatalf("RollbackAIAgentRollout: %v", err) - } - updated := AIAgentService.Get(agent.ID) - if updated == nil || updated.RolloutPercent != 100 || updated.PreviousRolloutPercent != 20 { - t.Fatalf("unexpected rollout rollback result: %#v", updated) - } - if err := AIAgentService.RollbackAIAgentRollout(agent.ID, operator); err != nil { - t.Fatalf("second RollbackAIAgentRollout: %v", err) - } - updated = AIAgentService.Get(agent.ID) - if updated == nil || updated.RolloutPercent != 20 || updated.PreviousRolloutPercent != 100 { - t.Fatalf("unexpected rollout redo result: %#v", updated) - } - if err := sqls.DB().Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("previous_rollout_percent", 0).Error; err != nil { - t.Fatalf("clear previous rollout: %v", err) - } - if err := AIAgentService.RollbackAIAgentRollout(agent.ID, operator); err == nil { - t.Fatal("expected missing previous rollout to be rejected") - } -} - -func TestAIAgentServiceUpdateUnpublishesAutonomousAgent(t *testing.T) { - setupAIAgentWorkflowTestDB(t) - operator := aiAgentWorkflowTestOperator() - agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ - Name: "autonomous agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeAutonomous, - ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer, - }, operator) - if err != nil { - t.Fatalf("CreateAIAgent() error = %v", err) - } - if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err != nil { - t.Fatalf("PublishAIAgent() error = %v", err) - } - if published := AIAgentService.Get(agent.ID); published == nil || published.PublishedRevisionID <= 0 { - t.Fatalf("expected published autonomous agent, got %#v", published) - } - if err := AIAgentService.UpdateAIAgent(request.UpdateAIAgentRequest{ID: agent.ID, CreateAIAgentRequest: request.CreateAIAgentRequest{ - Name: agent.Name, Description: "changed draft", AIConfigID: agent.AIConfigID, RuntimeMode: enums.AIAgentRuntimeModeAutonomous, - ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer, - }}, operator); err != nil { - t.Fatalf("UpdateAIAgent() error = %v", err) - } - if updated := AIAgentService.Get(agent.ID); updated == nil || updated.PublishedRevisionID != 0 { - t.Fatalf("expected autonomous update to clear published revision, got %#v", updated) - } -} - -func TestAIAgentServiceRejectsPublishWithUnavailableModelConfig(t *testing.T) { - setupAIAgentWorkflowTestDB(t) - operator := aiAgentWorkflowTestOperator() - configID := createAIAgentWorkflowTestConfig(t) - agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ - Name: "unavailable model agent", AIConfigID: configID, RuntimeMode: enums.AIAgentRuntimeModeAutonomous, - ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer, - }, operator) - if err != nil { - t.Fatalf("CreateAIAgent() error = %v", err) - } - if err := sqls.DB().Model(&models.AIConfig{}).Where("id = ?", configID).Update("status", enums.StatusDisabled).Error; err != nil { - t.Fatalf("disable model config: %v", err) - } - if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err == nil { - t.Fatal("expected unavailable model config to reject publishing") - } -} - -func TestAIAgentServiceAllowsPublishWithAdministratorSelectedMCPTool(t *testing.T) { - setupAIAgentWorkflowTestDB(t) - operator := aiAgentWorkflowTestOperator() - agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ - Name: "mcp tool agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeAutonomous, - ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer, - }, operator) - if err != nil { - t.Fatalf("CreateAIAgent() error = %v", err) - } - if err := sqls.DB().Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("allowed_mcp_tools", `[{"toolCode":"mcp/demo/write_order"}]`).Error; err != nil { - t.Fatalf("set MCP tool: %v", err) - } - if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err != nil { - t.Fatalf("expected administrator-selected MCP tool to be publishable, got %v", err) - } -} - -func TestAIWorkflowServiceDefaultAgentWorkflowDefinitionRequiresKnowledgeRetrieveConfiguration(t *testing.T) { - definition := AIWorkflowService.DefaultAgentWorkflowDefinition() - if definition.SchemaVersion != dsl.SchemaVersion || nodeTypeByID(definition, "start_1") != workflowregistry.NodeTypeStart { - t.Fatalf("expected default workflow definition") - } - validation := workflowvalidator.ValidateDefinition(definition, workflowregistry.DefaultRegistry()) - if validation.Valid || !workflowValidationHasMessage(validation, "需要选择至少一个知识库") { - t.Fatalf("expected default workflow definition to require node knowledge bases, got %#v", validation.Errors) - } - if nodeTypeByID(definition, "understanding_1") != workflowregistry.NodeTypeConversationUnderstanding { - t.Fatalf("expected default workflow to include conversation understanding, got nodes: %#v", definition.Nodes) - } - if nodeTypeByID(definition, "policy_1") != workflowregistry.NodeTypeReplyPolicy { - t.Fatalf("expected default workflow to include reply policy, got nodes: %#v", definition.Nodes) - } - if !workflowHasNodeType(definition, workflowregistry.NodeTypeHandoffToHuman) { - t.Fatalf("expected default workflow to include human handoff node") - } - if !workflowHasNodeType(definition, workflowregistry.NodeTypeCreateTicket) { - t.Fatalf("expected default workflow to include ticket creation node") - } - if nodeTypeByID(definition, "handoff_confirm_1") != workflowregistry.NodeTypeHumanConfirm { - t.Fatalf("expected default workflow handoff path to include human confirmation") - } - handoff := workflowNodeByID(t, definition, "handoff_1") - if nodeID, field, ok := handoff.Data.InputsValues["confirmed"].Ref(); !ok || nodeID != "handoff_confirm_1" || field != "confirmed" { - t.Fatalf("expected handoff to use confirmation result, got %#v", handoff.Data.InputsValues["confirmed"]) - } -} - -func TestAIWorkflowServiceDefaultAgentWorkflowTicketPromptIncludesDraftFields(t *testing.T) { - definition := AIWorkflowService.DefaultAgentWorkflowDefinition() - prompt := workflowNodeByID(t, definition, "ticket_confirm_prompt_1") - if _, ok := prompt.Data.InputsValues["ticketTitle"]; !ok { - t.Fatalf("expected ticket confirm prompt to map ticketTitle") - } - if _, ok := prompt.Data.InputsValues["ticketDescription"]; !ok { - t.Fatalf("expected ticket confirm prompt to map ticketDescription") - } - config := map[string]any{} - if err := json.Unmarshal(prompt.Data.Config, &config); err != nil { - t.Fatalf("unmarshal prompt config: %v", err) - } - staticReply, _ := config["staticReply"].(string) - if !strings.Contains(staticReply, "{{ticketTitle}}") || !strings.Contains(staticReply, "{{ticketDescription}}") { - t.Fatalf("expected prompt template to include ticket title and description, got %q", staticReply) - } -} - -func TestAIWorkflowServiceDefaultAgentWorkflowLayoutDoesNotOverlap(t *testing.T) { - definition := AIWorkflowService.DefaultAgentWorkflowDefinition() - assertWorkflowLayoutDoesNotOverlap(t, definition) -} - -func TestAIWorkflowServicePublishAgentWorkflowBindsAgentVersion(t *testing.T) { - setupAIAgentWorkflowTestDB(t) - operator := aiAgentWorkflowTestOperator() - aiConfigID := createAIAgentWorkflowTestConfig(t) - - agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ - Name: "workflow agent without version", - AIConfigID: aiConfigID, - ServiceMode: enums.IMConversationServiceModeAIOnly, - HandoffMode: enums.AIAgentHandoffModeWaitPool, - FallbackMode: enums.AIAgentFallbackModeNoAnswer, - }, operator) - if err != nil { - t.Fatalf("CreateAIAgent() error = %v", err) - } - workflow, err := AIWorkflowService.SaveAgentWorkflow(request.SaveAIWorkflowRequest{ - AgentID: agent.ID, - Name: "After sales flow", - Description: "Support workflow", - Definition: validAIWorkflowDefinition(), - }, operator) - if err != nil { - t.Fatalf("SaveAgentWorkflow() error = %v", err) - } - - version, err := AIWorkflowService.PublishAgentWorkflow(request.PublishAIWorkflowRequest{ - AgentID: agent.ID, - Definition: validAIWorkflowDefinition(), - }, operator) - if err != nil { - t.Fatalf("PublishAgentWorkflow() error = %v", err) - } - if version.WorkflowID != workflow.ID { - t.Fatalf("expected version workflow id %d, got %d", workflow.ID, version.WorkflowID) - } - storedAgent := AIAgentService.Get(agent.ID) - if storedAgent == nil { - t.Fatalf("expected stored agent") - } - if storedAgent.WorkflowVersionID != version.ID { - t.Fatalf("expected agent workflow version %d, got %d", version.ID, storedAgent.WorkflowVersionID) - } - if storedAgent.PublishedRevisionID <= 0 { - t.Fatalf("expected published agent revision id, got %d", storedAgent.PublishedRevisionID) - } - var revision models.AgentRevision - if err := sqls.DB().First(&revision, storedAgent.PublishedRevisionID).Error; err != nil { - t.Fatalf("load agent revision: %v", err) - } - if revision.AgentID != agent.ID || revision.WorkflowVersionID != version.ID || revision.Revision != 1 || revision.DefinitionHash == "" { - t.Fatalf("unexpected published agent revision: %#v", revision) - } - if !strings.Contains(revision.Definition, `"modelName":"gpt-test"`) || strings.Contains(revision.Definition, "revision-test-secret") { - t.Fatalf("unexpected revision definition: %s", revision.Definition) - } -} - -func TestAIAgentServiceBindsPublishedWorkflowVersionIndependently(t *testing.T) { - setupAIAgentWorkflowTestDB(t) - operator := aiAgentWorkflowTestOperator() - workflow, err := AIWorkflowService.CreateWorkflow(request.CreateAIWorkflowRequest{Name: "共享建单流程", Definition: validAIWorkflowDefinition()}, operator) - if err != nil { - t.Fatalf("CreateWorkflow() error = %v", err) - } - version, err := AIWorkflowService.PublishWorkflow(request.PublishAIWorkflowRequest{WorkflowID: workflow.ID, Definition: validAIWorkflowDefinition()}, operator) - if err != nil { - t.Fatalf("PublishWorkflow() error = %v", err) - } - agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ - Name: "绑定共享工作流的 Agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeHybrid, - ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer, - WorkflowBindings: []request.AIAgentWorkflowBindingRequest{{WorkflowVersionID: version.ID, ToolName: "创建工单", TriggerInstruction: "用户要求创建工单", Enabled: true}}, - }, operator) - if err != nil { - t.Fatalf("CreateAIAgent() error = %v", err) - } - bindings := AIAgentService.ListWorkflowBindings(agent.ID) - if len(bindings) != 1 || bindings[0].Binding.WorkflowVersionID != version.ID || bindings[0].Workflow == nil || bindings[0].Workflow.AgentID != 0 { - t.Fatalf("unexpected independent workflow binding: %#v", bindings) - } - if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err != nil { - t.Fatalf("PublishAIAgent() error = %v", err) - } - stored := AIAgentService.Get(agent.ID) - snapshot, err := AgentRevisionService.ResolvePublishedSnapshot(*stored, *AIConfigService.Get(stored.AIConfigID)) - if err != nil || len(snapshot.WorkflowBindings) != 1 || snapshot.WorkflowBindings[0].WorkflowVersionID != version.ID { - t.Fatalf("expected published workflow binding snapshot, snapshot=%#v err=%v", snapshot, err) - } -} - -func setupAIAgentWorkflowTestDB(t *testing.T) { - t.Helper() - db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) - if err != nil { - t.Fatalf("open sqlite db: %v", err) - } - if err := db.AutoMigrate(&models.AIAgent{}, &models.AIConfig{}, &models.KnowledgeBase{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}, &models.AIAgentWorkflowBinding{}, &models.AgentRevision{}); err != nil { - t.Fatalf("auto migrate: %v", err) - } - sqls.SetDB(db) -} - -func createAIAgentWorkflowTestConfig(t *testing.T) int64 { - t.Helper() - item := &models.AIConfig{ - Name: "workflow-test-config", - Provider: enums.AIProviderOpenAI, - APIKey: "revision-test-secret", - ModelType: enums.AIModelTypeLLM, - ModelName: "gpt-test", - Status: enums.StatusOk, - } - if err := sqls.DB().Create(item).Error; err != nil { - t.Fatalf("create ai config: %v", err) - } - return item.ID -} - -func createAIAgentWorkflowTestKnowledgeBase(t *testing.T) int64 { - t.Helper() - item := &models.KnowledgeBase{ - Name: "workflow-test-kb", - KnowledgeType: string(enums.KnowledgeBaseTypeFAQ), - Status: enums.StatusOk, - } - if err := sqls.DB().Create(item).Error; err != nil { - t.Fatalf("create knowledge base: %v", err) - } - return item.ID -} - -func createAIAgentWorkflowVersion(t *testing.T) int64 { - t.Helper() - workflow := &models.AIWorkflow{ - Name: "workflow-test", - AgentID: 1, - Status: enums.StatusOk, - } - if err := sqls.DB().Create(workflow).Error; err != nil { - t.Fatalf("create workflow: %v", err) - } - version := &models.AIWorkflowVersion{ - WorkflowID: workflow.ID, - Version: 1, - Status: enums.StatusOk, - } - if err := sqls.DB().Create(version).Error; err != nil { - t.Fatalf("create workflow version: %v", err) - } - return version.ID -} - -func aiAgentWorkflowTestOperator() *dto.AuthPrincipal { - return &dto.AuthPrincipal{ - UserID: 1, - Username: "agent-workflow-tester", - Nickname: "agent-workflow-tester", - } -} - -func workflowHasNodeType(def dsl.Definition, nodeType string) bool { - for _, node := range def.Nodes { - if node.Type == nodeType { - return true - } - } - return false -} - -func workflowValidationHasMessage(result workflowvalidator.Result, message string) bool { - for _, item := range result.Errors { - if strings.Contains(item.Message, message) { - return true - } - } - return false -} - -func nodeTypeByID(def dsl.Definition, nodeID string) string { - for _, node := range def.Nodes { - if node.ID == nodeID { - return node.Type - } - } - return "" -} - -func workflowNodeByID(t *testing.T, def dsl.Definition, nodeID string) dsl.Node { - t.Helper() - for _, node := range def.Nodes { - if node.ID == nodeID { - return node - } - } - t.Fatalf("workflow node not found: %s", nodeID) - return dsl.Node{} -} - -func assertConditionBranchToNodeType(t *testing.T, def dsl.Definition, sourceID string, targetType string, operator string, right any) { - t.Helper() - nodeTypes := workflowNodeTypeMap(def) - for _, branch := range conditionBranches(t, def, sourceID) { - if nodeTypes[branch.TargetNodeID] != targetType || branch.Condition == nil { - continue - } - if branch.Condition.Operator == operator && branch.Condition.Right == right { - return - } - } - t.Fatalf("expected %s condition branch from %s to %s with right=%v", operator, sourceID, targetType, right) -} - -func assertConditionBranchToNodeID(t *testing.T, def dsl.Definition, sourceID string, targetID string, operator string, right any) { - t.Helper() - for _, branch := range conditionBranches(t, def, sourceID) { - if branch.TargetNodeID != targetID || branch.Condition == nil { - continue - } - if branch.Condition.Operator == operator && branch.Condition.Right == right { - return - } - } - t.Fatalf("expected %s condition branch from %s to %s with right=%v", operator, sourceID, targetID, right) -} - -func assertDefaultBranchToNodeID(t *testing.T, def dsl.Definition, sourceID string, targetID string) { - t.Helper() - for _, branch := range conditionBranches(t, def, sourceID) { - if branch.TargetNodeID == targetID && branch.Default { - return - } - } - t.Fatalf("expected default branch from %s to %s", sourceID, targetID) -} - -func conditionBranches(t *testing.T, def dsl.Definition, nodeID string) []dsl.ConditionBranch { - t.Helper() - for _, node := range def.Nodes { - if node.ID != nodeID { - continue - } - var config dsl.ConditionConfig - if err := json.Unmarshal(node.Data.Config, &config); err != nil { - t.Fatalf("unmarshal condition config for %s: %v", nodeID, err) - } - return config.Branches - } - t.Fatalf("condition node not found: %s", nodeID) - return nil -} - -func assertConditionBranchesHavePortEdges(t *testing.T, def dsl.Definition, nodeID string) { - t.Helper() - for _, branch := range conditionBranches(t, def, nodeID) { - if !workflowPortEdgeExists(def, nodeID, branch.TargetNodeID, branch.ID) { - t.Fatalf("expected condition branch %s.%s to have port edge to %s", nodeID, branch.ID, branch.TargetNodeID) - } - } -} - -func assertConditionBranchOrder(t *testing.T, def dsl.Definition, nodeID string, want []string) { - t.Helper() - branches := conditionBranches(t, def, nodeID) - if len(branches) != len(want) { - t.Fatalf("expected %s branch order %v, got %#v", nodeID, want, branches) - } - for index, branch := range branches { - if branch.ID != want[index] { - t.Fatalf("expected %s branch order %v, got branch %d = %s", nodeID, want, index, branch.ID) - } - } -} - -func assertConditionPortEdgeOrder(t *testing.T, def dsl.Definition, nodeID string, want []string) { - t.Helper() - got := make([]string, 0, len(want)) - for _, edge := range def.Edges { - if edge.SourceNodeID == nodeID { - got = append(got, edge.SourcePortID) - } - } - if len(got) != len(want) { - t.Fatalf("expected %s port edge order %v, got %v", nodeID, want, got) - } - for index, sourcePortID := range got { - if sourcePortID != want[index] { - t.Fatalf("expected %s port edge order %v, got edge %d = %s", nodeID, want, index, sourcePortID) - } - } -} - -func workflowPortEdgeExists(def dsl.Definition, sourceID string, targetID string, sourcePortID string) bool { - for _, edge := range def.Edges { - if edge.SourceNodeID == sourceID && edge.TargetNodeID == targetID && edge.SourcePortID == sourcePortID { - return true - } - } - return false -} - -func workflowEdgeExists(def dsl.Definition, sourceID string, targetID string) bool { - for _, edge := range def.Edges { - if edge.SourceNodeID == sourceID && edge.TargetNodeID == targetID { - return true - } - } - return false -} - -type workflowLayoutBox struct { - NodeID string - Left float64 - Top float64 - Right float64 - Bottom float64 -} - -func assertWorkflowLayoutDoesNotOverlap(t *testing.T, def dsl.Definition) { - t.Helper() - boxes := make([]workflowLayoutBox, 0, len(def.Nodes)) - for _, node := range def.Nodes { - width, height := defaultWorkflowNodeRenderSize(node.Type) - boxes = append(boxes, workflowLayoutBox{ - NodeID: node.ID, - Left: node.Meta.Position.X, - Top: node.Meta.Position.Y, - Right: node.Meta.Position.X + width, - Bottom: node.Meta.Position.Y + height, - }) - } - const minGap = 32.0 - for i := range boxes { - for j := i + 1; j < len(boxes); j++ { - if workflowBoxesOverlapWithGap(boxes[i], boxes[j], minGap) { - t.Fatalf("default workflow nodes are too close or overlapping: %s=%+v %s=%+v", boxes[i].NodeID, boxes[i], boxes[j].NodeID, boxes[j]) - } - } - } -} - -func defaultWorkflowNodeRenderSize(nodeType string) (float64, float64) { - if nodeType == workflowregistry.NodeTypeCondition { - return 160, 160 - } - return 220, 128 -} - -func workflowBoxesOverlapWithGap(a workflowLayoutBox, b workflowLayoutBox, gap float64) bool { - return a.Left < b.Right+gap && a.Right+gap > b.Left && a.Top < b.Bottom+gap && a.Bottom+gap > b.Top -} - -func workflowNodeTypeMap(def dsl.Definition) map[string]string { - ret := make(map[string]string, len(def.Nodes)) - for _, node := range def.Nodes { - ret[node.ID] = node.Type - } - return ret -} diff --git a/internal/services/ai_workflow_service.go b/internal/services/ai_workflow_service.go index b86c929..ed20ff6 100644 --- a/internal/services/ai_workflow_service.go +++ b/internal/services/ai_workflow_service.go @@ -163,16 +163,16 @@ func (s *aiWorkflowService) DefaultAgentWorkflowDefinition() dsl.Definition { return defaultAgentWorkflowDefinition() } -func (s *aiWorkflowService) ListPlaybookTemplates() []AIWorkflowTemplate { +func (s *aiWorkflowService) ListWorkflowTemplates() []AIWorkflowTemplate { return []AIWorkflowTemplate{ - {Code: "ticket-with-confirmation", Name: "创建工单", Description: "整理工单草稿,经客户确认后创建工单。", Definition: ticketWithConfirmationPlaybookDefinition()}, - {Code: "identity-confirmation", Name: "身份确认", Description: "在执行后续业务前收集客户的明确确认。", Definition: identityConfirmationPlaybookDefinition()}, - {Code: "complaint-escalation", Name: "投诉升级", Description: "投诉场景经客户确认后转入人工客服处理。", Definition: complaintEscalationPlaybookDefinition()}, - {Code: "refund-request-preparation", Name: "退款申请准备", Description: "整理退款诉求,确认后转人工继续核验和处理。", Definition: refundRequestPreparationPlaybookDefinition()}, + {Code: "ticket-with-confirmation", Name: "创建工单", Description: "整理工单草稿,经客户确认后创建工单。", Definition: ticketWithConfirmationWorkflowDefinition()}, + {Code: "identity-confirmation", Name: "身份确认", Description: "在执行后续业务前收集客户的明确确认。", Definition: identityConfirmationWorkflowDefinition()}, + {Code: "complaint-escalation", Name: "投诉升级", Description: "投诉场景经客户确认后转入人工客服处理。", Definition: complaintEscalationWorkflowDefinition()}, + {Code: "refund-request-preparation", Name: "退款申请准备", Description: "整理退款诉求,确认后转人工继续核验和处理。", Definition: refundRequestPreparationWorkflowDefinition()}, } } -func ticketWithConfirmationPlaybookDefinition() dsl.Definition { +func ticketWithConfirmationWorkflowDefinition() dsl.Definition { return dsl.Definition{SchemaVersion: dsl.SchemaVersion, Nodes: []dsl.Node{ workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil), @@ -522,7 +522,7 @@ func legacyDefaultAgentWorkflowDefinition() dsl.Definition { } } -func identityConfirmationPlaybookDefinition() dsl.Definition { +func identityConfirmationWorkflowDefinition() dsl.Definition { return dsl.Definition{SchemaVersion: dsl.SchemaVersion, Nodes: []dsl.Node{ workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil), @@ -546,11 +546,11 @@ func identityConfirmationPlaybookDefinition() dsl.Definition { } } -func complaintEscalationPlaybookDefinition() dsl.Definition { - return confirmationHandoffPlaybookDefinition("投诉升级确认", "我们将把本次投诉升级给人工客服处理。请确认是否继续。", "已为你升级投诉,人工客服会尽快跟进。", "投诉升级已取消。") +func complaintEscalationWorkflowDefinition() dsl.Definition { + return confirmationHandoffWorkflowDefinition("投诉升级确认", "我们将把本次投诉升级给人工客服处理。请确认是否继续。", "已为你升级投诉,人工客服会尽快跟进。", "投诉升级已取消。") } -func confirmationHandoffPlaybookDefinition(title, prompt, confirmedReply, cancelledReply string) dsl.Definition { +func confirmationHandoffWorkflowDefinition(title, prompt, confirmedReply, cancelledReply string) dsl.Definition { return dsl.Definition{SchemaVersion: dsl.SchemaVersion, Nodes: []dsl.Node{ workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil), @@ -574,8 +574,8 @@ func confirmationHandoffPlaybookDefinition(title, prompt, confirmedReply, cancel } } -func refundRequestPreparationPlaybookDefinition() dsl.Definition { - return confirmationHandoffPlaybookDefinition("退款申请确认", "我会先整理退款申请并转交人工客服核验。请确认是否继续。", "退款申请已准备完成,人工客服将继续核验订单和退款条件。", "退款申请准备已取消。") +func refundRequestPreparationWorkflowDefinition() dsl.Definition { + return confirmationHandoffWorkflowDefinition("退款申请确认", "我会先整理退款申请并转交人工客服核验。请确认是否继续。", "退款申请已准备完成,人工客服将继续核验订单和退款条件。", "退款申请准备已取消。") } func workflowNode(id string, nodeType string, title string, x float64, y float64, inputs map[string]dsl.Value, config any) dsl.Node { diff --git a/internal/services/ai_workflow_service_test.go b/internal/services/ai_workflow_service_test.go index 8f8e4e1..d595138 100644 --- a/internal/services/ai_workflow_service_test.go +++ b/internal/services/ai_workflow_service_test.go @@ -103,8 +103,8 @@ func TestAIWorkflowServicePublishCreatesImmutableVersion(t *testing.T) { } } -func TestAIWorkflowServicePlaybookTemplatesAreValid(t *testing.T) { - templates := AIWorkflowService.ListPlaybookTemplates() +func TestAIWorkflowServiceWorkflowTemplatesAreValid(t *testing.T) { + templates := AIWorkflowService.ListWorkflowTemplates() if len(templates) != 4 { t.Fatalf("template count = %d, want 4", len(templates)) } diff --git a/internal/services/business_tool_executor.go b/internal/services/business_tool_executor.go index 473ae3d..f026f08 100644 --- a/internal/services/business_tool_executor.go +++ b/internal/services/business_tool_executor.go @@ -14,8 +14,8 @@ import ( ) // BusinessToolExecutor is the write boundary for built-in business tools. -// Autonomous mode deliberately does not expose it; deterministic Playbooks -// invoke it only after their human-confirm node has completed. +// AgentDesk services remain the write boundary. Workflow nodes invoke this +// executor only after their human-confirm node has completed. var BusinessToolExecutor = newBusinessToolExecutor(aitooling.DefaultRegistry) type BusinessToolInput struct { diff --git a/internal/services/channel_service.go b/internal/services/channel_service.go index 3298d3a..48a0b10 100644 --- a/internal/services/channel_service.go +++ b/internal/services/channel_service.go @@ -436,20 +436,8 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe if aiAgent == nil || aiAgent.Status != enums.StatusOk { return nil, errorsx.InvalidParamI18n("error.e0004") } - if aiAgent.RuntimeMode == "" || aiAgent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow { - if len(AIAgentService.ListEnabledWorkflowBindings(sqls.DB(), aiAgent.ID)) != 1 { - return nil, errorsx.InvalidParam("ai agent workflow must be published before binding channel") - } - } else if aiAgent.RuntimeMode == enums.AIAgentRuntimeModeAutonomous { - if aiAgent.PublishedRevisionID <= 0 { - return nil, errorsx.InvalidParam("autonomous ai agent must be published before binding channel") - } - } else if aiAgent.RuntimeMode == enums.AIAgentRuntimeModeHybrid { - if aiAgent.PublishedRevisionID <= 0 { - return nil, errorsx.InvalidParam("hybrid ai agent and workflow must be published before binding channel") - } - } else { - return nil, errorsx.InvalidParam("ai agent runtime mode is not available yet") + if aiAgent.PublishedRevisionID <= 0 { + return nil, errorsx.InvalidParam("ai agent must be published before binding channel") } status := enums.Status(req.Status) if req.Status == 0 { diff --git a/internal/services/channel_service_test.go b/internal/services/channel_service_test.go index 8a46796..7ede0c2 100644 --- a/internal/services/channel_service_test.go +++ b/internal/services/channel_service_test.go @@ -15,7 +15,7 @@ import ( "gorm.io/gorm/schema" ) -func TestChannelServiceRejectsAgentWithoutPublishedWorkflow(t *testing.T) { +func TestChannelServiceRejectsAgentWithoutPublishedRevision(t *testing.T) { db := setupChannelServiceTestDB(t) agent := createChannelServiceTestAgent(t, db, 0) @@ -30,7 +30,7 @@ func TestChannelServiceRejectsAgentWithoutPublishedWorkflow(t *testing.T) { } } -func TestChannelServiceAllowsAgentWithPublishedWorkflow(t *testing.T) { +func TestChannelServiceAllowsAgentWithPublishedRevision(t *testing.T) { db := setupChannelServiceTestDB(t) agent := createChannelServiceTestAgent(t, db, 1001) @@ -96,75 +96,6 @@ func TestChannelServiceRollsBackPreviousAIAgentRolloutPercent(t *testing.T) { } } -func TestChannelServiceRejectsUnpublishedAutonomousRuntime(t *testing.T) { - db := setupChannelServiceTestDB(t) - agent := createChannelServiceTestAgent(t, db, 1001) - if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("runtime_mode", enums.AIAgentRuntimeModeAutonomous).Error; err != nil { - t.Fatalf("set autonomous runtime mode: %v", err) - } - - _, err := ChannelService.CreateChannel(request.CreateChannelRequest{ - ChannelType: enums.ChannelTypeWeb, - AIAgentID: agent.ID, - Name: "官网客服", - Status: int(enums.StatusOk), - }, channelServiceTestOperator()) - if err == nil || !strings.Contains(err.Error(), "must be published") { - t.Fatalf("expected unpublished autonomous runtime error, got %v", err) - } -} - -func TestChannelServiceAcceptsPublishedAutonomousRuntime(t *testing.T) { - db := setupChannelServiceTestDB(t) - agent := createChannelServiceTestAgent(t, db, 1001) - revision := &models.AgentRevision{AgentID: agent.ID, Revision: 1, Status: enums.StatusOk} - if err := db.Create(revision).Error; err != nil { - t.Fatalf("create agent revision: %v", err) - } - if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Updates(map[string]any{ - "runtime_mode": enums.AIAgentRuntimeModeAutonomous, - "published_revision_id": revision.ID, - }).Error; err != nil { - t.Fatalf("set autonomous runtime mode: %v", err) - } - item, err := ChannelService.CreateChannel(request.CreateChannelRequest{ - ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "自主客服", Status: int(enums.StatusOk), - }, channelServiceTestOperator()) - if err != nil || item == nil { - t.Fatalf("create channel for autonomous runtime: item=%#v err=%v", item, err) - } -} - -func TestChannelServiceRequiresBothHybridPublicationArtifacts(t *testing.T) { - db := setupChannelServiceTestDB(t) - agent := createChannelServiceTestAgent(t, db, 0) - revision := &models.AgentRevision{AgentID: agent.ID, Revision: 1, Status: enums.StatusOk} - if err := db.Create(revision).Error; err != nil { - t.Fatalf("create agent revision: %v", err) - } - if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Updates(map[string]any{ - "runtime_mode": enums.AIAgentRuntimeModeHybrid, - "published_revision_id": revision.ID, - }).Error; err != nil { - t.Fatalf("set hybrid runtime mode: %v", err) - } - _, err := ChannelService.CreateChannel(request.CreateChannelRequest{ - ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "混合客服", Status: int(enums.StatusOk), - }, channelServiceTestOperator()) - if err == nil || !strings.Contains(err.Error(), "hybrid ai agent") { - t.Fatalf("expected hybrid publication error, got %v", err) - } - if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("workflow_version_id", 1001).Error; err != nil { - t.Fatalf("set workflow version: %v", err) - } - item, err := ChannelService.CreateChannel(request.CreateChannelRequest{ - ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "混合客服已发布", Status: int(enums.StatusOk), - }, channelServiceTestOperator()) - if err != nil || item == nil { - t.Fatalf("create channel for hybrid runtime: item=%#v err=%v", item, err) - } -} - func setupChannelServiceTestDB(t *testing.T) *gorm.DB { t.Helper() dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) @@ -190,12 +121,12 @@ func setupChannelServiceTestDB(t *testing.T) *gorm.DB { return db } -func createChannelServiceTestAgent(t *testing.T, db *gorm.DB, workflowVersionID int64) models.AIAgent { +func createChannelServiceTestAgent(t *testing.T, db *gorm.DB, publishedRevisionID int64) models.AIAgent { t.Helper() item := models.AIAgent{ - Name: "测试 AI", - Status: enums.StatusOk, - WorkflowVersionID: workflowVersionID, + Name: "测试 AI", + Status: enums.StatusOk, + PublishedRevisionID: publishedRevisionID, } if err := db.Create(&item).Error; err != nil { t.Fatalf("create ai agent: %v", err) diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index 27a5f0d..670302c 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -70,8 +70,8 @@ func (s *dashboardService) GetOverview(rangeValue string, locale string) respons knowledgeRetrieveFailCount := repositories.DashboardRepository.CountKnowledgeRetrieveLogs(db, func(tx *gorm.DB) *gorm.DB { return tx.Where("created_at >= ? AND answer_status IN ?", todayStart, []int{2, 3, 4}) }) - skillRunFailCount := repositories.DashboardRepository.CountSkillRunLogs(db, func(tx *gorm.DB) *gorm.DB { - return tx.Where("created_at >= ? AND error_message <> ''", todayStart) + agentRunFailCount := repositories.DashboardRepository.CountAgentRuns(db, func(tx *gorm.DB) *gorm.DB { + return tx.Where("created_at >= ? AND status = ?", todayStart, "failed") }) aiHandoffCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB { return tx.Where("handoff_at >= ?", todayStart) @@ -108,7 +108,7 @@ func (s *dashboardService) GetOverview(rangeValue string, locale string) respons TodayKnowledgeRetrieves: knowledgeRetrieveCount, TodayKnowledgeRetrieveFailCount: knowledgeRetrieveFailCount, TodayKnowledgeRetrieveFailRate: calcRate(knowledgeRetrieveFailCount, knowledgeRetrieveCount), - TodaySkillRunFailCount: skillRunFailCount, + TodayAgentRunFailCount: agentRunFailCount, TodayAIHandoffCount: aiHandoffCount, }, Alerts: alerts, diff --git a/internal/services/skill_run_log_service.go b/internal/services/skill_run_log_service.go deleted file mode 100644 index 7f71096..0000000 --- a/internal/services/skill_run_log_service.go +++ /dev/null @@ -1,67 +0,0 @@ -package services - -import ( - "agent-desk/internal/models" - "agent-desk/internal/repositories" - - "agent-desk/internal/pkg/httpx/params" - - "github.com/mlogclub/simple/sqls" -) - -var SkillRunLogService = newSkillRunLogService() - -func newSkillRunLogService() *skillRunLogService { - return &skillRunLogService{} -} - -type skillRunLogService struct { -} - -func (s *skillRunLogService) Get(id int64) *models.SkillRunLog { - return repositories.SkillRunLogRepository.Get(sqls.DB(), id) -} - -func (s *skillRunLogService) Take(where ...interface{}) *models.SkillRunLog { - return repositories.SkillRunLogRepository.Take(sqls.DB(), where...) -} - -func (s *skillRunLogService) Find(cnd *sqls.Cnd) []models.SkillRunLog { - return repositories.SkillRunLogRepository.Find(sqls.DB(), cnd) -} - -func (s *skillRunLogService) FindOne(cnd *sqls.Cnd) *models.SkillRunLog { - return repositories.SkillRunLogRepository.FindOne(sqls.DB(), cnd) -} - -func (s *skillRunLogService) FindPageByParams(params *params.QueryParams) (list []models.SkillRunLog, paging *sqls.Paging) { - return repositories.SkillRunLogRepository.FindPageByParams(sqls.DB(), params) -} - -func (s *skillRunLogService) FindPageByCnd(cnd *sqls.Cnd) (list []models.SkillRunLog, paging *sqls.Paging) { - return repositories.SkillRunLogRepository.FindPageByCnd(sqls.DB(), cnd) -} - -func (s *skillRunLogService) Count(cnd *sqls.Cnd) int64 { - return repositories.SkillRunLogRepository.Count(sqls.DB(), cnd) -} - -func (s *skillRunLogService) Create(t *models.SkillRunLog) error { - return repositories.SkillRunLogRepository.Create(sqls.DB(), t) -} - -func (s *skillRunLogService) Update(t *models.SkillRunLog) error { - return repositories.SkillRunLogRepository.Update(sqls.DB(), t) -} - -func (s *skillRunLogService) Updates(id int64, columns map[string]interface{}) error { - return repositories.SkillRunLogRepository.Updates(sqls.DB(), id, columns) -} - -func (s *skillRunLogService) UpdateColumn(id int64, name string, value interface{}) error { - return repositories.SkillRunLogRepository.UpdateColumn(sqls.DB(), id, name, value) -} - -func (s *skillRunLogService) Delete(id int64) { - repositories.SkillRunLogRepository.Delete(sqls.DB(), id) -} diff --git a/web/app/dashboard/_components/dashboard-home.tsx b/web/app/dashboard/_components/dashboard-home.tsx index 32ecc36..75f13d1 100644 --- a/web/app/dashboard/_components/dashboard-home.tsx +++ b/web/app/dashboard/_components/dashboard-home.tsx @@ -138,9 +138,9 @@ export function DashboardHome() {
| 模式 | 运行 | 完成率 | 解决率 | 无依据率 | 工具成功率 | P95 | Token |
|---|---|---|---|---|---|---|---|
| {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} |