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:
+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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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{},
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
openai "github.com/openai/openai-go/v3"
|
||||||
|
"github.com/openai/openai-go/v3/shared"
|
||||||
|
|
||||||
|
"agent-desk/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ToolDefinition struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Parameters map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolCall struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Arguments string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolCallExecutor func(context.Context, ToolCall) (string, error)
|
||||||
|
|
||||||
|
type ToolLoopResult struct {
|
||||||
|
ChatCompletionResult
|
||||||
|
ToolCalls []ToolCall
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatWithTools executes a bounded OpenAI-compatible function-calling loop.
|
||||||
|
// Tool execution stays in the caller so business operations remain behind the
|
||||||
|
// application Tool Registry and Service layer.
|
||||||
|
func (s *llm) ChatWithTools(ctx context.Context, config models.AIConfig, systemPrompt, userPrompt string, definitions []ToolDefinition, maxSteps int, execute ToolCallExecutor) (*ToolLoopResult, error) {
|
||||||
|
if len(definitions) == 0 || execute == nil {
|
||||||
|
result, err := s.ChatWithConfig(ctx, config, systemPrompt, userPrompt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ToolLoopResult{ChatCompletionResult: *result}, nil
|
||||||
|
}
|
||||||
|
if maxSteps <= 0 {
|
||||||
|
maxSteps = 5
|
||||||
|
}
|
||||||
|
messages := []openai.ChatCompletionMessageParamUnion{}
|
||||||
|
if strings.TrimSpace(systemPrompt) != "" {
|
||||||
|
messages = append(messages, openai.ChatCompletionMessageParamUnion{OfSystem: &openai.ChatCompletionSystemMessageParam{Content: openai.ChatCompletionSystemMessageParamContentUnion{OfString: openai.String(systemPrompt)}}})
|
||||||
|
}
|
||||||
|
messages = append(messages, openai.ChatCompletionMessageParamUnion{OfUser: &openai.ChatCompletionUserMessageParam{Content: openai.ChatCompletionUserMessageParamContentUnion{OfString: openai.String(userPrompt)}}})
|
||||||
|
tools := make([]openai.ChatCompletionToolUnionParam, 0, len(definitions))
|
||||||
|
for _, definition := range definitions {
|
||||||
|
tools = append(tools, openai.ChatCompletionToolUnionParam{OfFunction: &openai.ChatCompletionFunctionToolParam{Function: shared.FunctionDefinitionParam{Name: definition.Name, Description: openai.String(definition.Description), Parameters: shared.FunctionParameters(definition.Parameters)}}})
|
||||||
|
}
|
||||||
|
client := newOpenAIClient(config)
|
||||||
|
allCalls := make([]ToolCall, 0)
|
||||||
|
for step := 0; step < maxSteps; step++ {
|
||||||
|
params := openai.ChatCompletionNewParams{Messages: messages, Model: shared.ChatModel(config.ModelName), Tools: tools}
|
||||||
|
if config.MaxOutputTokens > 0 {
|
||||||
|
params.MaxCompletionTokens = openai.Int(int64(config.MaxOutputTokens))
|
||||||
|
}
|
||||||
|
applyProviderSpecificChatParams(¶ms, config)
|
||||||
|
response, err := client.Chat.Completions.New(ctx, params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tool loop chat completion failed: %w", err)
|
||||||
|
}
|
||||||
|
if len(response.Choices) == 0 {
|
||||||
|
return nil, fmt.Errorf("tool loop returned no choices")
|
||||||
|
}
|
||||||
|
message := response.Choices[0].Message
|
||||||
|
if len(message.ToolCalls) == 0 {
|
||||||
|
return &ToolLoopResult{ChatCompletionResult: ChatCompletionResult{Content: strings.TrimSpace(message.Content), ModelName: config.ModelName, PromptTokens: int(response.Usage.PromptTokens), CompletionTokens: int(response.Usage.CompletionTokens)}, ToolCalls: allCalls}, nil
|
||||||
|
}
|
||||||
|
messages = append(messages, message.ToParam())
|
||||||
|
for _, rawCall := range message.ToolCalls {
|
||||||
|
call := rawCall.AsFunction()
|
||||||
|
if call.ID == "" || call.Function.Name == "" {
|
||||||
|
return nil, fmt.Errorf("tool loop received unsupported tool call")
|
||||||
|
}
|
||||||
|
toolCall := ToolCall{ID: call.ID, Name: call.Function.Name, Arguments: call.Function.Arguments}
|
||||||
|
allCalls = append(allCalls, toolCall)
|
||||||
|
output, callErr := execute(ctx, toolCall)
|
||||||
|
if callErr != nil {
|
||||||
|
output = "tool execution failed: " + callErr.Error()
|
||||||
|
}
|
||||||
|
messages = append(messages, openai.ChatCompletionMessageParamUnion{OfTool: &openai.ChatCompletionToolMessageParam{ToolCallID: call.ID, Content: openai.ChatCompletionToolMessageParamContentUnion{OfString: openai.String(output)}}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("tool loop exceeded maximum steps")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]"
|
||||||
|
}
|
||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-desk/internal/models"
|
||||||
|
"agent-desk/internal/pkg/dto"
|
||||||
|
"agent-desk/internal/pkg/dto/request"
|
||||||
|
"agent-desk/internal/pkg/enums"
|
||||||
|
"agent-desk/internal/pkg/httpx/params"
|
||||||
|
"agent-desk/internal/repositories"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/mlogclub/simple/sqls"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgentRunServiceFindsWorkflowAuditDetail(t *testing.T) {
|
||||||
|
db := setupAgentRunServiceTestDB(t)
|
||||||
|
now := time.Now()
|
||||||
|
endedAt := now.Add(time.Second)
|
||||||
|
run := &models.AgentRun{
|
||||||
|
ConversationID: 11,
|
||||||
|
AIAgentID: 12,
|
||||||
|
WorkflowRunID: 13,
|
||||||
|
EngineCode: "workflow",
|
||||||
|
Status: "completed",
|
||||||
|
StartedAt: now,
|
||||||
|
EndedAt: &endedAt,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := db.Create(run).Error; err != nil {
|
||||||
|
t.Fatalf("create agent run: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.AgentStep{AgentRunID: run.ID, StepType: "workflow", Status: "completed", StartedAt: now, EndedAt: &endedAt, CreatedAt: now}).Error; err != nil {
|
||||||
|
t.Fatalf("create agent step: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.AgentToolCall{AgentRunID: run.ID, ToolCode: "knowledge.retrieve", Status: "completed", CreatedAt: now}).Error; err != nil {
|
||||||
|
t.Fatalf("create tool call: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cnd := sqls.NewCnd().Eq("conversation_id", run.ConversationID).Desc("id").Page(1, 20)
|
||||||
|
queryParams := ¶ms.QueryParams{Cnd: *cnd}
|
||||||
|
list, paging := AgentRunService.FindPageByParams(queryParams)
|
||||||
|
if len(list) != 1 || paging.Total != 1 || list[0].ID != run.ID {
|
||||||
|
t.Fatalf("unexpected agent run page: list=%#v paging=%#v", list, paging)
|
||||||
|
}
|
||||||
|
item, steps, toolCalls := AgentRunService.GetDetail(run.ID)
|
||||||
|
if item == nil || len(steps) != 1 || len(toolCalls) != 1 {
|
||||||
|
t.Fatalf("unexpected agent run detail: run=%#v steps=%#v toolCalls=%#v", item, steps, toolCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentRunServiceAssociatesWorkflowRevision(t *testing.T) {
|
||||||
|
db := setupAgentRunServiceTestDB(t)
|
||||||
|
now := time.Now()
|
||||||
|
if err := db.Create(&models.AgentRevision{AgentID: 12, Revision: 1, WorkflowVersionID: 14}).Error; err != nil {
|
||||||
|
t.Fatalf("create agent revision: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := AgentRunService.RecordWorkflowRun(db, WorkflowAgentRunInput{
|
||||||
|
WorkflowRunID: 13, WorkflowVersionID: 14, ConversationID: 11, AIAgentID: 12,
|
||||||
|
Status: "completed", StartedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("RecordWorkflowRun returned error: %v", err)
|
||||||
|
}
|
||||||
|
run := repositories.AgentRunRepository.TakeByWorkflowRunID(db, 13)
|
||||||
|
if run == nil || run.AgentRevisionID <= 0 {
|
||||||
|
t.Fatalf("expected AgentRun to link revision, got %#v", run)
|
||||||
|
}
|
||||||
|
if stepID := AgentRunService.GetLatestStepID(run.ID); stepID <= 0 {
|
||||||
|
t.Fatalf("expected normalized agent step id, got %d", stepID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentRunServiceRecordsEngineToolCall(t *testing.T) {
|
||||||
|
db := setupAgentRunServiceTestDB(t)
|
||||||
|
now := time.Now()
|
||||||
|
runID, err := AgentRunService.RecordEngineRun(db, EngineAgentRunInput{
|
||||||
|
ConversationID: 1, AIAgentID: 2, AgentRevisionID: 3, EngineCode: "autonomous", Status: "completed", StartedAt: now,
|
||||||
|
StepType: "model", StepCode: "chat_completion", StepInputPreview: "authorization=Bearer-secret", ToolCalls: []EngineToolCallInput{{
|
||||||
|
ToolCode: "knowledge/search", RiskLevel: "read", Status: "completed", ArgumentsPreview: `{"token":"abc123","query":"refund"}`, ResultPreview: "policy text",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecordEngineRun returned error: %v", err)
|
||||||
|
}
|
||||||
|
_, steps, toolCalls := AgentRunService.GetDetail(runID)
|
||||||
|
if len(toolCalls) != 1 || toolCalls[0].ToolCode != "knowledge/search" || toolCalls[0].AgentStepID <= 0 {
|
||||||
|
t.Fatalf("unexpected tool audit: %#v", toolCalls)
|
||||||
|
}
|
||||||
|
if strings.Contains(toolCalls[0].ArgumentsPreview, "abc123") || len(steps) != 1 || strings.Contains(steps[0].InputPreview, "Bearer-secret") {
|
||||||
|
t.Fatalf("sensitive audit data leaked: steps=%#v calls=%#v", steps, toolCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentRunServiceRecordsHybridPlaybookResume(t *testing.T) {
|
||||||
|
db := setupAgentRunServiceTestDB(t)
|
||||||
|
now := time.Now().Add(-time.Minute)
|
||||||
|
run := &models.AgentRun{EngineCode: "hybrid", Status: "interrupted", StartedAt: now, CreatedAt: now, UpdatedAt: now}
|
||||||
|
if err := db.Create(run).Error; err != nil {
|
||||||
|
t.Fatalf("create hybrid run: %v", err)
|
||||||
|
}
|
||||||
|
if err := AgentRunService.RecordHybridPlaybookResume(db, run.ID, 33, "completed", "已完成工单登记。"); err != nil {
|
||||||
|
t.Fatalf("RecordHybridPlaybookResume returned error: %v", err)
|
||||||
|
}
|
||||||
|
item, steps, _ := AgentRunService.GetDetail(run.ID)
|
||||||
|
if item == nil || item.Status != "completed" || item.EndedAt == nil {
|
||||||
|
t.Fatalf("expected completed hybrid run, got %#v", item)
|
||||||
|
}
|
||||||
|
if len(steps) != 1 || steps[0].StepCode != "playbook_resume" || steps[0].WorkflowRunID != 33 || steps[0].OutputPreview != "已完成工单登记。" {
|
||||||
|
t.Fatalf("unexpected playbook resume step: %#v", steps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentRunServiceSavesQualityFeedbackPerRun(t *testing.T) {
|
||||||
|
db := setupAgentRunServiceTestDB(t)
|
||||||
|
now := time.Now()
|
||||||
|
run := &models.AgentRun{AIAgentID: 4, EngineCode: "autonomous", Status: "completed", StartedAt: now, CreatedAt: now, UpdatedAt: now}
|
||||||
|
if err := db.Create(run).Error; err != nil {
|
||||||
|
t.Fatalf("create agent run: %v", err)
|
||||||
|
}
|
||||||
|
operator := &dto.AuthPrincipal{UserID: 7, Username: "reviewer"}
|
||||||
|
if err := AgentRunService.SaveQualityFeedback(request.SaveAgentRunQualityFeedbackRequest{
|
||||||
|
AgentRunID: run.ID, ResolutionStatus: enums.AgentRunResolutionStatusResolved, EvidenceStatus: enums.AgentRunEvidenceStatusSupported, Comment: "issue resolved",
|
||||||
|
}, operator); err != nil {
|
||||||
|
t.Fatalf("save quality feedback: %v", err)
|
||||||
|
}
|
||||||
|
if err := AgentRunService.SaveQualityFeedback(request.SaveAgentRunQualityFeedbackRequest{
|
||||||
|
AgentRunID: run.ID, ResolutionStatus: enums.AgentRunResolutionStatusUnresolved, EvidenceStatus: enums.AgentRunEvidenceStatusUnsupported, Comment: "missing evidence",
|
||||||
|
}, operator); err != nil {
|
||||||
|
t.Fatalf("update quality feedback: %v", err)
|
||||||
|
}
|
||||||
|
feedback := AgentRunService.GetQualityFeedback(run.ID)
|
||||||
|
if feedback == nil || feedback.ResolutionStatus != enums.AgentRunResolutionStatusUnresolved || feedback.EvidenceStatus != enums.AgentRunEvidenceStatusUnsupported || feedback.Comment != "missing evidence" || feedback.UpdateUserName != "reviewer" {
|
||||||
|
t.Fatalf("unexpected quality feedback: %#v", feedback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentRunServiceAggregatesCrossEngineMetrics(t *testing.T) {
|
||||||
|
db := setupAgentRunServiceTestDB(t)
|
||||||
|
base := time.Now().Add(-time.Minute)
|
||||||
|
runs := []models.AgentRun{
|
||||||
|
{AIAgentID: 8, EngineCode: "autonomous", Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(100 * time.Millisecond)), PromptTokens: 10, CompletionTokens: 5, CreatedAt: base, UpdatedAt: base},
|
||||||
|
{AIAgentID: 8, EngineCode: "workflow", Status: "failed", StartedAt: base, EndedAt: timePtr(base.Add(300 * time.Millisecond)), PromptTokens: 8, CompletionTokens: 2, CreatedAt: base, UpdatedAt: base},
|
||||||
|
{AIAgentID: 9, EngineCode: "hybrid", Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(900 * time.Millisecond)), CreatedAt: base, UpdatedAt: base},
|
||||||
|
}
|
||||||
|
for index := range runs {
|
||||||
|
if err := db.Create(&runs[index]).Error; err != nil {
|
||||||
|
t.Fatalf("create run: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.AgentStep{AgentRunID: runs[0].ID, Status: "completed", StartedAt: base, CreatedAt: base}).Error; err != nil {
|
||||||
|
t.Fatalf("create step: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.AgentStep{AgentRunID: runs[1].ID, Status: "failed", StartedAt: base, CreatedAt: base}).Error; err != nil {
|
||||||
|
t.Fatalf("create step: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.AgentToolCall{AgentRunID: runs[0].ID, Status: "completed", CreatedAt: base}).Error; err != nil {
|
||||||
|
t.Fatalf("create completed tool call: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.AgentToolCall{AgentRunID: runs[1].ID, Status: "failed", CreatedAt: base}).Error; err != nil {
|
||||||
|
t.Fatalf("create failed tool call: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.Conversation{AIAgentID: 8}).Error; err != nil {
|
||||||
|
t.Fatalf("create conversation: %v", err)
|
||||||
|
}
|
||||||
|
handoffAt := base
|
||||||
|
if err := db.Create(&models.Conversation{AIAgentID: 8, HandoffAt: &handoffAt}).Error; err != nil {
|
||||||
|
t.Fatalf("create handoff conversation: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.ConversationInterrupt{AgentRunID: runs[0].ID, CheckPointID: "metrics-resolved", Status: "resolved", ResumeCount: 1, CreatedAt: base, UpdatedAt: base}).Error; err != nil {
|
||||||
|
t.Fatalf("create resolved interrupt: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.ConversationInterrupt{AgentRunID: runs[1].ID, CheckPointID: "metrics-cancelled", Status: "cancelled", ResumeCount: 1, CreatedAt: base, UpdatedAt: base}).Error; err != nil {
|
||||||
|
t.Fatalf("create cancelled interrupt: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.AgentRunQualityFeedback{AgentRunID: runs[0].ID, ResolutionStatus: enums.AgentRunResolutionStatusResolved, EvidenceStatus: enums.AgentRunEvidenceStatusSupported}).Error; err != nil {
|
||||||
|
t.Fatalf("create resolved feedback: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&models.AgentRunQualityFeedback{AgentRunID: runs[1].ID, ResolutionStatus: enums.AgentRunResolutionStatusUnresolved, EvidenceStatus: enums.AgentRunEvidenceStatusUnsupported}).Error; err != nil {
|
||||||
|
t.Fatalf("create unresolved feedback: %v", err)
|
||||||
|
}
|
||||||
|
metrics := AgentRunService.GetMetrics(8)
|
||||||
|
if metrics.TotalRuns != 2 || metrics.CompletedRuns != 1 || metrics.FailedRuns != 1 || metrics.CompletionRate != 0.5 {
|
||||||
|
t.Fatalf("unexpected run metrics: %#v", metrics)
|
||||||
|
}
|
||||||
|
if metrics.AverageDurationMS != 200 || metrics.P95DurationMS != 300 || metrics.ToolCalls != 2 || metrics.ToolSuccessRate != 0.5 || metrics.AverageSteps != 1 {
|
||||||
|
t.Fatalf("unexpected aggregate metrics: %#v", metrics)
|
||||||
|
}
|
||||||
|
if metrics.PromptTokens != 18 || metrics.CompletionTokens != 7 {
|
||||||
|
t.Fatalf("unexpected token metrics: %#v", metrics)
|
||||||
|
}
|
||||||
|
if metrics.HandoffRate != 0.5 || metrics.KnowledgeFallbackRate != 0 {
|
||||||
|
t.Fatalf("unexpected business metrics: %#v", metrics)
|
||||||
|
}
|
||||||
|
if metrics.ResumedInterrupts != 2 || metrics.ResolvedInterrupts != 1 || metrics.InterruptRecoveryRate != 0.5 {
|
||||||
|
t.Fatalf("unexpected interrupt recovery metrics: %#v", metrics)
|
||||||
|
}
|
||||||
|
if metrics.ReviewedRuns != 2 || metrics.ResolvedRuns != 1 || metrics.ResolutionRate != 0.5 || metrics.UnsupportedEvidenceRuns != 1 || metrics.UnsupportedEvidenceRate != 0.5 {
|
||||||
|
t.Fatalf("unexpected quality metrics: %#v", metrics)
|
||||||
|
}
|
||||||
|
comparisons := AgentRunService.GetEngineComparisons(8)
|
||||||
|
if len(comparisons) != 2 || comparisons[0].EngineCode != "autonomous" || comparisons[1].EngineCode != "workflow" {
|
||||||
|
t.Fatalf("unexpected engine comparison groups: %#v", comparisons)
|
||||||
|
}
|
||||||
|
if comparisons[0].Metrics.TotalRuns != 1 || comparisons[0].Metrics.ResolutionRate != 1 || comparisons[1].Metrics.TotalRuns != 1 || comparisons[1].Metrics.UnsupportedEvidenceRate != 1 {
|
||||||
|
t.Fatalf("unexpected engine comparison metrics: %#v", comparisons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func timePtr(value time.Time) *time.Time { return &value }
|
||||||
|
|
||||||
|
func setupAgentRunServiceTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
|
||||||
|
NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err == nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}, &models.AgentRunQualityFeedback{}, &models.Conversation{}, &models.ConversationInterrupt{}); err != nil {
|
||||||
|
t.Fatalf("auto migrate: %v", err)
|
||||||
|
}
|
||||||
|
sqls.SetDB(db)
|
||||||
|
return db
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 不会自动回复
|
未发布 Playbook,AI 不会自动回复
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user