feat: Enhance AI Agent and Channel Management

- Updated labels in the AI Agents dashboard for clarity, changing "流程状态" to "Playbook 状态" and "未发布流程" to "未发布 Playbook".
- Introduced AI Agent rollout percentage management in channel editing, allowing users to set and rollback rollout percentages.
- Added new API endpoints for rolling back AI Agent rollout and fetching agent run metrics.
- Implemented new UI components for displaying agent run details, including status, duration, and input/output tokens.
- Enhanced type definitions for AdminChannel and AIAgent to include rollout percentages and runtime modes.
- Updated navigation to include a section for agent runs.
- Added new translations for agent run features in both English and Chinese.
This commit is contained in:
mlogclub
2026-07-25 12:04:06 +08:00
parent 45741d4032
commit 34051a4631
101 changed files with 8377 additions and 340 deletions
+1 -1
Submodule docs updated: 0818d24796...8e0ae6ac8b
@@ -0,0 +1,106 @@
package runtime
import (
"context"
"strings"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
svc "agent-desk/internal/services"
)
// ApplicationRunInput identifies the persisted inputs for an Agent reply.
// Loading these records here keeps channels and debug adapters independent of
// individual engine requirements.
type ApplicationRunInput struct {
ConversationID int64
MessageID int64
AIAgentID int64
}
type ApplicationResumeInput struct {
ApplicationRunInput
CheckPointID string
ResumeData map[string]string
}
// AgentApplicationService is the single application boundary before engine
// dispatch. It owns persisted input loading and relationship validation; the
// selected Engine remains responsible only for runtime execution.
type AgentApplicationService struct {
runtime *Service
}
var DefaultAgentApplicationService = NewAgentApplicationService()
func NewAgentApplicationService() *AgentApplicationService {
return &AgentApplicationService{runtime: NewService()}
}
func (s *AgentApplicationService) Run(ctx context.Context, input ApplicationRunInput) (*RunResult, error) {
req, err := s.loadRequest(input)
if err != nil {
return nil, err
}
return s.RunPrepared(ctx, req)
}
// RunPrepared is for isolated adapters such as the dashboard debug session.
// Callers are responsible for constructing an ephemeral or already-validated
// request; no persistence side effects are introduced by this boundary.
func (s *AgentApplicationService) RunPrepared(ctx context.Context, req RunInput) (*RunResult, error) {
return s.runtime.Run(ctx, req)
}
func (s *AgentApplicationService) Resume(ctx context.Context, input ApplicationResumeInput) (*RunResult, error) {
req, err := s.loadRequest(input.ApplicationRunInput)
if err != nil {
return nil, err
}
checkPointID := strings.TrimSpace(input.CheckPointID)
interrupt := svc.ConversationInterruptService.GetByCheckPointID(checkPointID)
if interrupt == nil || interrupt.ConversationID != req.Conversation.ID {
return nil, errorsx.InvalidParam("pending conversation interrupt does not exist")
}
if interrupt.AIAgentID > 0 && interrupt.AIAgentID != req.AIAgent.ID {
return nil, errorsx.InvalidParam("interrupt does not belong to agent")
}
return s.ResumePrepared(ctx, ResumeInput{
Conversation: req.Conversation,
UserMessage: req.UserMessage,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
CheckPointID: checkPointID,
ResumeData: input.ResumeData,
})
}
func (s *AgentApplicationService) ResumePrepared(ctx context.Context, req ResumeInput) (*RunResult, error) {
return s.runtime.Resume(ctx, req)
}
func (s *AgentApplicationService) loadRequest(input ApplicationRunInput) (RunInput, error) {
if input.ConversationID <= 0 || input.MessageID <= 0 || input.AIAgentID <= 0 {
return RunInput{}, errorsx.InvalidParam("conversation, message and agent are required")
}
conversation := svc.ConversationService.Get(input.ConversationID)
if conversation == nil {
return RunInput{}, errorsx.InvalidParam("conversation does not exist")
}
message := svc.MessageService.Get(input.MessageID)
if message == nil || message.ConversationID != conversation.ID {
return RunInput{}, errorsx.InvalidParam("message does not belong to conversation")
}
agent := svc.AIAgentService.Get(input.AIAgentID)
if agent == nil || agent.Status != enums.StatusOk {
return RunInput{}, errorsx.InvalidParam("ai agent is unavailable")
}
if conversation.AIAgentID > 0 && conversation.AIAgentID != agent.ID {
return RunInput{}, errorsx.InvalidParam("agent does not belong to conversation")
}
config := svc.AIConfigService.Get(agent.AIConfigID)
if config == nil || config.Status != enums.StatusOk {
return RunInput{}, errorsx.InvalidParam("ai config is unavailable")
}
return RunInput{Conversation: *conversation, UserMessage: *message, AIAgent: *agent, AIConfig: *config}, nil
}
@@ -0,0 +1,54 @@
package runtime
import (
"strings"
"testing"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
func TestAgentApplicationServiceLoadsConsistentPersistedRequest(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AIConfig{}, &models.AIAgent{}, &models.Conversation{}, &models.Message{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
config := &models.AIConfig{Status: enums.StatusOk, ModelName: "test-model"}
if err := db.Create(config).Error; err != nil {
t.Fatalf("create config: %v", err)
}
agent := &models.AIAgent{Name: "agent", Status: enums.StatusOk, AIConfigID: config.ID}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("create agent: %v", err)
}
conversation := &models.Conversation{AIAgentID: agent.ID}
if err := db.Create(conversation).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
message := &models.Message{ConversationID: conversation.ID, SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: "hello"}
if err := db.Create(message).Error; err != nil {
t.Fatalf("create message: %v", err)
}
req, err := NewAgentApplicationService().loadRequest(ApplicationRunInput{ConversationID: conversation.ID, MessageID: message.ID, AIAgentID: agent.ID})
if err != nil {
t.Fatalf("loadRequest: %v", err)
}
if req.Conversation.ID != conversation.ID || req.UserMessage.ID != message.ID || req.AIAgent.ID != agent.ID || req.AIConfig.ID != config.ID {
t.Fatalf("unexpected request: %#v", req)
}
}
func TestAgentApplicationServiceRejectsMismatchedMessage(t *testing.T) {
service := NewAgentApplicationService()
if _, err := service.loadRequest(ApplicationRunInput{ConversationID: 1, MessageID: 0, AIAgentID: 1}); err == nil {
t.Fatal("expected invalid identifiers error")
}
}
@@ -0,0 +1,688 @@
package runtime
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
ai "agent-desk/internal/ai"
"agent-desk/internal/ai/runtime/instruction"
"agent-desk/internal/ai/runtime/readtools"
"agent-desk/internal/ai/runtime/retrievers"
runtimetooling "agent-desk/internal/ai/runtime/tooling"
"agent-desk/internal/ai/skills"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/toolx"
"agent-desk/internal/pkg/utils"
svc "agent-desk/internal/services"
"github.com/mlogclub/simple/sqls"
)
// AutonomousEngine is the low-risk, no-flow runtime. It uses bounded model
// turns and exposes configured MCP tools only through the shared Tool Registry.
type AutonomousEngine struct {
chat func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error)
history func(int64, int) []models.Message
retrieve func(context.Context, models.AIAgent, string) (string, int, error)
skillSelect func(context.Context, skills.RuntimeContext) (*skills.ExecutionResult, error)
toolChat func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error)
}
func NewAutonomousEngine() *AutonomousEngine {
return &AutonomousEngine{
chat: ai.LLM.ChatWithConfig,
history: func(conversationID int64, limit int) []models.Message {
items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, limit, "", "")
return items
},
retrieve: retrieveAutonomousKnowledge,
skillSelect: skills.RuntimeService.Select,
toolChat: ai.LLM.ChatWithTools,
}
}
func newAutonomousEngineWithChat(chat func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error)) *AutonomousEngine {
return &AutonomousEngine{chat: chat}
}
func (e *AutonomousEngine) Code() string {
return EngineCodeAutonomous
}
func (e *AutonomousEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) {
startedAt := time.Now()
req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content)
snapshot, err := svc.AgentRevisionService.ResolvePublishedSnapshot(req.AIAgent, req.AIConfig)
if err != nil {
_, _ = writeAutonomousRun(req, startedAt, nil, "", 0, 0, nil, autonomousSkillContext{}, autonomousResponsePolicy{}, nil, err)
return nil, err
}
req.AIAgent = snapshot.Agent
req.AIConfig = snapshot.AIConfig
skillContext := e.selectSkill(ctx, req)
knowledgeContext, retrieverCount, retrieveErr := e.retrieveKnowledge(ctx, req.AIAgent, req.UserMessage.Content)
responsePolicy := evaluateAutonomousResponsePolicy(req.AIAgent, knowledgeContext, retrieveErr)
systemPrompt := buildAutonomousSystemPrompt(req.AIAgent, len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0, knowledgeContext, retrieveErr)
if skillInstruction := strings.TrimSpace(instruction.BuildSkillDocument(skillContext.Skill, nil)); skillInstruction != "" {
systemPrompt += "\n\nSkill instructions:\n" + skillInstruction
}
userPrompt, historyCount := e.buildUserPrompt(req)
if knowledgeContext != "" {
userPrompt += "\n\nKnowledge evidence:\n" + knowledgeContext
}
var toolCalls []svc.EngineToolCallInput
var result *ai.ChatCompletionResult
agentAllowedTools := autonomousAllowedMCPToolCodes(req.AIAgent.AllowedMCPTools)
toolPolicy := parseAutonomousToolPolicy(req.AIAgent.ToolPolicy)
allowedTools := agentAllowedTools
if skillContext.Skill != nil {
allowedTools = intersectAutonomousToolCodes(agentAllowedTools, skillContext.AllowedToolCodes)
}
if req.Debug {
// Dashboard debug runs may inspect model and retrieval behavior but must
// not invoke direct MCP tools against production integrations.
allowedTools = nil
}
if responsePolicy.Enforced {
result = &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}
} else if len(allowedTools) > 0 && e.toolChat != nil {
loopResult, loopErr := e.toolChat(ctx, req.AIConfig, systemPrompt, userPrompt, []ai.ToolDefinition{autonomousToolSearchDefinition()}, req.AIAgent.MaxSteps, e.toolSearchExecutor(req.Conversation, req.AIAgent, agentAllowedTools, skillContext.AllowedToolCodes, toolPolicy, &toolCalls))
if loopErr != nil {
if len(toolCalls) == 0 {
err := loopErr
_, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err)
return nil, err
}
responsePolicy = autonomousToolFailurePolicy(req.AIAgent, "tool_loop_error")
result = &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}
}
if result == nil && loopResult != nil {
result = &loopResult.ChatCompletionResult
}
if autonomousHasConsecutiveToolFailures(toolCalls, 2) {
responsePolicy = autonomousToolFailurePolicy(req.AIAgent, "tool_consecutive_failures")
result = &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}
}
} else {
result, err = e.chat(ctx, req.AIConfig, systemPrompt, userPrompt)
}
if err != nil {
_, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err)
return nil, err
}
if result == nil || strings.TrimSpace(result.Content) == "" {
err = errorsx.InvalidParam("autonomous engine returned an empty reply")
_, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err)
return nil, err
}
result.Content, err = aitooling.NormalizeCustomerReply(result.Content)
if err != nil {
_, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err)
return nil, err
}
runID, recordErr := writeAutonomousRun(req, startedAt, result, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, nil)
if recordErr != nil {
return nil, recordErr
}
trace, _ := json.Marshal(map[string]any{
"engine": EngineCodeAutonomous,
"mode": autonomousExecutionMode(allowedTools),
"historyMessageCount": historyCount,
"retrieverCount": retrieverCount,
"skillID": skillContext.SkillID(),
"skillRouteError": skillContext.ErrorMessage,
"responsePolicyAction": responsePolicy.Action,
"debug": req.Debug,
})
return &Summary{
Status: "completed",
ReplyText: strings.TrimSpace(result.Content),
ModelName: result.ModelName,
PromptTokens: result.PromptTokens,
CompletionTokens: result.CompletionTokens,
HistoryMessageCount: historyCount,
RetrieverCount: retrieverCount,
PlannedSkillID: skillContext.SkillID(),
PlannedSkillName: skillContext.SkillName(),
PlanReason: skillContext.MatchReason,
SkillRouteTrace: skillContext.TraceData,
SkillAllowedToolCodes: append([]string(nil), skillContext.AllowedToolCodes...),
AgentRunID: runID,
HandoffRequested: responsePolicy.RequestHandoff && !req.Debug,
TraceData: string(trace),
}, nil
}
func (e *AutonomousEngine) buildUserPrompt(req Request) (string, int) {
limit := req.AIAgent.ContextWindow
if limit <= 0 {
limit = 12
}
if limit > 20 {
limit = 20
}
items := []models.Message(nil)
if e.history != nil && req.Conversation.ID > 0 {
// The triggering customer message is already persisted in most reply
// paths. Fetch one extra item so it does not consume history capacity.
items = e.history(req.Conversation.ID, limit+1)
}
lines := make([]string, 0, len(items)+2)
for _, item := range items {
if item.ID == req.UserMessage.ID || strings.TrimSpace(item.Content) == "" {
continue
}
role := autonomousMessageRole(item)
if role == "" {
continue
}
lines = append(lines, role+": "+utils.BuildRuntimeMessageText(item.MessageType, item.Content))
}
if len(lines) > limit {
lines = lines[len(lines)-limit:]
}
current := strings.TrimSpace(req.UserMessage.Content)
customerContext := buildAutonomousCustomerContext(req.Conversation)
if len(lines) == 0 && customerContext == "" {
return current, 0
}
parts := make([]string, 0, 3)
if customerContext != "" {
parts = append(parts, "Customer context:\n"+customerContext)
}
if len(lines) > 0 {
parts = append(parts, "Conversation history:\n"+strings.Join(lines, "\n"))
}
parts = append(parts, "Current customer message:\n"+current)
return strings.Join(parts, "\n\n"), len(lines)
}
func buildAutonomousCustomerContext(conversation models.Conversation) string {
parts := make([]string, 0, 2)
if name := strings.TrimSpace(conversation.CustomerName); name != "" {
parts = append(parts, "Customer: "+name)
}
if summary := strings.TrimSpace(conversation.LastMessageSummary); summary != "" {
parts = append(parts, "Recent summary: "+summary)
}
return strings.Join(parts, "\n")
}
func autonomousMessageRole(message models.Message) string {
switch message.SenderType {
case "customer":
return "Customer"
case "ai", "agent":
return "Assistant"
default:
return ""
}
}
func (e *AutonomousEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
return nil, errorsx.InvalidParam("autonomous agent has no resumable checkpoint")
}
func (e *AutonomousEngine) retrieveKnowledge(ctx context.Context, agent models.AIAgent, query string) (string, int, error) {
if e.retrieve == nil || len(utils.SplitInt64s(agent.KnowledgeIDs)) == 0 {
return "", 0, nil
}
return e.retrieve(ctx, agent, query)
}
type autonomousSkillContext struct {
Skill *models.SkillDefinition
MatchReason string
TraceData string
ErrorMessage string
AllowedToolCodes []string
}
type autonomousResponsePolicy struct {
Enforced bool
Action string
Reason string
ReplyText string
RequestHandoff bool
}
func evaluateAutonomousResponsePolicy(agent models.AIAgent, knowledgeContext string, retrieveErr error) autonomousResponsePolicy {
if len(utils.SplitInt64s(agent.KnowledgeIDs)) == 0 || strings.TrimSpace(knowledgeContext) != "" && retrieveErr == nil {
return autonomousResponsePolicy{}
}
if retrieveErr != nil {
return autonomousKnowledgeFallbackPolicy(agent, "knowledge_retrieve_error")
}
return autonomousKnowledgeFallbackPolicy(agent, "knowledge_evidence_missing")
}
func autonomousKnowledgeFallbackPolicy(agent models.AIAgent, reason string) autonomousResponsePolicy {
if agent.FallbackMode == enums.AIAgentFallbackModeHandoff {
return autonomousResponsePolicy{
Enforced: true, Action: "handoff", Reason: reason, RequestHandoff: true,
ReplyText: autonomousKnowledgeFallbackReply(agent),
}
}
return autonomousResponsePolicy{
Enforced: true, Action: "clarify", Reason: reason,
ReplyText: autonomousKnowledgeFallbackReply(agent),
}
}
func autonomousToolFailurePolicy(agent models.AIAgent, reason string) autonomousResponsePolicy {
if agent.FallbackMode == enums.AIAgentFallbackModeHandoff {
return autonomousResponsePolicy{
Enforced: true, Action: "handoff", Reason: reason, RequestHandoff: true,
ReplyText: autonomousToolFailureReply(agent),
}
}
return autonomousResponsePolicy{
Enforced: true, Action: "clarify", Reason: reason,
ReplyText: autonomousToolFailureReply(agent),
}
}
func autonomousToolFailureReply(agent models.AIAgent) string {
if reply := strings.TrimSpace(agent.FallbackMessage); reply != "" {
return reply
}
if agent.FallbackMode == enums.AIAgentFallbackModeHandoff {
return "暂时无法完成所需查询,正在为你转接人工客服。"
}
return "暂时无法完成所需查询,请补充更具体的信息后再试一次。"
}
func autonomousHasConsecutiveToolFailures(calls []svc.EngineToolCallInput, minimum int) bool {
if minimum <= 0 {
return false
}
failures := 0
for index := len(calls) - 1; index >= 0; index-- {
if calls[index].Status != "failed" {
break
}
failures++
}
return failures >= minimum
}
func autonomousKnowledgeFallbackReply(agent models.AIAgent) string {
if reply := strings.TrimSpace(agent.FallbackMessage); reply != "" {
return reply
}
if agent.FallbackMode == 0 || agent.FallbackMode == enums.AIAgentFallbackModeSuggestRetry {
return "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。"
}
if agent.FallbackMode == enums.AIAgentFallbackModeHandoff {
return "当前知识库没有足够明确的信息,正在为你转接人工客服。"
}
return "当前知识库暂无明确信息。"
}
func (c autonomousSkillContext) SkillID() int64 {
if c.Skill == nil {
return 0
}
return c.Skill.ID
}
func (c autonomousSkillContext) SkillName() string {
if c.Skill == nil {
return ""
}
return strings.TrimSpace(c.Skill.Name)
}
func (e *AutonomousEngine) selectSkill(ctx context.Context, req Request) autonomousSkillContext {
if e.skillSelect == nil || len(utils.SplitInt64s(req.AIAgent.SkillIDs)) == 0 {
return autonomousSkillContext{}
}
result, err := e.skillSelect(ctx, skills.RuntimeContext{
AIAgent: req.AIAgent, AIConfig: req.AIConfig, UserMessage: req.UserMessage.Content, ConversationID: req.Conversation.ID,
})
ret := autonomousSkillContext{}
if err != nil {
ret.ErrorMessage = err.Error()
return ret
}
if result == nil || result.Plan == nil {
return ret
}
ret.Skill = result.Plan.Skill
ret.MatchReason = strings.TrimSpace(result.Plan.MatchReason)
if result.Trace != nil {
data, _ := json.Marshal(result.Trace)
ret.TraceData = string(data)
}
if ret.Skill != nil {
ret.AllowedToolCodes = parseSkillToolWhitelist(ret.Skill.ToolWhitelist)
}
return ret
}
func parseSkillToolWhitelist(raw string) []string {
var items []string
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &items) != nil {
return nil
}
ret := make([]string, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
item = toolx.NormalizeToolCodeAlias(strings.TrimSpace(item))
if item == "" {
continue
}
if _, exists := seen[item]; exists {
continue
}
seen[item] = struct{}{}
ret = append(ret, item)
}
return ret
}
func intersectAutonomousToolCodes(agentAllowed, skillAllowed []string) []string {
if len(agentAllowed) == 0 || len(skillAllowed) == 0 {
return nil
}
allowed := make(map[string]struct{}, len(skillAllowed))
for _, item := range skillAllowed {
allowed[toolx.NormalizeToolCodeAlias(strings.TrimSpace(item))] = struct{}{}
}
ret := make([]string, 0, len(agentAllowed))
for _, item := range agentAllowed {
item = toolx.NormalizeToolCodeAlias(strings.TrimSpace(item))
if _, ok := allowed[item]; ok {
ret = append(ret, item)
}
}
return ret
}
type autonomousDirectTool struct {
ToolCode string `json:"toolCode"`
}
type autonomousToolSearchRequest struct {
ToolCode string `json:"toolCode"`
Arguments map[string]any `json:"arguments"`
}
type autonomousToolPolicy struct {
MaxTotalCalls int `json:"maxTotalCalls"`
MaxArgumentBytes int `json:"maxArgumentBytes"`
AllowedRiskLevels []string `json:"allowedRiskLevels"`
}
func parseAutonomousToolPolicy(raw string) autonomousToolPolicy {
policy := autonomousToolPolicy{MaxTotalCalls: 3, MaxArgumentBytes: 32 * 1024}
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &policy) != nil {
return policy
}
if policy.MaxTotalCalls <= 0 || policy.MaxTotalCalls > 8 {
policy.MaxTotalCalls = 3
}
if policy.MaxArgumentBytes <= 0 || policy.MaxArgumentBytes > 64*1024 {
policy.MaxArgumentBytes = 32 * 1024
}
return policy
}
func autonomousAllowedMCPToolCodes(raw string) []string {
var items []autonomousDirectTool
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &items) != nil {
return nil
}
ret := make([]string, 0, len(items))
for _, item := range items {
if code := strings.TrimSpace(item.ToolCode); code != "" {
ret = append(ret, code)
}
}
return ret
}
func autonomousToolSearchDefinition() ai.ToolDefinition {
return ai.ToolDefinition{
Name: "tool_search",
Description: "Use a configured read-only tool only when it is needed to answer the customer. Pass the exact allowed toolCode and an arguments object.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"toolCode": map[string]any{"type": "string"},
"arguments": map[string]any{"type": "object"},
},
"required": []string{"toolCode", "arguments"},
},
}
}
func (e *AutonomousEngine) toolSearchExecutor(conversation models.Conversation, agent models.AIAgent, allowedCodes, skillAllowedCodes []string, toolPolicy autonomousToolPolicy, records *[]svc.EngineToolCallInput) ai.ToolCallExecutor {
return func(ctx context.Context, call ai.ToolCall) (string, error) {
startedAt := time.Now()
if call.Name != "tool_search" {
return "", fmt.Errorf("unsupported autonomous tool: %s", call.Name)
}
var req autonomousToolSearchRequest
if err := json.Unmarshal([]byte(call.Arguments), &req); err != nil {
return "", fmt.Errorf("invalid tool_search arguments: %w", err)
}
policy := aitooling.Policy{
AllowedToolCodes: allowedCodes, SkillAllowedToolCodes: skillAllowedCodes, AllowedRiskLevels: toolPolicy.AllowedRiskLevels,
CallCount: autonomousToolCallCount(*records, req.ToolCode),
TotalCallCount: len(*records),
MaxTotalCalls: toolPolicy.MaxTotalCalls,
MaxArgumentBytes: toolPolicy.MaxArgumentBytes,
Confirmed: true, // The Agent's persisted allow-list is the administrator approval boundary.
}
definition, resultPreview, err := executeAutonomousReadTool(ctx, conversation, agent, strings.TrimSpace(req.ToolCode), req.Arguments, policy)
if err != nil && definition.Code == "" {
mcpDefinition, result, mcpErr := aitooling.DefaultMCPExecutor.Execute(ctx, strings.TrimSpace(req.ToolCode), req.Arguments, policy)
definition, err = mcpDefinition, mcpErr
resultPreview = runtimetooling.BuildReducedToolResultSummary(result)
}
durationMS := int(time.Since(startedAt).Milliseconds())
record := svc.EngineToolCallInput{
ToolCode: strings.TrimSpace(req.ToolCode), Status: "completed", ArgumentsPreview: aitooling.SanitizePreview(call.Arguments), DurationMS: durationMS,
}
if definition.Code != "" {
record.ToolCode = definition.Code
record.RiskLevel = definition.RiskLevel
record.RequireConfirm = definition.RequireConfirmation
}
if err != nil {
record.Status = "failed"
record.ErrorMessage = err.Error()
*records = append(*records, record)
return "", err
}
record.ResultPreview = aitooling.SanitizePreview(resultPreview)
*records = append(*records, record)
return record.ResultPreview, nil
}
}
func executeAutonomousReadTool(ctx context.Context, conversation models.Conversation, agent models.AIAgent, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, string, error) {
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
if toolCode != toolx.BuiltinConversationContext.Code && toolCode != toolx.BuiltinKnowledgeRetrieve.Code && toolCode != toolx.GraphTriageServiceRequest.Code && toolCode != toolx.GraphAnalyzeConversation.Code && toolCode != toolx.GraphPrepareTicketDraft.Code {
return aitooling.Definition{}, "", fmt.Errorf("tool is not a built-in read tool")
}
if toolCode == toolx.GraphTriageServiceRequest.Code || toolCode == toolx.GraphAnalyzeConversation.Code || toolCode == toolx.GraphPrepareTicketDraft.Code {
return readtools.ExecuteGraphTool(ctx, conversation, toolCode, arguments, policy)
}
definition, err := aitooling.DefaultRegistry.Resolve(toolCode)
if err != nil {
return aitooling.Definition{}, "", err
}
if err := aitooling.DefaultRegistry.Authorize(definition, policy); err != nil {
return definition, "", err
}
if definition.TimeoutMS > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond)
defer cancel()
}
if toolCode == toolx.BuiltinKnowledgeRetrieve.Code {
query, _ := arguments["query"].(string)
contextText, count, err := retrieveAutonomousKnowledge(ctx, agent, query)
if err != nil {
return definition, "", err
}
result, err := json.Marshal(map[string]any{"query": strings.TrimSpace(query), "resultCount": count, "context": contextText})
return definition, string(result), err
}
result, err := json.Marshal(map[string]any{
"conversationId": conversation.ID,
"customerName": strings.TrimSpace(conversation.CustomerName),
"lastMessageSummary": strings.TrimSpace(conversation.LastMessageSummary),
"currentAssigneeId": conversation.CurrentAssigneeID,
"recentMessages": autonomousToolConversationMessages(conversation.ID),
})
if err != nil {
return definition, "", err
}
return definition, string(result), nil
}
func autonomousToolConversationMessages(conversationID int64) []map[string]string {
if conversationID <= 0 {
return []map[string]string{}
}
items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, 6, "", "")
ret := make([]map[string]string, 0, len(items))
for _, item := range items {
role := autonomousMessageRole(item)
content := strings.TrimSpace(utils.BuildRuntimeMessageText(item.MessageType, item.Content))
if role == "" || content == "" {
continue
}
if runes := []rune(content); len(runes) > 240 {
content = string(runes[:240]) + "..."
}
ret = append(ret, map[string]string{"role": role, "content": content})
}
return ret
}
func autonomousToolCallCount(records []svc.EngineToolCallInput, toolCode string) int {
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
count := 0
for _, item := range records {
if toolx.NormalizeToolCodeAlias(strings.TrimSpace(item.ToolCode)) == toolCode {
count++
}
}
return count
}
func retrieveAutonomousKnowledge(ctx context.Context, agent models.AIAgent, query string) (string, int, error) {
retrieved, err := retrievers.NewKnowledgeRetriever(agent, utils.SplitInt64s(agent.KnowledgeIDs)).RetrieveContext(ctx, query)
if err != nil {
return "", 0, err
}
if retrieved == nil {
return "", 0, nil
}
return strings.TrimSpace(retrieved.ContextText), len(retrieved.ContextResults), nil
}
func buildAutonomousSystemPrompt(agent models.AIAgent, hasKnowledgeBase bool, knowledgeContext string, retrieveErr error) string {
prompt := strings.TrimSpace(agent.SystemPrompt)
if prompt == "" {
prompt = "You are a customer service assistant. Answer accurately, ask for clarification when evidence is insufficient, and do not invent facts."
}
if hasKnowledgeBase && strings.TrimSpace(knowledgeContext) == "" {
prompt += "\n\nNo supporting knowledge was retrieved. Do not invent an answer; ask a focused clarification question or offer human handoff."
}
if retrieveErr != nil {
prompt += "\n\nKnowledge retrieval is temporarily unavailable. Do not claim to have verified any policy or factual detail."
}
return prompt
}
func writeAutonomousRun(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount int, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, responsePolicy autonomousResponsePolicy, toolCalls []svc.EngineToolCallInput, cause error) (int64, error) {
endedAt := time.Now()
status := "completed"
errorMessage := ""
outputPreview := ""
promptTokens := 0
completionTokens := 0
if cause != nil {
status = "failed"
errorMessage = cause.Error()
} else if result != nil {
outputPreview = strings.TrimSpace(result.Content)
promptTokens = result.PromptTokens
completionTokens = result.CompletionTokens
}
trace, _ := json.Marshal(map[string]any{"engine": EngineCodeAutonomous, "mode": autonomousExecutionMode(autonomousAllowedMCPToolCodes(req.AIAgent.AllowedMCPTools)), "status": status, "historyMessageCount": historyCount, "retrieverCount": retrieverCount})
var runID int64
err := sqls.WithTransaction(func(tx *sqls.TxContext) error {
var recordErr error
runID, recordErr = svc.AgentRunService.RecordEngineRun(tx.Tx, svc.EngineAgentRunInput{
ConversationID: req.Conversation.ID, AIAgentID: req.AIAgent.ID, AgentRevisionID: req.AIAgent.PublishedRevisionID,
SourceMessageID: req.UserMessage.ID, EngineCode: EngineCodeAutonomous, Status: status,
PromptTokens: promptTokens, CompletionTokens: completionTokens, StartedAt: startedAt, EndedAt: &endedAt,
ErrorMessage: errorMessage, TraceData: string(trace), StepType: "model", StepCode: "chat_completion",
StepInputPreview: strings.TrimSpace(inputPreview), StepOutputPreview: outputPreview,
AdditionalSteps: autonomousAdditionalSteps(req, retrieverCount, retrieveErr, skillContext, responsePolicy),
ToolCalls: toolCalls,
})
return recordErr
})
return runID, err
}
func autonomousExecutionMode(allowedTools []string) string {
if len(allowedTools) > 0 {
return "tool_calling_loop"
}
return "single_model_turn"
}
func autonomousAdditionalSteps(req Request, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, responsePolicy autonomousResponsePolicy) []svc.EngineStepInput {
steps := make([]svc.EngineStepInput, 0, 3)
if len(utils.SplitInt64s(req.AIAgent.SkillIDs)) > 0 {
status := "completed"
if skillContext.ErrorMessage != "" {
status = "failed"
}
steps = append(steps, svc.EngineStepInput{
StepType: "skill_route", StepCode: "skill_select", Status: status,
InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "selected skill: " + skillContext.SkillName(),
ErrorMessage: skillContext.ErrorMessage,
})
}
if len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0 {
status := "completed"
errorMessage := ""
if retrieveErr != nil {
status = "failed"
errorMessage = retrieveErr.Error()
}
steps = append(steps, svc.EngineStepInput{
StepType: "knowledge", StepCode: "knowledge_retrieve", Status: status,
InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "retrieved context items: " + strconv.Itoa(retrieverCount), ErrorMessage: errorMessage,
})
}
if responsePolicy.Enforced {
policyCode := "knowledge_evidence"
if strings.HasPrefix(responsePolicy.Reason, "tool_") {
policyCode = "tool_failure"
}
steps = append(steps, svc.EngineStepInput{
StepType: "policy", StepCode: policyCode, Status: "completed",
InputPreview: responsePolicy.Reason, OutputPreview: responsePolicy.Action,
})
}
return steps
}
var _ Engine = (*AutonomousEngine)(nil)
@@ -0,0 +1,102 @@
package einoexperiment
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
applicationruntime "agent-desk/internal/ai/application/runtime"
"agent-desk/internal/ai/runtime/graphs"
)
const confirmationInterruptType = "human_confirm"
// ConfirmationRequest represents a high-risk Eino tool action that must pause
// at AgentDesk's existing conversation-interrupt boundary.
type ConfirmationRequest struct {
InterruptID string
ToolCode string
Prompt string
Arguments map[string]any
}
type confirmationCheckpoint struct {
Version int `json:"version"`
Engine string `json:"engine"`
InterruptID string `json:"interruptId"`
ToolCode string `json:"toolCode"`
Arguments map[string]any `json:"arguments"`
}
// BuildConfirmationResult returns the generic interrupted result consumed by
// replyInterruptService. That service persists ConversationInterrupt from the
// result, so this package remains independent of database writes.
func BuildConfirmationResult(input applicationruntime.RunInput, request ConfirmationRequest) (*applicationruntime.RunResult, error) {
interruptID := strings.TrimSpace(request.InterruptID)
if interruptID == "" {
interruptID = "eino_confirm"
}
toolCode := strings.TrimSpace(request.ToolCode)
if toolCode == "" {
return nil, fmt.Errorf("confirmation tool code is required")
}
prompt := strings.TrimSpace(request.Prompt)
if prompt == "" {
return nil, fmt.Errorf("confirmation prompt is required")
}
checkpointData, err := json.Marshal(confirmationCheckpoint{
Version: 1, Engine: "eino", InterruptID: interruptID, ToolCode: toolCode, Arguments: cloneConfirmationArguments(request.Arguments),
})
if err != nil {
return nil, fmt.Errorf("encode Eino confirmation checkpoint: %w", err)
}
return &applicationruntime.RunResult{
Status: "interrupted",
Interrupted: true,
CheckPointID: confirmationCheckpointID(input, interruptID, toolCode, checkpointData),
CheckPointData: string(checkpointData),
Interrupts: []applicationruntime.InterruptContextSummary{{
Type: confirmationInterruptType, ID: interruptID, InfoPreview: string(mustMarshalConfirmationPrompt(prompt)),
}},
}, nil
}
// ResumeConfirmation reads the generic ResumeInput populated by the existing
// AgentApplicationService and validates that it belongs to this checkpoint.
func ResumeConfirmation(checkPointData string, input applicationruntime.ResumeInput) (string, confirmationCheckpoint, error) {
checkpoint := confirmationCheckpoint{}
if err := json.Unmarshal([]byte(strings.TrimSpace(checkPointData)), &checkpoint); err != nil {
return "", checkpoint, fmt.Errorf("decode Eino confirmation checkpoint: %w", err)
}
if checkpoint.Version != 1 || checkpoint.Engine != "eino" || strings.TrimSpace(checkpoint.InterruptID) == "" || strings.TrimSpace(checkpoint.ToolCode) == "" {
return "", checkpoint, fmt.Errorf("invalid Eino confirmation checkpoint")
}
decision := graphs.ParseConfirmationDecision(strings.TrimSpace(input.ResumeData[checkpoint.InterruptID]))
if decision == "" {
return "", checkpoint, fmt.Errorf("Eino confirmation decision is required")
}
return string(decision), checkpoint, nil
}
func confirmationCheckpointID(input applicationruntime.RunInput, interruptID, toolCode string, data []byte) string {
digest := sha256.Sum256(append([]byte(strings.TrimSpace(toolCode)+":"+strings.TrimSpace(interruptID)+":"), data...))
return fmt.Sprintf("eino:%d:%d:%s", input.Conversation.ID, input.UserMessage.ID, hex.EncodeToString(digest[:8]))
}
func cloneConfirmationArguments(input map[string]any) map[string]any {
if len(input) == 0 {
return map[string]any{}
}
ret := make(map[string]any, len(input))
for key, value := range input {
ret[key] = value
}
return ret
}
func mustMarshalConfirmationPrompt(prompt string) []byte {
data, _ := json.Marshal(map[string]string{"message": prompt})
return data
}
@@ -0,0 +1,94 @@
package einoexperiment
import (
"context"
"encoding/json"
"fmt"
"time"
aitooling "agent-desk/internal/ai/tooling"
einotool "github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
// ToolHandler is the adapter point from an approved Eino experiment tool to
// AgentDesk business services. Production handlers must still call services,
// never repositories.
type ToolHandler func(ctx context.Context, arguments map[string]any) (string, error)
// ToolTrace is emitted for every guarded invocation. A future Engine adapter
// can translate it into AgentRun tool-call audit records without coupling this
// experiment package to the service layer.
type ToolTrace struct {
ToolCode string
Arguments map[string]any
Status string
Result string
Err error
Duration time.Duration
}
type ToolTraceHook func(ToolTrace)
// GuardedTool adapts an Eino InvokableTool to the shared ToolPolicyGuard. It is
// deliberately generic so Tool Registry semantics are checked before a tool
// handler is invoked.
type GuardedTool struct {
InfoDefinition *schema.ToolInfo
Definition aitooling.Definition
Policy aitooling.Policy
Handler ToolHandler
Trace ToolTraceHook
}
var _ einotool.InvokableTool = (*GuardedTool)(nil)
func (t *GuardedTool) Info(context.Context) (*schema.ToolInfo, error) {
if t == nil || t.InfoDefinition == nil {
return nil, fmt.Errorf("eino experiment tool info is required")
}
return t.InfoDefinition, nil
}
func (t *GuardedTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...einotool.Option) (string, error) {
if t == nil || t.Handler == nil {
return "", fmt.Errorf("eino experiment tool handler is required")
}
startedAt := time.Now()
arguments := map[string]any{}
if err := json.Unmarshal([]byte(argumentsInJSON), &arguments); err != nil {
t.emitTrace(arguments, "failed", "", err, startedAt)
return "", fmt.Errorf("decode tool arguments: %w", err)
}
if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{
Definition: t.Definition,
Arguments: arguments,
Policy: t.Policy,
}); err != nil {
t.emitTrace(arguments, "failed", "", err, startedAt)
return "", err
}
if t.Definition.TimeoutMS > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(t.Definition.TimeoutMS)*time.Millisecond)
defer cancel()
}
result, err := t.Handler(ctx, arguments)
status := "completed"
if err != nil {
status = "failed"
}
t.emitTrace(arguments, status, result, err, startedAt)
return result, err
}
func (t *GuardedTool) emitTrace(arguments map[string]any, status, result string, err error, startedAt time.Time) {
if t == nil || t.Trace == nil {
return
}
t.Trace(ToolTrace{
ToolCode: t.Definition.Code, Arguments: arguments, Status: status, Result: result, Err: err,
Duration: time.Since(startedAt),
})
}
@@ -0,0 +1,37 @@
package einoexperiment
import (
"context"
"fmt"
"strings"
"agent-desk/internal/ai/mcps"
runtimetooling "agent-desk/internal/ai/runtime/tooling"
aitooling "agent-desk/internal/ai/tooling"
)
// MCPToolExecutor is the narrow execution boundary used by the Eino
// experiment. The production MCP executor remains responsible for dynamic
// registry resolution, policy enforcement, timeout, and transport lifecycle.
type MCPToolExecutor interface {
Execute(context.Context, string, map[string]any, aitooling.Policy) (aitooling.Definition, *mcps.ToolCallResult, error)
}
// NewMCPToolHandler adapts a dynamically discovered MCP tool to GuardedTool.
// Callers must still configure GuardedTool.Definition and Policy so its
// pre-handler guard provides a deterministic rejection before MCP transport.
func NewMCPToolHandler(executor MCPToolExecutor, toolCode string, policy aitooling.Policy) ToolHandler {
return func(ctx context.Context, arguments map[string]any) (string, error) {
if executor == nil {
return "", fmt.Errorf("eino experiment MCP executor is required")
}
definition, result, err := executor.Execute(ctx, strings.TrimSpace(toolCode), arguments, policy)
if err != nil {
return "", err
}
if definition.Code == "" {
return "", fmt.Errorf("MCP executor returned an empty tool definition")
}
return runtimetooling.BuildReducedToolResultSummary(result), nil
}
}
@@ -0,0 +1,86 @@
// Package einoexperiment contains an isolated Eino ReAct verification path.
// It must not be registered in the production Agent Engine registry.
package einoexperiment
import (
"context"
"fmt"
"strings"
"time"
"agent-desk/internal/models"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/flow/agent/react"
"github.com/cloudwego/eino/schema"
)
// ReActConfig keeps the experiment dependency-injected. The caller owns model
// construction, connection reuse, and all production configuration decisions.
type ReActConfig struct {
Model model.ToolCallingChatModel
Tools []tool.BaseTool
MaxSteps int
}
// NewOpenAICompatibleModel adapts an existing AgentDesk AI configuration to
// Eino's OpenAI-compatible chat model. It is intentionally not wired into any
// production Engine; the experiment owns the adoption decision.
func NewOpenAICompatibleModel(ctx context.Context, config models.AIConfig) (model.ToolCallingChatModel, error) {
if strings.TrimSpace(config.APIKey) == "" || strings.TrimSpace(config.BaseURL) == "" || strings.TrimSpace(config.ModelName) == "" {
return nil, fmt.Errorf("ai config base URL, API key, and model name are required")
}
modelConfig := &einoopenai.ChatModelConfig{
APIKey: strings.TrimSpace(config.APIKey),
BaseURL: strings.TrimSpace(config.BaseURL),
Model: strings.TrimSpace(config.ModelName),
}
if config.TimeoutMS > 0 {
modelConfig.Timeout = time.Duration(config.TimeoutMS) * time.Millisecond
}
if config.MaxOutputTokens > 0 {
maxTokens := config.MaxOutputTokens
modelConfig.MaxCompletionTokens = &maxTokens
}
return einoopenai.NewChatModel(ctx, modelConfig)
}
// NewReAct creates an Eino ReAct agent without registering it with AgentDesk's
// runtime. It is deliberately suitable only for technical verification.
func NewReAct(ctx context.Context, config ReActConfig) (*react.Agent, error) {
if config.Model == nil {
return nil, fmt.Errorf("eino experiment model is required")
}
maxSteps := config.MaxSteps
if maxSteps <= 0 {
maxSteps = 5
}
return react.NewAgent(ctx, &react.AgentConfig{
ToolCallingModel: config.Model,
ToolsConfig: compose.ToolsNodeConfig{Tools: config.Tools},
MaxStep: maxSteps,
})
}
// Run performs one non-streaming experiment. Context cancellation is passed
// directly to Eino and the injected model/tools.
func Run(ctx context.Context, config ReActConfig, input []*schema.Message) (*schema.Message, error) {
agent, err := NewReAct(ctx, config)
if err != nil {
return nil, err
}
return agent.Generate(ctx, input)
}
// Stream performs one streaming experiment. The caller must close the returned
// reader after consuming it.
func Stream(ctx context.Context, config ReActConfig, input []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
agent, err := NewReAct(ctx, config)
if err != nil {
return nil, err
}
return agent.Stream(ctx, input)
}
@@ -0,0 +1,376 @@
package einoexperiment
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
applicationruntime "agent-desk/internal/ai/application/runtime"
"agent-desk/internal/ai/mcps"
"agent-desk/internal/ai/runtime/graphs"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
type scriptedToolCallingModel struct {
responses []*schema.Message
calls int
err error
block bool
lastInput []*schema.Message
}
type fakeMCPToolExecutor struct {
toolCode string
arguments map[string]any
policy aitooling.Policy
result *mcps.ToolCallResult
err error
}
type concurrentToolCallingModel struct {
calls atomic.Int32
}
var _ model.ToolCallingChatModel = (*concurrentToolCallingModel)(nil)
func (m *concurrentToolCallingModel) Generate(ctx context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
m.calls.Add(1)
return schema.AssistantMessage("并发调用完成。", nil), nil
}
func (m *concurrentToolCallingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
message, err := m.Generate(ctx, input, opts...)
if err != nil {
return nil, err
}
return schema.StreamReaderFromArray([]*schema.Message{message}), nil
}
func (m *concurrentToolCallingModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
return m, nil
}
func (e *fakeMCPToolExecutor) Execute(_ context.Context, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, *mcps.ToolCallResult, error) {
e.toolCode = toolCode
e.arguments = arguments
e.policy = policy
return aitooling.Definition{Code: toolCode, RiskLevel: aitooling.RiskLevelSensitive}, e.result, e.err
}
var _ model.ToolCallingChatModel = (*scriptedToolCallingModel)(nil)
func (m *scriptedToolCallingModel) Generate(ctx context.Context, input []*schema.Message, _ ...model.Option) (*schema.Message, error) {
m.lastInput = append([]*schema.Message(nil), input...)
if err := ctx.Err(); err != nil {
return nil, err
}
if m.block {
<-ctx.Done()
return nil, ctx.Err()
}
if m.err != nil {
return nil, m.err
}
if m.calls >= len(m.responses) {
return nil, errors.New("unexpected model call")
}
result := m.responses[m.calls]
m.calls++
return result, nil
}
func (m *scriptedToolCallingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
message, err := m.Generate(ctx, input, opts...)
if err != nil {
return nil, err
}
return schema.StreamReaderFromArray([]*schema.Message{message}), nil
}
func (m *scriptedToolCallingModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
return m, nil
}
func TestRunExecutesGuardedToolThenReturnsFinalAnswer(t *testing.T) {
called := false
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "customer_lookup", Desc: "Read customer data"},
Definition: aitooling.Definition{Code: "builtin/customer_lookup", RiskLevel: aitooling.RiskLevelRead},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/customer_lookup"}},
Handler: func(_ context.Context, arguments map[string]any) (string, error) {
called = arguments["customerId"] == "42"
return "customer: Ada", nil
},
}
model := &scriptedToolCallingModel{responses: []*schema.Message{
schema.AssistantMessage("", []schema.ToolCall{{ID: "call-1", Type: "function", Function: schema.FunctionCall{Name: "customer_lookup", Arguments: `{"customerId":"42"}`}}}),
schema.AssistantMessage("已找到客户资料。", nil),
}}
result, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}, MaxSteps: 4}, []*schema.Message{schema.UserMessage("查询客户")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if !called || result == nil || result.Content != "已找到客户资料。" || model.calls != 2 {
t.Fatalf("unexpected ReAct result: called=%t result=%#v modelCalls=%d", called, result, model.calls)
}
}
func TestRunInjectsProvidedConversationContext(t *testing.T) {
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("已理解上下文。", nil)}}
input := []*schema.Message{
schema.SystemMessage("你是客服助手,优先引用知识库。"),
schema.UserMessage("我的订单状态如何?"),
}
if _, err := Run(context.Background(), ReActConfig{Model: model}, input); err != nil {
t.Fatalf("Run: %v", err)
}
if len(model.lastInput) != len(input) || model.lastInput[0].Content != input[0].Content || model.lastInput[1].Content != input[1].Content {
t.Fatalf("conversation context was not passed to model: %#v", model.lastInput)
}
}
func TestNewOpenAICompatibleModelValidatesExistingAIConfig(t *testing.T) {
if _, err := NewOpenAICompatibleModel(context.Background(), models.AIConfig{}); err == nil {
t.Fatal("expected incomplete AI config error")
}
configured, err := NewOpenAICompatibleModel(context.Background(), models.AIConfig{
BaseURL: "https://api.example.test/v1", APIKey: "test-key", ModelName: "test-model", TimeoutMS: 1200, MaxOutputTokens: 256,
})
if err != nil || configured == nil {
t.Fatalf("expected OpenAI-compatible model adapter, model=%#v err=%v", configured, err)
}
}
func TestGuardedToolRejectsDisallowedPolicyBeforeHandler(t *testing.T) {
called := false
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "restricted_lookup", Desc: "Read restricted data"},
Definition: aitooling.Definition{Code: "builtin/restricted_lookup", RiskLevel: aitooling.RiskLevelRead},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/customer_lookup"}},
Handler: func(context.Context, map[string]any) (string, error) {
called = true
return "unexpected", nil
},
}
if _, err := guardedTool.InvokableRun(context.Background(), `{}`); err == nil {
t.Fatal("expected policy rejection")
}
if called {
t.Fatal("handler must not run after policy rejection")
}
}
func TestRunPropagatesCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("unused", nil)}}
if _, err := Run(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.Canceled) {
t.Fatalf("expected cancellation, got %v", err)
}
}
func TestRunPropagatesDeadlineDuringModelCall(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
model := &scriptedToolCallingModel{block: true}
if _, err := Run(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected deadline propagation, got %v", err)
}
}
func TestRunPropagatesModelFailure(t *testing.T) {
modelErr := errors.New("model unavailable")
model := &scriptedToolCallingModel{err: modelErr}
if _, err := Run(context.Background(), ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, modelErr) {
t.Fatalf("expected model error propagation, got %v", err)
}
}
func TestRunPropagatesToolFailure(t *testing.T) {
toolErr := errors.New("customer service unavailable")
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "failing_lookup", Desc: "Read customer data"},
Definition: aitooling.Definition{Code: "builtin/failing_lookup", RiskLevel: aitooling.RiskLevelRead},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/failing_lookup"}},
Handler: func(context.Context, map[string]any) (string, error) {
return "", toolErr
},
}
model := &scriptedToolCallingModel{responses: []*schema.Message{
schema.AssistantMessage("", []schema.ToolCall{{ID: "call-1", Type: "function", Function: schema.FunctionCall{Name: "failing_lookup", Arguments: `{}`}}}),
}}
if _, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, toolErr) {
t.Fatalf("expected tool error propagation, got %v", err)
}
}
func TestGuardedToolEnforcesTimeout(t *testing.T) {
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "slow_lookup", Desc: "Read customer data"},
Definition: aitooling.Definition{Code: "builtin/slow_lookup", RiskLevel: aitooling.RiskLevelRead, TimeoutMS: 20},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/slow_lookup"}},
Handler: func(ctx context.Context, _ map[string]any) (string, error) {
<-ctx.Done()
return "", ctx.Err()
},
}
if _, err := guardedTool.InvokableRun(context.Background(), `{}`); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected tool timeout, got %v", err)
}
}
func TestGuardedToolEmitsTraceForPolicyFailure(t *testing.T) {
var trace ToolTrace
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "restricted_lookup", Desc: "Read restricted data"},
Definition: aitooling.Definition{Code: "builtin/restricted_lookup", RiskLevel: aitooling.RiskLevelRead},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/other_lookup"}},
Handler: func(context.Context, map[string]any) (string, error) {
return "unexpected", nil
},
Trace: func(item ToolTrace) { trace = item },
}
if _, err := guardedTool.InvokableRun(context.Background(), `{"customerId":"42"}`); err == nil {
t.Fatal("expected policy rejection")
}
if trace.ToolCode != "builtin/restricted_lookup" || trace.Status != "failed" || trace.Err == nil || trace.Arguments["customerId"] != "42" || trace.Duration < 0 {
t.Fatalf("unexpected trace: %#v", trace)
}
}
func TestMCPToolHandlerUsesSharedExecutorAndReducesResult(t *testing.T) {
executor := &fakeMCPToolExecutor{result: &mcps.ToolCallResult{Content: []mcps.ToolResultContent{{Type: "text", Text: "customer: Ada"}}}}
policy := aitooling.Policy{AllowedToolCodes: []string{"crm/customer_lookup"}, Confirmed: true}
handler := NewMCPToolHandler(executor, "crm/customer_lookup", policy)
result, err := handler(context.Background(), map[string]any{"customerId": "42"})
if err != nil || result != "customer: Ada" {
t.Fatalf("unexpected MCP handler result=%q err=%v", result, err)
}
if executor.toolCode != "crm/customer_lookup" || executor.arguments["customerId"] != "42" || !executor.policy.Confirmed {
t.Fatalf("unexpected MCP execution: %#v", executor)
}
}
func TestRunStopsAtConfiguredMaxSteps(t *testing.T) {
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "loop_lookup", Desc: "Read loop data"},
Definition: aitooling.Definition{Code: "builtin/loop_lookup", RiskLevel: aitooling.RiskLevelRead},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/loop_lookup"}},
Handler: func(context.Context, map[string]any) (string, error) {
return "keep going", nil
},
}
responses := make([]*schema.Message, 8)
for i := range responses {
responses[i] = schema.AssistantMessage("", []schema.ToolCall{{
ID: "loop-call", Type: "function", Function: schema.FunctionCall{Name: "loop_lookup", Arguments: `{}`},
}})
}
model := &scriptedToolCallingModel{responses: responses}
if _, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}, MaxSteps: 2}, []*schema.Message{schema.UserMessage("循环查询")}); err == nil {
t.Fatal("expected configured maximum step limit to stop the loop")
}
}
func TestStreamReturnsModelOutput(t *testing.T) {
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("流式回复", nil)}}
stream, err := Stream(context.Background(), ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")})
if err != nil {
t.Fatalf("Stream: %v", err)
}
defer stream.Close()
result, err := schema.ConcatMessageStream(stream)
if err != nil {
t.Fatalf("ConcatMessageStream: %v", err)
}
if result.Content != "流式回复" {
t.Fatalf("unexpected stream result: %#v", result)
}
}
func TestStreamPropagatesCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("unused", nil)}}
if _, err := Stream(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.Canceled) {
t.Fatalf("expected stream cancellation, got %v", err)
}
}
func TestRunSupportsConcurrentIndependentCalls(t *testing.T) {
model := &concurrentToolCallingModel{}
const workers = 16
errs := make(chan error, workers)
var group sync.WaitGroup
for range workers {
group.Add(1)
go func() {
defer group.Done()
result, err := Run(context.Background(), ReActConfig{Model: model, MaxSteps: 3}, []*schema.Message{schema.UserMessage("并发查询")})
if err != nil {
errs <- err
return
}
if result == nil || result.Content != "并发调用完成。" {
errs <- errors.New("unexpected concurrent result")
}
}()
}
group.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
if model.calls.Load() != workers {
t.Fatalf("model calls = %d, want %d", model.calls.Load(), workers)
}
}
func TestConfirmationBridgeUsesGenericInterruptAndResumeContracts(t *testing.T) {
input := applicationruntime.RunInput{
Conversation: models.Conversation{ID: 11}, UserMessage: models.Message{ID: 22},
}
result, err := BuildConfirmationResult(input, ConfirmationRequest{
InterruptID: "confirm_refund", ToolCode: "graph/create_ticket_with_confirmation", Prompt: "是否确认提交退款工单?",
Arguments: map[string]any{"title": "退款申请"},
})
if err != nil {
t.Fatalf("BuildConfirmationResult: %v", err)
}
if !result.Interrupted || result.Status != "interrupted" || result.CheckPointID == "" || len(result.Interrupts) != 1 || result.Interrupts[0].Type != confirmationInterruptType || result.Interrupts[0].ID != "confirm_refund" {
t.Fatalf("unexpected confirmation result: %#v", result)
}
decision, checkpoint, err := ResumeConfirmation(result.CheckPointData, applicationruntime.ResumeInput{ResumeData: map[string]string{"confirm_refund": "确认"}})
if err != nil || decision != string(graphs.ConfirmationDecisionConfirm) || checkpoint.ToolCode != "graph/create_ticket_with_confirmation" || checkpoint.Arguments["title"] != "退款申请" {
t.Fatalf("unexpected resume bridge decision=%q checkpoint=%#v err=%v", decision, checkpoint, err)
}
decision, _, err = ResumeConfirmation(result.CheckPointData, applicationruntime.ResumeInput{ResumeData: map[string]string{"confirm_refund": "取消"}})
if err != nil || decision != string(graphs.ConfirmationDecisionCancel) {
t.Fatalf("unexpected cancellation decision=%q err=%v", decision, err)
}
}
func BenchmarkRunWithInjectedModel(b *testing.B) {
model := &concurrentToolCallingModel{}
input := []*schema.Message{schema.SystemMessage("你是客服助手。"), schema.UserMessage("查询订单状态")}
b.ReportAllocs()
b.ResetTimer()
for range b.N {
result, err := Run(context.Background(), ReActConfig{Model: model, MaxSteps: 3}, input)
if err != nil || result == nil || result.Content == "" {
b.Fatalf("Run result=%#v err=%v", result, err)
}
}
}
@@ -0,0 +1,141 @@
package einoexperiment
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-desk/internal/bootstrap"
"agent-desk/internal/models"
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/enums"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
einomodel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
// TestRealOpenAICompatibleEndpoint is intentionally opt-in because it spends
// a small amount of configured model quota. It verifies the production-shaped
// OpenAI-compatible adapter without exposing credentials in test output.
func TestRealOpenAICompatibleEndpoint(t *testing.T) {
if os.Getenv("EINO_EXPERIMENT_REAL") != "1" {
t.Skip("set EINO_EXPERIMENT_REAL=1 to run against the configured endpoint")
}
configPath := strings.TrimSpace(os.Getenv("EINO_EXPERIMENT_CONFIG"))
var err error
if configPath == "" {
configPath, err = findExperimentConfigPath()
if err != nil {
t.Fatal(err)
}
}
workingDir, err := os.Getwd()
if err != nil {
t.Fatalf("get working directory: %v", err)
}
repoRoot := filepath.Dir(filepath.Dir(configPath))
if err := os.Chdir(repoRoot); err != nil {
t.Fatalf("change to config root: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(workingDir) })
cfg, err := config.Load(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
db, err := bootstrap.InitDB(cfg.DB)
if err != nil {
t.Fatalf("open configured database: %v", err)
}
sqlDB, err := db.DB()
if err == nil {
defer sqlDB.Close()
}
var aiConfig models.AIConfig
if err := db.Where("model_type = ? AND status = ?", enums.AIModelTypeLLM, enums.StatusOk).Order("id").First(&aiConfig).Error; err != nil {
t.Fatalf("load enabled LLM config: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(maxInt(aiConfig.TimeoutMS, 30000))*time.Millisecond)
defer cancel()
model, err := NewOpenAICompatibleModel(ctx, aiConfig)
if err != nil {
t.Fatalf("create Eino model adapter: %v", err)
}
input := []*schema.Message{schema.SystemMessage("You are a terse service assistant."), schema.UserMessage("Reply with exactly: OK")}
startedAt := time.Now()
result, err := Run(ctx, ReActConfig{Model: model, MaxSteps: 2}, input)
if err != nil {
t.Fatalf("Eino ReAct request: %v", err)
}
if result == nil || strings.TrimSpace(result.Content) == "" {
t.Fatal("Eino endpoint returned an empty response")
}
if result.ResponseMeta == nil || result.ResponseMeta.Usage == nil {
t.Fatal("Eino endpoint did not return token usage")
}
t.Logf("real endpoint verified: latency=%s promptTokens=%d completionTokens=%d", time.Since(startedAt).Round(time.Millisecond), result.ResponseMeta.Usage.PromptTokens, result.ResponseMeta.Usage.CompletionTokens)
toolModel, err := model.WithTools([]*schema.ToolInfo{{
Name: "eino_echo",
Desc: "Echoes a short input. Always call this tool when asked to verify tool calling.",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"text": {Type: schema.String, Desc: "Short text to echo", Required: true},
}),
}})
if err != nil {
t.Fatalf("bind Eino tool: %v", err)
}
toolResult, err := toolModel.Generate(ctx, []*schema.Message{schema.UserMessage("Verify tool calling by invoking eino_echo with text OK.")},
einomodel.WithToolChoice(schema.ToolChoiceForced, "eino_echo"),
einoopenai.WithExtraFields(map[string]any{"enable_thinking": false}),
)
if err != nil {
t.Fatalf("real endpoint tool call: %v", err)
}
if toolResult == nil || len(toolResult.ToolCalls) != 1 || toolResult.ToolCalls[0].Function.Name != "eino_echo" {
t.Fatalf("expected one eino_echo tool call, got %#v", toolResult)
}
stream, err := model.Stream(ctx, []*schema.Message{schema.UserMessage("Reply with exactly: STREAM_OK")})
if err != nil {
t.Fatalf("real endpoint stream: %v", err)
}
// ConcatMessageStream consumes and closes the Eino reader. Do not close it
// again here: v0.9.6 treats a second close as a panic.
streamResult, err := schema.ConcatMessageStream(stream)
if err != nil {
t.Fatalf("concat real stream: %v", err)
}
if streamResult == nil || strings.TrimSpace(streamResult.Content) == "" {
t.Fatal("Eino endpoint stream returned an empty response")
}
t.Logf("real endpoint tool and stream verified: toolCalls=%d streamChars=%d", len(toolResult.ToolCalls), len([]rune(streamResult.Content)))
}
func findExperimentConfigPath() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
candidate := filepath.Join(dir, "config", "config.yaml")
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", os.ErrNotExist
}
dir = parent
}
}
func maxInt(value, fallback int) int {
if value > 0 {
return value
}
return fallback
}
+58
View File
@@ -0,0 +1,58 @@
package runtime
import (
"context"
"errors"
"strings"
"agent-desk/internal/pkg/errorsx"
)
const (
EngineCodeWorkflow = "workflow"
EngineCodeAutonomous = "autonomous"
)
// Engine executes one Agent Runtime mode. Implementations must keep business
// mutations behind AgentDesk services and return a normalized RunResult.
type Engine interface {
Code() string
Run(ctx context.Context, req RunInput) (*RunResult, error)
Resume(ctx context.Context, req ResumeInput) (*RunResult, error)
}
// EngineRegistry resolves the runtime implementation. Workflow is the default
// until Agent runtime modes are persisted on AIAgent in the next migration.
type EngineRegistry struct {
engines map[string]Engine
}
func NewEngineRegistry(engines ...Engine) *EngineRegistry {
registry := &EngineRegistry{engines: make(map[string]Engine, len(engines))}
for _, engine := range engines {
if engine == nil || strings.TrimSpace(engine.Code()) == "" {
continue
}
registry.engines[strings.TrimSpace(engine.Code())] = engine
}
return registry
}
func NewDefaultEngineRegistry() *EngineRegistry {
return NewEngineRegistry(NewWorkflowEngine(), NewAutonomousEngine(), NewHybridEngine())
}
func (r *EngineRegistry) Resolve(code string) (Engine, error) {
if r == nil {
return nil, errors.New("agent runtime engine registry is not configured")
}
code = strings.TrimSpace(code)
if code == "" {
code = EngineCodeWorkflow
}
engine := r.engines[code]
if engine == nil {
return nil, errorsx.InvalidParam("agent runtime engine does not exist")
}
return engine, nil
}
@@ -0,0 +1,550 @@
package runtime
import (
"context"
"fmt"
"strings"
"testing"
ai "agent-desk/internal/ai"
"agent-desk/internal/ai/skills"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/toolx"
svc "agent-desk/internal/services"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestServiceDefaultsToWorkflowEngine(t *testing.T) {
service := NewService()
engine, err := service.registry.Resolve("")
if err != nil {
t.Fatalf("resolve default engine: %v", err)
}
if engine.Code() != EngineCodeWorkflow {
t.Fatalf("expected default engine %q, got %q", EngineCodeWorkflow, engine.Code())
}
}
func TestServiceDispatchesRequestedEngine(t *testing.T) {
engine := &runtimeTestEngine{code: "test"}
service := NewServiceWithRegistry(NewEngineRegistry(engine))
summary, err := service.Run(context.Background(), RunInput{AIAgent: models.AIAgent{RuntimeMode: enums.AIAgentRuntimeMode(engine.code)}})
if err != nil {
t.Fatalf("run requested engine: %v", err)
}
if !engine.ran || summary == nil || summary.Status != "completed" {
t.Fatalf("unexpected engine dispatch result: engine=%#v summary=%#v", engine, summary)
}
}
func TestEngineContractKeepsLegacyRequestAliasesCompatible(t *testing.T) {
var _ Engine = (*runtimeTestEngine)(nil)
var input Request = RunInput{}
var result Summary = RunResult{Status: "completed"}
if input.Debug || result.Status != "completed" {
t.Fatalf("unexpected compatibility values: input=%#v result=%#v", input, result)
}
}
func TestServiceRejectsUnknownEngine(t *testing.T) {
service := NewServiceWithRegistry(NewEngineRegistry())
if _, err := service.Run(context.Background(), Request{AIAgent: models.AIAgent{RuntimeMode: "missing"}}); err == nil {
t.Fatal("expected unknown engine error")
}
}
func TestAutonomousEngineRecordsPublishedRevisionRun(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 7, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
var receivedPrompt string
engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, _ string, prompt string) (*ai.ChatCompletionResult, error) {
receivedPrompt = prompt
return &ai.ChatCompletionResult{Content: "可以协助你处理这个问题。", ModelName: "test-model", PromptTokens: 8, CompletionTokens: 5}, nil
})
engine.retrieve = func(context.Context, models.AIAgent, string) (string, int, error) {
return "退款需要先确认订单号。", 1, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "需要帮助"},
AIAgent: models.AIAgent{ID: 7, PublishedRevisionID: revision.ID, SystemPrompt: "保持专业", KnowledgeIDs: "21"}, AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary == nil || summary.AgentRunID <= 0 || summary.ReplyText == "" {
t.Fatalf("unexpected summary: %#v", summary)
}
var run models.AgentRun
if err := db.First(&run, summary.AgentRunID).Error; err != nil {
t.Fatalf("load agent run: %v", err)
}
if run.EngineCode != EngineCodeAutonomous || run.AgentRevisionID != revision.ID || run.Status != "completed" {
t.Fatalf("unexpected agent run: %#v", run)
}
if run.PromptTokens != 8 || !strings.Contains(receivedPrompt, "Knowledge evidence") {
t.Fatalf("expected knowledge evidence in prompt, got %q", receivedPrompt)
}
var steps []models.AgentStep
if err := db.Where("agent_run_id = ?", run.ID).Find(&steps).Error; err != nil || len(steps) != 2 || steps[1].StepType != "knowledge" {
t.Fatalf("expected model and knowledge steps, steps=%#v err=%v", steps, err)
}
}
func TestAutonomousEngineRecordsRejectedReplyAsFailed(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 15, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) {
return &ai.ChatCompletionResult{Content: "token=secret-value"}, nil
})
_, err = engine.Run(context.Background(), Request{UserMessage: models.Message{ID: 2, Content: "help"}, AIAgent: models.AIAgent{ID: 15, PublishedRevisionID: revision.ID}})
if err == nil {
t.Fatal("expected sensitive model reply to be rejected")
}
var run models.AgentRun
if err := db.Last(&run).Error; err != nil || run.Status != "failed" || strings.Contains(run.ErrorMessage, "secret-value") {
t.Fatalf("expected failed audit run, run=%#v err=%v", run, err)
}
}
func TestAutonomousEngineBuildsBoundedConversationContext(t *testing.T) {
engine := newAutonomousEngineWithChat(nil)
engine.history = func(conversationID int64, limit int) []models.Message {
if conversationID != 11 || limit != 3 {
t.Fatalf("unexpected history query: conversation=%d limit=%d", conversationID, limit)
}
return []models.Message{
{ID: 1, SenderType: "customer", MessageType: "text", Content: "之前的问题"},
{ID: 2, SenderType: "ai", MessageType: "text", Content: "之前的答复"},
{ID: 3, SenderType: "customer", MessageType: "text", Content: "当前问题"},
}
}
prompt, count := engine.buildUserPrompt(Request{
Conversation: models.Conversation{ID: 11}, UserMessage: models.Message{ID: 3, Content: "当前问题", MessageType: "text"},
AIAgent: models.AIAgent{ContextWindow: 2},
})
if count != 2 || !strings.Contains(prompt, "Customer: 之前的问题") || !strings.Contains(prompt, "Assistant: 之前的答复") || strings.Count(prompt, "当前问题") != 1 {
t.Fatalf("unexpected assembled prompt: %q", prompt)
}
}
func TestAutonomousEngineBuildsCustomerContext(t *testing.T) {
engine := newAutonomousEngineWithChat(nil)
prompt, count := engine.buildUserPrompt(Request{
Conversation: models.Conversation{CustomerName: "张三", LastMessageSummary: "已咨询退款条件"},
UserMessage: models.Message{Content: "我要申请退款", MessageType: "text"},
})
if count != 0 || !strings.Contains(prompt, "Customer: 张三") || !strings.Contains(prompt, "Recent summary: 已咨询退款条件") || !strings.Contains(prompt, "Current customer message:\n我要申请退款") {
t.Fatalf("unexpected customer context: %q", prompt)
}
}
func TestAutonomousEngineInjectsSelectedSkillAndRecordsRoute(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 9, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
var systemPrompt string
engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) {
systemPrompt = system
return &ai.ChatCompletionResult{Content: "我来协助处理退款。", ModelName: "test-model"}, nil
})
engine.skillSelect = func(context.Context, skills.RuntimeContext) (*skills.ExecutionResult, error) {
return &skills.ExecutionResult{Plan: &skills.ExecutionPlan{
Skill: &models.SkillDefinition{ID: 70, Name: "退款处理", Instruction: "先核对订单信息。", Examples: `["我要退款"]`, ToolWhitelist: `["support/order_lookup"]`},
MatchReason: "llm_route",
}, Trace: &skills.ExecutionTrace{Status: "ok", MatchReason: "llm_route"}}, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "我要退款"},
AIAgent: models.AIAgent{ID: 9, PublishedRevisionID: revision.ID, SkillIDs: "70", SystemPrompt: "保持简洁"}, AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary.PlannedSkillID != 70 || summary.PlannedSkillName != "退款处理" || summary.PlanReason != "llm_route" {
t.Fatalf("unexpected skill summary: %#v", summary)
}
if !strings.Contains(systemPrompt, "先核对订单信息") || !strings.Contains(systemPrompt, "我要退款") {
t.Fatalf("selected skill was not injected into system prompt: %q", systemPrompt)
}
var steps []models.AgentStep
if err := db.Where("agent_run_id = ?", summary.AgentRunID).Find(&steps).Error; err != nil {
t.Fatalf("load steps: %v", err)
}
if len(steps) != 2 || steps[1].StepType != "skill_route" || steps[1].StepCode != "skill_select" {
t.Fatalf("expected model and skill route audit steps, got %#v", steps)
}
}
func TestAutonomousEngineEnforcesKnowledgeFallbackPolicy(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 10, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
chatCalled := false
engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) {
chatCalled = true
return &ai.ChatCompletionResult{Content: "should not be used"}, nil
})
engine.retrieve = func(context.Context, models.AIAgent, string) (string, int, error) {
return "", 0, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "保修多久"},
AIAgent: models.AIAgent{ID: 10, PublishedRevisionID: revision.ID, KnowledgeIDs: "100", FallbackMessage: "请提供产品型号,我再继续查询。"},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if chatCalled || summary.ReplyText != "请提供产品型号,我再继续查询。" {
t.Fatalf("knowledge fallback policy was not enforced: chatCalled=%t summary=%#v", chatCalled, summary)
}
var steps []models.AgentStep
if err := db.Where("agent_run_id = ?", summary.AgentRunID).Find(&steps).Error; err != nil {
t.Fatalf("load steps: %v", err)
}
if len(steps) != 3 || steps[1].StepType != "knowledge" || steps[2].StepType != "policy" || steps[2].StepCode != "knowledge_evidence" {
t.Fatalf("expected model, knowledge and policy steps, got %#v", steps)
}
}
func TestAutonomousEngineDebugRunDoesNotExposeMCPTools(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 11, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) {
return &ai.ChatCompletionResult{Content: "调试回复", ModelName: "test-model"}, nil
})
engine.toolChat = func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
t.Fatal("debug run must not enter tool calling loop")
return nil, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "查询订单"},
AIAgent: models.AIAgent{ID: 11, PublishedRevisionID: revision.ID, AllowedMCPTools: `[{"toolCode":"orders/lookup"}]`},
AIConfig: models.AIConfig{ModelName: "test-model"}, Debug: true,
})
if err != nil || summary == nil || summary.ReplyText != "调试回复" {
t.Fatalf("unexpected debug run result: summary=%#v err=%v", summary, err)
}
}
func TestAutonomousEngineUsesPublishedRevisionSnapshot(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 12, Revision: 1, Definition: `{"agent":{"name":"published","aiConfigId":5,"runtimeMode":"autonomous","maxSteps":4,"systemPrompt":"published instruction"},"model":{"configId":5,"provider":"openai","baseUrl":"https://published.example/v1","modelType":"llm","modelName":"published-model","timeoutMs":12000}}`}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
var receivedSystem string
var receivedConfig models.AIConfig
engine := newAutonomousEngineWithChat(func(_ context.Context, config models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) {
receivedSystem = system
receivedConfig = config
return &ai.ChatCompletionResult{Content: "published response", ModelName: config.ModelName}, nil
})
_, err = engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "hello"},
AIAgent: models.AIAgent{ID: 12, PublishedRevisionID: revision.ID, SystemPrompt: "draft instruction", AIConfigID: 5},
AIConfig: models.AIConfig{ID: 5, APIKey: "rotated-key", ModelName: "draft-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if !strings.Contains(receivedSystem, "published instruction") || strings.Contains(receivedSystem, "draft instruction") {
t.Fatalf("system prompt did not use published snapshot: %q", receivedSystem)
}
if receivedConfig.ModelName != "published-model" || receivedConfig.BaseURL != "https://published.example/v1" || receivedConfig.APIKey != "rotated-key" {
t.Fatalf("model config did not use safe published snapshot: %#v", receivedConfig)
}
}
func TestHybridEngineUsesBoundPlaybookAndRecordsGenericAudit(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIWorkflowVersion{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
workflowVersion := &models.AIWorkflowVersion{WorkflowID: 21, Version: 1, Status: enums.StatusOk, Definition: `{"schemaVersion":2,"nodes":[{"id":"start_1","type":"start"},{"id":"end_1","type":"end"}],"edges":[{"sourceNodeID":"start_1","targetNodeID":"end_1"}]}`}
if err := db.Create(workflowVersion).Error; err != nil {
t.Fatalf("create workflow version: %v", err)
}
revision := &models.AgentRevision{AgentID: 14, Revision: 1, Status: enums.StatusOk, WorkflowVersionID: workflowVersion.ID, Definition: `{"agent":{"runtimeMode":"hybrid","systemPrompt":"published hybrid prompt","maxSteps":3},"workflowVersionId":1}`}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
engine := NewHybridEngine()
engine.chatWithTools = func(_ context.Context, _ models.AIConfig, system, _ string, definitions []ai.ToolDefinition, _ int, _ ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
if !strings.Contains(system, "published hybrid prompt") || len(definitions) != 1 || definitions[0].Name != "run_playbook" {
t.Fatalf("unexpected hybrid model context: system=%q definitions=%#v", system, definitions)
}
return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "这是自主回复。", ModelName: "test-model", PromptTokens: 5, CompletionTokens: 4}}, nil
}
summary, err := engine.Run(context.Background(), Request{
UserMessage: models.Message{ID: 3, Content: "普通咨询"},
AIAgent: models.AIAgent{ID: 14, RuntimeMode: enums.AIAgentRuntimeModeHybrid, PublishedRevisionID: revision.ID, WorkflowVersionID: workflowVersion.ID},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary == nil || summary.AgentRunID <= 0 || summary.WorkflowRunID != 0 || summary.ReplyText != "这是自主回复。" {
t.Fatalf("unexpected hybrid summary: %#v", summary)
}
var run models.AgentRun
if err := db.First(&run, summary.AgentRunID).Error; err != nil {
t.Fatalf("load agent run: %v", err)
}
if run.EngineCode != "hybrid" || run.AgentRevisionID != revision.ID || run.Status != "completed" {
t.Fatalf("unexpected hybrid audit: %#v", run)
}
}
func TestHybridEngineRejectsPlaybookWhenToolPolicyDisallowsWrites(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIWorkflowVersion{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
workflowVersion := &models.AIWorkflowVersion{WorkflowID: 22, Version: 1, Status: enums.StatusOk, Definition: `{"schemaVersion":2,"nodes":[{"id":"start_1","type":"start"},{"id":"end_1","type":"end"}],"edges":[{"sourceNodeID":"start_1","targetNodeID":"end_1"}]}`}
if err := db.Create(workflowVersion).Error; err != nil {
t.Fatalf("create workflow version: %v", err)
}
revision := &models.AgentRevision{AgentID: 15, Revision: 1, Status: enums.StatusOk, WorkflowVersionID: workflowVersion.ID, Definition: `{"agent":{"runtimeMode":"hybrid","systemPrompt":"published hybrid prompt","maxSteps":3,"toolPolicy":"{\"allowedRiskLevels\":[\"read\"]}"},"workflowVersionId":1}`}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
engine := NewHybridEngine()
engine.chatWithTools = func(ctx context.Context, _ models.AIConfig, _ string, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
_, err := execute(ctx, ai.ToolCall{Name: "run_playbook", Arguments: fmt.Sprintf(`{"workflowVersionId":%d}`, workflowVersion.ID)})
return nil, err
}
_, err = engine.Run(context.Background(), Request{
UserMessage: models.Message{ID: 4, Content: "请执行受控流程"},
AIAgent: models.AIAgent{ID: 15, RuntimeMode: enums.AIAgentRuntimeModeHybrid, PublishedRevisionID: revision.ID, WorkflowVersionID: workflowVersion.ID},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err == nil || !strings.Contains(err.Error(), "tool risk level is not allowed") {
t.Fatalf("expected tool policy rejection, got %v", err)
}
}
func TestIntersectAutonomousToolCodesUsesSkillWhitelist(t *testing.T) {
got := intersectAutonomousToolCodes([]string{"support/order_lookup", "support/create_ticket"}, []string{"support/order_lookup"})
if len(got) != 1 || got[0] != "support/order_lookup" {
t.Fatalf("intersection = %#v", got)
}
}
func TestParseAutonomousToolPolicyAndPerToolCount(t *testing.T) {
policy := parseAutonomousToolPolicy(`{"maxTotalCalls":2,"maxArgumentBytes":1024,"allowedRiskLevels":["read"]}`)
if policy.MaxTotalCalls != 2 || policy.MaxArgumentBytes != 1024 || len(policy.AllowedRiskLevels) != 1 {
t.Fatalf("unexpected policy: %#v", policy)
}
defaults := parseAutonomousToolPolicy(`{"maxTotalCalls":99,"maxArgumentBytes":999999}`)
if defaults.MaxTotalCalls != 3 || defaults.MaxArgumentBytes != 32*1024 {
t.Fatalf("invalid policy did not fall back to safe limits: %#v", defaults)
}
count := autonomousToolCallCount([]svc.EngineToolCallInput{{ToolCode: "orders/lookup"}, {ToolCode: "orders/other"}, {ToolCode: "orders/lookup"}}, "orders/lookup")
if count != 2 {
t.Fatalf("per-tool count = %d, want 2", count)
}
}
func TestAutonomousResponsePolicyRequestsHandoffOnlyWhenConfigured(t *testing.T) {
handoff := evaluateAutonomousResponsePolicy(models.AIAgent{KnowledgeIDs: "1", FallbackMode: enums.AIAgentFallbackModeHandoff}, "", nil)
if !handoff.Enforced || !handoff.RequestHandoff || handoff.Action != "handoff" {
t.Fatalf("unexpected handoff policy: %#v", handoff)
}
clarify := evaluateAutonomousResponsePolicy(models.AIAgent{KnowledgeIDs: "1", FallbackMode: enums.AIAgentFallbackModeSuggestRetry}, "", nil)
if !clarify.Enforced || clarify.RequestHandoff || clarify.Action != "clarify" {
t.Fatalf("unexpected clarify policy: %#v", clarify)
}
}
func TestAutonomousConversationContextToolUsesRegistryPolicy(t *testing.T) {
definition, result, err := executeAutonomousReadTool(context.Background(), models.Conversation{CustomerName: "张三", LastMessageSummary: "咨询退款"}, models.AIAgent{}, toolx.BuiltinConversationContext.Code, nil, aitooling.Policy{
AllowedToolCodes: []string{toolx.BuiltinConversationContext.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelRead}, Confirmed: true,
})
if err != nil || definition.Code != toolx.BuiltinConversationContext.Code || !strings.Contains(result, `"customerName":"张三"`) {
t.Fatalf("unexpected conversation context tool result: definition=%#v result=%q err=%v", definition, result, err)
}
_, _, err = executeAutonomousReadTool(context.Background(), models.Conversation{}, models.AIAgent{}, toolx.BuiltinConversationContext.Code, nil, aitooling.Policy{
AllowedToolCodes: []string{toolx.BuiltinConversationContext.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelWrite}, Confirmed: true,
})
if err == nil || !strings.Contains(err.Error(), "risk level") {
t.Fatalf("expected read tool risk rejection, got %v", err)
}
}
func TestAutonomousEngineExecutesAndAuditsConversationContextTool(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}, &models.Message{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 13, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
if err := db.Create(&models.Message{ConversationID: 1, SenderType: "customer", MessageType: "text", Content: "上一轮问题"}).Error; err != nil {
t.Fatalf("create prior message: %v", err)
}
engine := newAutonomousEngineWithChat(nil)
engine.toolChat = func(ctx context.Context, _ models.AIConfig, _, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
output, err := execute(ctx, ai.ToolCall{ID: "call-1", Name: "tool_search", Arguments: `{"toolCode":"builtin/conversation_context","arguments":{}}`})
if err != nil || !strings.Contains(output, `"customerName":"张三"`) || !strings.Contains(output, "上一轮问题") {
t.Fatalf("execute tool: output=%q err=%v", output, err)
}
output, err = execute(ctx, ai.ToolCall{ID: "call-2", Name: "tool_search", Arguments: `{"toolCode":"graph/prepare_ticket_draft","arguments":{"issue":"重复扣费"}}`})
if err != nil || !strings.Contains(output, `"title":"重复扣费"`) {
t.Fatalf("execute ticket draft tool: output=%q err=%v", output, err)
}
output, err = execute(ctx, ai.ToolCall{ID: "call-3", Name: "tool_search", Arguments: `{"toolCode":"graph/analyze_conversation","arguments":{"observedIssue":"重复扣费","needTicket":true}}`})
if err != nil || !strings.Contains(output, `"userIntent":"ticket_request"`) {
t.Fatalf("execute conversation analysis tool: output=%q err=%v", output, err)
}
output, err = execute(ctx, ai.ToolCall{ID: "call-4", Name: "tool_search", Arguments: `{"toolCode":"graph/triage_service_request","arguments":{"observedIssue":"重复扣费","needTicket":true}}`})
if err != nil || !strings.Contains(output, `"recommendedAction":"prepare_ticket"`) || !strings.Contains(output, `"ticketDraft"`) {
t.Fatalf("execute service triage tool: output=%q err=%v", output, err)
}
return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "已查询到当前会话信息。", ModelName: "test-model"}}, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1, CustomerName: "张三", LastMessageSummary: "咨询退款"}, UserMessage: models.Message{ID: 2, Content: "请查一下当前会话"},
AIAgent: models.AIAgent{ID: 13, PublishedRevisionID: revision.ID, ToolPolicy: `{"maxTotalCalls":4}`, AllowedMCPTools: `[{"toolCode":"builtin/conversation_context"},{"toolCode":"graph/prepare_ticket_draft"},{"toolCode":"graph/analyze_conversation"},{"toolCode":"graph/triage_service_request"}]`},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
_, _, calls := svc.AgentRunService.GetDetail(summary.AgentRunID)
if len(calls) != 4 || calls[0].ToolCode != toolx.BuiltinConversationContext.Code || calls[1].ToolCode != toolx.GraphPrepareTicketDraft.Code || calls[2].ToolCode != toolx.GraphAnalyzeConversation.Code || calls[3].ToolCode != toolx.GraphTriageServiceRequest.Code || calls[3].Status != "completed" {
t.Fatalf("unexpected tool audit: %#v", calls)
}
}
func TestAutonomousEngineFallsBackAfterConsecutiveToolFailures(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 14, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
engine := newAutonomousEngineWithChat(nil)
engine.toolChat = func(ctx context.Context, _ models.AIConfig, _, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
for _, callID := range []string{"call-1", "call-2"} {
_, _ = execute(ctx, ai.ToolCall{ID: callID, Name: "tool_search", Arguments: `{"toolCode":"unknown/unsafe","arguments":{}}`})
}
return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "model reply should be replaced", ModelName: "test-model"}}, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "查询订单"},
AIAgent: models.AIAgent{ID: 14, PublishedRevisionID: revision.ID, FallbackMode: enums.AIAgentFallbackModeHandoff, FallbackMessage: "查询暂不可用,正在转人工。", AllowedMCPTools: `[{"toolCode":"builtin/conversation_context"}]`},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary.ReplyText != "查询暂不可用,正在转人工。" || !summary.HandoffRequested {
t.Fatalf("expected handoff fallback after tool failures, got %#v", summary)
}
_, steps, calls := svc.AgentRunService.GetDetail(summary.AgentRunID)
if len(calls) != 2 || calls[0].Status != "failed" || calls[1].Status != "failed" {
t.Fatalf("expected failed tool audits, got %#v", calls)
}
if len(steps) < 2 || steps[len(steps)-1].StepCode != "tool_failure" || steps[len(steps)-1].OutputPreview != "handoff" {
t.Fatalf("expected tool failure policy audit, got %#v", steps)
}
}
type runtimeTestEngine struct {
code string
ran bool
}
func (e *runtimeTestEngine) Code() string {
return e.code
}
func (e *runtimeTestEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) {
e.ran = true
return &RunResult{Status: "completed"}, nil
}
func (e *runtimeTestEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
return &RunResult{Status: "completed"}, nil
}
@@ -0,0 +1,147 @@
package runtime
import (
"context"
"encoding/csv"
"strconv"
"strings"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/toolx"
)
// OfflineEvaluationCase is an isolated customer-service evaluation sample.
// Expectations are intentionally declarative so the same baseline can evolve
// without changing the runner's request contract.
type OfflineEvaluationCase struct {
ID string `json:"id"`
Category string `json:"category"`
Message string `json:"message"`
History []string `json:"history,omitempty"`
Expect map[string]any `json:"expect,omitempty"`
}
type OfflineEvaluationResult struct {
CaseID string `json:"caseId"`
Category string `json:"category"`
EngineCode string `json:"engineCode"`
Passed bool `json:"passed"`
ReplyText string `json:"replyText"`
Interrupted bool `json:"interrupted"`
Error string `json:"error,omitempty"`
Finding string `json:"finding,omitempty"`
}
type OfflineEvaluationReport struct {
EngineCode string `json:"engineCode"`
Total int `json:"total"`
Passed int `json:"passed"`
Results []OfflineEvaluationResult `json:"results"`
}
// OfflineEvaluationRunner executes only isolated Debug requests. The supplied
// runner makes it testable without a real model and lets callers choose an
// explicit Engine implementation for mode comparison.
type OfflineEvaluationRunner struct {
run func(context.Context, RunInput) (*RunResult, error)
}
func NewOfflineEvaluationRunner(run func(context.Context, RunInput) (*RunResult, error)) *OfflineEvaluationRunner {
return &OfflineEvaluationRunner{run: run}
}
func (r *OfflineEvaluationRunner) Run(ctx context.Context, engineCode string, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) OfflineEvaluationReport {
report := OfflineEvaluationReport{EngineCode: strings.TrimSpace(engineCode), Results: make([]OfflineEvaluationResult, 0, len(cases))}
for _, item := range cases {
result := OfflineEvaluationResult{CaseID: strings.TrimSpace(item.ID), Category: strings.TrimSpace(item.Category), EngineCode: report.EngineCode}
if r == nil || r.run == nil {
result.Error, result.Finding = "evaluation runner is not configured", "runner_missing"
report.Results = append(report.Results, result)
continue
}
summary, err := r.run(ctx, RunInput{
Conversation: models.Conversation{AIAgentID: agent.ID, LastMessageSummary: strings.Join(item.History, "\n")},
UserMessage: models.Message{SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: strings.TrimSpace(item.Message), RequestID: "offline-eval:" + strings.TrimSpace(item.ID)},
AIAgent: agent,
AIConfig: config,
Debug: true,
})
if err != nil {
result.Error, result.Finding = err.Error(), "engine_error"
report.Results = append(report.Results, result)
continue
}
if summary != nil {
result.ReplyText = strings.TrimSpace(summary.ReplyText)
result.Interrupted = summary.Interrupted
}
result.Passed, result.Finding = evaluateOfflineCase(item.Expect, summary)
if result.Passed {
report.Passed++
}
report.Results = append(report.Results, result)
}
report.Total = len(report.Results)
return report
}
func (r OfflineEvaluationReport) CSV() (string, error) {
var output strings.Builder
writer := csv.NewWriter(&output)
if err := writer.Write([]string{"caseId", "category", "engineCode", "passed", "interrupted", "finding", "error", "replyText"}); err != nil {
return "", err
}
for _, item := range r.Results {
if err := writer.Write([]string{item.CaseID, item.Category, item.EngineCode, strconv.FormatBool(item.Passed), strconv.FormatBool(item.Interrupted), item.Finding, item.Error, item.ReplyText}); err != nil {
return "", err
}
}
writer.Flush()
return output.String(), writer.Error()
}
func evaluateOfflineCase(expect map[string]any, summary *RunResult) (bool, string) {
if summary == nil || strings.TrimSpace(summary.ReplyText) == "" {
return false, "empty_reply"
}
if requiresConfirmation, _ := expect["requiresConfirmation"].(bool); requiresConfirmation && !summary.Interrupted {
return false, "confirmation_not_reached"
}
if maxWrites, ok := evaluationExpectationInt(expect["maxWriteToolCalls"]); ok {
if maxWrites < 0 {
return false, "invalid_expectation"
}
if writeToolCalls(summary) > maxWrites {
return false, "write_tool_limit_exceeded"
}
}
return true, ""
}
func evaluationExpectationInt(value any) (int, bool) {
switch item := value.(type) {
case int:
return item, true
case int64:
return int(item), true
case float64:
return int(item), item == float64(int(item))
default:
return 0, false
}
}
func writeToolCalls(summary *RunResult) int {
if summary == nil {
return 0
}
count := 0
for _, code := range summary.InvokedToolCodes {
switch toolx.NormalizeToolCodeAlias(code) {
case toolx.GraphCreateTicketConfirm.Code, toolx.GraphHandoffConversation.Code:
count++
}
}
return count
}
@@ -0,0 +1,54 @@
package runtime
import (
"context"
"strings"
"testing"
"agent-desk/internal/models"
)
func TestOfflineEvaluationRunnerUsesDebugIsolationAndExportsCSV(t *testing.T) {
var received []RunInput
runner := NewOfflineEvaluationRunner(func(_ context.Context, input RunInput) (*RunResult, error) {
received = append(received, input)
return &RunResult{ReplyText: "已根据知识库回答。"}, nil
})
report := runner.Run(context.Background(), "autonomous", models.AIAgent{ID: 12}, models.AIConfig{ID: 13}, []OfflineEvaluationCase{{ID: "faq", Category: "faq", Message: "保修期多久", History: []string{"客户:你好"}}})
if report.Total != 1 || report.Passed != 1 || len(received) != 1 || !received[0].Debug || received[0].Conversation.ID != 0 || received[0].UserMessage.RequestID != "offline-eval:faq" {
t.Fatalf("unexpected report or input: report=%#v input=%#v", report, received)
}
csv, err := report.CSV()
if err != nil || !strings.Contains(csv, "caseId,category,engineCode") || !strings.Contains(csv, "faq,faq,autonomous,true") {
t.Fatalf("unexpected csv=%q err=%v", csv, err)
}
}
func TestOfflineEvaluationRunnerChecksConfirmationExpectation(t *testing.T) {
runner := NewOfflineEvaluationRunner(func(context.Context, RunInput) (*RunResult, error) {
return &RunResult{ReplyText: "已转人工"}, nil
})
report := runner.Run(context.Background(), "workflow", models.AIAgent{}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "handoff", Expect: map[string]any{"requiresConfirmation": true}}})
if report.Passed != 0 || report.Results[0].Finding != "confirmation_not_reached" {
t.Fatalf("unexpected report: %#v", report)
}
}
func TestOfflineEvaluationRunnerChecksWriteToolLimit(t *testing.T) {
runner := NewOfflineEvaluationRunner(func(context.Context, RunInput) (*RunResult, error) {
return &RunResult{ReplyText: "调试回复", InvokedToolCodes: []string{"graph/handoff_to_human"}}, nil
})
report := runner.Run(context.Background(), "hybrid", models.AIAgent{}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "write", Expect: map[string]any{"maxWriteToolCalls": 0}}})
if report.Passed != 0 || report.Results[0].Finding != "write_tool_limit_exceeded" {
t.Fatalf("unexpected report: %#v", report)
}
}
func TestServiceRunsOfflineEvaluationWithExplicitEngine(t *testing.T) {
engine := &runtimeTestEngine{code: "evaluation"}
service := NewServiceWithRegistry(NewEngineRegistry(engine))
report, err := service.RunOfflineEvaluation(context.Background(), "evaluation", models.AIAgent{RuntimeMode: "workflow"}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "case"}})
if err != nil || !engine.ran || report.EngineCode != "evaluation" || report.Total != 1 {
t.Fatalf("unexpected evaluation report=%#v engine=%#v err=%v", report, engine, err)
}
}
@@ -0,0 +1,221 @@
package runtime
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
ai "agent-desk/internal/ai"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/ai/runtime/instruction"
workflowregistry "agent-desk/internal/ai/workflow/registry"
workflowvalidator "agent-desk/internal/ai/workflow/validator"
"agent-desk/internal/models"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/utils"
svc "agent-desk/internal/services"
"github.com/mlogclub/simple/sqls"
)
const hybridPlaybookToolCode = "playbook/run"
// HybridEngine lets the model choose whether to enter the Agent's one bound
// deterministic Playbook. The Playbook itself is always run by WorkflowEngine.
type HybridEngine struct {
chatWithTools func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error)
autonomous *AutonomousEngine
workflow *WorkflowEngine
}
func NewHybridEngine() *HybridEngine {
return &HybridEngine{
chatWithTools: ai.LLM.ChatWithTools,
autonomous: NewAutonomousEngine(),
workflow: NewWorkflowEngine(),
}
}
func (e *HybridEngine) Code() string {
return "hybrid"
}
func (e *HybridEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) {
startedAt := time.Now()
req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content)
snapshot, err := svc.AgentRevisionService.ResolvePublishedSnapshot(req.AIAgent, req.AIConfig)
if err != nil {
return nil, err
}
req.AIAgent, req.AIConfig = snapshot.Agent, snapshot.AIConfig
if req.AIAgent.WorkflowVersionID <= 0 {
return nil, errorsx.InvalidParam("hybrid agent requires a published playbook workflow")
}
workflow, err := resolveAgentWorkflow(req.AIAgent)
if err != nil {
return nil, err
}
if result := workflowvalidator.ValidateDefinition(workflow.Definition, workflowregistry.DefaultRegistry()); !result.Valid {
return nil, errorsx.InvalidParam("hybrid agent playbook validation failed")
}
skillContext := e.autonomous.selectSkill(ctx, req)
knowledgeContext, retrieverCount, retrieveErr := e.autonomous.retrieveKnowledge(ctx, req.AIAgent, req.UserMessage.Content)
responsePolicy := evaluateAutonomousResponsePolicy(req.AIAgent, knowledgeContext, retrieveErr)
if responsePolicy.Enforced {
return writeHybridResult(req, startedAt, &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}, "", 0, retrieverCount, skillContext, nil, responsePolicy, nil)
}
systemPrompt := buildAutonomousSystemPrompt(req.AIAgent, len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0, knowledgeContext, retrieveErr)
if skillInstruction := strings.TrimSpace(instruction.BuildSkillDocument(skillContext.Skill, nil)); skillInstruction != "" {
systemPrompt += "\n\nSkill instructions:\n" + skillInstruction
}
systemPrompt += "\n\nWhen a deterministic process is required, use run_playbook. Do not call it for ordinary factual questions."
userPrompt, historyCount := e.autonomous.buildUserPrompt(req)
if knowledgeContext != "" {
userPrompt += "\n\nKnowledge evidence:\n" + knowledgeContext
}
var playbookSummary *Summary
toolCalls := make([]svc.EngineToolCallInput, 0, 1)
toolPolicy := parseAutonomousToolPolicy(req.AIAgent.ToolPolicy)
loop, err := e.chatWithTools(ctx, req.AIConfig, systemPrompt, userPrompt, []ai.ToolDefinition{hybridPlaybookToolDefinition(req.AIAgent.WorkflowVersionID)}, req.AIAgent.MaxSteps, func(ctx context.Context, call ai.ToolCall) (string, error) {
if call.Name != "run_playbook" {
return "", fmt.Errorf("unsupported hybrid tool: %s", call.Name)
}
if len(toolCalls) >= 1 {
return "", fmt.Errorf("playbook call limit reached")
}
workflowVersionID, err := parseHybridPlaybookCall(call.Arguments)
if err != nil {
return "", err
}
if workflowVersionID != req.AIAgent.WorkflowVersionID {
return "", fmt.Errorf("playbook is not allowed")
}
playbookDefinition := aitooling.Definition{Code: hybridPlaybookToolCode, Name: "run_playbook", RiskLevel: aitooling.RiskLevelWrite, RequireConfirmation: true, MaxCallsPerRun: 1}
if err := aitooling.DefaultRegistry.Authorize(playbookDefinition, aitooling.Policy{
AllowedRiskLevels: toolPolicy.AllowedRiskLevels,
CallCount: len(toolCalls),
TotalCallCount: len(toolCalls),
MaxTotalCalls: 1,
Confirmed: true, // Workflow validation guarantees a human-confirm predecessor for high-risk nodes.
}); err != nil {
return "", err
}
callStartedAt := time.Now()
playbookSummary, err = e.workflow.Run(ctx, req)
toolRecord := svc.EngineToolCallInput{ToolCode: hybridPlaybookToolCode, RiskLevel: "write", RequireConfirm: true, ArgumentsPreview: call.Arguments, DurationMS: int(time.Since(callStartedAt).Milliseconds())}
if err != nil {
toolRecord.Status, toolRecord.ErrorMessage = "failed", err.Error()
toolCalls = append(toolCalls, toolRecord)
return "", err
}
toolRecord.Status = "completed"
toolRecord.ResultPreview = fmt.Sprintf("workflowRunId=%d status=%s", playbookSummary.WorkflowRunID, playbookSummary.Status)
toolCalls = append(toolCalls, toolRecord)
data, _ := json.Marshal(map[string]any{"workflowRunId": playbookSummary.WorkflowRunID, "status": playbookSummary.Status, "replyText": playbookSummary.ReplyText, "interrupted": playbookSummary.Interrupted})
return string(data), nil
})
if err != nil {
_, _ = writeHybridAudit(req, startedAt, nil, userPrompt, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, false, err)
return nil, err
}
if playbookSummary != nil && playbookSummary.Interrupted {
runID, auditErr := writeHybridAudit(req, startedAt, &ai.ChatCompletionResult{Content: playbookSummary.ReplyText, ModelName: playbookSummary.ModelName, PromptTokens: playbookSummary.PromptTokens, CompletionTokens: playbookSummary.CompletionTokens}, userPrompt, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, true, nil)
if auditErr != nil {
return nil, auditErr
}
playbookSummary.AgentRunID = runID
return playbookSummary, nil
}
if loop == nil || strings.TrimSpace(loop.Content) == "" {
err = errorsx.InvalidParam("hybrid engine returned an empty reply")
_, _ = writeHybridAudit(req, startedAt, nil, userPrompt, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, false, err)
return nil, err
}
return writeHybridResult(req, startedAt, &loop.ChatCompletionResult, userPrompt, historyCount, retrieverCount, skillContext, playbookSummary, responsePolicy, toolCalls)
}
func (e *HybridEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
interrupt := svc.ConversationInterruptService.GetByCheckPointID(req.CheckPointID)
summary, err := e.workflow.Resume(ctx, req)
if err != nil || summary == nil || interrupt == nil || interrupt.AgentRunID <= 0 {
return summary, err
}
if err := sqls.WithTransaction(func(tx *sqls.TxContext) error {
return svc.AgentRunService.RecordHybridPlaybookResume(tx.Tx, interrupt.AgentRunID, summary.WorkflowRunID, summary.Status, summary.ReplyText)
}); err != nil {
return nil, err
}
// The resumed WorkflowRun is a child audit artifact. Keep the original
// Hybrid run as the summary run surfaced to the conversation caller.
summary.AgentRunID = interrupt.AgentRunID
return summary, nil
}
func hybridPlaybookToolDefinition(workflowVersionID int64) ai.ToolDefinition {
return ai.ToolDefinition{Name: "run_playbook", Description: "Run the Agent's published deterministic Playbook when the customer needs a controlled business action.", Parameters: map[string]any{
"type": "object", "properties": map[string]any{"workflowVersionId": map[string]any{"type": "integer", "description": "The bound Playbook version."}}, "required": []string{"workflowVersionId"},
}}
}
func parseHybridPlaybookCall(raw string) (int64, error) {
var input struct {
WorkflowVersionID int64 `json:"workflowVersionId"`
}
if err := json.Unmarshal([]byte(raw), &input); err != nil || input.WorkflowVersionID <= 0 {
return 0, errorsx.InvalidParam("invalid playbook call")
}
return input.WorkflowVersionID, nil
}
func writeHybridResult(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount, retrieverCount int, skillContext autonomousSkillContext, playbook *Summary, responsePolicy autonomousResponsePolicy, toolCalls []svc.EngineToolCallInput) (*Summary, error) {
runID, err := writeHybridAudit(req, startedAt, result, inputPreview, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, false, nil)
if err != nil {
return nil, err
}
return &Summary{Status: "completed", ReplyText: strings.TrimSpace(result.Content), ModelName: result.ModelName, PromptTokens: result.PromptTokens, CompletionTokens: result.CompletionTokens, HistoryMessageCount: historyCount, RetrieverCount: retrieverCount, AgentRunID: runID, WorkflowRunID: workflowRunIDFromSummary(playbook)}, nil
}
func writeHybridAudit(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount, retrieverCount int, skillContext autonomousSkillContext, toolCalls []svc.EngineToolCallInput, responsePolicy autonomousResponsePolicy, interrupted bool, cause error) (int64, error) {
endedAt := time.Now()
status, errorMessage, outputPreview := "completed", "", ""
promptTokens, completionTokens := 0, 0
if interrupted {
status = "interrupted"
} else if cause != nil {
status, errorMessage = "failed", cause.Error()
} else if result != nil {
outputPreview, promptTokens, completionTokens = strings.TrimSpace(result.Content), result.PromptTokens, result.CompletionTokens
}
steps := autonomousAdditionalSteps(req, retrieverCount, nil, skillContext, responsePolicy)
for _, call := range toolCalls {
if call.ToolCode == hybridPlaybookToolCode {
steps = append(steps, svc.EngineStepInput{StepType: "playbook", StepCode: hybridPlaybookToolCode, WorkflowRunID: workflowRunIDFromToolResult(call.ResultPreview), Status: call.Status, InputPreview: call.ArgumentsPreview, OutputPreview: call.ResultPreview, ErrorMessage: call.ErrorMessage})
}
}
var runID int64
err := sqls.WithTransaction(func(tx *sqls.TxContext) error {
var recordErr error
runID, recordErr = svc.AgentRunService.RecordEngineRun(tx.Tx, svc.EngineAgentRunInput{ConversationID: req.Conversation.ID, AIAgentID: req.AIAgent.ID, AgentRevisionID: req.AIAgent.PublishedRevisionID, SourceMessageID: req.UserMessage.ID, EngineCode: "hybrid", Status: status, PromptTokens: promptTokens, CompletionTokens: completionTokens, StartedAt: startedAt, EndedAt: &endedAt, ErrorMessage: errorMessage, TraceData: `{"engine":"hybrid"}`, StepType: "model", StepCode: "chat_completion", StepInputPreview: inputPreview, StepOutputPreview: outputPreview, AdditionalSteps: steps, ToolCalls: toolCalls})
return recordErr
})
return runID, err
}
func workflowRunIDFromSummary(summary *Summary) int64 {
if summary == nil {
return 0
}
return summary.WorkflowRunID
}
func workflowRunIDFromToolResult(value string) int64 {
var id int64
_, _ = fmt.Sscanf(value, "workflowRunId=%d", &id)
return id
}
var _ Engine = (*HybridEngine)(nil)
+98 -80
View File
@@ -8,14 +8,15 @@ import (
workflowexecutor "agent-desk/internal/ai/runtime/workflow" workflowexecutor "agent-desk/internal/ai/runtime/workflow"
"agent-desk/internal/models" "agent-desk/internal/models"
"agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories" "agent-desk/internal/repositories"
svc "agent-desk/internal/services"
"github.com/mlogclub/simple/sqls" "github.com/mlogclub/simple/sqls"
) )
type Service struct { type Service struct {
registry *EngineRegistry
} }
const ( const (
@@ -25,91 +26,46 @@ const (
) )
func NewService() *Service { func NewService() *Service {
return &Service{} return NewServiceWithRegistry(NewDefaultEngineRegistry())
} }
func (s *Service) Run(ctx context.Context, req Request) (*Summary, error) { func NewServiceWithRegistry(registry *EngineRegistry) *Service {
req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content) return &Service{registry: registry}
aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent) }
if err != nil {
_, _ = writeWorkflowPrepareFailedRun(req, err.Error()) func (s *Service) Run(ctx context.Context, req RunInput) (*RunResult, error) {
return nil, err engine, err := s.registry.Resolve(resolveEngineCode(req.AIAgent.RuntimeMode))
}
req.AIAgent = aiAgent
workflowResult, err := workflowexecutor.NewExecutor().Execute(ctx, workflowexecutor.Input{
Definition: workflow.Definition,
Conversation: req.Conversation,
UserMessage: req.UserMessage,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
})
if err != nil {
if workflowResult != nil {
_, _ = writeWorkflowRun(req, workflow, workflowResult, err.Error())
}
return nil, err
}
workflowRunID, err := writeWorkflowRun(req, workflow, workflowResult, "")
if err != nil { if err != nil {
return nil, err return nil, err
} }
return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID), nil return engine.Run(ctx, req)
} }
func (s *Service) Resume(ctx context.Context, req ResumeRequest) (*Summary, error) { func (s *Service) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent) engine, err := s.registry.Resolve(resolveEngineCode(req.AIAgent.RuntimeMode))
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.AIAgent = aiAgent return engine.Resume(ctx, req)
if interrupt := repositories.ConversationInterruptRepository.GetByCheckPointID(sqls.DB(), req.CheckPointID); interrupt != nil {
if strings.TrimSpace(interrupt.RequestData) == "" {
if interrupt.WorkflowRunID > 0 || strings.HasPrefix(strings.TrimSpace(req.CheckPointID), "workflow:") {
return nil, errorsx.InvalidParam("workflow checkpoint data is required")
}
} else {
workflowResult, err := workflowexecutor.NewExecutor().Resume(ctx, workflowexecutor.Input{
Definition: workflow.Definition,
Conversation: req.Conversation,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
}, interrupt.RequestData, firstWorkflowResumeText(req.ResumeData))
if err != nil {
if workflowResult != nil {
_, _ = writeWorkflowRunWithExistingID(Request{
Conversation: req.Conversation,
UserMessage: req.UserMessage,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
}, workflow, workflowResult, err.Error(), interrupt.WorkflowRunID)
}
return nil, err
}
workflowRunID, err := writeWorkflowRunWithExistingID(Request{
Conversation: req.Conversation,
UserMessage: req.UserMessage,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
}, workflow, workflowResult, "", interrupt.WorkflowRunID)
if err != nil {
return nil, err
}
return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID), nil
}
}
return nil, errorsx.InvalidParam("legacy checkpoint is not supported; please start a new workflow reply")
} }
func firstWorkflowResumeText(data map[string]string) string { // RunOfflineEvaluation executes an explicitly selected Engine against isolated
for _, value := range data { // Debug inputs. It does not rely on the Agent's configured runtime mode, which
if strings.TrimSpace(value) != "" { // makes Workflow/Autonomous/Hybrid comparisons possible against one revision.
return strings.TrimSpace(value) func (s *Service) RunOfflineEvaluation(ctx context.Context, engineCode string, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) (OfflineEvaluationReport, error) {
} engine, err := s.registry.Resolve(strings.TrimSpace(engineCode))
if err != nil {
return OfflineEvaluationReport{EngineCode: strings.TrimSpace(engineCode)}, err
} }
return "" runner := NewOfflineEvaluationRunner(engine.Run)
return runner.Run(ctx, engine.Code(), agent, config, cases), nil
} }
func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workflow resolvedWorkflow, workflowRunID int64) *Summary { func resolveEngineCode(mode enums.AIAgentRuntimeMode) string {
return strings.TrimSpace(string(mode))
}
func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workflow resolvedWorkflow, workflowRunID int64, agentRunID int64) *Summary {
if result == nil { if result == nil {
return nil return nil
} }
@@ -131,6 +87,7 @@ func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workfl
WorkflowID: workflow.WorkflowID, WorkflowID: workflow.WorkflowID,
WorkflowVersionID: workflow.VersionID, WorkflowVersionID: workflow.VersionID,
WorkflowRunID: workflowRunID, WorkflowRunID: workflowRunID,
AgentRunID: agentRunID,
WorkflowNodePath: append([]string(nil), result.NodePath...), WorkflowNodePath: append([]string(nil), result.NodePath...),
TraceData: string(traceData), TraceData: string(traceData),
CheckPointID: result.CheckPointID, CheckPointID: result.CheckPointID,
@@ -155,7 +112,7 @@ func toWorkflowInterruptSummaries(items []workflowexecutor.InterruptSummary) []I
return ret return ret
} }
func writeWorkflowRun(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string) (int64, error) { func writeWorkflowRun(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string) (int64, int64, error) {
return writeWorkflowRunWithExistingID(req, workflow, result, errorMessage, 0) return writeWorkflowRunWithExistingID(req, workflow, result, errorMessage, 0)
} }
@@ -180,15 +137,38 @@ func writeWorkflowPrepareFailedRun(req Request, errorMessage string) (int64, err
EndedAt: &endedAt, EndedAt: &endedAt,
ErrorMessage: errorMessage, ErrorMessage: errorMessage,
} }
if err := repositories.AIWorkflowRunRepository.Create(sqls.DB(), run); err != nil { err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
return 0, err if err := repositories.AIWorkflowRunRepository.Create(ctx.Tx, run); err != nil {
} return err
return run.ID, nil }
traceData, _ := json.Marshal(map[string]any{
"status": "error",
"workflowId": workflowID,
"workflowVersionId": workflowVersionID,
"workflowRunId": run.ID,
})
_, err := svc.AgentRunService.RecordWorkflowRun(ctx.Tx, svc.WorkflowAgentRunInput{
WorkflowRunID: run.ID,
WorkflowVersionID: workflowVersionID,
ConversationID: req.Conversation.ID,
AIAgentID: req.AIAgent.ID,
SourceMessageID: req.UserMessage.ID,
Status: "failed",
StartedAt: now,
EndedAt: &endedAt,
ErrorMessage: errorMessage,
TraceData: string(traceData),
StepInputPreview: "workflow preparation",
StepOutputPreview: "",
})
return err
})
return run.ID, err
} }
func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string, existingRunID int64) (int64, error) { func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string, existingRunID int64) (int64, int64, error) {
if result == nil { if result == nil {
return 0, nil return 0, 0, nil
} }
now := time.Now() now := time.Now()
endedAt := now endedAt := now
@@ -198,6 +178,7 @@ func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, resu
} }
runStatus := workflowRunStatus(result.Status, errorMessage) runStatus := workflowRunStatus(result.Status, errorMessage)
var runID int64 var runID int64
var agentRunID int64
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
run := repositories.AIWorkflowRunRepository.Get(ctx.Tx, existingRunID) run := repositories.AIWorkflowRunRepository.Get(ctx.Tx, existingRunID)
if run == nil { if run == nil {
@@ -249,9 +230,46 @@ func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, resu
return err 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 nil
}) })
return runID, err return runID, agentRunID, err
}
func workflowAgentRunStatus(status string, errorMessage string) string {
if strings.TrimSpace(errorMessage) != "" || strings.TrimSpace(status) == "error" {
return "failed"
}
if strings.TrimSpace(status) == "interrupted" {
return "interrupted"
}
return "completed"
} }
func workflowRunStatus(status string, errorMessage string) int { func workflowRunStatus(status string, errorMessage string) int {
+48 -3
View File
@@ -2,32 +2,49 @@ package runtime
import ( import (
"agent-desk/internal/models" "agent-desk/internal/models"
"time"
) )
type Request struct { // RunInput is the normalized, fully prepared input shared by all Engine
// implementations. Persistent adapters load this object before dispatching
// into the runtime.
type RunInput struct {
Conversation models.Conversation Conversation models.Conversation
UserMessage models.Message UserMessage models.Message
AIAgent models.AIAgent AIAgent models.AIAgent
AIConfig models.AIConfig AIConfig models.AIConfig
CheckPointID string CheckPointID string
Debug bool
} }
type ResumeRequest struct { // Request remains as a compatibility alias while callers move to RunInput.
type Request = RunInput
// ResumeInput extends the prepared input with an approved interrupt payload.
// It deliberately carries the same persisted context as RunInput so resume
// semantics are consistent across Workflow, Autonomous, and Hybrid engines.
type ResumeInput struct {
Conversation models.Conversation Conversation models.Conversation
UserMessage models.Message UserMessage models.Message
AIAgent models.AIAgent AIAgent models.AIAgent
AIConfig models.AIConfig AIConfig models.AIConfig
CheckPointID string CheckPointID string
ResumeData map[string]string ResumeData map[string]string
Debug bool
} }
// ResumeRequest remains as a compatibility alias while callers move to ResumeInput.
type ResumeRequest = ResumeInput
type InterruptContextSummary struct { type InterruptContextSummary struct {
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
ID string `json:"id"` ID string `json:"id"`
InfoPreview string `json:"infoPreview,omitempty"` InfoPreview string `json:"infoPreview,omitempty"`
} }
type Summary struct { // RunResult is the normalized result returned by every Engine. Engine-specific
// details are represented by optional fields rather than engine-specific DTOs.
type RunResult struct {
RunID string RunID string
Status string Status string
ReplyText string ReplyText string
@@ -47,11 +64,39 @@ type Summary struct {
WorkflowID int64 WorkflowID int64
WorkflowVersionID int64 WorkflowVersionID int64
WorkflowRunID int64 WorkflowRunID int64
AgentRunID int64
WorkflowNodePath []string WorkflowNodePath []string
CheckPointID string CheckPointID string
CheckPointData string CheckPointData string
Interrupted bool Interrupted bool
HandoffRequested bool
Interrupts []InterruptContextSummary Interrupts []InterruptContextSummary
TraceData string TraceData string
ErrorMessage string ErrorMessage string
} }
// Summary remains as a compatibility alias while callers move to RunResult.
type Summary = RunResult
type StreamEventType string
const (
StreamEventStarted StreamEventType = "started"
StreamEventStep StreamEventType = "step"
StreamEventOutput StreamEventType = "output"
StreamEventCompleted StreamEventType = "completed"
StreamEventFailed StreamEventType = "failed"
)
// StreamEvent is the transport-neutral event contract for future streaming
// endpoints. Engines may emit partial output, audit steps, or a terminal state
// without exposing engine-specific event payloads to callers.
type StreamEvent struct {
Type StreamEventType `json:"type"`
RunID string `json:"runId,omitempty"`
AgentRunID int64 `json:"agentRunId,omitempty"`
StepCode string `json:"stepCode,omitempty"`
Content string `json:"content,omitempty"`
Error string `json:"error,omitempty"`
OccurredAt time.Time `json:"occurredAt"`
}
@@ -0,0 +1,112 @@
package runtime
import (
"context"
"strings"
workflowexecutor "agent-desk/internal/ai/runtime/workflow"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
)
// WorkflowEngine preserves the existing FlowGram DSL execution path as the
// first Agent Runtime engine. It remains the compatibility default for agents
// created before autonomous and hybrid modes are available.
type WorkflowEngine struct{}
func NewWorkflowEngine() *WorkflowEngine {
return &WorkflowEngine{}
}
func (e *WorkflowEngine) Code() string {
return EngineCodeWorkflow
}
func (e *WorkflowEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) {
req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content)
aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent)
if err != nil {
_, _ = writeWorkflowPrepareFailedRun(req, err.Error())
return nil, err
}
req.AIAgent = aiAgent
workflowResult, err := workflowexecutor.NewExecutor().Execute(ctx, workflowexecutor.Input{
Definition: workflow.Definition,
Conversation: req.Conversation,
UserMessage: req.UserMessage,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
Debug: req.Debug,
})
if err != nil {
if workflowResult != nil {
_, _, _ = writeWorkflowRun(req, workflow, workflowResult, err.Error())
}
return nil, err
}
workflowRunID, agentRunID, err := writeWorkflowRun(req, workflow, workflowResult, "")
if err != nil {
return nil, err
}
return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID, agentRunID), nil
}
func (e *WorkflowEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent)
if err != nil {
return nil, err
}
req.AIAgent = aiAgent
interrupt := repositories.ConversationInterruptRepository.GetByCheckPointID(sqls.DB(), req.CheckPointID)
if interrupt == nil {
return nil, errorsx.InvalidParam("legacy checkpoint is not supported; please start a new workflow reply")
}
if strings.TrimSpace(interrupt.RequestData) == "" {
if interrupt.WorkflowRunID > 0 || strings.HasPrefix(strings.TrimSpace(req.CheckPointID), "workflow:") {
return nil, errorsx.InvalidParam("workflow checkpoint data is required")
}
return nil, errorsx.InvalidParam("legacy checkpoint is not supported; please start a new workflow reply")
}
workflowResult, err := workflowexecutor.NewExecutor().Resume(ctx, workflowexecutor.Input{
Definition: workflow.Definition,
Conversation: req.Conversation,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
Debug: req.Debug,
}, interrupt.RequestData, firstWorkflowResumeText(req.ResumeData))
if err != nil {
if workflowResult != nil {
_, _, _ = writeWorkflowRunWithExistingID(Request{
Conversation: req.Conversation,
UserMessage: req.UserMessage,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
}, workflow, workflowResult, err.Error(), interrupt.WorkflowRunID)
}
return nil, err
}
workflowRunID, agentRunID, err := writeWorkflowRunWithExistingID(Request{
Conversation: req.Conversation,
UserMessage: req.UserMessage,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
}, workflow, workflowResult, "", interrupt.WorkflowRunID)
if err != nil {
return nil, err
}
return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID, agentRunID), nil
}
func firstWorkflowResumeText(data map[string]string) string {
for _, value := range data {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
var _ Engine = (*WorkflowEngine)(nil)
@@ -5,12 +5,14 @@ import (
"encoding/json" "encoding/json"
"strings" "strings"
"testing" "testing"
"time"
workflowexecutor "agent-desk/internal/ai/runtime/workflow" workflowexecutor "agent-desk/internal/ai/runtime/workflow"
"agent-desk/internal/ai/workflow/dsl" "agent-desk/internal/ai/workflow/dsl"
workflowregistry "agent-desk/internal/ai/workflow/registry" workflowregistry "agent-desk/internal/ai/workflow/registry"
"agent-desk/internal/models" "agent-desk/internal/models"
"agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/enums"
svc "agent-desk/internal/services"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls" "github.com/mlogclub/simple/sqls"
@@ -27,7 +29,7 @@ func TestToWorkflowSummaryPreservesInterruptCheckpoint(t *testing.T) {
Interrupts: []workflowexecutor.InterruptSummary{ Interrupts: []workflowexecutor.InterruptSummary{
{Type: "human_confirm", ID: "confirm_1", InfoPreview: `{"message":"请确认"}`}, {Type: "human_confirm", ID: "confirm_1", InfoPreview: `{"message":"请确认"}`},
}, },
}, "test-model", resolvedWorkflow{WorkflowID: 11, VersionID: 22}, 33) }, "test-model", resolvedWorkflow{WorkflowID: 11, VersionID: 22}, 33, 44)
if summary == nil || !summary.Interrupted { if summary == nil || !summary.Interrupted {
t.Fatalf("expected interrupted summary, got %#v", summary) t.Fatalf("expected interrupted summary, got %#v", summary)
@@ -41,6 +43,9 @@ func TestToWorkflowSummaryPreservesInterruptCheckpoint(t *testing.T) {
if summary.WorkflowID != 11 || summary.WorkflowVersionID != 22 || summary.WorkflowRunID != 33 { 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) 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" { if len(summary.Interrupts) != 1 || summary.Interrupts[0].ID != "confirm_1" {
t.Fatalf("unexpected interrupts: %#v", summary.Interrupts) t.Fatalf("unexpected interrupts: %#v", summary.Interrupts)
} }
@@ -125,6 +130,9 @@ func TestServiceResumeUsesWorkflowCheckpointData(t *testing.T) {
if summary.WorkflowRunID <= 0 { if summary.WorkflowRunID <= 0 {
t.Fatalf("expected workflow run id in resume summary") 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 var run models.AIWorkflowRun
if err := db.First(&run, summary.WorkflowRunID).Error; err != nil { if err := db.First(&run, summary.WorkflowRunID).Error; err != nil {
t.Fatalf("find resume workflow run: %v", err) t.Fatalf("find resume workflow run: %v", err)
@@ -132,6 +140,68 @@ func TestServiceResumeUsesWorkflowCheckpointData(t *testing.T) {
if run.MessageID != 2 || run.Status != workflowRunStatusCompleted { if run.MessageID != 2 || run.Status != workflowRunStatusCompleted {
t.Fatalf("unexpected resume workflow run: %#v", run) 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) { func TestServiceResumeReusesInterruptedWorkflowRun(t *testing.T) {
@@ -262,6 +332,13 @@ func TestServiceRunWritesFailedWorkflowRun(t *testing.T) {
if badNodeRun.Status != workflowRunStatusFailed || badNodeRun.ErrorMessage == "" { if badNodeRun.Status != workflowRunStatusFailed || badNodeRun.ErrorMessage == "" {
t.Fatalf("unexpected failed node run: %#v", badNodeRun) 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) { func TestServiceRunWritesFailedWorkflowRunWhenVersionDisabled(t *testing.T) {
@@ -324,7 +401,15 @@ func setupWorkflowResumeTestDB(t *testing.T) *gorm.DB {
_ = sqlDB.Close() _ = sqlDB.Close()
} }
}) })
if err := db.AutoMigrate(&models.AIWorkflowVersion{}, &models.AIWorkflowRun{}, &models.AIWorkflowNodeRun{}, &models.ConversationInterrupt{}); err != nil { if err := db.AutoMigrate(
&models.AIWorkflowVersion{},
&models.AIWorkflowRun{},
&models.AIWorkflowNodeRun{},
&models.AgentRun{},
&models.AgentStep{},
&models.AgentRevision{},
&models.ConversationInterrupt{},
); err != nil {
t.Fatalf("auto migrate: %v", err) t.Fatalf("auto migrate: %v", err)
} }
sqls.SetDB(db) sqls.SetDB(db)
+4 -2
View File
@@ -49,11 +49,12 @@ func DebugRunSkill(ctx context.Context, req request.SkillDebugRunRequest) (*resp
MessageType: enums.IMMessageTypeText, MessageType: enums.IMMessageTypeText,
Content: strings.TrimSpace(req.UserMessage), Content: strings.TrimSpace(req.UserMessage),
} }
summary, err := Service.Run(ctx, applicationruntime.Request{ summary, err := applicationruntime.DefaultAgentApplicationService.RunPrepared(ctx, applicationruntime.Request{
Conversation: *conversation, Conversation: *conversation,
UserMessage: message, UserMessage: message,
AIAgent: debugAgent, AIAgent: debugAgent,
AIConfig: *aiConfig, AIConfig: *aiConfig,
Debug: true,
}) })
if err != nil { if err != nil {
return buildSkillDebugRunResponse(req, summary, skill), err return buildSkillDebugRunResponse(req, summary, skill), err
@@ -92,7 +93,7 @@ func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest)
return nil, errorsx.InvalidParamI18n("error.e0117") return nil, errorsx.InvalidParamI18n("error.e0117")
} }
resumeText := strings.TrimSpace(req.UserMessage) resumeText := strings.TrimSpace(req.UserMessage)
summary, err := Service.Resume(ctx, applicationruntime.ResumeRequest{ summary, err := applicationruntime.DefaultAgentApplicationService.ResumePrepared(ctx, applicationruntime.ResumeRequest{
Conversation: *conversation, Conversation: *conversation,
AIAgent: *aiAgent, AIAgent: *aiAgent,
AIConfig: *aiConfig, AIConfig: *aiConfig,
@@ -100,6 +101,7 @@ func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest)
ResumeData: map[string]string{ ResumeData: map[string]string{
strings.TrimSpace(pendingInterrupt.InterruptID): resumeText, strings.TrimSpace(pendingInterrupt.InterruptID): resumeText,
}, },
Debug: true,
}) })
if err != nil { if err != nil {
if isCheckpointMissingError(err) { if isCheckpointMissingError(err) {
+44
View File
@@ -0,0 +1,44 @@
package runtime
import (
"context"
applicationruntime "agent-desk/internal/ai/application/runtime"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
svc "agent-desk/internal/services"
)
func init() {
svc.AgentEvaluationRunHook = RunAgentEvaluation
}
func RunAgentEvaluation(ctx context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) {
agent := svc.AIAgentService.Get(req.AIAgentID)
if agent == nil || agent.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0007")
}
config := svc.AIConfigService.Get(agent.AIConfigID)
if config == nil {
return nil, errorsx.InvalidParamI18n("error.e0008")
}
cases := make([]applicationruntime.OfflineEvaluationCase, 0, len(req.Cases))
for _, item := range req.Cases {
cases = append(cases, applicationruntime.OfflineEvaluationCase{ID: item.ID, Category: item.Category, Message: item.Message, History: item.History, Expect: item.Expect})
}
report, err := applicationruntime.NewService().RunOfflineEvaluation(ctx, req.EngineCode, *agent, *config, cases)
if err != nil {
return nil, err
}
csv, err := report.CSV()
if err != nil {
return nil, err
}
ret := &response.AgentEvaluationReportResponse{EngineCode: report.EngineCode, Total: report.Total, Passed: report.Passed, CSV: csv, Results: make([]response.AgentEvaluationResultResponse, 0, len(report.Results))}
for _, item := range report.Results {
ret.Results = append(ret.Results, response.AgentEvaluationResultResponse{CaseID: item.CaseID, Category: item.Category, EngineCode: item.EngineCode, Passed: item.Passed, ReplyText: item.ReplyText, Interrupted: item.Interrupted, Error: item.Error, Finding: item.Finding})
}
return ret, nil
}
@@ -0,0 +1,79 @@
// Package readtools executes deterministic, read-only graph tools through the
// shared Tool Registry boundary.
package readtools
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"agent-desk/internal/ai/runtime/graphs"
"agent-desk/internal/ai/runtime/retrievers"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"agent-desk/internal/pkg/toolx"
)
func ExecuteGraphTool(ctx context.Context, conversation models.Conversation, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, string, error) {
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
if toolCode != toolx.GraphTriageServiceRequest.Code && toolCode != toolx.GraphAnalyzeConversation.Code && toolCode != toolx.GraphPrepareTicketDraft.Code {
return aitooling.Definition{}, "", fmt.Errorf("tool is not a graph read tool")
}
definition, err := aitooling.DefaultRegistry.Resolve(toolCode)
if err != nil {
return aitooling.Definition{}, "", err
}
if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{
Definition: definition,
Arguments: arguments,
Policy: policy,
}); err != nil {
return definition, "", err
}
if definition.TimeoutMS > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond)
defer cancel()
}
data, err := json.Marshal(arguments)
if err != nil {
return definition, "", err
}
switch toolCode {
case toolx.GraphTriageServiceRequest.Code:
result, err := graphs.NewTriageServiceRequestGraph(conversation).Run(ctx, string(data))
return definition, result, err
case toolx.GraphAnalyzeConversation.Code:
result, err := graphs.NewAnalyzeConversationGraph(conversation).Run(ctx, string(data))
return definition, result, err
default:
result, err := graphs.NewPrepareTicketDraftGraph(conversation).Run(ctx, string(data))
return definition, result, err
}
}
// RetrieveKnowledge executes the built-in knowledge tool after the same
// registry policy and timeout checks used by graph tools.
func RetrieveKnowledge(ctx context.Context, agent models.AIAgent, knowledgeBaseIDs []int64, query string, policy aitooling.Policy) (aitooling.Definition, *retrievers.KnowledgeRetrieveResult, error) {
definition, err := aitooling.DefaultRegistry.Resolve(toolx.BuiltinKnowledgeRetrieve.Code)
if err != nil {
return aitooling.Definition{}, nil, err
}
arguments := map[string]any{"query": strings.TrimSpace(query), "knowledgeBaseIds": knowledgeBaseIDs}
if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{
Definition: definition,
Arguments: arguments,
Policy: policy,
}); err != nil {
return definition, nil, err
}
if definition.TimeoutMS > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond)
defer cancel()
}
result, err := retrievers.NewKnowledgeRetriever(agent, knowledgeBaseIDs).RetrieveContext(ctx, strings.TrimSpace(query))
return definition, result, err
}
@@ -0,0 +1,26 @@
package readtools
import (
"context"
"testing"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"agent-desk/internal/pkg/toolx"
)
func TestExecuteGraphToolRejectsDisallowedToolBeforeGraphExecution(t *testing.T) {
definition, _, err := ExecuteGraphTool(context.Background(), models.Conversation{}, toolx.GraphAnalyzeConversation.Code, map[string]any{
"observedIssue": "需要分析的问题",
}, aitooling.Policy{
AllowedToolCodes: []string{toolx.GraphPrepareTicketDraft.Code},
AllowedRiskLevels: []string{aitooling.RiskLevelRead},
Confirmed: true,
})
if err == nil {
t.Fatal("expected policy guard to reject the graph tool")
}
if definition.Code != toolx.GraphAnalyzeConversation.Code {
t.Fatalf("definition code = %q, want %q", definition.Code, toolx.GraphAnalyzeConversation.Code)
}
}
+5 -3
View File
@@ -3,6 +3,8 @@ package runtime
import ( import (
"fmt" "fmt"
"strings" "strings"
aitooling "agent-desk/internal/ai/tooling"
"time" "time"
"agent-desk/internal/models" "agent-desk/internal/models"
@@ -31,9 +33,9 @@ func newReplyCommitService() *replyCommitService {
} }
func (s *replyCommitService) SendAIReply(input replyCommitInput) (*models.Message, error) { func (s *replyCommitService) SendAIReply(input replyCommitInput) (*models.Message, error) {
replyText := strings.TrimSpace(input.ReplyText) replyText, err := aitooling.NormalizeCustomerReply(input.ReplyText)
if replyText == "" { if err != nil {
return nil, nil return nil, err
} }
replyMessage, err := svc.MessageService.SendAIMessageWithRequestIDAndWorkflowRunID( replyMessage, err := svc.MessageService.SendAIMessageWithRequestIDAndWorkflowRunID(
input.Conversation.ID, input.Conversation.ID,
@@ -46,6 +46,26 @@ func TestReplyCommitStoresWorkflowRunIDOnAIMessage(t *testing.T) {
} }
} }
func TestReplyCommitRejectsSensitiveModelOutput(t *testing.T) {
db := setupReplyCommitTestDB(t)
aiAgent := createReplyCommitTestAIAgent(t, db)
conversation := createReplyCommitTestConversation(t, db, aiAgent.ID)
_, err := newReplyCommitService().CommitAIReply(replyCommitInput{
Conversation: *conversation, Message: models.Message{ID: 102, RequestID: "trace-102"}, AIAgent: *aiAgent,
ReplyText: "authorization=Bearer-secret", ClientPrefix: "ai_reply",
})
if err == nil {
t.Fatal("expected sensitive model output to be rejected")
}
var count int64
if err := db.Model(&models.Message{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error; err != nil {
t.Fatalf("count messages: %v", err)
}
if count != 0 {
t.Fatalf("unexpected message written for rejected output: %d", count)
}
}
func setupReplyCommitTestDB(t *testing.T) *gorm.DB { func setupReplyCommitTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
dbName := "reply_commit_test_" + strings.NewReplacer("/", "_").Replace(t.Name()) dbName := "reply_commit_test_" + strings.NewReplacer("/", "_").Replace(t.Name())
+34
View File
@@ -1,6 +1,10 @@
package runtime package runtime
import ( import (
"crypto/sha256"
"encoding/binary"
"fmt"
"agent-desk/internal/models" "agent-desk/internal/models"
"agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/enums"
@@ -13,6 +17,36 @@ func newReplyEligibility() *replyEligibility {
return &replyEligibility{} return &replyEligibility{}
} }
// IsAIAgentRolloutEligible uses a stable conversation bucket so one customer
// remains consistently inside or outside a gray release throughout a session.
// Missing legacy values are treated as 100 to preserve existing behavior.
func IsAIAgentRolloutEligible(conversation models.Conversation, aiAgent models.AIAgent, channel *models.Channel) bool {
percent := normalizedRolloutPercent(aiAgent.RolloutPercent)
if channel != nil {
channelPercent := normalizedRolloutPercent(channel.AIAgentRolloutPercent)
if channelPercent < percent {
percent = channelPercent
}
}
if percent >= 100 {
return true
}
if conversation.ID <= 0 {
return false
}
seed := fmt.Sprintf("channel=%d;conversation=%d;agent=%d", conversation.ChannelID, conversation.ID, aiAgent.ID)
sum := sha256.Sum256([]byte(seed))
bucket := int(binary.BigEndian.Uint64(sum[:8]) % 100)
return bucket < percent
}
func normalizedRolloutPercent(percent int) int {
if percent <= 0 || percent > 100 {
return 100
}
return percent
}
func (e *replyEligibility) CanReply(conversation models.Conversation, message models.Message, aiAgent models.AIAgent) bool { func (e *replyEligibility) CanReply(conversation models.Conversation, message models.Message, aiAgent models.AIAgent) bool {
if message.SenderType != enums.IMSenderTypeCustomer { if message.SenderType != enums.IMSenderTypeCustomer {
return false return false
+2 -1
View File
@@ -48,6 +48,7 @@ func TestBuildConversationInterruptStoresWorkflowCheckpointData(t *testing.T) {
CheckPointData: `{"confirmNodeId":"confirm_1"}`, CheckPointData: `{"confirmNodeId":"confirm_1"}`,
Interrupted: true, Interrupted: true,
WorkflowRunID: 99, WorkflowRunID: 99,
AgentRunID: 88,
Interrupts: []applicationruntime.InterruptContextSummary{ Interrupts: []applicationruntime.InterruptContextSummary{
{Type: "human_confirm", ID: "confirm_1", InfoPreview: `{"message":"请确认"}`}, {Type: "human_confirm", ID: "confirm_1", InfoPreview: `{"message":"请确认"}`},
}, },
@@ -58,7 +59,7 @@ func TestBuildConversationInterruptStoresWorkflowCheckpointData(t *testing.T) {
if item.RequestData != `{"confirmNodeId":"confirm_1"}` { if item.RequestData != `{"confirmNodeId":"confirm_1"}` {
t.Fatalf("unexpected request data: %q", item.RequestData) t.Fatalf("unexpected request data: %q", item.RequestData)
} }
if item.WorkflowRunID != 99 || item.WorkflowNodeID != "confirm_1" { if item.WorkflowRunID != 99 || item.AgentRunID != 88 || item.WorkflowNodeID != "confirm_1" {
t.Fatalf("unexpected workflow interrupt identity: run=%d node=%q", item.WorkflowRunID, item.WorkflowNodeID) t.Fatalf("unexpected workflow interrupt identity: run=%d node=%q", item.WorkflowRunID, item.WorkflowNodeID)
} }
} }
@@ -29,6 +29,7 @@ func buildConversationInterrupt(conversation models.Conversation, message models
} }
item.ConversationID = conversation.ID item.ConversationID = conversation.ID
item.AIAgentID = aiAgent.ID item.AIAgentID = aiAgent.ID
item.AgentRunID = summary.AgentRunID
item.SourceMessageID = message.ID item.SourceMessageID = message.ID
item.InterruptID = firstInterruptID(summary) item.InterruptID = firstInterruptID(summary)
item.InterruptType = firstInterruptType(summary) item.InterruptType = firstInterruptType(summary)
@@ -82,6 +82,9 @@ func (s *replyInterruptService) ResumePendingInterrupt(ctx context.Context, owne
func (s *replyInterruptService) HandleInterruptedSummary(owner *aiReplyService, replyCtx aiReplyContext, summary *applicationruntime.Summary) error { func (s *replyInterruptService) HandleInterruptedSummary(owner *aiReplyService, replyCtx aiReplyContext, summary *applicationruntime.Summary) error {
pending := buildConversationInterrupt(replyCtx.Conversation, replyCtx.Message, replyCtx.AIAgent, summary) pending := buildConversationInterrupt(replyCtx.Conversation, replyCtx.Message, replyCtx.AIAgent, summary)
if pending != nil && pending.AgentRunID > 0 {
pending.AgentStepID = svc.AgentRunService.GetLatestStepID(pending.AgentRunID)
}
if err := svc.ConversationInterruptService.CreateOrUpdatePending(pending); err != nil { if err := svc.ConversationInterruptService.CreateOrUpdatePending(pending); err != nil {
return err return err
} }
+17
View File
@@ -49,6 +49,23 @@ func TestReplyEligibilityCanReply(t *testing.T) {
} }
} }
func TestAIAgentRolloutUsesStableConversationBucket(t *testing.T) {
conversation := models.Conversation{ID: 101, ChannelID: 7}
agent := models.AIAgent{ID: 9, RolloutPercent: 50}
first := IsAIAgentRolloutEligible(conversation, agent, &models.Channel{AIAgentRolloutPercent: 100})
for range 20 {
if got := IsAIAgentRolloutEligible(conversation, agent, &models.Channel{AIAgentRolloutPercent: 100}); got != first {
t.Fatalf("rollout bucket changed within one conversation: first=%t got=%t", first, got)
}
}
if normalizedRolloutPercent(0) != 100 || normalizedRolloutPercent(101) != 100 || normalizedRolloutPercent(25) != 25 {
t.Fatal("unexpected rollout percent normalization")
}
if !IsAIAgentRolloutEligible(conversation, models.AIAgent{ID: 9, RolloutPercent: 0}, &models.Channel{}) {
t.Fatal("legacy zero rollout values must preserve full rollout")
}
}
func TestResolveReplyTimeout(t *testing.T) { func TestResolveReplyTimeout(t *testing.T) {
service := newAIReplyService() service := newAIReplyService()
aiAgent := newAIAgentFixture() aiAgent := newAIAgentFixture()
@@ -58,6 +58,9 @@ func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.C
if s.eligibility != nil && !s.eligibility.CanReply(conversation, message, aiAgent) { if s.eligibility != nil && !s.eligibility.CanReply(conversation, message, aiAgent) {
return nil return nil
} }
if !IsAIAgentRolloutEligible(conversation, aiAgent, svc.ChannelService.Get(conversation.ChannelID)) {
return nil
}
if pendingInterrupt := svc.ConversationInterruptService.FindLatestPendingByConversationID(conversation.ID); pendingInterrupt != nil { if pendingInterrupt := svc.ConversationInterruptService.FindLatestPendingByConversationID(conversation.ID); pendingInterrupt != nil {
replyCtx.PendingInterrupt = pendingInterrupt replyCtx.PendingInterrupt = pendingInterrupt
return s.resumePendingInterrupt(ctx, replyCtx) return s.resumePendingInterrupt(ctx, replyCtx)
@@ -82,6 +85,16 @@ func (s *aiReplyService) executeReply(ctx context.Context, replyCtx aiReplyConte
if summary != nil && summary.Interrupted { if summary != nil && summary.Interrupted {
return s.interrupts.HandleInterruptedSummary(s, replyCtx, summary) return s.interrupts.HandleInterruptedSummary(s, replyCtx, summary)
} }
if summary != nil && summary.HandoffRequested {
if _, err := svc.ConversationHumanDispatchService.HandoffByAIWithRequestID(
replyCtx.Conversation.ID,
replyCtx.AIAgent,
"knowledge evidence unavailable",
replyCtx.Message.RequestID,
); err == nil {
return nil
}
}
if summary != nil && strings.TrimSpace(summary.ReplyText) != "" { if summary != nil && strings.TrimSpace(summary.ReplyText) != "" {
_, err := s.commit.CommitAIReply(replyCommitInput{ _, err := s.commit.CommitAIReply(replyCommitInput{
Conversation: replyCtx.Conversation, Conversation: replyCtx.Conversation,
+10 -19
View File
@@ -8,7 +8,6 @@ import (
applicationruntime "agent-desk/internal/ai/application/runtime" applicationruntime "agent-desk/internal/ai/application/runtime"
"agent-desk/internal/ai/runtime/graphs" "agent-desk/internal/ai/runtime/graphs"
"agent-desk/internal/models" "agent-desk/internal/models"
svc "agent-desk/internal/services"
) )
type runtimeReplyExecutor struct{} type runtimeReplyExecutor struct{}
@@ -31,15 +30,10 @@ func newRuntimeReplyExecutor() *runtimeReplyExecutor {
} }
func (e *runtimeReplyExecutor) Run(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.Summary, error) { func (e *runtimeReplyExecutor) Run(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.Summary, error) {
aiConfig := svc.AIConfigService.Get(input.AIAgent.AIConfigID) summary, err := applicationruntime.DefaultAgentApplicationService.Run(ctx, applicationruntime.ApplicationRunInput{
if aiConfig == nil { ConversationID: input.Conversation.ID,
return nil, fmt.Errorf("ai config is nil") MessageID: input.Message.ID,
} AIAgentID: input.AIAgent.ID,
summary, err := Service.Run(ctx, applicationruntime.Request{
Conversation: input.Conversation,
UserMessage: input.Message,
AIAgent: input.AIAgent,
AIConfig: *aiConfig,
}) })
return summary, err return summary, err
} }
@@ -48,15 +42,12 @@ func (e *runtimeReplyExecutor) ResumePendingInterrupt(ctx context.Context, input
if input.PendingInterrupt == nil { if input.PendingInterrupt == nil {
return nil, fmt.Errorf("pending interrupt is required") return nil, fmt.Errorf("pending interrupt is required")
} }
aiConfig := svc.AIConfigService.Get(input.AIAgent.AIConfigID) summary, err := applicationruntime.DefaultAgentApplicationService.Resume(ctx, applicationruntime.ApplicationResumeInput{
if aiConfig == nil { ApplicationRunInput: applicationruntime.ApplicationRunInput{
return nil, fmt.Errorf("ai config is nil") ConversationID: input.Conversation.ID,
} MessageID: input.Message.ID,
summary, err := Service.Resume(ctx, applicationruntime.ResumeRequest{ AIAgentID: input.AIAgent.ID,
Conversation: input.Conversation, },
UserMessage: input.Message,
AIAgent: input.AIAgent,
AIConfig: *aiConfig,
CheckPointID: strings.TrimSpace(input.PendingInterrupt.CheckPointID), CheckPointID: strings.TrimSpace(input.PendingInterrupt.CheckPointID),
ResumeData: map[string]string{ ResumeData: map[string]string{
strings.TrimSpace(input.PendingInterrupt.InterruptID): strings.TrimSpace(input.Message.Content), strings.TrimSpace(input.PendingInterrupt.InterruptID): strings.TrimSpace(input.Message.Content),
@@ -10,6 +10,7 @@ import (
"agent-desk/internal/ai/mcps" "agent-desk/internal/ai/mcps"
"agent-desk/internal/ai/runtime/registry" "agent-desk/internal/ai/runtime/registry"
"agent-desk/internal/ai/runtime/tooling" "agent-desk/internal/ai/runtime/tooling"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/pkg/i18nx" "agent-desk/internal/pkg/i18nx"
"agent-desk/internal/pkg/toolx" "agent-desk/internal/pkg/toolx"
@@ -167,11 +168,17 @@ func (t *ToolSearchTool) invokeTargetTool(ctx context.Context, toolCode string,
if !containsToolCode(t.allowedToolCodes, toolCode) { if !containsToolCode(t.allowedToolCodes, toolCode) {
return "", i18nx.Errorf("error.e0279") return "", i18nx.Errorf("error.e0279")
} }
result, err := mcps.Runtime.CallTool(ctx, serverCode, toolName, cloneArguments(arguments)) // A workflow administrator's allow-list is the explicit approval boundary
// for MCP tools. The registry still enforces its call limit and normalizes
// the safety metadata used by future autonomous engines.
_, result, err := aitooling.DefaultMCPExecutor.Execute(ctx, toolCode, arguments, aitooling.Policy{
AllowedToolCodes: t.allowedToolCodes,
Confirmed: true,
})
if err != nil { if err != nil {
return "", err return "", err
} }
return buildToolCallResultSummary(result), nil return aitooling.SanitizePreview(buildToolCallResultSummary(result)), nil
} }
func (t *ToolSearchTool) loadAllowedCandidates(ctx context.Context) ([]toolSearchCandidate, error) { func (t *ToolSearchTool) loadAllowedCandidates(ctx context.Context) ([]toolSearchCandidate, error) {
+85 -45
View File
@@ -13,13 +13,13 @@ import (
"agent-desk/internal/ai" "agent-desk/internal/ai"
"agent-desk/internal/ai/runtime/graphs" "agent-desk/internal/ai/runtime/graphs"
"agent-desk/internal/ai/runtime/retrievers" "agent-desk/internal/ai/runtime/readtools"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/ai/workflow/dsl" "agent-desk/internal/ai/workflow/dsl"
workflowregistry "agent-desk/internal/ai/workflow/registry" workflowregistry "agent-desk/internal/ai/workflow/registry"
"agent-desk/internal/models" "agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/toolx"
"agent-desk/internal/services" "agent-desk/internal/services"
) )
@@ -34,6 +34,7 @@ type Input struct {
UserMessage models.Message UserMessage models.Message
AIAgent models.AIAgent AIAgent models.AIAgent
AIConfig models.AIConfig AIConfig models.AIConfig
Debug bool
} }
type Result struct { type Result struct {
@@ -355,21 +356,37 @@ func (e *Executor) executeCreateTicket(state *runState, node dsl.Node) error {
}) })
return nil return nil
} }
if state.input.Debug {
state.setNodeVars(node.ID, map[string]any{
"ticketId": int64(0), "ticketNo": "", "created": false,
"message": "调试运行不会创建工单。", "skipped": true,
})
return nil
}
draft := asMap(state.resolveInput(node, "ticketDraft")) draft := asMap(state.resolveInput(node, "ticketDraft"))
title := strings.TrimSpace(toString(draft["title"])) title := strings.TrimSpace(toString(draft["title"]))
description := strings.TrimSpace(toString(draft["description"])) description := strings.TrimSpace(toString(draft["description"]))
item, err := services.TicketService.CreateFromConversation(request.CreateTicketFromConversationRequest{ result, err := services.BusinessToolExecutor.Execute(context.Background(), services.BusinessToolInput{
ConversationID: state.input.Conversation.ID, Conversation: state.input.Conversation, AIAgent: state.input.AIAgent,
Title: title, ToolCode: toolx.GraphCreateTicketConfirm.Code, Arguments: map[string]any{"title": title, "description": description},
Description: description, IdempotencyKey: workflowToolIdempotencyKey(state, node), Confirmed: true,
}, workflowAIPrincipal(state.input.AIAgent)) })
if err != nil { if err != nil {
return err return err
} }
var output struct {
TicketID int64 `json:"ticketId"`
TicketNo string `json:"ticketNo"`
Created bool `json:"created"`
}
if err := json.Unmarshal([]byte(result.ResultData), &output); err != nil {
return err
}
item := &models.Ticket{ID: output.TicketID, TicketNo: output.TicketNo}
state.setNodeVars(node.ID, map[string]any{ state.setNodeVars(node.ID, map[string]any{
"ticketId": item.ID, "ticketId": item.ID,
"ticketNo": item.TicketNo, "ticketNo": item.TicketNo,
"created": true, "created": output.Created,
"message": buildTicketCreatedMessage(item), "message": buildTicketCreatedMessage(item),
}) })
return nil return nil
@@ -386,18 +403,6 @@ func buildTicketCreatedMessage(item *models.Ticket) string {
return "工单已创建,工单号:" + ticketNo + "。" return "工单已创建,工单号:" + ticketNo + "。"
} }
func workflowAIPrincipal(aiAgent models.AIAgent) *dto.AuthPrincipal {
username := strings.TrimSpace(aiAgent.Name)
if username == "" {
username = "AI"
}
return &dto.AuthPrincipal{
UserID: 0,
Username: username,
Nickname: username,
}
}
type workflowConversationUnderstanding struct { type workflowConversationUnderstanding struct {
NormalizedMessage string NormalizedMessage string
MessageIntent string MessageIntent string
@@ -610,11 +615,14 @@ func (e *Executor) executePrepareTicketDraft(ctx context.Context, state *runStat
if currentAttempt := strings.TrimSpace(readStringConfig(node.Data.Config, "currentAttempt")); currentAttempt != "" { if currentAttempt := strings.TrimSpace(readStringConfig(node.Data.Config, "currentAttempt")); currentAttempt != "" {
input.CurrentAttempt = currentAttempt input.CurrentAttempt = currentAttempt
} }
args, err := json.Marshal(input) _, raw, err := readtools.ExecuteGraphTool(ctx, state.input.Conversation, toolx.GraphPrepareTicketDraft.Code, map[string]any{
if err != nil { "title": input.Title,
return err "description": input.Description,
} "issue": input.Issue,
raw, err := graphs.NewPrepareTicketDraftGraph(state.input.Conversation).Run(ctx, string(args)) "impact": input.Impact,
"expectedOutcome": input.ExpectedOutcome,
"currentAttempt": input.CurrentAttempt,
}, workflowReadToolPolicy(toolx.GraphPrepareTicketDraft.Code))
if err != nil { if err != nil {
return err return err
} }
@@ -665,11 +673,14 @@ func (e *Executor) executeAnalyzeConversation(ctx context.Context, state *runSta
if strings.TrimSpace(readStringConfig(node.Data.Config, "additionalContext")) != "" { if strings.TrimSpace(readStringConfig(node.Data.Config, "additionalContext")) != "" {
input.AdditionalContext = strings.TrimSpace(readStringConfig(node.Data.Config, "additionalContext")) input.AdditionalContext = strings.TrimSpace(readStringConfig(node.Data.Config, "additionalContext"))
} }
args, err := json.Marshal(input) _, raw, err := readtools.ExecuteGraphTool(ctx, state.input.Conversation, toolx.GraphAnalyzeConversation.Code, map[string]any{
if err != nil { "goal": input.Goal,
return err "observedIssue": input.ObservedIssue,
} "needTicket": input.NeedTicket,
raw, err := graphs.NewAnalyzeConversationGraph(state.input.Conversation).Run(ctx, string(args)) "needHumanHandoff": input.NeedHumanHandoff,
"needQualityCheck": input.NeedQualityCheck,
"additionalContext": input.AdditionalContext,
}, workflowReadToolPolicy(toolx.GraphAnalyzeConversation.Code))
if err != nil { if err != nil {
return err return err
} }
@@ -687,6 +698,14 @@ func (e *Executor) executeAnalyzeConversation(ctx context.Context, state *runSta
return nil return nil
} }
func workflowReadToolPolicy(toolCode string) aitooling.Policy {
return aitooling.Policy{
AllowedToolCodes: []string{toolCode},
AllowedRiskLevels: []string{aitooling.RiskLevelRead},
Confirmed: true,
}
}
func (e *Executor) executeHandoffToHuman(state *runState, node dsl.Node) error { func (e *Executor) executeHandoffToHuman(state *runState, node dsl.Node) error {
if _, hasConfirmedInput := node.Data.InputsValues["confirmed"]; hasConfirmedInput && !truthy(state.resolveInput(node, "confirmed")) { if _, hasConfirmedInput := node.Data.InputsValues["confirmed"]; hasConfirmedInput && !truthy(state.resolveInput(node, "confirmed")) {
state.setNodeVars(node.ID, map[string]any{ state.setNodeVars(node.ID, map[string]any{
@@ -700,16 +719,32 @@ func (e *Executor) executeHandoffToHuman(state *runState, node dsl.Node) error {
}) })
return nil return nil
} }
if state.input.Debug {
state.setNodeVars(node.ID, map[string]any{
"handoffId": int64(0), "reason": strings.TrimSpace(toString(state.resolveInput(node, "reason"))),
"decision": "cancelled", "teamId": int64(0), "assigneeId": int64(0),
"message": "调试运行不会转人工。", "skipped": true,
})
return nil
}
reason := strings.TrimSpace(toString(state.resolveInput(node, "reason"))) reason := strings.TrimSpace(toString(state.resolveInput(node, "reason")))
result, err := services.ConversationHumanDispatchService.HandoffByAIWithRequestID( result, err := services.BusinessToolExecutor.Execute(context.Background(), services.BusinessToolInput{
state.input.Conversation.ID, Conversation: state.input.Conversation, AIAgent: state.input.AIAgent,
state.input.AIAgent, ToolCode: toolx.GraphHandoffConversation.Code, Arguments: map[string]any{"reason": reason},
reason, IdempotencyKey: workflowToolIdempotencyKey(state, node), Confirmed: true,
strings.TrimSpace(state.input.UserMessage.RequestID), })
)
if err != nil { if err != nil {
return err return err
} }
var handoff struct {
Decision string `json:"decision"`
TeamID int64 `json:"teamId"`
AssigneeID int64 `json:"assigneeId"`
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(result.ResultData), &handoff); err != nil {
return err
}
output := map[string]any{ output := map[string]any{
"handoffId": int64(0), "handoffId": int64(0),
"reason": reason, "reason": reason,
@@ -718,24 +753,29 @@ func (e *Executor) executeHandoffToHuman(state *runState, node dsl.Node) error {
"assigneeId": int64(0), "assigneeId": int64(0),
"message": "", "message": "",
} }
if result != nil { output["decision"] = handoff.Decision
output["decision"] = string(result.Decision) output["teamId"] = handoff.TeamID
output["teamId"] = result.TeamID output["assigneeId"] = handoff.AssigneeID
output["assigneeId"] = result.AssigneeID output["message"] = strings.TrimSpace(handoff.Message)
output["message"] = strings.TrimSpace(result.Message)
}
state.setNodeVars(node.ID, output) state.setNodeVars(node.ID, output)
return nil return nil
} }
func workflowToolIdempotencyKey(state *runState, node dsl.Node) string {
requestID := strings.TrimSpace(state.input.UserMessage.RequestID)
if requestID != "" {
return fmt.Sprintf("workflow:%d:node:%s:request:%s", state.input.Conversation.ID, node.ID, requestID)
}
return fmt.Sprintf("workflow:%d:node:%s:message:%d", state.input.Conversation.ID, node.ID, state.input.UserMessage.ID)
}
func (e *Executor) executeKnowledgeRetrieve(ctx context.Context, state *runState, node dsl.Node) error { func (e *Executor) executeKnowledgeRetrieve(ctx context.Context, state *runState, node dsl.Node) error {
query := strings.TrimSpace(toString(state.resolveInput(node, "query"))) query := strings.TrimSpace(toString(state.resolveInput(node, "query")))
knowledgeBaseIDs := readInt64ArrayConfig(node.Data.Config, "knowledgeBaseIds") knowledgeBaseIDs := readInt64ArrayConfig(node.Data.Config, "knowledgeBaseIds")
if len(knowledgeBaseIDs) == 0 { if len(knowledgeBaseIDs) == 0 {
return fmt.Errorf("knowledge retrieve node requires knowledgeBaseIds") return fmt.Errorf("knowledge retrieve node requires knowledgeBaseIds")
} }
retriever := retrievers.NewKnowledgeRetriever(state.input.AIAgent, knowledgeBaseIDs) _, result, err := readtools.RetrieveKnowledge(ctx, state.input.AIAgent, knowledgeBaseIDs, query, workflowReadToolPolicy(toolx.BuiltinKnowledgeRetrieve.Code))
result, err := retriever.RetrieveContext(ctx, query)
if err != nil { if err != nil {
return err return err
} }
@@ -490,6 +490,43 @@ func TestExecutorResumeCreatesTicketAfterHumanConfirmation(t *testing.T) {
if trace == nil || !strings.Contains(trace.OutputPreview, "工单已创建") { if trace == nil || !strings.Contains(trace.OutputPreview, "工单已创建") {
t.Fatalf("expected create_ticket output to include customer-visible result message, got %#v", trace) t.Fatalf("expected create_ticket output to include customer-visible result message, got %#v", trace)
} }
// Replaying the same confirmation checkpoint must reuse the completed
// business-tool invocation rather than creating a second ticket.
if _, err := executor.Resume(context.Background(), Input{
Definition: createTicketWorkflowDefinition(), Conversation: conversation, UserMessage: userMessage, AIAgent: aiAgent,
}, interrupted.CheckPointData, "确认"); err != nil {
t.Fatalf("replay workflow resume: %v", err)
}
var ticketCount int64
if err := db.Model(&models.Ticket{}).Where("conversation_id = ?", conversation.ID).Count(&ticketCount).Error; err != nil || ticketCount != 1 {
t.Fatalf("ticket count after replay = %d, err=%v", ticketCount, err)
}
}
func TestExecutorDebugResumeDoesNotCreateTicket(t *testing.T) {
db := setupWorkflowExecutorHandoffDB(t)
aiAgent := createWorkflowExecutorHandoffAIAgent(t, db, "1")
conversation := createWorkflowExecutorHandoffConversation(t, db, aiAgent.ID)
userMessage := createWorkflowExecutorCustomerMessage(t, db, conversation.ID, "订单支付失败,请帮我登记工单")
executor := NewExecutor()
definition := createTicketWorkflowDefinition()
interrupted, err := executor.Execute(context.Background(), Input{Definition: definition, Conversation: conversation, UserMessage: userMessage, AIAgent: aiAgent, Debug: true})
if err != nil || !interrupted.Interrupted {
t.Fatalf("debug execute = %#v, err=%v", interrupted, err)
}
result, err := executor.Resume(context.Background(), Input{Definition: definition, Conversation: conversation, UserMessage: userMessage, AIAgent: aiAgent, Debug: true}, interrupted.CheckPointData, "确认")
if err != nil || result.Interrupted {
t.Fatalf("debug resume = %#v, err=%v", result, err)
}
var ticketCount int64
if err := db.Model(&models.Ticket{}).Where("conversation_id = ?", conversation.ID).Count(&ticketCount).Error; err != nil || ticketCount != 0 {
t.Fatalf("debug ticket count = %d, err=%v", ticketCount, err)
}
trace := findNodeTrace(result.NodeTraces, "create_ticket_1")
if trace == nil || !strings.Contains(trace.OutputPreview, "调试运行不会创建工单") {
t.Fatalf("expected debug write skip trace, got %#v", trace)
}
} }
func findNodeTrace(items []NodeTrace, nodeID string) *NodeTrace { func findNodeTrace(items []NodeTrace, nodeID string) *NodeTrace {
@@ -835,6 +872,7 @@ func setupWorkflowExecutorHandoffDB(t *testing.T) *gorm.DB {
&models.ConversationReadState{}, &models.ConversationReadState{},
&models.Message{}, &models.Message{},
&models.ChannelMessageOutbox{}, &models.ChannelMessageOutbox{},
&models.AgentToolInvocation{},
&models.Ticket{}, &models.Ticket{},
&models.TicketNoSequence{}, &models.TicketNoSequence{},
&models.TicketTag{}, &models.TicketTag{},
+91
View File
@@ -0,0 +1,91 @@
package ai
import (
"context"
"fmt"
"strings"
openai "github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/shared"
"agent-desk/internal/models"
)
type ToolDefinition struct {
Name string
Description string
Parameters map[string]any
}
type ToolCall struct {
ID string
Name string
Arguments string
}
type ToolCallExecutor func(context.Context, ToolCall) (string, error)
type ToolLoopResult struct {
ChatCompletionResult
ToolCalls []ToolCall
}
// ChatWithTools executes a bounded OpenAI-compatible function-calling loop.
// Tool execution stays in the caller so business operations remain behind the
// application Tool Registry and Service layer.
func (s *llm) ChatWithTools(ctx context.Context, config models.AIConfig, systemPrompt, userPrompt string, definitions []ToolDefinition, maxSteps int, execute ToolCallExecutor) (*ToolLoopResult, error) {
if len(definitions) == 0 || execute == nil {
result, err := s.ChatWithConfig(ctx, config, systemPrompt, userPrompt)
if err != nil {
return nil, err
}
return &ToolLoopResult{ChatCompletionResult: *result}, nil
}
if maxSteps <= 0 {
maxSteps = 5
}
messages := []openai.ChatCompletionMessageParamUnion{}
if strings.TrimSpace(systemPrompt) != "" {
messages = append(messages, openai.ChatCompletionMessageParamUnion{OfSystem: &openai.ChatCompletionSystemMessageParam{Content: openai.ChatCompletionSystemMessageParamContentUnion{OfString: openai.String(systemPrompt)}}})
}
messages = append(messages, openai.ChatCompletionMessageParamUnion{OfUser: &openai.ChatCompletionUserMessageParam{Content: openai.ChatCompletionUserMessageParamContentUnion{OfString: openai.String(userPrompt)}}})
tools := make([]openai.ChatCompletionToolUnionParam, 0, len(definitions))
for _, definition := range definitions {
tools = append(tools, openai.ChatCompletionToolUnionParam{OfFunction: &openai.ChatCompletionFunctionToolParam{Function: shared.FunctionDefinitionParam{Name: definition.Name, Description: openai.String(definition.Description), Parameters: shared.FunctionParameters(definition.Parameters)}}})
}
client := newOpenAIClient(config)
allCalls := make([]ToolCall, 0)
for step := 0; step < maxSteps; step++ {
params := openai.ChatCompletionNewParams{Messages: messages, Model: shared.ChatModel(config.ModelName), Tools: tools}
if config.MaxOutputTokens > 0 {
params.MaxCompletionTokens = openai.Int(int64(config.MaxOutputTokens))
}
applyProviderSpecificChatParams(&params, config)
response, err := client.Chat.Completions.New(ctx, params)
if err != nil {
return nil, fmt.Errorf("tool loop chat completion failed: %w", err)
}
if len(response.Choices) == 0 {
return nil, fmt.Errorf("tool loop returned no choices")
}
message := response.Choices[0].Message
if len(message.ToolCalls) == 0 {
return &ToolLoopResult{ChatCompletionResult: ChatCompletionResult{Content: strings.TrimSpace(message.Content), ModelName: config.ModelName, PromptTokens: int(response.Usage.PromptTokens), CompletionTokens: int(response.Usage.CompletionTokens)}, ToolCalls: allCalls}, nil
}
messages = append(messages, message.ToParam())
for _, rawCall := range message.ToolCalls {
call := rawCall.AsFunction()
if call.ID == "" || call.Function.Name == "" {
return nil, fmt.Errorf("tool loop received unsupported tool call")
}
toolCall := ToolCall{ID: call.ID, Name: call.Function.Name, Arguments: call.Function.Arguments}
allCalls = append(allCalls, toolCall)
output, callErr := execute(ctx, toolCall)
if callErr != nil {
output = "tool execution failed: " + callErr.Error()
}
messages = append(messages, openai.ChatCompletionMessageParamUnion{OfTool: &openai.ChatCompletionToolMessageParam{ToolCallID: call.ID, Content: openai.ChatCompletionToolMessageParamContentUnion{OfString: openai.String(output)}}})
}
}
return nil, fmt.Errorf("tool loop exceeded maximum steps")
}
+82
View File
@@ -0,0 +1,82 @@
package ai
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
)
func TestChatWithToolsExecutesToolAndContinuesConversation(t *testing.T) {
var requestCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
var body struct {
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
} `json:"tool_calls"`
} `json:"messages"`
Tools []json.RawMessage `json:"tools"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
switch requestCount.Add(1) {
case 1:
if len(body.Tools) != 1 || len(body.Messages) != 2 || body.Messages[1].Role != "user" {
t.Fatalf("unexpected first request: %+v", body)
}
_, _ = w.Write([]byte(`{"id":"chatcmpl-1","object":"chat.completion","created":1,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"","tool_calls":[{"id":"call-1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"refund\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`))
case 2:
if len(body.Messages) != 4 || body.Messages[2].Role != "assistant" || len(body.Messages[2].ToolCalls) != 1 || body.Messages[3].Role != "tool" || body.Messages[3].Content != "refund policy" {
t.Fatalf("tool result was not continued in second request: %+v", body.Messages)
}
_, _ = w.Write([]byte(`{"id":"chatcmpl-2","object":"chat.completion","created":2,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"Refunds are available within 30 days."},"finish_reason":"stop"}],"usage":{"prompt_tokens":20,"completion_tokens":4,"total_tokens":24}}`))
default:
t.Fatalf("unexpected extra request")
}
}))
defer server.Close()
var executed ToolCall
result, err := LLM.ChatWithTools(context.Background(), models.AIConfig{
Provider: enums.AIProviderOpenAI,
BaseURL: server.URL + "/v1",
APIKey: "test-key",
ModelName: "test-model",
}, "You are helpful.", "What is the refund policy?", []ToolDefinition{{
Name: "lookup",
Description: "Look up a policy.",
Parameters: map[string]any{"type": "object"},
}}, 3, func(_ context.Context, call ToolCall) (string, error) {
executed = call
return "refund policy", nil
})
if err != nil {
t.Fatalf("ChatWithTools: %v", err)
}
if got, want := result.Content, "Refunds are available within 30 days."; got != want {
t.Fatalf("result content = %q, want %q", got, want)
}
if executed.Name != "lookup" || executed.ID != "call-1" || executed.Arguments != `{"q":"refund"}` {
t.Fatalf("executed tool call = %+v", executed)
}
if len(result.ToolCalls) != 1 || result.PromptTokens != 20 || result.CompletionTokens != 4 {
t.Fatalf("unexpected result: %+v", result)
}
if got := requestCount.Load(); got != 2 {
t.Fatalf("request count = %d, want 2", got)
}
}
+67
View File
@@ -0,0 +1,67 @@
package tooling
import (
"context"
"fmt"
"strings"
"time"
"agent-desk/internal/ai/mcps"
"agent-desk/internal/pkg/toolx"
)
// MCPExecutor is the single execution boundary for dynamically discovered
// MCP tools. Engine adapters supply the policy for the current Agent run.
type MCPExecutor struct {
registry *Registry
runtime *mcps.RuntimeService
}
var DefaultMCPExecutor = NewMCPExecutor(DefaultRegistry, mcps.Runtime)
func NewMCPExecutor(registry *Registry, runtime *mcps.RuntimeService) *MCPExecutor {
return &MCPExecutor{registry: registry, runtime: runtime}
}
func (e *MCPExecutor) Execute(ctx context.Context, toolCode string, arguments map[string]any, policy Policy) (Definition, *mcps.ToolCallResult, error) {
definition, err := e.registry.Resolve(toolCode)
if err != nil {
return Definition{}, nil, err
}
if err := DefaultPolicyGuard.Authorize(Invocation{Definition: definition, Arguments: arguments, Policy: policy}); err != nil {
return Definition{}, nil, err
}
serverCode, toolName := toolx.SplitMCPToolCode(strings.TrimSpace(definition.Code))
if serverCode == "" || toolName == "" {
return Definition{}, nil, &UnsupportedExecutionError{ToolCode: definition.Code}
}
if e.runtime == nil {
return Definition{}, nil, fmt.Errorf("MCP executor runtime is not configured")
}
if definition.TimeoutMS > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond)
defer cancel()
}
result, err := e.runtime.CallTool(ctx, serverCode, toolName, cloneArguments(arguments))
return definition, result, err
}
type UnsupportedExecutionError struct {
ToolCode string
}
func (e *UnsupportedExecutionError) Error() string {
return "tool is not executable through MCP: " + e.ToolCode
}
func cloneArguments(input map[string]any) map[string]any {
if len(input) == 0 {
return map[string]any{}
}
ret := make(map[string]any, len(input))
for key, value := range input {
ret[key] = value
}
return ret
}
+219
View File
@@ -0,0 +1,219 @@
// Package tooling provides the engine-independent tool governance boundary.
package tooling
import (
"encoding/json"
"fmt"
"strings"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/toolx"
)
const (
RiskLevelRead = "read"
RiskLevelWrite = "write"
RiskLevelSensitive = "sensitive"
)
// Definition is the normalized, engine-independent description of a tool.
type Definition struct {
Code string
Name string
Description string
InputSchema map[string]any
SourceType enums.ToolSourceType
RiskLevel string
RequireConfirmation bool
MaxCallsPerRun int
TimeoutMS int
IdempotencyMode string
}
// Policy is supplied by the caller's agent/runtime context for one invocation.
// An empty AllowedToolCodes means the caller did not impose an allow-list.
type Policy struct {
AllowedToolCodes []string
SkillAllowedToolCodes []string
AllowedRiskLevels []string
CallCount int
TotalCallCount int
MaxTotalCalls int
MaxArgumentBytes int
Confirmed bool
}
type Invocation struct {
Definition Definition
Arguments map[string]any
Policy Policy
}
// PolicyGuard is the reusable enforcement point for every engine/tool adapter.
type PolicyGuard struct{}
var DefaultPolicyGuard = &PolicyGuard{}
type Registry struct{}
var DefaultRegistry = NewRegistry()
func NewRegistry() *Registry {
return &Registry{}
}
func (r *Registry) Resolve(toolCode string) (Definition, error) {
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
if toolCode == "" {
return Definition{}, fmt.Errorf("tool code is required")
}
if spec, ok := toolx.GetRegisteredToolSpec(toolCode); ok {
return definitionFromSpec(spec), nil
}
serverCode, toolName := toolx.SplitMCPToolCode(toolCode)
if serverCode == "" || toolName == "" {
return Definition{}, fmt.Errorf("unsupported tool code: %s", toolCode)
}
// MCP metadata cannot reliably describe side effects. Treat it as sensitive
// until an administrator provides a more specific policy in a later phase.
return Definition{
Code: toolCode,
Name: toolName,
InputSchema: map[string]any{"type": "object", "additionalProperties": true},
SourceType: enums.ToolSourceTypeMCP,
RiskLevel: RiskLevelSensitive,
RequireConfirmation: true,
MaxCallsPerRun: 3,
TimeoutMS: 30000,
IdempotencyMode: "caller",
}, nil
}
func (r *Registry) Authorize(definition Definition, policy Policy) error {
return DefaultPolicyGuard.Authorize(Invocation{Definition: definition, Policy: policy})
}
func (g *PolicyGuard) Authorize(invocation Invocation) error {
definition := invocation.Definition
policy := invocation.Policy
if definition.Code == "" {
return fmt.Errorf("tool definition is required")
}
if len(policy.AllowedToolCodes) > 0 && !containsCanonicalToolCode(policy.AllowedToolCodes, definition.Code) {
return fmt.Errorf("tool is not allowed: %s", definition.Code)
}
if len(policy.SkillAllowedToolCodes) > 0 && !containsCanonicalToolCode(policy.SkillAllowedToolCodes, definition.Code) {
return fmt.Errorf("tool is not allowed by the selected skill: %s", definition.Code)
}
if len(policy.AllowedRiskLevels) > 0 && !containsString(policy.AllowedRiskLevels, definition.RiskLevel) {
return fmt.Errorf("tool risk level is not allowed: %s", definition.RiskLevel)
}
if definition.MaxCallsPerRun > 0 && policy.CallCount >= definition.MaxCallsPerRun {
return fmt.Errorf("tool call limit reached: %s", definition.Code)
}
if policy.MaxTotalCalls > 0 && policy.TotalCallCount >= policy.MaxTotalCalls {
return fmt.Errorf("total tool call limit reached")
}
if policy.MaxArgumentBytes > 0 {
encoded, err := json.Marshal(invocation.Arguments)
if err != nil {
return fmt.Errorf("tool arguments are not serializable: %w", err)
}
if len(encoded) > policy.MaxArgumentBytes {
return fmt.Errorf("tool arguments exceed size limit: %s", definition.Code)
}
}
if definition.RequireConfirmation && !policy.Confirmed {
return fmt.Errorf("tool confirmation is required: %s", definition.Code)
}
return nil
}
func definitionFromSpec(spec toolx.ToolSpec) Definition {
definition := Definition{
Code: spec.Code,
Name: spec.Name,
Description: spec.Description,
SourceType: spec.SourceType,
RiskLevel: RiskLevelRead,
MaxCallsPerRun: 8,
TimeoutMS: 15000,
IdempotencyMode: "none",
}
switch spec.Code {
case toolx.BuiltinConversationContext.Code:
definition.InputSchema = objectSchema(map[string]any{})
case toolx.BuiltinKnowledgeRetrieve.Code:
definition.InputSchema = requiredObjectSchema([]string{"query"}, map[string]any{"query": map[string]any{"type": "string"}})
case toolx.GraphTriageServiceRequest.Code:
definition.InputSchema = objectSchema(map[string]any{
"goal": map[string]any{"type": "string"},
"observedIssue": map[string]any{"type": "string"},
"needTicket": map[string]any{"type": "boolean"},
"needHumanHandoff": map[string]any{"type": "boolean"},
"additionalContext": map[string]any{"type": "string"},
})
case toolx.GraphAnalyzeConversation.Code:
definition.InputSchema = objectSchema(map[string]any{
"goal": map[string]any{"type": "string"},
"observedIssue": map[string]any{"type": "string"},
"needTicket": map[string]any{"type": "boolean"},
"needHumanHandoff": map[string]any{"type": "boolean"},
"needQualityCheck": map[string]any{"type": "boolean"},
"additionalContext": map[string]any{"type": "string"},
})
case toolx.GraphPrepareTicketDraft.Code:
definition.InputSchema = objectSchema(map[string]any{
"title": map[string]any{"type": "string"},
"description": map[string]any{"type": "string"},
"issue": map[string]any{"type": "string"},
"impact": map[string]any{"type": "string"},
"expectedOutcome": map[string]any{"type": "string"},
"currentAttempt": map[string]any{"type": "string"},
})
case toolx.GraphCreateTicketConfirm.Code:
definition.RiskLevel = RiskLevelWrite
definition.RequireConfirmation = true
definition.MaxCallsPerRun = 1
definition.IdempotencyMode = "business"
definition.InputSchema = requiredObjectSchema([]string{"title", "description"}, map[string]any{
"title": map[string]any{"type": "string"}, "description": map[string]any{"type": "string"},
})
case toolx.GraphHandoffConversation.Code:
definition.RiskLevel = RiskLevelWrite
definition.RequireConfirmation = true
definition.MaxCallsPerRun = 1
definition.IdempotencyMode = "business"
definition.InputSchema = objectSchema(map[string]any{"reason": map[string]any{"type": "string"}})
}
return definition
}
func objectSchema(properties map[string]any) map[string]any {
return map[string]any{"type": "object", "properties": properties}
}
func requiredObjectSchema(required []string, properties map[string]any) map[string]any {
schema := objectSchema(properties)
schema["required"] = required
return schema
}
func containsString(items []string, target string) bool {
for _, item := range items {
if strings.EqualFold(strings.TrimSpace(item), strings.TrimSpace(target)) {
return true
}
}
return false
}
func containsCanonicalToolCode(items []string, target string) bool {
target = toolx.NormalizeToolCodeAlias(strings.TrimSpace(target))
for _, item := range items {
if toolx.NormalizeToolCodeAlias(strings.TrimSpace(item)) == target {
return true
}
}
return false
}
+139
View File
@@ -0,0 +1,139 @@
package tooling
import (
"strings"
"testing"
"agent-desk/internal/pkg/toolx"
)
func TestRegistryResolvesRegisteredToolPolicy(t *testing.T) {
definition, err := DefaultRegistry.Resolve(toolx.GraphCreateTicketConfirm.Code)
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if definition.RiskLevel != RiskLevelWrite || !definition.RequireConfirmation || definition.MaxCallsPerRun != 1 {
t.Fatalf("unexpected definition: %#v", definition)
}
if err := DefaultRegistry.Authorize(definition, Policy{AllowedToolCodes: []string{toolx.GraphCreateTicketConfirm.Code}}); err == nil {
t.Fatal("expected confirmation requirement")
}
}
func TestRegistryIncludesGraphInputSchemaAndRiskPolicy(t *testing.T) {
definition, err := DefaultRegistry.Resolve(toolx.GraphCreateTicketConfirm.Code)
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if definition.InputSchema["type"] != "object" || len(definition.InputSchema["required"].([]string)) != 2 {
t.Fatalf("unexpected graph schema: %#v", definition.InputSchema)
}
if err := DefaultPolicyGuard.Authorize(Invocation{Definition: definition, Policy: Policy{
AllowedToolCodes: []string{definition.Code}, AllowedRiskLevels: []string{RiskLevelRead}, Confirmed: true,
}}); err == nil || !strings.Contains(err.Error(), "risk level") {
t.Fatalf("expected risk policy rejection, got %v", err)
}
}
func TestRegistryRequiresConfirmationForHandoff(t *testing.T) {
definition, err := DefaultRegistry.Resolve(toolx.GraphHandoffConversation.Code)
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if definition.RiskLevel != RiskLevelWrite || !definition.RequireConfirmation || definition.IdempotencyMode != "business" {
t.Fatalf("unexpected handoff policy: %#v", definition)
}
if err := DefaultRegistry.Authorize(definition, Policy{AllowedToolCodes: []string{definition.Code}, AllowedRiskLevels: []string{RiskLevelWrite}}); err == nil || !strings.Contains(err.Error(), "confirmation") {
t.Fatalf("expected handoff confirmation rejection, got %v", err)
}
}
func TestRegistryIncludesAllTicketDraftToolInputs(t *testing.T) {
definition, err := DefaultRegistry.Resolve(toolx.GraphPrepareTicketDraft.Code)
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
properties, _ := definition.InputSchema["properties"].(map[string]any)
for _, key := range []string{"title", "description", "issue", "impact", "expectedOutcome", "currentAttempt"} {
if _, ok := properties[key]; !ok {
t.Fatalf("ticket draft schema missing %q: %#v", key, definition.InputSchema)
}
}
}
func TestRegistryTreatsMCPToolsAsSensitive(t *testing.T) {
definition, err := DefaultRegistry.Resolve("knowledge/search")
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if definition.RiskLevel != RiskLevelSensitive || !definition.RequireConfirmation {
t.Fatalf("unexpected MCP definition: %#v", definition)
}
if err := DefaultRegistry.Authorize(definition, Policy{AllowedToolCodes: []string{"knowledge/search"}, Confirmed: true}); err != nil {
t.Fatalf("Authorize returned error: %v", err)
}
}
func TestSanitizePreviewMasksAndBoundsSecrets(t *testing.T) {
preview := SanitizePreview(`authorization=Bearer-secret {"token":"abc123"}`)
if strings.Contains(preview, "Bearer-secret") || strings.Contains(preview, "abc123") {
t.Fatalf("secret leaked in preview: %q", preview)
}
}
func TestNormalizeCustomerReplyRejectsSecretAndNormalizesText(t *testing.T) {
if _, err := NormalizeCustomerReply("token=abc123"); err == nil {
t.Fatal("expected sensitive reply to be rejected")
}
reply, err := NormalizeCustomerReply(" first\x00\n\n\n\nsecond ")
if err != nil || reply != "first\n\nsecond" {
t.Fatalf("unexpected normalized reply: %q err=%v", reply, err)
}
}
func TestMCPExecutorRejectsUnconfirmedToolBeforeRuntimeCall(t *testing.T) {
executor := NewMCPExecutor(DefaultRegistry, nil)
_, _, err := executor.Execute(t.Context(), "knowledge/search", nil, Policy{
AllowedToolCodes: []string{"knowledge/search"},
})
if err == nil || !strings.Contains(err.Error(), "confirmation") {
t.Fatalf("expected confirmation rejection, got %v", err)
}
}
func TestPolicyGuardRejectsTotalCallsAndOversizedArguments(t *testing.T) {
definition, err := DefaultRegistry.Resolve("knowledge/search")
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if err := DefaultPolicyGuard.Authorize(Invocation{
Definition: definition,
Policy: Policy{AllowedToolCodes: []string{definition.Code}, Confirmed: true, TotalCallCount: 2, MaxTotalCalls: 2},
}); err == nil || !strings.Contains(err.Error(), "total") {
t.Fatalf("expected total call rejection, got %v", err)
}
if err := DefaultPolicyGuard.Authorize(Invocation{
Definition: definition, Arguments: map[string]any{"query": strings.Repeat("x", 40)},
Policy: Policy{AllowedToolCodes: []string{definition.Code}, Confirmed: true, MaxArgumentBytes: 16},
}); err == nil || !strings.Contains(err.Error(), "size") {
t.Fatalf("expected argument size rejection, got %v", err)
}
}
func TestPolicyGuardRejectsToolOutsideSelectedSkillWhitelist(t *testing.T) {
definition, err := DefaultRegistry.Resolve("knowledge/search")
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
err = DefaultPolicyGuard.Authorize(Invocation{
Definition: definition,
Policy: Policy{
AllowedToolCodes: []string{"knowledge/search"},
SkillAllowedToolCodes: []string{"customer/profile"},
Confirmed: true,
},
})
if err == nil || !strings.Contains(err.Error(), "selected skill") {
t.Fatalf("expected skill whitelist rejection, got %v", err)
}
}
+41
View File
@@ -0,0 +1,41 @@
package tooling
import (
"fmt"
"strings"
"unicode"
)
const maxCustomerReplyRunes = 8000
// NormalizeCustomerReply applies the final plain-text boundary before an AI
// response enters a customer conversation. It rejects likely credential
// assignments instead of masking them, because a masked secret is not useful
// customer-facing content.
func NormalizeCustomerReply(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", fmt.Errorf("ai reply is empty")
}
if secretAssignmentPattern.MatchString(value) {
return "", fmt.Errorf("ai reply contains sensitive credential data")
}
var builder strings.Builder
for _, r := range value {
if unicode.IsControl(r) && r != '\n' && r != '\t' {
continue
}
builder.WriteRune(r)
}
value = strings.TrimSpace(builder.String())
for strings.Contains(value, "\n\n\n") {
value = strings.ReplaceAll(value, "\n\n\n", "\n\n")
}
if value == "" {
return "", fmt.Errorf("ai reply is empty")
}
if len([]rune(value)) > maxCustomerReplyRunes {
return "", fmt.Errorf("ai reply exceeds maximum length")
}
return value, nil
}
+25
View File
@@ -0,0 +1,25 @@
package tooling
import (
"regexp"
"strings"
)
const maxPreviewChars = 4000
var secretAssignmentPattern = regexp.MustCompile(`(?i)(?:"|')?(api[_-]?key|authorization|password|secret|token|cookie)(?:"|')?\s*([:=])\s*(?:"[^"]*"|'[^']*'|[^\s,;}]+)`)
// SanitizePreview keeps audit/model previews bounded and masks common secrets.
// It intentionally operates on plain text so it also covers malformed JSON.
func SanitizePreview(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
value = secretAssignmentPattern.ReplaceAllString(value, "$1$2***")
runes := []rune(value)
if len(runes) <= maxPreviewChars {
return value
}
return strings.TrimSpace(string(runes[:maxPreviewChars])) + "\n[preview truncated]"
}
+7 -6
View File
@@ -248,14 +248,15 @@ func DefaultRegistry() *Registry {
}, },
}, },
NodeSpec{ NodeSpec{
Type: NodeTypeHandoffToHuman, Type: NodeTypeHandoffToHuman,
Title: "Handoff To Human", Title: "Handoff To Human",
Description: "Transfer the conversation to human support.", Description: "Transfer the conversation to human support.",
Icon: "HeadphonesIcon", Icon: "HeadphonesIcon",
RiskLevel: NodeRiskLevelHigh, RiskLevel: NodeRiskLevelHigh,
RequiresConfirmationPredecessor: true,
InputSchema: []VariableSpec{ InputSchema: []VariableSpec{
requiredInput("reason", "转人工原因", VariableTypeString, "触发转人工处理的业务原因。"), requiredInput("reason", "转人工原因", VariableTypeString, "触发转人工处理的业务原因。"),
optionalInput("confirmed", "已确认", VariableTypeBoolean, "客户是否已确认转人工。"), requiredInput("confirmed", "已确认", VariableTypeBoolean, "客户是否已确认转人工。"),
}, },
OutputSchema: []VariableSpec{ OutputSchema: []VariableSpec{
output("handoffId", "转人工记录 ID", VariableTypeInteger, "本次转人工操作的内部记录编号。"), output("handoffId", "转人工记录 ID", VariableTypeInteger, "本次转人工操作的内部记录编号。"),
@@ -162,6 +162,32 @@ func TestValidateDefinitionRejectsConfirmedInputFromNonConfirmNode(t *testing.T)
} }
} }
func TestValidateDefinitionRejectsHandoffWithoutConfirmedInput(t *testing.T) {
def := dsl.Definition{
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
node("start_1", "start", nil, nil),
node("confirm_1", "human_confirm", inputs("prompt", dsl.RefValue("start_1", "userMessage")), nil),
node("handoff_1", "handoff_to_human", inputs("reason", dsl.RefValue("start_1", "userMessage")), nil),
node("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
edge("start_1", "confirm_1"),
edge("confirm_1", "handoff_1"),
edge("handoff_1", "end_1"),
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected handoff without confirmed input to be invalid")
}
if !hasValidationMessage(result, "required input mapping is missing: confirmed") {
t.Fatalf("expected missing confirmed input error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsConditionBranchTargetWithoutEdge(t *testing.T) { func TestValidateDefinitionRejectsConditionBranchTargetWithoutEdge(t *testing.T) {
def := conditionDefinition() def := conditionDefinition()
def.Edges = []dsl.Edge{edge("start_1", "condition_1")} def.Edges = []dsl.Edge{edge("start_1", "condition_1")}
+15
View File
@@ -178,6 +178,7 @@ func registerDashboardChannelRoutes(group *gin.RouterGroup) {
group.POST("/delete", dashboard.ChannelPostDelete) group.POST("/delete", dashboard.ChannelPostDelete)
group.Any("/list", dashboard.ChannelAnyList) group.Any("/list", dashboard.ChannelAnyList)
group.POST("/reset_user_token_secret", dashboard.ChannelPostReset_user_token_secret) group.POST("/reset_user_token_secret", dashboard.ChannelPostReset_user_token_secret)
group.POST("/rollback_ai_agent_rollout", dashboard.ChannelPostRollback_ai_agent_rollout)
group.POST("/update", dashboard.ChannelPostUpdate) group.POST("/update", dashboard.ChannelPostUpdate)
group.POST("/update_status", dashboard.ChannelPostUpdate_status) group.POST("/update_status", dashboard.ChannelPostUpdate_status)
group.Any("/wxwork/kf/accounts", dashboard.ChannelAnyWxworkKfAccounts) group.Any("/wxwork/kf/accounts", dashboard.ChannelAnyWxworkKfAccounts)
@@ -217,6 +218,10 @@ func registerDashboardAIAgentRoutes(group *gin.RouterGroup) {
group.POST("/workflow/save", dashboard.AIWorkflowPostSaveAgent) group.POST("/workflow/save", dashboard.AIWorkflowPostSaveAgent)
group.POST("/workflow/validate", dashboard.AIWorkflowPostValidate) group.POST("/workflow/validate", dashboard.AIWorkflowPostValidate)
group.POST("/workflow/publish", dashboard.AIWorkflowPostPublishAgent) group.POST("/workflow/publish", dashboard.AIWorkflowPostPublishAgent)
group.POST("/publish", dashboard.AIAgentPostPublish)
group.POST("/rollback", dashboard.AIAgentPostRollback)
group.POST("/rollback_rollout", dashboard.AIAgentPostRollback_rollout)
group.Any("/:id/revision/list", dashboard.AIAgentAnyRevisionList)
group.GET("/:id", dashboard.AIAgentGetBy) group.GET("/:id", dashboard.AIAgentGetBy)
group.POST("/create", dashboard.AIAgentPostCreate) group.POST("/create", dashboard.AIAgentPostCreate)
group.POST("/delete", dashboard.AIAgentPostDelete) group.POST("/delete", dashboard.AIAgentPostDelete)
@@ -230,6 +235,7 @@ func registerDashboardAIAgentRoutes(group *gin.RouterGroup) {
func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) { func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) {
group.GET("/node-spec/list", dashboard.AIWorkflowGetNodeSpecList) group.GET("/node-spec/list", dashboard.AIWorkflowGetNodeSpecList)
group.GET("/default-definition", dashboard.AIWorkflowGetDefaultDefinition) group.GET("/default-definition", dashboard.AIWorkflowGetDefaultDefinition)
group.GET("/template/list", dashboard.AIWorkflowGetTemplateList)
group.POST("/validate", dashboard.AIWorkflowPostValidate) group.POST("/validate", dashboard.AIWorkflowPostValidate)
group.Any("/run/list", dashboard.AIWorkflowAnyRunList) group.Any("/run/list", dashboard.AIWorkflowAnyRunList)
group.GET("/run/:id", dashboard.AIWorkflowGetRunBy) group.GET("/run/:id", dashboard.AIWorkflowGetRunBy)
@@ -237,6 +243,15 @@ func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) {
group.GET("/version/:id", dashboard.AIWorkflowGetVersionBy) group.GET("/version/:id", dashboard.AIWorkflowGetVersionBy)
} }
func registerDashboardAgentRunRoutes(group *gin.RouterGroup) {
group.Any("/metrics", dashboard.AgentRunAnyMetrics)
group.Any("/comparison", dashboard.AgentRunAnyComparison)
group.POST("/evaluate", dashboard.AgentRunPostEvaluate)
group.Any("/list", dashboard.AgentRunAnyList)
group.POST("/quality_feedback", dashboard.AgentRunPostSave_quality_feedback)
group.GET("/:id", dashboard.AgentRunGetBy)
}
func registerDashboardAIConfigRoutes(group *gin.RouterGroup) { func registerDashboardAIConfigRoutes(group *gin.RouterGroup) {
group.GET("/:id", dashboard.AIConfigGetBy) group.GET("/:id", dashboard.AIConfigGetBy)
group.POST("/create", dashboard.AIConfigPostCreate) group.POST("/create", dashboard.AIConfigPostCreate)
+1
View File
@@ -189,6 +189,7 @@ func addRouter(app *gin.Engine) {
registerDashboardAgentTeamScheduleRoutes(dashboardGroup.Group("/agent-team-schedule")) registerDashboardAgentTeamScheduleRoutes(dashboardGroup.Group("/agent-team-schedule"))
registerDashboardAIAgentRoutes(dashboardGroup.Group("/ai-agent")) registerDashboardAIAgentRoutes(dashboardGroup.Group("/ai-agent"))
registerDashboardAIWorkflowRoutes(dashboardGroup.Group("/ai-workflow")) registerDashboardAIWorkflowRoutes(dashboardGroup.Group("/ai-workflow"))
registerDashboardAgentRunRoutes(dashboardGroup.Group("/agent-run"))
registerDashboardAIConfigRoutes(dashboardGroup.Group("/ai-config")) registerDashboardAIConfigRoutes(dashboardGroup.Group("/ai-config"))
registerDashboardAssetRoutes(dashboardGroup.Group("/asset")) registerDashboardAssetRoutes(dashboardGroup.Group("/asset"))
registerDashboardKnowledgeBaseRoutes(dashboardGroup.Group("/knowledge-base")) registerDashboardKnowledgeBaseRoutes(dashboardGroup.Group("/knowledge-base"))
+9
View File
@@ -43,8 +43,17 @@ func TestNewServerRegistersGinRoutes(t *testing.T) {
http.MethodPost + " /api/dashboard/user/create", http.MethodPost + " /api/dashboard/user/create",
http.MethodPost + " /api/dashboard/conversation/send_message", http.MethodPost + " /api/dashboard/conversation/send_message",
http.MethodGet + " /api/dashboard/ai-workflow/default-definition", http.MethodGet + " /api/dashboard/ai-workflow/default-definition",
http.MethodGet + " /api/dashboard/ai-workflow/template/list",
http.MethodGet + " /api/dashboard/ai-workflow/run/list", http.MethodGet + " /api/dashboard/ai-workflow/run/list",
http.MethodGet + " /api/dashboard/ai-workflow/run/:id", http.MethodGet + " /api/dashboard/ai-workflow/run/:id",
http.MethodGet + " /api/dashboard/agent-run/metrics",
http.MethodGet + " /api/dashboard/agent-run/comparison",
http.MethodPost + " /api/dashboard/agent-run/evaluate",
http.MethodGet + " /api/dashboard/agent-run/:id",
http.MethodPost + " /api/dashboard/ai-agent/rollback_rollout",
http.MethodPost + " /api/dashboard/channel/rollback_ai_agent_rollout",
http.MethodPost + " /api/dashboard/agent-run/quality_feedback",
http.MethodGet + " /api/dashboard/agent-run/list",
http.MethodGet + " /api/ws/dashboard", http.MethodGet + " /api/ws/dashboard",
http.MethodGet + " /api/ws/open", http.MethodGet + " /api/ws/open",
} }
@@ -0,0 +1,29 @@
package builders
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
)
func BuildAgentRevision(item *models.AgentRevision) response.AgentRevisionResponse {
if item == nil {
return response.AgentRevisionResponse{}
}
publishedAt := ""
if item.PublishedAt != nil {
publishedAt = item.PublishedAt.Format("2006-01-02 15:04:05")
}
return response.AgentRevisionResponse{
ID: item.ID, AgentID: item.AgentID, Revision: item.Revision, WorkflowVersionID: item.WorkflowVersionID,
Status: item.Status, DefinitionHash: item.DefinitionHash, PublishedAt: publishedAt,
PublishedByID: item.PublishedByID, PublishedByName: item.PublishedByName,
}
}
func BuildAgentRevisionList(items []models.AgentRevision) []response.AgentRevisionResponse {
ret := make([]response.AgentRevisionResponse, 0, len(items))
for i := range items {
ret = append(ret, BuildAgentRevision(&items[i]))
}
return ret
}
+145
View File
@@ -0,0 +1,145 @@
package builders
import (
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
)
func BuildAgentRun(item *models.AgentRun) response.AgentRunResponse {
if item == nil {
return response.AgentRunResponse{}
}
return response.AgentRunResponse{
ID: item.ID,
ConversationID: item.ConversationID,
AIAgentID: item.AIAgentID,
AgentRevisionID: item.AgentRevisionID,
SourceMessageID: item.SourceMessageID,
WorkflowRunID: item.WorkflowRunID,
EngineCode: item.EngineCode,
Status: item.Status,
PromptTokens: item.PromptTokens,
CompletionTokens: item.CompletionTokens,
StartedAt: formatAgentRunTime(item.StartedAt),
EndedAt: formatAgentRunTimePtr(item.EndedAt),
DurationMS: agentRunDurationMS(item.StartedAt, item.EndedAt),
ErrorMessage: item.ErrorMessage,
TraceData: item.TraceData,
CreatedAt: formatAgentRunTime(item.CreatedAt),
UpdatedAt: formatAgentRunTime(item.UpdatedAt),
}
}
func BuildAgentRunDetail(item *models.AgentRun, steps []models.AgentStep, toolCalls []models.AgentToolCall, feedback *models.AgentRunQualityFeedback) response.AgentRunResponse {
ret := BuildAgentRun(item)
ret.Steps = BuildAgentStepList(steps)
ret.ToolCalls = BuildAgentToolCallList(toolCalls)
ret.QualityFeedback = BuildAgentRunQualityFeedback(feedback)
return ret
}
func BuildAgentRunQualityFeedback(item *models.AgentRunQualityFeedback) *response.AgentRunQualityFeedbackResponse {
if item == nil {
return nil
}
return &response.AgentRunQualityFeedbackResponse{
ID: item.ID,
AgentRunID: item.AgentRunID,
ResolutionStatus: item.ResolutionStatus,
EvidenceStatus: item.EvidenceStatus,
Comment: item.Comment,
UpdateUserName: item.UpdateUserName,
UpdatedAt: formatAgentRunTime(item.UpdatedAt),
}
}
func BuildAgentRunList(list []models.AgentRun) []response.AgentRunResponse {
ret := make([]response.AgentRunResponse, 0, len(list))
for i := range list {
ret = append(ret, BuildAgentRun(&list[i]))
}
return ret
}
func BuildAgentStep(item *models.AgentStep) response.AgentStepResponse {
if item == nil {
return response.AgentStepResponse{}
}
return response.AgentStepResponse{
ID: item.ID,
AgentRunID: item.AgentRunID,
WorkflowRunID: item.WorkflowRunID,
StepType: item.StepType,
StepCode: item.StepCode,
Status: item.Status,
InputPreview: item.InputPreview,
OutputPreview: item.OutputPreview,
ErrorMessage: item.ErrorMessage,
StartedAt: formatAgentRunTime(item.StartedAt),
EndedAt: formatAgentRunTimePtr(item.EndedAt),
DurationMS: item.DurationMS,
}
}
func BuildAgentStepList(list []models.AgentStep) []response.AgentStepResponse {
ret := make([]response.AgentStepResponse, 0, len(list))
for i := range list {
ret = append(ret, BuildAgentStep(&list[i]))
}
return ret
}
func BuildAgentToolCall(item *models.AgentToolCall) response.AgentToolCallResponse {
if item == nil {
return response.AgentToolCallResponse{}
}
return response.AgentToolCallResponse{
ID: item.ID,
AgentRunID: item.AgentRunID,
AgentStepID: item.AgentStepID,
ToolCode: item.ToolCode,
RiskLevel: item.RiskLevel,
RequireConfirm: item.RequireConfirm,
Status: item.Status,
ArgumentsPreview: item.ArgumentsPreview,
ResultPreview: item.ResultPreview,
ErrorMessage: item.ErrorMessage,
DurationMS: item.DurationMS,
CreatedAt: formatAgentRunTime(item.CreatedAt),
}
}
func BuildAgentToolCallList(list []models.AgentToolCall) []response.AgentToolCallResponse {
ret := make([]response.AgentToolCallResponse, 0, len(list))
for i := range list {
ret = append(ret, BuildAgentToolCall(&list[i]))
}
return ret
}
func formatAgentRunTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.Format("2006-01-02 15:04:05")
}
func formatAgentRunTimePtr(value *time.Time) string {
if value == nil {
return ""
}
return formatAgentRunTime(*value)
}
func agentRunDurationMS(startedAt time.Time, endedAt *time.Time) int64 {
if startedAt.IsZero() || endedAt == nil || endedAt.IsZero() {
return 0
}
duration := endedAt.Sub(startedAt).Milliseconds()
if duration < 0 {
return 0
}
return duration
}
+9
View File
@@ -8,6 +8,7 @@ import (
workflowregistry "agent-desk/internal/ai/workflow/registry" workflowregistry "agent-desk/internal/ai/workflow/registry"
"agent-desk/internal/models" "agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/dto/response"
"agent-desk/internal/services"
) )
func BuildAIWorkflow(item *models.AIWorkflow) response.AIWorkflowResponse { func BuildAIWorkflow(item *models.AIWorkflow) response.AIWorkflowResponse {
@@ -89,6 +90,14 @@ func BuildAIWorkflowNodeSpecs(list []workflowregistry.NodeSpec) []response.AIWor
return ret return ret
} }
func BuildAIWorkflowTemplates(list []services.AIWorkflowTemplate) []response.AIWorkflowTemplateResponse {
ret := make([]response.AIWorkflowTemplateResponse, 0, len(list))
for _, item := range list {
ret = append(ret, response.AIWorkflowTemplateResponse{Code: item.Code, Name: item.Name, Description: item.Description, Definition: item.Definition})
}
return ret
}
func BuildAIWorkflowRun(item *models.AIWorkflowRun) response.AIWorkflowRunResponse { func BuildAIWorkflowRun(item *models.AIWorkflowRun) response.AIWorkflowRunResponse {
return BuildAIWorkflowRunWithContext(item, nil, nil, nil) return BuildAIWorkflowRunWithContext(item, nil, nil, nil)
} }
@@ -0,0 +1,103 @@
package dashboard
import (
"agent-desk/internal/builders"
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/services"
"github.com/gin-gonic/gin"
"github.com/mlogclub/simple/web"
)
func AgentRunAnyList(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil {
httpx.WriteJSON(ctx, err)
return
}
queryParams := params.NewQueryParams(ctx)
queryParams.Cnd = *params.NewPagedSqlCnd(ctx,
params.QueryFilter{ParamName: "conversationId"},
params.QueryFilter{ParamName: "aiAgentId"},
params.QueryFilter{ParamName: "agentRevisionId"},
params.QueryFilter{ParamName: "sourceMessageId"},
params.QueryFilter{ParamName: "workflowRunId"},
params.QueryFilter{ParamName: "engineCode"},
params.QueryFilter{ParamName: "status"},
).Desc("id")
list, paging := services.AgentRunService.FindPageByParams(queryParams)
httpx.WriteJSON(ctx, &web.PageResult{Results: builders.BuildAgentRunList(list), Page: paging})
}
func AgentRunGetBy(ctx *gin.Context) {
id, ok := httpx.GetPathInt64(ctx, "id")
if !ok {
return
}
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil {
httpx.WriteJSON(ctx, err)
return
}
run, steps, toolCalls := services.AgentRunService.GetDetail(id)
if run == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0002"))
return
}
httpx.WriteJSON(ctx, builders.BuildAgentRunDetail(run, steps, toolCalls, services.AgentRunService.GetQualityFeedback(run.ID)))
}
func AgentRunPostSave_quality_feedback(ctx *gin.Context) {
operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.SaveAgentRunQualityFeedbackRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
if err := services.AgentRunService.SaveQualityFeedback(req, operator); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, nil)
}
func AgentRunAnyMetrics(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil {
httpx.WriteJSON(ctx, err)
return
}
aiAgentID, _ := params.GetInt64(ctx, "aiAgentId")
httpx.WriteJSON(ctx, services.AgentRunService.GetMetrics(aiAgentID))
}
func AgentRunAnyComparison(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil {
httpx.WriteJSON(ctx, err)
return
}
aiAgentID, _ := params.GetInt64(ctx, "aiAgentId")
httpx.WriteJSON(ctx, services.AgentRunService.GetEngineComparisons(aiAgentID))
}
func AgentRunPostEvaluate(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.RunAgentEvaluationRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
result, err := services.AgentEvaluationService.Run(ctx, req)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, result)
}
+115 -29
View File
@@ -1,6 +1,7 @@
package dashboard package dashboard
import ( import (
"agent-desk/internal/builders"
"agent-desk/internal/pkg/httpx" "agent-desk/internal/pkg/httpx"
"encoding/json" "encoding/json"
"strings" "strings"
@@ -125,6 +126,77 @@ func AIAgentPostDelete(ctx *gin.Context) {
httpx.WriteJSON(ctx, nil) httpx.WriteJSON(ctx, nil)
} }
func AIAgentPostPublish(ctx *gin.Context) {
operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.PublishAIAgentRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
_, err = services.AIAgentService.PublishAIAgent(req.ID, operator)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, nil)
}
func AIAgentAnyRevisionList(ctx *gin.Context) {
id, ok := httpx.GetPathInt64(ctx, "id")
if !ok {
return
}
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil {
httpx.WriteJSON(ctx, err)
return
}
if services.AIAgentService.Get(id) == nil {
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0002"))
return
}
httpx.WriteJSON(ctx, builders.BuildAgentRevisionList(services.AgentRevisionService.FindByAgentID(id)))
}
func AIAgentPostRollback(ctx *gin.Context) {
operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.RollbackAIAgentRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
if err := services.AIAgentService.RollbackAIAgent(req.ID, req.RevisionID, operator); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, nil)
}
func AIAgentPostRollback_rollout(ctx *gin.Context) {
operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.RollbackAIAgentRolloutRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
if err := services.AIAgentService.RollbackAIAgentRollout(req.ID, operator); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, nil)
}
func AIAgentPostUpdate_sort(ctx *gin.Context) { func AIAgentPostUpdate_sort(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate); err != nil { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate); err != nil {
httpx.WriteJSON(ctx, err) httpx.WriteJSON(ctx, err)
@@ -165,36 +237,50 @@ func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse {
} }
func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) response.AIAgentResponse { func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) response.AIAgentResponse {
runtimeMode := item.RuntimeMode
if runtimeMode == "" {
runtimeMode = enums.AIAgentRuntimeModeWorkflow
}
ret := response.AIAgentResponse{ ret := response.AIAgentResponse{
ID: item.ID, ID: item.ID,
Name: item.Name, Name: item.Name,
Description: item.Description, Description: item.Description,
Status: item.Status, Status: item.Status,
StatusName: enums.GetStatusLabel(item.Status), StatusName: enums.GetStatusLabel(item.Status),
AIConfigID: item.AIConfigID, AIConfigID: item.AIConfigID,
ServiceMode: item.ServiceMode, RuntimeMode: runtimeMode,
ServiceModeName: enums.GetIMConversationServiceModeLabel(item.ServiceMode), RuntimeModeName: enums.GetAIAgentRuntimeModeLabel(runtimeMode),
SystemPrompt: item.SystemPrompt, MaxSteps: item.MaxSteps,
WelcomeMessage: item.WelcomeMessage, ContextWindow: item.ContextWindow,
ReplyTimeoutSeconds: item.ReplyTimeoutSeconds, ToolPolicy: item.ToolPolicy,
HandoffMode: item.HandoffMode, KnowledgePolicy: item.KnowledgePolicy,
HandoffModeName: enums.GetAIAgentHandoffModeLabel(item.HandoffMode), ServiceMode: item.ServiceMode,
FallbackMode: item.FallbackMode, ServiceModeName: enums.GetIMConversationServiceModeLabel(item.ServiceMode),
FallbackModeName: enums.GetAIAgentFallbackModeLabel(item.FallbackMode), SystemPrompt: item.SystemPrompt,
FallbackMessage: item.FallbackMessage, WelcomeMessage: item.WelcomeMessage,
SkillIDs: utils.SplitInt64s(item.SkillIDs), ReplyTimeoutSeconds: item.ReplyTimeoutSeconds,
Skills: make([]response.AIAgentSkillResponse, 0), RolloutPercent: item.RolloutPercent,
Teams: make([]response.AIAgentTeamResponse, 0), PreviousRolloutPercent: item.PreviousRolloutPercent,
DirectTools: make([]response.AIAgentMCPToolResponse, 0), HandoffMode: item.HandoffMode,
WorkflowVersionID: item.WorkflowVersionID, HandoffModeName: enums.GetAIAgentHandoffModeLabel(item.HandoffMode),
WorkflowPublished: item.WorkflowVersionID > 0, FallbackMode: item.FallbackMode,
WorkflowState: aiAgentWorkflowState(item.WorkflowVersionID), FallbackModeName: enums.GetAIAgentFallbackModeLabel(item.FallbackMode),
WorkflowStateText: aiAgentWorkflowStateText(item.WorkflowVersionID), FallbackMessage: item.FallbackMessage,
SortNo: item.SortNo, KnowledgeBaseIDs: utils.SplitInt64s(item.KnowledgeIDs),
CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"), SkillIDs: utils.SplitInt64s(item.SkillIDs),
UpdatedAt: item.UpdatedAt.Format("2006-01-02 15:04:05"), Skills: make([]response.AIAgentSkillResponse, 0),
CreateUserName: item.CreateUserName, Teams: make([]response.AIAgentTeamResponse, 0),
UpdateUserName: item.UpdateUserName, DirectTools: make([]response.AIAgentMCPToolResponse, 0),
WorkflowVersionID: item.WorkflowVersionID,
PublishedRevisionID: item.PublishedRevisionID,
WorkflowPublished: item.WorkflowVersionID > 0,
WorkflowState: aiAgentWorkflowState(item.WorkflowVersionID),
WorkflowStateText: aiAgentWorkflowStateText(item.WorkflowVersionID),
SortNo: item.SortNo,
CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: item.UpdatedAt.Format("2006-01-02 15:04:05"),
CreateUserName: item.CreateUserName,
UpdateUserName: item.UpdateUserName,
} }
if aiConfig := services.AIConfigService.Get(item.AIConfigID); aiConfig != nil { if aiConfig := services.AIConfigService.Get(item.AIConfigID); aiConfig != nil {
ret.AIConfigName = aiConfig.Name ret.AIConfigName = aiConfig.Name
@@ -4,6 +4,7 @@ import (
"testing" "testing"
"agent-desk/internal/models" "agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls" "github.com/mlogclub/simple/sqls"
@@ -14,6 +15,9 @@ func TestBuildAIAgentResponseExposesWorkflowPublishState(t *testing.T) {
setupAIAgentHandlerTestDB(t) setupAIAgentHandlerTestDB(t)
draft := buildAIAgentResponse(&models.AIAgent{}) draft := buildAIAgentResponse(&models.AIAgent{})
if draft.RuntimeMode != enums.AIAgentRuntimeModeWorkflow {
t.Fatalf("draft.RuntimeMode = %q, want %q", draft.RuntimeMode, enums.AIAgentRuntimeModeWorkflow)
}
if draft.WorkflowPublished { if draft.WorkflowPublished {
t.Fatalf("draft.WorkflowPublished = true, want false") t.Fatalf("draft.WorkflowPublished = true, want false")
} }
@@ -34,6 +38,11 @@ func TestBuildAIAgentResponseExposesWorkflowPublishState(t *testing.T) {
if published.WorkflowStateText == "" { if published.WorkflowStateText == "" {
t.Fatalf("expected published workflow state text") t.Fatalf("expected published workflow state text")
} }
rollout := buildAIAgentResponse(&models.AIAgent{RolloutPercent: 20, PreviousRolloutPercent: 100})
if rollout.RolloutPercent != 20 || rollout.PreviousRolloutPercent != 100 {
t.Fatalf("unexpected rollout response: %#v", rollout)
}
} }
func setupAIAgentHandlerTestDB(t *testing.T) { func setupAIAgentHandlerTestDB(t *testing.T) {
@@ -116,6 +116,14 @@ func AIWorkflowGetDefaultDefinition(ctx *gin.Context) {
httpx.WriteJSON(ctx, services.AIWorkflowService.DefaultAgentWorkflowDefinition()) httpx.WriteJSON(ctx, services.AIWorkflowService.DefaultAgentWorkflowDefinition())
} }
func AIWorkflowGetTemplateList(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, builders.BuildAIWorkflowTemplates(services.AIWorkflowService.ListPlaybookTemplates()))
}
func AIWorkflowPostValidate(ctx *gin.Context) { func AIWorkflowPostValidate(ctx *gin.Context) {
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil { if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil {
httpx.WriteJSON(ctx, err) httpx.WriteJSON(ctx, err)
@@ -100,6 +100,24 @@ func ChannelPostUpdate(ctx *gin.Context) {
httpx.WriteJSON(ctx, nil) httpx.WriteJSON(ctx, nil)
} }
func ChannelPostRollback_ai_agent_rollout(ctx *gin.Context) {
operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelUpdate)
if err != nil {
httpx.WriteJSON(ctx, err)
return
}
req := request.RollbackChannelAIAgentRolloutRequest{}
if err := params.ReadJSON(ctx, &req); err != nil {
httpx.WriteJSON(ctx, err)
return
}
if err := services.ChannelService.RollbackChannelAIAgentRollout(req.ID, operator); err != nil {
httpx.WriteJSON(ctx, err)
return
}
httpx.WriteJSON(ctx, nil)
}
func ChannelPostUpdate_status(ctx *gin.Context) { func ChannelPostUpdate_status(ctx *gin.Context) {
operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelUpdate) operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelUpdate)
if err != nil { if err != nil {
+135 -23
View File
@@ -57,6 +57,12 @@ var Models = []any{
&KnowledgeFeedback{}, &KnowledgeFeedback{},
&SkillDefinition{}, &SkillDefinition{},
&SkillRunLog{}, &SkillRunLog{},
&AgentRevision{},
&AgentRun{},
&AgentStep{},
&AgentToolCall{},
&AgentToolInvocation{},
&AgentRunQualityFeedback{},
&AIWorkflow{}, &AIWorkflow{},
&AIWorkflowVersion{}, &AIWorkflowVersion{},
&AIWorkflowRun{}, &AIWorkflowRun{},
@@ -65,6 +71,21 @@ var Models = []any{
&SystemConfig{}, &SystemConfig{},
} }
// AgentToolInvocation persists the idempotency boundary for a business tool.
// It is intentionally independent of AgentRun audit rows so a retry after a
// process interruption cannot repeat an external write.
type AgentToolInvocation struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
ConversationID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_agent_tool_invocation"`
AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"`
ToolCode string `gorm:"type:varchar(128);not null;default:'';uniqueIndex:uk_agent_tool_invocation"`
IdempotencyKey string `gorm:"type:varchar(160);not null;default:'';uniqueIndex:uk_agent_tool_invocation"`
Status string `gorm:"type:varchar(20);not null;default:'running';index"`
ResultData string `gorm:"type:text"`
ErrorMessage string `gorm:"type:text"`
AuditFields
}
type Migration struct { type Migration struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ID int64 `gorm:"primaryKey;autoIncrement"`
Version int64 `gorm:"type:bigint;not null;uniqueIndex"` Version int64 `gorm:"type:bigint;not null;uniqueIndex"`
@@ -508,24 +529,111 @@ type QuickReply struct {
// AIAgent AI 接待实例。 // AIAgent AI 接待实例。
type AIAgent struct { type AIAgent struct {
ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为 AI Agent 主键。 ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为 AI Agent 主键。
Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为 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 描述。 Description string `gorm:"type:varchar(255);not null;default:''"` // Description 为 AI Agent 描述。
Status enums.Status `gorm:"type:int;not null;index"` // Status 为 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。 AIConfigID int64 `gorm:"type:bigint;not null;default:0;index"` // AIConfigID 为关联的 AI 配置ID。
ServiceMode enums.IMConversationServiceMode `gorm:"type:int;not null;default:3;index"` // ServiceMode 为服务模式,如仅AI、仅人工、AI优先人工接管 RuntimeMode enums.AIAgentRuntimeMode `gorm:"type:varchar(30);not null;default:'workflow';index"` // RuntimeMode 为 Agent 的运行引擎模式
SystemPrompt string `gorm:"type:text"` // SystemPrompt 为该 Agent 的系统提示词 MaxSteps int `gorm:"type:int;not null;default:6"` // MaxSteps 为一次自主运行允许的最大推理步骤数
WelcomeMessage string `gorm:"type:text"` // WelcomeMessage 为该 Agent 的欢迎语或首响模板 ContextWindow int `gorm:"type:int;not null;default:0"` // ContextWindow 为会话上下文消息窗口,0 表示使用运行时默认值
ReplyTimeoutSeconds int `gorm:"type:int;not null;default:180"` // ReplyTimeoutSeconds 为异步自动回复超时秒数 ToolPolicy string `gorm:"type:text"` // ToolPolicy 为工具风险与确认策略JSON
TeamIDs string `gorm:"type:varchar(500);not null;default:''"` // TeamIDs 为转人工时可路由的客服组ID列表,多个之间使用逗号分隔 KnowledgePolicy string `gorm:"type:text"` // KnowledgePolicy 为知识检索与无依据回答策略JSON
HandoffMode enums.AIAgentHandoffMode `gorm:"type:int;not null;default:1"` // HandoffMode 为转人工执行方式,如进入待接入池、进入默认客服组待接入池 ServiceMode enums.IMConversationServiceMode `gorm:"type:int;not null;default:3;index"` // ServiceMode 为服务模式,如仅AI、仅人工、AI优先人工接管
FallbackMode enums.AIAgentFallbackMode `gorm:"type:int;not null;default:1"` // FallbackMode 为知识不足时的回复策略 SystemPrompt string `gorm:"type:text"` // SystemPrompt 为该 Agent 的系统提示词
FallbackMessage string `gorm:"type:text"` // FallbackMessage 为知识不足回复文案 WelcomeMessage string `gorm:"type:text"` // WelcomeMessage 为该 Agent 的欢迎语或首响模板
KnowledgeIDs string `gorm:"type:varchar(500);not null;default:''"` // KnowledgeIDs 为绑定的知识库ID列表,按顺序表示优先级 ReplyTimeoutSeconds int `gorm:"type:int;not null;default:180"` // ReplyTimeoutSeconds 为异步自动回复超时秒数
SkillIDs string `gorm:"type:varchar(500);not null;default:''"` // SkillIDs 为绑定的技能ID列表,按顺序表示允许路由的范围 RolloutPercent int `gorm:"type:int;not null;default:100"` // RolloutPercent 为该 Agent 的会话灰度百分比,100 表示全量
AllowedMCPTools string `gorm:"type:text"` // AllowedMCPTools 为允许 direct tool 路由的 MCP 工具白名单配置JSON PreviousRolloutPercent int `gorm:"type:int;not null;default:0"` // PreviousRolloutPercent 保存上一次生效的灰度比例,0 表示尚无可回滚值
WorkflowVersionID int64 `gorm:"type:bigint;not null;default:0;index"` // WorkflowVersionID 为绑定的已发布会话流程版本ID TeamIDs string `gorm:"type:varchar(500);not null;default:''"` // TeamIDs转人工时可路由的客服组ID列表,多个之间使用逗号分隔
SortNo int `gorm:"type:int;not null;default:0;index"` // SortNo 为后台展示排序号 HandoffMode enums.AIAgentHandoffMode `gorm:"type:int;not null;default:1"` // HandoffMode 为转人工执行方式,如进入待接入池、进入默认客服组待接入池
FallbackMode enums.AIAgentFallbackMode `gorm:"type:int;not null;default:1"` // FallbackMode 为知识不足时的回复策略。
FallbackMessage string `gorm:"type:text"` // FallbackMessage 为知识不足回复文案。
KnowledgeIDs string `gorm:"type:varchar(500);not null;default:''"` // KnowledgeIDs 为绑定的知识库ID列表,按顺序表示优先级。
SkillIDs string `gorm:"type:varchar(500);not null;default:''"` // SkillIDs 为绑定的技能ID列表,按顺序表示允许路由的范围。
AllowedMCPTools string `gorm:"type:text"` // AllowedMCPTools 为允许 direct tool 路由的 MCP 工具白名单配置JSON。
WorkflowVersionID int64 `gorm:"type:bigint;not null;default:0;index"` // WorkflowVersionID 为绑定的已发布会话流程版本ID。
PublishedRevisionID int64 `gorm:"type:bigint;not null;default:0;index"` // PublishedRevisionID 为当前已发布 Agent 配置快照ID。
SortNo int `gorm:"type:int;not null;default:0;index"` // SortNo 为后台展示排序号。
AuditFields
}
// AgentRevision stores an immutable published Agent configuration snapshot.
type AgentRevision struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
AgentID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_agent_revision"`
Revision int `gorm:"type:int;not null;uniqueIndex:uk_agent_revision"`
WorkflowVersionID int64 `gorm:"type:bigint;not null;default:0;index"`
Status enums.Status `gorm:"type:int;not null;default:0;index"`
Definition string `gorm:"type:longtext"`
DefinitionHash string `gorm:"type:varchar(64);not null;default:'';index"`
PublishedAt *time.Time `gorm:"type:datetime;index"`
PublishedByID int64 `gorm:"type:bigint;not null;default:0;index"`
PublishedByName string `gorm:"type:varchar(100);not null;default:''"`
AuditFields
}
// AgentRun is an Engine-independent record for one Agent reply execution.
type AgentRun struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
ConversationID int64 `gorm:"type:bigint;not null;default:0;index"`
AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"`
AgentRevisionID int64 `gorm:"type:bigint;not null;default:0;index"`
SourceMessageID int64 `gorm:"type:bigint;not null;default:0;index"`
WorkflowRunID int64 `gorm:"type:bigint;not null;default:0;index"`
EngineCode string `gorm:"type:varchar(50);not null;default:'';index"`
Status string `gorm:"type:varchar(30);not null;default:'';index"`
PromptTokens int `gorm:"type:int;not null;default:0"`
CompletionTokens int `gorm:"type:int;not null;default:0"`
StartedAt time.Time `gorm:"type:datetime;not null;index"`
EndedAt *time.Time `gorm:"type:datetime;index"`
ErrorMessage string `gorm:"type:text"`
TraceData string `gorm:"type:text"`
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
UpdatedAt time.Time `gorm:"type:datetime;not null;index"`
}
// AgentStep records a normalized model, tool, workflow, or policy transition.
type AgentStep struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
AgentRunID int64 `gorm:"type:bigint;not null;index"`
WorkflowRunID int64 `gorm:"type:bigint;not null;default:0;index"`
StepType string `gorm:"type:varchar(50);not null;default:'';index"`
StepCode string `gorm:"type:varchar(100);not null;default:'';index"`
Status string `gorm:"type:varchar(30);not null;default:'';index"`
InputPreview string `gorm:"type:text"`
OutputPreview string `gorm:"type:text"`
ErrorMessage string `gorm:"type:text"`
StartedAt time.Time `gorm:"type:datetime;not null;index"`
EndedAt *time.Time `gorm:"type:datetime;index"`
DurationMS int `gorm:"type:int;not null;default:0"`
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
}
// AgentToolCall records the safety-relevant details of a normalized tool call.
type AgentToolCall struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
AgentRunID int64 `gorm:"type:bigint;not null;index"`
AgentStepID int64 `gorm:"type:bigint;not null;default:0;index"`
ToolCode string `gorm:"type:varchar(150);not null;default:'';index"`
RiskLevel string `gorm:"type:varchar(30);not null;default:'';index"`
RequireConfirm bool `gorm:"not null;default:false"`
Status string `gorm:"type:varchar(30);not null;default:'';index"`
ArgumentsPreview string `gorm:"type:text"`
ResultPreview string `gorm:"type:text"`
ErrorMessage string `gorm:"type:text"`
DurationMS int `gorm:"type:int;not null;default:0"`
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
}
// AgentRunQualityFeedback is an operator-provided quality review for one
// normalized Agent run. Runtime completion must not be treated as resolution.
type AgentRunQualityFeedback struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
AgentRunID int64 `gorm:"type:bigint;not null;uniqueIndex"`
ResolutionStatus enums.AgentRunResolutionStatus `gorm:"type:varchar(20);not null;default:'unknown';index"`
EvidenceStatus enums.AgentRunEvidenceStatus `gorm:"type:varchar(20);not null;default:'unknown';index"`
Comment string `gorm:"type:text"`
AuditFields AuditFields
} }
@@ -595,11 +703,13 @@ type AIWorkflowNodeRun struct {
// 渠道本身负责定义“入口如何识别、默认接入哪个 AI Agent、渠道专属配置是什么”, // 渠道本身负责定义“入口如何识别、默认接入哪个 AI Agent、渠道专属配置是什么”,
// 而具体消息收发、会话映射等运行时数据由各自的渠道业务表承载。 // 而具体消息收发、会话映射等运行时数据由各自的渠道业务表承载。
type Channel struct { type Channel struct {
ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为渠道主键。 ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为渠道主键。
Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为渠道名称,用于后台展示和业务识别,例如“官网客服”“企业微信主客服”。 Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为渠道名称,用于后台展示和业务识别,例如“官网客服”“企业微信主客服”。
ChannelType string `gorm:"type:varchar(30);not null;default:'';index"` // ChannelType 为渠道类型,决定该渠道的接入方式和配置解释规则。当前规划的典型取值包括:web、wxwork_kf。 ChannelType string `gorm:"type:varchar(30);not null;default:'';index"` // ChannelType 为渠道类型,决定该渠道的接入方式和配置解释规则。当前规划的典型取值包括:web、wxwork_kf。
ChannelID string `gorm:"type:varchar(64);not null;default:'';uniqueIndex"` // ChannelID 为渠道入口标识,由系统自动生成。对 web 渠道,该字段用于前端通过 X-Channel-Id 标识接入来源;对其他渠道,作为统一的系统内稳定渠道标识保留。 ChannelID string `gorm:"type:varchar(64);not null;default:'';uniqueIndex"` // ChannelID 为渠道入口标识,由系统自动生成。对 web 渠道,该字段用于前端通过 X-Channel-Id 标识接入来源;对其他渠道,作为统一的系统内稳定渠道标识保留。
AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"` // AIAgentID 为该渠道默认接入的 AI Agent。 当外部客户通过该渠道首次进入系统且尚未命中现有未结束会话时,系统会使用该 AI Agent 作为会话默认接待实例。 AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"` // AIAgentID 为该渠道默认接入的 AI Agent。 当外部客户通过该渠道首次进入系统且尚未命中现有未结束会话时,系统会使用该 AI Agent 作为会话默认接待实例。
AIAgentRolloutPercent int `gorm:"type:int;not null;default:100"` // AIAgentRolloutPercent 为该渠道对 AI 自动回复施加的灰度百分比,100 表示不额外限制。
PreviousAIAgentRolloutPercent int `gorm:"type:int;not null;default:0"` // PreviousAIAgentRolloutPercent 保存渠道上一次生效的 Agent 灰度比例,0 表示尚无可回滚值。
// ConfigJSON 为渠道专属扩展配置,使用 JSON 存储。 // ConfigJSON 为渠道专属扩展配置,使用 JSON 存储。
// 例如: // 例如:
// 1. web 渠道可记录允许域名、品牌配置等; // 1. web 渠道可记录允许域名、品牌配置等;
@@ -914,6 +1024,8 @@ type ConversationInterrupt struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ID int64 `gorm:"primaryKey;autoIncrement"`
ConversationID int64 `gorm:"type:bigint;not null;default:0;index"` ConversationID int64 `gorm:"type:bigint;not null;default:0;index"`
AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"` AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"`
AgentRunID int64 `gorm:"type:bigint;not null;default:0;index"`
AgentStepID int64 `gorm:"type:bigint;not null;default:0;index"`
SourceMessageID int64 `gorm:"type:bigint;not null;default:0;index"` SourceMessageID int64 `gorm:"type:bigint;not null;default:0;index"`
LastResumeMessageID int64 `gorm:"type:bigint;not null;default:0;index"` LastResumeMessageID int64 `gorm:"type:bigint;not null;default:0;index"`
WorkflowRunID int64 `gorm:"type:bigint;not null;default:0;index"` WorkflowRunID int64 `gorm:"type:bigint;not null;default:0;index"`
@@ -0,0 +1,15 @@
package request
type RunAgentEvaluationRequest struct {
AIAgentID int64 `json:"aiAgentId"`
EngineCode string `json:"engineCode"`
Cases []AgentEvaluationCase `json:"cases"`
}
type AgentEvaluationCase struct {
ID string `json:"id"`
Category string `json:"category"`
Message string `json:"message"`
History []string `json:"history,omitempty"`
Expect map[string]any `json:"expect,omitempty"`
}
@@ -0,0 +1,10 @@
package request
import "agent-desk/internal/pkg/enums"
type SaveAgentRunQualityFeedbackRequest struct {
AgentRunID int64 `json:"agentRunId"`
ResolutionStatus enums.AgentRunResolutionStatus `json:"resolutionStatus"`
EvidenceStatus enums.AgentRunEvidenceStatus `json:"evidenceStatus"`
Comment string `json:"comment"`
}
+20
View File
@@ -46,14 +46,21 @@ type CreateAIAgentRequest struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
AIConfigID int64 `json:"aiConfigId"` AIConfigID int64 `json:"aiConfigId"`
RuntimeMode enums.AIAgentRuntimeMode `json:"runtimeMode"`
MaxSteps int `json:"maxSteps"`
ContextWindow int `json:"contextWindow"`
ToolPolicy string `json:"toolPolicy"`
KnowledgePolicy string `json:"knowledgePolicy"`
ServiceMode enums.IMConversationServiceMode `json:"serviceMode"` ServiceMode enums.IMConversationServiceMode `json:"serviceMode"`
SystemPrompt string `json:"systemPrompt"` SystemPrompt string `json:"systemPrompt"`
WelcomeMessage string `json:"welcomeMessage"` WelcomeMessage string `json:"welcomeMessage"`
ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"` ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"`
RolloutPercent int `json:"rolloutPercent"`
TeamIDs []int64 `json:"teamIds"` TeamIDs []int64 `json:"teamIds"`
HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"` HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"`
FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"` FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"`
FallbackMessage string `json:"fallbackMessage"` FallbackMessage string `json:"fallbackMessage"`
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
SkillIDs []int64 `json:"skillIds"` SkillIDs []int64 `json:"skillIds"`
DirectTools []AIAgentMCPToolRequest `json:"directTools"` DirectTools []AIAgentMCPToolRequest `json:"directTools"`
} }
@@ -67,6 +74,19 @@ type DeleteAIAgentRequest struct {
ID int64 `json:"id"` ID int64 `json:"id"`
} }
type PublishAIAgentRequest struct {
ID int64 `json:"id"`
}
type RollbackAIAgentRequest struct {
ID int64 `json:"id"`
RevisionID int64 `json:"revisionId"`
}
type RollbackAIAgentRolloutRequest struct {
ID int64 `json:"id"`
}
type UpdateAIAgentStatusRequest struct { type UpdateAIAgentStatusRequest struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Status int `json:"status"` Status int `json:"status"`
+11 -6
View File
@@ -1,12 +1,13 @@
package request package request
type CreateChannelRequest struct { type CreateChannelRequest struct {
ChannelType string `json:"channelType"` ChannelType string `json:"channelType"`
AIAgentID int64 `json:"aiAgentId"` AIAgentID int64 `json:"aiAgentId"`
Name string `json:"name"` AIAgentRolloutPercent int `json:"aiAgentRolloutPercent"`
ConfigJSON string `json:"configJson"` Name string `json:"name"`
Status int `json:"status"` ConfigJSON string `json:"configJson"`
Remark string `json:"remark"` Status int `json:"status"`
Remark string `json:"remark"`
} }
type UpdateChannelRequest struct { type UpdateChannelRequest struct {
@@ -19,6 +20,10 @@ type UpdateChannelStatusRequest struct {
Status int `json:"status"` Status int `json:"status"`
} }
type RollbackChannelAIAgentRolloutRequest struct {
ID int64 `json:"id"`
}
type DeleteChannelRequest struct { type DeleteChannelRequest struct {
ID int64 `json:"id"` ID int64 `json:"id"`
} }
@@ -0,0 +1,20 @@
package response
type AgentEvaluationResultResponse struct {
CaseID string `json:"caseId"`
Category string `json:"category"`
EngineCode string `json:"engineCode"`
Passed bool `json:"passed"`
ReplyText string `json:"replyText"`
Interrupted bool `json:"interrupted"`
Error string `json:"error,omitempty"`
Finding string `json:"finding,omitempty"`
}
type AgentEvaluationReportResponse struct {
EngineCode string `json:"engineCode"`
Total int `json:"total"`
Passed int `json:"passed"`
Results []AgentEvaluationResultResponse `json:"results"`
CSV string `json:"csv"`
}
@@ -0,0 +1,66 @@
package response
import "agent-desk/internal/pkg/enums"
type AgentRunResponse struct {
ID int64 `json:"id"`
ConversationID int64 `json:"conversationId"`
AIAgentID int64 `json:"aiAgentId"`
AgentRevisionID int64 `json:"agentRevisionId"`
SourceMessageID int64 `json:"sourceMessageId"`
WorkflowRunID int64 `json:"workflowRunId"`
EngineCode string `json:"engineCode"`
Status string `json:"status"`
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
StartedAt string `json:"startedAt"`
EndedAt string `json:"endedAt"`
DurationMS int64 `json:"durationMs"`
ErrorMessage string `json:"errorMessage"`
TraceData string `json:"traceData"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Steps []AgentStepResponse `json:"steps,omitempty"`
ToolCalls []AgentToolCallResponse `json:"toolCalls,omitempty"`
QualityFeedback *AgentRunQualityFeedbackResponse `json:"qualityFeedback,omitempty"`
}
type AgentRunQualityFeedbackResponse struct {
ID int64 `json:"id"`
AgentRunID int64 `json:"agentRunId"`
ResolutionStatus enums.AgentRunResolutionStatus `json:"resolutionStatus"`
EvidenceStatus enums.AgentRunEvidenceStatus `json:"evidenceStatus"`
Comment string `json:"comment"`
UpdateUserName string `json:"updateUserName"`
UpdatedAt string `json:"updatedAt"`
}
type AgentStepResponse struct {
ID int64 `json:"id"`
AgentRunID int64 `json:"agentRunId"`
WorkflowRunID int64 `json:"workflowRunId"`
StepType string `json:"stepType"`
StepCode string `json:"stepCode"`
Status string `json:"status"`
InputPreview string `json:"inputPreview"`
OutputPreview string `json:"outputPreview"`
ErrorMessage string `json:"errorMessage"`
StartedAt string `json:"startedAt"`
EndedAt string `json:"endedAt"`
DurationMS int `json:"durationMs"`
}
type AgentToolCallResponse struct {
ID int64 `json:"id"`
AgentRunID int64 `json:"agentRunId"`
AgentStepID int64 `json:"agentStepId"`
ToolCode string `json:"toolCode"`
RiskLevel string `json:"riskLevel"`
RequireConfirm bool `json:"requireConfirm"`
Status string `json:"status"`
ArgumentsPreview string `json:"argumentsPreview"`
ResultPreview string `json:"resultPreview"`
ErrorMessage string `json:"errorMessage"`
DurationMS int `json:"durationMs"`
CreatedAt string `json:"createdAt"`
}
+52 -30
View File
@@ -24,6 +24,18 @@ type AIAgentMCPToolResponse struct {
Arguments map[string]string `json:"arguments"` Arguments map[string]string `json:"arguments"`
} }
type AgentRevisionResponse struct {
ID int64 `json:"id"`
AgentID int64 `json:"agentId"`
Revision int `json:"revision"`
WorkflowVersionID int64 `json:"workflowVersionId"`
Status enums.Status `json:"status"`
DefinitionHash string `json:"definitionHash"`
PublishedAt string `json:"publishedAt"`
PublishedByID int64 `json:"publishedById"`
PublishedByName string `json:"publishedByName"`
}
type AIConfigResponse struct { type AIConfigResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -67,34 +79,44 @@ func BuildAIConfigResponse(item *models.AIConfig) AIConfigResponse {
} }
type AIAgentResponse struct { type AIAgentResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Status enums.Status `json:"status"` Status enums.Status `json:"status"`
StatusName string `json:"statusName"` StatusName string `json:"statusName"`
AIConfigID int64 `json:"aiConfigId"` AIConfigID int64 `json:"aiConfigId"`
AIConfigName string `json:"aiConfigName"` AIConfigName string `json:"aiConfigName"`
ServiceMode enums.IMConversationServiceMode `json:"serviceMode"` RuntimeMode enums.AIAgentRuntimeMode `json:"runtimeMode"`
ServiceModeName string `json:"serviceModeName"` RuntimeModeName string `json:"runtimeModeName"`
SystemPrompt string `json:"systemPrompt"` MaxSteps int `json:"maxSteps"`
WelcomeMessage string `json:"welcomeMessage"` ContextWindow int `json:"contextWindow"`
ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"` ToolPolicy string `json:"toolPolicy"`
Teams []AIAgentTeamResponse `json:"teams"` KnowledgePolicy string `json:"knowledgePolicy"`
HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"` ServiceMode enums.IMConversationServiceMode `json:"serviceMode"`
HandoffModeName string `json:"handoffModeName"` ServiceModeName string `json:"serviceModeName"`
FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"` SystemPrompt string `json:"systemPrompt"`
FallbackModeName string `json:"fallbackModeName"` WelcomeMessage string `json:"welcomeMessage"`
FallbackMessage string `json:"fallbackMessage"` ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"`
SkillIDs []int64 `json:"skillIds"` RolloutPercent int `json:"rolloutPercent"`
Skills []AIAgentSkillResponse `json:"skills"` PreviousRolloutPercent int `json:"previousRolloutPercent"`
DirectTools []AIAgentMCPToolResponse `json:"directTools"` Teams []AIAgentTeamResponse `json:"teams"`
WorkflowVersionID int64 `json:"workflowVersionId"` HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"`
WorkflowPublished bool `json:"workflowPublished"` HandoffModeName string `json:"handoffModeName"`
WorkflowState string `json:"workflowState"` FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"`
WorkflowStateText string `json:"workflowStateText"` FallbackModeName string `json:"fallbackModeName"`
SortNo int `json:"sortNo"` FallbackMessage string `json:"fallbackMessage"`
CreatedAt string `json:"createdAt"` KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
UpdatedAt string `json:"updatedAt"` SkillIDs []int64 `json:"skillIds"`
CreateUserName string `json:"createUserName"` Skills []AIAgentSkillResponse `json:"skills"`
UpdateUserName string `json:"updateUserName"` DirectTools []AIAgentMCPToolResponse `json:"directTools"`
WorkflowVersionID int64 `json:"workflowVersionId"`
PublishedRevisionID int64 `json:"publishedRevisionId"`
WorkflowPublished bool `json:"workflowPublished"`
WorkflowState string `json:"workflowState"`
WorkflowStateText string `json:"workflowStateText"`
SortNo int `json:"sortNo"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
CreateUserName string `json:"createUserName"`
UpdateUserName string `json:"updateUserName"`
} }
@@ -41,6 +41,13 @@ type AIWorkflowValidationResponse struct {
Errors []workflowvalidator.Error `json:"errors"` Errors []workflowvalidator.Error `json:"errors"`
} }
type AIWorkflowTemplateResponse struct {
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
Definition dsl.Definition `json:"definition"`
}
type AIWorkflowNodeSpecResponse struct { type AIWorkflowNodeSpecResponse struct {
Type string `json:"type"` Type string `json:"type"`
Title string `json:"title"` Title string `json:"title"`
+21 -17
View File
@@ -6,15 +6,17 @@ import (
) )
type ChannelResponse struct { type ChannelResponse struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ChannelType string `json:"channelType"` ChannelType string `json:"channelType"`
ChannelID string `json:"channelId"` ChannelID string `json:"channelId"`
AIAgentID int64 `json:"aiAgentId"` AIAgentID int64 `json:"aiAgentId"`
AIAgentName string `json:"aiAgentName,omitempty"` AIAgentRolloutPercent int `json:"aiAgentRolloutPercent"`
Name string `json:"name"` PreviousAIAgentRolloutPercent int `json:"previousAiAgentRolloutPercent"`
ConfigJSON string `json:"configJson"` AIAgentName string `json:"aiAgentName,omitempty"`
Status enums.Status `json:"status"` Name string `json:"name"`
Remark string `json:"remark"` ConfigJSON string `json:"configJson"`
Status enums.Status `json:"status"`
Remark string `json:"remark"`
} }
type WxWorkKFAccountResponse struct { type WxWorkKFAccountResponse struct {
@@ -29,13 +31,15 @@ func BuildChannelResponse(item *models.Channel) ChannelResponse {
return ChannelResponse{} return ChannelResponse{}
} }
return ChannelResponse{ return ChannelResponse{
ID: item.ID, ID: item.ID,
ChannelType: item.ChannelType, ChannelType: item.ChannelType,
ChannelID: item.ChannelID, ChannelID: item.ChannelID,
AIAgentID: item.AIAgentID, AIAgentID: item.AIAgentID,
Name: item.Name, AIAgentRolloutPercent: item.AIAgentRolloutPercent,
ConfigJSON: item.ConfigJSON, PreviousAIAgentRolloutPercent: item.PreviousAIAgentRolloutPercent,
Status: item.Status, Name: item.Name,
Remark: item.Remark, ConfigJSON: item.ConfigJSON,
Status: item.Status,
Remark: item.Remark,
} }
} }
+28
View File
@@ -1,5 +1,33 @@
package enums package enums
type AgentRunResolutionStatus string
const (
AgentRunResolutionStatusUnknown AgentRunResolutionStatus = "unknown"
AgentRunResolutionStatusResolved AgentRunResolutionStatus = "resolved"
AgentRunResolutionStatusUnresolved AgentRunResolutionStatus = "unresolved"
)
var AgentRunResolutionStatusValues = []AgentRunResolutionStatus{
AgentRunResolutionStatusUnknown,
AgentRunResolutionStatusResolved,
AgentRunResolutionStatusUnresolved,
}
type AgentRunEvidenceStatus string
const (
AgentRunEvidenceStatusUnknown AgentRunEvidenceStatus = "unknown"
AgentRunEvidenceStatusSupported AgentRunEvidenceStatus = "supported"
AgentRunEvidenceStatusUnsupported AgentRunEvidenceStatus = "unsupported"
)
var AgentRunEvidenceStatusValues = []AgentRunEvidenceStatus{
AgentRunEvidenceStatusUnknown,
AgentRunEvidenceStatusSupported,
AgentRunEvidenceStatusUnsupported,
}
type ServiceStatus int type ServiceStatus int
const ( const (
+36
View File
@@ -220,22 +220,58 @@ type AIAgentFallbackMode int
const ( const (
AIAgentFallbackModeNoAnswer AIAgentFallbackMode = 1 AIAgentFallbackModeNoAnswer AIAgentFallbackMode = 1
AIAgentFallbackModeSuggestRetry AIAgentFallbackMode = 2 AIAgentFallbackModeSuggestRetry AIAgentFallbackMode = 2
AIAgentFallbackModeHandoff AIAgentFallbackMode = 3
) )
var AIAgentFallbackModeValues = []AIAgentFallbackMode{ var AIAgentFallbackModeValues = []AIAgentFallbackMode{
AIAgentFallbackModeNoAnswer, AIAgentFallbackModeNoAnswer,
AIAgentFallbackModeSuggestRetry, AIAgentFallbackModeSuggestRetry,
AIAgentFallbackModeHandoff,
} }
var aiAgentFallbackModeLabelMap = map[AIAgentFallbackMode]string{ var aiAgentFallbackModeLabelMap = map[AIAgentFallbackMode]string{
AIAgentFallbackModeNoAnswer: "直接说明知识不足", AIAgentFallbackModeNoAnswer: "直接说明知识不足",
AIAgentFallbackModeSuggestRetry: "引导用户补充信息", AIAgentFallbackModeSuggestRetry: "引导用户补充信息",
AIAgentFallbackModeHandoff: "转人工客服",
} }
func GetAIAgentFallbackModeLabel(mode AIAgentFallbackMode) string { func GetAIAgentFallbackModeLabel(mode AIAgentFallbackMode) string {
return aiAgentFallbackModeLabelMap[mode] 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 ( const (
IMRealtimeEventConnected = "connected" IMRealtimeEventConnected = "connected"
IMRealtimeEventPong = "pong" IMRealtimeEventPong = "pong"
+23 -2
View File
@@ -57,6 +57,24 @@ var (
SourceType: enums.ToolSourceTypeBuiltin, SourceType: enums.ToolSourceTypeBuiltin,
AutoInjected: true, AutoInjected: true,
} }
BuiltinConversationContext = ToolSpec{
Code: "builtin/conversation_context",
ServerCode: "builtin",
Name: "conversation_context",
Title: "会话上下文",
Description: "读取当前客户基础信息和会话摘要。",
SourceType: enums.ToolSourceTypeBuiltin,
DirectAccess: true,
}
BuiltinKnowledgeRetrieve = ToolSpec{
Code: "builtin/knowledge_retrieve",
ServerCode: "builtin",
Name: "knowledge_retrieve",
Title: "知识检索",
Description: "在当前 Agent 已绑定的知识库中检索证据。",
SourceType: enums.ToolSourceTypeBuiltin,
DirectAccess: true,
}
GraphTriageServiceRequest = ToolSpec{ GraphTriageServiceRequest = ToolSpec{
Code: "graph/triage_service_request", Code: "graph/triage_service_request",
ServerCode: "graph", ServerCode: "graph",
@@ -66,6 +84,7 @@ var (
Description: i18nx.Get("tool.graph.triageServiceRequest.description"), Description: i18nx.Get("tool.graph.triageServiceRequest.description"),
DescriptionKey: "tool.graph.triageServiceRequest.description", DescriptionKey: "tool.graph.triageServiceRequest.description",
SourceType: enums.ToolSourceTypeGraph, SourceType: enums.ToolSourceTypeGraph,
DirectAccess: true,
RuntimeStatic: true, RuntimeStatic: true,
Appendix: i18nx.Get("tool.graph.triageServiceRequest.appendix"), Appendix: i18nx.Get("tool.graph.triageServiceRequest.appendix"),
AppendixKey: "tool.graph.triageServiceRequest.appendix", AppendixKey: "tool.graph.triageServiceRequest.appendix",
@@ -80,6 +99,7 @@ var (
DescriptionKey: "tool.graph.analyzeConversation.description", DescriptionKey: "tool.graph.analyzeConversation.description",
SourceType: enums.ToolSourceTypeGraph, SourceType: enums.ToolSourceTypeGraph,
RuntimeStatic: true, RuntimeStatic: true,
DirectAccess: true,
Appendix: i18nx.Get("tool.graph.analyzeConversation.appendix"), Appendix: i18nx.Get("tool.graph.analyzeConversation.appendix"),
AppendixKey: "tool.graph.analyzeConversation.appendix", AppendixKey: "tool.graph.analyzeConversation.appendix",
} }
@@ -92,6 +112,7 @@ var (
Description: i18nx.Get("tool.graph.prepareTicketDraft.description"), Description: i18nx.Get("tool.graph.prepareTicketDraft.description"),
DescriptionKey: "tool.graph.prepareTicketDraft.description", DescriptionKey: "tool.graph.prepareTicketDraft.description",
SourceType: enums.ToolSourceTypeGraph, SourceType: enums.ToolSourceTypeGraph,
DirectAccess: true,
RuntimeStatic: true, RuntimeStatic: true,
Appendix: i18nx.Get("tool.graph.prepareTicketDraft.appendix"), Appendix: i18nx.Get("tool.graph.prepareTicketDraft.appendix"),
AppendixKey: "tool.graph.prepareTicketDraft.appendix", AppendixKey: "tool.graph.prepareTicketDraft.appendix",
@@ -105,7 +126,6 @@ var (
Description: i18nx.Get("tool.graph.createTicketConfirm.description"), Description: i18nx.Get("tool.graph.createTicketConfirm.description"),
DescriptionKey: "tool.graph.createTicketConfirm.description", DescriptionKey: "tool.graph.createTicketConfirm.description",
SourceType: enums.ToolSourceTypeGraph, SourceType: enums.ToolSourceTypeGraph,
DirectAccess: true,
RuntimeStatic: true, RuntimeStatic: true,
Aliases: []string{"builtin/create_ticket_with_confirmation"}, Aliases: []string{"builtin/create_ticket_with_confirmation"},
Appendix: i18nx.Get("tool.graph.createTicketConfirm.appendix"), Appendix: i18nx.Get("tool.graph.createTicketConfirm.appendix"),
@@ -120,7 +140,6 @@ var (
Description: i18nx.Get("tool.graph.handoffConversation.description"), Description: i18nx.Get("tool.graph.handoffConversation.description"),
DescriptionKey: "tool.graph.handoffConversation.description", DescriptionKey: "tool.graph.handoffConversation.description",
SourceType: enums.ToolSourceTypeGraph, SourceType: enums.ToolSourceTypeGraph,
DirectAccess: true,
RuntimeStatic: true, RuntimeStatic: true,
Appendix: i18nx.Get("tool.graph.handoffConversation.appendix"), Appendix: i18nx.Get("tool.graph.handoffConversation.appendix"),
AppendixKey: "tool.graph.handoffConversation.appendix", AppendixKey: "tool.graph.handoffConversation.appendix",
@@ -128,6 +147,8 @@ var (
RegisteredToolSpecs = []ToolSpec{ RegisteredToolSpecs = []ToolSpec{
BuiltinToolSearch, BuiltinToolSearch,
BuiltinSkill, BuiltinSkill,
BuiltinConversationContext,
BuiltinKnowledgeRetrieve,
GraphTriageServiceRequest, GraphTriageServiceRequest,
GraphAnalyzeConversation, GraphAnalyzeConversation,
GraphPrepareTicketDraft, GraphPrepareTicketDraft,
@@ -0,0 +1,56 @@
package repositories
import (
"agent-desk/internal/models"
"gorm.io/gorm"
)
var AgentRevisionRepository = newAgentRevisionRepository()
func newAgentRevisionRepository() *agentRevisionRepository {
return &agentRevisionRepository{}
}
type agentRevisionRepository struct{}
func (r *agentRevisionRepository) Get(db *gorm.DB, id int64) *models.AgentRevision {
ret := &models.AgentRevision{}
if err := db.First(ret, "id = ?", id).Error; err != nil {
return nil
}
return ret
}
func (r *agentRevisionRepository) Create(db *gorm.DB, item *models.AgentRevision) error {
return db.Create(item).Error
}
func (r *agentRevisionRepository) FindByAgentID(db *gorm.DB, agentID int64) []models.AgentRevision {
if agentID <= 0 {
return []models.AgentRevision{}
}
items := make([]models.AgentRevision, 0)
db.Where("agent_id = ?", agentID).Order("revision DESC, id DESC").Find(&items)
return items
}
func (r *agentRevisionRepository) MaxRevisionByAgentID(db *gorm.DB, agentID int64) int {
if agentID <= 0 {
return 0
}
var ret int
db.Model(&models.AgentRevision{}).Where("agent_id = ?", agentID).Select("COALESCE(MAX(revision), 0)").Scan(&ret)
return ret
}
func (r *agentRevisionRepository) TakeByAgentIDAndWorkflowVersionID(db *gorm.DB, agentID int64, workflowVersionID int64) *models.AgentRevision {
if agentID <= 0 || workflowVersionID <= 0 {
return nil
}
ret := &models.AgentRevision{}
if err := db.Where("agent_id = ? AND workflow_version_id = ?", agentID, workflowVersionID).Order("id DESC").First(ret).Error; err != nil {
return nil
}
return ret
}
@@ -0,0 +1,45 @@
package repositories
import (
"agent-desk/internal/models"
"gorm.io/gorm"
)
var AgentRunQualityFeedbackRepository = newAgentRunQualityFeedbackRepository()
func newAgentRunQualityFeedbackRepository() *agentRunQualityFeedbackRepository {
return &agentRunQualityFeedbackRepository{}
}
type agentRunQualityFeedbackRepository struct{}
func (r *agentRunQualityFeedbackRepository) GetByAgentRunID(db *gorm.DB, agentRunID int64) *models.AgentRunQualityFeedback {
if agentRunID <= 0 {
return nil
}
item := &models.AgentRunQualityFeedback{}
if err := db.Where("agent_run_id = ?", agentRunID).First(item).Error; err != nil {
return nil
}
return item
}
func (r *agentRunQualityFeedbackRepository) FindByAgentRunIDs(db *gorm.DB, agentRunIDs []int64) []models.AgentRunQualityFeedback {
if len(agentRunIDs) == 0 {
return []models.AgentRunQualityFeedback{}
}
var items []models.AgentRunQualityFeedback
if err := db.Where("agent_run_id IN ?", agentRunIDs).Find(&items).Error; err != nil {
return []models.AgentRunQualityFeedback{}
}
return items
}
func (r *agentRunQualityFeedbackRepository) Create(db *gorm.DB, item *models.AgentRunQualityFeedback) error {
return db.Create(item).Error
}
func (r *agentRunQualityFeedbackRepository) Updates(db *gorm.DB, id int64, columns map[string]any) error {
return db.Model(&models.AgentRunQualityFeedback{}).Where("id = ?", id).Updates(columns).Error
}
@@ -0,0 +1,68 @@
package repositories
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/httpx/params"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
var AgentRunRepository = newAgentRunRepository()
func newAgentRunRepository() *agentRunRepository {
return &agentRunRepository{}
}
type agentRunRepository struct{}
func (r *agentRunRepository) Get(db *gorm.DB, id int64) *models.AgentRun {
ret := &models.AgentRun{}
if err := db.First(ret, "id = ?", id).Error; err != nil {
return nil
}
return ret
}
func (r *agentRunRepository) TakeByWorkflowRunID(db *gorm.DB, workflowRunID int64) *models.AgentRun {
if workflowRunID <= 0 {
return nil
}
ret := &models.AgentRun{}
if err := db.Where("workflow_run_id = ?", workflowRunID).Order("id DESC").First(ret).Error; err != nil {
return nil
}
return ret
}
func (r *agentRunRepository) Create(db *gorm.DB, item *models.AgentRun) error {
return db.Create(item).Error
}
func (r *agentRunRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.AgentRun, paging *sqls.Paging) {
cnd.Find(db, &list)
return list, &sqls.Paging{Page: cnd.Paging.Page, Limit: cnd.Paging.Limit, Total: cnd.Count(db, &models.AgentRun{})}
}
func (r *agentRunRepository) FindPageByParams(db *gorm.DB, queryParams *params.QueryParams) (list []models.AgentRun, paging *sqls.Paging) {
return r.FindPageByCnd(db, &queryParams.Cnd)
}
func (r *agentRunRepository) Updates(db *gorm.DB, id int64, columns map[string]any) error {
return db.Model(&models.AgentRun{}).Where("id = ?", id).Updates(columns).Error
}
func (r *agentRunRepository) FindRecent(db *gorm.DB, aiAgentID int64, limit int) []models.AgentRun {
if limit <= 0 || limit > 5000 {
limit = 5000
}
query := db.Order("id DESC").Limit(limit)
if aiAgentID > 0 {
query = query.Where("ai_agent_id = ?", aiAgentID)
}
var items []models.AgentRun
if err := query.Find(&items).Error; err != nil {
return nil
}
return items
}
@@ -0,0 +1,54 @@
package repositories
import (
"agent-desk/internal/models"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
var AgentStepRepository = newAgentStepRepository()
func newAgentStepRepository() *agentStepRepository {
return &agentStepRepository{}
}
type agentStepRepository struct{}
func (r *agentStepRepository) Create(db *gorm.DB, item *models.AgentStep) error {
return db.Create(item).Error
}
func (r *agentStepRepository) FindByAgentRunID(db *gorm.DB, agentRunID int64) []models.AgentStep {
if agentRunID <= 0 {
return []models.AgentStep{}
}
return r.Find(db, sqls.NewCnd().Eq("agent_run_id", agentRunID).Asc("id"))
}
func (r *agentStepRepository) LastByAgentRunID(db *gorm.DB, agentRunID int64) *models.AgentStep {
if agentRunID <= 0 {
return nil
}
ret := &models.AgentStep{}
if err := db.Where("agent_run_id = ?", agentRunID).Order("id DESC").First(ret).Error; err != nil {
return nil
}
return ret
}
func (r *agentStepRepository) FindByAgentRunIDs(db *gorm.DB, agentRunIDs []int64) []models.AgentStep {
if len(agentRunIDs) == 0 {
return nil
}
var items []models.AgentStep
if err := db.Where("agent_run_id IN ?", agentRunIDs).Find(&items).Error; err != nil {
return nil
}
return items
}
func (r *agentStepRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.AgentStep) {
cnd.Find(db, &list)
return
}
@@ -0,0 +1,43 @@
package repositories
import (
"agent-desk/internal/models"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
var AgentToolCallRepository = newAgentToolCallRepository()
func newAgentToolCallRepository() *agentToolCallRepository {
return &agentToolCallRepository{}
}
type agentToolCallRepository struct{}
func (r *agentToolCallRepository) Create(db *gorm.DB, item *models.AgentToolCall) error {
return db.Create(item).Error
}
func (r *agentToolCallRepository) FindByAgentRunID(db *gorm.DB, agentRunID int64) []models.AgentToolCall {
if agentRunID <= 0 {
return []models.AgentToolCall{}
}
return r.Find(db, sqls.NewCnd().Eq("agent_run_id", agentRunID).Asc("id"))
}
func (r *agentToolCallRepository) FindByAgentRunIDs(db *gorm.DB, agentRunIDs []int64) []models.AgentToolCall {
if len(agentRunIDs) == 0 {
return nil
}
var items []models.AgentToolCall
if err := db.Where("agent_run_id IN ?", agentRunIDs).Find(&items).Error; err != nil {
return nil
}
return items
}
func (r *agentToolCallRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.AgentToolCall) {
cnd.Find(db, &list)
return
}
@@ -0,0 +1,34 @@
package repositories
import (
"agent-desk/internal/models"
"gorm.io/gorm"
)
var AgentToolInvocationRepository = newAgentToolInvocationRepository()
func newAgentToolInvocationRepository() *agentToolInvocationRepository {
return &agentToolInvocationRepository{}
}
type agentToolInvocationRepository struct{}
func (r *agentToolInvocationRepository) GetByIdempotencyKey(db *gorm.DB, conversationID int64, toolCode, idempotencyKey string) *models.AgentToolInvocation {
if conversationID <= 0 || toolCode == "" || idempotencyKey == "" {
return nil
}
var item models.AgentToolInvocation
if err := db.Where("conversation_id = ? AND tool_code = ? AND idempotency_key = ?", conversationID, toolCode, idempotencyKey).First(&item).Error; err != nil {
return nil
}
return &item
}
func (r *agentToolInvocationRepository) Create(db *gorm.DB, item *models.AgentToolInvocation) error {
return db.Create(item).Error
}
func (r *agentToolInvocationRepository) Updates(db *gorm.DB, id int64, values map[string]any) error {
return db.Model(&models.AgentToolInvocation{}).Where("id = ?", id).Updates(values).Error
}
@@ -39,6 +39,17 @@ func (r *conversationInterruptRepository) FindLatestPendingByConversationID(db *
return ret return ret
} }
func (r *conversationInterruptRepository) FindByAgentRunIDs(db *gorm.DB, agentRunIDs []int64) []models.ConversationInterrupt {
if len(agentRunIDs) == 0 {
return []models.ConversationInterrupt{}
}
var items []models.ConversationInterrupt
if err := db.Where("agent_run_id IN ?", agentRunIDs).Find(&items).Error; err != nil {
return []models.ConversationInterrupt{}
}
return items
}
func (r *conversationInterruptRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.ConversationInterrupt) { func (r *conversationInterruptRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.ConversationInterrupt) {
cnd.Find(db, &list) cnd.Find(db, &list)
return return
@@ -72,6 +83,8 @@ func (r *conversationInterruptRepository) UpsertByCheckPointID(db *gorm.DB, item
columns := map[string]any{ columns := map[string]any{
"conversation_id": item.ConversationID, "conversation_id": item.ConversationID,
"ai_agent_id": item.AIAgentID, "ai_agent_id": item.AIAgentID,
"agent_run_id": item.AgentRunID,
"agent_step_id": item.AgentStepID,
"source_message_id": item.SourceMessageID, "source_message_id": item.SourceMessageID,
"last_resume_message_id": item.LastResumeMessageID, "last_resume_message_id": item.LastResumeMessageID,
"workflow_run_id": item.WorkflowRunID, "workflow_run_id": item.WorkflowRunID,
@@ -77,6 +77,30 @@ func (r *conversationRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
return cnd.Count(db, &models.Conversation{}) return cnd.Count(db, &models.Conversation{})
} }
func (r *conversationRepository) CountByAIAgentID(db *gorm.DB, aiAgentID int64) int64 {
query := db.Model(&models.Conversation{})
if aiAgentID > 0 {
query = query.Where("ai_agent_id = ?", aiAgentID)
}
var count int64
if err := query.Count(&count).Error; err != nil {
return 0
}
return count
}
func (r *conversationRepository) CountHandoffByAIAgentID(db *gorm.DB, aiAgentID int64) int64 {
query := db.Model(&models.Conversation{}).Where("handoff_at IS NOT NULL")
if aiAgentID > 0 {
query = query.Where("ai_agent_id = ?", aiAgentID)
}
var count int64
if err := query.Count(&count).Error; err != nil {
return 0
}
return count
}
func (r *conversationRepository) Create(db *gorm.DB, t *models.Conversation) (err error) { func (r *conversationRepository) Create(db *gorm.DB, t *models.Conversation) (err error) {
err = db.Create(t).Error err = db.Create(t).Error
return return
@@ -0,0 +1,37 @@
package services
import (
"context"
"fmt"
"strings"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/errorsx"
)
var AgentEvaluationService = newAgentEvaluationService()
var AgentEvaluationRunHook func(context.Context, request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error)
type agentEvaluationService struct{}
func newAgentEvaluationService() *agentEvaluationService { return &agentEvaluationService{} }
func (s *agentEvaluationService) Run(ctx context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) {
if req.AIAgentID <= 0 {
return nil, errorsx.InvalidParam("ai agent id is required")
}
if strings.TrimSpace(req.EngineCode) == "" {
return nil, errorsx.InvalidParam("engine code is required")
}
if len(req.Cases) == 0 {
return nil, errorsx.InvalidParam("evaluation cases are required")
}
if len(req.Cases) > 100 {
return nil, errorsx.InvalidParam("evaluation case limit exceeded")
}
if AgentEvaluationRunHook == nil {
return nil, fmt.Errorf("agent evaluation runner is not initialized")
}
return AgentEvaluationRunHook(ctx, req)
}
@@ -0,0 +1,26 @@
package services
import (
"context"
"testing"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
)
func TestAgentEvaluationServiceValidatesAndCallsRunner(t *testing.T) {
previous := AgentEvaluationRunHook
t.Cleanup(func() { AgentEvaluationRunHook = previous })
called := false
AgentEvaluationRunHook = func(_ context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) {
called = true
return &response.AgentEvaluationReportResponse{EngineCode: req.EngineCode, Total: len(req.Cases)}, nil
}
result, err := AgentEvaluationService.Run(context.Background(), request.RunAgentEvaluationRequest{AIAgentID: 1, EngineCode: "autonomous", Cases: []request.AgentEvaluationCase{{ID: "faq", Message: "hello"}}})
if err != nil || !called || result.Total != 1 {
t.Fatalf("result=%#v called=%t err=%v", result, called, err)
}
if _, err := AgentEvaluationService.Run(context.Background(), request.RunAgentEvaluationRequest{}); err == nil {
t.Fatal("expected invalid request")
}
}
+214
View File
@@ -0,0 +1,214 @@
package services
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
var AgentRevisionService = newAgentRevisionService()
func newAgentRevisionService() *agentRevisionService {
return &agentRevisionService{}
}
type agentRevisionService struct{}
func (s *agentRevisionService) Get(id int64) *models.AgentRevision {
if id <= 0 {
return nil
}
return repositories.AgentRevisionRepository.Get(sqls.DB(), id)
}
func (s *agentRevisionService) FindByAgentID(agentID int64) []models.AgentRevision {
return repositories.AgentRevisionRepository.FindByAgentID(sqls.DB(), agentID)
}
type agentRevisionDefinition struct {
Agent agentRevisionAgent `json:"agent"`
Model agentRevisionModel `json:"model"`
WorkflowVersionID int64 `json:"workflowVersionId"`
WorkflowDefinition string `json:"workflowDefinition"`
}
// agentRevisionModel deliberately excludes APIKey. A revision must capture
// reproducible routing/model parameters without duplicating credentials.
type agentRevisionModel struct {
ConfigID int64 `json:"configId"`
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
ModelType string `json:"modelType"`
ModelName string `json:"modelName"`
MaxContextTokens int `json:"maxContextTokens"`
MaxOutputTokens int `json:"maxOutputTokens"`
TimeoutMS int `json:"timeoutMs"`
MaxRetryCount int `json:"maxRetryCount"`
}
type agentRevisionAgent struct {
Name string `json:"name"`
Description string `json:"description"`
AIConfigID int64 `json:"aiConfigId"`
RuntimeMode string `json:"runtimeMode"`
MaxSteps int `json:"maxSteps"`
ContextWindow int `json:"contextWindow"`
ToolPolicy string `json:"toolPolicy"`
KnowledgePolicy string `json:"knowledgePolicy"`
ServiceMode int `json:"serviceMode"`
SystemPrompt string `json:"systemPrompt"`
WelcomeMessage string `json:"welcomeMessage"`
ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"`
TeamIDs string `json:"teamIds"`
HandoffMode int `json:"handoffMode"`
FallbackMode int `json:"fallbackMode"`
FallbackMessage string `json:"fallbackMessage"`
KnowledgeIDs string `json:"knowledgeIds"`
SkillIDs string `json:"skillIds"`
AllowedMCPTools string `json:"allowedMcpTools"`
}
// AgentRevisionSnapshot is the immutable runtime configuration restored from
// a published revision. Model credentials deliberately remain on the current
// AIConfig so credential rotation does not require republishing every Agent.
type AgentRevisionSnapshot struct {
Revision models.AgentRevision
Agent models.AIAgent
AIConfig models.AIConfig
}
// ResolvePublishedSnapshot restores a published Agent revision for runtime
// execution. Empty legacy definitions retain the current fields so historical
// records created before snapshot hydration remain executable.
func (s *agentRevisionService) ResolvePublishedSnapshot(agent models.AIAgent, config models.AIConfig) (*AgentRevisionSnapshot, error) {
if agent.PublishedRevisionID <= 0 {
return nil, errorsx.InvalidParam("autonomous agent is not published")
}
revision := repositories.AgentRevisionRepository.Get(sqls.DB(), agent.PublishedRevisionID)
if revision == nil || revision.AgentID != agent.ID || revision.Status != enums.StatusOk {
return nil, errorsx.InvalidParam("autonomous agent published revision does not exist")
}
snapshot := &AgentRevisionSnapshot{Revision: *revision, Agent: agent, AIConfig: config}
if strings.TrimSpace(revision.Definition) == "" {
return snapshot, nil
}
definition := agentRevisionDefinition{}
if err := json.Unmarshal([]byte(revision.Definition), &definition); err != nil {
return nil, errorsx.InvalidParam("autonomous agent published revision is invalid")
}
if definition.Agent.AIConfigID > 0 && definition.Agent.AIConfigID != config.ID {
return nil, errorsx.InvalidParam("published agent model config no longer matches")
}
applyRevisionAgentSnapshot(&snapshot.Agent, definition.Agent)
if definition.WorkflowVersionID > 0 {
snapshot.Agent.WorkflowVersionID = definition.WorkflowVersionID
}
applyRevisionModelSnapshot(&snapshot.AIConfig, definition.Model)
return snapshot, nil
}
func applyRevisionAgentSnapshot(agent *models.AIAgent, definition agentRevisionAgent) {
if agent == nil {
return
}
agent.Name = definition.Name
agent.Description = definition.Description
agent.AIConfigID = definition.AIConfigID
agent.RuntimeMode = enums.AIAgentRuntimeMode(definition.RuntimeMode)
agent.MaxSteps = definition.MaxSteps
agent.ContextWindow = definition.ContextWindow
agent.ToolPolicy = definition.ToolPolicy
agent.KnowledgePolicy = definition.KnowledgePolicy
agent.ServiceMode = enums.IMConversationServiceMode(definition.ServiceMode)
agent.SystemPrompt = definition.SystemPrompt
agent.WelcomeMessage = definition.WelcomeMessage
agent.ReplyTimeoutSeconds = definition.ReplyTimeoutSeconds
agent.TeamIDs = definition.TeamIDs
agent.HandoffMode = enums.AIAgentHandoffMode(definition.HandoffMode)
agent.FallbackMode = enums.AIAgentFallbackMode(definition.FallbackMode)
agent.FallbackMessage = definition.FallbackMessage
agent.KnowledgeIDs = definition.KnowledgeIDs
agent.SkillIDs = definition.SkillIDs
agent.AllowedMCPTools = definition.AllowedMCPTools
}
func applyRevisionModelSnapshot(config *models.AIConfig, definition agentRevisionModel) {
if config == nil || definition.ConfigID <= 0 {
return
}
config.Provider = enums.AIProvider(definition.Provider)
config.BaseURL = definition.BaseURL
config.ModelType = enums.AIModelType(definition.ModelType)
config.ModelName = definition.ModelName
config.MaxContextTokens = definition.MaxContextTokens
config.MaxOutputTokens = definition.MaxOutputTokens
config.TimeoutMS = definition.TimeoutMS
config.MaxRetryCount = definition.MaxRetryCount
}
// PublishWorkflowSnapshot keeps the Agent settings and its referenced
// workflow definition together as an immutable, reproducible revision.
func (s *agentRevisionService) PublishWorkflowSnapshot(db *gorm.DB, agent *models.AIAgent, version *models.AIWorkflowVersion, operator *dto.AuthPrincipal) (*models.AgentRevision, error) {
return s.publishSnapshot(db, agent, version, operator)
}
func (s *agentRevisionService) PublishSnapshot(db *gorm.DB, agent *models.AIAgent, operator *dto.AuthPrincipal) (*models.AgentRevision, error) {
return s.publishSnapshot(db, agent, nil, operator)
}
func (s *agentRevisionService) publishSnapshot(db *gorm.DB, agent *models.AIAgent, version *models.AIWorkflowVersion, operator *dto.AuthPrincipal) (*models.AgentRevision, error) {
model := agentRevisionModel{ConfigID: agent.AIConfigID}
if config := repositories.AIConfigRepository.Get(db, agent.AIConfigID); config != nil {
model = agentRevisionModel{
ConfigID: config.ID, Provider: string(config.Provider), BaseURL: config.BaseURL, ModelType: string(config.ModelType),
ModelName: config.ModelName, MaxContextTokens: config.MaxContextTokens, MaxOutputTokens: config.MaxOutputTokens,
TimeoutMS: config.TimeoutMS, MaxRetryCount: config.MaxRetryCount,
}
}
workflowVersionID := int64(0)
workflowDefinition := ""
if version != nil {
workflowVersionID = version.ID
workflowDefinition = version.Definition
}
definition := agentRevisionDefinition{
Agent: agentRevisionAgent{
Name: agent.Name, Description: agent.Description, AIConfigID: agent.AIConfigID,
RuntimeMode: string(agent.RuntimeMode), MaxSteps: agent.MaxSteps, ContextWindow: agent.ContextWindow,
ToolPolicy: agent.ToolPolicy, KnowledgePolicy: agent.KnowledgePolicy, ServiceMode: int(agent.ServiceMode), SystemPrompt: agent.SystemPrompt,
WelcomeMessage: agent.WelcomeMessage, ReplyTimeoutSeconds: agent.ReplyTimeoutSeconds, TeamIDs: agent.TeamIDs, HandoffMode: int(agent.HandoffMode),
FallbackMode: int(agent.FallbackMode), FallbackMessage: agent.FallbackMessage, KnowledgeIDs: agent.KnowledgeIDs,
SkillIDs: agent.SkillIDs, AllowedMCPTools: agent.AllowedMCPTools,
},
Model: model,
WorkflowVersionID: workflowVersionID,
WorkflowDefinition: workflowDefinition,
}
data, err := json.Marshal(definition)
if err != nil {
return nil, err
}
now := time.Now()
hash := sha256.Sum256(data)
item := &models.AgentRevision{
AgentID: agent.ID, Revision: repositories.AgentRevisionRepository.MaxRevisionByAgentID(db, agent.ID) + 1,
WorkflowVersionID: workflowVersionID, Status: enums.StatusOk, Definition: string(data), DefinitionHash: hex.EncodeToString(hash[:]),
PublishedAt: &now, PublishedByID: operator.UserID, PublishedByName: operator.Username, AuditFields: utils.BuildAuditFields(operator),
}
if err := repositories.AgentRevisionRepository.Create(db, item); err != nil {
return nil, err
}
return item, nil
}
@@ -0,0 +1,50 @@
package services
import (
"encoding/json"
"strings"
"testing"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
func TestAgentRevisionServiceRestoresPublishedSnapshotAndKeepsAPIKey(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
definition := agentRevisionDefinition{
Agent: agentRevisionAgent{
Name: "published agent", AIConfigID: 8, RuntimeMode: string(enums.AIAgentRuntimeModeAutonomous),
MaxSteps: 5, ContextWindow: 9, SystemPrompt: "published instruction", KnowledgeIDs: "4", ReplyTimeoutSeconds: 90,
},
Model: agentRevisionModel{ConfigID: 8, Provider: string(enums.AIProviderOpenAI), BaseURL: "https://published.example/v1", ModelType: string(enums.AIModelTypeLLM), ModelName: "published-model", TimeoutMS: 12000},
}
data, err := json.Marshal(definition)
if err != nil {
t.Fatalf("marshal definition: %v", err)
}
revision := &models.AgentRevision{AgentID: 7, Revision: 1, Status: enums.StatusOk, Definition: string(data)}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
snapshot, err := AgentRevisionService.ResolvePublishedSnapshot(models.AIAgent{ID: 7, PublishedRevisionID: revision.ID, SystemPrompt: "draft instruction"}, models.AIConfig{ID: 8, APIKey: "rotated-secret", ModelName: "draft-model"})
if err != nil {
t.Fatalf("ResolvePublishedSnapshot: %v", err)
}
if snapshot.Agent.SystemPrompt != "published instruction" || snapshot.Agent.MaxSteps != 5 || snapshot.Agent.ReplyTimeoutSeconds != 90 {
t.Fatalf("agent snapshot not restored: %#v", snapshot.Agent)
}
if snapshot.AIConfig.ModelName != "published-model" || snapshot.AIConfig.BaseURL != "https://published.example/v1" || snapshot.AIConfig.APIKey != "rotated-secret" {
t.Fatalf("model snapshot not restored safely: %#v", snapshot.AIConfig)
}
}
+514
View File
@@ -0,0 +1,514 @@
package services
import (
"regexp"
"slices"
"sort"
"strings"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
var AgentRunService = newAgentRunService()
func newAgentRunService() *agentRunService {
return &agentRunService{}
}
type agentRunService struct{}
type AgentRunMetrics struct {
TotalRuns int `json:"totalRuns"`
CompletedRuns int `json:"completedRuns"`
FailedRuns int `json:"failedRuns"`
InterruptedRuns int `json:"interruptedRuns"`
CompletionRate float64 `json:"completionRate"`
ToolCalls int `json:"toolCalls"`
ToolSuccessRate float64 `json:"toolSuccessRate"`
AverageSteps float64 `json:"averageSteps"`
AverageDurationMS int64 `json:"averageDurationMs"`
P95DurationMS int64 `json:"p95DurationMs"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
HandoffRate float64 `json:"handoffRate"`
KnowledgeFallbackRate float64 `json:"knowledgeFallbackRate"`
ResumedInterrupts int `json:"resumedInterrupts"`
ResolvedInterrupts int `json:"resolvedInterrupts"`
InterruptRecoveryRate float64 `json:"interruptRecoveryRate"`
ReviewedRuns int `json:"reviewedRuns"`
ResolvedRuns int `json:"resolvedRuns"`
ResolutionRate float64 `json:"resolutionRate"`
UnsupportedEvidenceRuns int `json:"unsupportedEvidenceRuns"`
UnsupportedEvidenceRate float64 `json:"unsupportedEvidenceRate"`
}
type AgentRunEngineComparison struct {
EngineCode string `json:"engineCode"`
Metrics AgentRunMetrics `json:"metrics"`
}
const maxAgentAuditPreviewChars = 4000
var agentAuditSecretPattern = regexp.MustCompile(`(?i)(?:"|')?(api[_-]?key|authorization|password|secret|token|cookie)(?:"|')?\s*([:=])\s*(?:"[^"]*"|'[^']*'|[^\s,;}]+)`)
func (s *agentRunService) Get(id int64) *models.AgentRun {
if id <= 0 {
return nil
}
return repositories.AgentRunRepository.Get(sqls.DB(), id)
}
func (s *agentRunService) FindPageByParams(queryParams *params.QueryParams) (list []models.AgentRun, paging *sqls.Paging) {
return repositories.AgentRunRepository.FindPageByParams(sqls.DB(), queryParams)
}
func (s *agentRunService) GetDetail(id int64) (*models.AgentRun, []models.AgentStep, []models.AgentToolCall) {
run := s.Get(id)
if run == nil {
return nil, nil, nil
}
return run,
repositories.AgentStepRepository.FindByAgentRunID(sqls.DB(), id),
repositories.AgentToolCallRepository.FindByAgentRunID(sqls.DB(), id)
}
func (s *agentRunService) GetLatestStepID(agentRunID int64) int64 {
step := repositories.AgentStepRepository.LastByAgentRunID(sqls.DB(), agentRunID)
if step == nil {
return 0
}
return step.ID
}
func (s *agentRunService) GetQualityFeedback(agentRunID int64) *models.AgentRunQualityFeedback {
return repositories.AgentRunQualityFeedbackRepository.GetByAgentRunID(sqls.DB(), agentRunID)
}
func (s *agentRunService) SaveQualityFeedback(req request.SaveAgentRunQualityFeedbackRequest, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
if req.AgentRunID <= 0 {
return errorsx.InvalidParam("agent run id is required")
}
if !slices.Contains(enums.AgentRunResolutionStatusValues, req.ResolutionStatus) || !slices.Contains(enums.AgentRunEvidenceStatusValues, req.EvidenceStatus) {
return errorsx.InvalidParam("invalid agent run quality feedback status")
}
comment := strings.TrimSpace(req.Comment)
if len([]rune(comment)) > 2000 {
return errorsx.InvalidParam("agent run quality feedback comment is too long")
}
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if repositories.AgentRunRepository.Get(ctx.Tx, req.AgentRunID) == nil {
return errorsx.InvalidParam("agent run does not exist")
}
current := repositories.AgentRunQualityFeedbackRepository.GetByAgentRunID(ctx.Tx, req.AgentRunID)
if current == nil {
return repositories.AgentRunQualityFeedbackRepository.Create(ctx.Tx, &models.AgentRunQualityFeedback{
AgentRunID: req.AgentRunID, ResolutionStatus: req.ResolutionStatus, EvidenceStatus: req.EvidenceStatus, Comment: comment,
AuditFields: utils.BuildAuditFields(operator),
})
}
return repositories.AgentRunQualityFeedbackRepository.Updates(ctx.Tx, current.ID, map[string]any{
"resolution_status": req.ResolutionStatus,
"evidence_status": req.EvidenceStatus,
"comment": comment,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
})
}
// GetMetrics aggregates normalized audit records in Go so SQLite and MySQL
// use identical percentile and rate semantics.
func (s *agentRunService) GetMetrics(aiAgentID int64) AgentRunMetrics {
runs := repositories.AgentRunRepository.FindRecent(sqls.DB(), aiAgentID, 5000)
metrics := s.aggregateMetrics(sqls.DB(), runs)
if len(runs) == 0 {
return metrics
}
conversationCount := repositories.ConversationRepository.CountByAIAgentID(sqls.DB(), aiAgentID)
if conversationCount > 0 {
metrics.HandoffRate = float64(repositories.ConversationRepository.CountHandoffByAIAgentID(sqls.DB(), aiAgentID)) / float64(conversationCount)
}
return metrics
}
// GetEngineComparisons keeps Workflow, Autonomous, and Hybrid reports based on
// the same normalized audit and reviewed-quality records. Conversation-level
// handoff is deliberately excluded because it cannot be attributed to one
// Engine after a mode change.
func (s *agentRunService) GetEngineComparisons(aiAgentID int64) []AgentRunEngineComparison {
runs := repositories.AgentRunRepository.FindRecent(sqls.DB(), aiAgentID, 5000)
groups := make(map[string][]models.AgentRun)
for _, run := range runs {
engineCode := strings.TrimSpace(run.EngineCode)
if engineCode == "" {
engineCode = "unknown"
}
groups[engineCode] = append(groups[engineCode], run)
}
engineCodes := make([]string, 0, len(groups))
for engineCode := range groups {
engineCodes = append(engineCodes, engineCode)
}
sort.Strings(engineCodes)
ret := make([]AgentRunEngineComparison, 0, len(engineCodes))
for _, engineCode := range engineCodes {
ret = append(ret, AgentRunEngineComparison{EngineCode: engineCode, Metrics: s.aggregateMetrics(sqls.DB(), groups[engineCode])})
}
return ret
}
func (s *agentRunService) aggregateMetrics(db *gorm.DB, runs []models.AgentRun) AgentRunMetrics {
metrics := AgentRunMetrics{TotalRuns: len(runs)}
if len(runs) == 0 {
return metrics
}
runIDs := make([]int64, 0, len(runs))
durations := make([]int64, 0, len(runs))
var durationTotal int64
for _, run := range runs {
runIDs = append(runIDs, run.ID)
switch run.Status {
case "completed":
metrics.CompletedRuns++
case "failed":
metrics.FailedRuns++
case "interrupted":
metrics.InterruptedRuns++
}
metrics.PromptTokens += int64(run.PromptTokens)
metrics.CompletionTokens += int64(run.CompletionTokens)
if run.EndedAt != nil {
duration := run.EndedAt.Sub(run.StartedAt).Milliseconds()
if duration < 0 {
duration = 0
}
durations = append(durations, duration)
durationTotal += duration
}
}
metrics.CompletionRate = float64(metrics.CompletedRuns) / float64(metrics.TotalRuns)
if len(durations) > 0 {
metrics.AverageDurationMS = durationTotal / int64(len(durations))
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
index := (len(durations)*95+99)/100 - 1
metrics.P95DurationMS = durations[index]
}
steps := repositories.AgentStepRepository.FindByAgentRunIDs(db, runIDs)
metrics.AverageSteps = float64(len(steps)) / float64(metrics.TotalRuns)
fallbackRunIDs := make(map[int64]struct{})
for _, step := range steps {
if step.StepType == "policy" && step.StepCode == "knowledge_evidence" {
fallbackRunIDs[step.AgentRunID] = struct{}{}
}
}
metrics.KnowledgeFallbackRate = float64(len(fallbackRunIDs)) / float64(metrics.TotalRuns)
toolCalls := repositories.AgentToolCallRepository.FindByAgentRunIDs(db, runIDs)
metrics.ToolCalls = len(toolCalls)
if len(toolCalls) > 0 {
completed := 0
for _, call := range toolCalls {
if call.Status == "completed" {
completed++
}
}
metrics.ToolSuccessRate = float64(completed) / float64(len(toolCalls))
}
interrupts := repositories.ConversationInterruptRepository.FindByAgentRunIDs(db, runIDs)
for _, interrupt := range interrupts {
if interrupt.ResumeCount <= 0 {
continue
}
metrics.ResumedInterrupts++
if interrupt.Status == "resolved" {
metrics.ResolvedInterrupts++
}
}
if metrics.ResumedInterrupts > 0 {
metrics.InterruptRecoveryRate = float64(metrics.ResolvedInterrupts) / float64(metrics.ResumedInterrupts)
}
feedbacks := repositories.AgentRunQualityFeedbackRepository.FindByAgentRunIDs(db, runIDs)
metrics.ReviewedRuns = len(feedbacks)
for _, feedback := range feedbacks {
if feedback.ResolutionStatus == enums.AgentRunResolutionStatusResolved {
metrics.ResolvedRuns++
}
if feedback.EvidenceStatus == enums.AgentRunEvidenceStatusUnsupported {
metrics.UnsupportedEvidenceRuns++
}
}
if metrics.ReviewedRuns > 0 {
metrics.ResolutionRate = float64(metrics.ResolvedRuns) / float64(metrics.ReviewedRuns)
metrics.UnsupportedEvidenceRate = float64(metrics.UnsupportedEvidenceRuns) / float64(metrics.ReviewedRuns)
}
return metrics
}
type WorkflowAgentRunInput struct {
WorkflowRunID int64
WorkflowVersionID int64
ConversationID int64
AIAgentID int64
SourceMessageID int64
Status string
PromptTokens int
CompletionTokens int
StartedAt time.Time
EndedAt *time.Time
ErrorMessage string
TraceData string
StepInputPreview string
StepOutputPreview string
}
type EngineAgentRunInput struct {
ConversationID int64
AIAgentID int64
AgentRevisionID int64
SourceMessageID int64
EngineCode string
Status string
PromptTokens int
CompletionTokens int
StartedAt time.Time
EndedAt *time.Time
ErrorMessage string
TraceData string
StepType string
StepCode string
StepInputPreview string
StepOutputPreview string
AdditionalSteps []EngineStepInput
ToolCalls []EngineToolCallInput
}
type EngineStepInput struct {
StepType string
StepCode string
WorkflowRunID int64
Status string
InputPreview string
OutputPreview string
ErrorMessage string
}
type EngineToolCallInput struct {
ToolCode string
RiskLevel string
RequireConfirm bool
Status string
ArgumentsPreview string
ResultPreview string
ErrorMessage string
DurationMS int
}
// RecordHybridPlaybookResume closes or re-interrupts the Hybrid AgentRun that
// originally selected a Playbook. The detailed WorkflowRun remains separately
// auditable; this step preserves the parent AgentRun -> AgentStep -> WorkflowRun
// relationship across a human confirmation pause.
func (s *agentRunService) RecordHybridPlaybookResume(db *gorm.DB, agentRunID, workflowRunID int64, status, replyText string) error {
if agentRunID <= 0 {
return nil
}
run := repositories.AgentRunRepository.Get(db, agentRunID)
if run == nil || run.EngineCode != "hybrid" {
return nil
}
status = strings.TrimSpace(status)
if status == "" {
status = "completed"
}
now := time.Now()
durationMS := int(now.Sub(run.StartedAt).Milliseconds())
if durationMS < 0 {
durationMS = 0
}
if err := repositories.AgentRunRepository.Updates(db, run.ID, map[string]any{
"status": status,
"ended_at": &now,
"error_message": "",
"updated_at": now,
}); err != nil {
return err
}
return repositories.AgentStepRepository.Create(db, &models.AgentStep{
AgentRunID: run.ID, WorkflowRunID: workflowRunID,
StepType: "playbook", StepCode: "playbook_resume", Status: status,
InputPreview: "human confirmation resume",
OutputPreview: sanitizeAgentAuditPreview(replyText),
StartedAt: now, EndedAt: &now, DurationMS: durationMS, CreatedAt: now,
})
}
// RecordEngineRun writes a non-workflow Engine audit run and its normalized
// root step in one transaction owned by the caller.
func (s *agentRunService) RecordEngineRun(db *gorm.DB, input EngineAgentRunInput) (int64, error) {
now := time.Now()
startedAt := input.StartedAt
if startedAt.IsZero() {
startedAt = now
}
status := strings.TrimSpace(input.Status)
if status == "" {
status = "completed"
}
run := &models.AgentRun{
ConversationID: input.ConversationID, AIAgentID: input.AIAgentID, AgentRevisionID: input.AgentRevisionID,
SourceMessageID: input.SourceMessageID, EngineCode: strings.TrimSpace(input.EngineCode), Status: status,
PromptTokens: input.PromptTokens, CompletionTokens: input.CompletionTokens, StartedAt: startedAt, EndedAt: input.EndedAt,
ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage), TraceData: sanitizeAgentAuditPreview(input.TraceData), CreatedAt: now, UpdatedAt: now,
}
if err := repositories.AgentRunRepository.Create(db, run); err != nil {
return 0, err
}
durationMS := 0
if input.EndedAt != nil {
durationMS = int(input.EndedAt.Sub(startedAt).Milliseconds())
if durationMS < 0 {
durationMS = 0
}
}
step := &models.AgentStep{
AgentRunID: run.ID, StepType: strings.TrimSpace(input.StepType), StepCode: strings.TrimSpace(input.StepCode), Status: status,
InputPreview: sanitizeAgentAuditPreview(input.StepInputPreview), OutputPreview: sanitizeAgentAuditPreview(input.StepOutputPreview), ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage),
StartedAt: startedAt, EndedAt: input.EndedAt, DurationMS: durationMS, CreatedAt: now,
}
if err := repositories.AgentStepRepository.Create(db, step); err != nil {
return 0, err
}
for _, extra := range input.AdditionalSteps {
extraStep := &models.AgentStep{
AgentRunID: run.ID, WorkflowRunID: extra.WorkflowRunID, StepType: strings.TrimSpace(extra.StepType), StepCode: strings.TrimSpace(extra.StepCode),
Status: firstNonEmptyString(extra.Status, status), InputPreview: sanitizeAgentAuditPreview(extra.InputPreview), OutputPreview: sanitizeAgentAuditPreview(extra.OutputPreview),
ErrorMessage: sanitizeAgentAuditPreview(extra.ErrorMessage), StartedAt: startedAt, EndedAt: input.EndedAt, DurationMS: durationMS, CreatedAt: now,
}
if err := repositories.AgentStepRepository.Create(db, extraStep); err != nil {
return 0, err
}
}
for _, call := range input.ToolCalls {
toolCall := &models.AgentToolCall{
AgentRunID: run.ID, AgentStepID: step.ID, ToolCode: strings.TrimSpace(call.ToolCode), RiskLevel: strings.TrimSpace(call.RiskLevel),
RequireConfirm: call.RequireConfirm, Status: firstNonEmptyString(call.Status, status), ArgumentsPreview: sanitizeAgentAuditPreview(call.ArgumentsPreview),
ResultPreview: sanitizeAgentAuditPreview(call.ResultPreview), ErrorMessage: sanitizeAgentAuditPreview(call.ErrorMessage), DurationMS: call.DurationMS, CreatedAt: now,
}
if err := repositories.AgentToolCallRepository.Create(db, toolCall); err != nil {
return 0, err
}
}
return run.ID, nil
}
func sanitizeAgentAuditPreview(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
value = agentAuditSecretPattern.ReplaceAllString(value, "$1$2***")
runes := []rune(value)
if len(runes) <= maxAgentAuditPreviewChars {
return value
}
return strings.TrimSpace(string(runes[:maxAgentAuditPreviewChars])) + "\n[preview truncated]"
}
func firstNonEmptyString(items ...string) string {
for _, item := range items {
if value := strings.TrimSpace(item); value != "" {
return value
}
}
return ""
}
// RecordWorkflowRun writes the Engine-independent audit record inside the
// caller's transaction. Workflow-specific tables remain the detailed source
// for node-level diagnosis while AgentRun becomes the cross-engine summary.
func (s *agentRunService) RecordWorkflowRun(db *gorm.DB, input WorkflowAgentRunInput) (int64, error) {
now := time.Now()
status := strings.TrimSpace(input.Status)
if status == "" {
status = "completed"
}
startedAt := input.StartedAt
if startedAt.IsZero() {
startedAt = now
}
run := repositories.AgentRunRepository.TakeByWorkflowRunID(db, input.WorkflowRunID)
agentRevisionID := int64(0)
if revision := repositories.AgentRevisionRepository.TakeByAgentIDAndWorkflowVersionID(db, input.AIAgentID, input.WorkflowVersionID); revision != nil {
agentRevisionID = revision.ID
}
if run == nil {
run = &models.AgentRun{
ConversationID: input.ConversationID,
AIAgentID: input.AIAgentID,
AgentRevisionID: agentRevisionID,
SourceMessageID: input.SourceMessageID,
WorkflowRunID: input.WorkflowRunID,
EngineCode: "workflow",
Status: status,
PromptTokens: input.PromptTokens,
CompletionTokens: input.CompletionTokens,
StartedAt: startedAt,
EndedAt: input.EndedAt,
ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage),
TraceData: sanitizeAgentAuditPreview(input.TraceData),
CreatedAt: now,
UpdatedAt: now,
}
if err := repositories.AgentRunRepository.Create(db, run); err != nil {
return 0, err
}
} else if err := repositories.AgentRunRepository.Updates(db, run.ID, map[string]any{
"agent_revision_id": agentRevisionID,
"status": status,
"prompt_tokens": input.PromptTokens,
"completion_tokens": input.CompletionTokens,
"ended_at": input.EndedAt,
"error_message": sanitizeAgentAuditPreview(input.ErrorMessage),
"trace_data": sanitizeAgentAuditPreview(input.TraceData),
"updated_at": now,
}); err != nil {
return 0, err
}
durationMS := 0
if input.EndedAt != nil {
durationMS = int(input.EndedAt.Sub(startedAt).Milliseconds())
if durationMS < 0 {
durationMS = 0
}
}
step := &models.AgentStep{
AgentRunID: run.ID,
StepType: "workflow",
StepCode: "workflow",
Status: status,
InputPreview: sanitizeAgentAuditPreview(input.StepInputPreview),
OutputPreview: sanitizeAgentAuditPreview(input.StepOutputPreview),
ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage),
StartedAt: startedAt,
EndedAt: input.EndedAt,
DurationMS: durationMS,
CreatedAt: now,
}
if err := repositories.AgentStepRepository.Create(db, step); err != nil {
return 0, err
}
return run.ID, nil
}
+237
View File
@@ -0,0 +1,237 @@
package services
import (
"strings"
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/repositories"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestAgentRunServiceFindsWorkflowAuditDetail(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
now := time.Now()
endedAt := now.Add(time.Second)
run := &models.AgentRun{
ConversationID: 11,
AIAgentID: 12,
WorkflowRunID: 13,
EngineCode: "workflow",
Status: "completed",
StartedAt: now,
EndedAt: &endedAt,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(run).Error; err != nil {
t.Fatalf("create agent run: %v", err)
}
if err := db.Create(&models.AgentStep{AgentRunID: run.ID, StepType: "workflow", Status: "completed", StartedAt: now, EndedAt: &endedAt, CreatedAt: now}).Error; err != nil {
t.Fatalf("create agent step: %v", err)
}
if err := db.Create(&models.AgentToolCall{AgentRunID: run.ID, ToolCode: "knowledge.retrieve", Status: "completed", CreatedAt: now}).Error; err != nil {
t.Fatalf("create tool call: %v", err)
}
cnd := sqls.NewCnd().Eq("conversation_id", run.ConversationID).Desc("id").Page(1, 20)
queryParams := &params.QueryParams{Cnd: *cnd}
list, paging := AgentRunService.FindPageByParams(queryParams)
if len(list) != 1 || paging.Total != 1 || list[0].ID != run.ID {
t.Fatalf("unexpected agent run page: list=%#v paging=%#v", list, paging)
}
item, steps, toolCalls := AgentRunService.GetDetail(run.ID)
if item == nil || len(steps) != 1 || len(toolCalls) != 1 {
t.Fatalf("unexpected agent run detail: run=%#v steps=%#v toolCalls=%#v", item, steps, toolCalls)
}
}
func TestAgentRunServiceAssociatesWorkflowRevision(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
now := time.Now()
if err := db.Create(&models.AgentRevision{AgentID: 12, Revision: 1, WorkflowVersionID: 14}).Error; err != nil {
t.Fatalf("create agent revision: %v", err)
}
if _, err := AgentRunService.RecordWorkflowRun(db, WorkflowAgentRunInput{
WorkflowRunID: 13, WorkflowVersionID: 14, ConversationID: 11, AIAgentID: 12,
Status: "completed", StartedAt: now,
}); err != nil {
t.Fatalf("RecordWorkflowRun returned error: %v", err)
}
run := repositories.AgentRunRepository.TakeByWorkflowRunID(db, 13)
if run == nil || run.AgentRevisionID <= 0 {
t.Fatalf("expected AgentRun to link revision, got %#v", run)
}
if stepID := AgentRunService.GetLatestStepID(run.ID); stepID <= 0 {
t.Fatalf("expected normalized agent step id, got %d", stepID)
}
}
func TestAgentRunServiceRecordsEngineToolCall(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
now := time.Now()
runID, err := AgentRunService.RecordEngineRun(db, EngineAgentRunInput{
ConversationID: 1, AIAgentID: 2, AgentRevisionID: 3, EngineCode: "autonomous", Status: "completed", StartedAt: now,
StepType: "model", StepCode: "chat_completion", StepInputPreview: "authorization=Bearer-secret", ToolCalls: []EngineToolCallInput{{
ToolCode: "knowledge/search", RiskLevel: "read", Status: "completed", ArgumentsPreview: `{"token":"abc123","query":"refund"}`, ResultPreview: "policy text",
}},
})
if err != nil {
t.Fatalf("RecordEngineRun returned error: %v", err)
}
_, steps, toolCalls := AgentRunService.GetDetail(runID)
if len(toolCalls) != 1 || toolCalls[0].ToolCode != "knowledge/search" || toolCalls[0].AgentStepID <= 0 {
t.Fatalf("unexpected tool audit: %#v", toolCalls)
}
if strings.Contains(toolCalls[0].ArgumentsPreview, "abc123") || len(steps) != 1 || strings.Contains(steps[0].InputPreview, "Bearer-secret") {
t.Fatalf("sensitive audit data leaked: steps=%#v calls=%#v", steps, toolCalls)
}
}
func TestAgentRunServiceRecordsHybridPlaybookResume(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
now := time.Now().Add(-time.Minute)
run := &models.AgentRun{EngineCode: "hybrid", Status: "interrupted", StartedAt: now, CreatedAt: now, UpdatedAt: now}
if err := db.Create(run).Error; err != nil {
t.Fatalf("create hybrid run: %v", err)
}
if err := AgentRunService.RecordHybridPlaybookResume(db, run.ID, 33, "completed", "已完成工单登记。"); err != nil {
t.Fatalf("RecordHybridPlaybookResume returned error: %v", err)
}
item, steps, _ := AgentRunService.GetDetail(run.ID)
if item == nil || item.Status != "completed" || item.EndedAt == nil {
t.Fatalf("expected completed hybrid run, got %#v", item)
}
if len(steps) != 1 || steps[0].StepCode != "playbook_resume" || steps[0].WorkflowRunID != 33 || steps[0].OutputPreview != "已完成工单登记。" {
t.Fatalf("unexpected playbook resume step: %#v", steps)
}
}
func TestAgentRunServiceSavesQualityFeedbackPerRun(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
now := time.Now()
run := &models.AgentRun{AIAgentID: 4, EngineCode: "autonomous", Status: "completed", StartedAt: now, CreatedAt: now, UpdatedAt: now}
if err := db.Create(run).Error; err != nil {
t.Fatalf("create agent run: %v", err)
}
operator := &dto.AuthPrincipal{UserID: 7, Username: "reviewer"}
if err := AgentRunService.SaveQualityFeedback(request.SaveAgentRunQualityFeedbackRequest{
AgentRunID: run.ID, ResolutionStatus: enums.AgentRunResolutionStatusResolved, EvidenceStatus: enums.AgentRunEvidenceStatusSupported, Comment: "issue resolved",
}, operator); err != nil {
t.Fatalf("save quality feedback: %v", err)
}
if err := AgentRunService.SaveQualityFeedback(request.SaveAgentRunQualityFeedbackRequest{
AgentRunID: run.ID, ResolutionStatus: enums.AgentRunResolutionStatusUnresolved, EvidenceStatus: enums.AgentRunEvidenceStatusUnsupported, Comment: "missing evidence",
}, operator); err != nil {
t.Fatalf("update quality feedback: %v", err)
}
feedback := AgentRunService.GetQualityFeedback(run.ID)
if feedback == nil || feedback.ResolutionStatus != enums.AgentRunResolutionStatusUnresolved || feedback.EvidenceStatus != enums.AgentRunEvidenceStatusUnsupported || feedback.Comment != "missing evidence" || feedback.UpdateUserName != "reviewer" {
t.Fatalf("unexpected quality feedback: %#v", feedback)
}
}
func TestAgentRunServiceAggregatesCrossEngineMetrics(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
base := time.Now().Add(-time.Minute)
runs := []models.AgentRun{
{AIAgentID: 8, EngineCode: "autonomous", Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(100 * time.Millisecond)), PromptTokens: 10, CompletionTokens: 5, CreatedAt: base, UpdatedAt: base},
{AIAgentID: 8, EngineCode: "workflow", Status: "failed", StartedAt: base, EndedAt: timePtr(base.Add(300 * time.Millisecond)), PromptTokens: 8, CompletionTokens: 2, CreatedAt: base, UpdatedAt: base},
{AIAgentID: 9, EngineCode: "hybrid", Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(900 * time.Millisecond)), CreatedAt: base, UpdatedAt: base},
}
for index := range runs {
if err := db.Create(&runs[index]).Error; err != nil {
t.Fatalf("create run: %v", err)
}
}
if err := db.Create(&models.AgentStep{AgentRunID: runs[0].ID, Status: "completed", StartedAt: base, CreatedAt: base}).Error; err != nil {
t.Fatalf("create step: %v", err)
}
if err := db.Create(&models.AgentStep{AgentRunID: runs[1].ID, Status: "failed", StartedAt: base, CreatedAt: base}).Error; err != nil {
t.Fatalf("create step: %v", err)
}
if err := db.Create(&models.AgentToolCall{AgentRunID: runs[0].ID, Status: "completed", CreatedAt: base}).Error; err != nil {
t.Fatalf("create completed tool call: %v", err)
}
if err := db.Create(&models.AgentToolCall{AgentRunID: runs[1].ID, Status: "failed", CreatedAt: base}).Error; err != nil {
t.Fatalf("create failed tool call: %v", err)
}
if err := db.Create(&models.Conversation{AIAgentID: 8}).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
handoffAt := base
if err := db.Create(&models.Conversation{AIAgentID: 8, HandoffAt: &handoffAt}).Error; err != nil {
t.Fatalf("create handoff conversation: %v", err)
}
if err := db.Create(&models.ConversationInterrupt{AgentRunID: runs[0].ID, CheckPointID: "metrics-resolved", Status: "resolved", ResumeCount: 1, CreatedAt: base, UpdatedAt: base}).Error; err != nil {
t.Fatalf("create resolved interrupt: %v", err)
}
if err := db.Create(&models.ConversationInterrupt{AgentRunID: runs[1].ID, CheckPointID: "metrics-cancelled", Status: "cancelled", ResumeCount: 1, CreatedAt: base, UpdatedAt: base}).Error; err != nil {
t.Fatalf("create cancelled interrupt: %v", err)
}
if err := db.Create(&models.AgentRunQualityFeedback{AgentRunID: runs[0].ID, ResolutionStatus: enums.AgentRunResolutionStatusResolved, EvidenceStatus: enums.AgentRunEvidenceStatusSupported}).Error; err != nil {
t.Fatalf("create resolved feedback: %v", err)
}
if err := db.Create(&models.AgentRunQualityFeedback{AgentRunID: runs[1].ID, ResolutionStatus: enums.AgentRunResolutionStatusUnresolved, EvidenceStatus: enums.AgentRunEvidenceStatusUnsupported}).Error; err != nil {
t.Fatalf("create unresolved feedback: %v", err)
}
metrics := AgentRunService.GetMetrics(8)
if metrics.TotalRuns != 2 || metrics.CompletedRuns != 1 || metrics.FailedRuns != 1 || metrics.CompletionRate != 0.5 {
t.Fatalf("unexpected run metrics: %#v", metrics)
}
if metrics.AverageDurationMS != 200 || metrics.P95DurationMS != 300 || metrics.ToolCalls != 2 || metrics.ToolSuccessRate != 0.5 || metrics.AverageSteps != 1 {
t.Fatalf("unexpected aggregate metrics: %#v", metrics)
}
if metrics.PromptTokens != 18 || metrics.CompletionTokens != 7 {
t.Fatalf("unexpected token metrics: %#v", metrics)
}
if metrics.HandoffRate != 0.5 || metrics.KnowledgeFallbackRate != 0 {
t.Fatalf("unexpected business metrics: %#v", metrics)
}
if metrics.ResumedInterrupts != 2 || metrics.ResolvedInterrupts != 1 || metrics.InterruptRecoveryRate != 0.5 {
t.Fatalf("unexpected interrupt recovery metrics: %#v", metrics)
}
if metrics.ReviewedRuns != 2 || metrics.ResolvedRuns != 1 || metrics.ResolutionRate != 0.5 || metrics.UnsupportedEvidenceRuns != 1 || metrics.UnsupportedEvidenceRate != 0.5 {
t.Fatalf("unexpected quality metrics: %#v", metrics)
}
comparisons := AgentRunService.GetEngineComparisons(8)
if len(comparisons) != 2 || comparisons[0].EngineCode != "autonomous" || comparisons[1].EngineCode != "workflow" {
t.Fatalf("unexpected engine comparison groups: %#v", comparisons)
}
if comparisons[0].Metrics.TotalRuns != 1 || comparisons[0].Metrics.ResolutionRate != 1 || comparisons[1].Metrics.TotalRuns != 1 || comparisons[1].Metrics.UnsupportedEvidenceRate != 1 {
t.Fatalf("unexpected engine comparison metrics: %#v", comparisons)
}
}
func timePtr(value time.Time) *time.Time { return &value }
func setupAgentRunServiceTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}, &models.AgentRunQualityFeedback{}, &models.Conversation{}, &models.ConversationInterrupt{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
return db
}
@@ -0,0 +1,81 @@
package services
import (
"strings"
"time"
"agent-desk/internal/models"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
)
const (
agentToolInvocationStatusRunning = "running"
agentToolInvocationStatusCompleted = "completed"
agentToolInvocationStatusFailed = "failed"
)
var AgentToolInvocationService = newAgentToolInvocationService()
type AgentToolInvocationClaim struct {
Item *models.AgentToolInvocation
Completed bool
Acquired bool
}
type agentToolInvocationService struct{}
func newAgentToolInvocationService() *agentToolInvocationService {
return &agentToolInvocationService{}
}
// Claim obtains the persistent idempotency boundary. A completed invocation
// can be returned to callers; an in-flight invocation is never executed again.
func (s *agentToolInvocationService) Claim(conversationID, aiAgentID int64, toolCode, idempotencyKey string) (*AgentToolInvocationClaim, error) {
toolCode = strings.TrimSpace(toolCode)
idempotencyKey = strings.TrimSpace(idempotencyKey)
if conversationID <= 0 || toolCode == "" || idempotencyKey == "" {
return nil, nil
}
if item := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(sqls.DB(), conversationID, toolCode, idempotencyKey); item != nil {
if item.Status == agentToolInvocationStatusCompleted {
return &AgentToolInvocationClaim{Item: item, Completed: true}, nil
}
if item.Status == agentToolInvocationStatusRunning {
return &AgentToolInvocationClaim{Item: item}, nil
}
if err := repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusRunning, "error_message": "", "updated_at": time.Now()}); err != nil {
return nil, err
}
item.Status, item.ErrorMessage = agentToolInvocationStatusRunning, ""
return &AgentToolInvocationClaim{Item: item, Acquired: true}, nil
}
item := &models.AgentToolInvocation{ConversationID: conversationID, AIAgentID: aiAgentID, ToolCode: toolCode, IdempotencyKey: idempotencyKey, Status: agentToolInvocationStatusRunning}
if err := repositories.AgentToolInvocationRepository.Create(sqls.DB(), item); err != nil {
// A concurrent caller may have created the unique invocation first.
if existing := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(sqls.DB(), conversationID, toolCode, idempotencyKey); existing != nil {
return &AgentToolInvocationClaim{Item: existing, Completed: existing.Status == agentToolInvocationStatusCompleted}, nil
}
return nil, err
}
return &AgentToolInvocationClaim{Item: item, Acquired: true}, nil
}
func (s *agentToolInvocationService) Complete(item *models.AgentToolInvocation, resultData string) error {
if item == nil || item.ID <= 0 {
return nil
}
return repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusCompleted, "result_data": resultData, "error_message": "", "updated_at": time.Now()})
}
func (s *agentToolInvocationService) Fail(item *models.AgentToolInvocation, cause error) error {
if item == nil || item.ID <= 0 {
return nil
}
message := ""
if cause != nil {
message = cause.Error()
}
return repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusFailed, "error_message": message, "updated_at": time.Now()})
}
@@ -0,0 +1,65 @@
package services
import (
"strings"
"testing"
"agent-desk/internal/models"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestAgentToolInvocationServiceReusesCompletedInvocation(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentToolInvocation{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
first, err := AgentToolInvocationService.Claim(10, 20, "graph/create_ticket_with_confirmation", "message:30:node:create")
if err != nil || first == nil || first.Item == nil || first.Completed {
t.Fatalf("first claim = %#v, err=%v", first, err)
}
if err := AgentToolInvocationService.Complete(first.Item, `{"ticketId":40}`); err != nil {
t.Fatalf("complete invocation: %v", err)
}
second, err := AgentToolInvocationService.Claim(10, 20, "graph/create_ticket_with_confirmation", "message:30:node:create")
if err != nil || second == nil || !second.Completed || second.Item.ResultData != `{"ticketId":40}` {
t.Fatalf("second claim = %#v, err=%v", second, err)
}
}
func TestAgentToolInvocationServiceAllowsFailedInvocationRetry(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentToolInvocation{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
first, err := AgentToolInvocationService.Claim(11, 21, "graph/handoff_to_human", "message:31:node:handoff")
if err != nil {
t.Fatalf("first claim: %v", err)
}
if err := AgentToolInvocationService.Fail(first.Item, errTestToolInvocation); err != nil {
t.Fatalf("fail invocation: %v", err)
}
second, err := AgentToolInvocationService.Claim(11, 21, "graph/handoff_to_human", "message:31:node:handoff")
if err != nil || second == nil || second.Completed || second.Item.Status != agentToolInvocationStatusRunning || second.Item.ErrorMessage != "" {
t.Fatalf("retry claim = %#v, err=%v", second, err)
}
}
var errTestToolInvocation = &toolInvocationTestError{}
type toolInvocationTestError struct{}
func (e *toolInvocationTestError) Error() string { return "tool failed" }
+276 -10
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"time" "time"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models" "agent-desk/internal/models"
"agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/request"
@@ -18,10 +19,13 @@ import (
"agent-desk/internal/pkg/httpx/params" "agent-desk/internal/pkg/httpx/params"
"github.com/mlogclub/simple/sqls" "github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
) )
var AIAgentService = newAIAgentService() var AIAgentService = newAIAgentService()
const defaultNewAutonomousRolloutPercent = 5
func newAIAgentService() *aIAgentService { func newAIAgentService() *aIAgentService {
return &aIAgentService{} return &aIAgentService{}
} }
@@ -79,8 +83,11 @@ func (s *aIAgentService) CreateAIAgent(req request.CreateAIAgentRequest, operato
if err := repositories.AIAgentRepository.Create(ctx.Tx, item); err != nil { if err := repositories.AIAgentRepository.Create(ctx.Tx, item); err != nil {
return err return err
} }
_, err := AIWorkflowService.createDefaultAgentWorkflow(ctx.Tx, item, operator) if item.RuntimeMode == enums.AIAgentRuntimeModeWorkflow || item.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
return err _, err := AIWorkflowService.createDefaultAgentWorkflow(ctx.Tx, item, operator)
return err
}
return nil
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
@@ -91,31 +98,48 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato
if operator == nil { if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired") return errorsx.UnauthorizedI18n("error.auth.expired")
} }
if s.Get(req.ID) == nil { current := s.Get(req.ID)
if current == nil {
return errorsx.InvalidParamI18n("error.e0002") return errorsx.InvalidParamI18n("error.e0002")
} }
item, err := s.buildAIAgentModel(req.ID, req.CreateAIAgentRequest) item, err := s.buildAIAgentModel(req.ID, req.CreateAIAgentRequest)
if err != nil { if err != nil {
return err return err
} }
return repositories.AIAgentRepository.Updates(sqls.DB(), req.ID, map[string]any{ columns := map[string]any{
"name": item.Name, "name": item.Name,
"description": item.Description, "description": item.Description,
"ai_config_id": item.AIConfigID, "ai_config_id": item.AIConfigID,
"runtime_mode": item.RuntimeMode,
"max_steps": item.MaxSteps,
"context_window": item.ContextWindow,
"tool_policy": item.ToolPolicy,
"knowledge_policy": item.KnowledgePolicy,
"service_mode": item.ServiceMode, "service_mode": item.ServiceMode,
"system_prompt": item.SystemPrompt, "system_prompt": item.SystemPrompt,
"welcome_message": item.WelcomeMessage, "welcome_message": item.WelcomeMessage,
"reply_timeout_seconds": item.ReplyTimeoutSeconds, "reply_timeout_seconds": item.ReplyTimeoutSeconds,
"rollout_percent": item.RolloutPercent,
"team_ids": item.TeamIDs, "team_ids": item.TeamIDs,
"handoff_mode": item.HandoffMode, "handoff_mode": item.HandoffMode,
"fallback_mode": item.FallbackMode, "fallback_mode": item.FallbackMode,
"fallback_message": item.FallbackMessage, "fallback_message": item.FallbackMessage,
"knowledge_ids": item.KnowledgeIDs,
"skill_ids": item.SkillIDs, "skill_ids": item.SkillIDs,
"allowed_mcp_tools": item.AllowedMCPTools, "allowed_mcp_tools": item.AllowedMCPTools,
"update_user_id": operator.UserID, "update_user_id": operator.UserID,
"update_user_name": operator.Username, "update_user_name": operator.Username,
"updated_at": time.Now(), "updated_at": time.Now(),
}) }
if item.RolloutPercent != current.RolloutPercent {
columns["previous_rollout_percent"] = current.RolloutPercent
}
if current.RuntimeMode == enums.AIAgentRuntimeModeAutonomous || current.RuntimeMode == enums.AIAgentRuntimeModeHybrid || item.RuntimeMode == enums.AIAgentRuntimeModeAutonomous || item.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
// Draft edits must not silently change the already published autonomous or hybrid
// behavior. The operator must explicitly publish the new revision.
columns["published_revision_id"] = 0
}
return repositories.AIAgentRepository.Updates(sqls.DB(), req.ID, columns)
} }
func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) error { func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) error {
@@ -134,6 +158,133 @@ func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) er
}) })
} }
// PublishAIAgent snapshots a non-workflow Agent before it can receive traffic.
func (s *aIAgentService) PublishAIAgent(id int64, operator *dto.AuthPrincipal) (*models.AgentRevision, error) {
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
var revision *models.AgentRevision
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
agent := repositories.AIAgentRepository.Get(ctx.Tx, id)
if agent == nil || agent.Status != enums.StatusOk {
return errorsx.InvalidParamI18n("error.e0002")
}
if agent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow || agent.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
return errorsx.InvalidParam("workflow and hybrid agents must publish a workflow version")
}
if err := s.validatePublishableAgent(ctx.Tx, agent); err != nil {
return err
}
var err error
revision, err = AgentRevisionService.PublishSnapshot(ctx.Tx, agent, operator)
if err != nil {
return err
}
return repositories.AIAgentRepository.Updates(ctx.Tx, agent.ID, map[string]any{
"published_revision_id": revision.ID,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
})
if err != nil {
return nil, err
}
return revision, nil
}
func (s *aIAgentService) validatePublishableAgent(db *gorm.DB, agent *models.AIAgent) error {
if agent == nil || agent.AIConfigID <= 0 {
return errorsx.InvalidParam("ai agent model configuration is required before publishing")
}
config := repositories.AIConfigRepository.Get(db, agent.AIConfigID)
if config == nil || config.Status != enums.StatusOk {
return errorsx.InvalidParam("ai agent model configuration is unavailable")
}
if _, err := s.normalizeToolPolicy(agent.ToolPolicy); err != nil {
return err
}
if strings.TrimSpace(agent.AllowedMCPTools) == "" {
return nil
}
var directTools []request.AIAgentMCPToolRequest
if err := json.Unmarshal([]byte(agent.AllowedMCPTools), &directTools); err != nil {
return errorsx.InvalidParam("ai agent direct tools are invalid")
}
for _, item := range directTools {
definition, err := aitooling.DefaultRegistry.Resolve(item.ToolCode)
if err != nil || definition.InputSchema == nil {
return errorsx.InvalidParam("ai agent direct tool definition is unavailable")
}
if definition.RequireConfirmation {
return errorsx.InvalidParam("ai agent sensitive direct tools must be executed through a confirmed playbook")
}
}
return nil
}
// RollbackAIAgent switches an Agent back to a previously published immutable
// revision. It never rewrites the historical snapshot itself.
func (s *aIAgentService) RollbackAIAgent(id, revisionID int64, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
if id <= 0 || revisionID <= 0 {
return errorsx.InvalidParam("agent id and revision id are required")
}
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
agent := repositories.AIAgentRepository.Get(ctx.Tx, id)
if agent == nil || agent.Status != enums.StatusOk {
return errorsx.InvalidParamI18n("error.e0002")
}
revision := repositories.AgentRevisionRepository.Get(ctx.Tx, revisionID)
if revision == nil || revision.AgentID != agent.ID || revision.Status != enums.StatusOk {
return errorsx.InvalidParam("agent revision does not exist")
}
updates := map[string]any{
"published_revision_id": revision.ID,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
}
if agent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow || agent.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
if revision.WorkflowVersionID <= 0 || repositories.AIWorkflowVersionRepository.Get(ctx.Tx, revision.WorkflowVersionID) == nil {
return errorsx.InvalidParam("workflow revision does not contain a published workflow version")
}
updates["workflow_version_id"] = revision.WorkflowVersionID
}
return repositories.AIAgentRepository.Updates(ctx.Tx, agent.ID, updates)
})
}
// RollbackAIAgentRollout restores the prior Agent rollout percentage and
// swaps it into history, allowing operators to undo and redo one rollout
// change without rewriting an immutable AgentRevision.
func (s *aIAgentService) RollbackAIAgentRollout(id int64, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
if id <= 0 {
return errorsx.InvalidParam("agent id is required")
}
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
agent := repositories.AIAgentRepository.Get(ctx.Tx, id)
if agent == nil || agent.Status != enums.StatusOk {
return errorsx.InvalidParamI18n("error.e0002")
}
if agent.PreviousRolloutPercent < 1 || agent.PreviousRolloutPercent > 100 {
return errorsx.InvalidParam("agent rollout has no previous value to restore")
}
return repositories.AIAgentRepository.Updates(ctx.Tx, agent.ID, map[string]any{
"rollout_percent": agent.PreviousRolloutPercent,
"previous_rollout_percent": agent.RolloutPercent,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
})
}
func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRequest) (*models.AIAgent, error) { func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRequest) (*models.AIAgent, error) {
name := strings.TrimSpace(req.Name) name := strings.TrimSpace(req.Name)
if name == "" { if name == "" {
@@ -152,6 +303,28 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe
if aiConfig.Status != enums.StatusOk { if aiConfig.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0011") return nil, errorsx.InvalidParamI18n("error.e0011")
} }
if req.RuntimeMode == "" {
req.RuntimeMode = enums.AIAgentRuntimeModeAutonomous
}
if !enums.IsValidAIAgentRuntimeMode(req.RuntimeMode) {
return nil, errorsx.InvalidParam("invalid ai agent runtime mode")
}
if req.RuntimeMode != enums.AIAgentRuntimeModeWorkflow && req.RuntimeMode != enums.AIAgentRuntimeModeAutonomous && req.RuntimeMode != enums.AIAgentRuntimeModeHybrid {
return nil, errorsx.InvalidParam("ai agent runtime mode is not available yet")
}
if req.MaxSteps == 0 {
req.MaxSteps = 6
}
if req.MaxSteps < 1 || req.MaxSteps > 8 {
return nil, errorsx.InvalidParam("ai agent max steps must be between 1 and 8")
}
if req.ContextWindow < 0 {
return nil, errorsx.InvalidParam("ai agent context window must not be negative")
}
toolPolicy, err := s.normalizeToolPolicy(req.ToolPolicy)
if err != nil {
return nil, err
}
if !slices.Contains(enums.IMConversationServiceModeValues, req.ServiceMode) { if !slices.Contains(enums.IMConversationServiceModeValues, req.ServiceMode) {
return nil, errorsx.InvalidParamI18n("error.e0230") return nil, errorsx.InvalidParamI18n("error.e0230")
} }
@@ -175,11 +348,25 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe
if req.ReplyTimeoutSeconds < 0 { if req.ReplyTimeoutSeconds < 0 {
return nil, errorsx.InvalidParamI18n("error.e0144") return nil, errorsx.InvalidParamI18n("error.e0144")
} }
if req.RolloutPercent == 0 {
if req.RuntimeMode == enums.AIAgentRuntimeModeAutonomous || req.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
req.RolloutPercent = defaultNewAutonomousRolloutPercent
} else {
req.RolloutPercent = 100
}
}
if req.RolloutPercent < 1 || req.RolloutPercent > 100 {
return nil, errorsx.InvalidParam("ai agent rollout percent must be between 1 and 100")
}
skillIDs, err := s.normalizeSkillIDs(req.SkillIDs) skillIDs, err := s.normalizeSkillIDs(req.SkillIDs)
if err != nil { if err != nil {
return nil, err return nil, err
} }
knowledgeBaseIDs, err := s.normalizeKnowledgeBaseIDs(req.KnowledgeBaseIDs)
if err != nil {
return nil, err
}
directTools, err := s.normalizeDirectTools(req.DirectTools) directTools, err := s.normalizeDirectTools(req.DirectTools)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -196,20 +383,93 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe
Name: name, Name: name,
Description: strings.TrimSpace(req.Description), Description: strings.TrimSpace(req.Description),
AIConfigID: req.AIConfigID, AIConfigID: req.AIConfigID,
RuntimeMode: req.RuntimeMode,
MaxSteps: req.MaxSteps,
ContextWindow: req.ContextWindow,
ToolPolicy: toolPolicy,
KnowledgePolicy: strings.TrimSpace(req.KnowledgePolicy),
ServiceMode: req.ServiceMode, ServiceMode: req.ServiceMode,
SystemPrompt: strings.TrimSpace(req.SystemPrompt), SystemPrompt: strings.TrimSpace(req.SystemPrompt),
WelcomeMessage: strings.TrimSpace(req.WelcomeMessage), WelcomeMessage: strings.TrimSpace(req.WelcomeMessage),
ReplyTimeoutSeconds: req.ReplyTimeoutSeconds, ReplyTimeoutSeconds: req.ReplyTimeoutSeconds,
RolloutPercent: req.RolloutPercent,
TeamIDs: utils.JoinInt64s(teamIDs), TeamIDs: utils.JoinInt64s(teamIDs),
HandoffMode: req.HandoffMode, HandoffMode: req.HandoffMode,
FallbackMode: req.FallbackMode, FallbackMode: req.FallbackMode,
FallbackMessage: strings.TrimSpace(req.FallbackMessage), FallbackMessage: strings.TrimSpace(req.FallbackMessage),
KnowledgeIDs: utils.JoinInt64s(knowledgeBaseIDs),
SkillIDs: utils.JoinInt64s(skillIDs), SkillIDs: utils.JoinInt64s(skillIDs),
AllowedMCPTools: directToolsJSON, AllowedMCPTools: directToolsJSON,
WorkflowVersionID: 0, WorkflowVersionID: 0,
}, nil }, nil
} }
type normalizedAIAgentToolPolicy struct {
MaxTotalCalls int `json:"maxTotalCalls,omitempty"`
MaxArgumentBytes int `json:"maxArgumentBytes,omitempty"`
AllowedRiskLevels []string `json:"allowedRiskLevels,omitempty"`
}
func (s *aIAgentService) normalizeToolPolicy(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
policy := normalizedAIAgentToolPolicy{}
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
return "", errorsx.InvalidParam("ai agent tool policy must be valid JSON")
}
if policy.MaxTotalCalls < 0 || policy.MaxTotalCalls > 8 {
return "", errorsx.InvalidParam("ai agent tool policy maxTotalCalls must be between 1 and 8")
}
if policy.MaxArgumentBytes < 0 || policy.MaxArgumentBytes > 64*1024 {
return "", errorsx.InvalidParam("ai agent tool policy maxArgumentBytes must be between 1 and 65536")
}
seen := make(map[string]struct{}, len(policy.AllowedRiskLevels))
riskLevels := make([]string, 0, len(policy.AllowedRiskLevels))
for _, level := range policy.AllowedRiskLevels {
level = strings.ToLower(strings.TrimSpace(level))
if level == "" {
continue
}
if level != "read" && level != "write" && level != "sensitive" {
return "", errorsx.InvalidParam("ai agent tool policy contains an invalid risk level")
}
if _, exists := seen[level]; exists {
continue
}
seen[level] = struct{}{}
riskLevels = append(riskLevels, level)
}
policy.AllowedRiskLevels = riskLevels
data, err := json.Marshal(policy)
if err != nil {
return "", errorsx.InvalidParam("ai agent tool policy is invalid")
}
return string(data), nil
}
func (s *aIAgentService) normalizeKnowledgeBaseIDs(input []int64) ([]int64, error) {
ret := make([]int64, 0, len(input))
seen := make(map[int64]struct{})
for _, id := range input {
if id <= 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
knowledgeBase := KnowledgeBaseService.Get(id)
if knowledgeBase == nil || knowledgeBase.Status != enums.StatusOk {
return nil, errorsx.InvalidParam("knowledge base is not available")
}
seen[id] = struct{}{}
ret = append(ret, id)
}
slices.Sort(ret)
return ret, nil
}
func (s *aIAgentService) normalizeTeamIDs(input []int64) ([]int64, error) { func (s *aIAgentService) normalizeTeamIDs(input []int64) ([]int64, error) {
ret := make([]int64, 0, len(input)) ret := make([]int64, 0, len(input))
seen := make(map[int64]struct{}) seen := make(map[int64]struct{})
@@ -271,11 +531,17 @@ func (s *aIAgentService) normalizeDirectTools(input []request.AIAgentMCPToolRequ
if toolx.IsAutoInjectedToolCode(strings.TrimSpace(normalized.ToolCode)) { if toolx.IsAutoInjectedToolCode(strings.TrimSpace(normalized.ToolCode)) {
continue continue
} }
if toolx.ResolveToolSourceType(normalized.ToolCode) != enums.ToolSourceTypeMCP { if spec, registered := toolx.GetRegisteredToolSpec(normalized.ToolCode); registered {
return nil, errorsx.InvalidParamI18n("error.e0020") if !spec.DirectAccess || spec.AutoInjected || (spec.Code != toolx.BuiltinConversationContext.Code && spec.Code != toolx.BuiltinKnowledgeRetrieve.Code && spec.Code != toolx.GraphTriageServiceRequest.Code && spec.Code != toolx.GraphAnalyzeConversation.Code && spec.Code != toolx.GraphPrepareTicketDraft.Code) {
} return nil, errorsx.InvalidParamI18n("error.e0020")
if err := ToolCatalogService.ValidateToolCode(normalized.ToolCode); err != nil { }
return nil, err } else {
if toolx.ResolveToolSourceType(normalized.ToolCode) != enums.ToolSourceTypeMCP {
return nil, errorsx.InvalidParamI18n("error.e0020")
}
if err := ToolCatalogService.ValidateToolCode(normalized.ToolCode); err != nil {
return nil, err
}
} }
key := strings.TrimSpace(normalized.ToolCode) key := strings.TrimSpace(normalized.ToolCode)
if _, exists := seen[key]; exists { if _, exists := seen[key]; exists {
@@ -12,13 +12,14 @@ import (
"agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/toolx"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls" "github.com/mlogclub/simple/sqls"
"gorm.io/gorm" "gorm.io/gorm"
) )
func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) { func TestAIAgentServiceCreatesWorkflowOnlyWhenRequested(t *testing.T) {
setupAIAgentWorkflowTestDB(t) setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator() operator := aiAgentWorkflowTestOperator()
aiConfigID := createAIAgentWorkflowTestConfig(t) aiConfigID := createAIAgentWorkflowTestConfig(t)
@@ -26,6 +27,7 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{ item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "workflow agent", Name: "workflow agent",
AIConfigID: aiConfigID, AIConfigID: aiConfigID,
RuntimeMode: enums.AIAgentRuntimeModeWorkflow,
ServiceMode: enums.IMConversationServiceModeAIOnly, ServiceMode: enums.IMConversationServiceModeAIOnly,
HandoffMode: enums.AIAgentHandoffModeWaitPool, HandoffMode: enums.AIAgentHandoffModeWaitPool,
FallbackMode: enums.AIAgentFallbackModeNoAnswer, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
@@ -33,6 +35,15 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err) 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) workflow, err := AIWorkflowService.GetOrCreateAgentWorkflow(item.ID, operator)
if err != nil { if err != nil {
@@ -81,10 +92,12 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
} }
} }
assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypeSendReply, "eq", "direct_reply") assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypeSendReply, "eq", "direct_reply")
assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypeHandoffToHuman, "eq", "handoff_to_human") assertConditionBranchToNodeID(t, stored, "policy_route_1", "handoff_confirm_prompt_1", "eq", "handoff_to_human")
assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypePrepareTicketDraft, "eq", "prepare_ticket") 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) 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") 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") assertConditionBranchToNodeID(t, stored, "answerability_route_1", "reply_1", "eq", "answerable")
assertDefaultBranchToNodeID(t, stored, "answerability_route_1", "fallback_reply_1") assertDefaultBranchToNodeID(t, stored, "answerability_route_1", "fallback_reply_1")
if !workflowEdgeExists(stored, "create_ticket_1", "ticket_result_reply_1") { if !workflowEdgeExists(stored, "create_ticket_1", "ticket_result_reply_1") {
@@ -93,6 +106,7 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
assertConditionBranchesHavePortEdges(t, stored, "policy_route_1") assertConditionBranchesHavePortEdges(t, stored, "policy_route_1")
assertConditionBranchesHavePortEdges(t, stored, "ticket_draft_route_1") assertConditionBranchesHavePortEdges(t, stored, "ticket_draft_route_1")
assertConditionBranchesHavePortEdges(t, stored, "ticket_confirm_route_1") assertConditionBranchesHavePortEdges(t, stored, "ticket_confirm_route_1")
assertConditionBranchesHavePortEdges(t, stored, "handoff_confirm_route_1")
assertConditionBranchesHavePortEdges(t, stored, "answerability_route_1") assertConditionBranchesHavePortEdges(t, stored, "answerability_route_1")
assertConditionBranchOrder(t, stored, "policy_route_1", []string{ assertConditionBranchOrder(t, stored, "policy_route_1", []string{
"handoff", "handoff",
@@ -114,6 +128,230 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
}) })
} }
func TestAIAgentServiceDefaultsNewAutonomousAgentToSmallRollout(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "small-rollout autonomous agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, aiAgentWorkflowTestOperator())
if err != nil {
t.Fatalf("CreateAIAgent: %v", err)
}
if item.RolloutPercent != defaultNewAutonomousRolloutPercent {
t.Fatalf("autonomous rollout default = %d, want %d", item.RolloutPercent, defaultNewAutonomousRolloutPercent)
}
}
func TestAIAgentServiceDefaultsToAutonomousWithoutWorkflow(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "default autonomous agent", AIConfigID: createAIAgentWorkflowTestConfig(t),
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
if item.RuntimeMode != enums.AIAgentRuntimeModeAutonomous {
t.Fatalf("default runtime mode = %q, want %q", item.RuntimeMode, enums.AIAgentRuntimeModeAutonomous)
}
var workflowCount int64
if err := sqls.DB().Model(&models.AIWorkflow{}).Where("agent_id = ?", item.ID).Count(&workflowCount).Error; err != nil {
t.Fatalf("count workflows: %v", err)
}
if workflowCount != 0 {
t.Fatalf("default autonomous agent created %d workflows", workflowCount)
}
}
func TestAIAgentServiceCreatesWorkflowDraftForHybrid(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "hybrid agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeHybrid,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, aiAgentWorkflowTestOperator())
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
var workflowCount int64
if err := sqls.DB().Model(&models.AIWorkflow{}).Where("agent_id = ?", item.ID).Count(&workflowCount).Error; err != nil {
t.Fatalf("count workflows: %v", err)
}
if workflowCount != 1 {
t.Fatalf("hybrid agent created %d workflows, want 1", workflowCount)
}
}
func TestAIAgentServiceNormalizesToolPolicy(t *testing.T) {
policy, err := AIAgentService.normalizeToolPolicy(`{"maxTotalCalls":2,"maxArgumentBytes":1024,"allowedRiskLevels":["READ","read","sensitive"]}`)
if err != nil {
t.Fatalf("normalizeToolPolicy: %v", err)
}
if !strings.Contains(policy, `"maxTotalCalls":2`) || !strings.Contains(policy, `"allowedRiskLevels":["read","sensitive"]`) {
t.Fatalf("unexpected normalized policy: %s", policy)
}
if _, err := AIAgentService.normalizeToolPolicy(`{"allowedRiskLevels":["admin"]}`); err == nil {
t.Fatal("expected invalid risk level error")
}
if _, err := AIAgentService.normalizeToolPolicy(`not-json`); err == nil {
t.Fatal("expected invalid JSON error")
}
}
func TestAIAgentServiceAllowsRegisteredReadDirectTool(t *testing.T) {
tools, err := AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.BuiltinConversationContext.Code}})
if err != nil {
t.Fatalf("normalizeDirectTools: %v", err)
}
if len(tools) != 1 || tools[0].ToolCode != toolx.BuiltinConversationContext.Code {
t.Fatalf("unexpected normalized direct tools: %#v", tools)
}
tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.BuiltinKnowledgeRetrieve.Code}})
if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.BuiltinKnowledgeRetrieve.Code {
t.Fatalf("expected registered knowledge retrieve tool to be allowed, tools=%#v err=%v", tools, err)
}
tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphPrepareTicketDraft.Code}})
if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.GraphPrepareTicketDraft.Code {
t.Fatalf("expected registered ticket draft tool to be allowed, tools=%#v err=%v", tools, err)
}
tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphAnalyzeConversation.Code}})
if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.GraphAnalyzeConversation.Code {
t.Fatalf("expected registered conversation analysis tool to be allowed, tools=%#v err=%v", tools, err)
}
tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphTriageServiceRequest.Code}})
if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.GraphTriageServiceRequest.Code {
t.Fatalf("expected registered service triage tool to be allowed, tools=%#v err=%v", tools, err)
}
if _, err := AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphHandoffConversation.Code}}); err == nil {
t.Fatal("expected unsupported graph direct tool to be rejected")
}
}
func TestAIAgentServiceRollsBackToOwnPublishedRevision(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
db := sqls.DB()
agent := &models.AIAgent{Name: "rollback-agent", Status: enums.StatusOk, RuntimeMode: enums.AIAgentRuntimeModeAutonomous}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("create agent: %v", err)
}
revision := &models.AgentRevision{AgentID: agent.ID, Revision: 1, Status: enums.StatusOk}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
if err := AIAgentService.RollbackAIAgent(agent.ID, revision.ID, aiAgentWorkflowTestOperator()); err != nil {
t.Fatalf("RollbackAIAgent: %v", err)
}
if updated := AIAgentService.Get(agent.ID); updated == nil || updated.PublishedRevisionID != revision.ID {
t.Fatalf("rollback did not bind revision: %#v", updated)
}
otherRevision := &models.AgentRevision{AgentID: agent.ID + 1, Revision: 1, Status: enums.StatusOk}
if err := db.Create(otherRevision).Error; err != nil {
t.Fatalf("create other revision: %v", err)
}
if err := AIAgentService.RollbackAIAgent(agent.ID, otherRevision.ID, aiAgentWorkflowTestOperator()); err == nil {
t.Fatal("expected cross-agent revision rollback rejection")
}
}
func TestAIAgentServiceRollsBackPreviousRolloutPercent(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
agent := &models.AIAgent{
Name: "rollout-agent",
Status: enums.StatusOk,
RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
RolloutPercent: 20,
PreviousRolloutPercent: 100,
}
if err := sqls.DB().Create(agent).Error; err != nil {
t.Fatalf("create agent: %v", err)
}
operator := aiAgentWorkflowTestOperator()
if err := AIAgentService.RollbackAIAgentRollout(agent.ID, operator); err != nil {
t.Fatalf("RollbackAIAgentRollout: %v", err)
}
updated := AIAgentService.Get(agent.ID)
if updated == nil || updated.RolloutPercent != 100 || updated.PreviousRolloutPercent != 20 {
t.Fatalf("unexpected rollout rollback result: %#v", updated)
}
if err := AIAgentService.RollbackAIAgentRollout(agent.ID, operator); err != nil {
t.Fatalf("second RollbackAIAgentRollout: %v", err)
}
updated = AIAgentService.Get(agent.ID)
if updated == nil || updated.RolloutPercent != 20 || updated.PreviousRolloutPercent != 100 {
t.Fatalf("unexpected rollout redo result: %#v", updated)
}
if err := sqls.DB().Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("previous_rollout_percent", 0).Error; err != nil {
t.Fatalf("clear previous rollout: %v", err)
}
if err := AIAgentService.RollbackAIAgentRollout(agent.ID, operator); err == nil {
t.Fatal("expected missing previous rollout to be rejected")
}
}
func TestAIAgentServiceUpdateUnpublishesAutonomousAgent(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "autonomous agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err != nil {
t.Fatalf("PublishAIAgent() error = %v", err)
}
if published := AIAgentService.Get(agent.ID); published == nil || published.PublishedRevisionID <= 0 {
t.Fatalf("expected published autonomous agent, got %#v", published)
}
if err := AIAgentService.UpdateAIAgent(request.UpdateAIAgentRequest{ID: agent.ID, CreateAIAgentRequest: request.CreateAIAgentRequest{
Name: agent.Name, Description: "changed draft", AIConfigID: agent.AIConfigID, RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}}, operator); err != nil {
t.Fatalf("UpdateAIAgent() error = %v", err)
}
if updated := AIAgentService.Get(agent.ID); updated == nil || updated.PublishedRevisionID != 0 {
t.Fatalf("expected autonomous update to clear published revision, got %#v", updated)
}
}
func TestAIAgentServiceRejectsPublishWithUnavailableModelConfig(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
configID := createAIAgentWorkflowTestConfig(t)
agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "unavailable model agent", AIConfigID: configID, RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
if err := sqls.DB().Model(&models.AIConfig{}).Where("id = ?", configID).Update("status", enums.StatusDisabled).Error; err != nil {
t.Fatalf("disable model config: %v", err)
}
if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err == nil {
t.Fatal("expected unavailable model config to reject publishing")
}
}
func TestAIAgentServiceRejectsPublishWithSensitiveDirectTool(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "sensitive tool agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
if err := sqls.DB().Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("allowed_mcp_tools", `[{"toolCode":"mcp/demo/write_order"}]`).Error; err != nil {
t.Fatalf("set direct tool: %v", err)
}
if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err == nil || !strings.Contains(err.Error(), "confirmed playbook") {
t.Fatalf("expected sensitive direct tool publish rejection, got %v", err)
}
}
func TestAIWorkflowServiceDefaultAgentWorkflowDefinitionRequiresKnowledgeRetrieveConfiguration(t *testing.T) { func TestAIWorkflowServiceDefaultAgentWorkflowDefinitionRequiresKnowledgeRetrieveConfiguration(t *testing.T) {
definition := AIWorkflowService.DefaultAgentWorkflowDefinition() definition := AIWorkflowService.DefaultAgentWorkflowDefinition()
if definition.SchemaVersion != dsl.SchemaVersion || nodeTypeByID(definition, "start_1") != workflowregistry.NodeTypeStart { if definition.SchemaVersion != dsl.SchemaVersion || nodeTypeByID(definition, "start_1") != workflowregistry.NodeTypeStart {
@@ -135,6 +373,13 @@ func TestAIWorkflowServiceDefaultAgentWorkflowDefinitionRequiresKnowledgeRetriev
if !workflowHasNodeType(definition, workflowregistry.NodeTypeCreateTicket) { if !workflowHasNodeType(definition, workflowregistry.NodeTypeCreateTicket) {
t.Fatalf("expected default workflow to include ticket creation node") 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) { func TestAIWorkflowServiceDefaultAgentWorkflowTicketPromptIncludesDraftFields(t *testing.T) {
@@ -203,6 +448,19 @@ func TestAIWorkflowServicePublishAgentWorkflowBindsAgentVersion(t *testing.T) {
if storedAgent.WorkflowVersionID != version.ID { if storedAgent.WorkflowVersionID != version.ID {
t.Fatalf("expected agent workflow version %d, got %d", version.ID, storedAgent.WorkflowVersionID) t.Fatalf("expected agent workflow version %d, got %d", version.ID, storedAgent.WorkflowVersionID)
} }
if storedAgent.PublishedRevisionID <= 0 {
t.Fatalf("expected published agent revision id, got %d", storedAgent.PublishedRevisionID)
}
var revision models.AgentRevision
if err := sqls.DB().First(&revision, storedAgent.PublishedRevisionID).Error; err != nil {
t.Fatalf("load agent revision: %v", err)
}
if revision.AgentID != agent.ID || revision.WorkflowVersionID != version.ID || revision.Revision != 1 || revision.DefinitionHash == "" {
t.Fatalf("unexpected published agent revision: %#v", revision)
}
if !strings.Contains(revision.Definition, `"modelName":"gpt-test"`) || strings.Contains(revision.Definition, "revision-test-secret") {
t.Fatalf("unexpected revision definition: %s", revision.Definition)
}
} }
func setupAIAgentWorkflowTestDB(t *testing.T) { func setupAIAgentWorkflowTestDB(t *testing.T) {
@@ -211,7 +469,7 @@ func setupAIAgentWorkflowTestDB(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("open sqlite db: %v", err) t.Fatalf("open sqlite db: %v", err)
} }
if err := db.AutoMigrate(&models.AIAgent{}, &models.AIConfig{}, &models.KnowledgeBase{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}); err != nil { if err := db.AutoMigrate(&models.AIAgent{}, &models.AIConfig{}, &models.KnowledgeBase{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}, &models.AgentRevision{}); err != nil {
t.Fatalf("auto migrate: %v", err) t.Fatalf("auto migrate: %v", err)
} }
sqls.SetDB(db) sqls.SetDB(db)
@@ -222,6 +480,7 @@ func createAIAgentWorkflowTestConfig(t *testing.T) int64 {
item := &models.AIConfig{ item := &models.AIConfig{
Name: "workflow-test-config", Name: "workflow-test-config",
Provider: enums.AIProviderOpenAI, Provider: enums.AIProviderOpenAI,
APIKey: "revision-test-secret",
ModelType: enums.AIModelTypeLLM, ModelType: enums.AIModelTypeLLM,
ModelName: "gpt-test", ModelName: "gpt-test",
Status: enums.StatusOk, Status: enums.StatusOk,
+140 -8
View File
@@ -42,6 +42,13 @@ type AIWorkflowRunAuditItem struct {
Agent *models.AIAgent Agent *models.AIAgent
} }
type AIWorkflowTemplate struct {
Code string
Name string
Description string
Definition dsl.Definition
}
func (s *aiWorkflowService) Get(id int64) *models.AIWorkflow { func (s *aiWorkflowService) Get(id int64) *models.AIWorkflow {
if id <= 0 { if id <= 0 {
return nil return nil
@@ -194,6 +201,46 @@ func (s *aiWorkflowService) DefaultAgentWorkflowDefinition() dsl.Definition {
return defaultAgentWorkflowDefinition() return defaultAgentWorkflowDefinition()
} }
func (s *aiWorkflowService) ListPlaybookTemplates() []AIWorkflowTemplate {
return []AIWorkflowTemplate{
{Code: "ticket-with-confirmation", Name: "创建工单", Description: "整理工单草稿,经客户确认后创建工单。", Definition: ticketWithConfirmationPlaybookDefinition()},
{Code: "identity-confirmation", Name: "身份确认", Description: "在执行后续业务前收集客户的明确确认。", Definition: identityConfirmationPlaybookDefinition()},
{Code: "complaint-escalation", Name: "投诉升级", Description: "投诉场景经客户确认后转入人工客服处理。", Definition: complaintEscalationPlaybookDefinition()},
{Code: "refund-request-preparation", Name: "退款申请准备", Description: "整理退款诉求,确认后转人工继续核验和处理。", Definition: refundRequestPreparationPlaybookDefinition()},
}
}
func ticketWithConfirmationPlaybookDefinition() dsl.Definition {
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
workflowNode("draft_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 600, 180, workflowInputs("issue", "start_1", "userMessage"), nil),
workflowNode("ready_route_1", workflowregistry.NodeTypeCondition, "草稿分流", 1020, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("ready", "草稿完整", "prompt_1", "draft_1", "ready", "is_true", nil),
{ID: "default", Name: "补充信息", TargetNodeID: "followup_1", Default: true},
}}),
workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, "建单确认", 1440, 100, map[string]dsl.Value{"userMessage": dsl.RefValue("start_1", "userMessage"), "ticketTitle": dsl.RefValue("draft_1", "title"), "ticketDescription": dsl.RefValue("draft_1", "description")}, map[string]any{"staticReply": "我已整理工单草稿:{{ticketTitle}}。请确认是否创建。"}),
workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认建单", 1860, 100, workflowInputs("prompt", "prompt_1", "replyText"), nil),
workflowNode("confirm_route_1", workflowregistry.NodeTypeCondition, "确认分流", 2280, 100, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("confirmed", "已确认", "create_1", "confirm_1", "confirmed", "is_true", nil),
{ID: "default", Name: "取消", TargetNodeID: "cancel_1", Default: true},
}}),
workflowNode("create_1", workflowregistry.NodeTypeCreateTicket, "创建工单", 2700, 20, map[string]dsl.Value{"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"), "confirmed": dsl.RefValue("confirm_1", "confirmed")}, nil),
workflowNode("followup_1", workflowregistry.NodeTypeLLMReply, "补充信息", 1440, 330, map[string]dsl.Value{"userMessage": dsl.RefValue("start_1", "userMessage"), "followUpQuestions": dsl.RefValue("draft_1", "followUpQuestions")}, map[string]any{"staticReply": "创建工单前还需要补充:{{followUpQuestions}}"}),
workflowNode("cancel_1", workflowregistry.NodeTypeLLMReply, "取消提示", 2700, 200, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消创建工单。"}),
workflowNode("send_result_1", workflowregistry.NodeTypeSendReply, "发送建单结果", 3120, 20, workflowInputs("replyText", "create_1", "message"), nil),
workflowNode("send_followup_1", workflowregistry.NodeTypeSendReply, "发送补充提示", 1860, 330, workflowInputs("replyText", "followup_1", "replyText"), nil),
workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 3120, 200, workflowInputs("replyText", "cancel_1", "replyText"), nil),
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 3540, 180, nil, nil),
},
Edges: []dsl.Edge{
workflowEdge("start_1", "draft_1"), workflowEdge("draft_1", "ready_route_1"), workflowPortEdge("ready_route_1", "prompt_1", "ready"), workflowPortEdge("ready_route_1", "followup_1", "default"),
workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "confirm_route_1"), workflowPortEdge("confirm_route_1", "create_1", "confirmed"), workflowPortEdge("confirm_route_1", "cancel_1", "default"),
workflowEdge("create_1", "send_result_1"), workflowEdge("send_result_1", "end_1"), workflowEdge("followup_1", "send_followup_1"), workflowEdge("send_followup_1", "end_1"), workflowEdge("cancel_1", "send_cancel_1"), workflowEdge("send_cancel_1", "end_1"),
},
}
}
func (s *aiWorkflowService) ValidateDefinition(def dsl.Definition) workflowvalidator.Result { func (s *aiWorkflowService) ValidateDefinition(def dsl.Definition) workflowvalidator.Result {
return workflowvalidator.ValidateDefinition(def, s.registry) return workflowvalidator.ValidateDefinition(def, s.registry)
} }
@@ -390,11 +437,23 @@ func (s *aiWorkflowService) PublishAgentWorkflow(req request.PublishAIWorkflowRe
}); err != nil { }); err != nil {
return err return err
} }
agent := repositories.AIAgentRepository.Get(ctx.Tx, req.AgentID)
if agent == nil {
return errorsx.InvalidParamI18n("error.e0002")
}
if err := AIAgentService.validatePublishableAgent(ctx.Tx, agent); err != nil {
return err
}
revision, err := AgentRevisionService.PublishWorkflowSnapshot(ctx.Tx, agent, version, operator)
if err != nil {
return err
}
return repositories.AIAgentRepository.Updates(ctx.Tx, req.AgentID, map[string]any{ return repositories.AIAgentRepository.Updates(ctx.Tx, req.AgentID, map[string]any{
"workflow_version_id": version.ID, "workflow_version_id": version.ID,
"update_user_id": operator.UserID, "published_revision_id": revision.ID,
"update_user_name": operator.Username, "update_user_id": operator.UserID,
"updated_at": now, "update_user_name": operator.Username,
"updated_at": now,
}) })
}) })
if err != nil { if err != nil {
@@ -434,7 +493,7 @@ func defaultAgentWorkflowDefinition() dsl.Definition {
"riskSignals": dsl.RefValue("understanding_1", "riskSignals"), "riskSignals": dsl.RefValue("understanding_1", "riskSignals"),
}, nil), }, nil),
workflowNode("policy_route_1", workflowregistry.NodeTypeCondition, "策略分流", 1560, 125.5, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ workflowNode("policy_route_1", workflowregistry.NodeTypeCondition, "策略分流", 1560, 125.5, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("handoff", "转人工", "handoff_1", "policy_1", "action", "eq", "handoff_to_human"), workflowConditionBranch("handoff", "转人工", "handoff_confirm_prompt_1", "policy_1", "action", "eq", "handoff_to_human"),
workflowConditionBranch("direct", "直接回复", "policy_reply_1", "policy_1", "action", "eq", "direct_reply"), workflowConditionBranch("direct", "直接回复", "policy_reply_1", "policy_1", "action", "eq", "direct_reply"),
workflowConditionBranch("clarify", "追问澄清", "policy_reply_1", "policy_1", "action", "eq", "clarify"), workflowConditionBranch("clarify", "追问澄清", "policy_reply_1", "policy_1", "action", "eq", "clarify"),
workflowConditionBranch("end_conversation", "结束语", "policy_reply_1", "policy_1", "action", "eq", "end_conversation"), workflowConditionBranch("end_conversation", "结束语", "policy_reply_1", "policy_1", "action", "eq", "end_conversation"),
@@ -442,9 +501,20 @@ func defaultAgentWorkflowDefinition() dsl.Definition {
workflowConditionBranch("knowledge", "知识库回复", "retrieve_1", "policy_1", "action", "eq", "retrieve_knowledge"), workflowConditionBranch("knowledge", "知识库回复", "retrieve_1", "policy_1", "action", "eq", "retrieve_knowledge"),
{ID: "default", Name: "策略兜底", TargetNodeID: "policy_reply_1", Default: true}, {ID: "default", Name: "策略兜底", TargetNodeID: "policy_reply_1", Default: true},
}}), }}),
workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工", 2020, 0, workflowInputs("reason", "start_1", "userMessage"), nil), workflowNode("handoff_confirm_prompt_1", workflowregistry.NodeTypeLLMReply, "转人工确认文案", 2020, 0, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "我可以为你转接人工客服处理。请回复“确认”继续转人工,或回复“取消”继续由 AI 协助。"}),
workflowNode("handoff_confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认转人工", 2480, 0, workflowInputs("prompt", "handoff_confirm_prompt_1", "replyText"), nil),
workflowNode("handoff_confirm_route_1", workflowregistry.NodeTypeCondition, "转人工确认分流", 2940, 0, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("confirmed", "已确认", "handoff_1", "handoff_confirm_1", "confirmed", "is_true", nil),
{ID: "default", Name: "取消或未确认", TargetNodeID: "handoff_cancel_reply_1", Default: true},
}}),
workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工", 3400, 0, map[string]dsl.Value{
"reason": dsl.RefValue("start_1", "userMessage"),
"confirmed": dsl.RefValue("handoff_confirm_1", "confirmed"),
}, nil),
workflowNode("handoff_cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消转人工提示", 3400, 480, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消转人工。你可以继续补充问题,我会继续协助。"}),
workflowNode("send_handoff_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 3860, 480, workflowInputs("replyText", "handoff_cancel_reply_1", "replyText"), nil),
workflowNode("policy_reply_1", workflowregistry.NodeTypeSendReply, "发送策略回复", 4320, 98.5, workflowInputs("replyText", "policy_1", "replyText"), nil), workflowNode("policy_reply_1", workflowregistry.NodeTypeSendReply, "发送策略回复", 4320, 98.5, workflowInputs("replyText", "policy_1", "replyText"), nil),
workflowNode("handoff_end_1", workflowregistry.NodeTypeEnd, "结束", 2480, 0, nil, nil), workflowNode("handoff_end_1", workflowregistry.NodeTypeEnd, "结束", 3860, 0, nil, nil),
workflowNode("draft_ticket_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 2020, 379, workflowInputs("issue", "start_1", "userMessage"), nil), workflowNode("draft_ticket_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 2020, 379, workflowInputs("issue", "start_1", "userMessage"), nil),
workflowNode("ticket_draft_route_1", workflowregistry.NodeTypeCondition, "草稿就绪分流", 2480, 329, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{ workflowNode("ticket_draft_route_1", workflowregistry.NodeTypeCondition, "草稿就绪分流", 2480, 329, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("ready", "草稿完整", "ticket_confirm_prompt_1", "draft_ticket_1", "ready", "is_true", nil), workflowConditionBranch("ready", "草稿完整", "ticket_confirm_prompt_1", "draft_ticket_1", "ready", "is_true", nil),
@@ -497,7 +567,7 @@ func defaultAgentWorkflowDefinition() dsl.Definition {
workflowEdge("start_1", "understanding_1"), workflowEdge("start_1", "understanding_1"),
workflowEdge("understanding_1", "policy_1"), workflowEdge("understanding_1", "policy_1"),
workflowEdge("policy_1", "policy_route_1"), workflowEdge("policy_1", "policy_route_1"),
workflowPortEdge("policy_route_1", "handoff_1", "handoff"), workflowPortEdge("policy_route_1", "handoff_confirm_prompt_1", "handoff"),
workflowPortEdge("policy_route_1", "policy_reply_1", "direct"), workflowPortEdge("policy_route_1", "policy_reply_1", "direct"),
workflowPortEdge("policy_route_1", "policy_reply_1", "clarify"), workflowPortEdge("policy_route_1", "policy_reply_1", "clarify"),
workflowPortEdge("policy_route_1", "policy_reply_1", "end_conversation"), workflowPortEdge("policy_route_1", "policy_reply_1", "end_conversation"),
@@ -505,7 +575,13 @@ func defaultAgentWorkflowDefinition() dsl.Definition {
workflowPortEdge("policy_route_1", "retrieve_1", "knowledge"), workflowPortEdge("policy_route_1", "retrieve_1", "knowledge"),
workflowPortEdge("policy_route_1", "policy_reply_1", "default"), workflowPortEdge("policy_route_1", "policy_reply_1", "default"),
workflowEdge("policy_reply_1", "end_1"), workflowEdge("policy_reply_1", "end_1"),
workflowEdge("handoff_confirm_prompt_1", "handoff_confirm_1"),
workflowEdge("handoff_confirm_1", "handoff_confirm_route_1"),
workflowPortEdge("handoff_confirm_route_1", "handoff_1", "confirmed"),
workflowPortEdge("handoff_confirm_route_1", "handoff_cancel_reply_1", "default"),
workflowEdge("handoff_1", "handoff_end_1"), workflowEdge("handoff_1", "handoff_end_1"),
workflowEdge("handoff_cancel_reply_1", "send_handoff_cancel_1"),
workflowEdge("send_handoff_cancel_1", "end_1"),
workflowEdge("draft_ticket_1", "ticket_draft_route_1"), workflowEdge("draft_ticket_1", "ticket_draft_route_1"),
workflowPortEdge("ticket_draft_route_1", "ticket_confirm_prompt_1", "ready"), workflowPortEdge("ticket_draft_route_1", "ticket_confirm_prompt_1", "ready"),
workflowPortEdge("ticket_draft_route_1", "ticket_followup_reply_1", "default"), workflowPortEdge("ticket_draft_route_1", "ticket_followup_reply_1", "default"),
@@ -531,6 +607,62 @@ func defaultAgentWorkflowDefinition() dsl.Definition {
} }
} }
func identityConfirmationPlaybookDefinition() dsl.Definition {
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, "身份确认提示", 600, 180, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "为保护你的账户信息,请确认是否继续身份核验。"}),
workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认身份核验", 1020, 180, workflowInputs("prompt", "prompt_1", "replyText"), nil),
workflowNode("route_1", workflowregistry.NodeTypeCondition, "确认分流", 1440, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("confirmed", "已确认", "confirmed_reply_1", "confirm_1", "confirmed", "is_true", nil),
{ID: "default", Name: "取消", TargetNodeID: "cancel_reply_1", Default: true},
}}),
workflowNode("confirmed_reply_1", workflowregistry.NodeTypeLLMReply, "确认结果", 1860, 100, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已收到确认,人工客服将继续为你核验身份。"}),
workflowNode("cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消提示", 1860, 280, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消身份核验。"}),
workflowNode("send_confirmed_1", workflowregistry.NodeTypeSendReply, "发送确认结果", 2280, 100, workflowInputs("replyText", "confirmed_reply_1", "replyText"), nil),
workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 2280, 280, workflowInputs("replyText", "cancel_reply_1", "replyText"), nil),
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 2700, 180, nil, nil),
},
Edges: []dsl.Edge{
workflowEdge("start_1", "prompt_1"), workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "route_1"),
workflowPortEdge("route_1", "confirmed_reply_1", "confirmed"), workflowPortEdge("route_1", "cancel_reply_1", "default"),
workflowEdge("confirmed_reply_1", "send_confirmed_1"), workflowEdge("cancel_reply_1", "send_cancel_1"), workflowEdge("send_confirmed_1", "end_1"), workflowEdge("send_cancel_1", "end_1"),
},
}
}
func complaintEscalationPlaybookDefinition() dsl.Definition {
return confirmationHandoffPlaybookDefinition("投诉升级确认", "我们将把本次投诉升级给人工客服处理。请确认是否继续。", "已为你升级投诉,人工客服会尽快跟进。", "投诉升级已取消。")
}
func confirmationHandoffPlaybookDefinition(title, prompt, confirmedReply, cancelledReply string) dsl.Definition {
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, title, 600, 180, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": prompt}),
workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认升级", 1020, 180, workflowInputs("prompt", "prompt_1", "replyText"), nil),
workflowNode("route_1", workflowregistry.NodeTypeCondition, "确认分流", 1440, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
workflowConditionBranch("confirmed", "已确认", "handoff_1", "confirm_1", "confirmed", "is_true", nil),
{ID: "default", Name: "取消", TargetNodeID: "cancel_reply_1", Default: true},
}}),
workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工处理", 1860, 100, map[string]dsl.Value{"reason": dsl.RefValue("start_1", "userMessage"), "confirmed": dsl.RefValue("confirm_1", "confirmed")}, nil),
workflowNode("cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消提示", 1860, 280, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": cancelledReply}),
workflowNode("send_handoff_1", workflowregistry.NodeTypeSendReply, "发送升级结果", 2280, 100, workflowInputs("replyText", "handoff_1", "message"), nil),
workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 2280, 280, workflowInputs("replyText", "cancel_reply_1", "replyText"), nil),
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 2700, 180, nil, nil),
},
Edges: []dsl.Edge{
workflowEdge("start_1", "prompt_1"), workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "route_1"),
workflowPortEdge("route_1", "handoff_1", "confirmed"), workflowPortEdge("route_1", "cancel_reply_1", "default"),
workflowEdge("handoff_1", "send_handoff_1"), workflowEdge("cancel_reply_1", "send_cancel_1"), workflowEdge("send_handoff_1", "end_1"), workflowEdge("send_cancel_1", "end_1"),
},
}
}
func refundRequestPreparationPlaybookDefinition() dsl.Definition {
return confirmationHandoffPlaybookDefinition("退款申请确认", "我会先整理退款申请并转交人工客服核验。请确认是否继续。", "退款申请已准备完成,人工客服将继续核验订单和退款条件。", "退款申请准备已取消。")
}
func workflowNode(id string, nodeType string, title string, x float64, y float64, inputs map[string]dsl.Value, config any) dsl.Node { func workflowNode(id string, nodeType string, title string, x float64, y float64, inputs map[string]dsl.Value, config any) dsl.Node {
return dsl.Node{ return dsl.Node{
ID: id, ID: id,
@@ -83,6 +83,26 @@ func TestAIWorkflowServicePublishCreatesImmutableVersion(t *testing.T) {
} }
} }
func TestAIWorkflowServicePlaybookTemplatesAreValid(t *testing.T) {
templates := AIWorkflowService.ListPlaybookTemplates()
if len(templates) != 4 {
t.Fatalf("template count = %d, want 4", len(templates))
}
seen := make(map[string]struct{}, len(templates))
for _, item := range templates {
if item.Code == "" || item.Name == "" {
t.Fatalf("template identity is required: %#v", item)
}
if _, exists := seen[item.Code]; exists {
t.Fatalf("duplicate template code: %s", item.Code)
}
seen[item.Code] = struct{}{}
if result := AIWorkflowService.ValidateDefinition(item.Definition); !result.Valid {
t.Fatalf("template %s is invalid: %#v", item.Code, result.Errors)
}
}
}
func TestAIWorkflowServicePublishIncrementsVersion(t *testing.T) { func TestAIWorkflowServicePublishIncrementsVersion(t *testing.T) {
setupAIWorkflowTestDB(t) setupAIWorkflowTestDB(t)
operator := aiWorkflowTestOperator() operator := aiWorkflowTestOperator()
+120
View File
@@ -0,0 +1,120 @@
package services
import (
"context"
"encoding/json"
"fmt"
"strings"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/toolx"
)
// BusinessToolExecutor is the write boundary for built-in business tools.
// Autonomous mode deliberately does not expose it; deterministic Playbooks
// invoke it only after their human-confirm node has completed.
var BusinessToolExecutor = newBusinessToolExecutor(aitooling.DefaultRegistry)
type BusinessToolInput struct {
Conversation models.Conversation
AIAgent models.AIAgent
ToolCode string
Arguments map[string]any
IdempotencyKey string
Confirmed bool
}
type BusinessToolResult struct {
Definition aitooling.Definition
ResultData string
Reused bool
}
type businessToolExecutor struct {
registry *aitooling.Registry
}
func newBusinessToolExecutor(registry *aitooling.Registry) *businessToolExecutor {
return &businessToolExecutor{registry: registry}
}
func (e *businessToolExecutor) Execute(_ context.Context, input BusinessToolInput) (*BusinessToolResult, error) {
toolCode := toolx.NormalizeToolCodeAlias(strings.TrimSpace(input.ToolCode))
definition, err := e.registry.Resolve(toolCode)
if err != nil {
return nil, err
}
if err := e.registry.Authorize(definition, aitooling.Policy{AllowedToolCodes: []string{definition.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelWrite}, Confirmed: input.Confirmed}); err != nil {
return nil, err
}
if input.Conversation.ID <= 0 || strings.TrimSpace(input.IdempotencyKey) == "" {
return nil, fmt.Errorf("business tool invocation requires conversation and idempotency key")
}
claim, err := AgentToolInvocationService.Claim(input.Conversation.ID, input.AIAgent.ID, definition.Code, input.IdempotencyKey)
if err != nil {
return nil, err
}
if claim == nil || claim.Item == nil {
return nil, fmt.Errorf("business tool invocation could not be claimed")
}
if claim.Completed {
return &BusinessToolResult{Definition: definition, ResultData: claim.Item.ResultData, Reused: true}, nil
}
if !claim.Acquired {
return nil, fmt.Errorf("business tool invocation is already running: %s", definition.Code)
}
resultData, err := e.execute(definition.Code, input)
if err != nil {
_ = AgentToolInvocationService.Fail(claim.Item, err)
return nil, err
}
if err := AgentToolInvocationService.Complete(claim.Item, resultData); err != nil {
return nil, err
}
return &BusinessToolResult{Definition: definition, ResultData: resultData}, nil
}
func (e *businessToolExecutor) execute(toolCode string, input BusinessToolInput) (string, error) {
switch toolCode {
case toolx.GraphCreateTicketConfirm.Code:
item, err := TicketService.CreateFromConversation(request.CreateTicketFromConversationRequest{
ConversationID: input.Conversation.ID,
Title: businessToolString(input.Arguments["title"]),
Description: businessToolString(input.Arguments["description"]),
}, businessToolPrincipal(input.AIAgent))
if err != nil {
return "", err
}
return businessToolJSON(map[string]any{"ticketId": item.ID, "ticketNo": item.TicketNo, "created": true})
case toolx.GraphHandoffConversation.Code:
result, err := ConversationHumanDispatchService.HandoffByAIWithRequestID(input.Conversation.ID, input.AIAgent, businessToolString(input.Arguments["reason"]), input.IdempotencyKey)
if err != nil {
return "", err
}
return businessToolJSON(map[string]any{"decision": result.Decision, "teamId": result.TeamID, "assigneeId": result.AssigneeID, "message": result.Message})
default:
return "", fmt.Errorf("business tool is not executable: %s", toolCode)
}
}
func businessToolString(value any) string {
text, _ := value.(string)
return strings.TrimSpace(text)
}
func businessToolPrincipal(agent models.AIAgent) *dto.AuthPrincipal {
name := strings.TrimSpace(agent.Name)
if name == "" {
name = "AI"
}
return &dto.AuthPrincipal{Username: name, Nickname: name}
}
func businessToolJSON(value any) (string, error) {
data, err := json.Marshal(value)
return string(data), err
}
+71 -20
View File
@@ -105,17 +105,49 @@ func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operato
if err != nil { if err != nil {
return err return err
} }
return repositories.ChannelRepository.Updates(sqls.DB(), req.ID, map[string]any{ columns := map[string]any{
"channel_type": item.ChannelType, "channel_type": item.ChannelType,
"channel_id": item.ChannelID, "channel_id": item.ChannelID,
"ai_agent_id": item.AIAgentID, "ai_agent_id": item.AIAgentID,
"name": item.Name, "ai_agent_rollout_percent": item.AIAgentRolloutPercent,
"config_json": item.ConfigJSON, "name": item.Name,
"status": item.Status, "config_json": item.ConfigJSON,
"remark": item.Remark, "status": item.Status,
"update_user_id": operator.UserID, "remark": item.Remark,
"update_user_name": operator.Username, "update_user_id": operator.UserID,
"updated_at": time.Now(), "update_user_name": operator.Username,
"updated_at": time.Now(),
}
if item.AIAgentRolloutPercent != current.AIAgentRolloutPercent {
columns["previous_ai_agent_rollout_percent"] = current.AIAgentRolloutPercent
}
return repositories.ChannelRepository.Updates(sqls.DB(), req.ID, columns)
}
// RollbackChannelAIAgentRollout restores the last channel-level rollout value
// and swaps it into history so the action itself is reversible.
func (s *channelService) RollbackChannelAIAgentRollout(id int64, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
if id <= 0 {
return errorsx.InvalidParam("channel id is required")
}
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
channel := repositories.ChannelRepository.Get(ctx.Tx, id)
if channel == nil || channel.Status == enums.StatusDeleted {
return errorsx.InvalidParamI18n("error.e0208")
}
if channel.PreviousAIAgentRolloutPercent < 1 || channel.PreviousAIAgentRolloutPercent > 100 {
return errorsx.InvalidParam("channel rollout has no previous value to restore")
}
return repositories.ChannelRepository.Updates(ctx.Tx, channel.ID, map[string]any{
"ai_agent_rollout_percent": channel.PreviousAIAgentRolloutPercent,
"previous_ai_agent_rollout_percent": channel.AIAgentRolloutPercent,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
}) })
} }
@@ -394,12 +426,30 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe
if req.AIAgentID <= 0 { if req.AIAgentID <= 0 {
return nil, errorsx.InvalidParamI18n("error.e0321") return nil, errorsx.InvalidParamI18n("error.e0321")
} }
if req.AIAgentRolloutPercent == 0 {
req.AIAgentRolloutPercent = 100
}
if req.AIAgentRolloutPercent < 1 || req.AIAgentRolloutPercent > 100 {
return nil, errorsx.InvalidParam("channel ai agent rollout percent must be between 1 and 100")
}
aiAgent := AIAgentService.Get(req.AIAgentID) aiAgent := AIAgentService.Get(req.AIAgentID)
if aiAgent == nil || aiAgent.Status != enums.StatusOk { if aiAgent == nil || aiAgent.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0004") return nil, errorsx.InvalidParamI18n("error.e0004")
} }
if aiAgent.WorkflowVersionID <= 0 { if aiAgent.RuntimeMode == "" || aiAgent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow {
return nil, errorsx.InvalidParam("ai agent workflow must be published before binding channel") if aiAgent.WorkflowVersionID <= 0 {
return nil, errorsx.InvalidParam("ai agent workflow must be published before binding channel")
}
} else if aiAgent.RuntimeMode == enums.AIAgentRuntimeModeAutonomous {
if aiAgent.PublishedRevisionID <= 0 {
return nil, errorsx.InvalidParam("autonomous ai agent must be published before binding channel")
}
} else if aiAgent.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
if aiAgent.PublishedRevisionID <= 0 || aiAgent.WorkflowVersionID <= 0 {
return nil, errorsx.InvalidParam("hybrid ai agent and workflow must be published before binding channel")
}
} else {
return nil, errorsx.InvalidParam("ai agent runtime mode is not available yet")
} }
status := enums.Status(req.Status) status := enums.Status(req.Status)
if req.Status == 0 { if req.Status == 0 {
@@ -485,12 +535,13 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe
} }
return &models.Channel{ return &models.Channel{
ChannelType: channelType, ChannelType: channelType,
ChannelID: channelID, ChannelID: channelID,
AIAgentID: req.AIAgentID, AIAgentID: req.AIAgentID,
Name: name, AIAgentRolloutPercent: req.AIAgentRolloutPercent,
ConfigJSON: configJSON, Name: name,
Status: status, ConfigJSON: configJSON,
Remark: strings.TrimSpace(req.Remark), Status: status,
Remark: strings.TrimSpace(req.Remark),
}, nil }, nil
} }
+118 -1
View File
@@ -48,6 +48,123 @@ func TestChannelServiceAllowsAgentWithPublishedWorkflow(t *testing.T) {
} }
} }
func TestChannelServiceStoresAIAgentRolloutPercent(t *testing.T) {
db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 1001)
item, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, AIAgentRolloutPercent: 25,
Name: "灰度渠道", Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err != nil || item == nil || item.AIAgentRolloutPercent != 25 {
t.Fatalf("expected persisted rollout percent, item=%#v err=%v", item, err)
}
if _, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, AIAgentRolloutPercent: 101,
Name: "错误灰度渠道", Status: int(enums.StatusOk),
}, channelServiceTestOperator()); err == nil {
t.Fatal("expected invalid rollout percent to be rejected")
}
}
func TestChannelServiceRollsBackPreviousAIAgentRolloutPercent(t *testing.T) {
db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 1001)
channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, AIAgentRolloutPercent: 20,
Name: "渠道灰度回滚", Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err != nil {
t.Fatalf("create channel: %v", err)
}
if err := db.Model(&models.Channel{}).Where("id = ?", channel.ID).Update("previous_ai_agent_rollout_percent", 100).Error; err != nil {
t.Fatalf("set previous rollout: %v", err)
}
operator := channelServiceTestOperator()
if err := ChannelService.RollbackChannelAIAgentRollout(channel.ID, operator); err != nil {
t.Fatalf("RollbackChannelAIAgentRollout: %v", err)
}
updated := ChannelService.Get(channel.ID)
if updated == nil || updated.AIAgentRolloutPercent != 100 || updated.PreviousAIAgentRolloutPercent != 20 {
t.Fatalf("unexpected channel rollout rollback: %#v", updated)
}
if err := ChannelService.RollbackChannelAIAgentRollout(channel.ID, operator); err != nil {
t.Fatalf("second RollbackChannelAIAgentRollout: %v", err)
}
updated = ChannelService.Get(channel.ID)
if updated == nil || updated.AIAgentRolloutPercent != 20 || updated.PreviousAIAgentRolloutPercent != 100 {
t.Fatalf("unexpected channel rollout redo: %#v", updated)
}
}
func TestChannelServiceRejectsUnpublishedAutonomousRuntime(t *testing.T) {
db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 1001)
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("runtime_mode", enums.AIAgentRuntimeModeAutonomous).Error; err != nil {
t.Fatalf("set autonomous runtime mode: %v", err)
}
_, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb,
AIAgentID: agent.ID,
Name: "官网客服",
Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err == nil || !strings.Contains(err.Error(), "must be published") {
t.Fatalf("expected unpublished autonomous runtime error, got %v", err)
}
}
func TestChannelServiceAcceptsPublishedAutonomousRuntime(t *testing.T) {
db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 1001)
revision := &models.AgentRevision{AgentID: agent.ID, Revision: 1, Status: enums.StatusOk}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create agent revision: %v", err)
}
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Updates(map[string]any{
"runtime_mode": enums.AIAgentRuntimeModeAutonomous,
"published_revision_id": revision.ID,
}).Error; err != nil {
t.Fatalf("set autonomous runtime mode: %v", err)
}
item, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "自主客服", Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err != nil || item == nil {
t.Fatalf("create channel for autonomous runtime: item=%#v err=%v", item, err)
}
}
func TestChannelServiceRequiresBothHybridPublicationArtifacts(t *testing.T) {
db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 0)
revision := &models.AgentRevision{AgentID: agent.ID, Revision: 1, Status: enums.StatusOk}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create agent revision: %v", err)
}
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Updates(map[string]any{
"runtime_mode": enums.AIAgentRuntimeModeHybrid,
"published_revision_id": revision.ID,
}).Error; err != nil {
t.Fatalf("set hybrid runtime mode: %v", err)
}
_, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "混合客服", Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err == nil || !strings.Contains(err.Error(), "hybrid ai agent") {
t.Fatalf("expected hybrid publication error, got %v", err)
}
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("workflow_version_id", 1001).Error; err != nil {
t.Fatalf("set workflow version: %v", err)
}
item, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "混合客服已发布", Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err != nil || item == nil {
t.Fatalf("create channel for hybrid runtime: item=%#v err=%v", item, err)
}
}
func setupChannelServiceTestDB(t *testing.T) *gorm.DB { func setupChannelServiceTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
@@ -66,7 +183,7 @@ func setupChannelServiceTestDB(t *testing.T) *gorm.DB {
_ = sqlDB.Close() _ = sqlDB.Close()
} }
}) })
if err := db.AutoMigrate(&models.AIAgent{}, &models.Channel{}); err != nil { if err := db.AutoMigrate(&models.AIAgent{}, &models.AgentRevision{}, &models.Channel{}); err != nil {
t.Fatalf("auto migrate: %v", err) t.Fatalf("auto migrate: %v", err)
} }
sqls.SetDB(db) sqls.SetDB(db)
@@ -94,6 +94,8 @@ func (s *conversationInterruptService) mergeForCheckpointUpdate(current, next *m
merged := *current merged := *current
merged.ConversationID = current.ConversationID merged.ConversationID = current.ConversationID
merged.AIAgentID = current.AIAgentID merged.AIAgentID = current.AIAgentID
merged.AgentRunID = current.AgentRunID
merged.AgentStepID = current.AgentStepID
merged.SourceMessageID = current.SourceMessageID merged.SourceMessageID = current.SourceMessageID
merged.LastResumeMessageID = current.LastResumeMessageID merged.LastResumeMessageID = current.LastResumeMessageID
merged.WorkflowRunID = current.WorkflowRunID merged.WorkflowRunID = current.WorkflowRunID
@@ -120,6 +122,8 @@ func (s *conversationInterruptService) mergeForPendingUpdate(current, next *mode
merged := *current merged := *current
merged.ConversationID = next.ConversationID merged.ConversationID = next.ConversationID
merged.AIAgentID = next.AIAgentID merged.AIAgentID = next.AIAgentID
merged.AgentRunID = next.AgentRunID
merged.AgentStepID = next.AgentStepID
merged.SourceMessageID = next.SourceMessageID merged.SourceMessageID = next.SourceMessageID
merged.WorkflowRunID = next.WorkflowRunID merged.WorkflowRunID = next.WorkflowRunID
merged.WorkflowNodeID = next.WorkflowNodeID merged.WorkflowNodeID = next.WorkflowNodeID
+167
View File
@@ -0,0 +1,167 @@
"use client"
import { useEffect, useState } from "react"
import { AlertTriangleIcon, BotMessageSquareIcon, Clock3Icon, WorkflowIcon, WrenchIcon } from "lucide-react"
import { toast } from "sonner"
import { DashboardListPage } from "@/components/dashboard/list"
import { JsonTreeViewer } from "@/components/json-tree-viewer"
import { OptionCombobox } from "@/components/option-combobox"
import { ProjectDialog } from "@/components/project-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { fetchAgentRun, fetchAgentRunMetrics, fetchAgentRuns, fetchAIWorkflowRun, fetchAgentRunEngineComparisons, saveAgentRunQualityFeedback, type AgentRun, type AgentRunEngineComparison, type AgentRunMetrics, type AgentStep, type AgentToolCall, type AIWorkflowRun } from "@/lib/api/admin"
import { formatDateTime } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
import { WorkflowRunAuditGraph } from "../ai-workflow-runs/_components/workflow-run-audit-graph"
function statusVariant(status: string) {
if (status === "failed") return "destructive" as const
if (status === "interrupted") return "outline" as const
if (status === "completed") return "default" as const
return "secondary" as const
}
export default function DashboardAgentRunsPage() {
const t = useI18n()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [run, setRun] = useState<AgentRun | null>(null)
const [workflowAuditOpen, setWorkflowAuditOpen] = useState(false)
const [workflowAuditLoading, setWorkflowAuditLoading] = useState(false)
const [workflowRun, setWorkflowRun] = useState<AIWorkflowRun | null>(null)
const [metrics, setMetrics] = useState<AgentRunMetrics | null>(null)
const [comparisons, setComparisons] = useState<AgentRunEngineComparison[]>([])
useEffect(() => {
void fetchAgentRunMetrics().then(setMetrics).catch(() => setMetrics(null))
void fetchAgentRunEngineComparisons().then(setComparisons).catch(() => setComparisons([]))
}, [])
async function openDetail(id: number) {
setOpen(true)
setLoading(true)
try {
setRun(await fetchAgentRun(id))
} catch (error) {
toast.error(error instanceof Error ? error.message : t("agentRun.loadDetailFailed"))
setOpen(false)
} finally {
setLoading(false)
}
}
async function openWorkflowAudit(id: number) {
if (id <= 0) return
setWorkflowAuditOpen(true)
setWorkflowAuditLoading(true)
try {
setWorkflowRun(await fetchAIWorkflowRun(id))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 Workflow 节点审计失败")
setWorkflowAuditOpen(false)
} finally {
setWorkflowAuditLoading(false)
}
}
return (
<>
{metrics ? <div className="grid grid-cols-2 gap-px border-b bg-border sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-10">
<Metric label="运行完成率" value={`${Math.round(metrics.completionRate * 100)}%`} detail={`${metrics.completedRuns}/${metrics.totalRuns}`} />
<Metric label="解决率" value={metrics.reviewedRuns ? `${Math.round(metrics.resolutionRate * 100)}%` : "-"} detail={`${metrics.resolvedRuns}/${metrics.reviewedRuns} 已质检`} />
<Metric label="无依据率" value={metrics.reviewedRuns ? `${Math.round(metrics.unsupportedEvidenceRate * 100)}%` : "-"} detail={`${metrics.unsupportedEvidenceRuns}/${metrics.reviewedRuns} 已质检`} />
<Metric label="工具成功率" value={metrics.toolCalls ? `${Math.round(metrics.toolSuccessRate * 100)}%` : "-"} detail={`${metrics.toolCalls} 次调用`} />
<Metric label="平均步骤" value={metrics.averageSteps.toFixed(1)} detail={`${metrics.totalRuns} 次运行`} />
<Metric label="P95 时延" value={`${metrics.p95DurationMs} ms`} detail={`平均 ${metrics.averageDurationMs} ms`} />
<Metric label="Token" value={`${metrics.promptTokens + metrics.completionTokens}`} detail={`${metrics.promptTokens}/${metrics.completionTokens}`} />
<Metric label="转人工率" value={`${Math.round(metrics.handoffRate * 100)}%`} detail="已转人工会话" />
<Metric label="知识兜底率" value={`${Math.round(metrics.knowledgeFallbackRate * 100)}%`} detail="证据不足或检索失败" />
<Metric label="中断恢复率" value={metrics.resumedInterrupts ? `${Math.round(metrics.interruptRecoveryRate * 100)}%` : "-"} detail={`${metrics.resolvedInterrupts}/${metrics.resumedInterrupts}`} />
</div> : null}
{comparisons.length > 0 ? <section className="border-b"><div className="px-4 py-3 text-sm font-medium"></div><div className="overflow-x-auto"><table className="w-full min-w-[760px] text-sm"><thead className="border-y bg-muted/30 text-left text-xs text-muted-foreground"><tr><th className="px-4 py-2 font-medium"></th><th className="px-4 py-2 text-right font-medium"></th><th className="px-4 py-2 text-right font-medium"></th><th className="px-4 py-2 text-right font-medium"></th><th className="px-4 py-2 text-right font-medium"></th><th className="px-4 py-2 text-right font-medium"></th><th className="px-4 py-2 text-right font-medium">P95</th><th className="px-4 py-2 text-right font-medium">Token</th></tr></thead><tbody>{comparisons.map((item) => <tr key={item.engineCode} className="border-b last:border-0"><td className="px-4 py-2 font-medium">{item.engineCode}</td><td className="px-4 py-2 text-right">{item.metrics.totalRuns}</td><td className="px-4 py-2 text-right">{Math.round(item.metrics.completionRate * 100)}%</td><td className="px-4 py-2 text-right">{item.metrics.reviewedRuns ? `${Math.round(item.metrics.resolutionRate * 100)}%` : "-"}</td><td className="px-4 py-2 text-right">{item.metrics.reviewedRuns ? `${Math.round(item.metrics.unsupportedEvidenceRate * 100)}%` : "-"}</td><td className="px-4 py-2 text-right">{item.metrics.toolCalls ? `${Math.round(item.metrics.toolSuccessRate * 100)}%` : "-"}</td><td className="px-4 py-2 text-right">{item.metrics.p95DurationMs} ms</td><td className="px-4 py-2 text-right">{item.metrics.promptTokens + item.metrics.completionTokens}</td></tr>)}</tbody></table></div></section> : null}
<DashboardListPage<AgentRun>
filters={[
{ name: "conversationId", label: t("agentRun.conversation"), defaultValue: "", valueType: "number", className: "w-full sm:w-40" },
{ name: "aiAgentId", label: t("agentRun.agent"), defaultValue: "", valueType: "number", className: "w-full sm:w-40" },
{ name: "engineCode", label: t("agentRun.engine"), defaultValue: "", className: "w-full sm:w-40" },
{ name: "status", label: t("agentRun.status"), defaultValue: "", className: "w-full sm:w-40" },
]}
fetchList={fetchAgentRuns}
getItemId={(item) => item.id}
getRowClassName={() => "cursor-pointer"}
onRowClick={(item) => void openDetail(item.id)}
columns={[
{ key: "startedAt", label: t("agentRun.startedAt"), className: "w-42 text-xs text-muted-foreground", render: (item) => formatDateTime(item.startedAt || item.createdAt) },
{ key: "engine", label: t("agentRun.engine"), className: "w-32", render: (item) => item.engineCode || "-" },
{ key: "agent", label: t("agentRun.agent"), className: "w-28", render: (item) => `#${item.aiAgentId || "-"}` },
{ key: "conversation", label: t("agentRun.conversation"), className: "w-28", render: (item) => `#${item.conversationId || "-"}` },
{ key: "status", label: t("agentRun.status"), className: "w-30", render: (item) => <Badge variant={statusVariant(item.status)}>{item.status || "-"}</Badge> },
{ key: "duration", label: t("agentRun.duration"), className: "w-24 text-right", render: (item) => `${item.durationMs || 0} ms` },
{ key: "tokens", label: t("agentRun.tokens"), className: "w-28 text-right", render: (item) => `${item.promptTokens || 0}/${item.completionTokens || 0}` },
{ key: "error", label: t("agentRun.error"), className: "w-72 max-w-72", render: (item) => item.errorMessage ? <span className="block truncate text-xs text-destructive" title={item.errorMessage}>{item.errorMessage}</span> : "-" },
]}
labels={{ refresh: t("agentRun.refresh"), query: t("agentRun.query"), loading: t("agentRun.loading"), empty: t("agentRun.empty"), loadFailed: t("agentRun.loadFailed") }}
/>
<AgentRunDetailDialog open={open} loading={loading} run={run} onOpenWorkflowAudit={openWorkflowAudit} onQualityFeedbackSaved={(id) => void openDetail(id)} onOpenChange={(next) => { setOpen(next); if (!next) setRun(null) }} t={t} />
<WorkflowAuditDialog open={workflowAuditOpen} loading={workflowAuditLoading} run={workflowRun} onOpenChange={(next) => { setWorkflowAuditOpen(next); if (!next) setWorkflowRun(null) }} />
</>
)
}
function Metric({ label, value, detail }: { label: string; value: string; detail: string }) { return <div className="bg-background px-4 py-3"><div className="text-xs text-muted-foreground">{label}</div><div className="mt-1 text-lg font-semibold">{value}</div><div className="text-xs text-muted-foreground">{detail}</div></div> }
function AgentRunDetailDialog({ open, loading, run, onOpenChange, onOpenWorkflowAudit, onQualityFeedbackSaved, t }: { open: boolean; loading: boolean; run: AgentRun | null; onOpenChange: (open: boolean) => void; onOpenWorkflowAudit: (workflowRunId: number) => void; onQualityFeedbackSaved: (agentRunId: number) => void; t: (key: string) => string }) {
return <ProjectDialog open={open} onOpenChange={onOpenChange} size="xl" allowFullscreen defaultFullscreen title={<span className="flex items-center gap-2"><BotMessageSquareIcon className="size-4" />{t("agentRun.detailTitle")}</span>} description={run ? `Run #${run.id}` : t("agentRun.detailDescription")} footer={<Button variant="outline" onClick={() => onOpenChange(false)}>{t("agentRun.close")}</Button>}>
{loading ? <div className="py-10 text-sm text-muted-foreground">{t("agentRun.loadingDetail")}</div> : run ? <div className="space-y-4">
<div className="flex flex-wrap gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs"><Meta label={t("agentRun.engine")} value={run.engineCode} /><Meta label={t("agentRun.status")} value={run.status} /><Meta label={t("agentRun.agent")} value={`#${run.aiAgentId}`} /><Meta label={t("agentRun.revision")} value={`#${run.agentRevisionId || "-"}`} /><Meta label={t("agentRun.duration")} value={`${run.durationMs || 0} ms`} /><Meta label={t("agentRun.tokens")} value={`${run.promptTokens || 0}/${run.completionTokens || 0}`} /></div>
{run.workflowRunId > 0 ? <section className="flex items-center justify-between gap-3 border px-3 py-2"><div><div className="text-sm font-medium"> Playbook </div><div className="text-xs text-muted-foreground">Workflow Run #{run.workflowRunId} </div></div><Button type="button" variant="outline" size="sm" onClick={() => onOpenWorkflowAudit(run.workflowRunId)}><WorkflowIcon /></Button></section> : null}
<QualityFeedbackPanel run={run} onSaved={onQualityFeedbackSaved} />
{run.errorMessage ? <div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangleIcon className="size-4 shrink-0" />{run.errorMessage}</div> : null}
<Preview title={t("agentRun.trace")} raw={run.traceData} />
<section className="space-y-2"><h3 className="text-sm font-medium">{t("agentRun.steps")}</h3>{(run.steps ?? []).map((step) => <StepBlock key={step.id} step={step} t={t} />)}{!run.steps?.length ? <p className="text-sm text-muted-foreground">{t("agentRun.emptySteps")}</p> : null}</section>
<section className="space-y-2"><h3 className="text-sm font-medium">{t("agentRun.toolCalls")}</h3>{(run.toolCalls ?? []).map((call) => <ToolCallBlock key={call.id} call={call} t={t} />)}{!run.toolCalls?.length ? <p className="text-sm text-muted-foreground">{t("agentRun.emptyToolCalls")}</p> : null}</section>
</div> : <div className="py-10 text-sm text-muted-foreground">{t("agentRun.notFound")}</div>}
</ProjectDialog>
}
function QualityFeedbackPanel({ run, onSaved }: { run: AgentRun; onSaved: (agentRunId: number) => void }) {
const [resolutionStatus, setResolutionStatus] = useState<"unknown" | "resolved" | "unresolved">("unknown")
const [evidenceStatus, setEvidenceStatus] = useState<"unknown" | "supported" | "unsupported">("unknown")
const [comment, setComment] = useState("")
const [saving, setSaving] = useState(false)
useEffect(() => {
setResolutionStatus(run.qualityFeedback?.resolutionStatus ?? "unknown")
setEvidenceStatus(run.qualityFeedback?.evidenceStatus ?? "unknown")
setComment(run.qualityFeedback?.comment ?? "")
}, [run.id, run.qualityFeedback])
async function save() {
setSaving(true)
try {
await saveAgentRunQualityFeedback({ agentRunId: run.id, resolutionStatus, evidenceStatus, comment })
toast.success("质检结果已保存")
onSaved(run.id)
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存质检结果失败")
} finally {
setSaving(false)
}
}
return <section className="space-y-3 border p-3"><div><h3 className="text-sm font-medium"></h3><p className="text-xs text-muted-foreground"></p></div><div className="grid gap-3 sm:grid-cols-2"><OptionCombobox value={resolutionStatus} placeholder="选择解决情况" options={[{ value: "unknown", label: "解决情况:未判断" }, { value: "resolved", label: "解决情况:已解决" }, { value: "unresolved", label: "解决情况:未解决" }]} onChange={(value) => setResolutionStatus(value === "resolved" || value === "unresolved" ? value : "unknown")} /><OptionCombobox value={evidenceStatus} placeholder="选择依据情况" options={[{ value: "unknown", label: "依据情况:未判断" }, { value: "supported", label: "依据情况:有依据" }, { value: "unsupported", label: "依据情况:无依据" }]} onChange={(value) => setEvidenceStatus(value === "supported" || value === "unsupported" ? value : "unknown")} /></div><Textarea rows={3} value={comment} onChange={(event) => setComment(event.target.value)} placeholder="质检备注" /><div className="flex items-center justify-between gap-3"><span className="text-xs text-muted-foreground">{run.qualityFeedback?.updatedAt ? `最近标注:${run.qualityFeedback.updatedAt}` : "尚未标注"}</span><Button type="button" size="sm" disabled={saving} onClick={save}></Button></div></section>
}
function WorkflowAuditDialog({ open, loading, run, onOpenChange }: { open: boolean; loading: boolean; run: AIWorkflowRun | null; onOpenChange: (open: boolean) => void }) {
return <ProjectDialog open={open} onOpenChange={onOpenChange} size="xl" allowFullscreen defaultFullscreen title={<span className="flex items-center gap-2"><WorkflowIcon className="size-4" />Workflow </span>} description={run ? `Workflow Run #${run.id}` : "加载关联 Playbook 的节点审计"} footer={<Button variant="outline" onClick={() => onOpenChange(false)}></Button>}>
{loading ? <div className="py-10 text-sm text-muted-foreground">...</div> : run ? <div className="space-y-3"><div className="flex flex-wrap gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs"><Meta label="状态" value={run.statusName} /><Meta label="Workflow" value={run.workflowName || `#${run.workflowId}`} /><Meta label="版本" value={`v${run.workflowVersion || "-"}`} /><Meta label="时延" value={`${run.durationMs || 0} ms`} /></div>{run.errorMessage ? <div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangleIcon className="size-4 shrink-0" />{run.errorMessage}</div> : null}<WorkflowRunAuditGraph run={run} /></div> : <div className="py-10 text-sm text-muted-foreground"> Workflow Run</div>}
</ProjectDialog>
}
function Meta({ label, value }: { label: string; value: string }) { return <span className="inline-flex items-center gap-1 rounded-md border bg-background px-2 py-1"><span className="text-muted-foreground">{label}</span><span className="font-medium">{value || "-"}</span></span> }
function StepBlock({ step, t }: { step: AgentStep; t: (key: string) => string }) { return <div className="rounded-md border p-3"><div className="flex flex-wrap items-center gap-2"><Clock3Icon className="size-4 text-muted-foreground" /><span className="font-medium">{step.stepCode || step.stepType}</span><Badge variant={statusVariant(step.status)}>{step.status}</Badge><span className="text-xs text-muted-foreground">{step.durationMs || 0} ms</span></div>{step.errorMessage ? <p className="mt-2 text-xs text-destructive">{step.errorMessage}</p> : null}<div className="mt-3 grid gap-3 lg:grid-cols-2"><Preview title={t("agentRun.input")} raw={step.inputPreview} /><Preview title={t("agentRun.output")} raw={step.outputPreview} /></div></div> }
function ToolCallBlock({ call, t }: { call: AgentToolCall; t: (key: string) => string }) { return <div className="rounded-md border p-3"><div className="flex flex-wrap items-center gap-2"><WrenchIcon className="size-4 text-muted-foreground" /><span className="font-medium">{call.toolCode}</span><Badge variant={statusVariant(call.status)}>{call.status}</Badge><span className="text-xs text-muted-foreground">{call.riskLevel}</span></div>{call.errorMessage ? <p className="mt-2 text-xs text-destructive">{call.errorMessage}</p> : null}<div className="mt-3 grid gap-3 lg:grid-cols-2"><Preview title={t("agentRun.arguments")} raw={call.argumentsPreview} /><Preview title={t("agentRun.result")} raw={call.resultPreview} /></div></div> }
function Preview({ title, raw }: { title: string; raw: string }) { const value = parseJSON(raw); return <div className="min-w-0"><div className="mb-1 text-xs text-muted-foreground">{title}</div>{value !== null ? <JsonTreeViewer value={value} collapsed={2} /> : raw?.trim() ? <pre className="max-h-52 overflow-auto rounded-md border bg-muted/20 p-2 text-xs whitespace-pre-wrap break-all">{raw}</pre> : <div className="rounded-md border bg-muted/20 px-2 py-1.5 text-xs text-muted-foreground">-</div>}</div> }
function parseJSON(raw: string): unknown | null { try { return raw?.trim() ? JSON.parse(raw) : null } catch { return null } }
@@ -6,6 +6,7 @@ import {
GitBranchIcon, GitBranchIcon,
HistoryIcon, HistoryIcon,
PlugIcon, PlugIcon,
RotateCcwIcon,
SaveIcon, SaveIcon,
SettingsIcon, SettingsIcon,
Trash2Icon, Trash2Icon,
@@ -36,25 +37,34 @@ import { Textarea } from "@/components/ui/textarea"
import { import {
createAIAgent, createAIAgent,
fetchAIAgent, fetchAIAgent,
fetchAIAgentRevisions,
fetchAIAgentWorkflow, fetchAIAgentWorkflow,
fetchAIConfigsAll, fetchAIConfigsAll,
fetchKnowledgeBasesAll,
fetchAIWorkflowDefaultDefinition, fetchAIWorkflowDefaultDefinition,
fetchAIWorkflowNodeSpecs, fetchAIWorkflowNodeSpecs,
fetchAIWorkflowTemplates,
fetchAIWorkflowVersions, fetchAIWorkflowVersions,
fetchAgentTeamsAll, fetchAgentTeamsAll,
fetchMCPCatalog, fetchMCPCatalog,
fetchSkillDefinitionsAll, fetchSkillDefinitionsAll,
publishAIAgentWorkflow, publishAIAgentWorkflow,
publishAIAgent,
rollbackAIAgent,
rollbackAIAgentRollout,
saveAIAgentWorkflow, saveAIAgentWorkflow,
updateAIAgent, updateAIAgent,
validateAIWorkflow, validateAIWorkflow,
type AIAgent, type AIAgent,
type AgentRevision,
type AIConfig, type AIConfig,
type AIWorkflowDefinition, type AIWorkflowDefinition,
type AIWorkflowNodeSpec, type AIWorkflowNodeSpec,
type AIWorkflowTemplate,
type AIWorkflowVersion, type AIWorkflowVersion,
type AdminAgentTeam, type AdminAgentTeam,
type CreateAIAgentPayload, type CreateAIAgentPayload,
type KnowledgeBase,
type MCPToolCatalogItem, type MCPToolCatalogItem,
type MCPToolSourceType, type MCPToolSourceType,
type SkillDefinition, type SkillDefinition,
@@ -128,6 +138,7 @@ export function AIAgentConfigWorkbench({
const [activeSection, setActiveSection] = useState<SectionKey>("basic") const [activeSection, setActiveSection] = useState<SectionKey>("basic")
const [agent, setAgent] = useState<AIAgent | null>(null) const [agent, setAgent] = useState<AIAgent | null>(null)
const [workflowVersions, setWorkflowVersions] = useState<AIWorkflowVersion[]>([]) const [workflowVersions, setWorkflowVersions] = useState<AIWorkflowVersion[]>([])
const [agentRevisions, setAgentRevisions] = useState<AgentRevision[]>([])
const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([]) const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [savingAgent, setSavingAgent] = useState(false) const [savingAgent, setSavingAgent] = useState(false)
@@ -137,28 +148,36 @@ export function AIAgentConfigWorkbench({
const [name, setName] = useState("") const [name, setName] = useState("")
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [aiConfigId, setAIConfigId] = useState("") const [aiConfigId, setAIConfigId] = useState("")
const [runtimeMode, setRuntimeMode] = useState<"workflow" | "autonomous" | "hybrid">("autonomous")
const [serviceMode, setServiceMode] = useState(String(IMConversationServiceMode.AIFirst)) const [serviceMode, setServiceMode] = useState(String(IMConversationServiceMode.AIFirst))
const [systemPrompt, setSystemPrompt] = useState("") const [systemPrompt, setSystemPrompt] = useState("")
const [welcomeMessage, setWelcomeMessage] = useState("") const [welcomeMessage, setWelcomeMessage] = useState("")
const [replyTimeoutSeconds, setReplyTimeoutSeconds] = useState("180") const [replyTimeoutSeconds, setReplyTimeoutSeconds] = useState("180")
const [rolloutPercent, setRolloutPercent] = useState("5")
const [handoffMode, setHandoffMode] = useState(String(AIAgentHandoffMode.WaitPool)) const [handoffMode, setHandoffMode] = useState(String(AIAgentHandoffMode.WaitPool))
const [fallbackMode, setFallbackMode] = useState(String(AIAgentFallbackMode.NoAnswer)) const [fallbackMode, setFallbackMode] = useState(String(AIAgentFallbackMode.NoAnswer))
const [fallbackMessage, setFallbackMessage] = useState("") const [fallbackMessage, setFallbackMessage] = useState("")
const [selectedTeamIds, setSelectedTeamIds] = useState<number[]>([]) const [selectedTeamIds, setSelectedTeamIds] = useState<number[]>([])
const [selectedSkillIds, setSelectedSkillIds] = useState<number[]>([]) const [selectedSkillIds, setSelectedSkillIds] = useState<number[]>([])
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<number[]>([])
const [directTools, setDirectTools] = useState<DirectToolItem[]>([]) const [directTools, setDirectTools] = useState<DirectToolItem[]>([])
const [definition, setDefinition] = useState<AIWorkflowDefinition>(fallbackDefinition) const [definition, setDefinition] = useState<AIWorkflowDefinition>(fallbackDefinition)
const [workflowRevision, setWorkflowRevision] = useState(0) const [workflowRevision, setWorkflowRevision] = useState(0)
const [workflowTemplates, setWorkflowTemplates] = useState<AIWorkflowTemplate[]>([])
const [selectedWorkflowTemplate, setSelectedWorkflowTemplate] = useState("")
const [aiConfigs, setAIConfigs] = useState<AIConfig[]>([]) const [aiConfigs, setAIConfigs] = useState<AIConfig[]>([])
const [agentTeams, setAgentTeams] = useState<AdminAgentTeam[]>([]) const [agentTeams, setAgentTeams] = useState<AdminAgentTeam[]>([])
const [skills, setSkills] = useState<SkillDefinition[]>([]) const [skills, setSkills] = useState<SkillDefinition[]>([])
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([])
const [toolCatalog, setToolCatalog] = useState<MCPToolCatalogItem[]>([]) const [toolCatalog, setToolCatalog] = useState<MCPToolCatalogItem[]>([])
const [teamToAdd, setTeamToAdd] = useState("") const [teamToAdd, setTeamToAdd] = useState("")
const [skillToAdd, setSkillToAdd] = useState("") const [skillToAdd, setSkillToAdd] = useState("")
const [knowledgeBaseToAdd, setKnowledgeBaseToAdd] = useState("")
const [directToolGroupToAdd, setDirectToolGroupToAdd] = useState("") const [directToolGroupToAdd, setDirectToolGroupToAdd] = useState("")
const [directToolToAdd, setDirectToolToAdd] = useState("") const [directToolToAdd, setDirectToolToAdd] = useState("")
const previousRolloutPercent = agent?.previousRolloutPercent ?? 0
useEffect(() => { useEffect(() => {
setCurrentAgentId(agentId ?? null) setCurrentAgentId(agentId ?? null)
@@ -175,51 +194,63 @@ export function AIAgentConfigWorkbench({
const [ const [
specs, specs,
defaultDefinition, defaultDefinition,
templates,
configs, configs,
teams, teams,
skillList, skillList,
knowledgeBaseList,
catalog, catalog,
] = await Promise.all([ ] = await Promise.all([
fetchAIWorkflowNodeSpecs(), fetchAIWorkflowNodeSpecs(),
fetchAIWorkflowDefaultDefinition().catch(() => fallbackDefinition), fetchAIWorkflowDefaultDefinition().catch(() => fallbackDefinition),
fetchAIWorkflowTemplates(),
fetchAIConfigsAll({ modelType: AIModelType.LLM }), fetchAIConfigsAll({ modelType: AIModelType.LLM }),
fetchAgentTeamsAll(), fetchAgentTeamsAll(),
fetchSkillDefinitionsAll({ status: Status.Ok }), fetchSkillDefinitionsAll({ status: Status.Ok }),
fetchKnowledgeBasesAll({ status: Status.Ok }),
fetchMCPCatalog(), fetchMCPCatalog(),
]) ])
setNodeSpecs(specs ?? []) setNodeSpecs(specs ?? [])
setWorkflowTemplates(templates ?? [])
setAIConfigs(configs ?? []) setAIConfigs(configs ?? [])
setAgentTeams(teams ?? []) setAgentTeams(teams ?? [])
setSkills(skillList ?? []) setSkills(skillList ?? [])
setKnowledgeBases(knowledgeBaseList ?? [])
setToolCatalog(catalog ?? []) setToolCatalog(catalog ?? [])
if (!currentAgentId || currentAgentId <= 0) { if (!currentAgentId || currentAgentId <= 0) {
setAgent(null) setAgent(null)
setWorkflowVersions([]) setWorkflowVersions([])
setAgentRevisions([])
setName("") setName("")
setDescription("") setDescription("")
setAIConfigId("") setAIConfigId("")
setRuntimeMode("autonomous")
setServiceMode(String(IMConversationServiceMode.AIFirst)) setServiceMode(String(IMConversationServiceMode.AIFirst))
setSystemPrompt("") setSystemPrompt("")
setWelcomeMessage("") setWelcomeMessage("")
setReplyTimeoutSeconds("180") setReplyTimeoutSeconds("180")
setRolloutPercent("5")
setHandoffMode(String(AIAgentHandoffMode.WaitPool)) setHandoffMode(String(AIAgentHandoffMode.WaitPool))
setFallbackMode(String(AIAgentFallbackMode.NoAnswer)) setFallbackMode(String(AIAgentFallbackMode.NoAnswer))
setFallbackMessage("") setFallbackMessage("")
setSelectedTeamIds([]) setSelectedTeamIds([])
setSelectedSkillIds([]) setSelectedSkillIds([])
setSelectedKnowledgeBaseIds([])
setDirectTools([]) setDirectTools([])
replaceWorkflowDefinition(defaultDefinition ?? fallbackDefinition) replaceWorkflowDefinition(defaultDefinition ?? fallbackDefinition)
return return
} }
const [agentDetail, workflowDetail] = await Promise.all([ const [agentDetail, workflowDetail, revisionList] = await Promise.all([
fetchAIAgent(currentAgentId), fetchAIAgent(currentAgentId),
fetchAIAgentWorkflow(currentAgentId), fetchAIAgentWorkflow(currentAgentId),
fetchAIAgentRevisions(currentAgentId),
]) ])
setAgent(agentDetail) setAgent(agentDetail)
setAgentRevisions(revisionList ?? [])
if (workflowDetail?.id > 0) { if (workflowDetail?.id > 0) {
const versionPage = await fetchAIWorkflowVersions({ workflowId: workflowDetail.id, limit: 20 }) const versionPage = await fetchAIWorkflowVersions({ workflowId: workflowDetail.id, limit: 20 })
setWorkflowVersions(versionPage.results ?? []) setWorkflowVersions(versionPage.results ?? [])
@@ -228,16 +259,19 @@ export function AIAgentConfigWorkbench({
} }
setName(agentDetail.name) setName(agentDetail.name)
setDescription(agentDetail.description || "") setDescription(agentDetail.description || "")
setAIConfigId(toText(agentDetail.aiConfigId)) setAIConfigId(toText(agentDetail.aiConfigId))
setRuntimeMode(agentDetail.runtimeMode === "autonomous" || agentDetail.runtimeMode === "hybrid" ? agentDetail.runtimeMode : "workflow")
setServiceMode(String(agentDetail.serviceMode || IMConversationServiceMode.AIFirst)) setServiceMode(String(agentDetail.serviceMode || IMConversationServiceMode.AIFirst))
setSystemPrompt(agentDetail.systemPrompt || "") setSystemPrompt(agentDetail.systemPrompt || "")
setWelcomeMessage(agentDetail.welcomeMessage || "") setWelcomeMessage(agentDetail.welcomeMessage || "")
setReplyTimeoutSeconds(String(agentDetail.replyTimeoutSeconds ?? 180)) setReplyTimeoutSeconds(String(agentDetail.replyTimeoutSeconds ?? 180))
setRolloutPercent(String(agentDetail.rolloutPercent || 100))
setHandoffMode(String(agentDetail.handoffMode || AIAgentHandoffMode.WaitPool)) setHandoffMode(String(agentDetail.handoffMode || AIAgentHandoffMode.WaitPool))
setFallbackMode(String(agentDetail.fallbackMode || AIAgentFallbackMode.NoAnswer)) setFallbackMode(String(agentDetail.fallbackMode || AIAgentFallbackMode.NoAnswer))
setFallbackMessage(agentDetail.fallbackMessage || "") setFallbackMessage(agentDetail.fallbackMessage || "")
setSelectedTeamIds((agentDetail.teams ?? []).map((team) => team.id)) setSelectedTeamIds((agentDetail.teams ?? []).map((team) => team.id))
setSelectedSkillIds(agentDetail.skillIds ?? []) setSelectedSkillIds(agentDetail.skillIds ?? [])
setSelectedKnowledgeBaseIds(agentDetail.knowledgeBaseIds ?? [])
setDirectTools(agentDetail.directTools ?? []) setDirectTools(agentDetail.directTools ?? [])
replaceWorkflowDefinition(workflowDetail.draftDefinition ?? defaultDefinition ?? fallbackDefinition) replaceWorkflowDefinition(workflowDetail.draftDefinition ?? defaultDefinition ?? fallbackDefinition)
} catch (error) { } catch (error) {
@@ -259,6 +293,14 @@ export function AIAgentConfigWorkbench({
], ],
[] []
) )
const runtimeModeOptions = useMemo(
() => [
{ value: "autonomous", label: "自主接待" },
{ value: "hybrid", label: "自主接待 + 流程" },
{ value: "workflow", label: "高级编排 / Playbooks" },
],
[]
)
const handoffModeOptions = useMemo( const handoffModeOptions = useMemo(
() => [ () => [
{ value: String(AIAgentHandoffMode.WaitPool), label: "进入待接入池" }, { value: String(AIAgentHandoffMode.WaitPool), label: "进入待接入池" },
@@ -271,6 +313,7 @@ export function AIAgentConfigWorkbench({
() => [ () => [
{ value: String(AIAgentFallbackMode.NoAnswer), label: "直接说明知识不足" }, { value: String(AIAgentFallbackMode.NoAnswer), label: "直接说明知识不足" },
{ value: String(AIAgentFallbackMode.SuggestRetry), label: "引导用户补充信息" }, { value: String(AIAgentFallbackMode.SuggestRetry), label: "引导用户补充信息" },
{ value: String(AIAgentFallbackMode.Handoff), label: "转人工客服" },
], ],
[] []
) )
@@ -286,10 +329,18 @@ export function AIAgentConfigWorkbench({
() => skills.map((item) => ({ value: String(item.id), label: item.name })), () => skills.map((item) => ({ value: String(item.id), label: item.name })),
[skills] [skills]
) )
const knowledgeBaseOptions = useMemo(
() => knowledgeBases.map((item) => ({ value: String(item.id), label: item.name })),
[knowledgeBases]
)
const directToolOptions = useMemo<DirectToolOption[]>( const directToolOptions = useMemo<DirectToolOption[]>(
() => () =>
toolCatalog toolCatalog
.filter((tool) => !tool.autoInjected && tool.sourceType === "mcp") .filter(
(tool) =>
!tool.autoInjected &&
(tool.sourceType === "mcp" || tool.toolCode === "builtin/conversation_context" || tool.toolCode === "graph/prepare_ticket_draft")
)
.map((tool) => ({ .map((tool) => ({
value: tool.toolCode, value: tool.toolCode,
label: `${tool.title || tool.toolName} · ${tool.toolCode}`, label: `${tool.title || tool.toolName} · ${tool.toolCode}`,
@@ -356,14 +407,17 @@ export function AIAgentConfigWorkbench({
name: name.trim(), name: name.trim(),
description: description.trim(), description: description.trim(),
aiConfigId: Number(aiConfigId), aiConfigId: Number(aiConfigId),
runtimeMode,
serviceMode: Number(serviceMode), serviceMode: Number(serviceMode),
systemPrompt: systemPrompt.trim(), systemPrompt: systemPrompt.trim(),
welcomeMessage: welcomeMessage.trim(), welcomeMessage: welcomeMessage.trim(),
replyTimeoutSeconds: Number(replyTimeoutSeconds), replyTimeoutSeconds: Number(replyTimeoutSeconds),
rolloutPercent: Number(rolloutPercent),
teamIds: uniqueNumbers(selectedTeamIds), teamIds: uniqueNumbers(selectedTeamIds),
handoffMode: Number(handoffMode), handoffMode: Number(handoffMode),
fallbackMode: Number(fallbackMode), fallbackMode: Number(fallbackMode),
fallbackMessage: fallbackMessage.trim(), fallbackMessage: fallbackMessage.trim(),
knowledgeBaseIds: uniqueNumbers(selectedKnowledgeBaseIds),
skillIds: uniqueNumbers(selectedSkillIds), skillIds: uniqueNumbers(selectedSkillIds),
directTools, directTools,
} }
@@ -392,6 +446,20 @@ export function AIAgentConfigWorkbench({
} }
} }
async function publishAutonomousAgent() {
if (!agent || runtimeMode !== "autonomous") return
setSavingAgent(true)
try {
await publishAIAgent(agent.id)
await loadData()
toast.success("Autonomous Agent published")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to publish Autonomous Agent")
} finally {
setSavingAgent(false)
}
}
async function saveWorkflowDraft() { async function saveWorkflowDraft() {
if (!currentAgentId) return if (!currentAgentId) return
setSavingWorkflow(true) setSavingWorkflow(true)
@@ -410,6 +478,36 @@ export function AIAgentConfigWorkbench({
} }
} }
async function rollbackAgentRevision(revisionId: number) {
if (!agent || revisionId <= 0 || revisionId === agent.publishedRevisionId) return
setSavingAgent(true)
try {
await rollbackAIAgent(agent.id, revisionId)
toast.success("已回滚到选中的 Agent 版本")
await loadData()
onAgentSaved?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : "回滚 Agent 版本失败")
} finally {
setSavingAgent(false)
}
}
async function rollbackAgentRollout() {
if (!agent || agent.previousRolloutPercent < 1) return
setSavingAgent(true)
try {
await rollbackAIAgentRollout(agent.id)
toast.success("已恢复上一次灰度比例")
await loadData()
onAgentSaved?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复灰度比例失败")
} finally {
setSavingAgent(false)
}
}
async function validateWorkflowDraft() { async function validateWorkflowDraft() {
setSavingWorkflow(true) setSavingWorkflow(true)
try { try {
@@ -438,6 +536,13 @@ export function AIAgentConfigWorkbench({
} }
} }
function applySelectedWorkflowTemplate() {
const template = workflowTemplates.find((item) => item.code === selectedWorkflowTemplate)
if (!template) return
replaceWorkflowDefinition(template.definition)
toast.success(`已应用 ${template.name} 模板,保存草稿或发布后生效`)
}
async function publishWorkflow() { async function publishWorkflow() {
if (!currentAgentId) return if (!currentAgentId) return
setSavingWorkflow(true) setSavingWorkflow(true)
@@ -475,13 +580,16 @@ export function AIAgentConfigWorkbench({
const sections: { key: SectionKey; title: string; icon: ReactNode }[] = [ const sections: { key: SectionKey; title: string; icon: ReactNode }[] = [
{ key: "basic", title: "基础信息", icon: <SettingsIcon /> }, { key: "basic", title: "基础信息", icon: <SettingsIcon /> },
{ key: "capabilities", title: "能力来源", icon: <PlugIcon /> }, { key: "capabilities", title: "能力来源", icon: <PlugIcon /> },
{ key: "workflow", title: "会话流程", icon: <GitBranchIcon /> }, { key: "workflow", title: "高级编排 / Playbooks", icon: <GitBranchIcon /> },
] ]
const selectedTeamOptions = selectedOptions(selectedTeamIds, teamOptions) const selectedTeamOptions = selectedOptions(selectedTeamIds, teamOptions)
const selectedSkillOptions = selectedOptions(selectedSkillIds, skillOptions) const selectedSkillOptions = selectedOptions(selectedSkillIds, skillOptions)
const workflowPublished = isWorkflowPublished(agent) const workflowPublished = isWorkflowPublished(agent)
const workflowStateText = const autonomousPublished = runtimeMode === "autonomous" && (agent?.publishedRevisionId ?? 0) > 0
const hybridPublished = runtimeMode === "hybrid" && workflowPublished && (agent?.publishedRevisionId ?? 0) > 0
const runtimePublished = runtimeMode === "workflow" ? workflowPublished : runtimeMode === "hybrid" ? hybridPublished : autonomousPublished
const workflowStateText =
agent?.workflowStateText || (workflowPublished ? "已发布" : "未发布") agent?.workflowStateText || (workflowPublished ? "已发布" : "未发布")
return ( return (
@@ -494,8 +602,8 @@ export function AIAgentConfigWorkbench({
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<h1 className="truncate text-base font-semibold">{agent?.name ?? "新建 AI Agent"}</h1> <h1 className="truncate text-base font-semibold">{agent?.name ?? "新建 AI Agent"}</h1>
{agent?.statusName ? <Badge variant="secondary">{agent.statusName}</Badge> : null} {agent?.statusName ? <Badge variant="secondary">{agent.statusName}</Badge> : null}
<Badge variant={workflowPublished ? "default" : "outline"}> <Badge variant={runtimePublished ? "default" : "outline"}>
{workflowStateText} {runtimeMode === "autonomous" ? (autonomousPublished ? "已发布" : "未发布") : runtimeMode === "hybrid" ? (hybridPublished ? "已发布" : "未发布") : workflowStateText}
</Badge> </Badge>
{workflowPublished ? ( {workflowPublished ? (
<Badge variant="secondary"> #{agent?.workflowVersionId}</Badge> <Badge variant="secondary"> #{agent?.workflowVersionId}</Badge>
@@ -507,6 +615,7 @@ export function AIAgentConfigWorkbench({
null null
) : ( ) : (
<> <>
{agent && runtimeMode === "autonomous" ? <Button type="button" variant="outline" disabled={savingAgent || loading} onClick={publishAutonomousAgent}> Agent</Button> : null}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -522,9 +631,9 @@ export function AIAgentConfigWorkbench({
</div> </div>
<div className="flex min-h-0 flex-1 flex-col bg-background"> <div className="flex min-h-0 flex-1 flex-col bg-background">
{agent && !workflowPublished ? ( {agent && !runtimePublished ? (
<div className="shrink-0 border-b border-amber-200 bg-amber-50 px-5 py-2 text-sm text-amber-900"> <div className="shrink-0 border-b border-amber-200 bg-amber-50 px-5 py-2 text-sm text-amber-900">
AI {runtimeMode === "autonomous" ? "未发布 Agent,AI 不会自动回复。保存配置后发布 Agent,再绑定渠道或启用自动回复。" : runtimeMode === "hybrid" ? "未发布 Hybrid Agent,AI 不会自动回复。保存配置后请进入“高级编排 / Playbooks”发布一个版本。" : "未发布 Playbook,AI 不会自动回复。保存配置后请进入“高级编排 / Playbooks”发布一个版本,再绑定渠道或启用自动回复。"}
</div> </div>
) : null} ) : null}
<div className="shrink-0 border-b bg-muted/30 px-4 py-2"> <div className="shrink-0 border-b bg-muted/30 px-4 py-2">
@@ -597,6 +706,9 @@ export function AIAgentConfigWorkbench({
onChange={setAIConfigId} onChange={setAIConfigId}
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="运行模式">
<OptionCombobox value={runtimeMode} options={runtimeModeOptions} placeholder="选择运行模式" onChange={(value) => setRuntimeMode(value === "autonomous" || value === "hybrid" ? value : "workflow")} />
</FieldBlock>
<FieldBlock label="回复超时秒数"> <FieldBlock label="回复超时秒数">
<Input <Input
type="number" type="number"
@@ -606,6 +718,17 @@ export function AIAgentConfigWorkbench({
onChange={(event) => setReplyTimeoutSeconds(event.target.value)} onChange={(event) => setReplyTimeoutSeconds(event.target.value)}
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="会话灰度比例(%">
<div className="flex items-center gap-2">
<Input type="number" min={1} max={100} step={1} value={rolloutPercent} onChange={(event) => setRolloutPercent(event.target.value)} />
{previousRolloutPercent > 0 ? (
<Button type="button" variant="outline" size="sm" disabled={savingAgent} onClick={rollbackAgentRollout}>
<RotateCcwIcon />
{previousRolloutPercent}%
</Button>
) : null}
</div>
</FieldBlock>
</div> </div>
<FieldBlock label="系统提示词"> <FieldBlock label="系统提示词">
<ContentEditor <ContentEditor
@@ -657,6 +780,23 @@ export function AIAgentConfigWorkbench({
{activeSection === "capabilities" ? ( {activeSection === "capabilities" ? (
<ConfigSection> <ConfigSection>
<div className="text-sm font-medium"></div>
<AddRow
value={knowledgeBaseToAdd}
options={knowledgeBaseOptions.filter((option) => !selectedKnowledgeBaseIds.includes(Number(option.value)))}
placeholder="选择知识库"
onValueChange={setKnowledgeBaseToAdd}
onAdd={() => {
addSelected(knowledgeBaseToAdd, selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds)
setKnowledgeBaseToAdd("")
}}
/>
<BadgeList empty="未配置知识库。" items={selectedOptions(selectedKnowledgeBaseIds, knowledgeBaseOptions)} onRemove={(id) => setSelectedKnowledgeBaseIds((current) => current.filter((item) => item !== id))} />
</ConfigSection>
) : null}
{activeSection === "capabilities" ? (
<ConfigSection>
<AddRow <AddRow
value={skillToAdd} value={skillToAdd}
options={skillOptions.filter((option) => !selectedSkillIds.includes(Number(option.value)))} options={skillOptions.filter((option) => !selectedSkillIds.includes(Number(option.value)))}
@@ -730,7 +870,19 @@ export function AIAgentConfigWorkbench({
) : null} ) : null}
{activeSection === "workflow" ? ( {activeSection === "workflow" ? (
<WorkflowEditor <div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2">
<OptionCombobox
value={selectedWorkflowTemplate}
options={workflowTemplates.map((item) => ({ value: item.code, label: item.name }))}
placeholder="选择 Playbook 模板"
onChange={setSelectedWorkflowTemplate}
/>
<Button type="button" variant="outline" size="sm" disabled={!selectedWorkflowTemplate || savingWorkflow || loading} onClick={applySelectedWorkflowTemplate}>
</Button>
</div>
<WorkflowEditor
key={workflowRevision} key={workflowRevision}
definition={definition} definition={definition}
nodeSpecs={nodeSpecs} nodeSpecs={nodeSpecs}
@@ -756,7 +908,8 @@ export function AIAgentConfigWorkbench({
</Button> </Button>
} }
/> />
</div>
) : null} ) : null}
<Dialog open={versionDialogOpen} onOpenChange={setVersionDialogOpen}> <Dialog open={versionDialogOpen} onOpenChange={setVersionDialogOpen}>
@@ -767,6 +920,9 @@ export function AIAgentConfigWorkbench({
<VersionRecordsTable <VersionRecordsTable
agent={agent} agent={agent}
workflowVersions={workflowVersions} workflowVersions={workflowVersions}
agentRevisions={agentRevisions}
onRollback={rollbackAgentRevision}
rollbackDisabled={savingAgent || loading}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -828,12 +984,52 @@ function AddRow({
function VersionRecordsTable({ function VersionRecordsTable({
agent, agent,
workflowVersions, workflowVersions,
agentRevisions,
onRollback,
rollbackDisabled,
}: { }: {
agent: AIAgent | null agent: AIAgent | null
workflowVersions: AIWorkflowVersion[] workflowVersions: AIWorkflowVersion[]
agentRevisions: AgentRevision[]
onRollback: (revisionId: number) => void
rollbackDisabled: boolean
}) { }) {
return ( return (
<div className="max-h-[60vh] overflow-auto rounded-md border"> <div className="max-h-[60vh] space-y-4 overflow-auto">
<div className="rounded-md border">
<div className="border-b px-3 py-2 text-sm font-medium">Agent </div>
{agentRevisions.length > 0 ? (
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead className="w-28"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{agentRevisions.map((revision) => {
const active = agent?.publishedRevisionId === revision.id
return (
<TableRow key={revision.id}>
<TableCell className="font-medium">r{revision.revision}{active ? <Badge variant="secondary" className="ml-2"></Badge> : null}</TableCell>
<TableCell className="text-muted-foreground">{revision.publishedAt || "-"}</TableCell>
<TableCell>{revision.publishedByName || "-"}</TableCell>
<TableCell>{revision.workflowVersionId > 0 ? `#${revision.workflowVersionId}` : "-"}</TableCell>
<TableCell className="text-right">
<Button type="button" variant="outline" size="sm" disabled={active || rollbackDisabled} onClick={() => onRollback(revision.id)}></Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
) : <div className="p-4 text-sm text-muted-foreground"> Agent </div>}
</div>
<div className="rounded-md border">
<div className="border-b px-3 py-2 text-sm font-medium"></div>
{workflowVersions.length > 0 ? ( {workflowVersions.length > 0 ? (
<Table> <Table>
<TableHeader className="bg-muted/40"> <TableHeader className="bg-muted/40">
@@ -875,6 +1071,7 @@ function VersionRecordsTable({
) : ( ) : (
<div className="p-4 text-sm text-muted-foreground"></div> <div className="p-4 text-sm text-muted-foreground"></div>
)} )}
</div>
</div> </div>
) )
} }
+2 -2
View File
@@ -122,7 +122,7 @@ export default function DashboardAIAgentsPage() {
}, },
{ {
key: "workflow", key: "workflow",
label: "流程状态", label: "Playbook 状态",
render: (item) => { render: (item) => {
const published = isWorkflowPublished(item); const published = isWorkflowPublished(item);
return ( return (
@@ -139,7 +139,7 @@ export default function DashboardAIAgentsPage() {
</div> </div>
{!published ? ( {!published ? (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
AI PlaybookAI
</div> </div>
) : ( ) : (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
+61 -11
View File
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, Resolver, useForm, useWatch } from "react-hook-form" import { Controller, Resolver, useForm, useWatch } from "react-hook-form"
import { z } from "zod/v4" import { z } from "zod/v4"
import { CopyIcon, ExternalLinkIcon } from "lucide-react" import { CopyIcon, ExternalLinkIcon, RotateCcwIcon } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { getWidgetDemoPath } from "@/components/support-chat/demo-navigation" import { getWidgetDemoPath } from "@/components/support-chat/demo-navigation"
@@ -28,6 +28,7 @@ import {
fetchAIAgentsAll, fetchAIAgentsAll,
fetchChannel, fetchChannel,
fetchWxWorkKFAccounts, fetchWxWorkKFAccounts,
rollbackChannelAIAgentRollout,
resetChannelUserTokenSecret, resetChannelUserTokenSecret,
} from "@/lib/api/admin" } from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider" import { useI18n } from "@/i18n/provider"
@@ -74,6 +75,7 @@ function createSchema(t: Translate) {
.object({ .object({
channelType: z.enum(["web", "wechat_mp", "wxwork_kf"], t("channel.typeRequired")), channelType: z.enum(["web", "wechat_mp", "wxwork_kf"], t("channel.typeRequired")),
aiAgentId: z.string().trim().regex(/^\d+$/, t("channel.agentRequired")), aiAgentId: z.string().trim().regex(/^\d+$/, t("channel.agentRequired")),
aiAgentRolloutPercent: z.coerce.number().int().min(1).max(100),
name: z.string().trim().min(1, t("channel.nameRequired")), name: z.string().trim().min(1, t("channel.nameRequired")),
openKfId: z.string().trim(), openKfId: z.string().trim(),
widgetTitle: z.string().trim(), widgetTitle: z.string().trim(),
@@ -98,6 +100,7 @@ function createSchema(t: Translate) {
type EditForm = { type EditForm = {
channelType: "web" | "wechat_mp" | "wxwork_kf" channelType: "web" | "wechat_mp" | "wxwork_kf"
aiAgentId: string aiAgentId: string
aiAgentRolloutPercent: number
name: string name: string
openKfId: string openKfId: string
widgetTitle: string widgetTitle: string
@@ -114,6 +117,7 @@ function createEmptyForm(t: Translate): EditForm {
return { return {
channelType: "web", channelType: "web",
aiAgentId: "", aiAgentId: "",
aiAgentRolloutPercent: 100,
name: "", name: "",
openKfId: "", openKfId: "",
widgetTitle: defaultWebChannelConfig.title, widgetTitle: defaultWebChannelConfig.title,
@@ -202,6 +206,7 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm {
? "wechat_mp" ? "wechat_mp"
: "web", : "web",
aiAgentId: item.aiAgentId > 0 ? String(item.aiAgentId) : "", aiAgentId: item.aiAgentId > 0 ? String(item.aiAgentId) : "",
aiAgentRolloutPercent: item.aiAgentRolloutPercent || 100,
name: item.name, name: item.name,
openKfId: parseOpenKfId(item.configJson), openKfId: parseOpenKfId(item.configJson),
widgetTitle: wechatConfig?.title ?? webConfig.title, widgetTitle: wechatConfig?.title ?? webConfig.title,
@@ -240,6 +245,7 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin
return { return {
channelType, channelType,
aiAgentId: Number(form.aiAgentId), aiAgentId: Number(form.aiAgentId),
aiAgentRolloutPercent: form.aiAgentRolloutPercent,
name: form.name.trim(), name: form.name.trim(),
configJson, configJson,
status, status,
@@ -247,8 +253,15 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin
} }
} }
function isAgentWorkflowPublished(agent: AIAgent | undefined) { function isAgentChannelBindable(agent: AIAgent | undefined) {
return Boolean(agent?.workflowPublished ?? (agent?.workflowVersionId ?? 0) > 0) if (!agent) return false
if (agent.runtimeMode === "autonomous") {
return agent.publishedRevisionId > 0
}
if (agent.runtimeMode === "hybrid") {
return agent.publishedRevisionId > 0 && agent.workflowVersionId > 0
}
return Boolean(agent.workflowPublished ?? agent.workflowVersionId > 0)
} }
type ChannelFormBodyProps = Omit<ChannelFormDialogProps, "open"> type ChannelFormBodyProps = Omit<ChannelFormDialogProps, "open">
@@ -300,6 +313,7 @@ function ChannelFormBody({
const [wxWorkKFAccountsLoading, setWxWorkKFAccountsLoading] = useState(false) const [wxWorkKFAccountsLoading, setWxWorkKFAccountsLoading] = useState(false)
const [wxWorkKFAccountsError, setWxWorkKFAccountsError] = useState("") const [wxWorkKFAccountsError, setWxWorkKFAccountsError] = useState("")
const [channelDetail, setChannelDetail] = useState<AdminChannel | null>(null) const [channelDetail, setChannelDetail] = useState<AdminChannel | null>(null)
const [rollingBackRollout, setRollingBackRollout] = useState(false)
const [currentStatus, setCurrentStatus] = useState(0) const [currentStatus, setCurrentStatus] = useState(0)
const form = useForm< const form = useForm<
z.input<typeof schema>, z.input<typeof schema>,
@@ -321,6 +335,26 @@ function ChannelFormBody({
const aiAgentId = useWatch({ control, name: "aiAgentId" }) const aiAgentId = useWatch({ control, name: "aiAgentId" })
const openKfId = useWatch({ control, name: "openKfId" }) const openKfId = useWatch({ control, name: "openKfId" })
const userTokenSecret = useWatch({ control, name: "userTokenSecret" }) const userTokenSecret = useWatch({ control, name: "userTokenSecret" })
const previousRolloutPercent = channelDetail?.previousAiAgentRolloutPercent ?? 0
async function rollbackRolloutPercent() {
if (!channelDetail || previousRolloutPercent < 1) return
setRollingBackRollout(true)
try {
await rollbackChannelAIAgentRollout(channelDetail.id)
setValue("aiAgentRolloutPercent", previousRolloutPercent)
setChannelDetail({
...channelDetail,
aiAgentRolloutPercent: previousRolloutPercent,
previousAiAgentRolloutPercent: channelDetail.aiAgentRolloutPercent,
})
toast.success("已恢复上一次渠道灰度比例")
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复渠道灰度比例失败")
} finally {
setRollingBackRollout(false)
}
}
useEffect(() => { useEffect(() => {
async function loadAIAgents() { async function loadAIAgents() {
@@ -392,11 +426,11 @@ function ChannelFormBody({
const selectedAIAgent = aiAgents.find((item) => String(item.id) === aiAgentId) const selectedAIAgent = aiAgents.find((item) => String(item.id) === aiAgentId)
const availableAIAgents = aiAgents.filter( const availableAIAgents = aiAgents.filter(
(item) => isAgentWorkflowPublished(item) || String(item.id) === aiAgentId (item) => isAgentChannelBindable(item) || String(item.id) === aiAgentId
) )
const aiAgentOptions = availableAIAgents.map((item) => ({ const aiAgentOptions = availableAIAgents.map((item) => ({
value: String(item.id), value: String(item.id),
label: isAgentWorkflowPublished(item) label: isAgentChannelBindable(item)
? `${item.name} · 当前生效 #${item.workflowVersionId}` ? `${item.name} · 当前生效 #${item.workflowVersionId}`
: `${item.name} · 未发布`, : `${item.name} · 未发布`,
})) }))
@@ -426,8 +460,8 @@ function ChannelFormBody({
async function onFormSubmit(values: EditForm) { async function onFormSubmit(values: EditForm) {
const selected = aiAgents.find((item) => String(item.id) === values.aiAgentId) const selected = aiAgents.find((item) => String(item.id) === values.aiAgentId)
if (!isAgentWorkflowPublished(selected)) { if (!isAgentChannelBindable(selected)) {
toast.error("该 Agent 尚未发布流程,不能绑定渠道") toast.error("该 Agent 尚未完成发布,不能绑定渠道")
return return
} }
await onSubmit(buildPayload(values, currentStatus, t)) await onSubmit(buildPayload(values, currentStatus, t))
@@ -521,23 +555,39 @@ function ChannelFormBody({
/> />
)} )}
/> />
{selectedAIAgent && !isAgentWorkflowPublished(selectedAIAgent) ? ( {selectedAIAgent && !isAgentChannelBindable(selectedAIAgent) ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900"> <div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Agent AI Agent Agent AI Agent
</div> </div>
) : null} ) : null}
{selectedAIAgent && isAgentWorkflowPublished(selectedAIAgent) ? ( {selectedAIAgent && isAgentChannelBindable(selectedAIAgent) ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary"> <Badge variant="secondary">
{selectedAIAgent.workflowStateText || "已发布"} {selectedAIAgent.runtimeMode === "autonomous" ? "已发布" : selectedAIAgent.workflowStateText || "已发布"}
</Badge> </Badge>
<span> #{selectedAIAgent.workflowVersionId}</span> <span>{selectedAIAgent.runtimeMode === "autonomous" ? `当前版本 #${selectedAIAgent.publishedRevisionId}` : `当前生效版本 #${selectedAIAgent.workflowVersionId}`}</span>
</div> </div>
) : null} ) : null}
<FieldError errors={[errors.aiAgentId]} /> <FieldError errors={[errors.aiAgentId]} />
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.aiAgentRolloutPercent}>
<FieldLabel htmlFor="channel-ai-agent-rollout">AI %</FieldLabel>
<FieldContent>
<div className="flex items-center gap-2">
<Input id="channel-ai-agent-rollout" type="number" min={1} max={100} step={1} {...register("aiAgentRolloutPercent")} />
{previousRolloutPercent > 0 ? (
<Button type="button" variant="outline" size="sm" disabled={saving || rollingBackRollout} onClick={rollbackRolloutPercent}>
<RotateCcwIcon />
{previousRolloutPercent}%
</Button>
) : null}
</div>
<FieldError errors={[errors.aiAgentRolloutPercent]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.channelType}> <Field data-invalid={!!errors.channelType}>
<FieldLabel>{t("channel.channelType")}</FieldLabel> <FieldLabel>{t("channel.channelType")}</FieldLabel>
<FieldContent> <FieldContent>
+230 -1
View File
@@ -177,6 +177,8 @@ export type AdminChannel = {
channelType: string channelType: string
channelId: string channelId: string
aiAgentId: number aiAgentId: number
aiAgentRolloutPercent: number
previousAiAgentRolloutPercent: number
aiAgentName?: string aiAgentName?: string
name: string name: string
configJson: string configJson: string
@@ -194,6 +196,7 @@ export type WxWorkKFAccount = {
export type CreateAdminChannelPayload = { export type CreateAdminChannelPayload = {
channelType: string channelType: string
aiAgentId: number aiAgentId: number
aiAgentRolloutPercent: number
name: string name: string
configJson: string configJson: string
status: number status: number
@@ -216,17 +219,26 @@ export type AIAgent = {
statusName: string statusName: string
aiConfigId: number aiConfigId: number
aiConfigName?: string aiConfigName?: string
runtimeMode: "workflow" | "autonomous" | "hybrid"
runtimeModeName: string
maxSteps: number
contextWindow: number
toolPolicy: string
knowledgePolicy: string
serviceMode: number serviceMode: number
serviceModeName: string serviceModeName: string
systemPrompt: string systemPrompt: string
welcomeMessage: string welcomeMessage: string
replyTimeoutSeconds: number replyTimeoutSeconds: number
rolloutPercent: number
previousRolloutPercent: number
teams: { id: number; name: string }[] teams: { id: number; name: string }[]
handoffMode: number handoffMode: number
handoffModeName: string handoffModeName: string
fallbackMode: number fallbackMode: number
fallbackModeName: string fallbackModeName: string
fallbackMessage: string fallbackMessage: string
knowledgeBaseIds: number[]
skillIds: number[] skillIds: number[]
skills: { id: number; name: string }[] skills: { id: number; name: string }[]
directTools: { directTools: {
@@ -238,6 +250,7 @@ export type AIAgent = {
arguments?: Record<string, string> arguments?: Record<string, string>
}[] }[]
workflowVersionId: number workflowVersionId: number
publishedRevisionId: number
workflowPublished: boolean workflowPublished: boolean
workflowState: string workflowState: string
workflowStateText: string workflowStateText: string
@@ -252,14 +265,21 @@ export type CreateAIAgentPayload = {
name: string name: string
description: string description: string
aiConfigId: number aiConfigId: number
runtimeMode?: "workflow" | "autonomous" | "hybrid"
maxSteps?: number
contextWindow?: number
toolPolicy?: string
knowledgePolicy?: string
serviceMode: number serviceMode: number
systemPrompt: string systemPrompt: string
welcomeMessage: string welcomeMessage: string
replyTimeoutSeconds: number replyTimeoutSeconds: number
rolloutPercent: number
teamIds: number[] teamIds: number[]
handoffMode: number handoffMode: number
fallbackMode: number fallbackMode: number
fallbackMessage: string fallbackMessage: string
knowledgeBaseIds: number[]
skillIds: number[] skillIds: number[]
directTools: { directTools: {
toolCode: string toolCode: string
@@ -275,6 +295,18 @@ export type UpdateAIAgentPayload = CreateAIAgentPayload & {
id: number id: number
} }
export type AgentRevision = {
id: number
agentId: number
revision: number
workflowVersionId: number
status: number
definitionHash: string
publishedAt: string
publishedById: number
publishedByName: string
}
export type AIWorkflowPosition = { export type AIWorkflowPosition = {
x: number x: number
y: number y: number
@@ -387,6 +419,13 @@ export type AIWorkflowNodeSpec = {
defaultInputs?: Record<string, AIWorkflowValue> defaultInputs?: Record<string, AIWorkflowValue>
} }
export type AIWorkflowTemplate = {
code: string
name: string
description: string
definition: AIWorkflowDefinition
}
export type AIWorkflowValidationResult = { export type AIWorkflowValidationResult = {
valid: boolean valid: boolean
errors: { errors: {
@@ -568,6 +607,123 @@ export type AIWorkflowRun = {
nodes?: AIWorkflowNodeRun[] nodes?: AIWorkflowNodeRun[]
} }
export type AgentRun = {
id: number
conversationId: number
aiAgentId: number
agentRevisionId: number
sourceMessageId: number
workflowRunId: number
engineCode: string
status: string
promptTokens: number
completionTokens: number
startedAt: string
endedAt: string
durationMs: number
errorMessage: string
traceData: string
createdAt: string
updatedAt: string
steps?: AgentStep[]
toolCalls?: AgentToolCall[]
qualityFeedback?: AgentRunQualityFeedback
}
export type AgentRunQualityFeedback = {
id: number
agentRunId: number
resolutionStatus: "unknown" | "resolved" | "unresolved"
evidenceStatus: "unknown" | "supported" | "unsupported"
comment: string
updateUserName: string
updatedAt: string
}
export type AgentRunMetrics = {
totalRuns: number
completedRuns: number
failedRuns: number
interruptedRuns: number
completionRate: number
toolCalls: number
toolSuccessRate: number
averageSteps: number
averageDurationMs: number
p95DurationMs: number
promptTokens: number
completionTokens: number
handoffRate: number
knowledgeFallbackRate: number
resumedInterrupts: number
resolvedInterrupts: number
interruptRecoveryRate: number
reviewedRuns: number
resolvedRuns: number
resolutionRate: number
unsupportedEvidenceRuns: number
unsupportedEvidenceRate: number
}
export type AgentRunEngineComparison = {
engineCode: string
metrics: AgentRunMetrics
}
export type AgentEvaluationCase = {
id: string
category?: string
message: string
history?: string[]
expect?: Record<string, unknown>
}
export type AgentEvaluationReport = {
engineCode: string
total: number
passed: number
results: {
caseId: string
category: string
engineCode: string
passed: boolean
replyText: string
interrupted: boolean
error?: string
finding?: string
}[]
csv: string
}
export type AgentStep = {
id: number
agentRunId: number
stepType: string
stepCode: string
status: string
inputPreview: string
outputPreview: string
errorMessage: string
startedAt: string
endedAt: string
durationMs: number
}
export type AgentToolCall = {
id: number
agentRunId: number
agentStepId: number
toolCode: string
riskLevel: string
requireConfirm: boolean
status: string
argumentsPreview: string
resultPreview: string
errorMessage: string
durationMs: number
createdAt: string
}
export type AdminAgentProfile = { export type AdminAgentProfile = {
id: number id: number
userId: number userId: number
@@ -744,6 +900,13 @@ export function updateChannel(payload: UpdateAdminChannelPayload) {
}) })
} }
export function rollbackChannelAIAgentRollout(id: number) {
return request<void>("/api/dashboard/channel/rollback_ai_agent_rollout", {
method: "POST",
body: JSON.stringify({ id }),
})
}
export function updateChannelStatus(id: number, status: number) { export function updateChannelStatus(id: number, status: number) {
return request<void>("/api/dashboard/channel/update_status", { return request<void>("/api/dashboard/channel/update_status", {
method: "POST", method: "POST",
@@ -800,6 +963,31 @@ export function updateAIAgent(payload: UpdateAIAgentPayload) {
}) })
} }
export function publishAIAgent(id: number) {
return request<void>("/api/dashboard/ai-agent/publish", {
method: "POST",
body: JSON.stringify({ id }),
})
}
export function fetchAIAgentRevisions(id: number) {
return request<AgentRevision[]>(`/api/dashboard/ai-agent/${id}/revision/list`)
}
export function rollbackAIAgent(id: number, revisionId: number) {
return request<void>("/api/dashboard/ai-agent/rollback", {
method: "POST",
body: JSON.stringify({ id, revisionId }),
})
}
export function rollbackAIAgentRollout(id: number) {
return request<void>("/api/dashboard/ai-agent/rollback_rollout", {
method: "POST",
body: JSON.stringify({ id }),
})
}
export function deleteAIAgent(id: number) { export function deleteAIAgent(id: number) {
return request<void>("/api/dashboard/ai-agent/delete", { return request<void>("/api/dashboard/ai-agent/delete", {
method: "POST", method: "POST",
@@ -837,7 +1025,11 @@ export function fetchAIWorkflowNodeSpecs() {
} }
export function fetchAIWorkflowDefaultDefinition() { export function fetchAIWorkflowDefaultDefinition() {
return request<AIWorkflowDefinition>("/api/dashboard/ai-workflow/default-definition") return request<AIWorkflowDefinition>("/api/dashboard/ai-workflow/default-definition")
}
export function fetchAIWorkflowTemplates() {
return request<AIWorkflowTemplate[]>("/api/dashboard/ai-workflow/template/list")
} }
export function fetchAIWorkflowVersions(query?: Record<string, string | number | undefined>) { export function fetchAIWorkflowVersions(query?: Record<string, string | number | undefined>) {
@@ -1135,6 +1327,43 @@ export function fetchAIWorkflowRun(id: number) {
return request<AIWorkflowRun>(`/api/dashboard/ai-workflow/run/${id}`) return request<AIWorkflowRun>(`/api/dashboard/ai-workflow/run/${id}`)
} }
export function fetchAgentRuns(query?: Record<string, string | number | undefined>) {
return request<PageResult<AgentRun>>(
`/api/dashboard/agent-run/list${toQueryString(query)}`
)
}
export function fetchAgentRun(id: number) {
return request<AgentRun>(`/api/dashboard/agent-run/${id}`)
}
export function fetchAgentRunMetrics(aiAgentId?: number) {
return request<AgentRunMetrics>(`/api/dashboard/agent-run/metrics${toQueryString(aiAgentId ? { aiAgentId } : undefined)}`)
}
export function fetchAgentRunEngineComparisons(aiAgentId?: number) {
return request<AgentRunEngineComparison[]>(`/api/dashboard/agent-run/comparison${toQueryString(aiAgentId ? { aiAgentId } : undefined)}`)
}
export function runAgentEvaluation(payload: { aiAgentId: number; engineCode: string; cases: AgentEvaluationCase[] }) {
return request<AgentEvaluationReport>("/api/dashboard/agent-run/evaluate", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function saveAgentRunQualityFeedback(payload: {
agentRunId: number
resolutionStatus: AgentRunQualityFeedback["resolutionStatus"]
evidenceStatus: AgentRunQualityFeedback["evidenceStatus"]
comment: string
}) {
return request<void>("/api/dashboard/agent-run/quality_feedback", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateSkillDefinitionStatus(id: number, status: number) { export function updateSkillDefinitionStatus(id: number, status: number) {
return request<void>("/api/dashboard/skill-definition/update_status", { return request<void>("/api/dashboard/skill-definition/update_status", {
method: "POST", method: "POST",
+13
View File
@@ -3,10 +3,12 @@
export enum AIAgentFallbackMode { export enum AIAgentFallbackMode {
NoAnswer = 1, NoAnswer = 1,
SuggestRetry = 2, SuggestRetry = 2,
Handoff = 3,
} }
export const AIAgentFallbackModeLabels: Record<AIAgentFallbackMode, string> = { export const AIAgentFallbackModeLabels: Record<AIAgentFallbackMode, string> = {
[AIAgentFallbackMode.NoAnswer]: "直接说明知识不足", [AIAgentFallbackMode.NoAnswer]: "直接说明知识不足",
[AIAgentFallbackMode.SuggestRetry]: "引导用户补充信息", [AIAgentFallbackMode.SuggestRetry]: "引导用户补充信息",
[AIAgentFallbackMode.Handoff]: "转人工客服",
} }
export enum AIAgentHandoffMode { export enum AIAgentHandoffMode {
@@ -20,6 +22,17 @@ export const AIAgentHandoffModeLabels: Record<AIAgentHandoffMode, string> = {
[AIAgentHandoffMode.AIHoldAndNotify]: "AI继续接待并提醒人工", [AIAgentHandoffMode.AIHoldAndNotify]: "AI继续接待并提醒人工",
} }
export enum AIAgentRuntimeMode {
Workflow = "workflow",
Autonomous = "autonomous",
Hybrid = "hybrid",
}
export const AIAgentRuntimeModeLabels: Record<AIAgentRuntimeMode, string> = {
[AIAgentRuntimeMode.Workflow]: "流程编排",
[AIAgentRuntimeMode.Autonomous]: "自主运行",
[AIAgentRuntimeMode.Hybrid]: "混合运行",
}
export enum AIModelType { export enum AIModelType {
LLM = "llm", LLM = "llm",
Embedding = "embedding", Embedding = "embedding",
+6
View File
@@ -211,6 +211,12 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
icon: <WorkflowIcon />, icon: <WorkflowIcon />,
requiredPermission: "aiAgent.view", requiredPermission: "aiAgent.view",
}, },
{
titleKey: "nav.agentRuns",
url: "/dashboard/agent-runs",
icon: <BotMessageSquareIcon />,
requiredPermission: "aiAgent.view",
},
], ],
}, },
{ {
+32
View File
@@ -2288,6 +2288,37 @@
"input": "Input", "input": "Input",
"output": "Output" "output": "Output"
}, },
"agentRun": {
"conversation": "Conversation",
"agent": "Agent",
"engine": "Engine",
"status": "Status",
"startedAt": "Started",
"duration": "Duration",
"tokens": "Input/Output Tokens",
"error": "Error",
"refresh": "Refresh",
"query": "Query",
"loading": "Loading agent runs",
"empty": "No agent runs",
"loadFailed": "Failed to load agent runs",
"loadDetailFailed": "Failed to load agent run detail",
"detailTitle": "Agent Run Detail",
"detailDescription": "View the unified run audit",
"loadingDetail": "Loading agent run detail",
"close": "Close",
"revision": "Revision",
"trace": "Trace",
"steps": "Steps",
"emptySteps": "No steps recorded",
"toolCalls": "Tool Calls",
"emptyToolCalls": "No tool calls recorded",
"input": "Input Preview",
"output": "Output Preview",
"arguments": "Arguments Preview",
"result": "Result Preview",
"notFound": "Agent run not found"
},
"nav": { "nav": {
"overview": "Overview", "overview": "Overview",
"receptionCenter": "Support Desk", "receptionCenter": "Support Desk",
@@ -2308,6 +2339,7 @@
"aiAgents": "Agents", "aiAgents": "Agents",
"aiWorkflows": "AI Workflows", "aiWorkflows": "AI Workflows",
"workflowRuns": "Workflow Audit", "workflowRuns": "Workflow Audit",
"agentRuns": "Agent Audit",
"skillDefinition": "Skills", "skillDefinition": "Skills",
"mcp": "MCP tools", "mcp": "MCP tools",
"system": "System", "system": "System",

Some files were not shown because too many files have changed in this diff Show More